From 710973a28bdbd4b7c9599758f7cdfb47895580d6 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:55:42 -0700 Subject: [PATCH] refactor(network): pluggable accept-path filters; move the blocklist to UOContent Introduces IConnectionFilter and the ConnectionFilters registry: one seam the accept path consults per inbound socket, so core no longer has to know where a gate's data comes from. The registry is a plain array walked by an indexed loop, so the hot path has no enumerator, no closure and no allocation; an interface dispatch is noise next to the accept syscall. Filters register during the Configure sweep, cheapest first, and the first denial short-circuits. A filter that throws on the accept path is unregistered and the connection fails open. A filter that faults once faults for every subsequent connection, so leaving it registered would mean an exception and a log line per accept -- exactly the amplification an attacker wants -- and a broken filter must not be able to deny every connection either. With that seam in place the whole file blocklist moves to UOContent: it is policy (which feeds, when to promote, what to report) built on an external file format with an external producer, and core does not need any of it. Firewall stays in core -- it is long-standing public API, it is what an admin reaches for manually, and a shard running without UOContent still has to be able to block an address -- but it now reaches the accept path through the same registry via a small adapter, so the two remain separate implementations rather than one conflated store. Blocklist policy moves out of bans.json into a UOContent Configuration/ blocklist.json, next to crowdsec.json. bans.json keeps only what core decides: reportRateLimitTrips and autoBanDuration. Cleanups found while moving the code: - BanChannel.Stop() persisted the Firewall. A contribution coordinator has no business saving an enforcement store; that is now the firewall filter's Stop. - BlocklistGate.Evaluate took a `whitelisted` flag that was hardcoded false at its only call site, and no whitelist concept exists anywhere in core. Dropped. - FileBlocklist was a static holding a snapshot and a promote-guard, which forced its tests onto the sequential collection and a LoadForTesting reset hook. It is now an instance, so each test owns its own state and they run in parallel. BlocklistGate folds into it: the pure decision survives as an internal Evaluate, which is all the separate type ever provided. - The promote-guard sweep timer was registered from NetState.Configure, two files from the guard it swept, and ran even with the blocklist disabled. It now starts with the filter that owns it. Core sheds ~570 source and ~340 test lines for ~90 of seam. 1344 tests pass. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + .../Blocklist/BlocklistAcceptGateTests.cs | 50 ------ .../Blocklist/BlocklistBanSettingsTests.cs | 22 --- .../Tests/Network/ConnectionFiltersTests.cs | 133 +++++++++++++++ Projects/Server/Main.cs | 7 +- Projects/Server/Network/Bans/BanChannel.cs | 7 +- .../Server/Network/Bans/BanConfiguration.cs | 31 ---- .../Network/Bans/Blocklist/BlocklistGate.cs | 42 ----- Projects/Server/Network/Bans/IBanReporter.cs | 6 +- Projects/Server/Network/ConnectionFilters.cs | 151 ++++++++++++++++++ Projects/Server/Network/Firewall/Firewall.cs | 4 + .../Firewall/FirewallConnectionFilter.cs | 54 +++++++ Projects/Server/Network/IConnectionFilter.cs | 59 +++++++ .../Network/NetState/NetState.Network.cs | 18 +-- Projects/Server/Network/NetState/NetState.cs | 9 +- .../Blocklist/BlocklistConfigurationTests.cs | 65 ++++++++ .../Bans/Blocklist/BlocklistFileTests.cs | 0 .../Bans/Blocklist/BlocklistFilterTests.cs | 94 +++++++++++ .../Bans/Blocklist/BlocklistSnapshotTests.cs | 0 .../Bans/Blocklist/PromotedGuardTests.cs | 0 .../Misc/Blocklist/BlocklistBans.cs} | 29 ++-- .../Misc/Blocklist/BlocklistConfiguration.cs | 97 +++++++++++ .../Misc}/Blocklist/BlocklistFile.cs | 0 .../Misc/Blocklist/BlocklistFilter.cs} | 133 +++++++++++---- .../Misc}/Blocklist/BlocklistSnapshot.cs | 0 .../Misc}/Blocklist/PromotedGuard.cs | 0 tools/Export-IpBlocklist.ps1 | 26 ++- 27 files changed, 797 insertions(+), 241 deletions(-) delete mode 100644 Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs delete mode 100644 Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs create mode 100644 Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs delete mode 100644 Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs create mode 100644 Projects/Server/Network/ConnectionFilters.cs create mode 100644 Projects/Server/Network/Firewall/FirewallConnectionFilter.cs create mode 100644 Projects/Server/Network/IConnectionFilter.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs rename Projects/{Server.Tests => UOContent.Tests}/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs (100%) create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs rename Projects/{Server.Tests => UOContent.Tests}/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs (100%) rename Projects/{Server.Tests => UOContent.Tests}/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs (100%) rename Projects/{Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs => UOContent/Misc/Blocklist/BlocklistBans.cs} (57%) create mode 100644 Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs rename Projects/{Server/Network/Bans => UOContent/Misc}/Blocklist/BlocklistFile.cs (100%) rename Projects/{Server/Network/Bans/Blocklist/FileBlocklist.cs => UOContent/Misc/Blocklist/BlocklistFilter.cs} (50%) rename Projects/{Server/Network/Bans => UOContent/Misc}/Blocklist/BlocklistSnapshot.cs (100%) rename Projects/{Server/Network/Bans => UOContent/Misc}/Blocklist/PromotedGuard.cs (100%) diff --git a/.gitignore b/.gitignore index 1c3f9172a..222cdabf2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /Distribution/Configuration/antimacro.json /Distribution/Configuration/assistants.json /Distribution/Configuration/bans.json +/Distribution/Configuration/blocklist.json /Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json /Distribution/Configuration/ip-blocklist.txt diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs deleted file mode 100644 index 01a3707c7..000000000 --- a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistAcceptGateTests.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System.Net; -using Server.Network.Bans.Blocklist; -using Xunit; - -namespace Server.Tests.Network.Bans.Blocklist; - -[Collection("Sequential Server Tests")] -public class BlocklistAcceptGateTests -{ - [Fact] - public void Blocklisted_ip_denied_and_reported_once() - { - FileBlocklist.LoadForTesting(BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes("1.2.3.4"), out _, out _)); - var guard = new PromotedGuard(); - var ip = IPAddress.Parse("1.2.3.4"); - - var deny1 = BlocklistGate.Evaluate(ip, false, guard, 1000, true, 5000, out var r1); - var deny2 = BlocklistGate.Evaluate(ip, false, guard, 1500, true, 5000, out var r2); - - Assert.True(deny1); - Assert.True(r1); - Assert.True(deny2); - Assert.False(r2); // denied both, reported once within TTL - } - - [Fact] - public void Whitelisted_and_clean_ips_pass() - { - FileBlocklist.LoadForTesting(BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes("1.2.3.4"), out _, out _)); - var guard = new PromotedGuard(); - - Assert.False(BlocklistGate.Evaluate(IPAddress.Parse("1.2.3.4"), true, guard, 1, true, 5000, out _)); - Assert.False(BlocklistGate.Evaluate(IPAddress.Parse("9.9.9.9"), false, guard, 1, true, 5000, out _)); - } -} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs deleted file mode 100644 index c65cda6d0..000000000 --- a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Bans.Blocklist; - -public class BlocklistBanSettingsTests -{ - [Fact] - public void Blocklist_defaults_are_present() - { - var s = new BanSettings(); - - // Default points at the location tools/Export-IpBlocklist.ps1 writes; the gate stays inert until - // that file actually exists, so shipping a non-empty default changes nothing for shards without one. - Assert.Equal("Configuration/ip-blocklist.txt", s.BlocklistFile); - Assert.Equal(TimeSpan.FromSeconds(60), s.BlocklistReloadInterval); - Assert.True(s.ReportBlocklistHits); - Assert.Equal(TimeSpan.FromHours(6), s.BlocklistBanDuration); - Assert.Equal(TimeSpan.FromSeconds(60), s.BlocklistPromoteSuppression); - } -} diff --git a/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs new file mode 100644 index 000000000..115211264 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs @@ -0,0 +1,133 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ConnectionFiltersTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using System.Threading; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +[Collection("Sequential Server Tests")] +public class ConnectionFiltersTests : IDisposable +{ + public ConnectionFiltersTests() => ConnectionFilters.ResetForTesting(); + + public void Dispose() => ConnectionFilters.ResetForTesting(); + + [Fact] + public void No_filters_denies_nothing() + { + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); + Assert.Null(deniedBy); + } + + [Fact] + public void Register_is_idempotent_by_name() + { + ConnectionFilters.Register(new FakeFilter("dupe", deny: false)); + ConnectionFilters.Register(new FakeFilter("dupe", deny: true)); + + // The second registration is ignored, so the deny:true instance never gets consulted. + Assert.Single(ConnectionFilters.Filters); + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); + } + + [Fact] + public void First_denying_filter_short_circuits_and_is_named() + { + var first = new FakeFilter("allow-all", deny: false); + var second = new FakeFilter("deny-all", deny: true); + var third = new FakeFilter("never-reached", deny: true); + + ConnectionFilters.Register(first); + ConnectionFilters.Register(second); + ConnectionFilters.Register(third); + + Assert.True(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); + Assert.Equal("deny-all", deniedBy); + Assert.Equal(1, first.Calls); + Assert.Equal(1, second.Calls); + Assert.Equal(0, third.Calls); // short-circuited + } + + // A filter that throws once throws for every subsequent connection, which would turn one bug into an + // exception per accept. It must be dropped, and the connection must fail open rather than be denied + // by a filter that never actually answered. + [Fact] + public void Throwing_filter_is_unregistered_and_fails_open() + { + var bad = new FakeFilter("bad", deny: true, throws: true); + var good = new FakeFilter("good", deny: false); + + ConnectionFilters.Register(bad); + ConnectionFilters.Register(good); + + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); + Assert.Single(ConnectionFilters.Filters); + Assert.Equal("good", ConnectionFilters.Filters[0].Name); + + // Remaining filters still run on the same pass the faulty one was dropped in. + Assert.Equal(1, good.Calls); + } + + [Fact] + public void Register_configures_immediately() + { + var filter = new FakeFilter("cfg", deny: false); + ConnectionFilters.Register(filter); + + Assert.True(filter.Configured); + } + + private sealed class FakeFilter : IConnectionFilter + { + private readonly bool _deny; + private readonly bool _throws; + + public FakeFilter(string name, bool deny, bool throws = false) + { + Name = name; + _deny = deny; + _throws = throws; + } + + public string Name { get; } + public int Calls { get; private set; } + public bool Configured { get; private set; } + + public void Configure() => Configured = true; + + public void Start(CancellationToken token) + { + } + + public void Stop() + { + } + + public bool ShouldDeny(IPAddress address) + { + Calls++; + if (_throws) + { + throw new InvalidOperationException("simulated filter bug"); + } + + return _deny; + } + } +} diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index c3dbacdf4..2be9d7d04 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -1,4 +1,4 @@ -/************************************************************************* +/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * @@ -31,7 +31,6 @@ using Server.Json; using Server.Logging; using Server.Network; using Server.Network.Bans; -using Server.Network.Bans.Blocklist; using Server.Text; namespace Server; @@ -345,7 +344,7 @@ public static class Core PingServer.Shutdown(); NetState.Shutdown(); BanChannel.Stop(); - FileBlocklist.Stop(); + ConnectionFilters.Stop(); if (!_crashed) { @@ -466,7 +465,7 @@ public static class Core AssemblyHandler.Invoke("Initialize"); BanChannel.Start(ClosingTokenSource.Token); - FileBlocklist.Start(ClosingTokenSource.Token); + ConnectionFilters.Start(ClosingTokenSource.Token); NetState.Start(); PingServer.Start(); EventSink.InvokeServerStarted(); diff --git a/Projects/Server/Network/Bans/BanChannel.cs b/Projects/Server/Network/Bans/BanChannel.cs index d40e52e0d..91a6715a6 100644 --- a/Projects/Server/Network/Bans/BanChannel.cs +++ b/Projects/Server/Network/Bans/BanChannel.cs @@ -23,8 +23,8 @@ namespace Server.Network.Bans; /// /// Coordinates the configured contribution sinks. Enforcement is NOT here — -/// the accept path calls directly. This channel only fans locally-decided -/// bans out to external systems (CrowdSec), which distribute them to OS-level bouncers. +/// the accept path asks . This channel only fans locally-decided bans out +/// to external systems (CrowdSec), which distribute them to OS-level bouncers. /// public static class BanChannel { @@ -103,9 +103,6 @@ public static class BanChannel logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name); } } - - // Flush the local firewall's pending writes on the way down. - Firewall.Save(); } /// Fans a locally-decided ban out to every reporter. Non-blocking; never throws. diff --git a/Projects/Server/Network/Bans/BanConfiguration.cs b/Projects/Server/Network/Bans/BanConfiguration.cs index 36e8cd59a..2f48007e7 100644 --- a/Projects/Server/Network/Bans/BanConfiguration.cs +++ b/Projects/Server/Network/Bans/BanConfiguration.cs @@ -74,35 +74,4 @@ public record BanSettings /// Duration reported for an auto-detected (rate-limit) ban. [JsonPropertyName("autoBanDuration")] public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4); - - /// - /// Path to the local blocklist file. A relative path resolves against ; - /// an absolute path is used as-is (handy when several shards share one generated list). The gate is - /// inert while the file is absent, so the default is safe to ship — drop a list in and it takes effect - /// on the next poll. Set to "" to disable the gate entirely. Produce the file with - /// tools/Export-IpBlocklist.ps1. - /// - [JsonPropertyName("blocklistFile")] - public string BlocklistFile { get; set; } = "Configuration/ip-blocklist.txt"; - - /// How often the blocklist file is re-read for changes. - [JsonPropertyName("blocklistReloadInterval")] - public TimeSpan BlocklistReloadInterval { get; set; } = TimeSpan.FromSeconds(60); - - /// Whether blocklist hits are contributed to reporters. - [JsonPropertyName("reportBlocklistHits")] - public bool ReportBlocklistHits { get; set; } = true; - - /// Duration reported for a blocklist-matched ban. - [JsonPropertyName("blocklistBanDuration")] - public TimeSpan BlocklistBanDuration { get; set; } = TimeSpan.FromHours(6); - - /// - /// How long the accept-path guard suppresses re-reporting a promoted IP. This only needs to bridge - /// the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops repeat - /// traffic. Decoupled from so the guard doesn't remember hours of - /// distinct IPs. - /// - [JsonPropertyName("blocklistPromoteSuppression")] - public TimeSpan BlocklistPromoteSuppression { get; set; } = TimeSpan.FromSeconds(60); } diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs deleted file mode 100644 index 682cbc8b0..000000000 --- a/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs +++ /dev/null @@ -1,42 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistGate.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System.Net; - -namespace Server.Network.Bans.Blocklist; - -/// -/// Pure accept-gate decision, isolated for testability. Allocation-free: no closures on the accept -/// path. Returns true when the connection should be denied; indicates -/// whether this hit should be contributed to the ban channel (once per TTL). -/// -public static class BlocklistGate -{ - public static bool Evaluate( - IPAddress ip, bool whitelisted, PromotedGuard guard, long nowTicks, - bool reportHits, long ttlMs, out bool shouldReport) - { - shouldReport = false; - if (whitelisted || !FileBlocklist.IsBanned(ip)) - { - return false; // pass - } - if (reportHits) - { - shouldReport = guard.TryMark(ip.ToUInt128(), nowTicks, ttlMs); - } - return true; // deny - } -} diff --git a/Projects/Server/Network/Bans/IBanReporter.cs b/Projects/Server/Network/Bans/IBanReporter.cs index c0f82c62b..39cee7780 100644 --- a/Projects/Server/Network/Bans/IBanReporter.cs +++ b/Projects/Server/Network/Bans/IBanReporter.cs @@ -21,9 +21,9 @@ namespace Server.Network.Bans; /// /// A contribution sink behind . Reporters receive locally-decided bans -/// (manual admin bans, rate-limit trips) and forward them to an external system (e.g. CrowdSec), -/// which distributes them to OS-level bouncers. Reporters never answer the accept-path membership -/// query — enforcement is the local 's job. +/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system +/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path +/// membership query — that is an 's job. /// public interface IBanReporter { diff --git a/Projects/Server/Network/ConnectionFilters.cs b/Projects/Server/Network/ConnectionFilters.cs new file mode 100644 index 000000000..458ef2f16 --- /dev/null +++ b/Projects/Server/Network/ConnectionFilters.cs @@ -0,0 +1,151 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ConnectionFilters.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Logging; + +namespace Server.Network; + +/// +/// Registry of the gates the accept path consults, and their lifecycle. +/// Filters are registered during the Configure sweep and the backing store is a plain array, so +/// is an indexed loop over a field read — no enumerator, no closure, no +/// allocation. The whole accept path runs on the game loop, so no synchronization is needed. +/// +public static class ConnectionFilters +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters)); + + private static IConnectionFilter[] _filters = []; + + public static IReadOnlyList Filters => _filters; + + /// + /// Registers a gate (inversion of control, mirroring BanChannel.Register). Idempotent by + /// . Filters are consulted in registration order, so register + /// the cheapest and most selective first — core registers the firewall before content is swept. + /// + public static void Register(IConnectionFilter filter) + { + if (filter == null) + { + return; + } + + foreach (var existing in _filters) + { + if (existing.Name == filter.Name) + { + return; + } + } + + filter.Configure(); + + var updated = new IConnectionFilter[_filters.Length + 1]; + Array.Copy(_filters, updated, _filters.Length); + updated[^1] = filter; + _filters = updated; + + logger.Information("Registered connection filter '{Name}'", filter.Name); + } + + /// + /// True when any filter denies the connection. Short-circuits on the first denial; + /// names it for logging. + /// + public static bool ShouldDeny(IPAddress address, out string deniedBy) + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + // Try/catch costs nothing when nothing throws, and this is the one path where a faulty + // third-party filter would otherwise take down the accept loop for every connection. + try + { + if (filters[i].ShouldDeny(address)) + { + deniedBy = filters[i].Name; + return true; + } + } + catch (Exception e) + { + Disable(filters[i], e); + } + } + + deniedBy = null; + return false; + } + + /// + /// Drops a filter that threw on the accept path. A filter that faults once will fault for every + /// subsequent connection, so leaving it registered means an exception and a log line per accept — + /// exactly the amplification an attacker wants. Failing open here is deliberate: a broken filter + /// must not be able to deny every connection either. + /// + private static void Disable(IConnectionFilter filter, Exception e) + { + logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name); + + var updated = new List(_filters.Length); + foreach (var existing in _filters) + { + if (!ReferenceEquals(existing, filter)) + { + updated.Add(existing); + } + } + + _filters = updated.ToArray(); + } + + public static void Start(CancellationToken token) + { + foreach (var filter in _filters) + { + try + { + filter.Start(token); + } + catch (Exception e) + { + // A filter that cannot hydrate must not crash boot; it simply denies nothing. + logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name); + } + } + } + + public static void Stop() + { + foreach (var filter in _filters) + { + try + { + filter.Stop(); + } + catch (Exception e) + { + logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name); + } + } + } + + internal static void ResetForTesting() => _filters = []; +} diff --git a/Projects/Server/Network/Firewall/Firewall.cs b/Projects/Server/Network/Firewall/Firewall.cs index d86cc8f97..69af4c4e1 100644 --- a/Projects/Server/Network/Firewall/Firewall.cs +++ b/Projects/Server/Network/Firewall/Firewall.cs @@ -251,6 +251,10 @@ public static class Firewall // Main-thread maintenance: expire TTLs and flush pending writes. No background thread. Timer.DelayCall(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), Maintenance); + + // Expose the set to the accept path. Everything else (gump, commands, persistence) keeps using + // the Firewall API directly; only the per-connection question goes through the filter registry. + ConnectionFilters.Register(FirewallConnectionFilter.Instance); } private static void Maintenance() diff --git a/Projects/Server/Network/Firewall/FirewallConnectionFilter.cs b/Projects/Server/Network/Firewall/FirewallConnectionFilter.cs new file mode 100644 index 000000000..fc39eebff --- /dev/null +++ b/Projects/Server/Network/Firewall/FirewallConnectionFilter.cs @@ -0,0 +1,54 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallConnectionFilter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Threading; + +namespace Server.Network; + +/// +/// Exposes the admin-curated set to the accept path as an +/// . The firewall keeps its own API (the admin gump and commands mutate +/// it directly); this is only the accept-path adapter, since a static class cannot implement an +/// interface. It registers from the core assembly, so it is consulted before any content filter — which +/// is what we want, as it is the cheapest check (an empty set costs one length compare). +/// +internal sealed class FirewallConnectionFilter : IConnectionFilter +{ + public static readonly FirewallConnectionFilter Instance = new(); + + private FirewallConnectionFilter() + { + } + + public string Name => "firewall"; + + // Firewall.Configure() owns loading/persistence and does the registering, so there is nothing to do + // here; Register() calling this back is harmless. + public void Configure() + { + } + + // Nothing to hydrate in the background: the set is loaded synchronously at Configure and maintained + // by a main-loop timer. + public void Start(CancellationToken token) + { + } + + /// Flushes pending writes on the way down so a TTL expiry or late admin edit is not lost. + public void Stop() => Firewall.Save(); + + public bool ShouldDeny(IPAddress address) => Firewall.IsBlocked(address); +} diff --git a/Projects/Server/Network/IConnectionFilter.cs b/Projects/Server/Network/IConnectionFilter.cs new file mode 100644 index 000000000..15853947f --- /dev/null +++ b/Projects/Server/Network/IConnectionFilter.cs @@ -0,0 +1,59 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IConnectionFilter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Threading; + +namespace Server.Network; + +/// +/// A gate consulted for every inbound connection, before the socket is configured and before any +/// per-connection allocation. Implementations decide membership only — the accept path neither knows +/// nor cares where a filter's data comes from, so a filter may be a small admin-curated set held in +/// core (see Firewall) or a millions-strong list hydrated from a file by content. +/// +/// +/// +/// runs on the game loop once per accepted socket, which is the path that has +/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform +/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service) +/// belongs off the loop or behind a bounded, non-blocking enqueue. +/// +/// +/// Side effects that a hit implies (contributing to BanChannel, promoting to an OS firewall, +/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why +/// returns a bare bool: the accept path asks one question and does one thing. +/// +/// +public interface IConnectionFilter +{ + /// Stable id for logging/config (e.g. firewall, blocklist). + string Name { get; } + + /// Reads configuration. Called by . No I/O beyond config. + void Configure(); + + /// Starts any background hydration. The token is cancelled on shutdown. + void Start(CancellationToken token); + + /// Flushes and tears down. Called during shutdown. + void Stop(); + + /// + /// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on + /// . + /// + bool ShouldDeny(IPAddress address); +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 97249dcd6..b38c88d23 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -73,7 +73,6 @@ public partial class NetState public static IPEndPoint[] ListeningAddresses { get; private set; } private static IPRateLimiter _ipRateLimiter; - private static readonly Bans.Blocklist.PromotedGuard _blocklistGuard = new(); /// /// Configures the IORingGroup and socket manager. @@ -233,20 +232,11 @@ public partial class NetState Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, "rate-limit"); } } - else if (Firewall.IsBlocked(remoteIP)) + else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy)) { - logger.Debug("{Address} Firewalled", remoteIP); - } - else if (Bans.Blocklist.BlocklistGate.Evaluate(remoteIP, false, _blocklistGuard, Core.TickCount, - Bans.BanConfiguration.Settings.ReportBlocklistHits, - (long)Bans.BanConfiguration.Settings.BlocklistPromoteSuppression.TotalMilliseconds, out var promote)) - { - logger.Debug("{Address} Blocklisted", remoteIP); - - if (promote) - { - Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.BlocklistBanDuration, "blocklist"); - } + // Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the + // ban channel) is the filter's own business; the accept path just drops the socket. + logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy); } else { diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index faabc11f7..317ed46eb 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -97,14 +97,9 @@ public partial class NetState : IComparable, IValueLinkListNode _blocklistGuard.Sweep(Core.TickCount)); } // Internal constructor for accepted sockets diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs new file mode 100644 index 000000000..187878890 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -0,0 +1,65 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistConfigurationTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans.Blocklist; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +public class BlocklistConfigurationTests +{ + // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings + // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. + [Fact] + public void BlocklistSettings_RoundTripsThroughJsonConfig() + { + var original = new BlocklistSettings + { + File = "D:/shared/ip-blocklist.txt", + ReloadInterval = TimeSpan.FromMinutes(5), + ReportHits = false, + BanDuration = TimeSpan.FromHours(2), + PromoteSuppression = TimeSpan.FromSeconds(30) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"file\"", json); + Assert.Contains("\"reloadInterval\"", json); + Assert.Contains("\"reportHits\"", json); + Assert.Contains("\"banDuration\"", json); + Assert.Contains("\"promoteSuppression\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.File, restored.File); + Assert.Equal(original.ReloadInterval, restored.ReloadInterval); + Assert.Equal(original.ReportHits, restored.ReportHits); + Assert.Equal(original.BanDuration, restored.BanDuration); + Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); + } + + // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves + // without the other, a shard silently enforces nothing. + [Fact] + public void Default_file_matches_the_generator_output_path() + { + Assert.Equal("Configuration/ip-blocklist.txt", new BlocklistSettings().File); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs similarity index 100% rename from Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs rename to Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs new file mode 100644 index 000000000..291e58709 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs @@ -0,0 +1,94 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFilterTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Text; +using Server.Network.Bans.Blocklist; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +// No [Collection] and no static reset hook: the filter is an instance, so each test owns its own +// snapshot and promote-guard. That is the point of it no longer being a static class. +public class BlocklistFilterTests +{ + private static BlocklistFilter WithList(string list, bool reportHits = true, long suppressionMs = 5000) + { + var filter = new BlocklistFilter(); + filter.LoadForTesting( + BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(list), out _, out _), + reportHits, + suppressionMs + ); + + return filter; + } + + [Fact] + public void Listed_address_is_denied_and_reported_once_per_window() + { + var filter = WithList("1.2.3.4"); + var ip = IPAddress.Parse("1.2.3.4"); + + var deny1 = filter.Evaluate(ip, 1000, out var report1); + var deny2 = filter.Evaluate(ip, 1500, out var report2); + var deny3 = filter.Evaluate(ip, 1000 + 5001, out var report3); + + Assert.True(deny1); + Assert.True(report1); + + Assert.True(deny2); + Assert.False(report2); // denied again, but promotion suppressed inside the window + + Assert.True(deny3); + Assert.True(report3); // window elapsed, promotion may be retried + } + + [Fact] + public void Unlisted_address_passes() + { + var filter = WithList("1.2.3.4"); + + Assert.False(filter.Evaluate(IPAddress.Parse("9.9.9.9"), 1, out var report)); + Assert.False(report); + } + + [Fact] + public void Cidr_membership_is_honored() + { + var filter = WithList("10.20.30.0/24"); + + Assert.True(filter.Evaluate(IPAddress.Parse("10.20.30.255"), 1, out _)); + Assert.False(filter.Evaluate(IPAddress.Parse("10.20.31.0"), 1, out _)); + } + + [Fact] + public void ReportHits_disabled_still_denies_but_never_promotes() + { + var filter = WithList("1.2.3.4", reportHits: false); + + Assert.True(filter.Evaluate(IPAddress.Parse("1.2.3.4"), 1, out var report)); + Assert.False(report); + } + + [Fact] + public void Unconfigured_filter_denies_nothing() + { + var filter = new BlocklistFilter(); + + Assert.Equal(0, filter.Count); + Assert.False(filter.ShouldDeny(IPAddress.Parse("8.8.8.8"))); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs similarity index 100% rename from Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs rename to Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs similarity index 100% rename from Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs rename to Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs b/Projects/UOContent/Misc/Blocklist/BlocklistBans.cs similarity index 57% rename from Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs rename to Projects/UOContent/Misc/Blocklist/BlocklistBans.cs index 0f0e703e6..6c2ee8a04 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs +++ b/Projects/UOContent/Misc/Blocklist/BlocklistBans.cs @@ -2,7 +2,7 @@ * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * - * File: FileBlocklistTests.cs * + * File: BlocklistBans.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -13,27 +13,20 @@ * along with this program. If not, see . * *************************************************************************/ -using System.Net; +using Server.Network; using Server.Network.Bans.Blocklist; -using Xunit; -namespace Server.Tests.Network.Bans.Blocklist; +namespace Server.Misc; -[Collection("Sequential Server Tests")] -public class FileBlocklistTests +/// +/// Registers the file-backed blocklist with the Core registry during the +/// Configure sweep. Registration is unconditional: the filter self-disables when blocklist.json +/// names no file, or when that file does not exist yet, so no config gate is needed here. +/// +public static class BlocklistBans { - [Fact] - public void IsBanned_reflects_loaded_snapshot() + public static void Configure() { - FileBlocklist.LoadForTesting(BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes("1.2.3.4"), out _, out _)); - Assert.True(FileBlocklist.IsBanned(IPAddress.Parse("1.2.3.4"))); - Assert.False(FileBlocklist.IsBanned(IPAddress.Parse("1.2.3.5"))); - } - - [Fact] - public void Empty_when_unloaded_never_throws() - { - FileBlocklist.LoadForTesting(BlocklistSnapshot.Empty); - Assert.False(FileBlocklist.IsBanned(IPAddress.Parse("8.8.8.8"))); + ConnectionFilters.Register(new BlocklistFilter()); } } diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs new file mode 100644 index 000000000..8f1413ae8 --- /dev/null +++ b/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs @@ -0,0 +1,97 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans.Blocklist; + +/// +/// Loads the from Configuration/blocklist.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. +/// +public static class BlocklistConfiguration +{ + private const string _path = "Configuration/blocklist.json"; + + public static BlocklistSettings Settings { get; private set; } + + public static void Configure() + { + // Idempotent: this Configure() is auto-discovered and also called explicitly by the filter, + // so guard against a redundant second load. + if (Settings != null) + { + return; + } + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new BlocklistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . The filter is inert unless +/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs +/// the generator. +/// +public record BlocklistSettings +{ + /// + /// Path to the blocklist. A relative path resolves against ; an + /// absolute path is used as-is (handy when several shards share one generated list). Set to + /// "" to disable the gate entirely. Produce the file with tools/Export-IpBlocklist.ps1. + /// + [JsonPropertyName("file")] + public string File { get; set; } = "Configuration/ip-blocklist.txt"; + + /// How often the file is checked for changes. Reloads only happen when it actually changed. + [JsonPropertyName("reloadInterval")] + public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); + + /// Whether blocklist hits are contributed to the ban channel (the demand-paging promotion). + [JsonPropertyName("reportHits")] + public bool ReportHits { get; set; } = true; + + /// Duration reported for a blocklist-matched ban. + [JsonPropertyName("banDuration")] + public TimeSpan BanDuration { get; set; } = TimeSpan.FromHours(6); + + /// + /// How long the accept-path guard suppresses re-reporting a promoted address. This only needs to + /// bridge the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops + /// repeat traffic. Decoupled from so the guard doesn't have to remember + /// hours' worth of distinct addresses. + /// + [JsonPropertyName("promoteSuppression")] + public TimeSpan PromoteSuppression { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs b/Projects/UOContent/Misc/Blocklist/BlocklistFile.cs similarity index 100% rename from Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs rename to Projects/UOContent/Misc/Blocklist/BlocklistFile.cs diff --git a/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs b/Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs similarity index 50% rename from Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs rename to Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs index 04425ac1c..5b9e43177 100644 --- a/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs +++ b/Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs @@ -2,7 +2,7 @@ * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * - * File: FileBlocklist.cs * + * File: BlocklistFilter.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -23,28 +23,50 @@ using Server.Logging; namespace Server.Network.Bans.Blocklist; /// -/// In-app gate for a large, file-sourced IP blocklist. Holds an immutable snapshot swapped atomically -/// by an off-loop reload poll; accept-path reads are lock-free. Inert when no file is configured. +/// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator +/// (tools/Export-IpBlocklist.ps1) writes on a schedule. Holds an immutable snapshot swapped +/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is +/// configured or present. /// -public static class FileBlocklist +/// +/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on +/// Windows, so the millions live here and only addresses that actually connect are promoted to CrowdSec +/// (and from there to the OS firewall) through . +/// keeps a flood of repeat connections from re-reporting the same address before the bouncer picks it up. +/// +public sealed class BlocklistFilter : IConnectionFilter { - private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileBlocklist)); + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter)); - private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; - private static string _path; - private static TimeSpan _interval; - private static string _lastGenerated; - private static DateTime _lastWriteUtc; - private static CancellationTokenSource _cts; + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. + private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; - public static int Count => _snapshot.Count; + private readonly PromotedGuard _guard = new(); - public static void Configure() + private string _path; + private TimeSpan _interval; + private bool _reportHits; + private TimeSpan _banDuration; + private long _suppressionMs; + private string _lastGenerated; + private DateTime _lastWriteUtc; + private CancellationTokenSource _cts; + + public string Name => "blocklist"; + + public int Count => _snapshot.Count; + + public void Configure() { - BanConfiguration.Configure(); - var s = BanConfiguration.Settings; - _path = ResolvePath(s.BlocklistFile); - _interval = s.BlocklistReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.BlocklistReloadInterval; + BlocklistConfiguration.Configure(); + var s = BlocklistConfiguration.Settings; + + _path = ResolvePath(s.File); + _interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval; + _reportHits = s.ReportHits; + _banDuration = s.BanDuration; + _suppressionMs = (long)s.PromoteSuppression.TotalMilliseconds; } /// @@ -62,43 +84,88 @@ public static class FileBlocklist return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured); } - public static void Start(CancellationToken token) + public void Start(CancellationToken token) { if (_path == null) { - logger.Information("FileBlocklist disabled (blocklistFile empty in bans.json)"); + logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); return; } _cts = CancellationTokenSource.CreateLinkedTokenSource(token); // A missing file is the shipped default, not an error: the gate stays inert and the poll picks the - // list up whenever the generator (tools/Export-IpBlocklist.ps1) first writes it. No restart needed. + // list up whenever the generator first writes it. No restart needed. if (File.Exists(_path)) { Reload(); // synchronous prime; empty on failure (fail-open) } else { - logger.Information("FileBlocklist inert: no blocklist at \"{Path}\"; polling every {Interval}", _path, _interval); + logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval); } + // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); + _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); } - public static void Stop() + public void Stop() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; } - public static bool IsBanned(IPAddress ip) => _snapshot.IsBanned(ip); + public bool ShouldDeny(IPAddress address) + { + if (!Evaluate(address, Core.TickCount, out var shouldReport)) + { + return false; + } - // Test hook: inject a snapshot without file I/O. - public static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot; + if (shouldReport) + { + // Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop. + BanChannel.Report(address, _banDuration, "blocklist"); + } - private static async ValueTask PollLoop(CancellationToken token) + return true; + } + + /// + /// The pure decision, split out so the accept-path policy can be tested without a clock or a ban + /// channel. is true at most once per suppression window. + /// + internal bool Evaluate(IPAddress address, long nowTicks, out bool shouldReport) + { + shouldReport = false; + + if (!_snapshot.IsBanned(address)) + { + return false; + } + + if (_reportHits) + { + shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs); + } + + return true; + } + + private void SweepGuard() => _guard.Sweep(Core.TickCount); + + // Test hook: inject a snapshot and policy without file I/O. + internal void LoadForTesting(BlocklistSnapshot snapshot, bool reportHits = true, long suppressionMs = 60000) + { + _snapshot = snapshot; + _reportHits = reportHits; + _suppressionMs = suppressionMs; + } + + private async ValueTask PollLoop(CancellationToken token) { while (!token.IsCancellationRequested) { @@ -110,10 +177,13 @@ public static class FileBlocklist { return; } + try { if (ChangedSinceLastLoad()) { + // Parsing millions of lines competes with a save for CPU and page cache, so yield until + // the world is written out. See the threading policy in CLAUDE.md (rules #3 and #10). while (World.Saving || World.WorldState == WorldState.PendingSave) { await Task.Delay(TimeSpan.FromSeconds(1), token); @@ -128,12 +198,12 @@ public static class FileBlocklist } catch (Exception e) { - logger.Warning(e, "FileBlocklist reload check failed; keeping last snapshot ({Count})", Count); + logger.Warning(e, "Blocklist reload check failed; keeping last snapshot ({Count})", Count); } } } - private static bool ChangedSinceLastLoad() + private bool ChangedSinceLastLoad() { try { @@ -142,6 +212,7 @@ public static class FileBlocklist { return false; } + if (info.LastWriteTimeUtc == _lastWriteUtc) { return false; // cheapest guard @@ -151,10 +222,11 @@ public static class FileBlocklist { return false; } + return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated; } - private static void Reload() + private void Reload() { // Capture the mtime/header BEFORE Load() so the markers describe the version we're about to // parse, not whatever the producer may have atomically swapped in mid-parse. If a swap happens @@ -176,7 +248,8 @@ public static class FileBlocklist _snapshot = next; // single volatile swap; readers see old or new whole _lastGenerated = h.Generated; _lastWriteUtc = writeUtc; - logger.Information("FileBlocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}", + + logger.Information("Blocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}", parsed, next.Count, skipped, h.Generated); } } diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs similarity index 100% rename from Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs rename to Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs diff --git a/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs b/Projects/UOContent/Misc/Blocklist/PromotedGuard.cs similarity index 100% rename from Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs rename to Projects/UOContent/Misc/Blocklist/PromotedGuard.cs diff --git a/tools/Export-IpBlocklist.ps1 b/tools/Export-IpBlocklist.ps1 index a9f4b6522..55d841fd0 100644 --- a/tools/Export-IpBlocklist.ps1 +++ b/tools/Export-IpBlocklist.ps1 @@ -7,11 +7,11 @@ .DESCRIPTION This is the producer half of ModernUO's in-app blocklist gate. It fetches a deliberately THIN feed set, merges every source into one global set, drops duplicates and reserved/bogon addresses, then - writes the result to a plain text file that the shard reads via `blocklistFile` in - Configuration/bans.json. Nothing is installed and no credentials are needed — the output is just a - text file, so this can run on any machine that can reach the shard's Distribution folder. + writes the result to a plain text file that the shard reads via `file` in + Configuration/blocklist.json. Nothing is installed and no credentials are needed — the output is just + a text file, so this can run on any machine that can reach the shard's Distribution folder. - It writes the file the shard's `FileBlocklist` demand-pages against. IPs that actually connect are + It writes the file the shard's `BlocklistFilter` demand-pages against. IPs that actually connect are promoted to CrowdSec / the OS firewall by the shard; the OS firewall never has to hold millions of entries, which is exactly the scale it cannot handle on Windows. @@ -33,12 +33,11 @@ The only category deliberately held back is commercial VPN exit endpoints, which could block a legit player — and those are barely present here anyway (bitwire is ~5% of VPN-tunnel lists). If you ever want to protect VPN/Tor players, pass -ExcludeAnonymizers to subtract Tor/open-proxy/VPN IPs from the output. - The shard's IP whitelist still overrides the gate for anyone who has already logged in. - OUTPUT FORMAT (must stay in sync with Server/Network/Bans/Blocklist/BlocklistFile.cs): + OUTPUT FORMAT (must stay in sync with UOContent/Misc/Blocklist/BlocklistFile.cs): Line 1 is a header comment carrying the version markers, e.g. # modernuo-blocklist generated=2026-07-25T18:03:11Z count=3914022 ipv4=3901188 cidr=12834 - The shard polls `blocklistReloadInterval` and reloads when the file mtime AND `generated=` change, + The shard polls `reloadInterval` and reloads when the file mtime AND `generated=` change, so the header is REQUIRED — without it the shard loads once and never picks up a new file. Every following line is one entry: a bare IPv4/IPv6 address or a CIDR (`1.2.3.0/24`). Blank lines and lines starting with `#` or `;` are ignored. Order does not matter; the shard sorts and @@ -59,12 +58,12 @@ .PARAMETER DistributionPath Path to the shard's Distribution folder. The blocklist is written to - \Configuration\ip-blocklist.txt, which is the default `blocklistFile` in bans.json. + \Configuration\ip-blocklist.txt, which is the default `file` in blocklist.json. Not needed when the script is run from its place in the repo (tools\), or when -OutFile is given. .PARAMETER OutFile Explicit output path, overriding -DistributionPath. Use this if you relocated the blocklist and - changed `blocklistFile` in bans.json to match. + changed `file` in blocklist.json to match. .PARAMETER MinInterval Refuse to re-run while the existing blocklist is younger than this (default 2h), so a misbehaving @@ -93,15 +92,12 @@ .\Export-IpBlocklist.ps1 -DryRun .EXAMPLE + # Safe to call as often as you like — it no-ops unless the list is older than 2h. .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' .EXAMPLE .\Export-IpBlocklist.ps1 -OutFile 'D:\shared\ip-blocklist.txt' -ExcludeAnonymizers -.EXAMPLE - # Safe to call as often as you like — it no-ops unless the list is older than 2h. - .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' - .EXAMPLE # Regenerate right now, ignoring the cooldown. .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' -Force @@ -127,7 +123,7 @@ $ErrorActionPreference = 'Stop' $UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ModernUO-Blocklist-Export' $totalSw = [System.Diagnostics.Stopwatch]::StartNew() -# Default location, relative to the Distribution folder. Keep in sync with BanSettings.BlocklistFile. +# Default location, relative to the Distribution folder. Keep in sync with BlocklistSettings.File. $DefaultRelativePath = 'Configuration\ip-blocklist.txt' # --------------------------------------------------------------------------------------------------------- @@ -530,4 +526,4 @@ $wSw.Stop() $sizeMb = [Math]::Round((Get-Item -LiteralPath $OutFile).Length / 1MB, 1) Write-Host ("`nWrote {0} entries ({1} MB) to {2} in {3:N1}s (total {4:N1}s). generated={5}" -f ` $total, $sizeMb, $OutFile, $wSw.Elapsed.TotalSeconds, $totalSw.Elapsed.TotalSeconds, $generated) -Write-Host "The shard picks this up on its next blocklistReloadInterval poll; no restart needed." +Write-Host "The shard picks this up on its next reloadInterval poll; no restart needed."