feat(bans): Windows blocklist gate with demand-paged promotion

Enforce a millions-strong external IP blocklist in-app so the OS firewall
never has to hold it (Windows BFE can't). FileBlocklist loads a versioned
file into an immutable SortedRangeIndex snapshot off the game loop (yields to
world saves) and swaps it atomically; the accept path gates against it after
the manual-ban check and, once per suppression window (PromotedGuard),
promotes the hit to CrowdSec (scenario modernuo/blocklist) so the OS bouncer
kernel-drops repeat traffic. The bulk list is produced entirely out-of-process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-07-23 20:38:04 -07:00
parent 6249a20f15
commit de3cfa35b1
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
11 changed files with 857 additions and 0 deletions

View file

@ -0,0 +1,50 @@
/*************************************************************************
* 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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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 _));
}
}

View file

@ -0,0 +1,20 @@
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();
Assert.Equal("", 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);
}
}

View file

@ -0,0 +1,63 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistFileTests.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using System.Net;
using Server.Network.Bans.Blocklist;
using Xunit;
namespace Server.Tests.Network.Bans.Blocklist;
public class BlocklistFileTests
{
private static string WriteTemp(string content)
{
var p = Path.Combine(Path.GetTempPath(), "bl-" + Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(p, content);
return p;
}
[Fact]
public void Reads_header_generated_and_count()
{
var p = WriteTemp("# modernuo-blocklist v1 generated=2026-07-21T09:24:23Z count=2\n1.2.3.4\n5.6.7.0/24\n");
Assert.True(BlocklistFile.TryReadHeader(p, out var h));
Assert.True(h.Present);
Assert.Equal("2026-07-21T09:24:23Z", h.Generated);
Assert.Equal(2, h.Count);
File.Delete(p);
}
[Fact]
public void Missing_file_reports_absent_and_loads_empty()
{
var p = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N"));
Assert.False(BlocklistFile.TryReadHeader(p, out var h));
Assert.False(h.Present);
var snap = BlocklistFile.Load(p, out _, out _);
Assert.False(snap.IsBanned(IPAddress.Parse("1.2.3.4")));
}
[Fact]
public void Load_parses_body()
{
var p = WriteTemp("# generated=x count=1\n8.8.8.0/24\n");
var snap = BlocklistFile.Load(p, out var parsed, out _);
Assert.Equal(1, parsed);
Assert.True(snap.IsBanned(IPAddress.Parse("8.8.8.8")));
File.Delete(p);
}
}

View file

@ -0,0 +1,98 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistSnapshotTests.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Net;
using Server.Network.Bans.Blocklist;
using Xunit;
namespace Server.Tests.Network.Bans.Blocklist;
public class BlocklistSnapshotTests
{
private static BlocklistSnapshot Build(params string[] lines) =>
BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes(string.Join('\n', lines)), out _, out _);
[Fact]
public void Single_ip_is_matched()
{
var s = Build("1.2.3.4");
Assert.True(s.IsBanned(IPAddress.Parse("1.2.3.4")));
Assert.False(s.IsBanned(IPAddress.Parse("1.2.3.5")));
}
[Fact]
public void Cidr_contains_and_excludes_boundaries()
{
var s = Build("10.0.0.0/24");
Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.0")));
Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.255")));
Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0")));
Assert.False(s.IsBanned(IPAddress.Parse("9.255.255.255")));
}
[Fact]
public void Comments_blanks_and_garbage_are_skipped_not_thrown()
{
var s = BlocklistSnapshot.Build(
System.Text.Encoding.ASCII.GetBytes(
string.Join('\n', "# header generated=x", "", "not-an-ip", "1.2.3.4", "::1", "5.6.7.0/24")),
out var parsed, out var skipped);
Assert.Equal(3, parsed); // 1.2.3.4 + ::1 (valid loopback) + 5.6.7.0/24
Assert.True(skipped >= 1); // "not-an-ip"; blank/comment lines are silently skipped, not counted
Assert.True(s.IsBanned(IPAddress.Parse("5.6.7.200")));
}
[Fact]
public void Empty_snapshot_matches_nothing()
{
Assert.False(BlocklistSnapshot.Empty.IsBanned(IPAddress.Parse("1.2.3.4")));
}
[Fact]
public void Ipv6_single_and_cidr_are_matched()
{
var s = Build("2001:db8::1", "2001:db8:1::/48");
Assert.True(s.IsBanned(IPAddress.Parse("2001:db8::1")));
Assert.True(s.IsBanned(IPAddress.Parse("2001:db8:1::abcd")));
Assert.False(s.IsBanned(IPAddress.Parse("2001:db8:2::1")));
}
[Fact]
public void Ipv4_mapped_ipv6_is_normalized_to_v4()
{
var s = Build("1.2.3.4");
Assert.True(s.IsBanned(IPAddress.Parse("::ffff:1.2.3.4"))); // must not bypass the v4 set
}
[Fact]
public void Nested_cidr_intervals_are_coalesced()
{
// A /32 nested inside a /24: InRange's binary search only inspects the
// rightmost interval starting <= ip, so without coalescing an IP inside
// the /24 but outside the /32 would land on the /32 and wrongly pass.
var s = Build("10.0.0.0/24", "10.0.0.5/32");
Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // inside /24, outside /32
Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.5"))); // the nested /32 itself
Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0"))); // genuinely outside both
}
[Fact]
public void Overlapping_cidr_intervals_are_coalesced()
{
var s = Build("10.0.0.0/25", "10.0.0.64/25");
Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // covered by the second /25
Assert.False(s.IsBanned(IPAddress.Parse("10.0.0.200"))); // outside both
}
}

View file

@ -0,0 +1,39 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: FileBlocklistTests.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Net;
using Server.Network.Bans.Blocklist;
using Xunit;
namespace Server.Tests.Network.Bans.Blocklist;
[Collection("Sequential Server Tests")]
public class FileBlocklistTests
{
[Fact]
public void IsBanned_reflects_loaded_snapshot()
{
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")));
}
}

View file

@ -0,0 +1,46 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: PromotedGuardTests.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using Server.Network.Bans.Blocklist;
using Xunit;
namespace Server.Tests.Network.Bans.Blocklist;
public class PromotedGuardTests
{
[Fact]
public void First_mark_true_then_suppressed_until_ttl()
{
var g = new PromotedGuard();
Assert.True(g.TryMark((UInt128)42, 1000, 5000));
Assert.False(g.TryMark((UInt128)42, 2000, 5000)); // within TTL
Assert.True(g.TryMark((UInt128)42, 6001, 5000)); // expired → re-mark
}
[Fact]
public void Sweep_removes_expired_entries_allowing_remark()
{
var g = new PromotedGuard();
Assert.True(g.TryMark((UInt128)7, 0, 1000));
Assert.False(g.TryMark((UInt128)7, 500, 1000)); // still within TTL
g.Sweep(500); // not yet expired, sweep should not remove it
Assert.False(g.TryMark((UInt128)7, 999, 1000));
g.Sweep(1001); // now expired, sweep removes it
Assert.True(g.TryMark((UInt128)7, 1002, 1000)); // fresh mark, not "still marked"
}
}

View file

@ -0,0 +1,84 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistFile.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
namespace Server.Network.Bans.Blocklist;
public readonly record struct BlocklistHeader(string Generated, int Count, bool Present);
/// <summary>Reads the versioned blocklist file: cheap header probe + full snapshot load.</summary>
public static class BlocklistFile
{
public static bool TryReadHeader(string path, out BlocklistHeader header)
{
header = new BlocklistHeader(null, 0, false);
try
{
if (!File.Exists(path))
{
return false;
}
using var reader = new StreamReader(path);
var first = reader.ReadLine();
if (first == null)
{
header = new BlocklistHeader(null, 0, true);
return true;
}
string generated = null;
var count = 0;
if (first.StartsWith('#'))
{
foreach (var tok in first.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (tok.StartsWith("generated=", StringComparison.Ordinal))
{
generated = tok["generated=".Length..];
}
else if (tok.StartsWith("count=", StringComparison.Ordinal))
{
int.TryParse(tok["count=".Length..], out count);
}
}
}
header = new BlocklistHeader(generated, count, true);
return true;
}
catch
{
return false; // treat as absent; caller keeps last-good / empty
}
}
public static BlocklistSnapshot Load(string path, out int parsed, out int skipped)
{
parsed = 0;
skipped = 0;
try
{
if (!File.Exists(path))
{
return BlocklistSnapshot.Empty;
}
return BlocklistSnapshot.Build(File.ReadAllBytes(path), out parsed, out skipped);
}
catch
{
return BlocklistSnapshot.Empty;
}
}
}

View file

@ -0,0 +1,42 @@
/*************************************************************************
* 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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Net;
namespace Server.Network.Bans.Blocklist;
/// <summary>
/// Pure accept-gate decision, isolated for testability. Allocation-free: no closures on the accept
/// path. Returns true when the connection should be denied; <paramref name="shouldReport"/> indicates
/// whether this hit should be contributed to the ban channel (once per <see cref="PromotedGuard"/> TTL).
/// </summary>
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
}
}

View file

@ -0,0 +1,205 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistSnapshot.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Buffers.Text;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Server.Collections;
namespace Server.Network.Bans.Blocklist;
/// <summary>
/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced
/// interval index per family: IPv4 as <see cref="uint"/> ranges (lean for the millions-strong common
/// case), IPv6 as <see cref="UInt128"/> ranges (empty unless the feed carries v6). Immutable → lock-free reads.
/// </summary>
public sealed class BlocklistSnapshot
{
public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex<uint>.Empty, SortedRangeIndex<UInt128>.Empty);
private readonly SortedRangeIndex<uint> _v4;
private readonly SortedRangeIndex<UInt128> _v6;
public int Count => _v4.Count + _v6.Count;
private BlocklistSnapshot(SortedRangeIndex<uint> v4, SortedRangeIndex<UInt128> v6)
{
_v4 = v4;
_v6 = v6;
}
/// <summary>
/// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on
/// <c>'\n'</c> with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the
/// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser.
/// Malformed lines increment <paramref name="skipped"/> and never throw. Build-time intermediates use
/// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread.
/// </summary>
public static BlocklistSnapshot Build(ReadOnlySpan<byte> data, out int parsed, out int skipped)
{
parsed = 0;
skipped = 0;
// Only the two final index arrays (allocated inside SortedRangeIndex.Build) hit the heap; every
// build-time buffer here is a pooled ref list. mt: true is required — this runs off the game loop.
using var v4 = PooledRefList<SortedRangeIndex<uint>.Range>.Create(mt: true);
using var v6 = PooledRefList<SortedRangeIndex<UInt128>.Range>.Create(mt: true);
var rest = data;
while (!rest.IsEmpty)
{
ReadOnlySpan<byte> line;
var nl = rest.IndexOf((byte)'\n');
if (nl >= 0)
{
line = rest[..nl];
rest = rest[(nl + 1)..];
}
else
{
line = rest;
rest = default;
}
line = line[Ascii.Trim(line)];
if (line.IsEmpty || line[0] == (byte)'#' || line[0] == (byte)';')
{
continue;
}
var slash = line.IndexOf((byte)'/');
var addr = slash >= 0 ? line[..slash] : line;
var bitsToken = slash >= 0 ? line[(slash + 1)..] : default;
if (addr.IndexOf((byte)':') < 0)
{
// IPv4 single or CIDR — parsed straight from the byte span.
if (slash >= 0)
{
if (IPAddressUtility.TryParseV4(addr, out var ip) &&
TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 32)
{
var size = bits == 0 ? 0xFFFFFFFFu : (1u << (32 - bits)) - 1;
var b = ip & ~size;
v4.Add(new SortedRangeIndex<uint>.Range(b, b + size));
parsed++;
}
else
{
skipped++;
}
}
else if (IPAddressUtility.TryParseV4(addr, out var ip))
{
v4.Add(new SortedRangeIndex<uint>.Range(ip, ip));
parsed++;
}
else
{
skipped++;
}
}
else if (TryDecodeV6(addr, out var v))
{
// IPv6 is rare in these feeds; the single token was decoded and framework-parsed above.
if (slash >= 0)
{
if (TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 128)
{
var mask = bits == 0 ? UInt128.Zero : ~((UInt128.One << (128 - bits)) - 1);
var b = v & mask;
v6.Add(new SortedRangeIndex<UInt128>.Range(b, b | ~mask));
parsed++;
}
else
{
skipped++;
}
}
else
{
v6.Add(new SortedRangeIndex<UInt128>.Range(v, v));
parsed++;
}
}
else
{
skipped++;
}
}
v4.Sort(SortedRangeIndex<uint>.ByMin);
v6.Sort(SortedRangeIndex<UInt128>.ByMin);
return new BlocklistSnapshot(SortedRangeIndex<uint>.Build(v4.AsSpan()), SortedRangeIndex<UInt128>.Build(v6.AsSpan()));
}
// Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser.
private static bool TryDecodeV6(ReadOnlySpan<byte> addr, out UInt128 v)
{
v = UInt128.Zero;
if (addr.Length > 45)
{
return false;
}
Span<char> chars = stackalloc char[addr.Length];
for (var i = 0; i < addr.Length; i++)
{
chars[i] = (char)addr[i];
}
if (!IPAddress.TryParse(chars, out var a) || a.AddressFamily != AddressFamily.InterNetworkV6)
{
return false;
}
v = a.ToUInt128();
return true;
}
private static bool TryParseBits(ReadOnlySpan<byte> token, out int bits)
{
if (Utf8Parser.TryParse(token, out bits, out var consumed) && consumed == token.Length)
{
return true;
}
bits = 0;
return false;
}
public bool IsBanned(IPAddress ip)
{
if (ip.IsIPv4MappedToIPv6)
{
// v6-encoded v4 must not dodge the v4 set; extract the embedded v4 uint directly.
return IPAddressUtility.TryMappedV4(ip, out var mv) && _v4.Contains(mv);
}
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
return IPAddressUtility.TryV4(ip, out var v) && _v4.Contains(v);
}
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
{
return _v6.Contains(ip.ToUInt128());
}
return false;
}
}

View file

@ -0,0 +1,155 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: FileBlocklist.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Server.Logging;
namespace Server.Network.Bans.Blocklist;
/// <summary>
/// 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.
/// </summary>
public static class FileBlocklist
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileBlocklist));
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;
public static int Count => _snapshot.Count;
public static void Configure()
{
BanConfiguration.Configure();
var s = BanConfiguration.Settings;
_path = s.BlocklistFile;
_interval = s.BlocklistReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.BlocklistReloadInterval;
}
public static void Start(CancellationToken token)
{
if (string.IsNullOrWhiteSpace(_path))
{
logger.Information("FileBlocklist disabled (blocklistFile empty in bans.json)");
return;
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
Reload(); // synchronous prime; empty on failure (fail-open)
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
}
public static void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
public static bool IsBanned(IPAddress ip) => _snapshot.IsBanned(ip);
// Test hook: inject a snapshot without file I/O.
public static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot;
private static async ValueTask PollLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(_interval, token);
}
catch (OperationCanceledException)
{
return;
}
try
{
if (ChangedSinceLastLoad())
{
while (World.Saving || World.WorldState == WorldState.PendingSave)
{
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
Reload();
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
logger.Warning(e, "FileBlocklist reload check failed; keeping last snapshot ({Count})", Count);
}
}
}
private static bool ChangedSinceLastLoad()
{
try
{
var info = new FileInfo(_path);
if (!info.Exists)
{
return false;
}
if (info.LastWriteTimeUtc == _lastWriteUtc)
{
return false; // cheapest guard
}
}
catch
{
return false;
}
return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated;
}
private static 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
// mid-parse, the markers describe the old-or-equal version, so the next poll detects the change
// and reloads again -- this errs toward reloading and never skips a version.
var writeUtc = default(DateTime);
try
{
writeUtc = new FileInfo(_path).LastWriteTimeUtc;
}
catch
{
/* keep default */
}
BlocklistFile.TryReadHeader(_path, out var h);
var next = BlocklistFile.Load(_path, out var parsed, out var skipped);
_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}",
parsed, next.Count, skipped, h.Generated);
}
}

View file

@ -0,0 +1,55 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: PromotedGuard.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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
namespace Server.Network.Bans.Blocklist;
/// <summary>Suppresses re-reporting the same IP within a TTL. Accept-path thread only.</summary>
public sealed class PromotedGuard
{
private readonly Dictionary<UInt128, long> _expiry = new();
public bool TryMark(UInt128 ip, long nowTicks, long ttlMs)
{
if (_expiry.TryGetValue(ip, out var exp) && exp - nowTicks > 0)
{
return false;
}
_expiry[ip] = nowTicks + ttlMs;
return true;
}
public void Sweep(long nowTicks)
{
if (_expiry.Count == 0)
{
return;
}
using var dead = Collections.PooledRefQueue<UInt128>.Create();
foreach (var (ip, exp) in _expiry)
{
if (exp - nowTicks <= 0)
{
dead.Enqueue(ip);
}
}
while (dead.Count > 0)
{
_expiry.Remove(dead.Dequeue());
}
}
}