From 4ec488f59b473c0713636c8d12fca3a79146de84 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:37:19 -0700 Subject: [PATCH] refactor(network): collapse Firewall to a single-threaded persisted store Merge the old Firewall engine, AdminFirewall (parsing/persistence), and the runtime TTL sweep into one single-threaded store: no locks/version-counter, main-thread TTL expiry, and persistence to Configuration/firewall.json (with a one-time firewall.cfg migration). Lookups go through a shared, coalesced SortedRangeIndex (binary search); IP conversion/parse helpers are collected in IPAddressUtility. Firewall keeps IFirewallEntry for admin/gump/persistence. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Firewall/FirewallPersistenceTests.cs | 71 +++ .../Tests/Network/Firewall/FirewallTests.cs | 156 +++---- .../Server/Collections/SortedRangeIndex.cs | 134 ++++++ Projects/Server/Network/Firewall/Firewall.cs | 437 +++++++++++++----- .../Network/Firewall/FirewallSettings.cs | 42 ++ Projects/Server/Utilities/IPAddressUtility.cs | 190 ++++++++ Projects/Server/Utilities/Utility.cs | 39 -- Projects/UOContent/Misc/AdminFirewall.cs | 139 ------ 8 files changed, 825 insertions(+), 383 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs create mode 100644 Projects/Server/Collections/SortedRangeIndex.cs create mode 100644 Projects/Server/Network/Firewall/FirewallSettings.cs create mode 100644 Projects/Server/Utilities/IPAddressUtility.cs delete mode 100644 Projects/UOContent/Misc/AdminFirewall.cs diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/Server.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs new file mode 100644 index 000000000..f925cd5b4 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs @@ -0,0 +1,71 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallPersistenceTests.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.Net; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network.Firewall; + +[Collection("Sequential Server Tests")] +public class FirewallPersistenceTests +{ + [Fact] + public void ToSettings_RoundTrips_PermanentAndTtl() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("2.2.2.2")), TimeSpan.FromHours(1)); + + var settings = Server.Network.Firewall.ToSettings(); + + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.LoadFrom(settings); + + Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("1.2.3.4"))); + Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("2.2.2.2"))); + } + + [Fact] + public void LoadFrom_SkipsAlreadyExpired() + { + Server.Network.Firewall.ResetForTesting(); + var settings = new FirewallSettings + { + Entries = + [ + new FirewallEntryRecord { Value = "9.9.9.9", Expires = DateTime.UtcNow.AddHours(-1) } + ] + }; + + Server.Network.Firewall.LoadFrom(settings); + + Assert.False(Server.Network.Firewall.IsBlocked(IPAddress.Parse("9.9.9.9"))); + } + + [Fact] + public void ToSettings_OmitsExpiryForPermanent() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); + + var settings = Server.Network.Firewall.ToSettings(); + + Assert.Single(settings.Entries); + Assert.Null(settings.Entries[0].Expires); + Assert.Equal("1.2.3.4", settings.Entries[0].Value); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs index f803ee586..ca4c245d0 100644 --- a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs +++ b/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -1,137 +1,89 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallTests.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.Net; -using System.Threading.Tasks; using Server.Network; using Xunit; -namespace Server.Tests; +namespace Server.Tests.Network.Firewall; +[Collection("Sequential Server Tests")] public class FirewallTests { + private static IPAddress Ip(string s) => IPAddress.Parse(s); + [Fact] - public void Firewall_BlocksIPAddress_WhenAdded() + public void Add_ThenIsBlocked_SingleIp() { - var ip = IPAddress.Parse("192.168.1.1"); - var entry = new SingleIpFirewallEntry("192.168.1.1"); - - Assert.False(Firewall.IsBlocked(ip)); - - Firewall.Add(entry); - - Assert.True(Firewall.IsBlocked(ip)); + Server.Network.Firewall.ResetForTesting(); + Assert.True(Server.Network.Firewall.Add(new SingleIpFirewallEntry(Ip("1.2.3.4")))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.5"))); } [Fact] - public void Firewall_DoesNotBlockIPAddress_WhenNotAdded() + public void Add_Range_BlocksInside_NotOutside() { - var ip = IPAddress.Parse("192.168.1.2"); - Assert.False(Firewall.IsBlocked(ip)); + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new CidrFirewallEntry(Ip("10.0.0.0"), Ip("10.0.0.255"))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("10.0.0.7"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("10.0.1.0"))); } [Fact] - public void Firewall_StopsBlockingIPAddress_WhenRemoved() + public void Remove_Unblocks() { - var ip = IPAddress.Parse("192.168.1.3"); - var entry = new SingleIpFirewallEntry("192.168.1.3"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); + Server.Network.Firewall.ResetForTesting(); + var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); + Server.Network.Firewall.Add(entry); + Assert.True(Server.Network.Firewall.Remove(entry)); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); } [Fact] - public void Firewall_BlocksIPRange() + public void ExpireEntries_RemovesExpired_KeepsPermanent() { - var entry = new CidrFirewallEntry(IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.5")); + Server.Network.Firewall.ResetForTesting(); + var permanent = new SingleIpFirewallEntry(Ip("1.1.1.1")); + var temporary = new SingleIpFirewallEntry(Ip("2.2.2.2")); + Server.Network.Firewall.Add(permanent); // no ttl + Server.Network.Firewall.Add(temporary, TimeSpan.FromMilliseconds(50)); // ttl - Firewall.Add(entry); + Server.Network.Firewall.ExpireEntries(Core.TickCount + 100); // past the ttl - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.1"))); - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.3"))); - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.5"))); - - Assert.False(Firewall.IsBlocked(IPAddress.Parse("10.0.0.6"))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.1.1.1"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("2.2.2.2"))); } [Fact] - public void Firewall_CacheInvalidation_WorksOnUpdate() + public void ToFirewallEntry_ParsesForms() { - var ip = IPAddress.Parse("192.168.1.10"); - var entry = new SingleIpFirewallEntry("192.168.1.10"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("1.2.3.4")); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0/24")); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0-10.0.0.255")); + Assert.Null(Server.Network.Firewall.ToFirewallEntry("not-an-ip")); } [Fact] - public void Firewall_ReadsFirewallSetCorrectly() + public void ReadFirewallSet_SurfacesAddedEntries() { - var entry = new SingleIpFirewallEntry("172.16.0.1"); - Firewall.Add(entry); + Server.Network.Firewall.ResetForTesting(); + var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); + Server.Network.Firewall.Add(entry); - var found = false; - Firewall.ReadFirewallSet(set => - { - found = set.Contains(entry); - }); - - Assert.True(found); - } - - [Fact] - public void Firewall_IsThreadSafe() - { - var testIps = new IPAddress[256]; - for (var i = 0; i <= 255; i++) - { - testIps[i] = IPAddress.Parse($"192.168.0.{i}"); - } - - var entry = new CidrFirewallEntry(IPAddress.Parse("192.168.0.1"), IPAddress.Parse("192.168.0.255")); - Firewall.Add(entry); - - Parallel.ForEach(testIps, ip => - { - var shouldBlock = int.Parse(ip.ToString().Split('.')[3]) is > 0; - Assert.Equal(shouldBlock, Firewall.IsBlocked(ip)); - }); - - Firewall.Remove(entry); - - Parallel.ForEach(testIps, ip => - { - Assert.False(Firewall.IsBlocked(ip)); - }); - } - - [Fact] - public void Firewall_DoesNotThrowWhenRemovingNonExistentEntry() - { - var entry = new SingleIpFirewallEntry("203.0.113.5"); - Assert.False(Firewall.Remove(entry)); - } - - [Fact] - public void Firewall_CacheHandlesMultipleUpdates() - { - var ip = IPAddress.Parse("192.168.1.20"); - var entry = new SingleIpFirewallEntry("192.168.1.20"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); + Server.Network.Firewall.ReadFirewallSet(set => Assert.Contains(entry, set)); } } diff --git a/Projects/Server/Collections/SortedRangeIndex.cs b/Projects/Server/Collections/SortedRangeIndex.cs new file mode 100644 index 000000000..5af37fd59 --- /dev/null +++ b/Projects/Server/Collections/SortedRangeIndex.cs @@ -0,0 +1,134 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SortedRangeIndex.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.Numerics; + +namespace Server.Collections; + +/// +/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are +/// stored as two parallel arrays (_mins/_maxs) sorted by minimum and coalesced into +/// disjoint runs, so a single binary search decides membership. Coalescing is required for +/// correctness: only inspects the rightmost run whose minimum is <= the +/// value, which is only sound when the runs never overlap or nest. +/// +public sealed class SortedRangeIndex where T : IBinaryInteger +{ + public static readonly SortedRangeIndex Empty = new([], []); + + /// An inclusive [Min, Max] range. Singles are represented as Min == Max. + public readonly record struct Range(T Min, T Max); + + /// Orders ranges ascending by minimum; the coalescing pass in requires this. + public static readonly Comparison ByMin = static (a, b) => a.Min.CompareTo(b.Min); + + private readonly T[] _mins; + private readonly T[] _maxs; + + private SortedRangeIndex(T[] mins, T[] maxs) + { + _mins = mins; + _maxs = maxs; + } + + public int Count => _mins.Length; + + /// + /// True when falls in any range. Binary-searches for the rightmost run + /// whose minimum is <= the value, then tests that value against that run's maximum. + /// + public bool Contains(T value) + { + var lo = 0; + var hi = _mins.Length - 1; + var found = -1; + while (lo <= hi) + { + var mid = (lo + hi) >> 1; + if (_mins[mid] <= value) + { + found = mid; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return found >= 0 && value <= _maxs[found]; + } + + /// + /// Builds an index from ranges that are already sorted ascending by (sort + /// the source with first). Overlapping and nested ranges are merged into disjoint + /// runs. Two passes over the (pooled) input keep the run count exact so only the two final arrays are + /// heap-allocated: pass one counts the runs, pass two fills the exact-size arrays. + /// + public static SortedRangeIndex Build(ReadOnlySpan sortedByMin) + { + if (sortedByMin.IsEmpty) + { + return Empty; + } + + // Pass 1: count the disjoint runs so the final arrays can be sized exactly. + var runs = 1; + var curMax = sortedByMin[0].Max; + for (var i = 1; i < sortedByMin.Length; i++) + { + var r = sortedByMin[i]; + if (r.Min <= curMax) + { + if (r.Max > curMax) + { + curMax = r.Max; + } + } + else + { + runs++; + curMax = r.Max; + } + } + + // Pass 2: write the coalesced runs into the exact-size final arrays. + var mins = new T[runs]; + var maxs = new T[runs]; + var w = 0; + mins[0] = sortedByMin[0].Min; + maxs[0] = sortedByMin[0].Max; + for (var i = 1; i < sortedByMin.Length; i++) + { + var r = sortedByMin[i]; + if (r.Min <= maxs[w]) + { + if (r.Max > maxs[w]) + { + maxs[w] = r.Max; + } + } + else + { + w++; + mins[w] = r.Min; + maxs[w] = r.Max; + } + } + + return new SortedRangeIndex(mins, maxs); + } +} diff --git a/Projects/Server/Network/Firewall/Firewall.cs b/Projects/Server/Network/Firewall/Firewall.cs index ffb778aa8..d86cc8f97 100644 --- a/Projects/Server/Network/Firewall/Firewall.cs +++ b/Projects/Server/Network/Firewall/Firewall.cs @@ -14,120 +14,118 @@ *************************************************************************/ using System; -using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Net; using System.Runtime.CompilerServices; -using System.Threading; +using Server.Collections; +using Server.Json; +using Server.Logging; namespace Server.Network; public static class Firewall { - [ThreadStatic] - private static InternalValidationEntry _validationEntry; - private static readonly ConcurrentDictionary _isBlockedCache = []; - private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion); + // Single-threaded: the accept path, admin gump/command, TTL expiry timer, and boot load all run on + // the main game loop. No locks, caches, or version counters are needed. See the ban-channel design doc. + // _entries is the authoritative store (gump/persistence/TTL/command all work against it); _index is a + // derived, rebuild-on-demand SortedRangeIndex used only for the accept-path IsBlocked lookup, shared + // with the same sorted-range binary-search primitive the blocklist uses (see BlocklistSnapshot). + private static readonly List _entries = []; - private static int _firewallVersion; - private static readonly SortedSet _firewallSet = []; + // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. + private static readonly Dictionary _expiring = new(); - public static int FirewallSetCount => _firewallSet.Count; + private static SortedRangeIndex _index = SortedRangeIndex.Empty; + private static bool _indexDirty; - public static void ReadFirewallSet(Action> callback) + private static readonly ILogger logger = LogFactory.GetLogger(typeof(Firewall)); + private const string _path = "Configuration/firewall.json"; + private const string _legacyPath = "firewall.cfg"; + private static bool _dirty; + private static bool _configured; + + public static int FirewallSetCount => _entries.Count; + + public static void ReadFirewallSet(Action> callback) => callback(_entries); + + public static bool IsBlocked(IPAddress address) { - _firewallLock.EnterReadLock(); - try - { - callback(_firewallSet); - } - finally - { - _firewallLock.ExitReadLock(); - } - } - - internal static bool IsBlocked(IPAddress address) - { - if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion) - { - return true; - } - - if (_validationEntry == null) - { - _validationEntry = new InternalValidationEntry(address); - } - else - { - _validationEntry.Address = address; - } - - if (CheckBlocked(_validationEntry)) - { - _isBlockedCache[address] = _firewallVersion; - return true; - } - - return false; - } - - private static bool CheckBlocked(IFirewallEntry validationEntry) - { - if (_firewallSet.Count == 0) + if (_entries.Count == 0) { return false; } - _firewallLock.EnterReadLock(); - try - { - var min = _firewallSet.Min; - if (validationEntry.CompareTo(min) < 0) - { - return false; - } - - // Get all entries that are lower than our validation entry - var view = _firewallSet.GetViewBetween(min, validationEntry); - - // Loop backward since there shouldn't be any entries where the Min address is higher than ours - foreach (var firewallEntry in view.Reverse()) - { - if (firewallEntry.IsBlocked(validationEntry.MinIpAddress)) - { - return true; - } - } - - return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true; - } - finally - { - _firewallLock.ExitReadLock(); - } + EnsureIndex(); + return _index.Contains(address.ToUInt128()); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool Add(IFirewallEntry firewallEntry) + // Rebuilds the derived lookup index from the authoritative _entries list, but only when entries have + // changed since the last build. Runs on the main game loop, so the pooled build buffer is single-threaded + // (mt: false); only the two final SortedRangeIndex arrays are heap-allocated. + private static void EnsureIndex() { - _firewallLock.EnterWriteLock(); - try + if (!_indexDirty) { - if (_firewallSet.Add(firewallEntry)) + return; + } + + using var ranges = PooledRefList.Range>.Create(_entries.Count, mt: false); + foreach (var entry in _entries) + { + ranges.Add(new SortedRangeIndex.Range(entry.MinIpAddress, entry.MaxIpAddress)); + } + + ranges.Sort(SortedRangeIndex.ByMin); + _index = SortedRangeIndex.Build(ranges.AsSpan()); + _indexDirty = false; + } + + public static bool Add(IFirewallEntry firewallEntry) => Add(firewallEntry, TimeSpan.Zero); + + /// + /// Adds an entry. <= means permanent. Returns false + /// if the entry was already present. + /// + // Indexed scan (no closure allocation); firewall lists are small, so O(n) is negligible and this + // stays off the hot path (Add/Remove are admin/boot actions, not the accept path). + private static int IndexOfEntry(IFirewallEntry entry) + { + for (var i = 0; i < _entries.Count; i++) + { + if (_entries[i].CompareTo(entry) == 0) { - Interlocked.Increment(ref _firewallVersion); // Update version - return true; + return i; } + } + + return -1; + } + + public static bool Add(IFirewallEntry firewallEntry, TimeSpan ttl, bool persist = true) + { + if (firewallEntry == null || IndexOfEntry(firewallEntry) >= 0) + { return false; } - finally + + _entries.Add(firewallEntry); + + if (ttl > TimeSpan.Zero) { - _firewallLock.ExitWriteLock(); + _expiring[firewallEntry] = Core.TickCount + (long)ttl.TotalMilliseconds; } + + _indexDirty = true; + + if (persist) + { + MarkDirty(); + } + + return true; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool Remove(IFirewallEntry entry) { if (entry == null) @@ -135,34 +133,267 @@ public static class Firewall return false; } - _firewallLock.EnterWriteLock(); - try + var index = IndexOfEntry(entry); + if (index < 0) { - if (_firewallSet.Remove(entry)) - { - Interlocked.Increment(ref _firewallVersion); // Update version - return true; - } return false; } - finally + + // Remove the stored instance from _expiring (not the passed reference), so a value-equal + // entry created elsewhere still clears the TTL bookkeeping. + var stored = _entries[index]; + _entries.RemoveAt(index); + _expiring.Remove(stored); + _indexDirty = true; + MarkDirty(); + return true; + } + + /// + /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer (Task 2). + /// + internal static void ExpireEntries(long nowTicks) + { + if (_expiring.Count == 0) { - _firewallLock.ExitWriteLock(); + return; + } + + List expired = null; + foreach (var (entry, expiresAt) in _expiring) + { + if (expiresAt - nowTicks <= 0) + { + (expired ??= []).Add(entry); + } + } + + if (expired == null) + { + return; + } + + foreach (var entry in expired) + { + _entries.Remove(entry); + _expiring.Remove(entry); + } + + _indexDirty = true; + MarkDirty(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IFirewallEntry ToFirewallEntry(object entry) => + entry switch + { + IFirewallEntry firewallEntry => firewallEntry, + IPAddress address => new SingleIpFirewallEntry(address), + string s => ToFirewallEntry(s), + _ => null + }; + + public static IFirewallEntry ToFirewallEntry(string entry) + { + if (entry == null) + { + return null; + } + + try + { + var rangeSeparator = entry.IndexOf('-'); + if (rangeSeparator > -1) + { + return new CidrFirewallEntry( + IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), + IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) + ); + } + + if (entry.IndexOf('/') > -1) + { + return new CidrFirewallEntry(entry); + } + + return new SingleIpFirewallEntry(entry); + } + catch + { + return null; } } - private class InternalValidationEntry : BaseFirewallEntry + public static void Configure() { - private UInt128 _address; - - public IPAddress Address + if (_configured) { - set => _address = value.ToUInt128(); + return; + } + _configured = true; + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + LoadFrom(JsonConfig.Deserialize(path)); + } + else + { + var legacyPath = ResolveLegacyCfgPath(); + if (legacyPath != null) + { + MigrateLegacyCfg(legacyPath); + Save(); // materialize firewall.json; the .cfg is no longer read after this + TryMarkLegacyCfgMigrated(legacyPath); + } } - public override UInt128 MinIpAddress => _address; - public override UInt128 MaxIpAddress => _address; + // Main-thread maintenance: expire TTLs and flush pending writes. No background thread. + Timer.DelayCall(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), Maintenance); + } - public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress; + private static void Maintenance() + { + ExpireEntries(Core.TickCount); + + if (_dirty) + { + Save(); + } + } + + private static void MarkDirty() => _dirty = true; + + internal static void LoadFrom(FirewallSettings settings) + { + if (settings?.Entries == null) + { + return; + } + + var now = DateTime.UtcNow; + foreach (var record in settings.Entries) + { + var entry = ToFirewallEntry(record.Value); + if (entry == null) + { + logger.Warning("Ignoring unparseable firewall entry \"{Entry}\"", record.Value); + continue; + } + + var ttl = TimeSpan.Zero; + if (record.Expires is { } expires) + { + ttl = expires - now; + if (ttl <= TimeSpan.Zero) + { + continue; // already expired + } + } + + Add(entry, ttl, persist: false); + } + } + + internal static FirewallSettings ToSettings() + { + var now = DateTime.UtcNow; + var nowTicks = Core.TickCount; + var list = new List(_entries.Count); + + foreach (var entry in _entries) + { + DateTime? expires = null; + if (_expiring.TryGetValue(entry, out var expiresAtTick)) + { + expires = now.AddMilliseconds(expiresAtTick - nowTicks); + } + + list.Add(new FirewallEntryRecord { Value = entry.ToString(), Expires = expires }); + } + + return new FirewallSettings { Entries = list.ToArray() }; + } + + public static void Save() + { + _dirty = false; + var path = Path.Join(Core.BaseDirectory, _path); + var tmp = path + ".tmp"; + JsonConfig.Serialize(tmp, ToSettings()); + File.Move(tmp, path, overwrite: true); // atomic swap + } + + /// + /// Locates the legacy firewall.cfg to migrate. The modern convention is , + /// checked first; the pre-collapse AdminFirewall used a bare relative path (resolved against the + /// process's current working directory), which may differ from when the + /// shard is launched from elsewhere, so that's checked as a fallback. Returns null if neither exists. + /// + private static string ResolveLegacyCfgPath() + { + var underBaseDirectory = Path.Join(Core.BaseDirectory, _legacyPath); + if (File.Exists(underBaseDirectory)) + { + return underBaseDirectory; + } + + return File.Exists(_legacyPath) ? _legacyPath : null; + } + + private static void MigrateLegacyCfg(string legacyPath) + { + var searchValues = System.Buffers.SearchValues.Create("*Xx?"); + + using var reader = new StreamReader(legacyPath); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length == 0) + { + continue; + } + + if (line.AsSpan().ContainsAny(searchValues)) + { + logger.Warning("Legacy firewall entry \"{Entry}\" ignored during migration", line); + continue; + } + + var entry = ToFirewallEntry(line); + if (entry != null) + { + Add(entry, TimeSpan.Zero, persist: false); + } + } + + logger.Information("Migrated {Count} entr(ies) from legacy firewall.cfg to firewall.json", _entries.Count); + } + + /// + /// Renames the migrated .cfg to firewall.cfg.migrated so it isn't re-scanned on the next + /// boot and operators can see it was already migrated. Best-effort: a locked/read-only file must not + /// fail startup, since the migration itself (firewall.json) already succeeded. + /// + private static void TryMarkLegacyCfgMigrated(string legacyPath) + { + try + { + File.Move(legacyPath, legacyPath + ".migrated", overwrite: true); + } + catch (Exception e) + { + logger.Warning(e, "Could not rename migrated legacy firewall file \"{Path}\"", legacyPath); + } + } + + internal static void ResetForTesting() + { + _entries.Clear(); + _expiring.Clear(); + _index = SortedRangeIndex.Empty; + _indexDirty = false; + _configured = false; } } diff --git a/Projects/Server/Network/Firewall/FirewallSettings.cs b/Projects/Server/Network/Firewall/FirewallSettings.cs new file mode 100644 index 000000000..dd0281c92 --- /dev/null +++ b/Projects/Server/Network/Firewall/FirewallSettings.cs @@ -0,0 +1,42 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallSettings.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.Text.Json.Serialization; + +namespace Server.Network; + +/// +/// Persisted local firewall entries (manual admin bans). Stored at Configuration/firewall.json. +/// Auto-detected rate-limit trips are never persisted — they are contributed to CrowdSec, not stored here. +/// +public record FirewallSettings +{ + [JsonPropertyName("entries")] + public FirewallEntryRecord[] Entries { get; set; } = []; +} + +/// +/// One persisted entry. is a single IP, a min-max range, or CIDR. +/// is UTC wall-clock; null means permanent. +/// +public record FirewallEntryRecord +{ + [JsonPropertyName("value")] + public string Value { get; set; } + + [JsonPropertyName("expires")] + public DateTime? Expires { get; set; } +} diff --git a/Projects/Server/Utilities/IPAddressUtility.cs b/Projects/Server/Utilities/IPAddressUtility.cs new file mode 100644 index 000000000..757dfd99c --- /dev/null +++ b/Projects/Server/Utilities/IPAddressUtility.cs @@ -0,0 +1,190 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IPAddressUtility.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.Binary; +using System.Net; +using System.Net.Sockets; +using System.Numerics; + +namespace Server; + +/// +/// Low-level IPAddress conversion and parsing helpers shared by the firewall, ban channel, and +/// blocklist. All members are allocation-free (stack buffers only) so they are safe on hot accept +/// paths and inside tight parse loops. +/// +public static class IPAddressUtility +{ + // Converts an IPAddress to a UInt128 in IPv6 format + public static UInt128 ToUInt128(this IPAddress ip) + { + if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) + { + Span integer = stackalloc byte[4]; + return !ip.TryWriteBytes(integer, out _) + ? (UInt128)0 + : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); + } + + Span bytes = stackalloc byte[16]; + if (!ip.TryWriteBytes(bytes, out _)) + { + return 0; + } + + var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); + var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); + + return new UInt128(high, low); + } + + // Converts a UInt128 in IPv6 format to an IPAddress + public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) + { + // IPv4 mapped IPv6 address + if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) + { + var newAddress = IPAddress.HostToNetworkOrder((int)value); + return new IPAddress(unchecked((uint)newAddress)); + } + + Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address + ((IBinaryInteger)value).WriteBigEndian(bytes); + + return new IPAddress(bytes); + } + + /// Extracts the big-endian uint of an address. + public static bool TryV4(IPAddress ip, out uint v) + { + Span b = stackalloc byte[4]; + if (ip.TryWriteBytes(b, out var n) && n == 4) + { + v = ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; + return true; + } + + v = 0; + return false; + } + + /// + /// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes, + /// avoiding the allocation of . + /// + public static bool TryMappedV4(IPAddress ip, out uint v) + { + Span b = stackalloc byte[16]; + if (ip.TryWriteBytes(b, out var n) && n == 16) + { + v = ((uint)b[12] << 24) | ((uint)b[13] << 16) | ((uint)b[14] << 8) | b[15]; + return true; + } + + v = 0; + return false; + } + + /// Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict. + public static bool TryParseV4(ReadOnlySpan s, out uint v) + { + v = 0; + uint acc = 0; + int octet = 0, digits = 0, dots = 0; + foreach (var c in s) + { + if (c == '.') + { + if (digits == 0 || octet > 255) + { + return false; + } + + acc = (acc << 8) | (uint)octet; + dots++; + octet = 0; + digits = 0; + } + else if (c is >= '0' and <= '9') + { + octet = octet * 10 + (c - '0'); + if (++digits > 3) + { + return false; + } + } + else + { + return false; + } + } + + if (dots != 3 || digits == 0 || octet > 255) + { + return false; + } + + v = (acc << 8) | (uint)octet; + return true; + } + + /// + /// UTF-8/ASCII byte overload of , mirroring its + /// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no + /// per-line string allocation. + /// + public static bool TryParseV4(ReadOnlySpan s, out uint v) + { + v = 0; + uint acc = 0; + int octet = 0, digits = 0, dots = 0; + foreach (var c in s) + { + if (c == (byte)'.') + { + if (digits == 0 || octet > 255) + { + return false; + } + + acc = (acc << 8) | (uint)octet; + dots++; + octet = 0; + digits = 0; + } + else if (c is >= (byte)'0' and <= (byte)'9') + { + octet = octet * 10 + (c - '0'); + if (++digits > 3) + { + return false; + } + } + else + { + return false; + } + } + + if (dots != 3 || digits == 0 || octet > 255) + { + return false; + } + + v = (acc << 8) | (uint)octet; + return true; + } +} diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 4f33220aa..f3786d834 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -108,45 +108,6 @@ public static partial class Utility } } - // Converts an IPAddress to a UInt128 in IPv6 format - public static UInt128 ToUInt128(this IPAddress ip) - { - if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) - { - Span integer = stackalloc byte[4]; - return !ip.TryWriteBytes(integer, out _) - ? (UInt128)0 - : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); - } - - Span bytes = stackalloc byte[16]; - if (!ip.TryWriteBytes(bytes, out _)) - { - return 0; - } - - var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); - var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); - - return new UInt128(high, low); - } - - // Converts a UInt128 in IPv6 format to an IPAddress - public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) - { - // IPv4 mapped IPv6 address - if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) - { - var newAddress = IPAddress.HostToNetworkOrder((int)value); - return new IPAddress(unchecked((uint)newAddress)); - } - - Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address - ((IBinaryInteger)value).WriteBigEndian(bytes); - - return new IPAddress(bytes); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static UInt128 CreateCidrAddress(ReadOnlySpan bytes, int prefixLength, bool isMax) { diff --git a/Projects/UOContent/Misc/AdminFirewall.cs b/Projects/UOContent/Misc/AdminFirewall.cs deleted file mode 100644 index a4275d90c..000000000 --- a/Projects/UOContent/Misc/AdminFirewall.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.Net; -using System.Runtime.CompilerServices; -using Server.Logging; -using Server.Network; - -namespace Server; - -public static class AdminFirewall -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(AdminFirewall)); - - private const string firewallConfigPath = "firewall.cfg"; - - public static void Configure() - { - if (File.Exists(firewallConfigPath)) - { - var searchValues = SearchValues.Create("*Xx?"); - - using var ip = new StreamReader(firewallConfigPath); - - while (ip.ReadLine() is { } line) - { - line = line.Trim(); - - if (line.Length == 0) - { - continue; - } - - if (line.AsSpan().ContainsAny(searchValues)) - { - logger.Warning("Legacy firewall entry \"{Entry}\" ignored", line); - continue; - } - - Add(ToFirewallEntry(line), false); - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IFirewallEntry ToFirewallEntry(object entry) - { - return entry switch - { - IFirewallEntry firewallEntry => firewallEntry, - IPAddress address => new SingleIpFirewallEntry(address), - string s => ToFirewallEntry(s), - _ => null - }; - } - - public static IFirewallEntry ToFirewallEntry(string entry) - { - if (entry == null) - { - return null; - } - - try - { - var rangeSeparator = entry.IndexOf('-'); - if (rangeSeparator > -1) - { - return new CidrFirewallEntry( - IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), - IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) - ); - } - - // CIDR notation - if (entry.IndexOf('/') > -1) - { - return new CidrFirewallEntry(entry); - } - - return new SingleIpFirewallEntry(entry); - } - catch - { - return null; - } - } - - public static bool Remove(object obj, bool save = true) - { - var entry = ToFirewallEntry(obj); - - if (entry == null) - { - return false; - } - - if (!Firewall.Remove(entry)) - { - return false; - } - - if (save) - { - Save(); - } - - return true; - } - - public static void Add(object obj) => Add(ToFirewallEntry(obj)); - - public static bool Add(IFirewallEntry entry, bool save = true) - { - if (!Firewall.Add(entry)) - { - return false; - } - - if (save) - { - Save(); - } - - return true; - } - - public static void Save() - { - Firewall.ReadFirewallSet(firewallSet => - { - using var op = new StreamWriter(firewallConfigPath); - foreach (var entry in firewallSet) - { - op.WriteLine(entry); - } - }); - } -}