ModernUO/Projects/UOContent/Network/Blocklist/FileAllowlist.cs
Kamron Batman 19b8ce2ce4
feat: make the blocklist and file allowlist opt-in, decouple their config
Both features ran on every shard out of the box, each polling on its own
60s timer for files most shards never generate. Neither was ever asked for.

Blocklist: the only disable path was an empty "file", and the default is
non-empty, so Start() always reached Task.Run(PollLoop) plus a recurring
SweepGuard timer. Adds "enabled" (default false). A shard that has a list
on disk but no "enabled" key logs a Warning rather than silently dropping
a gate it was relying on.

File allowlist: moves out of blocklist.json into its own ip-allowlist.json
with "enabled" (default false), "files" and "reloadInterval". It was never
a sub-feature of the blocklist -- its two consumers are BlocklistFilter,
where the generator already subtracts these files anyway, and
BanExemptions, which suppresses behavioural ban contributions and works on
a shard running no blocklist at all. That second consumer is the only
mechanism for what it does, so a shared flag could not express it.
"allowlistFiles" stays bound on BlocklistSettings, defaulting to null and
deliberately not honoured, purely so an operator who set it is told where
it went instead of losing the carve-out silently.

The two still work together: the blocklist warns at startup when it is on
and the file allowlist is not, since only the generator's subtraction is
covering carve-outs then, and that does not cover ban contributions.

Also fixes the promote-guard sweep, which was recurring and tokenless, so
Stop() cancelled the poll but left the sweep running and a later Start()
added another. It is now held and stopped, and only started when hits are
reported -- nothing can mark the guard otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:07:55 -07:00

