feat(network): allowlist false-positive IPs, escalate on behaviour
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. Clearing the CrowdSec decision could not fix it either, because the file entry re-reports within promoteSuppression of every reconnect. 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. 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 the operator's own ip-allowlist.txt (created once, never rewritten) plus one generated file per network carve-out. - Subtraction is range-correct: an allowlisted address inside a blocked CIDR splits that CIDR around the hole instead of being ignored. This also fixes -ExcludeAnonymizers, which parsed CIDR entries and then only subtracted singles. - Carve-outs are a table (name, ASN, reason, offline seed) rather than a hardcoded network, so covering another CGNAT provider is one row. -RefreshCarveouts re-fetches from the ASN's current routing announcements and collapses them; -Carveout '' subtracts none. Announcements rather than ownership records, because registry data disagrees with what is routed and caps its result sets. - Editing an allowlist bypasses -MinInterval, so a just-added exemption is not indistinguishable from the allowlist not working. - The shipped starlink carve-out costs ~0.1% of the list. 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 and one scanner behind a shared address was enough to get everyone behind it firewalled. Reading the files also means an entry applies on the next reload rather than the next regeneration. - 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. Suppresses escalation only; every local defence still applies. BEHAVIOURAL DETECTION - silent-connect (reaped having sent zero bytes) and invalid-seed (a zero seed) are contributed. Both were already disconnected. - ForeignProtocol positively identifies HTTP, TLS and SSH. Asking "is this a good UO client?" cannot work: LoginEncryption.ClientDecrypt is a byte-for-byte stream XOR, so a client with encryption on when the shard expects none sends a structurally perfect connection whose payload is noise. Nothing assumes arrival framing, since TCP has no message boundaries and a rule of the form "these bytes must arrive together" drops real players on poor links. - Keyed on bytes-received rather than elapsed time throughout. A connection that sent something and ran out of time is far more likely a slow link, and banning those makes the player retry, trip the rate limiter, and compound it. - AutoDenylist holds behavioural detections locally for 15 minutes, as an IConnectionFilter plus IBanReporter over one store so engine detection sites never reach into content. Closes the gap where a flood pays for a socket and a NetState per connection while waiting for an OS bouncer, and is the whole defence on a shard running none. Not persisted: a holding pen that survives restarts is a ban without a ban's review. - BanReasons centralises the slugs. IsBehavioral is an opt-in set, not "everything except manual", so a future reason escalates normally instead of silently inheriting an exemption. The first cut of the exemption swallowed manual admin bans; this is why. FIXES - BanConfiguration.Settings was null until Configure() ran while the reap path dereferences it every Slice(), so a harness driving NetState.Slice() directly hit an NRE that looked flaky because it depended on test ordering. - -AllowlistFile was typed [string] while documented and used as a list. LAYOUT AND DOCS Content network code moves out of Misc into UOContent/Network, one concern per folder. Namespaces are untouched, so these are pure file moves. 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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b8d3fec59a
commit
927c87702d
41 changed files with 2901 additions and 32 deletions
|
|
@ -60,6 +60,10 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
internal ProtocolState _protocolState = ProtocolState.AwaitingSeed;
|
||||
private bool _packetLogging;
|
||||
|
||||
// Whether ANY inbound bytes have arrived: what separates a slow client from a socket held open on
|
||||
// purpose. See BanSettings.ReportBadConnects.
|
||||
internal bool _receivedData;
|
||||
|
||||
// Managed socket with buffers (handles lifecycle automatically)
|
||||
internal RingSocket _socket;
|
||||
|
||||
|
|
@ -621,6 +625,37 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
{
|
||||
case ProtocolState.AwaitingSeed:
|
||||
{
|
||||
// Traffic that is positively another protocol. Unlike "does not look like a
|
||||
// good client", this cannot misfire on a misconfigured one.
|
||||
var foreign = ForeignProtocol.Identify(buffer, out var foreignKind);
|
||||
|
||||
if (foreign == ForeignProtocolMatch.Incomplete)
|
||||
{
|
||||
_parserState = ParserState.AwaitingPartialPacket;
|
||||
break;
|
||||
}
|
||||
|
||||
if (foreign == ForeignProtocolMatch.Confirmed)
|
||||
{
|
||||
logger.Debug(
|
||||
"{Address} spoke {Protocol} on the game port; disconnecting",
|
||||
Address,
|
||||
foreignKind
|
||||
);
|
||||
|
||||
if (Bans.BanConfiguration.Settings.ReportBadConnects)
|
||||
{
|
||||
Bans.BanChannel.Report(
|
||||
Address,
|
||||
Bans.BanConfiguration.Settings.BadConnectDuration,
|
||||
Bans.BanReasons.ForeignProtocol
|
||||
);
|
||||
}
|
||||
|
||||
Disconnect(string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packetId == 0xEF)
|
||||
{
|
||||
_parserState = ParserState.ProcessingPacket;
|
||||
|
|
@ -636,6 +671,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
if (newSeed == 0)
|
||||
{
|
||||
// No real client sends a zero seed, so this is deliberate garbage
|
||||
// rather than a damaged connection — unlike the short-read branch
|
||||
// below, which a fragmented first segment can reach honestly.
|
||||
if (Bans.BanConfiguration.Settings.ReportBadConnects)
|
||||
{
|
||||
Bans.BanChannel.Report(
|
||||
Address,
|
||||
Bans.BanConfiguration.Settings.BadConnectDuration,
|
||||
Bans.BanReasons.InvalidSeed
|
||||
);
|
||||
}
|
||||
|
||||
Disconnect(string.Empty);
|
||||
return;
|
||||
}
|
||||
|
|
@ -647,8 +694,16 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
_parserState = ParserState.AwaitingNextPacket;
|
||||
_protocolState = ProtocolState.GameServer_AwaitingGameServerLogin;
|
||||
}
|
||||
else // Don't allow partial packets on initial connection, just disconnect them.
|
||||
else
|
||||
{
|
||||
// Fewer than four bytes for a raw seed: disconnect rather than wait. This
|
||||
// only affects pre-0xEF clients (0xEF goes through HandlePacket, which
|
||||
// already waits for its 21 bytes), and waiting here would be paid for on
|
||||
// the path that has to survive a flood -- a garbage client sending one or
|
||||
// two bytes, or a slow loris dribbling a byte every few seconds, would
|
||||
// hold a connection slot for the full ConnectingSocketIdleLimit instead of
|
||||
// being dropped immediately. With a fixed MaxConnections table that trades
|
||||
// capacity for a rare fragmentation case a reconnect already fixes.
|
||||
Disconnect(string.Empty);
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue