TEMPORARY workflow -- delete .github/workflows/tools-ps1-check.yml before merge.
Writing the check immediately found that the script did not parse at all under
Windows PowerShell 5.1, despite claiming to support it. The file is UTF-8
without a BOM, which Windows PowerShell reads as ANSI, and a UTF-8 em dash
decodes to a trailing U+201D -- a smart quote PowerShell accepts as a string
delimiter. Every em dash inside a string ended it early, so the script died in
a wall of parse errors.
Rather than add a BOM (which any editor can silently strip, breaking it again),
the script now targets PowerShell 7 only and the source is ASCII-only. Both
matter together: #requires is only honored once the file parses, so ASCII is
what lets Windows PowerShell print "requires PowerShell 7.0" instead of parse
noise. CI asserts both, and asserts the refusal is clean.
Dropping 5.1 support removes the scaffolding it needed: the File.Move-vs-
File.Replace probe collapses to a single File.Move(src, dst, overwrite) -- one
atomic rename on every platform, and it no longer needs to branch on whether the
destination exists -- the multi-segment Join-Path replaces a manual loop, and the
ServicePointManager TLS pin is gone, since only .NET Framework needed it.
The check runs on Linux, macOS and Windows. It is mostly offline, because the
feed-name filter throws after the Add-Type compile and the cooldown gate but
before any download, which makes "did it reach the filter?" a deterministic
signal. That gives discriminating assertions for the two portability bugs fixed
earlier: a wrong path join makes the script miss a list it should have found and
run instead of skipping, and a culture-sensitive duration parse makes a 3h-old
list look newer than -MinInterval 2.5h. One step does hit the network, using the
smallest feed, since the write and atomic swap need content.
Every step was run locally under pwsh 7 and Windows PowerShell 5.1 before
committing; two bugs in the checks themselves surfaced that way (Write-Host goes
to the information stream, so 2>&1 captured nothing, and the expected throw
aborted the step before its assertion ran).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The script targeted Windows in two ways that would have broken or, worse, run
silently wrong under pwsh on Linux/macOS:
- The default output path was the literal 'Configuration\ip-blocklist.txt'. On
Windows Join-Path happens to produce the right thing; on Linux it produces a
single file whose name contains backslashes, in the wrong directory. Now
joined one segment at a time (the multi-argument Join-Path is PowerShell 6+,
so this stays 5.1-compatible).
- The -MinInterval parse used the current culture. Under a comma-decimal locale
[double]::TryParse('2.5') does not fail -- it treats '.' as a group separator
and SUCCEEDS with 25, so `-MinInterval 2.5h` would silently become a 25 hour
cooldown. Parsed with InvariantCulture now, which is also the only reason the
bad-input throw is reachable at all.
Also: the generated= header timestamp is formatted with InvariantCulture, since
':' is the culture-defined time separator in a custom format string and the
shard compares that marker verbatim to detect a new list. Token matching is
ordinal.
The atomic swap picks File.Move(src, dst, overwrite) where it exists -- one
rename on every platform, MoveFileEx on Windows and rename(2) on Unix -- and
falls back to File.Replace on Windows PowerShell 5.1, which has no 3-argument
Move. ServicePointManager is only touched on the Desktop edition that needs it.
Verified end to end under de-DE: correct cooldown, invariant header, atomic
replace, no leftover .tmp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>