feat(network): allowlist false-positive IPs, escalate on behavior (#2556)
## Why The shard owner, on a Starlink CGNAT address, was blocked by the imported reputation blocklist. The cause was not CrowdSec. The address was a literal line in `ip-blocklist.txt`, so `BlocklistFilter` denied it at accept and then promoted it — and clearing the CrowdSec decision could not fix it either, because the file entry re-reports within `promoteSuppression` of every reconnect attempt. This is structural, not a one-off. Reputation feeds list shared consumer address space constantly: on CGNAT one public address fronts many subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else behind it is blocked with them. Where leases rotate, a listing says little about whoever holds the address now. Around 1,000 Starlink addresses sit in the current list. So exemptions go where they cost nothing, and escalation is driven by what a connection actually does. ## Generator — `tools/Export-IpBlocklist.ps1` `-AllowlistFile` takes multiple paths, subtracted from the merged set before the output is written. Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set: - `ip-allowlist.txt` — operator exemptions, created once and **never rewritten** - `ip-allowlist-<name>.txt` — a carve-out you built, regenerable and copyable between shards **Subtraction is range-correct.** An allowlisted address inside a blocked CIDR splits that CIDR around the hole rather than being silently ignored. This also fixes `-ExcludeAnonymizers`, which parsed CIDR entries into `$anonCidr` and then only ever subtracted singles. **No carve-out ships.** A carve-out names a real network, and which ones a shard should exempt depends on where its players actually are — so publishing one would make that policy call for every shard and put a specific provider's address space in the repo. The script builds them on request instead: ```powershell .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 ``` Carve-outs are **discovered, not configured**: every `ip-allowlist*.txt` beside the output is subtracted, by the generator and by the shard, so a file an admin adds needs no config edit and no code change. Each carries an `asn=` marker in its header, which is how `-RefreshCarveouts` rebuilds it without the script keeping a list of anyone's networks; a hand-written allowlist has no marker and is never rewritten. Prefixes come from **announcements, not ownership records**, because registry data disagrees with what is actually routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and `206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink. Editing an allowlist bypasses `-MinInterval`, so a just-added exemption isn't indistinguishable from the allowlist not working. A Starlink carve-out, if you build one, costs **~4,300 IPs + ~144 CIDRs of 4.2M (0.10%)**. ## Allowlists **`FileAllowlist`** reads the same files the generator subtracts, so an operator entry means "leave this address alone" for real. Subtraction alone only covers being *blocked*; behavioural detections never consult the blocklist, so without this a carve-out was quietly routed around — one scanner behind a shared address was enough to get everyone behind it contributed and firewalled, with nothing in the shard's own config explaining why. Reading the files also means an entry applies on the next reload rather than the next regeneration, which is what matters when someone is complaining now. **`LoginAllowlist`** is earned by authenticating, with a 90-day TTL because an address that logged in years ago is a stranger. Its own store rather than `Account.LoginIPs`, which has no timestamps and cannot be backfilled. An entry is evidence rather than a licence: 10 suppressed contributions in an hour revokes it, and a fresh login forgives the tally. Both are consulted **only after the blocklist has already matched**, so a normal accept pays nothing for them and the accept gate stays allowlist-free. `BanExemptions` combines them behind `BanChannel.IsExempt` and suppresses escalation only — every local defence still applies. Two limits, both deliberate and documented in the class: `LoginAllowlist` **cannot bootstrap** (an entry is only earned by getting in, so it never repairs an existing false positive), and it is weakest on rotating CGNAT. That is why `FileAllowlist` is the fix for those, and why it is manual. ## Behavioural detection | Reason | Trigger | |---|---| | `silent-connect` | Reaped after 5s having sent **zero bytes** | | `invalid-seed` | Opened with a zero seed | | `foreign-protocol` | Positively identified as HTTP, TLS or SSH | **`ForeignProtocol` inverts the test.** Asking "is this a good UO client?" cannot work: `LoginEncryption.ClientDecrypt` is a byte-for-byte stream XOR, so a legitimate client with encryption enabled when the shard expects none sends a structurally perfect connection whose payload is noise. "Speaks HTTP" is safe where "unreadable" is not — however misconfigured a UO client is, it never sends `GET / HTTP/1.1`. Nothing assumes arrival framing. TCP has no message boundaries, so a rule of the form "these bytes must arrive together" is broken by construction and drops real players on poor links. A prefix match with too few bytes to confirm waits for more. A four-byte seed can legitimately spell `GET ` (the address 71.69.84.32) or `0x16 0x03 0x0?` (22.3.x.x), so confirmation requires the request line to continue in printable ASCII or an actual ClientHello inside a plausible record — a real client's fifth byte is a packet id (`0x80`, `0x91`, `0xEF`), none of them printable, so those collisions fall through. Everything is keyed on **bytes-received rather than elapsed time**. A connection that sent something and ran out of time is far more likely a slow link than an attack, and banning those produces the worst failure mode available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being firewalled off. ## `AutoDenylist` A short-lived local hold (15m) on behavioural detections, as `IConnectionFilter` + `IBanReporter` over one store so the engine detection sites never reach into content. This closes the gap where a flood pays for a socket, buffer and `NetState` slot per connection while waiting for the OS bouncer — the verdicts that matter most are reachable only *after* reading bytes — and it is the entire defence on a shard running no bouncer, which is the default config. Not persisted: a holding pen that survives restarts is a ban without a ban's review. Cost: one dictionary lookup on a usually-empty dict per accept. ## `BanReasons` Centralises the reason slugs. `IsBehavioral` is an **opt-in** set, not "everything except manual", so a future reason escalates normally instead of silently inheriting an exemption or entering a local denylist. This caught a real bug during review: the first cut of the exemption swallowed `manual` admin bans (`Commands.cs`, three sites in `AdminGump`) for any allowlisted address. ## Fixes found in review - **`BanConfiguration.Settings` was null until `Configure()` ran**, while the reap path dereferences it every `Slice()`. A harness driving `NetState.Slice()` directly hit an NRE that presented as flaky because it depended on whether an earlier test had already called `Configure()` — which is why it failed on some CI platforms and not others. Now starts at the record's defaults, with idempotency tracked by a flag; this also removes the same latent NRE from the pre-existing rate-limit path. - **`-AllowlistFile` was typed `[string]`** while documented and used as a list, so passing two paths would have collapsed them into one string. ## Layout and docs Content network code moves out of `Misc/` into `UOContent/Network/`, one concern per folder — `AutoDenylist/`, `Blocklist/`, `CrowdSec/`, `Firewall/`, `LoginAllowlist/`, `Packets/`. **Namespaces are untouched**, so these are pure file moves (git tracks all 16 as renames). `dev-docs/ip-bans-and-allowlists.md` documents the subsystem, leading with the operator process for unblocking a player — including the three things that look sufficient and are not: deleting the CrowdSec decision alone, editing `ip-blocklist.txt` by hand, and `cscli allowlists` alone. `.gitignore` covers the new config files. ## Testing Build clean. **Server.Tests 810 passed**, **UOContent.Tests 637 passed**, zero warnings. This branch adds 38 tests; the rest of the delta is main's, since this is rebased on current `main`. New coverage: TTL boundary and renewal, private-address exclusion, manual-ban-never-exempt, unopted-reason-never-exempt, strike revocation, quiet-window reset, login forgiveness, file-allowlist CIDR coverage, file-allowlist not spending the earned list's strikes, denylist expiry-on-read, cap enforcement, lapsed-entry reclaim, HTTP/TLS/SSH identification, seed-collision fall-through, and encrypted-login-is-not-foreign. Generator verified end-to-end against live feeds: a clean run ships no carve-out, `-AddCarveout starlink -Asn 14593` fetches and collapses 213 prefixes to 115 ranges in 0.1s over 4.2M entries, `-RefreshCarveouts` rediscovers it by its `asn=` marker, a hand-written allowlist is left untouched, and deleting a carve-out drops it rather than having it rewritten. CIDR splitting verified exhaustively: a single-IP hole in a /24 leaves exactly 255 of 256 addresses blocked. ## Operator note Existing installs are unaffected until the generator next runs, which creates `ip-allowlist.txt` and nothing else. To unblock someone: add the address to that file and delete any live CrowdSec decision — the existing ban outlives the config change. The shard picks the entry up on its next reload, so re-running the generator is optional. A shard whose players are on CGNAT (satellite, mobile, or an ISP short on IPv4) will likely also want `-AddCarveout`; see `dev-docs/ip-bans-and-allowlists.md`. ## Also included: a latent CI failure this PR surfaced `fix(tests): serialize test classes that rent through STArrayPool` touches a property-list test file that has nothing to do with this feature. It is here because it was failing macOS CI, and it is trivially cherry-pickable out if you would rather it went to `main` on its own — **which may be the better call, since it is failing `main` today.** CI has since gone green with it applied. `STArrayPool` is single-threaded by design and its bucket cache is a plain `static`, not `[ThreadStatic]`, with a check-then-act initialize in `Return()`: ```csharp var cacheBuckets = _cacheBuckets ?? InitializeBuckets(); ``` Two threads both see null, both initialize, and the loser trips `Debug.Assert(_cacheBuckets is null)`. Anything renting from it has to stay off parallel test threads — which is what the `DisableParallelization` collections are for. - `ObjectPropertyListReentrancyTests` and `ObjectPropertyListNestedBuildTests` (added in #2555) build property lists, which rent the interpolation buffer, but were not in the sequential collection — unlike `PropertyListInvalidationDuringBuildTests` in the same file. This is a **latent failure already on `main`**; it is timing-dependent, so it shows on some platforms and not others. - `AutoDenylistTests` (added here) has the same exposure: its cap tests reach `AutoDenylist.Sweep`, which rents a `PooledRefList` without `mt`. The blocklist tests need no marking because `BlocklistSnapshot.Build` asks for the `mt` pool explicitly. No production change — `STArrayPool` is the right pool on the game loop, where both `Sweep` and the property list actually run. ## Deliberately not included Waiting for a fragmented four-byte seed at `AwaitingSeed`. It looked like a bug but the disconnect is a deliberate defence: only pre-0xEF clients reach it (0xEF goes through `HandlePacket`, which already waits for its 21 bytes), and waiting converts an instant drop into a full 5s slot hold for a client sending one or two bytes, or a loris dribbling a byte every few seconds. Against a fixed 4096-entry `MaxConnections` table that trades capacity that matters for a fragmentation case a reconnect already fixes.
This commit is contained in:
parent
b8d3fec59a
commit
aae173a797
42 changed files with 2925 additions and 32 deletions
|
|
@ -324,6 +324,7 @@ public static class AccountHandler
|
|||
e.Accepted = true;
|
||||
|
||||
acct.LogAccess(e.State);
|
||||
LoginAllowlist.RecordLogin(e.State?.Address);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +356,7 @@ public static class AccountHandler
|
|||
else
|
||||
{
|
||||
acct.LogAccess(e.State);
|
||||
LoginAllowlist.RecordLogin(e.State?.Address);
|
||||
|
||||
logger.Information("Login: {NetState} Account '{Username}' at character list", e.State, un);
|
||||
e.State.Account = acct;
|
||||
|
|
|
|||
|
|
@ -1156,7 +1156,7 @@ namespace Server.Commands.Generic
|
|||
try
|
||||
{
|
||||
Firewall.Add(new SingleIpFirewallEntry(state.Address));
|
||||
BanChannel.Report(state.Address, TimeSpan.Zero, "manual");
|
||||
BanChannel.Report(state.Address, TimeSpan.Zero, BanReasons.Manual);
|
||||
AddResponse("They have been firewalled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -1745,7 +1745,7 @@ namespace Server.Gumps
|
|||
for (var i = 0; i < a.LoginIPs.Length; ++i)
|
||||
{
|
||||
Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i]));
|
||||
BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, "manual");
|
||||
BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, BanReasons.Manual);
|
||||
}
|
||||
|
||||
notice = "All addresses in the list have been firewalled.";
|
||||
|
|
@ -1774,7 +1774,7 @@ namespace Server.Gumps
|
|||
|
||||
if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress)
|
||||
{
|
||||
BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, "manual");
|
||||
BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, BanReasons.Manual);
|
||||
}
|
||||
|
||||
notice = $"{toFirewall} : Added to firewall.";
|
||||
|
|
@ -3596,7 +3596,7 @@ namespace Server.Gumps
|
|||
BanChannel.Report(
|
||||
firewallEntry.MinIpAddress.ToIpAddress(),
|
||||
TimeSpan.Zero,
|
||||
"manual"
|
||||
BanReasons.Manual
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
219
Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs
Normal file
219
Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: AutoDenylist.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.Collections;
|
||||
using Server.Logging;
|
||||
using Server.Network.Bans;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
/// <summary>
|
||||
/// A short-lived, in-memory denylist of addresses the shard itself just caught misbehaving.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The local half of promotion. Contributing to CrowdSec only helps once an OS bouncer reacts; until then
|
||||
/// every reconnect costs a socket, a buffer and a <c>NetState</c> slot — and the verdicts that matter most
|
||||
/// are reachable only after reading bytes, like a zero seed. It is also the whole defence on a shard running
|
||||
/// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban
|
||||
/// without a ban's review. Only <see cref="BanReasons.IsBehavioral"/> verdicts are held.
|
||||
/// </remarks>
|
||||
public static class AutoDenylist
|
||||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist));
|
||||
|
||||
// Address (normalized v6 bits) -> Core.TickCount at which the hold lapses. Loop-only.
|
||||
private static readonly Dictionary<UInt128, long> _held = [];
|
||||
|
||||
private static bool _enabled;
|
||||
private static long _durationMs;
|
||||
private static int _maxEntries;
|
||||
private static bool _warnedFull;
|
||||
|
||||
public static int Count => _held.Count;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
AutoDenylistConfiguration.Load();
|
||||
var s = AutoDenylistConfiguration.Settings;
|
||||
|
||||
_enabled = s.Enabled && s.Duration > TimeSpan.Zero && s.MaxEntries > 0;
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_durationMs = (long)s.Duration.TotalMilliseconds;
|
||||
_maxEntries = s.MaxEntries;
|
||||
|
||||
ConnectionFilters.Register(new AutoDenylistFilter());
|
||||
BanChannel.Register(new AutoDenylistReporter());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds an address for the configured duration. Ignores non-behavioural verdicts and refuses to grow
|
||||
/// past the cap: the flood this exists for must not become a memory leak.
|
||||
/// </summary>
|
||||
public static void Hold(IPAddress address, string reason) => Hold(address, reason, Core.TickCount);
|
||||
|
||||
internal static bool Hold(IPAddress address, string reason, long nowTicks)
|
||||
{
|
||||
if (!_enabled || address == null || !BanReasons.IsBehavioral(reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = address.ToUInt128();
|
||||
|
||||
// An address already held is just extended, so no cap check is needed.
|
||||
if (!_held.ContainsKey(key) && _held.Count >= _maxEntries)
|
||||
{
|
||||
Sweep(nowTicks);
|
||||
|
||||
if (_held.Count >= _maxEntries)
|
||||
{
|
||||
if (!_warnedFull)
|
||||
{
|
||||
_warnedFull = true;
|
||||
logger.Warning(
|
||||
"Auto-denylist is full at {Max} addresses; further detections are disconnected but not held",
|
||||
_maxEntries
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_held[key] = nowTicks + _durationMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount);
|
||||
|
||||
/// <summary>The pure decision, split out so the accept-path policy can be tested without a clock.</summary>
|
||||
internal static bool IsDenied(IPAddress address, long nowTicks)
|
||||
{
|
||||
if (!_enabled || address == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decided on read, so a lapsed hold cannot deny even before the sweep. Subtraction: TickCount wraps.
|
||||
return _held.TryGetValue(address.ToUInt128(), out var expires) && expires - nowTicks > 0;
|
||||
}
|
||||
|
||||
/// <summary>Releases an address early, e.g. when an operator retracts a ban.</summary>
|
||||
public static void Release(IPAddress address)
|
||||
{
|
||||
if (_enabled && address != null)
|
||||
{
|
||||
_held.Remove(address.ToUInt128());
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Sweep(long nowTicks)
|
||||
{
|
||||
if (_held.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var lapsed = new PooledRefList<UInt128>(16);
|
||||
|
||||
foreach (var (address, expires) in _held)
|
||||
{
|
||||
if (expires - nowTicks <= 0)
|
||||
{
|
||||
lapsed.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < lapsed.Count; i++)
|
||||
{
|
||||
_held.Remove(lapsed[i]);
|
||||
}
|
||||
|
||||
if (lapsed.Count > 0)
|
||||
{
|
||||
_warnedFull = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries)
|
||||
{
|
||||
_held.Clear();
|
||||
_enabled = enabled;
|
||||
_durationMs = durationMs;
|
||||
_maxEntries = maxEntries;
|
||||
_warnedFull = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Accept-path gate for <see cref="AutoDenylist"/>.</summary>
|
||||
public sealed class AutoDenylistFilter : IConnectionFilter
|
||||
{
|
||||
public string Name => "auto-denylist";
|
||||
|
||||
public void Register()
|
||||
{
|
||||
}
|
||||
|
||||
public void Start(CancellationToken token)
|
||||
{
|
||||
// Only an optimisation: IsDenied expires on read.
|
||||
Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), () => AutoDenylist.Sweep(Core.TickCount));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
}
|
||||
|
||||
public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds <see cref="AutoDenylist"/> from the ban channel. A reporter rather than a direct call, because the
|
||||
/// detection sites live in the engine and must not reach into content.
|
||||
/// </summary>
|
||||
public sealed class AutoDenylistReporter : IBanReporter
|
||||
{
|
||||
public string Name => "auto-denylist";
|
||||
|
||||
public bool CanRetract => true;
|
||||
|
||||
public void Register()
|
||||
{
|
||||
}
|
||||
|
||||
public void Start(CancellationToken token)
|
||||
{
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The contributed <paramref name="ttl"/> is ignored: how long a bouncer should ban an address is a
|
||||
/// different question from how long this shard holds it at accept.
|
||||
/// </summary>
|
||||
public void Report(IPAddress address, TimeSpan ttl, string reason) => AutoDenylist.Hold(address, reason);
|
||||
|
||||
public void Retract(IPAddress address) => AutoDenylist.Release(address);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: AutoDenylistConfiguration.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;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="AutoDenylistSettings"/> from <c>Configuration/auto-denylist.json</c> (matching the
|
||||
/// per-feature JSON config pattern used by <c>BlocklistConfiguration</c>). Loaded once; a missing file writes
|
||||
/// a template so operators have something to edit.
|
||||
/// </summary>
|
||||
public static class AutoDenylistConfiguration
|
||||
{
|
||||
private const string _path = "Configuration/auto-denylist.json";
|
||||
|
||||
public static AutoDenylistSettings Settings { get; private set; }
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
var path = Path.Join(Core.BaseDirectory, _path);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Settings = JsonConfig.Deserialize<AutoDenylistSettings>(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Settings = new AutoDenylistSettings();
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Save()
|
||||
{
|
||||
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bound configuration for <see cref="AutoDenylist"/>.</summary>
|
||||
public record AutoDenylistSettings
|
||||
{
|
||||
/// <summary>Whether behavioural detections are held locally. Disabled makes the filter inert.</summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How long an address is denied at accept after the shard catches it misbehaving. Deliberately
|
||||
/// independent of the duration reported to external bouncers: this is a local holding pen, not a ban.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Short on purpose: it covers the gap before an OS bouncer reacts, and blunts a flood on shards running
|
||||
/// none. An address still attacking is simply re-detected and re-added, so the list sustains itself while
|
||||
/// a mistake clears on its own.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Hard cap on tracked addresses: a distinct-source flood is the case this exists for, so the cap is what
|
||||
/// stops it becoming the exhaustion it prevents. At the cap new addresses are not tracked, but are still
|
||||
/// disconnected by whichever gate detected them.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxEntries")]
|
||||
public int MaxEntries { get; set; } = 65536;
|
||||
}
|
||||
61
Projects/UOContent/Network/BanExemptions.cs
Normal file
61
Projects/UOContent/Network/BanExemptions.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BanExemptions.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 Server.Network.Bans;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Combines <see cref="FileAllowlist"/> and <see cref="LoginAllowlist"/> into the one answer
|
||||
/// <see cref="BanChannel.IsExempt"/> asks for, so neither source has to know about the other.
|
||||
/// </summary>
|
||||
public static class BanExemptions
|
||||
{
|
||||
public static void Configure()
|
||||
{
|
||||
BanChannel.IsExempt = IsExempt;
|
||||
}
|
||||
|
||||
public static bool IsExempt(IPAddress address, string reason) =>
|
||||
IsExempt(address, reason, LoginAllowlist.IsExemptFromEscalation);
|
||||
|
||||
/// <summary>
|
||||
/// Split for testing. <paramref name="loginAllowlist"/> is stateful — calling it spends a strike — so it
|
||||
/// must not be invoked once the answer is already decided.
|
||||
/// </summary>
|
||||
internal static bool IsExempt(IPAddress address, string reason, Func<IPAddress, string, bool> loginAllowlist)
|
||||
{
|
||||
if (address == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never suppress an operator's explicit ban. Checked first so it also costs no strike.
|
||||
if (!BanReasons.IsBehavioral(reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deliberate and unconditional, so it wins and must not spend the earned list's strikes.
|
||||
if (FileAllowlist.Contains(address))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return loginAllowlist(address, reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,19 @@ public record BlocklistSettings
|
|||
[JsonPropertyName("file")]
|
||||
public string File { get; set; } = "Configuration/ip-blocklist.txt";
|
||||
|
||||
/// <summary>
|
||||
/// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same
|
||||
/// files <c>tools/Export-IpBlocklist.ps1</c> subtracts at generation time; the shard reads them so an
|
||||
/// entry also suppresses ban contributions, which the generator alone cannot do. See
|
||||
/// <see cref="FileAllowlist"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds
|
||||
/// without anyone editing this file.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("allowlistFiles")]
|
||||
public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.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);
|
||||
|
|
@ -133,7 +133,7 @@ public sealed class BlocklistFilter : IConnectionFilter
|
|||
if (shouldReport)
|
||||
{
|
||||
// Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop.
|
||||
BanChannel.Report(address, _banDuration, "blocklist");
|
||||
BanChannel.Report(address, _banDuration, BanReasons.Blocklist);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -152,6 +152,21 @@ public sealed class BlocklistFilter : IConnectionFilter
|
|||
return false;
|
||||
}
|
||||
|
||||
// Both are asked only once the list has matched, so they cost the common accept nothing. The file
|
||||
// list is usually redundant because the generator subtracts it — except right after an operator adds
|
||||
// an entry without regenerating, which is exactly when someone is waiting to get back in.
|
||||
if (FileAllowlist.Contains(address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A feed listing an address a real player logged in from recently is far more often a false positive
|
||||
// than a compromise.
|
||||
if (LoginAllowlist.IsAllowed(address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_reportHits)
|
||||
{
|
||||
shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs);
|
||||
|
|
@ -182,6 +182,12 @@ public sealed class BlocklistSnapshot
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain set membership, for callers whose set is an ALLOWlist (see <see cref="FileAllowlist"/>) and for
|
||||
/// whom <see cref="IsBanned"/> would read backwards. The interval machinery is direction-agnostic.
|
||||
/// </summary>
|
||||
public bool Contains(IPAddress ip) => IsBanned(ip);
|
||||
|
||||
public bool IsBanned(IPAddress ip)
|
||||
{
|
||||
if (ip.IsIPv4MappedToIPv6)
|
||||
313
Projects/UOContent/Network/Blocklist/FileAllowlist.cs
Normal file
313
Projects/UOContent/Network/Blocklist/FileAllowlist.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: FileAllowlist.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Network.Bans;
|
||||
|
||||
/// <summary>
|
||||
/// The operator's own "leave this address alone" list, read from the same files
|
||||
/// <c>tools/Export-IpBlocklist.ps1</c> subtracts at generation time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The generator already subtracts these from the blocklist, but that only covers being BLOCKED.
|
||||
/// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is
|
||||
/// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address
|
||||
/// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the
|
||||
/// next regeneration. Unconditional, unlike <see cref="LoginAllowlist"/>, but still no shield against a
|
||||
/// manual ban — see <see cref="BanExemptions"/>.
|
||||
/// </remarks>
|
||||
public static class FileAllowlist
|
||||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist));
|
||||
|
||||
// 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 static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
|
||||
|
||||
private static string[] _patterns = [];
|
||||
private static TimeSpan _interval = TimeSpan.FromSeconds(60);
|
||||
private static long _lastStamp;
|
||||
private static CancellationTokenSource _cts;
|
||||
|
||||
public static int Count => _snapshot.Count;
|
||||
|
||||
/// <summary>True when an operator listed this address. Safe before <see cref="Initialize"/>.</summary>
|
||||
public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address);
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
// BlocklistFilter.Register ran during the Configure sweep, so the settings are populated.
|
||||
var settings = BlocklistConfiguration.Settings;
|
||||
if (settings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_patterns = ResolvePaths(settings.AllowlistFiles);
|
||||
_interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval;
|
||||
|
||||
if (_patterns.Length == 0)
|
||||
{
|
||||
logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)");
|
||||
return;
|
||||
}
|
||||
|
||||
Reload();
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token);
|
||||
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
private static string[] ResolvePaths(string[] configured)
|
||||
{
|
||||
if (configured == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var resolved = new string[configured.Length];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < configured.Length; i++)
|
||||
{
|
||||
var path = configured[i];
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Relative resolves against BaseDirectory, never the working directory, which differs when the
|
||||
// shard is launched from elsewhere. Absolute is as-is, so shards can share a list.
|
||||
resolved[count++] = Path.IsPathRooted(path) ? path : Path.Join(Core.BaseDirectory, path);
|
||||
}
|
||||
|
||||
Array.Resize(ref resolved, count);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands the configured patterns to actual files. Done per poll rather than once, so a carve-out an
|
||||
/// admin adds is picked up without a restart.
|
||||
/// </summary>
|
||||
private static string[] ExpandPaths()
|
||||
{
|
||||
var files = new List<string>();
|
||||
|
||||
for (var i = 0; i < _patterns.Length; i++)
|
||||
{
|
||||
var pattern = _patterns[i];
|
||||
var name = Path.GetFileName(pattern);
|
||||
|
||||
if (name.IndexOf('*') < 0 && name.IndexOf('?') < 0)
|
||||
{
|
||||
if (File.Exists(pattern))
|
||||
{
|
||||
files.Add(pattern);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(pattern);
|
||||
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matches = Directory.GetFiles(dir, name);
|
||||
Array.Sort(matches, StringComparer.Ordinal);
|
||||
|
||||
for (var j = 0; j < matches.Length; j++)
|
||||
{
|
||||
// Windows wildcard matching still honours legacy short names, so ".txt" can pull in the
|
||||
// generator's ".txt.tmp" mid-swap. Check the real extension.
|
||||
if (matches[j].EndsWith(".txt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(matches[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Unreadable directory; the next poll retries.
|
||||
}
|
||||
}
|
||||
|
||||
return files.ToArray();
|
||||
}
|
||||
|
||||
private static async ValueTask PollLoop(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_interval, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Stamp() != _lastStamp)
|
||||
{
|
||||
// A save owns the disk and nothing here is urgent.
|
||||
// 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, "File allowlist reload check failed; keeping last snapshot ({Count})", Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Change fingerprint across every configured file. A missing file contributes nothing, so creating or
|
||||
/// deleting one also registers as a change.
|
||||
/// </summary>
|
||||
private static long Stamp()
|
||||
{
|
||||
var stamp = 0L;
|
||||
var paths = ExpandPaths();
|
||||
|
||||
for (var i = 0; i < paths.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(paths[i]);
|
||||
if (info.Exists)
|
||||
{
|
||||
stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Mid-swap by the generator; the next poll picks it up.
|
||||
}
|
||||
}
|
||||
|
||||
return stamp;
|
||||
}
|
||||
|
||||
private static void Reload()
|
||||
{
|
||||
// Fingerprint BEFORE parsing, so it describes the version being read. Capturing after could skip a
|
||||
// version; a stale fingerprint only costs an extra reload.
|
||||
var stamp = Stamp();
|
||||
var combined = ReadAll(out var files);
|
||||
|
||||
// Reuses the blocklist parser and interval index: an address set is direction-agnostic.
|
||||
var next = combined.Length == 0
|
||||
? BlocklistSnapshot.Empty
|
||||
: BlocklistSnapshot.Build(combined, out _, out _);
|
||||
|
||||
_snapshot = next; // single volatile swap; readers see old or new whole
|
||||
_lastStamp = stamp;
|
||||
|
||||
logger.Information(
|
||||
"File allowlist loaded {Count} range(s) from {Files} file(s)",
|
||||
next.Count,
|
||||
files
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concatenates every configured file into one buffer. The parser is line-based, so a newline join is
|
||||
/// enough, and membership stays a single lookup.
|
||||
/// </summary>
|
||||
private static byte[] ReadAll(out int files)
|
||||
{
|
||||
files = 0;
|
||||
|
||||
var paths = ExpandPaths();
|
||||
var chunks = new byte[paths.Length][];
|
||||
var total = 0;
|
||||
|
||||
for (var i = 0; i < paths.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(paths[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(paths[i]);
|
||||
chunks[i] = bytes;
|
||||
total += bytes.Length + 1; // + newline separator
|
||||
files++;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Fail open per file: losing one entry beats refusing to load the rest.
|
||||
logger.Warning(e, "Could not read allowlist \"{Path}\"", paths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var combined = new byte[total];
|
||||
var offset = 0;
|
||||
|
||||
for (var i = 0; i < chunks.Length; i++)
|
||||
{
|
||||
var chunk = chunks[i];
|
||||
if (chunk == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Buffer.BlockCopy(chunk, 0, combined, offset, chunk.Length);
|
||||
offset += chunk.Length;
|
||||
combined[offset++] = (byte)'\n';
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
internal static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot ?? BlocklistSnapshot.Empty;
|
||||
}
|
||||
|
|
@ -363,7 +363,7 @@ public sealed class CrowdSecReporter : IBanReporter
|
|||
|
||||
foreach (var (value, item) in byIp)
|
||||
{
|
||||
var ttl = item.Reason == "manual" || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl;
|
||||
var ttl = item.Reason == BanReasons.Manual || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl;
|
||||
var scenario = $"{settings.Origin}/{item.Reason}";
|
||||
|
||||
alerts.Add(new CrowdSecAlert
|
||||
385
Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs
Normal file
385
Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: LoginAllowlist.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.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Server.Collections;
|
||||
using Server.Logging;
|
||||
using Server.Network.Bans;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
/// <summary>
|
||||
/// An allowlist addresses earn by logging in successfully, so a reputation feed cannot get a known player
|
||||
/// blocked and a flaky connection cannot get one globally banned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Consulted only after the blocklist has already matched, and again before a ban is contributed, so a
|
||||
/// normal accept pays nothing for it. An entry is evidence rather than a licence: enough strikes inside the
|
||||
/// window revokes it. It cannot bootstrap, so it hedges stable addresses and does not replace
|
||||
/// <see cref="FileAllowlist"/>. See <c>dev-docs/ip-bans-and-allowlists.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both dictionaries are game-loop state. Only the file write runs off-loop, over a snapshot taken on the
|
||||
/// loop.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LoginAllowlist
|
||||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoginAllowlist));
|
||||
|
||||
// Address (normalized v6 bits) -> unix seconds of its last successful login. Loop-only.
|
||||
private static readonly Dictionary<UInt128, long> _allowed = [];
|
||||
|
||||
// Suppressed contributions in the current window. Only holds allowlisted addresses, so it is bounded by
|
||||
// _allowed and cannot be grown by an attacker.
|
||||
private static readonly Dictionary<UInt128, Strike> _strikes = [];
|
||||
|
||||
private static bool _enabled;
|
||||
private static string _path;
|
||||
private static long _ttlSeconds;
|
||||
private static int _escalateAfterStrikes;
|
||||
private static long _strikeWindowSeconds;
|
||||
private static bool _dirty;
|
||||
|
||||
public static int Count => _allowed.Count;
|
||||
|
||||
private struct Strike
|
||||
{
|
||||
public int Count;
|
||||
public long WindowStart; // unix seconds; 0 means "no window open"
|
||||
}
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
LoginAllowlistConfiguration.Load();
|
||||
var s = LoginAllowlistConfiguration.Settings;
|
||||
|
||||
_enabled = s.Enabled && !string.IsNullOrWhiteSpace(s.File) && s.Ttl > TimeSpan.Zero;
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_path = Path.IsPathRooted(s.File) ? s.File : Path.Join(Core.BaseDirectory, s.File);
|
||||
_ttlSeconds = (long)s.Ttl.TotalSeconds;
|
||||
_escalateAfterStrikes = s.EscalateAfterStrikes;
|
||||
_strikeWindowSeconds = (long)s.StrikeWindow.TotalSeconds;
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
logger.Information("Login allowlist disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Load();
|
||||
|
||||
var interval = LoginAllowlistConfiguration.Settings.FlushInterval;
|
||||
if (interval <= TimeSpan.Zero)
|
||||
{
|
||||
interval = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
Timer.DelayCall(interval, interval, Flush);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful authentication. Private addresses are skipped: a LAN or loopback login says
|
||||
/// nothing about the public internet.
|
||||
/// </summary>
|
||||
public static void RecordLogin(IPAddress address) => RecordLogin(address, ToUnixSeconds(Core.Now));
|
||||
|
||||
/// <summary>The pure write, split out so policy can be tested without a clock.</summary>
|
||||
internal static void RecordLogin(IPAddress address, long nowUnix)
|
||||
{
|
||||
if (!_enabled || address == null || address.IsPrivateNetwork())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = address.ToUInt128();
|
||||
_allowed[key] = nowUnix;
|
||||
|
||||
// A fresh login clears the tally: someone just proved they hold an account.
|
||||
_strikes.Remove(key);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this address logged in within the TTL. Expiry is decided on read, so a stale entry left for
|
||||
/// the next flush can never allow anything.
|
||||
/// </summary>
|
||||
public static bool IsAllowed(IPAddress address) => IsAllowed(address, ToUnixSeconds(Core.Now));
|
||||
|
||||
/// <summary>The pure decision, split out so the TTL policy can be tested without a clock.</summary>
|
||||
internal static bool IsAllowed(IPAddress address, long nowUnix)
|
||||
{
|
||||
if (!_enabled || address == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _allowed.TryGetValue(address.ToUInt128(), out var stamp) && nowUnix - stamp <= _ttlSeconds;
|
||||
}
|
||||
|
||||
public static bool IsExemptFromEscalation(IPAddress address, string reason) =>
|
||||
IsExemptFromEscalation(address, reason, ToUnixSeconds(Core.Now));
|
||||
|
||||
/// <summary>
|
||||
/// Whether this contribution should be dropped instead of escalated, counting a strike if so. Not a pure
|
||||
/// read — calling it is what spends the address's allowance.
|
||||
/// </summary>
|
||||
internal static bool IsExemptFromEscalation(IPAddress address, string reason, long nowUnix)
|
||||
{
|
||||
// An operator's explicit ban, or a reason nobody opted in, escalates untouched.
|
||||
if (!BanReasons.IsBehavioral(reason) || !IsAllowed(address, nowUnix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_escalateAfterStrikes <= 0)
|
||||
{
|
||||
return true; // revocation disabled: an entry is unconditional
|
||||
}
|
||||
|
||||
var key = address.ToUInt128();
|
||||
_strikes.TryGetValue(key, out var strike);
|
||||
|
||||
if (strike.WindowStart == 0 || nowUnix - strike.WindowStart > _strikeWindowSeconds)
|
||||
{
|
||||
strike = new Strike { WindowStart = nowUnix };
|
||||
}
|
||||
|
||||
strike.Count++;
|
||||
|
||||
if (strike.Count < _escalateAfterStrikes)
|
||||
{
|
||||
_strikes[key] = strike;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allowance spent: drop the entry so this and all after it escalate. Earned back by logging in.
|
||||
_allowed.Remove(key);
|
||||
_strikes.Remove(key);
|
||||
_dirty = true;
|
||||
|
||||
logger.Information(
|
||||
"{Address} revoked from the login allowlist after {Count} suppressed contribution(s); last was '{Reason}'",
|
||||
address,
|
||||
strike.Count,
|
||||
reason
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void LoadForTesting(bool enabled, long ttlSeconds, int escalateAfterStrikes = 0, long strikeWindowSeconds = 3600)
|
||||
{
|
||||
_allowed.Clear();
|
||||
_strikes.Clear();
|
||||
_enabled = enabled;
|
||||
_ttlSeconds = ttlSeconds;
|
||||
_escalateAfterStrikes = escalateAfterStrikes;
|
||||
_strikeWindowSeconds = strikeWindowSeconds;
|
||||
_path = null;
|
||||
}
|
||||
|
||||
private static long ToUnixSeconds(DateTime utc) => (long)(utc - DateTime.UnixEpoch).TotalSeconds;
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
if (!_enabled || !_dirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A save owns the disk and nothing here is urgent. _dirty stays set, so skipping loses nothing.
|
||||
// See the threading policy in CLAUDE.md (rules #3 and #10).
|
||||
if (World.Saving || World.WorldState == WorldState.PendingSave)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nowUnix = ToUnixSeconds(Core.Now);
|
||||
var cutoff = nowUnix - _ttlSeconds;
|
||||
|
||||
// Prune and snapshot in one loop-side pass; the writer only sees private copies. Not pooled:
|
||||
// STArrayPool is single-threaded and these escape to another thread.
|
||||
var addresses = new UInt128[_allowed.Count];
|
||||
var stamps = new long[_allowed.Count];
|
||||
var count = 0;
|
||||
|
||||
using var expired = new PooledRefList<UInt128>(16);
|
||||
|
||||
foreach (var (address, stamp) in _allowed)
|
||||
{
|
||||
if (stamp < cutoff)
|
||||
{
|
||||
expired.Add(address);
|
||||
continue;
|
||||
}
|
||||
|
||||
addresses[count] = address;
|
||||
stamps[count] = stamp;
|
||||
count++;
|
||||
}
|
||||
|
||||
for (var i = 0; i < expired.Count; i++)
|
||||
{
|
||||
_allowed.Remove(expired[i]);
|
||||
_strikes.Remove(expired[i]);
|
||||
}
|
||||
|
||||
PruneStaleStrikes(nowUnix);
|
||||
|
||||
_dirty = false;
|
||||
|
||||
var path = _path;
|
||||
var total = count;
|
||||
var dropped = expired.Count;
|
||||
|
||||
_ = Task.Run(() => Write(path, addresses, stamps, total, dropped));
|
||||
}
|
||||
|
||||
/// <summary>Drops tallies whose window has closed.</summary>
|
||||
private static void PruneStaleStrikes(long nowUnix)
|
||||
{
|
||||
if (_strikes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var stale = new PooledRefList<UInt128>(16);
|
||||
|
||||
foreach (var (address, strike) in _strikes)
|
||||
{
|
||||
if (nowUnix - strike.WindowStart > _strikeWindowSeconds)
|
||||
{
|
||||
stale.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < stale.Count; i++)
|
||||
{
|
||||
_strikes.Remove(stale[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
// Sibling + swap, so a reader never sees a half-written list.
|
||||
var tmp = path + ".tmp";
|
||||
|
||||
using (var writer = new StreamWriter(tmp, false, new UTF8Encoding(false), 1 << 16))
|
||||
{
|
||||
writer.Write("# modernuo-login-allowlist generated=");
|
||||
writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture));
|
||||
writer.Write(" count=");
|
||||
writer.Write(count);
|
||||
writer.Write('\n');
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
writer.Write(addresses[i].ToIpAddress().ToString());
|
||||
writer.Write(' ');
|
||||
writer.Write(stamps[i]);
|
||||
writer.Write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(tmp, path, true);
|
||||
|
||||
if (dropped > 0)
|
||||
{
|
||||
logger.Information("Login allowlist wrote {Count} entr(ies), dropped {Dropped} past TTL", count, dropped);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Recoverable: entries are still in memory and the next flush retries.
|
||||
logger.Warning(e, "Could not write the login allowlist to \"{Path}\"", path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Load()
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
logger.Information("Login allowlist empty: no file at \"{Path}\"", _path);
|
||||
return;
|
||||
}
|
||||
|
||||
var cutoff = ToUnixSeconds(Core.Now) - _ttlSeconds;
|
||||
var loaded = 0;
|
||||
var skipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadLines(_path))
|
||||
{
|
||||
var span = line.AsSpan().Trim();
|
||||
if (span.Length == 0 || span[0] == '#' || span[0] == ';')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sep = span.IndexOf(' ');
|
||||
if (sep <= 0 ||
|
||||
!IPAddress.TryParse(span[..sep], out var address) ||
|
||||
!long.TryParse(span[(sep + 1)..].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var stamp))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expired on disk: do not carry a stranger into memory.
|
||||
if (stamp < cutoff)
|
||||
{
|
||||
skipped++;
|
||||
_dirty = true; // the file is now out of date; the next flush rewrites it
|
||||
continue;
|
||||
}
|
||||
|
||||
_allowed[address.ToUInt128()] = stamp;
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Fail open: an unreadable list allows nobody, which beats refusing to boot.
|
||||
logger.Warning(e, "Could not read the login allowlist at \"{Path}\"; continuing with {Count}", _path, _allowed.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.Information("Login allowlist loaded {Loaded} entr(ies) ({Skipped} expired or malformed)", loaded, skipped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: LoginAllowlistConfiguration.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;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="LoginAllowlistSettings"/> from <c>Configuration/login-allowlist.json</c> (matching
|
||||
/// the per-feature JSON config pattern used by <c>BlocklistConfiguration</c>). Loaded once; a missing file
|
||||
/// writes a template so operators have something to edit.
|
||||
/// </summary>
|
||||
public static class LoginAllowlistConfiguration
|
||||
{
|
||||
private const string _path = "Configuration/login-allowlist.json";
|
||||
|
||||
public static LoginAllowlistSettings Settings { get; private set; }
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
var path = Path.Join(Core.BaseDirectory, _path);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Settings = JsonConfig.Deserialize<LoginAllowlistSettings>(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Settings = new LoginAllowlistSettings();
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Save()
|
||||
{
|
||||
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bound configuration for <see cref="LoginAllowlist"/>: which addresses have recently proven they carry a
|
||||
/// real player, how long that proof counts, and how much misbehaviour revokes it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The recency window is the point. Consumer addresses are reassigned constantly, and on CGNAT the same
|
||||
/// address fronts a different subscriber week to week, so a list without a TTL becomes a list of strangers.
|
||||
/// </remarks>
|
||||
public record LoginAllowlistSettings
|
||||
{
|
||||
/// <summary>Whether successful logins are recorded and consulted at all. Disabled makes the list inert.</summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Where the list is persisted. A relative path resolves against <see cref="Core.BaseDirectory"/>.
|
||||
/// Plain text, one <c>address unix-seconds</c> pair per line, so it can be read and edited by hand.
|
||||
/// Set to <c>""</c> to disable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string File { get; set; } = "Configuration/login-allowlist.txt";
|
||||
|
||||
/// <summary>
|
||||
/// How long a successful login allowlists its address; dropped on the next flush after that. 90 days
|
||||
/// covers a player who takes a season off without carrying a reassigned address indefinitely.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ttl")]
|
||||
public TimeSpan Ttl { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
/// <summary>
|
||||
/// How often a changed list is written out. A crash loses at most this much, and an entry is re-earned by
|
||||
/// the next login.
|
||||
/// </summary>
|
||||
[JsonPropertyName("flushInterval")]
|
||||
public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// How many suppressed contributions inside <see cref="StrikeWindow"/> revoke an address's entry. Past
|
||||
/// this it escalates like anything else until it earns a new entry by logging in again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Generous on purpose: local defences never stop applying, so a high threshold only delays the external
|
||||
/// ban. A bad line might trip a gate a few times an hour; a host being used to flood burns through this
|
||||
/// in seconds. Set to 0 to never revoke.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("escalateAfterStrikes")]
|
||||
public int EscalateAfterStrikes { get; set; } = 10;
|
||||
|
||||
/// <summary>Rolling window the strike count is measured over. A quiet hour clears the tally.</summary>
|
||||
[JsonPropertyName("strikeWindow")]
|
||||
public TimeSpan StrikeWindow { get; set; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue