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:
parent
6249a20f15
commit
de3cfa35b1
11 changed files with 857 additions and 0 deletions
|
|
@ -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 _));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue