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

@ -0,0 +1,143 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanChannel.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 Server.Logging;
namespace Server.Network.Bans;
/// <summary>
/// Coordinates the configured <see cref="IBanReporter"/> contribution sinks. Enforcement is NOT here —
/// the accept path asks <see cref="ConnectionFilters"/>. This channel only fans locally-decided bans out
/// to external systems (CrowdSec), which distribute them to OS-level bouncers.
/// </summary>
public static class BanChannel
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel));
private static IBanReporter[] _reporters = [];
public static IReadOnlyList<IBanReporter> Reporters => _reporters;
/// <summary>
/// Registers a contribution sink from content (inversion of control). Idempotent by
/// <see cref="IBanReporter.Name"/>: a second registration of the same name is ignored. Configures the
/// reporter immediately so it is ready before <see cref="Start"/>.
/// </summary>
public static void Register(IBanReporter reporter)
{
if (reporter == null)
{
return;
}
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
if (reporters[i].Name == reporter.Name)
{
return;
}
}
reporter.Register();
var updated = new IBanReporter[_reporters.Length + 1];
Array.Copy(_reporters, updated, _reporters.Length);
updated[^1] = reporter;
_reporters = updated;
logger.Information("Ban channel registered reporter '{Name}'", reporter.Name);
}
internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? [];
public static void Start(CancellationToken token)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
var reporter = reporters[i];
try
{
reporter.Start(token);
}
catch (Exception e)
{
// A broken contribution path must not crash boot — enforcement is local and unaffected.
logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name);
}
}
}
public static void Stop()
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
var reporter = reporters[i];
try
{
reporter.Stop();
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name);
}
}
}
/// <summary>Fans a locally-decided ban out to every reporter. Non-blocking; never throws.</summary>
public static void Report(IPAddress ip, TimeSpan ttl, string reason)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
try
{
reporters[i].Report(ip, ttl, reason);
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name);
}
}
}
/// <summary>Fans a retraction (manual unban) out to every retract-capable reporter.</summary>
public static void Retract(IPAddress ip)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
if (!reporters[i].CanRetract)
{
continue;
}
try
{
reporters[i].Retract(ip);
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name);
}
}
}
}

View file

@ -0,0 +1,76 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanConfiguration.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="BanSettings"/> from <c>Configuration/bans.json</c> (matching the per-feature
/// JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file writes a
/// local-only, fail-open template so operators have something to edit.
/// </summary>
public static class BanConfiguration
{
private const string _path = "Configuration/bans.json";
public static BanSettings Settings { get; private set; }
public static void Configure()
{
// Idempotent: a second call must not re-deserialize or overwrite an operator's edits.
if (Settings != null)
{
return;
}
var path = Path.Join(Core.BaseDirectory, _path);
if (File.Exists(path))
{
Settings = JsonConfig.Deserialize<BanSettings>(path);
}
else
{
Settings = new BanSettings
{
ReportRateLimitTrips = true,
AutoBanDuration = TimeSpan.FromHours(4)
};
Save();
}
}
private static void Save()
{
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
}
}
/// <summary>Ban-channel policy: which reporters receive contributions, and how auto-detections are handled.</summary>
public record BanSettings
{
/// <summary>Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set.</summary>
[JsonPropertyName("reportRateLimitTrips")]
public bool ReportRateLimitTrips { get; set; } = true;
/// <summary>Duration reported for an auto-detected (rate-limit) ban.</summary>
[JsonPropertyName("autoBanDuration")]
public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4);
}

View file