358 lines
12 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: FileAllowlist.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Server.Logging;
namespace Server.Network.Bans;
/// <summary>
/// The operator's own "leave this address alone" list, read from the same files
/// <c>tools/Export-IpBlocklist.ps1</c> subtracts at generation time.
/// </summary>
/// <remarks>
/// The generator already subtracts these from the blocklist, but that only covers being BLOCKED.
/// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is
/// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address
/// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the
/// next regeneration. Opt-in via <c>blocklist.json</c>'s <c>allowlistEnabled</c>, since the poll runs for
/// the whole uptime; no shield against a manual ban either — see <see cref="BanExemptions"/>.
/// </remarks>
public static class FileAllowlist
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist));
// Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile
// 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[] _patterns = [];
private static TimeSpan _interval = TimeSpan.FromSeconds(60);
private static long _lastStamp;
private static CancellationTokenSource _cts;
public static int Count => _snapshot.Count;
/// <summary>True when an operator listed this address. Safe before <see cref="Initialize"/>.</summary>
public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address);
/// <summary>True when the shard is reading allowlist files. Safe before <see cref="Initialize"/>.</summary>
public static bool Enabled { get; private set; }
public static void Initialize()
{
FileAllowlistConfiguration.Load();
var settings = FileAllowlistConfiguration.Settings;
if (settings == null)
{
return;
}
// Ran after the Configure sweep, so blocklist.json is loaded and the deprecated key is readable.
WarnOnDeprecatedKey();
_patterns = ResolvePaths(settings.Files);
_interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval;
if (!settings.Enabled)
{
// The generator still subtracts these files, so a carve-out an operator already wrote looks
// like it works right up until a behavioural detection contributes the address anyway.
var present = ExpandPaths().Length;
_patterns = [];
if (present > 0)
{
logger.Warning(
"File allowlist is off (\"enabled\" false in ip-allowlist.json) but {Count} allowlist file(s) " +
"are present; those carve-outs will not suppress ban contributions",
present
);
}
else
{
logger.Information("File allowlist disabled (\"enabled\" false in ip-allowlist.json)");
}
return;
}
if (_patterns.Length == 0)
{
logger.Information("File allowlist disabled (\"files\" empty in ip-allowlist.json)");
return;
}
Enabled = true;
Reload();
_cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token);
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
}
public static void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
/// <summary>
/// The setting moved out of <c>blocklist.json</c>. Deliberately not honoured from there — that would
/// keep the coupling alive — but an operator who set it is told rather than losing it silently.
/// </summary>
private static void WarnOnDeprecatedKey()
{
if (BlocklistConfiguration.Settings?.AllowlistFiles is { Length: > 0 })
{
logger.Warning(
"\"allowlistFiles\" in blocklist.json is ignored; it moved to \"files\" in ip-allowlist.json. " +
"Copy it there and delete it from blocklist.json"
);
}
}
private static string[] ResolvePaths(string[] configured)
{
if (configured == null)
{
return [];
}
var resolved = new string[configured.Length];
var count = 0;
for (var i = 0; i < configured.Length; i++)
{
var path = configured[i];
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
// Relative resolves against BaseDirectory, never the working directory, which differs when the
// shard is launched from elsewhere. Absolute is as-is, so shards can share a list.
resolved[count++] = Path.IsPathRooted(path) ? path : Path.Join(Core.BaseDirectory, path);
}
Array.Resize(ref resolved, count);
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)
{
try
{
await Task.Delay(_interval, token);
}
catch (OperationCanceledException)
{
return;
}
try
{
if (Stamp() != _lastStamp)
{
// A save owns the disk and nothing here is urgent.
// See the threading policy in CLAUDE.md (rules #3 and #10).
while (World.Saving || World.WorldState == WorldState.PendingSave)
{
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
Reload();
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
logger.Warning(e, "File allowlist reload check failed; keeping last snapshot ({Count})", Count);
}
}
}
/// <summary>
/// Change fingerprint across every configured file. A missing file contributes nothing, so creating or
/// deleting one also registers as a change.
/// </summary>
private static long Stamp()
{
var stamp = 0L;
var paths = ExpandPaths();
for (var i = 0; i < paths.Length; i++)
{
try
{
var info = new FileInfo(paths[i]);
if (info.Exists)
{
stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length;
}
}
catch
{
// Mid-swap by the generator; the next poll picks it up.
}
}
return stamp;
}
private static void Reload()
{
// Fingerprint BEFORE parsing, so it describes the version being read. Capturing after could skip a
// version; a stale fingerprint only costs an extra reload.
var stamp = Stamp();
var combined = ReadAll(out var files);
// Reuses the blocklist parser and interval index: an address set is direction-agnostic.
var next = combined.Length == 0
? BlocklistSnapshot.Empty
: BlocklistSnapshot.Build(combined, out _, out _);
_snapshot = next; // single volatile swap; readers see old or new whole
_lastStamp = stamp;
logger.Information(
"File allowlist loaded {Count} range(s) from {Files} file(s)",
next.Count,
files
);
}
/// <summary>
/// Concatenates every configured file into one buffer. The parser is line-based, so a newline join is
/// enough, and membership stays a single lookup.
/// </summary>
private static byte[] ReadAll(out int files)
{
files = 0;
var paths = ExpandPaths();
var chunks = new byte[paths.Length][];
var total = 0;
for (var i = 0; i < paths.Length; i++)
{
try
{
if (!File.Exists(paths[i]))
{
continue;
}
var bytes = File.ReadAllBytes(paths[i]);
chunks[i] = bytes;
total += bytes.Length + 1; // + newline separator
files++;
}
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]);
}
}
if (total == 0)
{
return [];
}
var combined = new byte[total];
var offset = 0;
for (var i = 0; i < chunks.Length; i++)
{
var chunk = chunks[i];
if (chunk == null)
{
continue;
}
Buffer.BlockCopy(chunk, 0, combined, offset, chunk.Length);
offset += chunk.Length;
combined[offset++] = (byte)'\n';
}
return combined;
}
internal static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot ?? BlocklistSnapshot.Empty;
}