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
103
Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs
Normal file
103
Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BlocklistConfiguration.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using Server.Json;
|
||||
|
||||
namespace Server.Network.Bans;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="BlocklistSettings"/> from <c>Configuration/blocklist.json</c> (matching the
|
||||
/// per-feature JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file
|
||||
/// writes a template so operators have something to edit.
|
||||
/// </summary>
|
||||
public static class BlocklistConfiguration
|
||||
{
|
||||
private const string _path = "Configuration/blocklist.json";
|
||||
|
||||
public static BlocklistSettings Settings { get; private set; }
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
var path = Path.Join(Core.BaseDirectory, _path);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Settings = JsonConfig.Deserialize<BlocklistSettings>(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Settings = new BlocklistSettings();
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Save()
|
||||
{
|
||||
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bound configuration for <see cref="BlocklistFilter"/>. The filter is inert unless <see cref="File"/>
|
||||
/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs
|
||||
/// the generator.
|
||||
/// </summary>
|
||||
public record BlocklistSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the blocklist. A relative path resolves against <see cref="Core.BaseDirectory"/>; an
|
||||
/// absolute path is used as-is (handy when several shards share one generated list). Set to
|
||||
/// <c>""</c> to disable the gate entirely. Produce the file with <c>tools/Export-IpBlocklist.ps1</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string File { get; set; } = "Configuration/ip-blocklist.txt";
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <summary>Whether blocklist hits are contributed to the ban channel (the demand-paging promotion).</summary>
|
||||
[JsonPropertyName("reportHits")]
|
||||
public bool ReportHits { get; set; } = true;
|
||||
|
||||
/// <summary>Duration reported for a blocklist-matched ban.</summary>
|
||||
[JsonPropertyName("banDuration")]
|
||||
public TimeSpan BanDuration { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>
|
||||
/// How long the accept-path guard suppresses re-reporting a promoted address. This only needs to
|
||||
/// bridge the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops
|
||||
/// repeat traffic. Decoupled from <see cref="BanDuration"/> so the guard doesn't have to remember
|
||||
/// hours' worth of distinct addresses.
|
||||
/// </summary>
|
||||
[JsonPropertyName("promoteSuppression")]
|
||||
public TimeSpan PromoteSuppression { get; set; } = TimeSpan.FromSeconds(60);
|
||||
}
|
||||
86
Projects/UOContent/Network/Blocklist/BlocklistFile.cs
Normal file
86
Projects/UOContent/Network/Blocklist/BlocklistFile.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BlocklistFile.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Server.Network.Bans;
|
||||
|
||||
public readonly record struct BlocklistHeader(string Generated, int Count, bool Present);
|
||||
|
||||
/// <summary>Reads the versioned blocklist file: cheap header probe + full snapshot load.</summary>
|
||||
public static class BlocklistFile
|
||||
{
|
||||
public static bool TryReadHeader(string path, out BlocklistHeader header)
|
||||
{
|
||||
header = new BlocklistHeader(null, 0, false);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
using var reader = new StreamReader(path);
|
||||
var first = reader.ReadLine();
|
||||
if (first == null)
|
||||
{
|
||||
header = new BlocklistHeader(null, 0, true);
|
||||
return true;
|
||||
}
|
||||
string generated = null;
|
||||
var count = 0;
|
||||
if (first.StartsWith('#'))
|
||||
{
|
||||
var tokens = first.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = 0; i < tokens.Length; i++)
|
||||
{
|
||||
var tok = tokens[i];
|
||||
if (tok.StartsWith("generated=", StringComparison.Ordinal))
|
||||
{
|
||||
generated = tok["generated=".Length..];
|
||||
}
|
||||
else if (tok.StartsWith("count=", StringComparison.Ordinal))
|
||||
{
|
||||
int.TryParse(tok["count=".Length..], out count);
|
||||
}
|
||||
}
|
||||
}
|
||||
header = new BlocklistHeader(generated, count, true);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // treat as absent; caller keeps last-good / empty
|
||||
}
|
||||
}
|
||||
|
||||
public static BlocklistSnapshot Load(string path, out int parsed, out int skipped)
|
||||
{
|
||||
parsed = 0;
|
||||
skipped = 0;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return BlocklistSnapshot.Empty;
|
||||
}
|
||||
return BlocklistSnapshot.Build(File.ReadAllBytes(path), out parsed, out skipped);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BlocklistSnapshot.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
274
Projects/UOContent/Network/Blocklist/BlocklistFilter.cs
Normal file
274
Projects/UOContent/Network/Blocklist/BlocklistFilter.cs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BlocklistFilter.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Network.Bans;
|
||||
|
||||
/// <summary>
|
||||
/// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator
|
||||
/// (<c>tools/Export-IpBlocklist.ps1</c>) writes on a schedule. Holds an immutable snapshot swapped
|
||||
/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is
|
||||
/// configured or present.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on
|
||||
/// Windows, so the millions live here and only addresses that actually connect are promoted to CrowdSec
|
||||
/// (and from there to the OS firewall) through <see cref="BanChannel"/>. <see cref="PromotedGuard"/>
|
||||
/// keeps a flood of repeat connections from re-reporting the same address before the bouncer picks it up.
|
||||
/// </remarks>
|
||||
public sealed class BlocklistFilter : IConnectionFilter
|
||||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter));
|
||||
|
||||
// Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile
|
||||
// reference swap is the whole synchronization story — readers see the old or the new snapshot, whole.
|
||||
private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
|
||||
|
||||
private readonly PromotedGuard _guard = new();
|
||||
|
||||
private string _path;
|
||||
private TimeSpan _interval;
|
||||
private bool _reportHits;
|
||||
private TimeSpan _banDuration;
|
||||
private long _suppressionMs;
|
||||
private string _lastGenerated;
|
||||
private DateTime _lastWriteUtc;
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
public string Name => "blocklist";
|
||||
|
||||
public int Count => _snapshot.Count;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
ConnectionFilters.Register(new BlocklistFilter());
|
||||
}
|
||||
|
||||
public void Register()
|
||||
{
|
||||
BlocklistConfiguration.Load();
|
||||
var s = BlocklistConfiguration.Settings;
|
||||
|
||||
_path = ResolvePath(s.File);
|
||||
_interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval;
|
||||
_reportHits = s.ReportHits;
|
||||
_banDuration = s.BanDuration;
|
||||
_suppressionMs = (long)s.PromoteSuppression.TotalMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured path once: a relative path is anchored to <see cref="Core.BaseDirectory"/>
|
||||
/// (never the process working directory, which differs when the shard is launched from elsewhere), an
|
||||
/// absolute path is taken as-is so several shards can share one generated list.
|
||||
/// </summary>
|
||||
private static string ResolvePath(string configured)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured);
|
||||
}
|
||||
|
||||
public void Start(CancellationToken token)
|
||||
{
|
||||
if (_path == null)
|
||||
{
|
||||
logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)");
|
||||
return;
|
||||
}
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
|
||||
// A missing file is the shipped default, not an error: the gate stays inert until the poll picks
|
||||
// up whatever the generator first writes. No restart needed.
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
Reload(); // synchronous prime; empty on failure (fail-open)
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval);
|
||||
}
|
||||
|
||||
// Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded.
|
||||
Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard);
|
||||
|
||||
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
public bool ShouldDeny(IPAddress address)
|
||||
{
|
||||
if (!Evaluate(address, Core.TickCount, out var shouldReport))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldReport)
|
||||
{
|
||||
// Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop.
|
||||
BanChannel.Report(address, _banDuration, BanReasons.Blocklist);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pure decision, split out so the accept-path policy can be tested without a clock or a ban
|
||||
/// channel. <paramref name="shouldReport"/> is true at most once per suppression window.
|
||||
/// </summary>
|
||||
internal bool Evaluate(IPAddress address, long nowTicks, out bool shouldReport)
|
||||
{
|
||||
shouldReport = false;
|
||||
|
||||
if (!_snapshot.IsBanned(address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SweepGuard() => _guard.Sweep(Core.TickCount);
|
||||
|
||||
// Test hook: inject a snapshot and policy without file I/O.
|
||||
internal void LoadForTesting(BlocklistSnapshot snapshot, bool reportHits = true, long suppressionMs = 60000)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
_reportHits = reportHits;
|
||||
_suppressionMs = suppressionMs;
|
||||
}
|
||||
|
||||
private async ValueTask PollLoop(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_interval, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (ChangedSinceLastLoad())
|
||||
{
|
||||
// Parsing millions of lines competes with a save for CPU and page cache, so yield until
|
||||
// the world is written out. See the threading policy in CLAUDE.md (rules #3 and #10).
|
||||
while (World.Saving || World.WorldState == WorldState.PendingSave)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), token);
|
||||
}
|
||||
|
||||
Reload();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Warning(e, "Blocklist reload check failed; keeping last snapshot ({Count})", Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool ChangedSinceLastLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(_path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (info.LastWriteTimeUtc == _lastWriteUtc)
|
||||
{
|
||||
return false; // cheapest guard
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated;
|
||||
}
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
// Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not
|
||||
// one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll;
|
||||
// capturing after could skip a version entirely.
|
||||
var writeUtc = default(DateTime);
|
||||
try
|
||||
{
|
||||
writeUtc = new FileInfo(_path).LastWriteTimeUtc;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* keep default */
|
||||
}
|
||||
|
||||
BlocklistFile.TryReadHeader(_path, out var h);
|
||||
|
||||
var next = BlocklistFile.Load(_path, out var parsed, out var skipped);
|
||||
_snapshot = next; // single volatile swap; readers see old or new whole
|
||||
_lastGenerated = h.Generated;
|
||||
_lastWriteUtc = writeUtc;
|
||||
|
||||
logger.Information("Blocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}",
|
||||
parsed, next.Count, skipped, h.Generated);
|
||||
}
|
||||
}
|
||||
211
Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs
Normal file
211
Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: BlocklistSnapshot.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Server.Collections;
|
||||
|
||||
namespace Server.Network.Bans;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced
|
||||
/// interval index per family: IPv4 as <see cref="uint"/> ranges (lean for the millions-strong common
|
||||
/// case), IPv6 as <see cref="UInt128"/> ranges (empty unless the feed carries v6). Immutable → lock-free reads.
|
||||
/// </summary>
|
||||
public sealed class BlocklistSnapshot
|
||||
{
|
||||
public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex<uint>.Empty, SortedRangeIndex<UInt128>.Empty);
|
||||
|
||||
private readonly SortedRangeIndex<uint> _v4;
|
||||
private readonly SortedRangeIndex<UInt128> _v6;
|
||||
|
||||
public int Count => _v4.Count + _v6.Count;
|
||||
|
||||
private BlocklistSnapshot(SortedRangeIndex<uint> v4, SortedRangeIndex<UInt128> v6)
|
||||
{
|
||||
_v4 = v4;
|
||||
_v6 = v6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on
|
||||
/// <c>'\n'</c> with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the
|
||||
/// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser.
|
||||
/// Malformed lines increment <paramref name="skipped"/> and never throw. Build-time intermediates use
|
||||
/// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread.
|
||||
/// </summary>
|
||||
public static BlocklistSnapshot Build(ReadOnlySpan<byte> data, out int parsed, out int skipped)
|
||||
{
|
||||
parsed = 0;
|
||||
skipped = 0;
|
||||
|
||||
// Only the two final index arrays (allocated inside SortedRangeIndex.Build) hit the heap; every
|
||||
// build-time buffer here is a pooled ref list. mt: true is required — this runs off the game loop.
|
||||
using var v4 = PooledRefList<SortedRangeIndex<uint>.Range>.Create(mt: true);
|
||||
using var v6 = PooledRefList<SortedRangeIndex<UInt128>.Range>.Create(mt: true);
|
||||
|
||||
var rest = data;
|
||||
while (!rest.IsEmpty)
|
||||
{
|
||||
ReadOnlySpan<byte> line;
|
||||
var nl = rest.IndexOf((byte)'\n');
|
||||
if (nl >= 0)
|
||||
{
|
||||
line = rest[..nl];
|
||||
rest = rest[(nl + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
line = rest;
|
||||
rest = default;
|
||||
}
|
||||
|
||||
line = line[Ascii.Trim(line)];
|
||||
if (line.IsEmpty || line[0] == (byte)'#' || line[0] == (byte)';')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var slash = line.IndexOf((byte)'/');
|
||||
var addr = slash >= 0 ? line[..slash] : line;
|
||||
var bitsToken = slash >= 0 ? line[(slash + 1)..] : default;
|
||||
|
||||
if (addr.IndexOf((byte)':') < 0)
|
||||
{
|
||||
// IPv4 single or CIDR — parsed straight from the byte span.
|
||||
if (slash >= 0)
|
||||
{
|
||||
if (IPAddressUtility.TryParseV4(addr, out var ip) &&
|
||||
TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 32)
|
||||
{
|
||||
var size = bits == 0 ? 0xFFFFFFFFu : (1u << (32 - bits)) - 1;
|
||||
var b = ip & ~size;
|
||||
v4.Add(new SortedRangeIndex<uint>.Range(b, b + size));
|
||||
parsed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
else if (IPAddressUtility.TryParseV4(addr, out var ip))
|
||||
{
|
||||
v4.Add(new SortedRangeIndex<uint>.Range(ip, ip));
|
||||
parsed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
else if (TryDecodeV6(addr, out var v))
|
||||
{
|
||||
// IPv6 is rare in these feeds; the single token was decoded and framework-parsed above.
|
||||
if (slash >= 0)
|
||||
{
|
||||
if (TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 128)
|
||||
{
|
||||
var mask = bits == 0 ? UInt128.Zero : ~((UInt128.One << (128 - bits)) - 1);
|
||||
var b = v & mask;
|
||||
v6.Add(new SortedRangeIndex<UInt128>.Range(b, b | ~mask));
|
||||
parsed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
v6.Add(new SortedRangeIndex<UInt128>.Range(v, v));
|
||||
parsed++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
v4.Sort(SortedRangeIndex<uint>.ByMin);
|
||||
v6.Sort(SortedRangeIndex<UInt128>.ByMin);
|
||||
return new BlocklistSnapshot(SortedRangeIndex<uint>.Build(v4.AsSpan()), SortedRangeIndex<UInt128>.Build(v6.AsSpan()));
|
||||
}
|
||||
|
||||
// Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser.
|
||||
private static bool TryDecodeV6(ReadOnlySpan<byte> addr, out UInt128 v)
|
||||
{
|
||||
v = UInt128.Zero;
|
||||
if (addr.Length > 45)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<char> chars = stackalloc char[addr.Length];
|
||||
for (var i = 0; i < addr.Length; i++)
|
||||
{
|
||||
chars[i] = (char)addr[i];
|
||||
}
|
||||
|
||||
if (!IPAddress.TryParse(chars, out var a) || a.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
v = a.ToUInt128();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseBits(ReadOnlySpan<byte> token, out int bits)
|
||||
{
|
||||
if (Utf8Parser.TryParse(token, out bits, out var consumed) && consumed == token.Length)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bits = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// v6-encoded v4 must not dodge the v4 set; extract the embedded v4 uint directly.
|
||||
return IPAddressUtility.TryMappedV4(ip, out var mv) && _v4.Contains(mv);
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
return IPAddressUtility.TryV4(ip, out var v) && _v4.Contains(v);
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return _v6.Contains(ip.ToUInt128());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
55
Projects/UOContent/Network/Blocklist/PromotedGuard.cs
Normal file
55
Projects/UOContent/Network/Blocklist/PromotedGuard.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: PromotedGuard.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Server.Network.Bans;
|
||||
|
||||
/// <summary>Suppresses re-reporting the same IP within a TTL. Accept-path thread only.</summary>
|
||||
public sealed class PromotedGuard
|
||||
{
|
||||
private readonly Dictionary<UInt128, long> _expiry = [];
|
||||
|
||||
public bool TryMark(UInt128 ip, long nowTicks, long ttlMs)
|
||||
{
|
||||
if (_expiry.TryGetValue(ip, out var exp) && exp - nowTicks > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_expiry[ip] = nowTicks + ttlMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Sweep(long nowTicks)
|
||||
{
|
||||
if (_expiry.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
using var dead = Collections.PooledRefQueue<UInt128>.Create();
|
||||
foreach (var (ip, exp) in _expiry)
|
||||
{
|
||||
if (exp - nowTicks <= 0)
|
||||
{
|
||||
dead.Enqueue(ip);
|
||||
}
|
||||
}
|
||||
while (dead.Count > 0)
|
||||
{
|
||||
_expiry.Remove(dead.Dequeue());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue