diff --git a/.gitignore b/.gitignore
index 56fa78632..2cc93a052 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,10 +14,8 @@
/Distribution/Configuration/blocklist.json
/Distribution/Configuration/crowdsec.json
/Distribution/Configuration/expansion.json
-/Distribution/Configuration/ip-allowlist.txt
-/Distribution/Configuration/ip-allowlist.txt.tmp
-/Distribution/Configuration/ip-allowlist-starlink.txt
-/Distribution/Configuration/ip-allowlist-starlink.txt.tmp
+/Distribution/Configuration/ip-allowlist*.txt
+/Distribution/Configuration/ip-allowlist*.txt.tmp
/Distribution/Configuration/ip-blocklist.txt
/Distribution/Configuration/ip-blocklist.txt.tmp
/Distribution/Configuration/login-allowlist.json
diff --git a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs
index 302482573..8a2df5ca6 100644
--- a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs
+++ b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs
@@ -73,12 +73,12 @@ public record BlocklistSettings
/// entry also suppresses ban contributions, which the generator alone cannot do. See
/// .
///
+ ///
+ /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds
+ /// without anyone editing this file.
+ ///
[JsonPropertyName("allowlistFiles")]
- public string[] AllowlistFiles { get; set; } =
- [
- "Configuration/ip-allowlist.txt",
- "Configuration/ip-allowlist-starlink.txt"
- ];
+ public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.txt"];
/// How often the file is checked for changes. Reloads only happen when it actually changed.
[JsonPropertyName("reloadInterval")]
diff --git a/Projects/UOContent/Network/Blocklist/FileAllowlist.cs b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs
index 33e4f4288..0d5582713 100644
--- a/Projects/UOContent/Network/Blocklist/FileAllowlist.cs
+++ b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs
@@ -14,6 +14,7 @@
*************************************************************************/
using System;
+using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
@@ -42,7 +43,7 @@ public static class FileAllowlist
// 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[] _paths = [];
+ private static string[] _patterns = [];
private static TimeSpan _interval = TimeSpan.FromSeconds(60);
private static long _lastStamp;
private static CancellationTokenSource _cts;
@@ -61,10 +62,10 @@ public static class FileAllowlist
return;
}
- _paths = ResolvePaths(settings.AllowlistFiles);
+ _patterns = ResolvePaths(settings.AllowlistFiles);
_interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval;
- if (_paths.Length == 0)
+ if (_patterns.Length == 0)
{
logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)");
return;
@@ -110,6 +111,59 @@ public static class FileAllowlist
return resolved;
}
+ ///
+ /// 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.
+ ///
+ private static string[] ExpandPaths()
+ {
+ var files = new List();
+
+ 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)
@@ -155,12 +209,13 @@ public static class FileAllowlist
private static long Stamp()
{
var stamp = 0L;
+ var paths = ExpandPaths();
- for (var i = 0; i < _paths.Length; i++)
+ for (var i = 0; i < paths.Length; i++)
{
try
{
- var info = new FileInfo(_paths[i]);
+ var info = new FileInfo(paths[i]);
if (info.Exists)
{
stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length;
@@ -205,19 +260,20 @@ public static class FileAllowlist
{
files = 0;
- var chunks = new byte[_paths.Length][];
+ var paths = ExpandPaths();
+ var chunks = new byte[paths.Length][];
var total = 0;
- for (var i = 0; i < _paths.Length; i++)
+ for (var i = 0; i < paths.Length; i++)
{
try
{
- if (!File.Exists(_paths[i]))
+ if (!File.Exists(paths[i]))
{
continue;
}
- var bytes = File.ReadAllBytes(_paths[i]);
+ var bytes = File.ReadAllBytes(paths[i]);
chunks[i] = bytes;
total += bytes.Length + 1; // + newline separator
files++;
@@ -225,7 +281,7 @@ public static class FileAllowlist
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]);
+ logger.Warning(e, "Could not read allowlist \"{Path}\"", paths[i]);
}
}
diff --git a/dev-docs/ip-bans-and-allowlists.md b/dev-docs/ip-bans-and-allowlists.md
index 2caaabad3..5588bb19e 100644
--- a/dev-docs/ip-bans-and-allowlists.md
+++ b/dev-docs/ip-bans-and-allowlists.md
@@ -38,7 +38,7 @@ Two, with different authority:
| List | Source | Revocable? | Covers |
|---|---|---|---|
-| `FileAllowlist` | `ip-allowlist.txt`, `ip-allowlist-.txt` | No — an operator said so | Blocking **and** escalation |
+| `FileAllowlist` | every `ip-allowlist*.txt` | No — an operator said so | Blocking **and** escalation |
| `LoginAllowlist` | Earned by authenticating, 90-day TTL | Yes — 10 strikes/hour | Blocking **and** escalation |
Both are consulted **only after the blocklist has already matched**, so a normal accept — the one an
@@ -111,22 +111,26 @@ behind it is blocked with them — and where leases rotate, a listing says littl
address now. This is near-universal on mobile carriers, satellite (Starlink) and WISPs, and common on
fixed-line broadband outside North America.
-The generator therefore keeps a **carve-out table**, one entry per provider, each written to its own
-`ip-allowlist-.txt` and subtracted from the output. `starlink` ships **active**, costing about 0.1% of
-the list. Blank a file (keep the file) to reputation-block that network again; deleting it makes the next run
-write it back.
+A **carve-out** exempts a whole network. **None ship with ModernUO** — which providers to exempt depends on
+where your players actually are, and a carve-out names a real network, so you build the ones you need:
```powershell
-# Bring carve-out data up to date with what each network currently announces
-.\Export-IpBlocklist.ps1 -RefreshCarveouts
+# Exempt a CGNAT provider whose players keep getting listed
+.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
-# Generate with no carve-out at all, keeping your own allowlist
-.\Export-IpBlocklist.ps1 -Carveout ''
+# Later: bring every carve-out up to date with what those networks currently announce
+.\Export-IpBlocklist.ps1 -RefreshCarveouts
```
-Covering another CGNAT provider is a row in the `$Carveouts` table near the top of the script: a name, its
-ASN, a one-line reason, and either pasted prefixes or nothing at all if you intend to use
-`-RefreshCarveouts`.
+That writes `ip-allowlist-starlink.txt` beside the blocklist, and every `ip-allowlist*.txt` there is
+subtracted — both by the generator and by the shard, with no config edit. Starlink costs about 0.1% of the
+list. Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out.
+
+Carve-out files carry an `asn=` marker in their header, which is how `-RefreshCarveouts` finds them. A
+hand-written allowlist has no marker and is never rewritten.
+
+**Do you need one?** If players report being blocked and they are on satellite, mobile, or an ISP short on
+IPv4, probably yes. Find the ASN by looking up an address the network hands out on any public BGP lookup.
Prefixes come from **announcements, not ownership records**. 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
@@ -185,7 +189,7 @@ firewalled off. Shortening the 5s handshake window has been tried and broke real
| File | Controls |
|---|---|
| `bans.json` | `reportRateLimitTrips`, `autoBanDuration`, `reportBadConnects`, `badConnectDuration` |
-| `blocklist.json` | `file`, `allowlistFiles`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` |
+| `blocklist.json` | `file`, `allowlistFiles` (wildcards allowed), `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` |
| `login-allowlist.json` | `enabled`, `file`, `ttl`, `flushInterval`, `escalateAfterStrikes`, `strikeWindow` |
| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` |
| `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` |
diff --git a/tools/Export-IpBlocklist.ps1 b/tools/Export-IpBlocklist.ps1
index 6c50be88b..0e9fd781d 100644
--- a/tools/Export-IpBlocklist.ps1
+++ b/tools/Export-IpBlocklist.ps1
@@ -47,16 +47,20 @@
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.
+ Every `ip-allowlist*.txt` beside the output is subtracted, so 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 carve-outs live in `ip-allowlist-.txt`. 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.
+ NO CARVE-OUT IS SHIPPED. Which providers to exempt is a policy call that depends on where a shard's
+ players actually are, and a carve-out names a real network, so this script builds them on request rather
+ than publishing anyone's. A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on
+ IPv4 -- will usually want one:
+
+ .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
+
+ That costs roughly 0.1% of the list. Abusive hosts inside a carved-out network 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.
@@ -113,12 +117,11 @@
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-.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.
+ Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set:
+ ip-allowlist.txt operator exemptions -- created on first run, never rewritten
+ ip-allowlist-.txt a network carve-out -- generated data, safe to regenerate or copy
+ Discovered rather than configured, so a carve-out you add is picked up with no further edits. Blank a
+ file (keep the file) to disable its contents; delete it to drop it entirely.
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
@@ -127,17 +130,24 @@
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.
+.PARAMETER AddCarveout
+ Build a carve-out for a network and start subtracting it. Takes a short name for the file and -Asn for
+ the network, fetches that ASN's current routing announcements, collapses them, and writes
+ `ip-allowlist-.txt` beside the output. Implies -Force.
- 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.
+ .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
+
+ No carve-out ships with this script: naming a network to exempt is a policy call for the shard, so they
+ are built on request rather than published here. Re-running with the same name rebuilds the file.
+
+.PARAMETER Asn
+ Autonomous system number for -AddCarveout, e.g. 14593 for Starlink. Look one up by querying an address
+ the network hands out, or on any public BGP lookup.
.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.
+ Re-fetch every carve-out beside the output and rewrite it from current routing announcements. Implies
+ -Force. Carve-outs are recognised by the `asn=` marker in their header, so a hand-written allowlist is
+ left alone, and one 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.
@@ -175,13 +185,10 @@
.\Export-IpBlocklist.ps1 -AllowlistFile 'D:\shared\allow.txt' -DryRun
.EXAMPLE
- # Bring the carve-out data up to date with what the network currently announces.
+ # Exempt a CGNAT provider whose players keep getting listed, then keep it current.
+ .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
.\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
@@ -197,7 +204,8 @@ param(
[string] $OutFile,
[string] $MinInterval = '2h',
[string[]] $AllowlistFile,
- [string[]] $Carveout,
+ [string] $AddCarveout,
+ [int] $Asn,
[switch] $RefreshCarveouts,
[string[]] $Feeds,
[switch] $ExcludeAnonymizers,
@@ -206,6 +214,11 @@ param(
)
$ErrorActionPreference = 'Stop'
+
+# Fail before any download rather than after 60MB of feeds.
+if ($AddCarveout -and $Asn -le 0) {
+ throw "-AddCarveout needs -Asn, e.g. -AddCarveout starlink -Asn 14593"
+}
$UA = 'ModernUO-Blocklist-Export'
$totalSw = [System.Diagnostics.Stopwatch]::StartNew()
@@ -233,56 +246,13 @@ if (-not $OutFile) {
}
# ---------------------------------------------------------------------------------------------------------
-# Network carve-outs. Each entry becomes `ip-allowlist-.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.
+# Network carve-outs are DATA, not code: this script ships none. Which providers a shard exempts is a policy
+# call that depends on where its players actually are, so the carve-outs live in files an admin creates with
+# -AddCarveout, and every ip-allowlist*.txt beside the output is subtracted.
#
-# 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.
+# A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on IPv4 -- will usually want one:
+# .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
# ---------------------------------------------------------------------------------------------------------
-$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
@@ -290,27 +260,21 @@ $Carveouts = @(
# -- 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.
+# Carve-outs are discovered rather than listed, so a file an admin drops in is picked up with no config edit
+# and no code change. 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.
# ---------------------------------------------------------------------------------------------------------
-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
-}
+$AllowGlob = 'ip-allowlist*.txt'
+$ConfigDir = Split-Path -Parent $OutFile
$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) })
+ $AllowPaths = @(Get-ChildItem -Path $ConfigDir -Filter $AllowGlob -File -ErrorAction SilentlyContinue |
+ Sort-Object Name | ForEach-Object { $_.FullName })
}
function Write-AllowlistFile {
@@ -340,74 +304,72 @@ $OperatorTemplate = @'
# 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.
+# Network carve-outs live in their own ip-allowlist-.txt beside this one (see -AddCarveout), 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.
+# Carve-out files carry their own `asn=` marker, so -RefreshCarveouts can rebuild whatever an admin created
+# without this script keeping a list of anyone's networks.
function Get-CarveoutHeader {
- param([pscustomobject]$Row)
+ param([string]$Name, [int]$CarveoutAsn)
@(
- ("# {0} (AS{1}) carve-out -- subtracted from the generated blocklist." -f $Row.Label, $Row.Asn)
+ ("# {0} carve-out (asn={1}) -- subtracted from the generated blocklist." -f $Name, $CarveoutAsn)
"#"
- ("# {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."
+ "# Reputation feeds list shared consumer address space constantly, so a hit inside a CGNAT network"
+ "# says little about the player currently behind it. Abusive hosts here are still caught on BEHAVIOR."
"#"
- "# 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."
+ "# GENERATED DATA -- safe to regenerate, diff, or copy to another shard. Blank the file (keep the"
+ "# file) to reputation-block this network again; delete it to stop carving it out entirely."
"#"
- ("# Refresh with: .\Export-IpBlocklist.ps1 -RefreshCarveouts -Carveout {0}" -f $Row.Name)
+ "# Refresh with: .\Export-IpBlocklist.ps1 -RefreshCarveouts"
)
}
-# Fetches a carve-out's currently ANNOUNCED prefixes and collapses them. Routing data, not a registry:
+# Fetches a network'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)
+ param([int]$CarveoutAsn)
- $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
+ $url = "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS$CarveoutAsn"
+ $json = Get-Url -Url $url -Label ("AS{0} prefixes" -f $CarveoutAsn) | 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." }
+ if (-not $v4) { throw "AS$CarveoutAsn 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 }
+# Reads the `asn=` marker back out of a carve-out file. Anything without one is a hand-written allowlist and
+# is left alone by -RefreshCarveouts.
+function Get-CarveoutAsn {
+ param([string]$Path)
-$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
+ foreach ($line in (Get-Content -LiteralPath $Path -TotalCount 5 -ErrorAction SilentlyContinue)) {
+ if ($line -match 'asn=(\d+)') { return [int]$Matches[1] }
}
- $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 0
+}
- return $p
-})
+# The operator's own list is the one file this script will create unprompted; carve-outs are opt-in.
+if (-not $allowExplicit) {
+ $operatorPath = Join-Path $ConfigDir 'ip-allowlist.txt'
+ if (-not (Test-Path -LiteralPath $operatorPath -PathType Leaf)) {
+ Write-AllowlistFile -Path $operatorPath -Lines @($OperatorTemplate)
+ Write-Host ("Created allowlist at {0} (add player/staff exemptions here)." -f $operatorPath)
+ $AllowPaths = @($operatorPath) + $AllowPaths
+ }
+}
+else {
+ $AllowPaths = @($AllowPaths | ForEach-Object {
+ if (Test-Path -LiteralPath $_ -PathType Leaf) { return $_ }
+ Write-Warning ("Allowlist '{0}' does not exist -- nothing will be subtracted from it. Check the path." -f $_)
+ })
+}
# ---------------------------------------------------------------------------------------------------------
# Cooldown gate. Runs BEFORE anything is downloaded: the whole point is that a misconfigured scheduler or a
@@ -471,9 +433,9 @@ function Get-BlocklistAge {
}
$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) {
+# Asking for carve-out data implies -Force: waiting out the cooldown and leaving the old data in place would
+# be the wrong answer.
+if (-not $Force -and -not $RefreshCarveouts -and -not $AddCarveout -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
@@ -970,18 +932,29 @@ function Get-Url {
# 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 ($AddCarveout) {
+ $path = Join-Path $ConfigDir ("ip-allowlist-{0}.txt" -f $AddCarveout)
+ $prefixes = Get-CarveoutPrefixes -CarveoutAsn $Asn
+
+ Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $AddCarveout -CarveoutAsn $Asn) + $prefixes)
+ Write-Host ("Created {0} carve-out from AS{1}: {2} prefixes -> {3}" -f $AddCarveout, $Asn, @($prefixes).Count, $path)
+
+ if ($AllowPaths -notcontains $path) { $AllowPaths += $path }
+}
+
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 }
+ foreach ($path in $AllowPaths) {
+ $carveoutAsn = Get-CarveoutAsn -Path $path
+ if ($carveoutAsn -le 0) { continue } # hand-written allowlist, not ours to rewrite
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)
+ $name = [IO.Path]::GetFileNameWithoutExtension($path) -replace '^ip-allowlist-', ''
+ $prefixes = Get-CarveoutPrefixes -CarveoutAsn $carveoutAsn
+ Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $name -CarveoutAsn $carveoutAsn) + $prefixes)
+ Write-Host ("Refreshed {0} carve-out from AS{1}: {2} prefixes" -f $name, $carveoutAsn, @($prefixes).Count)
}
catch {
- Write-Warning ("{0}: refresh failed ({1}) -- keeping the existing carve-out" -f $c.Label, $_.Exception.Message)
+ Write-Warning ("AS{0}: refresh failed ({1}) -- keeping the existing carve-out" -f $carveoutAsn, $_.Exception.Message)
}
}
}