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:
parent
bec4cfa910
commit
c39454137e
52 changed files with 4655 additions and 547 deletions
143
Projects/Server/Network/Bans/BanChannel.cs
Normal file
143
Projects/Server/Network/Bans/BanChannel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Projects/Server/Network/Bans/BanConfiguration.cs
Normal file
76
Projects/Server/Network/Bans/BanConfiguration.cs
Normal 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);
|
||||
}
|
||||
55
Projects/Server/Network/Bans/IBanReporter.cs
Normal file
55
Projects/Server/Network/Bans/IBanReporter.cs
Normal 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);
|
||||
}
|
||||
155
Projects/Server/Network/ConnectionFilters.cs
Normal file
155
Projects/Server/Network/ConnectionFilters.cs
Normal 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 = [];
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BaseFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public abstract class BaseFirewallEntry : IFirewallEntry, ISpanFormattable
|
||||
{
|
||||
public abstract UInt128 MinIpAddress { get; }
|
||||
public abstract UInt128 MaxIpAddress { get; }
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool IsBlocked(IPAddress address) => IsBlocked(address.ToUInt128());
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool IsBlocked(UInt128 address) => address >= MinIpAddress && address <= MaxIpAddress;
|
||||
|
||||
public override string ToString() =>
|
||||
MinIpAddress == MaxIpAddress ? MinIpAddress.ToIpAddress().ToString()
|
||||
: $"{MinIpAddress.ToIpAddress()}-{MaxIpAddress.ToIpAddress()}";
|
||||
|
||||
public string ToString(string? format, IFormatProvider? formatProvider) =>
|
||||
// format and provider are explicitly ignored
|
||||
ToString();
|
||||
|
||||
public bool TryFormat(
|
||||
Span<char> destination,
|
||||
out int charsWritten,
|
||||
ReadOnlySpan<char> format,
|
||||
IFormatProvider? provider
|
||||
)
|
||||
{
|
||||
if (!((ISpanFormattable)MinIpAddress.ToIpAddress()).TryFormat(destination, out charsWritten, format, provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MinIpAddress == MaxIpAddress)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Range
|
||||
destination[charsWritten++] = '-';
|
||||
|
||||
var total = charsWritten;
|
||||
|
||||
if (!((ISpanFormattable)MaxIpAddress.ToIpAddress()).TryFormat(destination[charsWritten..], out charsWritten, format, provider))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
charsWritten += total;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: CidrFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public class CidrFirewallEntry : BaseFirewallEntry
|
||||
{
|
||||
public override UInt128 MinIpAddress { get; }
|
||||
public override UInt128 MaxIpAddress { get; }
|
||||
|
||||
public CidrFirewallEntry(string ipAddressOrCidr)
|
||||
: this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength)
|
||||
{
|
||||
}
|
||||
|
||||
public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress)
|
||||
{
|
||||
MinIpAddress = minAddress.ToUInt128();
|
||||
MaxIpAddress = maxAddress.ToUInt128();
|
||||
}
|
||||
|
||||
public CidrFirewallEntry(IPAddress ipAddress, int prefixLength)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
|
||||
if (ipAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
{
|
||||
prefixLength += 96; // 32 -> 128
|
||||
}
|
||||
|
||||
ipAddress.WriteMappedIPv6To(bytes);
|
||||
|
||||
MinIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, false);
|
||||
MaxIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, true);
|
||||
}
|
||||
|
||||
private static IPAddress ParseIPAddress(ReadOnlySpan<char> ipString, out int prefixLength)
|
||||
{
|
||||
var slashIndex = ipString.IndexOf('/');
|
||||
var ipAddress = IPAddress.Parse(slashIndex > -1 ? ipString[..slashIndex] : ipString);
|
||||
var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
|
||||
|
||||
if (slashIndex == -1)
|
||||
{
|
||||
prefixLength = maxPrefixLength;
|
||||
}
|
||||
else
|
||||
{
|
||||
var prefixPart = ipString[(slashIndex + 1)..];
|
||||
|
||||
if (!int.TryParse(prefixPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
|
||||
{
|
||||
throw new ArgumentException("Invalid prefix length.");
|
||||
}
|
||||
}
|
||||
|
||||
return ipAddress;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: IFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public interface IFirewallEntry : IComparable<IFirewallEntry>
|
||||
{
|
||||
UInt128 MinIpAddress { get; }
|
||||
UInt128 MaxIpAddress { get; }
|
||||
|
||||
int IComparable<IFirewallEntry>.CompareTo(IFirewallEntry? other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (MinIpAddress < other.MinIpAddress)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (MinIpAddress > other.MinIpAddress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (MaxIpAddress > other.MaxIpAddress)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (MaxIpAddress < other.MaxIpAddress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0; // Equal ranges
|
||||
}
|
||||
|
||||
bool IsBlocked(IPAddress address);
|
||||
|
||||
bool IsBlocked(UInt128 address);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: SingleIpFirewallEntry.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public class SingleIpFirewallEntry : BaseFirewallEntry
|
||||
{
|
||||
public override UInt128 MinIpAddress { get; }
|
||||
|
||||
public override UInt128 MaxIpAddress => MinIpAddress;
|
||||
|
||||
public SingleIpFirewallEntry(string ipAddress) => MinIpAddress = IPAddress.Parse(ipAddress).ToUInt128();
|
||||
|
||||
public SingleIpFirewallEntry(IPAddress ipAddress) => MinIpAddress = ipAddress.ToUInt128();
|
||||
}
|
||||
60
Projects/Server/Network/IConnectionFilter.cs
Normal file
60
Projects/Server/Network/IConnectionFilter.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue