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."