@ -0,0 +1,55 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IBanReporter.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.Threading;
namespace Server.Network.Bans;
/// <summary>
/// A contribution sink behind <see cref="BanChannel"/>. Reporters receive locally-decided bans
/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system
/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path
/// membership query — that is an <see cref="IConnectionFilter"/>'s job.
/// </summary>
public interface IBanReporter
{
/// <summary>Stable id for logging/config (e.g. <c>crowdsec</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. No network or file I/O here.</summary>
void Register();
/// <summary>Starts background delivery. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down background delivery.</summary>
void Stop();
/// <summary>
/// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only
/// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O.
/// </summary>
/// <param name="ttl"><see cref="TimeSpan.Zero"/> or negative = use the reporter's default duration.</param>
/// <param name="reason">Short slug (<c>manual</c>, <c>rate-limit</c>) used as the scenario suffix.</param>
void Report(IPAddress address, TimeSpan ttl, string reason);
/// <summary>True if this reporter can retract a previously-reported ban.</summary>
bool CanRetract { get; }
/// <summary>Enqueues a retraction (e.g. a manual unban). No-op if unsupported.</summary>
void Retract(IPAddress address);
}

View file

@ -0,0 +1,155 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ConnectionFilters.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 Server.Logging;
namespace Server.Network;
/// <summary>
/// Registry of the <see cref="IConnectionFilter"/> gates the accept path consults, and their lifecycle.
/// Filters are registered during the Configure sweep and the backing store is a plain array, so
/// <see cref="ShouldDeny"/> is an indexed loop over a field read — no enumerator, no closure, no
/// allocation. The whole accept path runs on the game loop, so no synchronization is needed.
/// </summary>
public static class ConnectionFilters
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters));
private static IConnectionFilter[] _filters = [];
public static IReadOnlyList<IConnectionFilter> Filters => _filters;
/// <summary>
/// Registers a gate (inversion of control, mirroring <c>BanChannel.Register</c>). Idempotent by
/// <see cref="IConnectionFilter.Name"/>. Filters are consulted in registration order, so register
/// the cheapest and most selective first — core registers the firewall before content is swept.
/// </summary>
public static void Register(IConnectionFilter filter)
{
if (filter == null)
{
return;
}
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
if (filters[i].Name == filter.Name)
{
return;
}
}
filter.Register();
var updated = new IConnectionFilter[_filters.Length + 1];
Array.Copy(_filters, updated, _filters.Length);
updated[^1] = filter;
_filters = updated;
logger.Information("Registered connection filter '{Name}'", filter.Name);
}
/// <summary>
/// True when any filter denies the connection. Short-circuits on the first denial;
/// <paramref name="deniedBy"/> names it for logging.
/// </summary>
public static bool ShouldDeny(IPAddress address, out string deniedBy)
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
// A faulty filter must not take down the accept loop for every connection.
try
{
if (filters[i].ShouldDeny(address))
{
deniedBy = filters[i].Name;
return true;
}
}
catch (Exception e)
{
Disable(filters[i], e);
}
}
deniedBy = null;
return false;
}
/// <summary>
/// Drops a filter that threw on the accept path: one that faults once faults for every subsequent
/// connection, costing an exception and a log line per accept. Failing open is deliberate — a broken
/// filter must not be able to deny every connection either.
/// </summary>
private static void Disable(IConnectionFilter filter, Exception e)
{
logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name);
var filters = _filters;
var updated = new List<IConnectionFilter>(filters.Length);
for (var i = 0; i < filters.Length; i++)
{
if (!ReferenceEquals(filters[i], filter))
{
updated.Add(filters[i]);
}
}
_filters = updated.ToArray();
}
public static void Start(CancellationToken token)
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
var filter = filters[i];
try
{
filter.Start(token);
}
catch (Exception e)
{
// A filter that cannot hydrate must not crash boot; it simply denies nothing.
logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name);
}
}
}
public static void Stop()
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
var filter = filters[i];
try
{
filter.Stop();
}
catch (Exception e)
{
logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name);
}
}
}
internal static void ResetForTesting() => _filters = [];
}

View file

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

View file

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

View file

@ -1,168 +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.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading;
namespace Server.Network;
public static class Firewall
{
[ThreadStatic]
private static InternalValidationEntry _validationEntry;
private static readonly ConcurrentDictionary<IPAddress, int> _isBlockedCache = [];
private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion);
private static int _firewallVersion;
private static readonly SortedSet<IFirewallEntry> _firewallSet = [];
public static int FirewallSetCount => _firewallSet.Count;
public static void ReadFirewallSet(Action<IReadOnlySet<IFirewallEntry>> callback)
{
_firewallLock.EnterReadLock();
try
{
callback(_firewallSet);
}
finally
{
_firewallLock.ExitReadLock();
}
}
internal static bool IsBlocked(IPAddress address)
{
if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion)
{
return true;
}
if (_validationEntry == null)
{
_validationEntry = new InternalValidationEntry(address);
}
else
{
_validationEntry.Address = address;
}
if (CheckBlocked(_validationEntry))
{
_isBlockedCache[address] = _firewallVersion;
return true;
}
return false;
}
private static bool CheckBlocked(IFirewallEntry validationEntry)
{
if (_firewallSet.Count == 0)
{
return false;
}
_firewallLock.EnterReadLock();
try
{
var min = _firewallSet.Min;
if (validationEntry.CompareTo(min) < 0)
{
return false;
}
// Get all entries that are lower than our validation entry
var view = _firewallSet.GetViewBetween(min, validationEntry);
// Loop backward since there shouldn't be any entries where the Min address is higher than ours
foreach (var firewallEntry in view.Reverse())
{
if (firewallEntry.IsBlocked(validationEntry.MinIpAddress))
{
return true;
}
}
return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true;
}
finally
{
_firewallLock.ExitReadLock();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Add(IFirewallEntry firewallEntry)
{
_firewallLock.EnterWriteLock();
try
{
if (_firewallSet.Add(firewallEntry))
{
Interlocked.Increment(ref _firewallVersion); // Update version
return true;
}
return false;
}
finally
{
_firewallLock.ExitWriteLock();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Remove(IFirewallEntry entry)
{
if (entry == null)
{
return false;
}
_firewallLock.EnterWriteLock();
try
{
if (_firewallSet.Remove(entry))
{
Interlocked.Increment(ref _firewallVersion); // Update version
return true;
}
return false;
}
finally
{
_firewallLock.ExitWriteLock();
}
}
private class InternalValidationEntry : BaseFirewallEntry
{
private UInt128 _address;
public IPAddress Address
{
set => _address = value.ToUInt128();
}
public override UInt128 MinIpAddress => _address;
public override UInt128 MaxIpAddress => _address;
public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress;
}
}

View file

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

View file

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

View file

@ -0,0 +1,60 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IConnectionFilter.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>
/// 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 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>
/// <see cref="ShouldDeny"/> runs on the game loop once per accepted socket, which is the path that has
/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform
/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service)
/// belongs off the loop or behind a bounded, non-blocking enqueue.
/// </para>
/// <para>
/// Side effects that a hit implies (contributing to <c>BanChannel</c>, promoting to an OS firewall,
/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why
/// <see cref="ShouldDeny"/> returns a bare bool: the accept path asks one question and does one thing.
/// </para>
/// </remarks>
public interface IConnectionFilter
{
/// <summary>Stable id for logging/config (e.g. <c>firewall</c>, <c>blocklist</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. Called by <see cref="ConnectionFilters.Register"/>. No I/O beyond config.</summary>
void Register();
/// <summary>Starts any background hydration. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down. Called during shutdown.</summary>
void Stop();
/// <summary>
/// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on
/// <see cref="IConnectionFilter"/>.
/// </summary>
bool ShouldDeny(IPAddress address);
}

View file

@ -224,10 +224,19 @@ public partial class NetState
if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts))
{
logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts);
if (Bans.BanConfiguration.Settings.ReportRateLimitTrips)
{
// Enqueue-only contribution; NOT added to the local firewall set (the limiter already
// gates it here and the OS bouncer drops it at the kernel).
Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, "rate-limit");
}
}
else if (Firewall.IsBlocked(remoteIP))
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
logger.Debug("{Address} Firewalled", remoteIP);
// Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the
// ban channel) is the filter's own business; the accept path just drops the socket.
logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
}
else
{