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);
- }
- });
- }
-}