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