Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
06982d2
Update nugets
jjxtra Apr 28, 2026
2fff0a4
Update example
jjxtra Apr 29, 2026
bf52dcb
Claude pass 1
jjxtra May 5, 2026
0d325c7
Claude pass 2
jjxtra May 5, 2026
334026a
Claude pass 3
jjxtra May 5, 2026
78e9366
Validate at tests on Linux
jjxtra May 5, 2026
9d9878e
Coverage pass
jjxtra May 6, 2026
9833454
Coverage pass 2
jjxtra May 6, 2026
c5e3859
Extra windows firewall cleanup
jjxtra May 6, 2026
719e2d4
Add note to check logfile.txt
jjxtra May 6, 2026
d287d5d
Performance boost
jjxtra May 7, 2026
7d1ebee
Speed up tests
jjxtra May 7, 2026
3c11b69
Performance boost/test speed up
jjxtra May 7, 2026
1bd7997
Change log level of missing keys
jjxtra May 10, 2026
d1f9085
Trim warning fixes
jjxtra May 10, 2026
c80efb1
More trim warning fix
jjxtra May 10, 2026
4c099ef
Trim warnings
jjxtra May 11, 2026
6244057
New test
jjxtra May 11, 2026
d80fe63
New string
jjxtra May 14, 2026
31dc354
Another test
jjxtra May 19, 2026
c07386f
New RDP failed login
jjxtra May 20, 2026
0682764
FQDN fixes
jjxtra Jun 3, 2026
8f41dd1
New string, another test
jjxtra Jun 15, 2026
e5fed05
.NET 10
jjxtra Jun 16, 2026
c5cbf38
Fix file casing
jjxtra Jun 16, 2026
e2a8c9e
Fix rule name on firewalld
jjxtra Jun 16, 2026
d0d2c19
Fix doubled up prefix
jjxtra Jun 16, 2026
d4ece45
Update log file path from auth.txt to auth.log
jjxtra Jun 17, 2026
beb01a0
Reduce firewall task debug
jjxtra Jun 18, 2026
f10db13
Update nugets
jjxtra Jun 19, 2026
ee45675
Update nuget
jjxtra Jun 19, 2026
b5d9850
New mailenable expression
jjxtra Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash",
"Powershell",
"Python",
"Write",
"Edit",
"MultiEdit"
],
"defaultMode": "dontAsk"
}
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ bin/
obj/
package/
packages/
results/
report/
.nuget-packages/
*.suo
*.cachefile
*.user
Expand Down
5 changes: 4 additions & 1 deletion IPBan/IPBan.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
<ServerGarbageCollection>false</ServerGarbageCollection>
<TrimMode>partial</TrimMode>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- IL2104: third-party assemblies produce trim warnings internally; suppressed as unfixable -->
<!-- IL2026: runtime-internal COM activation (BuiltInComInteropSupport) is not trim-compatible; unfixable from user code -->
<NoWarn>$(NoWarn);IL2104;IL2026</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand All @@ -45,6 +48,6 @@
<TrimmerRootAssembly Include="System.Runtime" />
<TrimmerRootAssembly Include="mscorlib" />
<TrimmerRootAssembly Include="netstandard" />
</ItemGroup>
</ItemGroup>

