Commit graph

4 commits

Author SHA1 Message Date
Kamron Batman
710973a28b
refactor(network): pluggable accept-path filters; move the blocklist to UOContent
Introduces IConnectionFilter and the ConnectionFilters registry: one seam the
accept path consults per inbound socket, so core no longer has to know where a
gate's data comes from. The registry is a plain array walked by an indexed loop,
so the hot path has no enumerator, no closure and no allocation; an interface
dispatch is noise next to the accept syscall. Filters register during the
Configure sweep, cheapest first, and the first denial short-circuits.

A filter that throws on the accept path is unregistered and the connection
fails open. A filter that faults once faults for every subsequent connection,
so leaving it registered would mean an exception and a log line per accept --
exactly the amplification an attacker wants -- and a broken filter must not be
able to deny every connection either.

With that seam in place the whole file blocklist moves to UOContent: it is
policy (which feeds, when to promote, what to report) built on an external file
format with an external producer, and core does not need any of it. Firewall
stays in core -- it is long-standing public API, it is what an admin reaches for
manually, and a shard running without UOContent still has to be able to block an
address -- but it now reaches the accept path through the same registry via a
small adapter, so the two remain separate implementations rather than one
conflated store.

Blocklist policy moves out of bans.json into a UOContent Configuration/
blocklist.json, next to crowdsec.json. bans.json keeps only what core decides:
reportRateLimitTrips and autoBanDuration.

Cleanups found while moving the code:

- BanChannel.Stop() persisted the Firewall. A contribution coordinator has no
  business saving an enforcement store; that is now the firewall filter's Stop.
- BlocklistGate.Evaluate took a `whitelisted` flag that was hardcoded false at
  its only call site, and no whitelist concept exists anywhere in core. Dropped.
- FileBlocklist was a static holding a snapshot and a promote-guard, which
  forced its tests onto the sequential collection and a LoadForTesting reset
  hook. It is now an instance, so each test owns its own state and they run in
  parallel. BlocklistGate folds into it: the pure decision survives as an
  internal Evaluate, which is all the separate type ever provided.
- The promote-guard sweep timer was registered from NetState.Configure, two
  files from the guard it swept, and ran even with the blocklist disabled. It
  now starts with the filter that owns it.

Core sheds ~570 source and ~340 test lines for ~90 of seam. 1344 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:55:42 -07:00
Kamron Batman
464d4b7f66
feat(bans): blocklist generator script and a default blocklist location
Adds tools/Export-IpBlocklist.ps1, the producer half of the in-app blocklist
gate. It merges a thin, non-overlapping set of public IP threat feeds into one
de-duplicated, bogon-filtered list and writes the plain-text file the shard
reads via `blocklistFile`. Parsing is done in a compiled Add-Type hot loop
because the anchor feed alone is ~4M lines; the output is streamed so millions
of entries never become millions of strings.

The file is written to a .tmp sibling and swapped with File.Replace, so the
shard never observes a half-written list, and a total feed outage refuses to
overwrite a good list with an empty one. Re-running is idempotent: the script
exits without downloading anything while the list on disk is younger than
-MinInterval (default 2h, the anchor feed's own refresh period), so a
misconfigured scheduler cannot hammer the upstream feeds. Age comes from the
`generated=` header the script writes, falling back to mtime, so no sidecar
state file is needed. -Force overrides.

`blocklistFile` now defaults to Configuration/ip-blocklist.txt instead of being
empty. The gate stays inert while that file is absent, so this is a no-op for
shards that never run the generator, and dropping the file in later is picked
up by the existing poll with no restart.

Two fixes this exposed:

- FileBlocklist used the configured path verbatim despite documenting it as
  relative to Core.BaseDirectory, so a relative path resolved against the
  process working directory. Relative paths now resolve against BaseDirectory;
  absolute paths are honored so several shards can share one generated list.
- A missing file is now the shipped default rather than a misconfiguration, so
  boot logs "inert: no blocklist at ..." instead of "loaded 0 entries".

tools/ stays ignored except for this script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:40:46 -07:00
Kamron Batman
de3cfa35b1
feat(bans): Windows blocklist gate with demand-paged promotion
Enforce a millions-strong external IP blocklist in-app so the OS firewall
never has to hold it (Windows BFE can't). FileBlocklist loads a versioned
file into an immutable SortedRangeIndex snapshot off the game loop (yields to
world saves) and swaps it atomically; the accept path gates against it after
the manual-ban check and, once per suppression window (PromotedGuard),
promotes the hit to CrowdSec (scenario modernuo/blocklist) so the OS bouncer
kernel-drops repeat traffic. The bulk list is produced entirely out-of-process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:38:04 -07:00
Kamron Batman
6249a20f15
feat(bans): contribute-first ban channel with CrowdSec reporter
Add a pluggable ban seam: BanChannel fans locally-decided bans out to
IBanReporter sinks (Report/Retract) and the accept path enforces locally.
CrowdSec is a write-only reporter living in UOContent (registered into the
Core seam via BanChannel.Register) — it batches decisions onto a bounded,
coalescing, drop-on-overflow queue and POSTs /v1/alerts with watcher creds,
retry/backoff, and a bounded flush-on-stop. CrowdSec never enforces in-app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:37:42 -07:00