/************************************************************************* * 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 . * *************************************************************************/ using System; using System.Buffers; 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, so no locks, caches, or version counters are needed. _entries is the // authoritative store; _index is a derived, rebuild-on-demand SortedRangeIndex used only for the // accept-path IsBlocked lookup, over the same primitive the blocklist uses (see BlocklistSnapshot). // See dev-docs/ip-bans-and-allowlists.md. private static readonly List _entries = []; // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. private static readonly Dictionary _expiring = []; private static SortedRangeIndex _index = SortedRangeIndex.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> 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.Range>.Create(_entries.Count, mt: false); for (var i = 0; i < _entries.Count; i++) { var entry = _entries[i]; 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) { 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; } /// /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer. /// internal static void ExpireEntries(long nowTicks) { if (_expiring.Count == 0) { return; } List expired = null; foreach (var (entry, expiresAt) in _expiring) { if (expiresAt - nowTicks <= 0) { (expired ??= []).Add(entry); } } if (expired == null) { return; } for (var i = 0; i < expired.Count; i++) { var entry = expired[i]; _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(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; } // Core.Now: this runs on the game loop, via the Configure sweep. var now = Core.Now; var records = settings.Entries; for (var i = 0; i < records.Length; i++) { var record = records[i]; 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() { // expires is derived below as now + (expiresAtTick - nowTicks), so both operands must come from // the same instant. Core.Now and Core.TickCount are refreshed together each loop iteration; a // fresh DateTime.UtcNow here would bake the loop's lag into every persisted expiry. var now = Core.Now; var nowTicks = Core.TickCount; var list = new List(_entries.Count); for (var i = 0; i < _entries.Count; i++) { var entry = _entries[i]; 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 = 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; } }