</Project>
22 changes: 18 additions & 4 deletions IPBanCore/Core/IPBan/IPBanConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ namespace DigitalRuby.IPBanCore
/// <summary>
/// Configuration for ip ban app
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Configuration XML models are runtime-deserialized and preserved by IPBanCore usage patterns.")]
public sealed class IPBanConfig : IIsWhitelisted
{
/// <summary>
Expand Down Expand Up @@ -134,6 +135,7 @@ public void Dispose()
private readonly string processToRunOnUnban = string.Empty;
private readonly bool useDefaultBannedIPAddressHandler;
private readonly string getUrlUpdate = string.Empty;
private readonly string getUrlUpdateSha256 = string.Empty;
private readonly string getUrlStart = string.Empty;
private readonly string getUrlStop = string.Empty;
private readonly string getUrlConfig = string.Empty;
Expand Down Expand Up @@ -238,6 +240,7 @@ private IPBanConfig(XmlDocument doc, IDnsLookup dns = null, IDnsServerList dnsLi
TryGetConfig<int>("UserNameWhitelistMinimumEditDistance", ref userNameWhitelistMaximumEditDistance);
TryGetConfig<int>("FailedLoginAttemptsBeforeBanUserNameWhitelist", ref failedLoginAttemptsBeforeBanUserNameWhitelist);
TryGetConfig<string>("GetUrlUpdate", ref getUrlUpdate);
TryGetConfig<string>("GetUrlUpdateSha256", ref getUrlUpdateSha256);
TryGetConfig<string>("GetUrlStart", ref getUrlStart);
TryGetConfig<string>("GetUrlStop", ref getUrlStop);
TryGetConfig<string>("GetUrlConfig", ref getUrlConfig);
Expand All @@ -260,16 +263,19 @@ private string GetAppSettingsValue(string key, bool logMissing = true)
{
if (string.IsNullOrWhiteSpace(key))
{
// bad key
Logger.Warn("Ignoring null/empty key");
if (logMissing)
{
// bad key
Logger.Debug("Ignoring null/empty key");
}
return null;
}

if (!appSettings.TryGetValue(key, out var stringValue) || stringValue is null)
{
if (logMissing)
{
Logger.Warn("Ignoring key {0}, not found in appSettings", key);
Logger.Debug("Ignoring key {0}, not found in appSettings", key);
}
return null; // skip trying to convert
}
Expand Down Expand Up @@ -638,7 +644,7 @@ public bool IsUserNameWithinMaximumEditDistanceOfUserNameWhitelist(string userNa
foreach (string userNameToCheckAgainst in userNameWhitelist)
{
int distance = LevenshteinUnsafe.Distance(userName, userNameToCheckAgainst);
if (distance <= userNameWhitelistMaximumEditDistance)
if (distance >= 0 && distance <= userNameWhitelistMaximumEditDistance)
{
return true;
}
Expand Down Expand Up @@ -1152,6 +1158,14 @@ public static string ValidateFirewallUriRules(string firewallUriRules)
/// </summary>
public string GetUrlUpdate { get { return getUrlUpdate; } }

/// <summary>
/// Expected SHA-256 hash (hex, case-insensitive) of the binary returned by GetUrlUpdate.
/// If empty, the auto-update download is fetched but NOT executed β€” this is the safe default
/// and protects against a malicious/MITMed update server. Operators must explicitly set this
/// hash to opt in to automated update execution.
/// </summary>
public string GetUrlUpdateSha256 { get { return getUrlUpdateSha256; } }

/// <summary>
/// A url to get when the service starts, empty for none. See ReplaceUrl of IPBanService for place-holders.
/// </summary>
Expand Down
20 changes: 13 additions & 7 deletions IPBanCore/Core/IPBan/IPBanDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,24 @@ private static long GetInt64(object value)
/// </summary>
public static IPAddressEntry ParseIPAddressEntry(SqliteDataReader reader)
{
string ipAddress = reader.GetString(0);
long lastFailedLogin = reader.GetInt64(1);
long failedLoginCount = reader.GetInt64(2);
// Older DB schemas (created before BanEndDate / UserName / Source columns were added)
// can return NULL even though the column declarations now have defaults β€” read with
// IsDBNull guards so a stale schema doesn't crash every query that hits a legacy row.
string ipAddress = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
long lastFailedLogin = reader.IsDBNull(1) ? 0L : reader.GetInt64(1);
long failedLoginCount = reader.IsDBNull(2) ? 0L : reader.GetInt64(2);
object banDateObj = reader.GetValue(3);
IPAddressState state = (IPAddressState)(int)reader.GetInt32(4);
IPAddressState state = reader.IsDBNull(4) ? IPAddressState.Active : (IPAddressState)(int)reader.GetInt32(4);
object banEndDateObj = reader.GetValue(5);
string userName = reader.GetString(6);
string source = reader.GetString(7);
string userName = reader.IsDBNull(6) ? string.Empty : reader.GetString(6);
string source = reader.IsDBNull(7) ? string.Empty : reader.GetString(7);
long banDateLong = GetInt64(banDateObj);
long banEndDateLong = GetInt64(banEndDateObj);
DateTime? banDate = (banDateLong == 0 ? (DateTime?)null : banDateLong.ToDateTimeUnixMilliseconds());
DateTime? banEndDate = (banDateLong == 0 ? (DateTime?)null : banEndDateLong.ToDateTimeUnixMilliseconds());
// Each ban-date column is independent: BanDate may be set while BanEndDate is NULL
// (legacy rows from before BanEndDate existed, or in-flight transitions). Each guard
// must check its own column.
DateTime? banEndDate = (banEndDateLong == 0 ? (DateTime?)null : banEndDateLong.ToDateTimeUnixMilliseconds());
DateTime lastFailedLoginDt = lastFailedLogin.ToDateTimeUnixMilliseconds();
return new IPAddressEntry
{
Expand Down
7 changes: 7 additions & 0 deletions IPBanCore/Core/IPBan/IPBanFirewallUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Pipelines;
using System.Linq;
Expand Down Expand Up @@ -57,6 +58,7 @@ private static void AppendRange(StringBuilder b, PortRange range)
/// <param name="rulePrefix">Rule prefix or null for default</param>
/// <param name="previousFirewall">Current firewall</param>
/// <returns>Firewall</returns>
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Firewall implementations are selected and activated dynamically at runtime by design.")]
public static IIPBanFirewall CreateFirewall(IReadOnlyCollection<Type> allTypes,
string rulePrefix = null,
IIPBanFirewall previousFirewall = null)
Expand Down Expand Up @@ -611,6 +613,11 @@ public static int RunProcess(string program, object input, object output, params
inputStream.CopyTo(p.StandardInput.BaseStream);
}
}
catch (IOException)
{
// the process may have already exited and closed stdin (broken pipe);
// feeding stdin is best-effort, so ignore the write failure
}
finally
{
try { p.StandardInput.Close(); } catch { /* ignore */ }
Expand Down
22 changes: 17 additions & 5 deletions IPBanCore/Core/IPBan/IPBanIPThreatUploader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -31,6 +32,7 @@ public void Dispose()
}

/// <inheritdoc />
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Anonymous payload shape is fixed and used only for IPThreat API upload.")]
public async Task Update(CancellationToken cancelToken = default)
{
// ready to run?
Expand Down Expand Up @@ -104,12 +106,22 @@ await service.RequestMaker.MakeRequestAsync(ipThreatReportApiUri,
/// <inheritdoc />
public void AddIPAddressLogEvents(IEnumerable<IPAddressLogEvent> events)
{
lock (events)
// Run the filter outside the lock β€” the predicate calls into service.Config which we
// don't want to hold the events lock across. Only the AddRange happens inside.
var filtered = events.Where(e => e.Type == IPAddressEventType.Blocked &&
e.Count > 0 &&
!e.External &&
!service.Config.IsWhitelisted(e.IPAddress, out _)).ToArray();
if (filtered.Length == 0)
{
return;
}
// Qualify with `this.` so the lock targets the field β€” the parameter is also named
// `events` and would otherwise shadow it, locking an unrelated caller-supplied object
// while the field itself stayed unprotected.
lock (this.events)
{
this.events.AddRange(events.Where(e => e.Type == IPAddressEventType.Blocked &&
e.Count > 0 &&
!e.External &&
!service.Config.IsWhitelisted(e.IPAddress, out _)));
this.events.AddRange(filtered);
}
}
}
1 change: 0 additions & 1 deletion IPBanCore/Core/IPBan/IPBanLogManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ public IPBanLogManager(IIPBanService service)
/// <inheritdoc />
public Task Update(CancellationToken cancelToken)
{
UpdateLogFiles(service.Config);
if (service.ManualCycle)
{
foreach (var scanner in logsToParse)
Expand Down
2 changes: 1 addition & 1 deletion IPBanCore/Core/IPBan/IPBanMemoryFirewall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ public override string GetPorts(string ruleName)
{
return ruleRanges.Ports;
}
else if (!allowRuleRanges.TryGetValue(ruleName, out ruleRanges))
else if (allowRuleRanges.TryGetValue(ruleName, out ruleRanges))
{
return ruleRanges.Ports;
}
Expand Down
13 changes: 10 additions & 3 deletions IPBanCore/Core/IPBan/IPBanService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,12 @@ public void AddIPAddressLogEvents(IEnumerable<IPAddressLogEvent> events)
}

/// <summary>
/// Write a new config file
/// Write a new config file. Virtual so tests can intercept the call without touching
/// the on-disk config.
/// </summary>
/// <param name="xml">Xml of the new config file</param>
/// <returns>Task</returns>
public async Task WriteConfigAsync(string xml)
public virtual async Task WriteConfigAsync(string xml)
{
// Ensure valid xml before writing the file
XmlDocument doc = new();
Expand Down Expand Up @@ -488,6 +489,13 @@ public static T CreateAndStartIPBanTestService<T>(string directory = null, strin
ExtensionMethods.FileWriteAllTextWithRetry(configFileOverridePath, configFileOverrideText);
T service = IPBanService.CreateService<T>();
service.ConfigFilePath = configFilePath;
service.ConfigReaderWriter.UseFile = false;
service.ConfigReaderWriter.GlobalConfigString = configFileText;
service.ConfigOverrideReaderWriter.UseFile = false;
service.ConfigOverrideReaderWriter.GlobalConfigString = configFileOverrideText;
service.LocalIPAddressString = "127.0.0.1";
service.RemoteIPAddressString = "127.0.0.1";
service.OtherIPAddressesString = "127.0.0.1";
service.MultiThreaded = false;
service.ManualCycle = true;
service.DnsList = null; // too slow for tests, turn off
Expand Down Expand Up @@ -552,7 +560,6 @@ public static void DisposeIPBanTestService(IPBanService service)
Directory.Delete(appDataCache, true);
}
service.Firewall.Truncate();
service.RunCycleAsync().Sync();
service.IPBanDelegate = null;
service.Dispose();
IPBanService.CleanupIPBanTestFiles();
Expand Down
69 changes: 58 additions & 11 deletions IPBanCore/Core/IPBan/IPBanService_Private.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -205,7 +206,10 @@ private async Task SetNetworkInfo(CancellationToken cancelToken)
}

// request new config file
await GetUrl(UrlType.Config, cancelToken);
if (!string.IsNullOrWhiteSpace(Config.GetUrlConfig))
{
await GetUrl(UrlType.Config, cancelToken);
}
}

private async Task ProcessPendingFailedLogins(IReadOnlyList<IPAddressLogEvent> ipAddresses, CancellationToken cancelToken)
Expand Down Expand Up @@ -813,6 +817,17 @@ protected virtual void OnFirewallDisposing() { }
/// <returns>Task</returns>
protected virtual Task OnUpdate(CancellationToken cancelToken) => Task.CompletedTask;

/// <summary>
/// Launch the verified update binary. Virtual so tests can intercept the actual
/// process launch without having to spawn a real subprocess on the host.
/// </summary>
/// <param name="tempFile">Path to the (already-written, hash-verified) update binary.</param>
/// <param name="args">Command-line arguments to pass to the binary.</param>
protected virtual void LaunchUpdateBinary(string tempFile, string args)
{
ProcessUtility.CreateDetachedProcess(tempFile, args);
}

/// <summary>
/// Get url from config
/// </summary>
Expand Down Expand Up @@ -857,14 +872,45 @@ protected virtual async Task<bool> GetUrl(UrlType urlType, CancellationToken can
// if the update url sends bytes, we assume a software update, and run the result as an .exe
if (bytes.Length != 0)
{
var tempFile = Path.Combine(TempFile.TempDirectory, "IPBanServiceUpdate.exe");
File.WriteAllBytes(tempFile, bytes);

// however you are doing the update, you must allow -c and -d parameters
// pass -c to tell the update executable to delete itself when done
// pass -d for a directory which tells the .exe where this service lives
string args = "-c \"-d=" + AppContext.BaseDirectory + "\"";
ProcessUtility.CreateDetachedProcess(tempFile, args);
// Verify the downloaded binary against an operator-configured SHA-256
// hash before executing it. The auto-update channel runs the result as
// a privileged process (service account on Windows, root on Linux), so
// any attacker who can MITM this URL or influence the config-supplied
// GetUrlUpdate value would otherwise get arbitrary code execution. If
// no hash is configured the bytes are skipped β€” execution requires an
// explicit operator opt-in.
string expectedHash = (Config.GetUrlUpdateSha256 ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(expectedHash))
{
Logger.Warn("Auto-update download from {0} skipped β€” no GetUrlUpdateSha256 " +
"is configured. Set GetUrlUpdateSha256 to the SHA-256 hex of the expected " +
"update binary to opt in to automatic execution.", url);
}
else
{
byte[] actualHashBytes = SHA256.HashData(bytes);
string actualHash = Convert.ToHexString(actualHashBytes);
if (!string.Equals(actualHash, expectedHash.Replace(" ", string.Empty),
StringComparison.OrdinalIgnoreCase))
{
Logger.Error("Auto-update download from {0} REJECTED β€” hash mismatch. " +
"Expected {1}, got {2}. The binary will not be executed.",
url, expectedHash, actualHash);
}
else
{
var tempFile = Path.Combine(TempFile.TempDirectory, "IPBanServiceUpdate.exe");
File.WriteAllBytes(tempFile, bytes);

// however you are doing the update, you must allow -c and -d parameters
// pass -c to tell the update executable to delete itself when done
// pass -d for a directory which tells the .exe where this service lives
string args = "-c \"-d=" + AppContext.BaseDirectory + "\"";
Logger.Warn("Auto-update download from {0} verified (sha256 {1}); executing.",
url, actualHash);
LaunchUpdateBinary(tempFile, args);
}
}
}
}
else if (urlType == UrlType.Config && bytes.Length != 0)
Expand All @@ -883,7 +929,8 @@ protected virtual async Task<bool> GetUrl(UrlType urlType, CancellationToken can
private async Task UpdateUpdaters(CancellationToken cancelToken)
{
// hit start url if first time, if not first time will be ignored
if (!(await GetUrl(UrlType.Start, cancelToken)))
if ((!string.IsNullOrWhiteSpace(Config.GetUrlStart) || !string.IsNullOrWhiteSpace(Config.GetUrlUpdate)) &&
!(await GetUrl(UrlType.Start, cancelToken)))
{
// send update
await GetUrl(UrlType.Update, cancelToken);
Expand Down Expand Up @@ -951,7 +998,7 @@ private async Task RunFirewallTasks(CancellationToken cancelToken)
return;
}

Logger.Info("Processing {0} firewall tasks", taskCount);
Logger.Debug("Processing {0} firewall tasks", taskCount);

while (firewallTasks.TryDequeue(out var firewallTask))
{
Expand Down
Loading