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"
|
||||
}
|
||||
}
|
||||
84
Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs
Normal file
84
Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs
Normal file
42
Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs
Normal 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
|
||||
}
|
||||
}
|
||||
205
Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs
Normal file
205
Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
155
Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs
Normal file
155
Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
55
Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs
Normal file
55
Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue