refactor(blocklist): build carve-outs on request, ship none

A carve-out names a real network, and which ones a shard should exempt depends
on where its players actually are. Publishing one in ModernUO makes that policy
call for everybody and puts a specific provider's address space in the repo, so
the script now builds them on request instead:

    .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593

That drops the embedded prefix blob and the table listing it. What is left is the
mechanism: fetch an ASN's current announcements, collapse them, write
ip-allowlist-<name>.txt.

Carve-outs are discovered rather than configured. Every ip-allowlist*.txt beside
the output is subtracted, by the generator and by the shard, so a file an admin
adds needs no config edit and no code change -- blocklist.json's allowlistFiles
now accepts wildcards and defaults to one pattern. FileAllowlist expands per poll
rather than at startup, so a carve-out added later is picked up without a
restart, and skips anything that is not really .txt so the generator's .tmp
sibling can never be read mid-swap.

Each carve-out file carries an asn= marker in its header, which is how
-RefreshCarveouts finds what to rebuild without this script keeping a list of
anyone's networks. A hand-written allowlist has no marker and is never rewritten.
-AddCarveout validates -Asn before any download rather than after 60MB of feeds.

Only ip-allowlist.txt is still created unprompted; deleting a carve-out now drops
it for good instead of having it written back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-07-30 22:44:50 -07:00
parent b6c0a0bde8
commit a5d49894cd
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
5 changed files with 204 additions and 173 deletions

View file

@ -73,12 +73,12 @@ public record BlocklistSettings
/// entry also suppresses ban contributions, which the generator alone cannot do. See
/// <see cref="FileAllowlist"/>.
/// </summary>
/// <remarks>
/// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds
/// without anyone editing this file.
/// </remarks>
[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"];
/// <summary>How often the file is checked for changes. Reloads only happen when it actually changed.</summary>
[JsonPropertyName("reloadInterval")]

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
private static string[] ExpandPaths()
{
var files = new List<string>();
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]);
}
}