diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs new file mode 100644 index 000000000..01a3707c7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs @@ -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 . * + *************************************************************************/ + +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 new file mode 100644 index 000000000..4ecd64ee7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs new file mode 100644 index 000000000..c732d5365 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs @@ -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 . * + *************************************************************************/ + +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); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs new file mode 100644 index 000000000..4cc83cdfd --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs @@ -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 . * + *************************************************************************/ + +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 + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs new file mode 100644 index 000000000..0f0e703e6 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs @@ -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 . * + *************************************************************************/ + +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"))); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs new file mode 100644 index 000000000..5f30818b7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs @@ -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 . * + *************************************************************************/ + +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" + } +} diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs new file mode 100644 index 000000000..6fbc4a12a --- /dev/null +++ b/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs @@ -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 . * + *************************************************************************/ + +using System; +using System.IO; + +namespace Server.Network.Bans.Blocklist; + +public readonly record struct BlocklistHeader(string Generated, int Count, bool Present); + +/// Reads the versioned blocklist file: cheap header probe + full snapshot load. +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; + } + } +} diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs new file mode 100644 index 000000000..682cbc8b0 --- /dev/null +++ b/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs @@ -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 . * + *************************************************************************/ + +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/Blocklist/BlocklistSnapshot.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs new file mode 100644 index 000000000..e2e6857a8 --- /dev/null +++ b/Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs @@ -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 . * + *************************************************************************/ + +using System; +using System.Buffers.Text; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Server.Collections; + +namespace Server.Network.Bans.Blocklist; + +/// +/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced +/// interval index per family: IPv4 as ranges (lean for the millions-strong common +/// case), IPv6 as ranges (empty unless the feed carries v6). Immutable → lock-free reads. +/// +public sealed class BlocklistSnapshot +{ + public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex.Empty, SortedRangeIndex.Empty); + + private readonly SortedRangeIndex _v4; + private readonly SortedRangeIndex _v6; + + public int Count => _v4.Count + _v6.Count; + + private BlocklistSnapshot(SortedRangeIndex v4, SortedRangeIndex v6) + { + _v4 = v4; + _v6 = v6; + } + + /// + /// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on + /// '\n' 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 and never throw. Build-time intermediates use + /// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread. + /// + public static BlocklistSnapshot Build(ReadOnlySpan 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.Range>.Create(mt: true); + using var v6 = PooledRefList.Range>.Create(mt: true); + + var rest = data; + while (!rest.IsEmpty) + { + ReadOnlySpan 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.Range(b, b + size)); + parsed++; + } + else + { + skipped++; + } + } + else if (IPAddressUtility.TryParseV4(addr, out var ip)) + { + v4.Add(new SortedRangeIndex.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.Range(b, b | ~mask)); + parsed++; + } + else + { + skipped++; + } + } + else + { + v6.Add(new SortedRangeIndex.Range(v, v)); + parsed++; + } + } + else + { + skipped++; + } + } + + v4.Sort(SortedRangeIndex.ByMin); + v6.Sort(SortedRangeIndex.ByMin); + return new BlocklistSnapshot(SortedRangeIndex.Build(v4.AsSpan()), SortedRangeIndex.Build(v6.AsSpan())); + } + + // Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser. + private static bool TryDecodeV6(ReadOnlySpan addr, out UInt128 v) + { + v = UInt128.Zero; + if (addr.Length > 45) + { + return false; + } + + Span 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 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; + } +} diff --git a/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs b/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs new file mode 100644 index 000000000..afb5b3a6d --- /dev/null +++ b/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs @@ -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 . * + *************************************************************************/ + +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +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. +/// +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); + } +} diff --git a/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs b/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs new file mode 100644 index 000000000..baec5dfbf --- /dev/null +++ b/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs @@ -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 . * + *************************************************************************/ + +using System; +using System.Collections.Generic; + +namespace Server.Network.Bans.Blocklist; + +/// Suppresses re-reporting the same IP within a TTL. Accept-path thread only. +public sealed class PromotedGuard +{ + private readonly Dictionary _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.Create(); + foreach (var (ip, exp) in _expiry) + { + if (exp - nowTicks <= 0) + { + dead.Enqueue(ip); + } + } + while (dead.Count > 0) + { + _expiry.Remove(dead.Dequeue()); + } + } +}