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