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>
1109 lines
55 KiB
PowerShell
1109 lines
55 KiB
PowerShell
#requires -Version 7.0
|
|
<#
|
|
.SYNOPSIS
|
|
Downloads a small, non-overlapping set of public IP threat feeds and writes them to a single
|
|
ModernUO blocklist file -- merged, de-duplicated and bogon-filtered.
|
|
|
|
.DESCRIPTION
|
|
This is the producer half of ModernUO's in-app blocklist gate. It fetches a deliberately THIN feed
|
|
set, merges every source into one global set, drops duplicates and reserved/bogon addresses, then
|
|
writes the result to a plain text file that the shard reads via `file` in
|
|
Configuration/blocklist.json. Nothing is installed and no credentials are needed -- the output is just
|
|
a text file, so this can run on any machine that can reach the shard's Distribution folder.
|
|
|
|
It writes the file the shard's `BlocklistFilter` demand-pages against. IPs that actually connect are
|
|
promoted to CrowdSec / the OS firewall by the shard; the OS firewall never has to hold millions of
|
|
entries, which is exactly the scale it cannot handle on Windows.
|
|
|
|
Inclusion principle: any category of IP used in OTHER attacks that could plausibly be turned against a
|
|
game server should be blocked -- compromised hosts, botnets, scanners, spam / DDoS-as-a-service bots,
|
|
open proxies and Tor relays. That whole surface is already covered by the anchor feed `bitwire-it`,
|
|
which is itself a 91-source aggregator (it folds in spamhaus, ipsum, firehol-level2, blocklist-de,
|
|
dshield, emergingthreats, binarydefense, cins-army, bruteforceblocker, greensnow, vxvault, ThreatFox,
|
|
StopForumSpam/sblam, Tor, open-proxy and C2 lists). So the inclusive posture lives in the base layer,
|
|
and every one of those standalone feeds is dropped as pure redundancy. Only the feeds bitwire does NOT
|
|
already carry are kept on top of it:
|
|
|
|
bitwire-it 2h-refreshed 91-source aggregate (compromised hosts, botnets, scanners, spam
|
|
bots, Tor/open-proxy abuse relays, ThreatFox C2) -- the broad base layer.
|
|
romainmarcoux ~130k fresh attacker IPs bitwire's snapshot lags on (high-churn feed).
|
|
sentinel-turris ~800 unique honeypot probers (Turris greylist) not in bitwire.
|
|
firehol-level1 hijacked/reputation NETBLOCKS (spamhaus DROP-style) -- bogon-filtered.
|
|
|
|
The only category deliberately held back is commercial VPN exit endpoints, which could block a legit
|
|
player -- and those are barely present here anyway (bitwire is ~5% of VPN-tunnel lists). If you ever want
|
|
to protect VPN/Tor players, pass -ExcludeAnonymizers to subtract Tor/open-proxy/VPN IPs from the output.
|
|
|
|
ALLOWLIST
|
|
Aggregators inevitably list shared consumer address space. A CGNAT public IP fronts many subscribers, so
|
|
one abusive customer gets the address listed and every other subscriber behind it is blocked with them.
|
|
Entries in the allowlist file (-AllowlistFile) are SUBTRACTED from the merged set before it is written, so
|
|
the exemption costs nothing on the shard's accept path -- which is deliberately allowlist-free, because a
|
|
whitelist there could only ever turn a deny into an allow at the price of a lookup on every accept, the
|
|
attacker's included. Allowlisting belongs here (at generation) and at the enforcement layer
|
|
(`cscli allowlists`), never at the gate.
|
|
|
|
Subtraction is range-correct: an allowlisted address that falls inside a blocked CIDR splits that CIDR
|
|
around the hole instead of being silently ignored, so an exemption always takes effect no matter which
|
|
shape the feed happened to publish.
|
|
|
|
Allowlists are split by owner rather than kept in one file: `ip-allowlist.txt` holds the operator's own
|
|
exemptions and is never rewritten, while network-wide carve-outs live in their own generated files beside
|
|
it. Both are created on first run and merged into one allow set. Keeping them apart means a carve-out can
|
|
be regenerated, diffed or copied to another shard without disturbing hand-written entries.
|
|
|
|
The carve-out shipped by default is Starlink (`ip-allowlist-starlink.txt`, AS14593), the worst offender
|
|
for this: it is CGNAT throughout and its leases rotate, so a listing there says almost nothing about the
|
|
player currently holding the address. It costs ~0.1% of the list. Blank the file to restore
|
|
reputation-blocking on Starlink -- abusive hosts in that space are still caught on BEHAVIOR by the rate
|
|
limiter and promoted to CrowdSec, which is the gate that actually observes them.
|
|
|
|
OUTPUT FORMAT (must stay in sync with UOContent/Network/Blocklist/BlocklistFile.cs):
|
|
Line 1 is a header comment carrying the version markers, e.g.
|
|
# modernuo-blocklist generated=2026-07-25T18:03:11Z count=3914022 ipv4=3901188 cidr=12834
|
|
The shard polls `reloadInterval` and reloads when the file mtime AND `generated=` change,
|
|
so the header is REQUIRED -- without it the shard loads once and never picks up a new file.
|
|
Every following line is one entry: a bare IPv4/IPv6 address or a CIDR (`1.2.3.0/24`). Blank lines
|
|
and lines starting with `#` or `;` are ignored. Order does not matter; the shard sorts and
|
|
coalesces on load. The feeds used here are IPv4-only, but the shard parses IPv6 lines too.
|
|
|
|
The file is written to a `.tmp` sibling and swapped into place atomically, so the shard never reads a
|
|
half-written list -- it either sees the previous version or the new one, whole.
|
|
|
|
Performance: bitwire alone is ~4M lines. Parsing/validating/bogon-filtering that in interpreted
|
|
PowerShell is the slow part (minutes), so the hot loop is compiled once via Add-Type (C#) -- it runs in
|
|
~1s. Downloads stream with a live Write-Progress bar; every phase prints its own elapsed time so you can
|
|
see exactly where the wall-clock goes.
|
|
|
|
Requires PowerShell 7 (pwsh), which runs on Windows, Linux and macOS -- Windows PowerShell 5.1 is
|
|
not supported and the script refuses to run there. Schedule it with Task Scheduler, cron, or a
|
|
systemd timer.
|
|
|
|
Every run rewrites the whole file, so an IP that drops off the feeds stops being blocked on the next
|
|
run -- there is no TTL to tune. Calling it is idempotent: if the list on disk is younger than
|
|
-MinInterval the script exits without downloading anything, so an over-eager trigger costs nothing
|
|
upstream. -Force overrides that.
|
|
|
|
.PARAMETER DistributionPath
|
|
Path to the shard's Distribution folder. The blocklist is written to the Configuration/ip-blocklist.txt
|
|
beneath it, which is the default `file` in blocklist.json. Not needed when the script is run from its
|
|
place in the repo (tools/), or when -OutFile is given.
|
|
|
|
.PARAMETER OutFile
|
|
Explicit output path, overriding -DistributionPath. Use this if you relocated the blocklist and
|
|
changed `file` in blocklist.json to match.
|
|
|
|
.PARAMETER MinInterval
|
|
Refuse to re-run while the existing blocklist is younger than this (default 2h), so a misbehaving
|
|
scheduler, a login script or a stuck retry loop cannot hammer the upstream feeds. The age comes from
|
|
the `generated=` header of the file already on disk (falling back to its mtime), so it survives across
|
|
machines and reboots -- there is no separate state file. Nothing is downloaded when the check trips.
|
|
Accepts `90s`, `45m`, `2h`, `2.5h`, `1d`, or a bare number of hours. Use `0` to disable the check.
|
|
Match this to how often you actually want fresh data: the anchor feed only refreshes every 2h, so
|
|
running more often than that costs bandwidth and gains nothing.
|
|
|
|
.PARAMETER Force
|
|
Run regardless of how recently the blocklist was generated (bypasses -MinInterval).
|
|
|
|
.PARAMETER Feeds
|
|
Which feeds to include (by Name). Default: all of them.
|
|
|
|
.PARAMETER AllowlistFile
|
|
One or more lists of addresses that must NEVER be blocked; every entry is subtracted from the merged set
|
|
before the output is written. Same format as the blocklist: one bare IPv4 or CIDR per line, `#`/`;`
|
|
comments ignored.
|
|
|
|
Defaults to the operator's own list plus one file per network carve-out, all beside the output, created
|
|
if missing and merged into one allow set:
|
|
ip-allowlist.txt operator exemptions -- created once, never rewritten
|
|
ip-allowlist-<name>.txt one per -Carveout -- generated data, safe to regenerate or copy
|
|
Splitting them means a network-wide carve-out can be refreshed or shared between shards without
|
|
touching anyone's hand-written entries. Blank a file (keep the file) to disable its contents.
|
|
|
|
Passing this parameter replaces the defaults entirely; an explicitly-named file that does not exist is a
|
|
warning rather than a silent template write, so a typo cannot look like it worked. Pass `''` to disable
|
|
subtraction altogether.
|
|
|
|
Editing any allowlist also bypasses -MinInterval on the next run: an exemption you just added would
|
|
otherwise sit unapplied for up to the cooldown, which reads exactly like the allowlist not working.
|
|
|
|
.PARAMETER Carveout
|
|
Which network carve-outs to maintain and subtract, by Name. Default: all rows in the `$Carveouts` table,
|
|
which currently ships `starlink` only. Pass `''` to keep the operator's own allowlist while subtracting
|
|
no carve-outs.
|
|
|
|
Covering another CGNAT provider is a table row near the top of this script: a Name, its ASN, a one-line
|
|
reason, and either a pasted Seed or nothing at all if you intend to run -RefreshCarveouts.
|
|
|
|
.PARAMETER RefreshCarveouts
|
|
Re-fetch each selected carve-out's prefixes from its ASN's current routing announcements, collapse them,
|
|
and rewrite its file. Implies -Force. A carve-out whose fetch fails keeps the data it already had.
|
|
|
|
Announcements rather than ownership records on purpose: registry data disagrees with what is actually
|
|
routed, and registry queries silently cap their result sets.
|
|
|
|
.PARAMETER ExcludeAnonymizers
|
|
Also download Tor-exit / open-proxy / VPN-tunnel lists and SUBTRACT those IPs from the output. Off by
|
|
default -- for a game server, Tor/open-proxy relays are attack infrastructure you want to block. Turn
|
|
this on only if you need to keep VPN/Tor players reachable.
|
|
|
|
.PARAMETER DryRun
|
|
Download + parse + merge + count only. Writes nothing.
|
|
|
|
.EXAMPLE
|
|
.\Export-IpBlocklist.ps1 -DryRun
|
|
|
|
.EXAMPLE
|
|
# Safe to call as often as you like -- it no-ops unless the list is older than 2h.
|
|
.\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution'
|
|
|
|
.EXAMPLE
|
|
.\Export-IpBlocklist.ps1 -OutFile 'D:\shared\ip-blocklist.txt' -ExcludeAnonymizers
|
|
|
|
.EXAMPLE
|
|
# Regenerate right now, ignoring the cooldown.
|
|
.\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' -Force
|
|
|
|
.EXAMPLE
|
|
# Unblock a player caught by a shared-IP listing: add the address, then regenerate. Editing the
|
|
# allowlist bypasses the cooldown, so no -Force is needed.
|
|
Add-Content 'C:\Shard\Distribution\Configuration\ip-allowlist.txt' '203.0.113.42'
|
|
.\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution'
|
|
|
|
.EXAMPLE
|
|
# Check what an allowlist would cost before committing to it.
|
|
.\Export-IpBlocklist.ps1 -AllowlistFile 'D:\shared\allow.txt' -DryRun
|
|
|
|
.EXAMPLE
|
|
# Bring the carve-out data up to date with what the network currently announces.
|
|
.\Export-IpBlocklist.ps1 -RefreshCarveouts
|
|
|
|
.EXAMPLE
|
|
# Generate without any network carve-out, keeping your own exemptions.
|
|
.\Export-IpBlocklist.ps1 -Carveout ''
|
|
|
|
.EXAMPLE
|
|
# Linux/macOS, e.g. from cron:
|
|
pwsh -File /opt/modernuo/Export-IpBlocklist.ps1 -DistributionPath /opt/modernuo/Distribution
|
|
|
|
.NOTES
|
|
Feeds are aggressive-but-low-FP for a game server (attacker / botnet / compromised / abuse-relay SOURCE
|
|
IPs). Reserved/bogon space (0/8, 10/8, 127/8, RFC1918, multicast, etc.) is always filtered out -- this
|
|
matters because firehol-level1 ships bogon netblocks that would otherwise block private/reserved ranges.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $DistributionPath,
|
|
[string] $OutFile,
|
|
[string] $MinInterval = '2h',
|
|
[string[]] $AllowlistFile,
|
|
[string[]] $Carveout,
|
|
[switch] $RefreshCarveouts,
|
|
[string[]] $Feeds,
|
|
[switch] $ExcludeAnonymizers,
|
|
[switch] $Force,
|
|
[switch] $DryRun
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$UA = 'ModernUO-Blocklist-Export'
|
|
$totalSw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
|
|
# Default location under the Distribution folder. Keep in sync with BlocklistSettings.File.
|
|
# Kept as separate segments (never a literal 'a\b') so Join-Path picks the right separator per OS.
|
|
$DefaultPathSegments = @('Configuration', 'ip-blocklist.txt')
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Resolve the output path. Explicit -OutFile wins; then -DistributionPath; then the in-repo layout
|
|
# (tools\ sits next to Distribution\) so a checkout works with no arguments at all. The script is meant to
|
|
# be copied onto the shard host, and there it needs -DistributionPath (or -OutFile).
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
if (-not $OutFile) {
|
|
if (-not $DistributionPath -and $PSScriptRoot) {
|
|
$inRepo = Join-Path (Split-Path -Parent $PSScriptRoot) 'Distribution'
|
|
if (Test-Path -LiteralPath $inRepo -PathType Container) { $DistributionPath = $inRepo }
|
|
}
|
|
if (-not $DistributionPath) {
|
|
throw "Could not locate the shard's Distribution folder. Pass -DistributionPath 'C:\path\to\Distribution' (or -OutFile)."
|
|
}
|
|
if (-not (Test-Path -LiteralPath $DistributionPath -PathType Container)) {
|
|
throw "DistributionPath '$DistributionPath' does not exist."
|
|
}
|
|
$OutFile = Join-Path $DistributionPath @DefaultPathSegments
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Network carve-outs. Each entry becomes `ip-allowlist-<Name>.txt` beside the output and is subtracted from
|
|
# the generated list. To cover another CGNAT provider, add a row: give it a Name, its ASN, and either paste a
|
|
# Seed or leave Seed empty and run -RefreshCarveouts to fetch the prefixes from routing data.
|
|
#
|
|
# Seed is the offline copy, so a fresh checkout works with no network access. It is split on whitespace, so
|
|
# the wrapping is only for readability.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
$Carveouts = @(
|
|
[pscustomobject]@{
|
|
Name = 'starlink'
|
|
Asn = 14593
|
|
Label = 'Starlink (SpaceX)'
|
|
Why = 'CGNAT throughout, with rotating leases, so one address fronts many subscribers at once.'
|
|
Seed = @'
|
|
9.161.0.0/21 9.161.8.0/23 9.161.10.0/24 9.161.128.0/20 9.161.144.0/22 9.161.150.0/23 9.161.152.0/23 9.170.0.0/19
|
|
9.170.32.0/20 9.170.48.0/22 9.170.54.0/23 9.170.56.0/21 9.170.64.0/21 9.170.72.0/22 9.246.0.0/19 9.246.32.0/20
|
|
9.246.48.0/23 9.246.52.0/22 9.246.56.0/21 9.246.64.0/18 9.246.128.0/20 9.246.144.0/22 14.1.64.0/19
|
|
64.226.208.0/24 65.181.0.0/20 65.181.16.0/21 65.181.24.0/22 65.181.30.0/23 66.9.160.0/20 66.9.176.0/21
|
|
66.9.184.0/22 74.244.0.0/16 74.245.0.0/20 74.245.16.0/23 74.245.19.0/24 74.245.20.0/24 74.245.29.0/24
|
|
74.245.30.0/23 74.245.32.0/24 74.245.35.0/24 74.245.36.0/24 74.245.39.0/24 74.245.40.0/22 74.245.44.0/24
|
|
74.245.138.0/23 74.245.140.0/23 74.245.142.0/24 74.245.156.0/22 74.245.160.0/22 74.245.164.0/24 74.245.192.0/21
|
|
74.245.200.0/23 74.245.202.0/24 74.245.206.0/24 74.245.208.0/22 74.245.212.0/24 74.245.217.0/24 74.245.224.0/21
|
|
74.245.232.0/22 74.245.236.0/23 74.245.239.0/24 74.245.240.0/21 74.245.248.0/23 74.245.251.0/24 74.245.252.0/22
|
|
87.251.24.0/21 91.102.180.0/22 98.97.0.0/17 98.97.128.0/19 98.97.160.0/20 98.97.176.0/22 98.97.180.0/23
|
|
98.97.182.0/24 98.97.184.0/24 98.97.186.0/23 98.97.188.0/23 98.97.190.0/24 103.235.92.0/22 116.91.208.0/20
|
|
129.222.0.0/18 129.222.64.0/20 129.222.80.0/21 129.222.88.0/22 129.222.94.0/23 129.222.96.0/19 129.222.128.0/18
|
|
129.222.192.0/20 129.222.208.0/21 129.222.224.0/19 129.224.192.0/20 129.224.208.0/21 129.224.216.0/22
|
|
129.224.222.0/24 135.129.2.0/23 135.129.4.0/22 135.129.8.0/21 135.129.16.0/23 135.129.19.0/24 135.129.20.0/22
|
|
135.129.24.0/21 135.129.32.0/23 135.129.34.0/24 135.129.36.0/22 135.129.40.0/21 135.129.48.0/21 135.129.57.0/24
|
|
135.129.58.0/23 135.129.60.0/22 135.129.112.0/23 135.129.115.0/24 135.129.116.0/24 135.129.118.0/23
|
|
135.129.120.0/21 135.129.240.0/24 135.129.244.0/24 135.129.248.0/22 135.129.252.0/23 135.129.254.0/24
|
|
137.83.112.0/22 137.83.116.0/23 137.83.118.0/24 137.83.121.0/24 137.83.122.0/23 137.83.124.0/22 138.84.32.0/19
|
|
141.109.64.0/20 141.109.80.0/24 141.109.82.0/23 141.109.84.0/22 141.109.88.0/23 141.109.92.0/24 143.105.0.0/18
|
|
143.105.64.0/19 143.105.96.0/20 143.105.112.0/21 143.105.121.0/24 143.105.122.0/23 143.105.124.0/22
|
|
143.105.128.0/17 143.131.0.0/21 143.131.9.0/24 143.131.10.0/23 143.131.12.0/22 144.126.64.0/20 144.126.80.0/21
|
|
144.126.96.0/19 145.224.64.0/18 148.222.128.0/21 148.222.192.0/19 148.227.64.0/18 149.19.108.0/23
|
|
149.19.160.0/20 150.228.0.0/16 153.66.0.0/15 162.43.192.0/22 164.152.165.0/24 168.140.240.0/20 169.150.16.0/23
|
|
169.150.18.0/24 169.150.22.0/23 169.150.26.0/24 169.150.28.0/22 169.155.224.0/19 170.203.64.0/19
|
|
170.203.192.0/19 173.250.196.0/23 173.250.199.0/24 173.250.200.0/24 173.250.203.0/24 173.250.204.0/22
|
|
173.250.208.0/20 176.116.124.0/23 179.60.168.0/21 179.64.0.0/17 179.64.132.0/24 179.64.140.0/24 179.64.147.0/24
|
|
179.64.153.0/24 179.64.154.0/24 179.64.156.0/24 179.64.160.0/23 179.64.172.0/24 179.64.178.0/24 179.64.180.0/24
|
|
179.64.186.0/24 179.65.0.0/19 179.65.32.0/20 179.65.126.0/23 179.65.128.0/19 179.65.160.0/21 179.65.176.0/20
|
|
179.238.0.0/18 179.238.64.0/19 188.92.248.0/21 188.95.144.0/23 198.54.103.0/24 200.189.16.0/20 205.174.156.0/23
|
|
206.83.96.0/21 206.83.104.0/24 206.83.106.0/23 206.83.108.0/22 206.83.112.0/20 206.214.224.0/20 206.224.64.0/20
|
|
206.224.80.0/21 206.224.88.0/23 209.198.128.0/22 209.198.132.0/23 209.198.135.0/24 209.198.136.0/21
|
|
209.198.144.0/20 212.105.128.0/21 212.105.136.0/22 212.105.140.0/23 212.105.144.0/20 216.128.0.0/19
|
|
216.147.120.0/21 216.180.80.0/20 216.234.192.0/19 217.65.136.0/21 217.142.16.0/20
|
|
'@
|
|
}
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Resolve the allowlists. They sit beside the output by default so relocating the blocklist keeps the set
|
|
# together, and they are split by owner: `ip-allowlist.txt` is the operator's -- hand-edited, never rewritten
|
|
# -- while each carve-out file is generated data that can be regenerated, diffed or copied between shards
|
|
# without touching anyone's local exemptions.
|
|
#
|
|
# An EXPLICIT -AllowlistFile replaces the whole set and is never templated: if the operator names a file, a
|
|
# missing one is a typo worth hearing about, not something to paper over with defaults they did not ask for.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
if ($Carveout) {
|
|
$Carveouts = @($Carveouts | Where-Object { $Carveout -contains $_.Name })
|
|
if (-not $Carveouts) { throw "No carve-outs matched -Carveout. Known: $(($Carveouts | ForEach-Object Name) -join ', ')" }
|
|
}
|
|
elseif ($PSBoundParameters.ContainsKey('Carveout')) {
|
|
$Carveouts = @() # -Carveout '' disables them without disabling the operator's own list
|
|
}
|
|
|
|
$allowExplicit = $PSBoundParameters.ContainsKey('AllowlistFile')
|
|
$AllowPaths = @()
|
|
|
|
if ($allowExplicit) {
|
|
$AllowPaths = @($AllowlistFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
}
|
|
else {
|
|
$cfgDir = Split-Path -Parent $OutFile
|
|
$AllowPaths = @(Join-Path $cfgDir 'ip-allowlist.txt') +
|
|
@($Carveouts | ForEach-Object { Join-Path $cfgDir ("ip-allowlist-{0}.txt" -f $_.Name) })
|
|
}
|
|
|
|
function Write-AllowlistFile {
|
|
param([string]$Path, [string[]]$Lines)
|
|
|
|
$dir = Split-Path -Parent $Path
|
|
if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) {
|
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
}
|
|
|
|
# Same atomic write the blocklist gets: a half-written allowlist would silently under-subtract.
|
|
$tmp = $Path + '.tmp'
|
|
[IO.File]::WriteAllLines($tmp, $Lines, [System.Text.UTF8Encoding]::new($false))
|
|
[IO.File]::Move($tmp, $Path, $true)
|
|
}
|
|
|
|
$OperatorTemplate = @'
|
|
# ModernUO blocklist allowlist -- every entry here is SUBTRACTED from the generated blocklist.
|
|
#
|
|
# This file is yours. Export-IpBlocklist.ps1 creates it once and never rewrites it, so anything you add
|
|
# survives every regeneration.
|
|
#
|
|
# One entry per line: a bare IPv4 address (1.2.3.4) or a CIDR (1.2.3.0/24). Lines starting with '#' or ';'
|
|
# are comments. Order does not matter. Re-run Export-IpBlocklist.ps1 to apply changes -- editing this file
|
|
# bypasses the -MinInterval cooldown, so no -Force is needed.
|
|
#
|
|
# Removal is range-correct: an address listed here is removed even when a feed published it as part of a
|
|
# larger CIDR -- that CIDR is split around the hole rather than dropped wholesale or silently ignored.
|
|
#
|
|
# Network-wide carve-outs live in their own files beside this one, so they can be regenerated or copied
|
|
# between shards without touching anything you put here.
|
|
#
|
|
# Put player/staff exemptions below, one per line, e.g.:
|
|
# 203.0.113.42 # shard owner, listed via a shared upstream address
|
|
'@
|
|
|
|
# Header for a carve-out file, built from its table row so adding a provider needs no new prose.
|
|
function Get-CarveoutHeader {
|
|
param([pscustomobject]$Row)
|
|
|
|
@(
|
|
("# {0} (AS{1}) carve-out -- subtracted from the generated blocklist." -f $Row.Label, $Row.Asn)
|
|
"#"
|
|
("# {0}" -f $Row.Why)
|
|
"# Reputation feeds list those addresses constantly, so a hit there says little about the player"
|
|
"# currently behind it. Abusive hosts inside it are still caught on BEHAVIOR by the rate limiter."
|
|
"#"
|
|
"# GENERATED DATA -- safe to regenerate, diff, or copy to another shard. Blank the file (keep the file)"
|
|
"# to reputation-block this network again; deleting it just makes the next run write it back."
|
|
"#"
|
|
("# Refresh with: .\Export-IpBlocklist.ps1 -RefreshCarveouts -Carveout {0}" -f $Row.Name)
|
|
)
|
|
}
|
|
|
|
# Fetches a carve-out's currently ANNOUNCED prefixes and collapses them. Routing data, not a registry:
|
|
# ownership records disagree with what is actually announced, and registry queries cap their result sets.
|
|
function Get-CarveoutPrefixes {
|
|
param([pscustomobject]$Row)
|
|
|
|
$url = "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS$($Row.Asn)"
|
|
$json = Get-Url -Url $url -Label ("AS{0} prefixes" -f $Row.Asn) | ConvertFrom-Json
|
|
|
|
$v4 = @($json.data.prefixes.prefix | Where-Object { $_ -and $_ -notmatch ':' })
|
|
if (-not $v4) { throw "AS$($Row.Asn) announced no IPv4 prefixes -- refusing to overwrite the carve-out." }
|
|
|
|
[BlocklistExporter]::CollapsePrefixes(($v4 -join "`n"))
|
|
}
|
|
|
|
# Seed any default file that is missing, from the offline copy so a fresh checkout needs no network.
|
|
# -RefreshCarveouts rewrites them from routing data later, once Add-Type and Get-Url exist.
|
|
# Explicit paths are never created -- see the resolution note above.
|
|
$CarveoutByFile = @{}
|
|
foreach ($c in $Carveouts) { $CarveoutByFile[("ip-allowlist-{0}.txt" -f $c.Name)] = $c }
|
|
|
|
$AllowPaths = @($AllowPaths | ForEach-Object {
|
|
$p = $_
|
|
if (Test-Path -LiteralPath $p -PathType Leaf) { return $p }
|
|
|
|
if ($allowExplicit) {
|
|
Write-Warning ("Allowlist '{0}' does not exist -- nothing will be subtracted from it. Check the path." -f $p)
|
|
return
|
|
}
|
|
|
|
$row = $CarveoutByFile[(Split-Path -Leaf $p)]
|
|
if ($row) {
|
|
$prefixes = @($row.Seed -split '\s+' | Where-Object { $_ })
|
|
Write-AllowlistFile -Path $p -Lines (@(Get-CarveoutHeader -Row $row) + $prefixes)
|
|
Write-Host ("Created {0} carve-out at {1} ({2} prefixes; blank the file to disable)." -f `
|
|
$row.Label, $p, $prefixes.Count)
|
|
}
|
|
else {
|
|
Write-AllowlistFile -Path $p -Lines @($OperatorTemplate)
|
|
Write-Host ("Created allowlist at {0} (add player/staff exemptions here)." -f $p)
|
|
}
|
|
|
|
return $p
|
|
})
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Cooldown gate. Runs BEFORE anything is downloaded: the whole point is that a misconfigured scheduler or a
|
|
# retry loop cannot spam the upstream feeds. State lives in the output file itself (`generated=` header,
|
|
# mtime as fallback), so it is correct across reboots, machines and hand-runs with no sidecar state file.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
function ConvertTo-Duration {
|
|
param([string]$Text)
|
|
if ([string]::IsNullOrWhiteSpace($Text)) { return [TimeSpan]::Zero }
|
|
$t = $Text.Trim().ToLowerInvariant()
|
|
$unit = $t[$t.Length - 1]
|
|
$numText = if ($unit -match '[0-9.]') { $t } else { $t.Substring(0, $t.Length - 1) }
|
|
$n = 0.0
|
|
# InvariantCulture is not optional here: under a comma-decimal locale (de-DE, fr-FR, ...) the
|
|
# current-culture parse reads '2.5' as 25 -- it treats '.' as a group separator and SUCCEEDS, so
|
|
# `-MinInterval 2.5h` would silently become a 25 hour cooldown instead of failing loudly.
|
|
if (-not [double]::TryParse($numText, [Globalization.NumberStyles]::Float,
|
|
[Globalization.CultureInfo]::InvariantCulture, [ref]$n)) {
|
|
throw "Could not parse duration '$Text' (try 90s, 45m, 2h, 2.5h, 1d)."
|
|
}
|
|
switch ($unit) {
|
|
's' { return [TimeSpan]::FromSeconds($n) }
|
|
'm' { return [TimeSpan]::FromMinutes($n) }
|
|
'h' { return [TimeSpan]::FromHours($n) }
|
|
'd' { return [TimeSpan]::FromDays($n) }
|
|
default { return [TimeSpan]::FromHours($n) } # bare number == hours
|
|
}
|
|
}
|
|
|
|
# Age of the list already on disk, or $null when there is nothing usable to age.
|
|
function Get-BlocklistAge {
|
|
param([string]$Path)
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
|
|
|
|
# Prefer the header we wrote: it describes the data, not the file, so copying/restoring the file
|
|
# cannot make a stale list look fresh (or a fresh one look stale).
|
|
try {
|
|
$first = Get-Content -LiteralPath $Path -TotalCount 1 -ErrorAction Stop
|
|
if ($first -and $first.StartsWith('#')) {
|
|
foreach ($tok in $first.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)) {
|
|
if ($tok.StartsWith('generated=', [StringComparison]::Ordinal)) {
|
|
$stamp = [DateTime]::MinValue
|
|
$styles = [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal
|
|
if ([DateTime]::TryParse($tok.Substring(10), [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$stamp)) {
|
|
return @{ Age = ([DateTime]::UtcNow - $stamp); Stamp = $tok.Substring(10); Source = 'header' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
# Hand-maintained or truncated file: fall back to the filesystem timestamp.
|
|
try {
|
|
$w = (Get-Item -LiteralPath $Path -ErrorAction Stop).LastWriteTimeUtc
|
|
return @{ Age = ([DateTime]::UtcNow - $w)
|
|
Stamp = $w.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture)
|
|
Source = 'mtime' }
|
|
}
|
|
catch { return $null }
|
|
}
|
|
|
|
$minAge = ConvertTo-Duration $MinInterval
|
|
# -RefreshCarveouts implies -Force: the operator explicitly asked for new carve-out data, so waiting out the
|
|
# cooldown and leaving the old data in place would be the wrong answer.
|
|
if (-not $Force -and -not $RefreshCarveouts -and $minAge -gt [TimeSpan]::Zero) {
|
|
$existing = Get-BlocklistAge -Path $OutFile
|
|
if ($existing) {
|
|
# A negative age means the stamp is in the future (clock skew, or a file from another host). Treat it
|
|
# as fresh: refusing to run is the recoverable failure, hammering the feeds on every tick is not.
|
|
if ($existing.Age -lt $minAge) {
|
|
# An allowlist edited since the list was built is the one case where waiting out the cooldown is
|
|
# the wrong answer: the operator is unblocking someone, and "nothing happened" is indistinguishable
|
|
# from the allowlist not working. Cheap to honour -- it can only ever shrink the output.
|
|
$changedAllow = $null
|
|
$builtAt = [DateTime]::UtcNow - $existing.Age
|
|
foreach ($p in $AllowPaths) {
|
|
try {
|
|
if ((Get-Item -LiteralPath $p -ErrorAction Stop).LastWriteTimeUtc -gt $builtAt) {
|
|
$changedAllow = $p
|
|
break
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
if ($changedAllow) {
|
|
Write-Host ("Allowlist {0} changed since the blocklist was built; regenerating despite -MinInterval {1}." -f `
|
|
$changedAllow, $MinInterval)
|
|
}
|
|
else {
|
|
$agoText = if ($existing.Age -lt [TimeSpan]::Zero) { 'in the future -- check the clock' } else { ("{0:N1}h ago" -f $existing.Age.TotalHours) }
|
|
Write-Host ("Blocklist at {0} was generated {1} ({2}={3}); newer than -MinInterval {4}." -f `
|
|
$OutFile, $agoText, $existing.Source, $existing.Stamp, $MinInterval)
|
|
Write-Host "Nothing downloaded. Pass -Force to regenerate now, or lower -MinInterval."
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Compiled hot loop. Interpreted PowerShell chokes on bitwire's ~4M lines; this parses + validates + bogon-
|
|
# filters + de-dupes in one compiled pass, and writes the final file directly (no 4M-element PS pipelines).
|
|
# Deliberately plain C#: no LINQ, no generics beyond HashSet, nothing that would slow the hot loop.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
Add-Type -TypeDefinition @'
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
|
|
public static class BlocklistExporter
|
|
{
|
|
static bool TryParseIPv4(string s, int start, int len, out uint val)
|
|
{
|
|
val = 0;
|
|
uint acc = 0; int octet = 0, dots = 0, digits = 0, end = start + len;
|
|
for (int i = start; i < end; i++)
|
|
{
|
|
char c = s[i];
|
|
if (c == '.')
|
|
{
|
|
if (digits == 0 || octet > 255) return false;
|
|
acc = (acc << 8) | (uint)octet; dots++; octet = 0; digits = 0;
|
|
}
|
|
else if (c >= '0' && c <= '9')
|
|
{
|
|
octet = octet * 10 + (c - '0'); if (++digits > 3) return false;
|
|
}
|
|
else return false;
|
|
}
|
|
if (dots != 3 || digits == 0 || octet > 255) return false;
|
|
val = (acc << 8) | (uint)octet;
|
|
return true;
|
|
}
|
|
|
|
static bool IsBogon(uint start, uint end, uint[] bs, uint[] be)
|
|
{
|
|
for (int i = 0; i < bs.Length; i++)
|
|
if (start <= be[i] && end >= bs[i]) return true;
|
|
return false;
|
|
}
|
|
|
|
// Parse one feed's text; add bare IPs to `singles`, CIDRs to `cidrs`. Returns count newly added.
|
|
public static int AddContent(string content, HashSet<uint> singles, HashSet<string> cidrs, uint[] bs, uint[] be)
|
|
{
|
|
int added = 0, n = content.Length, i = 0;
|
|
while (i < n)
|
|
{
|
|
int eol = content.IndexOf('\n', i);
|
|
int lineEnd = (eol < 0) ? n : eol;
|
|
int a = i, b = lineEnd;
|
|
while (a < b && (content[a] == ' ' || content[a] == '\t' || content[a] == '\r')) a++;
|
|
while (b > a && (content[b - 1] == ' ' || content[b - 1] == '\t' || content[b - 1] == '\r')) b--;
|
|
i = (eol < 0) ? n : eol + 1;
|
|
if (a >= b) continue;
|
|
char first = content[a];
|
|
if (first == '#' || first == ';') continue;
|
|
|
|
// Feeds vary: some are bare IPs, some are CSV/whitespace records with the IP first.
|
|
int t = a;
|
|
while (t < b)
|
|
{
|
|
char c = content[t];
|
|
if (c == ' ' || c == '\t' || c == ',' || c == ';') break;
|
|
t++;
|
|
}
|
|
int slash = -1;
|
|
for (int k = a; k < t; k++) { if (content[k] == '/') { slash = k; break; } }
|
|
|
|
if (slash >= 0)
|
|
{
|
|
uint ip;
|
|
if (!TryParseIPv4(content, a, slash - a, out ip)) continue;
|
|
int bits = 0, bd = 0;
|
|
for (int k = slash + 1; k < t; k++)
|
|
{
|
|
char c = content[k];
|
|
if (c < '0' || c > '9') { bd = -1; break; }
|
|
bits = bits * 10 + (c - '0'); bd++;
|
|
}
|
|
if (bd <= 0 || bits > 32) continue;
|
|
ulong size = (bits == 0) ? 0xFFFFFFFFUL : ((1UL << (32 - bits)) - 1UL);
|
|
ulong endAddr = (ulong)ip + size; if (endAddr > 0xFFFFFFFFUL) endAddr = 0xFFFFFFFFUL;
|
|
if (IsBogon(ip, (uint)endAddr, bs, be)) continue;
|
|
if (cidrs.Add(content.Substring(a, t - a))) added++;
|
|
}
|
|
else
|
|
{
|
|
uint ip;
|
|
if (!TryParseIPv4(content, a, t - a, out ip)) continue;
|
|
if (IsBogon(ip, ip, bs, be)) continue;
|
|
if (singles.Add(ip)) added++;
|
|
}
|
|
}
|
|
return added;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------
|
|
// Allowlist subtraction. Allow entries become sorted, merged [start,end] ranges once; the blocklist is
|
|
// then filtered against them. The interesting case is a blocked CIDR that only PARTIALLY overlaps an
|
|
// allow range -- dropping it whole would unblock far more than asked, keeping it whole would ignore the
|
|
// exemption, so it is split into the surviving pieces and re-emitted as minimal CIDRs.
|
|
// ---------------------------------------------------------------------------------------------------
|
|
public sealed class RangeSet
|
|
{
|
|
public uint[] Start;
|
|
public uint[] End;
|
|
public int Count;
|
|
}
|
|
|
|
public sealed class AllowResult
|
|
{
|
|
public int SinglesRemoved;
|
|
public int CidrsDropped;
|
|
public int CidrsSplit;
|
|
public int EntriesAdded;
|
|
}
|
|
|
|
// Parses "a.b.c.d/p" to an inclusive range. The base is masked to the prefix, so a sloppy 1.2.3.5/24
|
|
// means the whole 1.2.3.0/24 -- the standard reading, and the safe direction for an exemption.
|
|
static bool TryCidrRange(string c, out ulong lo, out ulong hi)
|
|
{
|
|
lo = 0; hi = 0;
|
|
int slash = c.IndexOf('/');
|
|
if (slash <= 0) return false;
|
|
uint ip;
|
|
if (!TryParseIPv4(c, 0, slash, out ip)) return false;
|
|
int bits = 0, bd = 0;
|
|
for (int i = slash + 1; i < c.Length; i++)
|
|
{
|
|
char ch = c[i];
|
|
if (ch < '0' || ch > '9') { bd = -1; break; }
|
|
bits = bits * 10 + (ch - '0'); bd++;
|
|
}
|
|
if (bd <= 0 || bits > 32) return false;
|
|
// 1u << 32 is undefined in C# (the shift count is masked to 5 bits), so /0 is special-cased.
|
|
uint mask = (bits == 0) ? 0u : ~((uint)((1UL << (32 - bits)) - 1UL));
|
|
ulong size = (bits == 0) ? 0x100000000UL : (1UL << (32 - bits));
|
|
lo = ip & mask;
|
|
hi = lo + size - 1UL;
|
|
return true;
|
|
}
|
|
|
|
public static RangeSet BuildRanges(HashSet<uint> singles, HashSet<string> cidrs)
|
|
{
|
|
uint[] s = new uint[singles.Count + cidrs.Count];
|
|
uint[] e = new uint[s.Length];
|
|
int k = 0;
|
|
|
|
foreach (uint v in singles) { s[k] = v; e[k] = v; k++; }
|
|
foreach (string c in cidrs)
|
|
{
|
|
ulong lo, hi;
|
|
if (!TryCidrRange(c, out lo, out hi)) continue;
|
|
s[k] = (uint)lo;
|
|
e[k] = (uint)(hi > 0xFFFFFFFFUL ? 0xFFFFFFFFUL : hi);
|
|
k++;
|
|
}
|
|
|
|
Array.Resize(ref s, k);
|
|
Array.Resize(ref e, k);
|
|
Array.Sort(s, e);
|
|
|
|
// Coalesce overlapping AND adjacent ranges so the lookups below can assume disjoint, ordered spans.
|
|
int w = 0;
|
|
for (int i = 0; i < k; i++)
|
|
{
|
|
if (w > 0 && (ulong)s[i] <= (ulong)e[w - 1] + 1UL)
|
|
{
|
|
if (e[i] > e[w - 1]) e[w - 1] = e[i];
|
|
}
|
|
else
|
|
{
|
|
s[w] = s[i]; e[w] = e[i]; w++;
|
|
}
|
|
}
|
|
|
|
return new RangeSet { Start = s, End = e, Count = w };
|
|
}
|
|
|
|
// Index of the first range whose End >= v (ranges are disjoint and sorted, so End is sorted too).
|
|
static int FirstEndAtLeast(uint[] re, int n, uint v)
|
|
{
|
|
int lo = 0, hi = n;
|
|
while (lo < hi)
|
|
{
|
|
int mid = (int)(((uint)lo + (uint)hi) >> 1);
|
|
if (re[mid] < v) lo = mid + 1; else hi = mid;
|
|
}
|
|
return lo;
|
|
}
|
|
|
|
static bool Covered(uint[] rs, uint[] re, int n, uint v)
|
|
{
|
|
int i = FirstEndAtLeast(re, n, v);
|
|
return i < n && rs[i] <= v;
|
|
}
|
|
|
|
static string FormatCidr(uint ip, int bits)
|
|
{
|
|
char[] buf = new char[19];
|
|
int p = 0;
|
|
p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, ip & 255); buf[p++] = '/';
|
|
p = WriteOctet(buf, p, (uint)bits);
|
|
return new string(buf, 0, p);
|
|
}
|
|
|
|
// Writes [lo,hi] as the minimal set of aligned CIDR blocks. A /32 goes back to the singles set so the
|
|
// output keeps the file's convention of bare addresses for single hosts.
|
|
static void Emit(ulong lo, ulong hi, HashSet<uint> singles, HashSet<string> cidrs, AllowResult r)
|
|
{
|
|
while (lo <= hi)
|
|
{
|
|
int bits = 32;
|
|
while (bits > 0)
|
|
{
|
|
ulong size = 1UL << (32 - (bits - 1));
|
|
if ((lo % size) != 0UL) break;
|
|
if (lo + size - 1UL > hi) break;
|
|
bits--;
|
|
}
|
|
|
|
if (bits == 32)
|
|
{
|
|
if (singles.Add((uint)lo)) r.EntriesAdded++;
|
|
}
|
|
else if (cidrs.Add(FormatCidr((uint)lo, bits)))
|
|
{
|
|
r.EntriesAdded++;
|
|
}
|
|
|
|
lo += 1UL << (32 - bits);
|
|
}
|
|
}
|
|
|
|
public static AllowResult ApplyAllowlist(HashSet<uint> singles, HashSet<string> cidrs, RangeSet allow)
|
|
{
|
|
var r = new AllowResult();
|
|
if (allow == null || allow.Count == 0) return r;
|
|
|
|
uint[] rs = allow.Start, re = allow.End;
|
|
int n = allow.Count;
|
|
|
|
// Singles first: the CIDR pass below can add new singles, and those are outside the allow ranges by
|
|
// construction, so re-testing them would be wasted work.
|
|
uint[] sarr = new uint[singles.Count];
|
|
singles.CopyTo(sarr);
|
|
for (int i = 0; i < sarr.Length; i++)
|
|
{
|
|
if (Covered(rs, re, n, sarr[i]) && singles.Remove(sarr[i])) r.SinglesRemoved++;
|
|
}
|
|
|
|
string[] carr = new string[cidrs.Count];
|
|
cidrs.CopyTo(carr);
|
|
cidrs.Clear();
|
|
|
|
for (int i = 0; i < carr.Length; i++)
|
|
{
|
|
string c = carr[i];
|
|
ulong lo, hi;
|
|
|
|
// Unparseable entries are kept verbatim rather than dropped: this pass exists to subtract, and
|
|
// silently discarding something it could not read would weaken the list.
|
|
if (!TryCidrRange(c, out lo, out hi)) { cidrs.Add(c); continue; }
|
|
if (hi > 0xFFFFFFFFUL) hi = 0xFFFFFFFFUL;
|
|
|
|
int idx = FirstEndAtLeast(re, n, (uint)lo);
|
|
if (idx >= n || (ulong)rs[idx] > hi) { cidrs.Add(c); continue; } // no overlap: the common case
|
|
|
|
ulong cursor = lo;
|
|
int before = r.EntriesAdded;
|
|
for (int j = idx; j < n && (ulong)rs[j] <= hi; j++)
|
|
{
|
|
if ((ulong)rs[j] > cursor) Emit(cursor, (ulong)rs[j] - 1UL, singles, cidrs, r);
|
|
ulong next = (ulong)re[j] + 1UL;
|
|
if (next > cursor) cursor = next;
|
|
if (cursor > hi) break;
|
|
}
|
|
if (cursor <= hi) Emit(cursor, hi, singles, cidrs, r);
|
|
|
|
if (r.EntriesAdded == before) r.CidrsDropped++; else r.CidrsSplit++;
|
|
}
|
|
|
|
return r;
|
|
}
|
|
|
|
static string FormatIp(uint ip)
|
|
{
|
|
char[] buf = new char[16];
|
|
int p = 0;
|
|
p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, ip & 255);
|
|
return new string(buf, 0, p);
|
|
}
|
|
|
|
// Collapses a prefix list into the minimal equivalent set, in ascending order. Used by
|
|
// -RefreshCarveouts: routing data publishes thousands of overlapping announcements.
|
|
public static string[] CollapsePrefixes(string content)
|
|
{
|
|
uint[] noBogon = new uint[0];
|
|
var singles = new HashSet<uint>();
|
|
var cidrs = new HashSet<string>();
|
|
AddContent(content, singles, cidrs, noBogon, noBogon);
|
|
|
|
var ranges = BuildRanges(singles, cidrs);
|
|
var result = new List<string>();
|
|
|
|
for (int i = 0; i < ranges.Count; i++)
|
|
{
|
|
ulong lo = ranges.Start[i], hi = ranges.End[i];
|
|
|
|
while (lo <= hi)
|
|
{
|
|
int bits = 32;
|
|
while (bits > 0)
|
|
{
|
|
ulong size = 1UL << (32 - (bits - 1));
|
|
if ((lo % size) != 0UL) break;
|
|
if (lo + size - 1UL > hi) break;
|
|
bits--;
|
|
}
|
|
|
|
result.Add(bits == 32 ? FormatIp((uint)lo) : FormatCidr((uint)lo, bits));
|
|
lo += 1UL << (32 - bits);
|
|
}
|
|
}
|
|
|
|
return result.ToArray();
|
|
}
|
|
|
|
static int WriteOctet(char[] buf, int pos, uint v)
|
|
{
|
|
if (v >= 100) { buf[pos++] = (char)('0' + v / 100); buf[pos++] = (char)('0' + (v / 10) % 10); }
|
|
else if (v >= 10) { buf[pos++] = (char)('0' + v / 10); }
|
|
buf[pos++] = (char)('0' + v % 10);
|
|
return pos;
|
|
}
|
|
|
|
// Writes the whole blocklist in one streamed pass so we never materialize millions of strings.
|
|
// Header first (the shard's reload detector requires it), then singles, then CIDRs. LF line endings.
|
|
public static void Write(string path, string header, HashSet<uint> singles, HashSet<string> cidrs)
|
|
{
|
|
using (var w = new StreamWriter(path, false, new UTF8Encoding(false), 1 << 20))
|
|
{
|
|
w.Write(header); w.Write('\n');
|
|
|
|
char[] buf = new char[20];
|
|
foreach (uint v in singles)
|
|
{
|
|
int p = 0;
|
|
p = WriteOctet(buf, p, (v >> 24) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, (v >> 16) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, (v >> 8) & 255); buf[p++] = '.';
|
|
p = WriteOctet(buf, p, v & 255); buf[p++] = '\n';
|
|
w.Write(buf, 0, p);
|
|
}
|
|
|
|
foreach (string c in cidrs) { w.Write(c); w.Write('\n'); }
|
|
}
|
|
}
|
|
}
|
|
'@
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Feed set -- thin, non-overlapping. See .DESCRIPTION for why each is kept and what was dropped as redundant.
|
|
# romainmarcoux's "full" set is sharded; only aa..ad carry data today (ae.. are empty placeholders).
|
|
# A 404/empty shard is skipped, so extend this list if upstream grows the shard count.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
$rmBase = 'https://raw.githubusercontent.com/romainmarcoux/malicious-ip/main/full-300k-'
|
|
$rmShards = @('aa','ab','ac','ad') | ForEach-Object { $rmBase + $_ + '.txt' }
|
|
|
|
$AllFeeds = @(
|
|
[pscustomobject]@{ Name = 'bitwire-it'; Urls = @('https://raw.githubusercontent.com/bitwire-it/ipblocklist/main/inbound.txt') }
|
|
[pscustomobject]@{ Name = 'romainmarcoux'; Urls = $rmShards }
|
|
[pscustomobject]@{ Name = 'sentinel-turris';Urls = @('https://view.sentinel.turris.cz/greylist-data/greylist-latest.csv') }
|
|
[pscustomobject]@{ Name = 'firehol-level1'; Urls = @('https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset') }
|
|
)
|
|
|
|
# Anonymizer / relay lists subtracted only when -ExcludeAnonymizers is set (Tor exits, open proxies, VPN tunnels).
|
|
$AnonFeeds = @(
|
|
'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/tor_exits.ipset'
|
|
'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/sslproxies_7d.ipset'
|
|
'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/socks_proxy_7d.ipset'
|
|
'https://raw.githubusercontent.com/ShadowWhisperer/IPs/master/Lists/Tunnels'
|
|
)
|
|
|
|
if ($Feeds) {
|
|
$AllFeeds = $AllFeeds | Where-Object { $Feeds -contains $_.Name }
|
|
if (-not $AllFeeds) { throw "No feeds matched -Feeds." }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Reserved / bogon ranges -- never valid attacker SOURCE IPs; always filtered. Built once as uint32 arrays.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
function ConvertTo-IPv4UInt {
|
|
param([string]$s)
|
|
$a = $s.Split('.')
|
|
if ($a.Length -ne 4) { return $null }
|
|
$v = [uint32]0
|
|
foreach ($o in $a) {
|
|
$n = 0
|
|
if (-not [int]::TryParse($o, [ref]$n) -or $n -lt 0 -or $n -gt 255) { return $null }
|
|
$v = ($v -shl 8) -bor [uint32]$n
|
|
}
|
|
return $v
|
|
}
|
|
|
|
$bogonCidrs = '0.0.0.0/8','10.0.0.0/8','100.64.0.0/10','127.0.0.0/8','169.254.0.0/16','172.16.0.0/12',
|
|
'192.0.0.0/24','192.0.2.0/24','192.168.0.0/16','198.18.0.0/15','198.51.100.0/24',
|
|
'203.0.113.0/24','224.0.0.0/3' # 224/3 covers multicast + reserved + 255.255.255.255
|
|
$bogStart = [System.Collections.Generic.List[uint32]]::new()
|
|
$bogEnd = [System.Collections.Generic.List[uint32]]::new()
|
|
foreach ($c in $bogonCidrs) {
|
|
$p = $c.Split('/'); $base = ConvertTo-IPv4UInt $p[0]; $bits = [int]$p[1]
|
|
$size = [uint32]([Math]::Pow(2, 32 - $bits))
|
|
$bogStart.Add($base); $bogEnd.Add([uint32]($base + $size - 1))
|
|
}
|
|
$bogStart = $bogStart.ToArray(); $bogEnd = $bogEnd.ToArray()
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Streaming download with a live progress bar (Write-Progress) so large feeds show real byte progress.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
function Get-Url {
|
|
param([string]$Url, [string]$Label)
|
|
$req = [System.Net.HttpWebRequest]::Create($Url)
|
|
$req.UserAgent = $UA; $req.Timeout = 120000; $req.ReadWriteTimeout = 120000
|
|
$resp = $req.GetResponse()
|
|
try {
|
|
$total = $resp.ContentLength
|
|
$stream = $resp.GetResponseStream()
|
|
$ms = New-Object System.IO.MemoryStream
|
|
$buf = New-Object byte[] (1MB)
|
|
$read = 0; $lastReport = 0
|
|
while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) {
|
|
$ms.Write($buf, 0, $n); $read += $n
|
|
if ($read - $lastReport -ge 2MB) {
|
|
$lastReport = $read
|
|
if ($total -gt 0) {
|
|
Write-Progress -Activity ("Downloading {0}" -f $Label) -PercentComplete ([int](100 * $read / $total)) `
|
|
-Status ("{0:N1} / {1:N1} MB" -f ($read / 1MB), ($total / 1MB))
|
|
} else {
|
|
Write-Progress -Activity ("Downloading {0}" -f $Label) -Status ("{0:N1} MB" -f ($read / 1MB))
|
|
}
|
|
}
|
|
}
|
|
Write-Progress -Activity ("Downloading {0}" -f $Label) -Completed
|
|
return [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
|
|
}
|
|
finally { $resp.Close() }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Refresh carve-out data from routing announcements. Runs here because it needs Get-Url and the compiled
|
|
# collapser. A failure leaves the existing file alone rather than truncating a working carve-out.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
if ($RefreshCarveouts) {
|
|
foreach ($c in $Carveouts) {
|
|
$path = $AllowPaths | Where-Object { (Split-Path -Leaf $_) -eq ("ip-allowlist-{0}.txt" -f $c.Name) } | Select-Object -First 1
|
|
if (-not $path) { continue }
|
|
|
|
try {
|
|
$prefixes = Get-CarveoutPrefixes -Row $c
|
|
Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Row $c) + $prefixes)
|
|
Write-Host ("Refreshed {0} carve-out: {1} prefixes -> {2}" -f $c.Label, @($prefixes).Count, $path)
|
|
}
|
|
catch {
|
|
Write-Warning ("{0}: refresh failed ({1}) -- keeping the existing carve-out" -f $c.Label, $_.Exception.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Collect every kept feed into ONE global set, timing each phase.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
$singles = [System.Collections.Generic.HashSet[uint32]]::new()
|
|
$cidrs = [System.Collections.Generic.HashSet[string]]::new()
|
|
$feedCount = 0
|
|
|
|
foreach ($feed in $AllFeeds) {
|
|
$before = $singles.Count + $cidrs.Count
|
|
$ok = $false
|
|
foreach ($url in $feed.Urls) {
|
|
try {
|
|
$dlSw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
$content = Get-Url -Url $url -Label $feed.Name
|
|
$dlSw.Stop()
|
|
$mb = [Math]::Round($content.Length / 1MB, 1)
|
|
|
|
$pSw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
[void][BlocklistExporter]::AddContent($content, $singles, $cidrs, $bogStart, $bogEnd)
|
|
$pSw.Stop()
|
|
Write-Host (" [dl {0,6:N1}s / parse {1,5:N1}s] {2}" -f $dlSw.Elapsed.TotalSeconds, $pSw.Elapsed.TotalSeconds, ("{0} ({1} MB)" -f $feed.Name, $mb))
|
|
$ok = $true
|
|
}
|
|
catch { Write-Warning ("{0}: {1} -- skipping shard ({2})" -f $feed.Name, $url, $_.Exception.Message) }
|
|
}
|
|
if ($ok) {
|
|
$feedCount++
|
|
$delta = ($singles.Count + $cidrs.Count) - $before
|
|
Write-Host ("{0,-16} +{1,8} new (running total {2} ip / {3} cidr)`n" -f $feed.Name, $delta, $singles.Count, $cidrs.Count)
|
|
}
|
|
else { Write-Warning ("{0}: all sources failed -- skipping" -f $feed.Name) }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Subtraction pass: the allowlist file, plus the anonymizer feeds when -ExcludeAnonymizers is set. Both are
|
|
# "never block these", so they share one set and one range-correct removal -- which is also the fix for the
|
|
# old anonymizer path, where CIDR entries were parsed and then never actually subtracted.
|
|
#
|
|
# Bogon filtering is deliberately NOT applied here: it exists to keep junk OUT of the blocklist, and running
|
|
# it over subtractive input would quietly discard exemptions instead (e.g. a shard exempting its own LAN).
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
$allowSingles = [System.Collections.Generic.HashSet[uint32]]::new()
|
|
$allowCidrs = [System.Collections.Generic.HashSet[string]]::new()
|
|
$noBogon = [uint32[]]::new(0)
|
|
|
|
foreach ($p in $AllowPaths) {
|
|
try {
|
|
$allowText = Get-Content -LiteralPath $p -Raw -ErrorAction Stop
|
|
if (-not $allowText) { continue }
|
|
|
|
$before = $allowSingles.Count + $allowCidrs.Count
|
|
[void][BlocklistExporter]::AddContent($allowText, $allowSingles, $allowCidrs, $noBogon, $noBogon)
|
|
Write-Host (" allowlist {0,-28} +{1} entr(ies)" -f (Split-Path -Leaf $p), (($allowSingles.Count + $allowCidrs.Count) - $before))
|
|
}
|
|
catch { Write-Warning ("Could not read allowlist {0}: {1}" -f $p, $_.Exception.Message) }
|
|
}
|
|
|
|
if ($ExcludeAnonymizers) {
|
|
foreach ($url in $AnonFeeds) {
|
|
try { [void][BlocklistExporter]::AddContent((Get-Url -Url $url -Label 'anonymizers'), $allowSingles, $allowCidrs, $noBogon, $noBogon) }
|
|
catch { Write-Warning ("anonymizer list {0}: {1}" -f $url, $_.Exception.Message) }
|
|
}
|
|
}
|
|
|
|
$allowCount = $allowSingles.Count + $allowCidrs.Count
|
|
if ($allowCount -gt 0) {
|
|
$aSw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
$ranges = [BlocklistExporter]::BuildRanges($allowSingles, $allowCidrs)
|
|
$res = [BlocklistExporter]::ApplyAllowlist($singles, $cidrs, $ranges)
|
|
$aSw.Stop()
|
|
|
|
Write-Host ("Allowlist: {0} entr(ies) -> {1} ranges; removed {2} IPs, dropped {3} CIDRs, split {4} into {5} ({6:N1}s)" -f `
|
|
$allowCount, $ranges.Count, $res.SinglesRemoved, $res.CidrsDropped, $res.CidrsSplit, $res.EntriesAdded, $aSw.Elapsed.TotalSeconds)
|
|
}
|
|
|
|
$total = $singles.Count + $cidrs.Count
|
|
Write-Host ("Merged {0} feed(s): {1} unique single IPs + {2} unique CIDRs (bogon-filtered) in {3:N1}s." -f `
|
|
$feedCount, $singles.Count, $cidrs.Count, $totalSw.Elapsed.TotalSeconds)
|
|
|
|
if ($DryRun) {
|
|
Write-Host ("DRY RUN: nothing written (would have written {0} entries to {1})." -f $total, $OutFile)
|
|
return
|
|
}
|
|
|
|
# A partial feed outage must not silently shrink the shard's blocklist to nothing; keep the last good file.
|
|
if ($total -eq 0) { throw "No entries parsed -- refusing to overwrite '$OutFile' with an empty list." }
|
|
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
# Write to a .tmp sibling and swap it into place, so the shard (which reads the whole file on a change)
|
|
# never observes a half-written list. One rename does it whether or not a list is already there.
|
|
# ---------------------------------------------------------------------------------------------------------
|
|
$outDir = Split-Path -Parent $OutFile
|
|
if ($outDir -and -not (Test-Path -LiteralPath $outDir -PathType Container)) {
|
|
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
|
}
|
|
|
|
# InvariantCulture: ':' is the culture-defined time separator in a custom format string, and the
|
|
# header is a machine-read marker the shard compares verbatim.
|
|
$generated = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture)
|
|
# The shard's header reader is token-based and ignores tokens it does not know, so `allow=` is additive --
|
|
# it is here so an operator can tell from the file alone whether a carve-out was in effect when it was built.
|
|
$header = "# modernuo-blocklist generated=$generated count=$total ipv4=$($singles.Count) cidr=$($cidrs.Count) feeds=$feedCount allow=$allowCount"
|
|
|
|
$tmp = $OutFile + '.tmp'
|
|
$wSw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
try {
|
|
[BlocklistExporter]::Write($tmp, $header, $singles, $cidrs)
|
|
# One atomic rename over the destination on every platform: MoveFileEx REPLACE_EXISTING on
|
|
# Windows, rename(2) on Linux and macOS.
|
|
[IO.File]::Move($tmp, $OutFile, $true)
|
|
}
|
|
finally {
|
|
# Never leave a partial .tmp next to a live blocklist for the next run to trip over.
|
|
if (Test-Path -LiteralPath $tmp -PathType Leaf) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
|
|
}
|
|
$wSw.Stop()
|
|
|
|
$sizeMb = [Math]::Round((Get-Item -LiteralPath $OutFile).Length / 1MB, 1)
|
|
Write-Host ("`nWrote {0} entries ({1} MB) to {2} in {3:N1}s (total {4:N1}s). generated={5}" -f `
|
|
$total, $sizeMb, $OutFile, $wSw.Elapsed.TotalSeconds, $totalSw.Elapsed.TotalSeconds, $generated)
|
|
Write-Host "The shard picks this up on its next reloadInterval poll; no restart needed."
|