refactor(network): move Firewall to UOContent; core keeps only the filter seam
Core now owns the question -- "should this socket be denied?" -- and none of the answers. The firewall was the last implementation left in core, and the reasons to keep it did not survive scrutiny: it is not extended downstream, and a shard running bare core has no way to populate it anyway, since the admin gump and the commands that mutate it are both content. Larger shards front the server with an upstream proxy or edge scrubbing and never use it; it survives as the fallback an admin reaches for over a single player, which is squarely content's concern. Nothing about the firewall changes for operators: same Server.Network namespace, same Configuration/firewall.json, same gump and commands, same legacy .cfg migration. It reaches the accept path through ConnectionFilters like any other filter, and registers itself first because an empty set is the cheapest gate. Untangling core from the firewall entry types first: - NetworkUtilities built its reserved-network tables out of CidrFirewallEntry, which made core depend on the firewall for something with nothing to do with banning. Those are constant CIDR blocks answering "is this address in one of these ranges?", so they are now a SortedRangeIndex<UInt128> -- the same primitive the firewall and blocklist already share. Same semantics, same public API, one linear scan replaced by a binary search. - The CIDR -> normalized range parse those tables needed is now IPAddressUtility.TryParseCidrRange, and CidrFirewallEntry drops its private copy of that logic in favor of it. Core no longer references IFirewallEntry or Firewall anywhere. 1344 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2be79d054a
commit
50c8287c7e
14 changed files with 98 additions and 57 deletions
|
|
@ -1,71 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BaseFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public abstract class BaseFirewallEntry : IFirewallEntry, ISpanFormattable
|
||||
{
|
||||
public abstract UInt128 MinIpAddress { get; }
|
||||
public abstract UInt128 MaxIpAddress { get; }
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool IsBlocked(IPAddress address) => IsBlocked(address.ToUInt128());
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool IsBlocked(UInt128 address) => address >= MinIpAddress && address <= MaxIpAddress;
|
||||
|
||||
public override string ToString() =>
|
||||
MinIpAddress == MaxIpAddress ? MinIpAddress.ToIpAddress().ToString()
|
||||
: $"{MinIpAddress.ToIpAddress()}-{MaxIpAddress.ToIpAddress()}";
|
||||
|
||||
public string ToString(string? format, IFormatProvider? formatProvider) =>
|
||||
// format and provider are explicitly ignored
|
||||
ToString();
|
||||
|
||||
public bool TryFormat(
|
||||
Span<char> destination,
|
||||
out int charsWritten,
|
||||
ReadOnlySpan<char> format,
|
||||
IFormatProvider? provider
|
||||
)
|
||||
{
|
||||
if (!((ISpanFormattable)MinIpAddress.ToIpAddress()).TryFormat(destination, out charsWritten, format, provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MinIpAddress == MaxIpAddress)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Range
|
||||
destination[charsWritten++] = '-';
|
||||
|
||||
var total = charsWritten;
|
||||
|
||||
if (!((ISpanFormattable)MaxIpAddress.ToIpAddress()).TryFormat(destination[charsWritten..], out charsWritten, format, provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
charsWritten += total;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: CidrFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public class CidrFirewallEntry : BaseFirewallEntry
|
||||
{
|
||||
public override UInt128 MinIpAddress { get; }
|
||||
public override UInt128 MaxIpAddress { get; }
|
||||
|
||||
public CidrFirewallEntry(string ipAddressOrCidr)
|
||||
: this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength)
|
||||
{
|
||||
}
|
||||
|
||||
public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress)
|
||||
{
|
||||
MinIpAddress = minAddress.ToUInt128();
|
||||
MaxIpAddress = maxAddress.ToUInt128();
|
||||
}
|
||||
|
||||
public CidrFirewallEntry(IPAddress ipAddress, int prefixLength)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
|
||||
if (ipAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
{
|
||||
prefixLength += 96; // 32 -> 128
|
||||
}
|
||||
|
||||
ipAddress.WriteMappedIPv6To(bytes);
|
||||
|
||||
MinIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, false);
|
||||
MaxIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, true);
|
||||
}
|
||||
|
||||
private static IPAddress ParseIPAddress(ReadOnlySpan<char> ipString, out int prefixLength)
|
||||
{
|
||||
var slashIndex = ipString.IndexOf('/');
|
||||
var ipAddress = IPAddress.Parse(slashIndex > -1 ? ipString[..slashIndex] : ipString);
|
||||
var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
|
||||
|
||||
if (slashIndex == -1)
|
||||
{
|
||||
prefixLength = maxPrefixLength;
|
||||
}
|
||||
else
|
||||
{
|
||||
var prefixPart = ipString[(slashIndex + 1)..];
|
||||
|
||||
if (!int.TryParse(prefixPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
|
||||
{
|
||||
throw new ArgumentException("Invalid prefix length.");
|
||||
}
|
||||
}
|
||||
|
||||
return ipAddress;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: Firewall.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Server.Collections;
|
||||
using Server.Json;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public static class Firewall
|
||||
{
|
||||
// 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<IFirewallEntry> _entries = [];
|
||||
|
||||
// Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent.
|
||||
private static readonly Dictionary<IFirewallEntry, long> _expiring = new();
|
||||
|
||||
private static SortedRangeIndex<UInt128> _index = SortedRangeIndex<UInt128>.Empty;
|
||||
private static bool _indexDirty;
|
||||
|
||||
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<IReadOnlyCollection<IFirewallEntry>> callback) => callback(_entries);
|
||||
|
||||
public static bool IsBlocked(IPAddress address)
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureIndex();
|
||||
return _index.Contains(address.ToUInt128());
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
if (!_indexDirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var ranges = PooledRefList<SortedRangeIndex<UInt128>.Range>.Create(_entries.Count, mt: false);
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
ranges.Add(new SortedRangeIndex<UInt128>.Range(entry.MinIpAddress, entry.MaxIpAddress));
|
||||
}
|
||||
|
||||
ranges.Sort(SortedRangeIndex<UInt128>.ByMin);
|
||||
_index = SortedRangeIndex<UInt128>.Build(ranges.AsSpan());
|
||||
_indexDirty = false;
|
||||
}
|
||||
|
||||
public static bool Add(IFirewallEntry firewallEntry) => Add(firewallEntry, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an entry. <paramref name="ttl"/> <= <see cref="TimeSpan.Zero"/> means permanent. Returns false
|
||||
/// if the entry was already present.
|
||||
/// </summary>
|
||||
// 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)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static bool Add(IFirewallEntry firewallEntry, TimeSpan ttl, bool persist = true)
|
||||
{
|
||||
if (firewallEntry == null || IndexOfEntry(firewallEntry) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_entries.Add(firewallEntry);
|
||||
|
||||
if (ttl > TimeSpan.Zero)
|
||||
{
|
||||
_expiring[firewallEntry] = Core.TickCount + (long)ttl.TotalMilliseconds;
|
||||
}
|
||||
|
||||
_indexDirty = true;
|
||||
|
||||
if (persist)
|
||||
{
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool Remove(IFirewallEntry entry)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var index = IndexOfEntry(entry);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer (Task 2).
|
||||
/// </summary>
|
||||
internal static void ExpireEntries(long nowTicks)
|
||||
{
|
||||
if (_expiring.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<IFirewallEntry> 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;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
if (_configured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_configured = true;
|
||||
|
||||
var path = Path.Join(Core.BaseDirectory, _path);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
LoadFrom(JsonConfig.Deserialize<FirewallSettings>(path));
|
||||
}
|
||||
else
|
||||
{
|
||||
var legacyPath = ResolveLegacyCfgPath();
|
||||
if (legacyPath != null)
|
||||
{
|
||||
MigrateLegacyCfg(legacyPath);
|
||||
Save(); // materialize firewall.json; the .cfg is no longer read after this
|
||||
TryMarkLegacyCfgMigrated(legacyPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Main-thread maintenance: expire TTLs and flush pending writes. No background thread.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), Maintenance);
|
||||
|
||||
// Expose the set to the accept path. Everything else (gump, commands, persistence) keeps using
|
||||
// the Firewall API directly; only the per-connection question goes through the filter registry.
|
||||
ConnectionFilters.Register(FirewallConnectionFilter.Instance);
|
||||
}
|
||||
|
||||
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<FirewallEntryRecord>(_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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates the legacy firewall.cfg to migrate. The modern convention is <see cref="Core.BaseDirectory"/>,
|
||||
/// checked first; the pre-collapse <c>AdminFirewall</c> used a bare relative path (resolved against the
|
||||
/// process's current working directory), which may differ from <see cref="Core.BaseDirectory"/> when the
|
||||
/// shard is launched from elsewhere, so that's checked as a fallback. Returns null if neither exists.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames the migrated <c>.cfg</c> to <c>firewall.cfg.migrated</c> 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.
|
||||
/// </summary>
|
||||
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<UInt128>.Empty;
|
||||
_indexDirty = false;
|
||||
_configured = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: FirewallConnectionFilter.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the admin-curated <see cref="Firewall"/> set to the accept path as an
|
||||
/// <see cref="IConnectionFilter"/>. The firewall keeps its own API (the admin gump and commands mutate
|
||||
/// it directly); this is only the accept-path adapter, since a static class cannot implement an
|
||||
/// interface. It registers from the core assembly, so it is consulted before any content filter — which
|
||||
/// is what we want, as it is the cheapest check (an empty set costs one length compare).
|
||||
/// </summary>
|
||||
internal sealed class FirewallConnectionFilter : IConnectionFilter
|
||||
{
|
||||
public static readonly FirewallConnectionFilter Instance = new();
|
||||
|
||||
private FirewallConnectionFilter()
|
||||
{
|
||||
}
|
||||
|
||||
public string Name => "firewall";
|
||||
|
||||
// Firewall.Configure() owns loading/persistence and does the registering, so there is nothing to do
|
||||
// here; Register() calling this back is harmless.
|
||||
public void Configure()
|
||||
{
|
||||
}
|
||||
|
||||
// Nothing to hydrate in the background: the set is loaded synchronously at Configure and maintained
|
||||
// by a main-loop timer.
|
||||
public void Start(CancellationToken token)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Flushes pending writes on the way down so a TTL expiry or late admin edit is not lost.</summary>
|
||||
public void Stop() => Firewall.Save();
|
||||
|
||||
public bool ShouldDeny(IPAddress address) => Firewall.IsBlocked(address);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/*************************************************************************
|
||||
* 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 <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted local firewall entries (manual admin bans). Stored at <c>Configuration/firewall.json</c>.
|
||||
/// Auto-detected rate-limit trips are never persisted — they are contributed to CrowdSec, not stored here.
|
||||
/// </summary>
|
||||
public record FirewallSettings
|
||||
{
|
||||
[JsonPropertyName("entries")]
|
||||
public FirewallEntryRecord[] Entries { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One persisted entry. <see cref="Value"/> is a single IP, a <c>min-max</c> range, or CIDR.
|
||||
/// <see cref="Expires"/> is UTC wall-clock; null means permanent.
|
||||
/// </summary>
|
||||
public record FirewallEntryRecord
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; }
|
||||
|
||||
[JsonPropertyName("expires")]
|
||||
public DateTime? Expires { get; set; }
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: IFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public interface IFirewallEntry : IComparable<IFirewallEntry>
|
||||
{
|
||||
UInt128 MinIpAddress { get; }
|
||||
UInt128 MaxIpAddress { get; }
|
||||
|
||||
int IComparable<IFirewallEntry>.CompareTo(IFirewallEntry? other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (MinIpAddress < other.MinIpAddress)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (MinIpAddress > other.MinIpAddress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (MaxIpAddress > other.MaxIpAddress)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (MaxIpAddress < other.MaxIpAddress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0; // Equal ranges
|
||||
}
|
||||
|
||||
bool IsBlocked(IPAddress address);
|
||||
|
||||
bool IsBlocked(UInt128 address);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: SingleIpFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public class SingleIpFirewallEntry : BaseFirewallEntry
|
||||
{
|
||||
public override UInt128 MinIpAddress { get; }
|
||||
|
||||
public override UInt128 MaxIpAddress => MinIpAddress;
|
||||
|
||||
public SingleIpFirewallEntry(string ipAddress) => MinIpAddress = IPAddress.Parse(ipAddress).ToUInt128();
|
||||
|
||||
public SingleIpFirewallEntry(IPAddress ipAddress) => MinIpAddress = ipAddress.ToUInt128();
|
||||
}
|
||||
|
|
@ -21,8 +21,9 @@ namespace Server.Network;
|
|||
/// <summary>
|
||||
/// A gate consulted for every inbound connection, before the socket is configured and before any
|
||||
/// per-connection allocation. Implementations decide membership only — the accept path neither knows
|
||||
/// nor cares where a filter's data comes from, so a filter may be a small admin-curated set held in
|
||||
/// core (see <c>Firewall</c>) or a millions-strong list hydrated from a file by content.
|
||||
/// nor cares where a filter's data comes from, so a filter may be a handful of admin-curated entries,
|
||||
/// a millions-strong list hydrated from a file, or a query against something else entirely. Core owns
|
||||
/// the question; content owns every answer (see <c>Firewall</c> and <c>BlocklistFilter</c> in UOContent).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,49 @@ public static class IPAddressUtility
|
|||
return new IPAddress(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>a.b.c.d/n</c>, <c>::/n</c>, or a bare address (treated as a single-host range) into an
|
||||
/// inclusive <see cref="UInt128"/> range in normalized IPv6 form. A bare IPv4 prefix is widened by 96
|
||||
/// bits so v4 and v6 ranges are directly comparable. Returns false on anything malformed.
|
||||
/// </summary>
|
||||
public static bool TryParseCidrRange(ReadOnlySpan<char> cidr, out UInt128 min, out UInt128 max)
|
||||
{
|
||||
min = default;
|
||||
max = default;
|
||||
|
||||
var slash = cidr.IndexOf('/');
|
||||
if (!IPAddress.TryParse(slash >= 0 ? cidr[..slash] : cidr, out var ip))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isV6 = ip.AddressFamily == AddressFamily.InterNetworkV6;
|
||||
var maxPrefixLength = isV6 ? 128 : 32;
|
||||
int prefixLength;
|
||||
|
||||
if (slash < 0)
|
||||
{
|
||||
prefixLength = maxPrefixLength;
|
||||
}
|
||||
else if (!int.TryParse(cidr[(slash + 1)..], out prefixLength) ||
|
||||
prefixLength < 0 || prefixLength > maxPrefixLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isV6)
|
||||
{
|
||||
prefixLength += 96; // 32 -> 128
|
||||
}
|
||||
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
ip.WriteMappedIPv6To(bytes);
|
||||
|
||||
min = Utility.CreateCidrAddress(bytes, prefixLength, false);
|
||||
max = Utility.CreateCidrAddress(bytes, prefixLength, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the big-endian uint of an <see cref="AddressFamily.InterNetwork"/> address.</summary>
|
||||
public static bool TryV4(IPAddress ip, out uint v)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Server.Network;
|
||||
using Server.Collections;
|
||||
|
||||
namespace Server;
|
||||
|
||||
|
|
@ -14,36 +15,42 @@ public static class NetworkUtilities
|
|||
_ => false
|
||||
};
|
||||
|
||||
private static readonly IFirewallEntry[] _privateNetworkV4 =
|
||||
[
|
||||
new CidrFirewallEntry("127.0.0.1/8"),
|
||||
new CidrFirewallEntry("192.168.0.0/16"),
|
||||
new CidrFirewallEntry("10.0.0.0/8"),
|
||||
new CidrFirewallEntry("172.16.0.0/12"),
|
||||
new CidrFirewallEntry("169.254.0.0/16"),
|
||||
new CidrFirewallEntry("100.64.0.0/10")
|
||||
];
|
||||
// These are constant reserved ranges, not firewall entries -- they only ever answer "is this address
|
||||
// in one of these blocks?", which is exactly what SortedRangeIndex is for. Building them through the
|
||||
// firewall entry types was a convenience that made core depend on the firewall for something that has
|
||||
// nothing to do with banning.
|
||||
private static readonly SortedRangeIndex<UInt128> _privateNetworkV4 = BuildIndex(
|
||||
"127.0.0.1/8",
|
||||
"192.168.0.0/16",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"169.254.0.0/16",
|
||||
"100.64.0.0/10"
|
||||
);
|
||||
|
||||
private static readonly IFirewallEntry[] _privateNetworkV6 =
|
||||
[
|
||||
new CidrFirewallEntry("fc00::/7"),
|
||||
new CidrFirewallEntry("fe80::/10")
|
||||
];
|
||||
private static readonly SortedRangeIndex<UInt128> _privateNetworkV6 = BuildIndex(
|
||||
"fc00::/7",
|
||||
"fe80::/10"
|
||||
);
|
||||
|
||||
public static bool IsPrivateNetworkV4(this IPAddress ip)
|
||||
private static SortedRangeIndex<UInt128> BuildIndex(params ReadOnlySpan<string> cidrs)
|
||||
{
|
||||
for (var i = 0; i < _privateNetworkV4.Length; i++)
|
||||
var ranges = new SortedRangeIndex<UInt128>.Range[cidrs.Length];
|
||||
for (var i = 0; i < cidrs.Length; i++)
|
||||
{
|
||||
if (_privateNetworkV4[i].IsBlocked(ip))
|
||||
if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max))
|
||||
{
|
||||
return true;
|
||||
throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\"");
|
||||
}
|
||||
|
||||
ranges[i] = new SortedRangeIndex<UInt128>.Range(min, max);
|
||||
}
|
||||
|
||||
return false;
|
||||
Array.Sort(ranges, SortedRangeIndex<UInt128>.ByMin);
|
||||
return SortedRangeIndex<UInt128>.Build(ranges);
|
||||
}
|
||||
|
||||
public static bool IsPrivateNetworkV6(this IPAddress ip) =>
|
||||
_privateNetworkV6[0].IsBlocked(ip) ||
|
||||
_privateNetworkV6[1].IsBlocked(ip);
|
||||
public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128());
|
||||
|
||||
public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue