feat(network): pluggable connection filters; file blocklist + contribute-first CrowdSec (#2542)

Reshapes IP banning around one idea: **core owns the question, content owns every answer.**

Core gains a single accept-path seam — `IConnectionFilter` — and loses everything that used to implement one. The firewall moves to UOContent, a new file-backed blocklist joins it there, and CrowdSec is repositioned from an in-app enforcer to a contribute-first reporter.

## The seam

```csharp
public interface IConnectionFilter
{
    string Name { get; }
    void Configure();
    void Start(CancellationToken token);
    void Stop();
    bool ShouldDeny(IPAddress address);
}
```

The accept path went from hardcoded branches to one question:

```csharp
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
    logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
}
```

Filters register during the Configure sweep. The registry is a plain array walked by an indexed loop — no enumerator, no closure, no allocation — and the first denial short-circuits. An interface dispatch is noise next to the `accept()` syscall, so pluggability costs nothing measurable on the path that has to survive a DDoS.

Whatever a hit implies — persisting, promoting to an OS bouncer, contributing to the ban channel — is the filter's business, not the accept path's.

A filter that throws is **unregistered and the connection fails open**. A filter that faults once faults for every subsequent connection, so leaving it registered means an exception and a log line per accept — exactly the amplification an attacker wants — and a broken filter must not be able to deny everyone either.

This deliberately does **not** reuse `EventSink.InvokeSocketConnect`: that fires later and allocates a `SocketConnectEventArgs` per connection, which is what the accept path avoids for rejected traffic.

## What ships behind it

**`firewall`** (UOContent) — the existing admin-curated set. Collapsed from `Firewall` + `AdminFirewall` + a threaded enforcer into one single-threaded store with **zero concurrency primitives**: the accept path, admin gump, TTL expiry and boot load all run on the game loop. Persists to `Configuration/firewall.json` with automatic migration from the legacy `firewall.cfg`. No behavior change for operators — same namespace, same gump, same commands.

**`blocklist`** (UOContent) — new. Holds a millions-strong list in-app and **demand-pages** hits up to CrowdSec, which promotes them to the OS firewall.

The motivation is concrete: CrowdSec's Windows bouncer cannot load the ~3.9M IPs that 91 community feeds produce, but it handles ~100k fine. So the millions live in-process behind a binary search, and only addresses that *actually connect* get promoted. A `PromotedGuard` suppresses re-reporting an address until the bouncer picks it up.

The list is parsed straight from UTF-8 file bytes with no per-line string allocation, off the game loop, and published as an immutable snapshot swapped through a single `volatile` reference. Reloads yield to world saves.

**`tools/Export-IpBlocklist.ps1`** — the producer. Requires PowerShell 7 and runs on Windows, Linux and macOS; Windows PowerShell 5.1 is refused up front via `#requires`. Merges a thin, non-overlapping feed set into one de-duplicated, bogon-filtered file. Parsing runs in a compiled `Add-Type` hot loop (~1s for ~4M lines instead of minutes). Written to a `.tmp` sibling and swapped with `File.Replace`, so the shard never reads a half-written list, and a total feed outage refuses to overwrite a good list with an empty one. Re-running is idempotent — it exits without downloading anything while the list on disk is younger than `-MinInterval` (default 2h, the anchor feed's own refresh period), so a misconfigured scheduler can't hammer upstream.

## CrowdSec: contribute-first

`IBanReporter` + `BanChannel` fan locally-decided bans out to external systems. `CrowdSecReporter` (UOContent) posts to LAPI `POST /v1/alerts` and retracts via `DELETE /v1/decisions`.

Reporting is **enqueue-only** on the accept path: a bounded, coalescing channel drained off-loop with bounded retry, counted drops on overflow, and a flush on shutdown. Under a DDoS the accept path never does synchronous or lock-contending per-IP work.

### Why not pull decisions from CrowdSec?

The original design streamed decisions into an in-app snapshot and enforced them at the accept gate. That's the wrong layer: by the time the shard sees the connection, the TCP handshake and socket setup are already paid for. `cs-firewall-bouncer` drops the same traffic **at the kernel**, and it's what CrowdSec is built to do. So the shard now contributes what it uniquely knows (rate-limit trips, blocklist hits from real connection attempts) and lets the OS enforce.

The one thing the OS can't do — hold millions of entries on Windows — is exactly what the in-app blocklist covers, and it feeds the same pipeline.

## Threading policy

`CLAUDE.md` rule #3 is rewritten as an explicit three-part policy, with rule #10 restated in tandem:

- Anything touching game state runs **only** on the main loop.
- Heavy work that *needs* game state must be **chunked** across ticks, never threaded.
- Heavy work that does *not* need game state (large-file parse, external I/O) **must** run off-loop **and must yield to world saves**.

Results come back via an immutable snapshot swapped through a single `volatile` reference, or `Core.LoopContext.Post` — never by letting the scheduler decide where heavy work runs. Both new subsystems follow it.

## Shared primitives

`SortedRangeIndex<T> where T : IBinaryInteger<T>` — coalesced disjoint interval arrays plus a binary search. The firewall, the blocklist, and (as of this PR) core's reserved-network tables all use it.

Coalescing is a correctness requirement, not an optimization: multi-feed lists nest CIDRs (`/24` containing a `/32`), and a search that inspects only the rightmost run whose minimum is ≤ the value is sound **only** over disjoint runs. That bug was caught in review and is covered by regression tests.

`IPAddressUtility` collects the allocation-free `IPAddress` ↔ `UInt128` conversions and CIDR parsing that were previously scattered or duplicated.

## Config

| File | Owner | Keys |
|---|---|---|
| `Configuration/bans.json` | core | `reportRateLimitTrips`, `autoBanDuration` |
| `Configuration/blocklist.json` | content | `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` |
| `Configuration/crowdsec.json` | content | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` |
| `Configuration/firewall.json` | content | persisted firewall entries (migrated from `firewall.cfg`) |

Everything is inert by default. CrowdSec self-disables without credentials; the blocklist self-disables until its file exists. A shard that changes nothing sees no behavior change.

## Notes for review

- **Core no longer references `Firewall` or `IFirewallEntry` anywhere.** `NetworkUtilities` used to build its reserved-network tables out of `CidrFirewallEntry`, which coupled core to the firewall for something unrelated to banning; those are now a `SortedRangeIndex<UInt128>`, same semantics and public API.
- **`BanChannel.Stop()` no longer persists the firewall** — a contribution coordinator has no business saving an enforcement store. That's the firewall filter's `Stop()`.
- **A dead `whitelisted` parameter was dropped** from the blocklist gate: it was hardcoded `false` at its only call site, and no whitelist concept exists in core.
- **The blocklist filter is an instance, not a static.** The static version forced its tests onto the sequential collection with a reset hook; they now run in parallel.
- `dev-docs/networking-packets.md` documents the seam for content authors, plus a known wart in the `IPAddress` ↔ `UInt128` normalization flagged for a follow-up PR.
- The generator was verified on Linux, macOS and Windows under a temporary CI matrix (since removed). It caught two portability bugs — a Windows-only path separator, and a culture-sensitive duration parse that read `2.5` as `25` on comma-decimal locales and *silently* turned a 2.5h cooldown into 25h — plus a third that made the script unparseable on Windows PowerShell 5.1. The source is ASCII-only for that last reason: `#requires` is only honored once a file parses, so non-ASCII in a BOM-less script produces parse errors instead of the version message.

## Tests

**1344 pass** (782 `Server.Tests`, 562 `UOContent.Tests`). New coverage: filter registry (registration, short-circuit, fault-disable), blocklist parsing/CIDR/coalescing, snapshot reload markers, promote-guard TTL, ban-channel fan-out, CrowdSec alert building/dedup/flush-on-stop, and the generator's output-format contract pinned against the reader.
This commit is contained in:
Kamron Batman 2026-07-25 11:59:37 -07:00 committed by GitHub
parent bec4cfa910
commit c39454137e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4655 additions and 547 deletions

View file

@ -9,6 +9,7 @@ using Server.Items;
using Server.Mobiles;
using Server.Multis;
using Server.Network;
using Server.Network.Bans;
using Server.Spells;
namespace Server.Commands.Generic
@ -1154,7 +1155,8 @@ namespace Server.Commands.Generic
try
{
AdminFirewall.Add(state.Address);
Firewall.Add(new SingleIpFirewallEntry(state.Address));
BanChannel.Report(state.Address, TimeSpan.Zero, "manual");
AddResponse("They have been firewalled.");
}
catch (Exception ex)

View file

@ -11,6 +11,7 @@ using Server.Maps;
using Server.Misc;
using Server.Multis;
using Server.Network;
using Server.Network.Bans;
using Server.Prompts;
using Server.Saves;
using Server.Text;
@ -1743,7 +1744,8 @@ namespace Server.Gumps
{
for (var i = 0; i < a.LoginIPs.Length; ++i)
{
AdminFirewall.Add(a.LoginIPs[i]);
Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i]));
BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, "manual");
}
notice = "All addresses in the list have been firewalled.";
@ -1767,7 +1769,13 @@ namespace Server.Gumps
if (okay)
{
AdminFirewall.Add(toFirewall);
var firewallEntry = Firewall.ToFirewallEntry(toFirewall);
Firewall.Add(firewallEntry);
if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress)
{
BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, "manual");
}
notice = $"{toFirewall} : Added to firewall.";
}
@ -3559,7 +3567,7 @@ namespace Server.Gumps
IFirewallEntry firewallEntry;
try
{
firewallEntry = AdminFirewall.ToFirewallEntry(text);
firewallEntry = Firewall.ToFirewallEntry(text);
}
catch
{
@ -3581,7 +3589,17 @@ namespace Server.Gumps
$"{from.AccessLevel} {CommandLogging.Format(from)} firewalling {firewallEntry}"
);
AdminFirewall.Add(firewallEntry);
Firewall.Add(firewallEntry);
if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress)
{
BanChannel.Report(
firewallEntry.MinIpAddress.ToIpAddress(),
TimeSpan.Zero,
"manual"
);
}
from.SendGump(
new AdminGump(
from,
@ -3620,7 +3638,13 @@ namespace Server.Gumps
$"{from.AccessLevel} {CommandLogging.Format(from)} removing {m_State} from firewall list"
);
AdminFirewall.Remove(m_State);
Firewall.Remove(m_State as IFirewallEntry);
if (m_State is IFirewallEntry fe && fe.MinIpAddress == fe.MaxIpAddress)
{
BanChannel.Retract(fe.MinIpAddress.ToIpAddress());
}
from.SendGump(
new AdminGump(
from,

View file

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

View file

@ -0,0 +1,90 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistConfiguration.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.IO;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Network.Bans;
/// <summary>
/// Loads the <see cref="BlocklistSettings"/> from <c>Configuration/blocklist.json</c> (matching the
/// per-feature JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file
/// writes a template so operators have something to edit.
/// </summary>
public static class BlocklistConfiguration
{
private const string _path = "Configuration/blocklist.json";
public static BlocklistSettings Settings { get; private set; }
public static void Load()
{
var path = Path.Join(Core.BaseDirectory, _path);
if (File.Exists(path))
{
Settings = JsonConfig.Deserialize<BlocklistSettings>(path);
}
else
{
Settings = new BlocklistSettings();
Save();
}
}
private static void Save()
{
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
}
}
/// <summary>
/// Bound configuration for <see cref="BlocklistFilter"/>. The filter is inert unless <see cref="File"/>
/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs
/// the generator.
/// </summary>
public record BlocklistSettings
{
/// <summary>
/// Path to the blocklist. A relative path resolves against <see cref="Core.BaseDirectory"/>; an
/// absolute path is used as-is (handy when several shards share one generated list). Set to
/// <c>""</c> to disable the gate entirely. Produce the file with <c>tools/Export-IpBlocklist.ps1</c>.
/// </summary>
[JsonPropertyName("file")]
public string File { get; set; } = "Configuration/ip-blocklist.txt";
/// <summary>How often the file is checked for changes. Reloads only happen when it actually changed.</summary>
[JsonPropertyName("reloadInterval")]
public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Whether blocklist hits are contributed to the ban channel (the demand-paging promotion).</summary>
[JsonPropertyName("reportHits")]
public bool ReportHits { get; set; } = true;
/// <summary>Duration reported for a blocklist-matched ban.</summary>
[JsonPropertyName("banDuration")]
public TimeSpan BanDuration { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// How long the accept-path guard suppresses re-reporting a promoted address. This only needs to
/// bridge the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops
/// repeat traffic. Decoupled from <see cref="BanDuration"/> so the guard doesn't have to remember
/// hours' worth of distinct addresses.
/// </summary>
[JsonPropertyName("promoteSuppression")]
public TimeSpan PromoteSuppression { get; set; } = TimeSpan.FromSeconds(60);
}

View file

@ -0,0 +1,86 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistFile.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
namespace Server.Network.Bans;
public readonly record struct BlocklistHeader(string Generated, int Count, bool Present);
/// <summary>Reads the versioned blocklist file: cheap header probe + full snapshot load.</summary>
public static class BlocklistFile
{
public static bool TryReadHeader(string path, out BlocklistHeader header)
{
header = new BlocklistHeader(null, 0, false);
try
{
if (!File.Exists(path))
{
return false;
}
using var reader = new StreamReader(path);
var first = reader.ReadLine();
if (first == null)
{
header = new BlocklistHeader(null, 0, true);
return true;
}
string generated = null;
var count = 0;
if (first.StartsWith('#'))
{
var tokens = first.Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < tokens.Length; i++)
{
var tok = tokens[i];
if (tok.StartsWith("generated=", StringComparison.Ordinal))
{
generated = tok["generated=".Length..];
}
else if (tok.StartsWith("count=", StringComparison.Ordinal))
{
int.TryParse(tok["count=".Length..], out count);
}
}
}
header = new BlocklistHeader(generated, count, true);
return true;
}
catch
{
return false; // treat as absent; caller keeps last-good / empty
}
}
public static BlocklistSnapshot Load(string path, out int parsed, out int skipped)
{
parsed = 0;
skipped = 0;
try
{
if (!File.Exists(path))
{
return BlocklistSnapshot.Empty;
}
return BlocklistSnapshot.Build(File.ReadAllBytes(path), out parsed, out skipped);
}
catch
{
return BlocklistSnapshot.Empty;
}
}
}

View file

@ -0,0 +1,259 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistFilter.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.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Server.Logging;
namespace Server.Network.Bans;
/// <summary>
/// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator
/// (<c>tools/Export-IpBlocklist.ps1</c>) writes on a schedule. Holds an immutable snapshot swapped
/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is
/// configured or present.
/// </summary>
/// <remarks>
/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on
/// Windows, so the millions live here and only addresses that actually connect are promoted to CrowdSec
/// (and from there to the OS firewall) through <see cref="BanChannel"/>. <see cref="PromotedGuard"/>
/// keeps a flood of repeat connections from re-reporting the same address before the bouncer picks it up.
/// </remarks>
public sealed class BlocklistFilter : IConnectionFilter
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter));
// Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile
// reference swap is the whole synchronization story — readers see the old or the new snapshot, whole.
private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
private readonly PromotedGuard _guard = new();
private string _path;
private TimeSpan _interval;
private bool _reportHits;
private TimeSpan _banDuration;
private long _suppressionMs;
private string _lastGenerated;
private DateTime _lastWriteUtc;
private CancellationTokenSource _cts;
public string Name => "blocklist";
public int Count => _snapshot.Count;
public static void Configure()
{
ConnectionFilters.Register(new BlocklistFilter());
}
public void Register()
{
BlocklistConfiguration.Load();
var s = BlocklistConfiguration.Settings;
_path = ResolvePath(s.File);
_interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval;
_reportHits = s.ReportHits;
_banDuration = s.BanDuration;
_suppressionMs = (long)s.PromoteSuppression.TotalMilliseconds;
}
/// <summary>
/// Resolves the configured path once: a relative path is anchored to <see cref="Core.BaseDirectory"/>
/// (never the process working directory, which differs when the shard is launched from elsewhere), an
/// absolute path is taken as-is so several shards can share one generated list.
/// </summary>
private static string ResolvePath(string configured)
{
if (string.IsNullOrWhiteSpace(configured))
{
return null;
}
return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured);
}
public void Start(CancellationToken token)
{
if (_path == null)
{
logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)");
return;
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
// A missing file is the shipped default, not an error: the gate stays inert until the poll picks
// up whatever the generator first writes. No restart needed.
if (File.Exists(_path))
{
Reload(); // synchronous prime; empty on failure (fail-open)
}
else
{
logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval);
}
// Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded.
Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard);
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
}
public void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
public bool ShouldDeny(IPAddress address)
{
if (!Evaluate(address, Core.TickCount, out var shouldReport))
{
return false;
}
if (shouldReport)
{
// Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop.
BanChannel.Report(address, _banDuration, "blocklist");
}
return true;
}
/// <summary>
/// The pure decision, split out so the accept-path policy can be tested without a clock or a ban
/// channel. <paramref name="shouldReport"/> is true at most once per suppression window.
/// </summary>
internal bool Evaluate(IPAddress address, long nowTicks, out bool shouldReport)
{
shouldReport = false;
if (!_snapshot.IsBanned(address))
{
return false;
}
if (_reportHits)
{
shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs);
}
return true;
}
private void SweepGuard() => _guard.Sweep(Core.TickCount);
// Test hook: inject a snapshot and policy without file I/O.
internal void LoadForTesting(BlocklistSnapshot snapshot, bool reportHits = true, long suppressionMs = 60000)
{
_snapshot = snapshot;
_reportHits = reportHits;
_suppressionMs = suppressionMs;
}
private async ValueTask PollLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(_interval, token);
}
catch (OperationCanceledException)
{
return;
}
try
{
if (ChangedSinceLastLoad())
{
// Parsing millions of lines competes with a save for CPU and page cache, so yield until
// the world is written out. See the threading policy in CLAUDE.md (rules #3 and #10).
while (World.Saving || World.WorldState == WorldState.PendingSave)
{
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
Reload();
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
logger.Warning(e, "Blocklist reload check failed; keeping last snapshot ({Count})", Count);
}
}
}
private bool ChangedSinceLastLoad()
{
try
{
var info = new FileInfo(_path);
if (!info.Exists)
{
return false;
}
if (info.LastWriteTimeUtc == _lastWriteUtc)
{
return false; // cheapest guard
}
}
catch
{
return false;
}
return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated;
}
private void Reload()
{
// Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not
// one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll;
// capturing after could skip a version entirely.
var writeUtc = default(DateTime);
try
{
writeUtc = new FileInfo(_path).LastWriteTimeUtc;
}
catch
{
/* keep default */
}
BlocklistFile.TryReadHeader(_path, out var h);
var next = BlocklistFile.Load(_path, out var parsed, out var skipped);
_snapshot = next; // single volatile swap; readers see old or new whole
_lastGenerated = h.Generated;
_lastWriteUtc = writeUtc;
logger.Information("Blocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}",
parsed, next.Count, skipped, h.Generated);
}
}

View file

@ -0,0 +1,205 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BlocklistSnapshot.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Buffers.Text;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Server.Collections;
namespace Server.Network.Bans;
/// <summary>
/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced
/// interval index per family: IPv4 as <see cref="uint"/> ranges (lean for the millions-strong common
/// case), IPv6 as <see cref="UInt128"/> ranges (empty unless the feed carries v6). Immutable → lock-free reads.
/// </summary>
public sealed class BlocklistSnapshot
{
public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex<uint>.Empty, SortedRangeIndex<UInt128>.Empty);
private readonly SortedRangeIndex<uint> _v4;
private readonly SortedRangeIndex<UInt128> _v6;
public int Count => _v4.Count + _v6.Count;
private BlocklistSnapshot(SortedRangeIndex<uint> v4, SortedRangeIndex<UInt128> v6)
{
_v4 = v4;
_v6 = v6;
}
/// <summary>
/// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on
/// <c>'\n'</c> with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the
/// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser.
/// Malformed lines increment <paramref name="skipped"/> and never throw. Build-time intermediates use
/// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread.
/// </summary>
public static BlocklistSnapshot Build(ReadOnlySpan<byte> data, out int parsed, out int skipped)
{
parsed = 0;
skipped = 0;
// Only the two final index arrays (allocated inside SortedRangeIndex.Build) hit the heap; every
// build-time buffer here is a pooled ref list. mt: true is required — this runs off the game loop.
using var v4 = PooledRefList<SortedRangeIndex<uint>.Range>.Create(mt: true);
using var v6 = PooledRefList<SortedRangeIndex<UInt128>.Range>.Create(mt: true);
var rest = data;
while (!rest.IsEmpty)
{
ReadOnlySpan<byte> line;
var nl = rest.IndexOf((byte)'\n');
if (nl >= 0)
{
line = rest[..nl];
rest = rest[(nl + 1)..];
}
else
{
line = rest;
rest = default;
}
line = line[Ascii.Trim(line)];
if (line.IsEmpty || line[0] == (byte)'#' || line[0] == (byte)';')
{
continue;
}
var slash = line.IndexOf((byte)'/');
var addr = slash >= 0 ? line[..slash] : line;
var bitsToken = slash >= 0 ? line[(slash + 1)..] : default;
if (addr.IndexOf((byte)':') < 0)
{
// IPv4 single or CIDR — parsed straight from the byte span.
if (slash >= 0)
{
if (IPAddressUtility.TryParseV4(addr, out var ip) &&
TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 32)
{
var size = bits == 0 ? 0xFFFFFFFFu : (1u << (32 - bits)) - 1;
var b = ip & ~size;
v4.Add(new SortedRangeIndex<uint>.Range(b, b + size));
parsed++;
}
else
{
skipped++;
}
}
else if (IPAddressUtility.TryParseV4(addr, out var ip))
{
v4.Add(new SortedRangeIndex<uint>.Range(ip, ip));
parsed++;
}
else
{
skipped++;
}
}
else if (TryDecodeV6(addr, out var v))
{
// IPv6 is rare in these feeds; the single token was decoded and framework-parsed above.
if (slash >= 0)
{
if (TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 128)
{
var mask = bits == 0 ? UInt128.Zero : ~((UInt128.One << (128 - bits)) - 1);
var b = v & mask;
v6.Add(new SortedRangeIndex<UInt128>.Range(b, b | ~mask));
parsed++;
}
else
{
skipped++;
}
}
else
{
v6.Add(new SortedRangeIndex<UInt128>.Range(v, v));
parsed++;
}
}
else
{
skipped++;
}
}
v4.Sort(SortedRangeIndex<uint>.ByMin);
v6.Sort(SortedRangeIndex<UInt128>.ByMin);
return new BlocklistSnapshot(SortedRangeIndex<uint>.Build(v4.AsSpan()), SortedRangeIndex<UInt128>.Build(v6.AsSpan()));
}
// Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser.
private static bool TryDecodeV6(ReadOnlySpan<byte> addr, out UInt128 v)
{
v = UInt128.Zero;
if (addr.Length > 45)
{
return false;
}
Span<char> chars = stackalloc char[addr.Length];
for (var i = 0; i < addr.Length; i++)
{
chars[i] = (char)addr[i];
}
if (!IPAddress.TryParse(chars, out var a) || a.AddressFamily != AddressFamily.InterNetworkV6)
{
return false;
}
v = a.ToUInt128();
return true;
}
private static bool TryParseBits(ReadOnlySpan<byte> token, out int bits)
{
if (Utf8Parser.TryParse(token, out bits, out var consumed) && consumed == token.Length)
{
return true;
}
bits = 0;
return false;
}
public bool IsBanned(IPAddress ip)
{
if (ip.IsIPv4MappedToIPv6)
{
// v6-encoded v4 must not dodge the v4 set; extract the embedded v4 uint directly.
return IPAddressUtility.TryMappedV4(ip, out var mv) && _v4.Contains(mv);
}
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
return IPAddressUtility.TryV4(ip, out var v) && _v4.Contains(v);
}
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
{
return _v6.Contains(ip.ToUInt128());
}
return false;
}
}

View file

@ -0,0 +1,55 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: PromotedGuard.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
namespace Server.Network.Bans;
/// <summary>Suppresses re-reporting the same IP within a TTL. Accept-path thread only.</summary>
public sealed class PromotedGuard
{
private readonly Dictionary<UInt128, long> _expiry = [];
public bool TryMark(UInt128 ip, long nowTicks, long ttlMs)
{
if (_expiry.TryGetValue(ip, out var exp) && exp - nowTicks > 0)
{
return false;
}
_expiry[ip] = nowTicks + ttlMs;
return true;
}
public void Sweep(long nowTicks)
{
if (_expiry.Count == 0)
{
return;
}
using var dead = Collections.PooledRefQueue<UInt128>.Create();
foreach (var (ip, exp) in _expiry)
{
if (exp - nowTicks <= 0)
{
dead.Enqueue(ip);
}
}
while (dead.Count > 0)
{
_expiry.Remove(dead.Dequeue());
}
}
}

View file

@ -0,0 +1,65 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: CrowdSecAlert.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.Text.Json.Serialization;
namespace Server.Network.Bans.CrowdSec;
/// <summary>One CrowdSec alert (<c>POST /v1/alerts</c> takes an array of these).</summary>
public sealed class CrowdSecAlert
{
[JsonPropertyName("scenario")] public string Scenario { get; set; }
[JsonPropertyName("message")] public string Message { get; set; }
[JsonPropertyName("events_count")] public int EventsCount { get; set; } = 1;
[JsonPropertyName("start_at")] public string StartAt { get; set; }
[JsonPropertyName("stop_at")] public string StopAt { get; set; }
[JsonPropertyName("capacity")] public int Capacity { get; set; }
[JsonPropertyName("leakspeed")] public string LeakSpeed { get; set; } = "0s";
[JsonPropertyName("simulated")] public bool Simulated { get; set; }
[JsonPropertyName("events")] public object[] Events { get; set; } = [];
[JsonPropertyName("remediation")] public bool Remediation { get; set; } = true;
[JsonPropertyName("source")] public CrowdSecSource Source { get; set; }
[JsonPropertyName("decisions")] public CrowdSecDecisionDto[] Decisions { get; set; }
}
public sealed class CrowdSecSource
{
[JsonPropertyName("scope")] public string Scope { get; set; } = "Ip";
[JsonPropertyName("value")] public string Value { get; set; }
}
public sealed class CrowdSecDecisionDto
{
[JsonPropertyName("origin")] public string Origin { get; set; }
[JsonPropertyName("type")] public string Type { get; set; } = "ban";
[JsonPropertyName("scope")] public string Scope { get; set; } = "Ip";
[JsonPropertyName("value")] public string Value { get; set; }
[JsonPropertyName("duration")] public string Duration { get; set; }
[JsonPropertyName("scenario")] public string Scenario { get; set; }
}
/// <summary>Watcher login request/response for <c>POST /v1/watchers/login</c>.</summary>
public sealed class CrowdSecLoginRequest
{
[JsonPropertyName("machine_id")] public string MachineId { get; set; }
[JsonPropertyName("password")] public string Password { get; set; }
}
public sealed class CrowdSecLoginResponse
{
[JsonPropertyName("code")] public int Code { get; set; }
[JsonPropertyName("token")] public string Token { get; set; }
[JsonPropertyName("expire")] public string Expire { get; set; }
}

View file

@ -0,0 +1,137 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: CrowdSecAlertClient.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.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Server.Network.Bans.CrowdSec;
/// <summary>Reporter-side LAPI operations, mockable for tests.</summary>
public interface ICrowdSecAlertClient : IDisposable
{
ValueTask PostAlertsAsync(IReadOnlyList<CrowdSecAlert> alerts, CancellationToken token);
ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token);
}
/// <summary>
/// CrowdSec LAPI watcher client: authenticates with machine credentials and posts/deletes decisions.
/// Holds a JWT refreshed on expiry or a 401.
/// </summary>
public sealed class CrowdSecAlertClient : ICrowdSecAlertClient
{
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
private readonly string _machineId;
private readonly string _password;
private string _token;
private DateTime _tokenExpiresUtc = DateTime.MinValue;
public CrowdSecAlertClient(CrowdSecSettings settings)
{
var baseUri = new Uri(settings.LapiUrl, UriKind.Absolute); // fails loud on malformed url
_http = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromSeconds(30) };
_http.DefaultRequestHeaders.Add("User-Agent", "ModernUO-watcher/1.0");
_machineId = settings.MachineId;
_password = settings.Password;
}
private async Task EnsureAuthAsync(CancellationToken token)
{
if (_token != null && DateTime.UtcNow < _tokenExpiresUtc - TimeSpan.FromMinutes(1))
{
return;
}
var request = new CrowdSecLoginRequest { MachineId = _machineId, Password = _password };
using var response = await _http.PostAsJsonAsync("/v1/watchers/login", request, _jsonOptions, token)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var login = await response.Content.ReadFromJsonAsync<CrowdSecLoginResponse>(_jsonOptions, token)
.ConfigureAwait(false);
_token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token.");
_tokenExpiresUtc = DateTime.TryParse(login.Expire, out var exp) ? exp.ToUniversalTime() : DateTime.UtcNow.AddHours(1);
}
private void Authorize(HttpRequestMessage message) =>
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
public async ValueTask PostAlertsAsync(IReadOnlyList<CrowdSecAlert> alerts, CancellationToken token)
{
if (alerts.Count == 0)
{
return;
}
await SendWithRetryAsync(() =>
{
var message = new HttpRequestMessage(HttpMethod.Post, "/v1/alerts")
{
Content = JsonContent.Create(alerts, options: _jsonOptions)
};
Authorize(message);
return message;
}, token).ConfigureAwait(false);
}
public async ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token)
{
var query = BuildDeleteQuery(origin, ip);
await SendWithRetryAsync(() =>
{
var message = new HttpRequestMessage(HttpMethod.Delete, query);
Authorize(message);
return message;
}, token).ConfigureAwait(false);
}
/// <summary>
/// Builds the decisions-delete query string. <paramref name="origin"/> is operator-controlled config
/// (crowdsec.json), so it must be escaped like any other untrusted value going into a URL.
/// </summary>
internal static string BuildDeleteQuery(string origin, IPAddress ip) =>
$"/v1/decisions?origin={Uri.EscapeDataString(origin)}&ip={Uri.EscapeDataString(ip.ToString())}";
private async ValueTask SendWithRetryAsync(Func<HttpRequestMessage> build, CancellationToken token)
{
await EnsureAuthAsync(token).ConfigureAwait(false);
using var first = build();
using var response = await _http.SendAsync(first, token).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized)
{
response.EnsureSuccessStatusCode();
return;
}
// Token rejected mid-flight: force a re-login and retry once.
_token = null;
await EnsureAuthAsync(token).ConfigureAwait(false);
using var retry = build();
using var retryResponse = await _http.SendAsync(retry, token).ConfigureAwait(false);
retryResponse.EnsureSuccessStatusCode();
}
public void Dispose() => _http.Dispose();
}

View file

@ -0,0 +1,101 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: CrowdSecConfiguration.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.IO;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Network.Bans.CrowdSec;
/// <summary>
/// Loads the <see cref="CrowdSecSettings"/> from <c>Configuration/crowdsec.json</c> (matching the
/// per-feature JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file
/// writes a disabled-by-default template so operators have something to edit.
/// </summary>
public static class CrowdSecConfiguration
{
private const string _path = "Configuration/crowdsec.json";
public static CrowdSecSettings Settings { get; private set; }
public static void Load()
{
var path = Path.Join(Core.BaseDirectory, _path);
if (File.Exists(path))
{
Settings = JsonConfig.Deserialize<CrowdSecSettings>(path);
}
else
{
Settings = new CrowdSecSettings
{
LapiUrl = "http://127.0.0.1:8080",
MachineId = "",
Password = "",
Origin = "modernuo",
ManualBanDuration = TimeSpan.FromHours(168),
FlushInterval = TimeSpan.FromSeconds(1),
MaxQueue = 10000
};
Save();
}
}
private static void Save()
{
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
}
}
/// <summary>
/// Bound configuration for <see cref="CrowdSecReporter"/>. Read once at <c>Configure()</c>.
/// The reporter is inert unless <see cref="ReportingEnabled"/> is true.
/// </summary>
public record CrowdSecSettings
{
/// <summary>LAPI endpoint. Default <c>http://127.0.0.1:8080</c>.</summary>
[JsonPropertyName("lapiUrl")]
public string LapiUrl { get; set; } = "http://127.0.0.1:8080";
/// <summary>Watcher machine id from <c>cscli machines add</c>. Empty disables reporting.</summary>
[JsonPropertyName("machineId")]
public string MachineId { get; set; } = "";
/// <summary>Watcher password paired with <see cref="MachineId"/>.</summary>
[JsonPropertyName("password")]
public string Password { get; set; } = "";
/// <summary>Decision <c>origin</c> stamped on our contributions. Default <c>modernuo</c>.</summary>
[JsonPropertyName("origin")]
public string Origin { get; set; } = "modernuo";
/// <summary>Duration for manual admin bans pushed to CrowdSec. Default 168h (renewable). Finite so a missed retract self-heals.</summary>
[JsonPropertyName("manualBanDuration")]
public TimeSpan ManualBanDuration { get; set; } = TimeSpan.FromHours(168);
/// <summary>Max time the drain coalesces before flushing a batch. Default 1s.</summary>
[JsonPropertyName("flushInterval")]
public TimeSpan FlushInterval { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>Bounded contribution queue capacity; overflow is dropped (counted). Default 10000.</summary>
[JsonPropertyName("maxQueue")]
public int MaxQueue { get; set; } = 10000;
[JsonIgnore]
public bool ReportingEnabled => !string.IsNullOrWhiteSpace(MachineId) && !string.IsNullOrWhiteSpace(Password);
}

View file

@ -0,0 +1,399 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: CrowdSecReporter.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.Net;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Server.Logging;
namespace Server.Network.Bans.CrowdSec;
/// <summary>
/// Contributes locally-decided bans to CrowdSec via the LAPI alerts API. Non-blocking on the accept
/// path: <see cref="Report"/> enqueues onto a bounded, drop-on-overflow channel drained by a single
/// background task that coalesces by IP and POSTs batched alerts.
/// </summary>
public sealed class CrowdSecReporter : IBanReporter
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(CrowdSecReporter));
internal readonly record struct ReportItem(IPAddress Ip, TimeSpan Ttl, string Reason, bool Retract);
// Bounded retry for transient LAPI failures during a drain send (network blips, 5xx). Distinct from
// CrowdSecAlertClient.SendWithRetryAsync's single 401-relogin retry, which is an auth concern.
private static readonly TimeSpan[] _retryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)];
private ICrowdSecAlertClient _client;
private CrowdSecSettings _settings;
private Channel<ReportItem> _queue;
private CancellationTokenSource _cts;
private Task _drainTask;
private int _dropped;
private int _sendFailures;
public CrowdSecReporter()
{
}
// Test/embedding ctor with an injected client + settings.
internal CrowdSecReporter(ICrowdSecAlertClient client, CrowdSecSettings settings)
{
_client = client;
_settings = settings;
_queue = CreateQueue(settings.MaxQueue);
}
public string Name => "crowdsec";
public bool CanRetract => true;
public int DroppedCount => _dropped;
/// <summary>
/// Batches ultimately dropped after the bounded transient-retry in <see cref="DrainLoop"/> gave up.
/// Distinct from <see cref="DroppedCount"/> (queue-overflow drops on the accept path): this counts
/// sustained LAPI outages so operators can see contribution loss instead of it being silent.
/// </summary>
public int SendFailureCount => _sendFailures;
/// <summary>The drain loop's task, so tests can assert it stays alive until the loop exits.</summary>
internal Task DrainTaskForTesting => _drainTask;
public static void Configure()
{
BanChannel.Register(new CrowdSecReporter());
}
public void Register()
{
CrowdSecConfiguration.Load();
_settings ??= CrowdSecConfiguration.Settings;
}
public void Start(CancellationToken token)
{
if (!_settings.ReportingEnabled)
{
logger.Information("CrowdSec reporter disabled (machineId/password empty in crowdsec.json)");
return;
}
_client ??= new CrowdSecAlertClient(_settings);
_queue ??= CreateQueue(_settings.MaxQueue);
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
_drainTask = Task.Run(() => DrainLoop(_cts.Token), _cts.Token);
}
public void Stop()
{
_cts?.Cancel();
// The flush below reads a SingleReader channel, so wait for the drain to actually exit first.
var drainExited = true;
try
{
// Wait(timeout) is false only on timeout; a throw means faulted/cancelled, which is still exited.
drainExited = _drainTask == null || _drainTask.Wait(TimeSpan.FromSeconds(2));
}
catch
{
// Ignored: a faulted wait means the drain has completed and released the channel.
}
_cts?.Dispose();
_cts = null;
if (drainExited)
{
FlushRemainingOnStop();
}
_client?.Dispose();
_drainTask = null;
}
/// <summary>
/// Best-effort bounded flush of whatever is still queued at shutdown. Blocking is correct here — the
/// loop has stopped ticking — but must not happen on the loop thread: <see cref="Stop"/> runs where
/// <c>SynchronizationContext.Current</c> is the <c>EventLoopContext</c>, and a captured continuation
/// would be posted to a queue nothing pumps any more. <see cref="Task.Run(Func{Task})"/> keeps the
/// chain on the pool; the bounded wait caps a wedged send at a few seconds of shutdown.
/// </summary>
private void FlushRemainingOnStop()
{
if (_queue == null || _client == null)
{
return;
}
_queue.Writer.TryComplete();
List<ReportItem> reports = [];
List<ReportItem> retracts = [];
while (_queue.Reader.TryRead(out var item))
{
(item.Retract ? retracts : reports).Add(item);
}
if (reports.Count == 0 && retracts.Count == 0)
{
return;
}
try
{
if (!Task.Run(() => FlushRemainingOnStopAsync(reports, retracts)).Wait(TimeSpan.FromSeconds(4)))
{
logger.Warning(
"CrowdSec flush-on-stop timed out; {Count} item(s) not contributed",
reports.Count + retracts.Count
);
}
}
catch (Exception e)
{
logger.Warning(e, "CrowdSec flush-on-stop failed");
}
}
/// <summary>
/// Uses a fresh token, not the drain loop's already-cancelled one, which would fail every send
/// immediately. Reports go as one deduped batch; retracts go as individual DELETEs so an admin's
/// unban propagates on a clean shutdown. Leftovers self-heal via
/// <see cref="CrowdSecSettings.ManualBanDuration"/>.
/// </summary>
private async Task FlushRemainingOnStopAsync(List<ReportItem> reports, List<ReportItem> retracts)
{
using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
if (reports.Count > 0)
{
var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow);
try
{
await _client.PostAlertsAsync(alerts, flushCts.Token).ConfigureAwait(false);
}
catch (Exception e)
{
logger.Warning(e, "CrowdSec flush-on-stop reports failed");
RecordSendFailure(alerts.Count);
}
}
HashSet<string> seen = [];
for (var i = 0; i < retracts.Count; i++)
{
if (flushCts.IsCancellationRequested)
{
break; // out of budget; the rest self-heal via ManualBanDuration
}
var ip = retracts[i].Ip;
if (!seen.Add(ip.ToString()))
{
continue;
}
try
{
await _client.DeleteDecisionsAsync(_settings.Origin, ip, flushCts.Token).ConfigureAwait(false);
}
catch (Exception e)
{
logger.Warning(e, "CrowdSec flush-on-stop retract failed for {Address}", ip);
RecordSendFailure(1);
}
}
}
public void Report(IPAddress address, TimeSpan ttl, string reason) =>
Enqueue(new ReportItem(address, ttl, reason, false));
public void Retract(IPAddress address) =>
Enqueue(new ReportItem(address, TimeSpan.Zero, "retract", true));
private void Enqueue(ReportItem item)
{
if (_queue == null || !_queue.Writer.TryWrite(item))
{
Interlocked.Increment(ref _dropped);
}
}
// FullMode.Wait (the default) makes TryWrite return false immediately when the channel is full
// instead of blocking the caller — exactly the non-blocking drop-on-overflow behavior the accept
// path requires. DropWrite would silently discard the new item and always report success, which
// would make overflow undetectable.
private static Channel<ReportItem> CreateQueue(int capacity) =>
Channel.CreateBounded<ReportItem>(new BoundedChannelOptions(Math.Max(1, capacity))
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true
});
// Must return Task: Start() passes this to Task.Run, which has no Func<ValueTask> overload, so a
// ValueTask would bind to Task.Run<TResult> and yield a Task<ValueTask> that completes at the first
// await rather than when the loop exits.
private async Task DrainLoop(CancellationToken token)
{
var reader = _queue.Reader;
while (!token.IsCancellationRequested)
{
try
{
if (!await reader.WaitToReadAsync(token).ConfigureAwait(false))
{
return;
}
// Coalesce a burst before flushing.
await Task.Delay(_settings.FlushInterval, token).ConfigureAwait(false);
List<ReportItem> reports = [];
List<ReportItem> retracts = [];
while (reader.TryRead(out var item))
{
(item.Retract ? retracts : reports).Add(item);
}
if (reports.Count > 0)
{
var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow);
if (!await SendWithBoundedRetryAsync(() => _client.PostAlertsAsync(alerts, token), token)
.ConfigureAwait(false))
{
RecordSendFailure(alerts.Count);
}
}
for (var i = 0; i < retracts.Count; i++)
{
var ip = retracts[i].Ip;
if (!await SendWithBoundedRetryAsync(() => _client.DeleteDecisionsAsync(_settings.Origin, ip, token), token)
.ConfigureAwait(false))
{
RecordSendFailure(1);
}
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
// Contribution is auxiliary: log and keep draining. Never crash the shard.
logger.Warning(e, "CrowdSec contribution flush failed; dropped this batch");
}
}
}
/// <summary>
/// Sends with up to 3 attempts total (1 initial + 2 retries), backing off 1s then 2s between
/// attempts, for transient LAPI failures (network blips, 5xx). Backoff uses <see cref="Task.Delay"/>
/// so it never blocks the thread; a cancellation during backoff propagates as
/// <see cref="OperationCanceledException"/> so the drain loop exits cleanly. Returns false (never
/// throws for a send failure) once attempts are exhausted, so the caller can count the drop and keep
/// draining instead of losing the rest of the batch/queue.
/// </summary>
private static async ValueTask<bool> SendWithBoundedRetryAsync(Func<ValueTask> send, CancellationToken token)
{
for (var attempt = 0; ; attempt++)
{
try
{
await send().ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception e)
{
if (attempt >= _retryDelays.Length)
{
logger.Warning(e, "CrowdSec send failed after {Attempts} attempt(s); giving up", attempt + 1);
return false;
}
var delay = _retryDelays[attempt];
logger.Warning(e, "CrowdSec send failed (attempt {Attempt}); retrying in {Delay}", attempt + 1, delay);
await Task.Delay(delay, token).ConfigureAwait(false);
}
}
}
private void RecordSendFailure(int itemCount)
{
var total = Interlocked.Increment(ref _sendFailures);
logger.Warning(
"CrowdSec contribution batch dropped after retries ({Items} item(s)); total dropped batches: {Total}",
itemCount,
total
);
}
/// <summary>Coalesces items by IP (last write wins) and builds one alert per unique address.</summary>
internal static List<CrowdSecAlert> BuildAlerts(IEnumerable<ReportItem> items, CrowdSecSettings settings, DateTime nowUtc)
{
Dictionary<string, ReportItem> byIp = [];
foreach (var item in items)
{
byIp[item.Ip.ToString()] = item;
}
var timestamp = nowUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
var alerts = new List<CrowdSecAlert>(byIp.Count);
foreach (var (value, item) in byIp)
{
var ttl = item.Reason == "manual" || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl;
var scenario = $"{settings.Origin}/{item.Reason}";
alerts.Add(new CrowdSecAlert
{
Scenario = scenario,
Message = $"ModernUO {item.Reason} ban for {value}",
StartAt = timestamp,
StopAt = timestamp,
Source = new CrowdSecSource { Scope = "Ip", Value = value },
Decisions =
[
new CrowdSecDecisionDto
{
Origin = settings.Origin,
Type = "ban",
Scope = "Ip",
Value = value,
Duration = FormatDuration(ttl),
Scenario = scenario
}
]
});
}
return alerts;
}
/// <summary>CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient.</summary>
internal static string FormatDuration(TimeSpan ttl)
{
var seconds = (long)ttl.TotalSeconds;
return $"{Math.Max(1, seconds)}s";
}
}

View file

@ -0,0 +1,71 @@
/*************************************************************************
* 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;
}
}

View file

@ -0,0 +1,60 @@
/*************************************************************************
* 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)
{
// Core owns the CIDR -> normalized range parse.
if (!IPAddressUtility.TryParseCidrRange(ipAddressOrCidr, out var min, out var max))
{
throw new ArgumentException("Invalid IP address or CIDR.", nameof(ipAddressOrCidr));
}
MinIpAddress = min;
MaxIpAddress = max;
}
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);
}
}

View file

@ -0,0 +1,413 @@
/*************************************************************************
* 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.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. 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 = [];
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);
for (var i = 0; i < _entries.Count; i++)
{
var entry = _entries[i];
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"/> &lt;= <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;
}
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<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;
}
// 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<FirewallEntryRecord>(_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
}
/// <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 = 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;
}
}

View file

@ -0,0 +1,52 @@
/*************************************************************************
* 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 is the cheapest gate to consult — an empty set costs one length compare — so
/// <see cref="Firewall.Configure"/> registers it first.
/// </summary>
internal sealed class FirewallConnectionFilter : IConnectionFilter
{
public static readonly FirewallConnectionFilter Instance = new();
private FirewallConnectionFilter()
{
}
public string Name => "firewall";
// Firewall.Configure() owns loading and registration.
public void Register()
{
}
// Nothing to hydrate: the set loads at Configure and is 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);
}

View file

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

View file

@ -0,0 +1,59 @@
/*************************************************************************
* 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);
}

View file

@ -0,0 +1,30 @@
/*************************************************************************
* 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();
}