ModernUO/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs
Kamron Batman 464d4b7f66
feat(bans): blocklist generator script and a default blocklist location
Adds tools/Export-IpBlocklist.ps1, the producer half of the in-app blocklist
gate. It merges a thin, non-overlapping set of public IP threat feeds into one
de-duplicated, bogon-filtered list and writes the plain-text file the shard
reads via `blocklistFile`. Parsing is done in a compiled Add-Type hot loop
because the anchor feed alone is ~4M lines; the output is streamed so millions
of entries never become millions of strings.

The file is written to a .tmp sibling and swapped with File.Replace, so the
shard never observes a half-written list, and a total feed outage refuses to
overwrite a good list with an empty one. Re-running is idempotent: the script
exits without downloading anything while the list on disk is younger than
-MinInterval (default 2h, the anchor feed's own refresh period), so a
misconfigured scheduler cannot hammer the upstream feeds. Age comes from the
`generated=` header the script writes, falling back to mtime, so no sidecar
state file is needed. -Force overrides.

`blocklistFile` now defaults to Configuration/ip-blocklist.txt instead of being
empty. The gate stays inert while that file is absent, so this is a no-op for
shards that never run the generator, and dropping the file in later is picked
up by the existing poll with no restart.

Two fixes this exposed:

- FileBlocklist used the configured path verbatim despite documenting it as
  relative to Core.BaseDirectory, so a relative path resolved against the
  process working directory. Relative paths now resolve against BaseDirectory;
  absolute paths are honored so several shards can share one generated list.
- A missing file is now the shipped default rather than a misconfiguration, so
  boot logs "inert: no blocklist at ..." instead of "loaded 0 entries".

tools/ stays ignored except for this script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:40:46 -07:00

182 lines
6.4 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: FileBlocklist.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.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Server.Logging;
namespace Server.Network.Bans.Blocklist;
/// <summary>
/// In-app gate for a large, file-sourced IP blocklist. Holds an immutable snapshot swapped atomically
/// by an off-loop reload poll; accept-path reads are lock-free. Inert when no file is configured.
/// </summary>
public static class FileBlocklist
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileBlocklist));
private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
private static string _path;
private static TimeSpan _interval;
private static string _lastGenerated;
private static DateTime _lastWriteUtc;
private static CancellationTokenSource _cts;
public static int Count => _snapshot.Count;
public static void Configure()
{
BanConfiguration.Configure();
var s = BanConfiguration.Settings;
_path = ResolvePath(s.BlocklistFile);
_interval = s.BlocklistReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.BlocklistReloadInterval;
}
/// <summary>
/// Resolves the configured path once: a relative path is anchored to <see cref="Core.BaseDirectory"/>
/// (never the process working directory, which differs when the shard is launched from elsewhere), an
/// absolute path is taken as-is so several shards can share one generated list.
/// </summary>
private static string ResolvePath(string configured)
{
if (string.IsNullOrWhiteSpace(configured))
{
return null;
}
return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured);
}
public static void Start(CancellationToken token)
{
if (_path == null)
{
logger.Information("FileBlocklist disabled (blocklistFile empty in bans.json)");
return;
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
// A missing file is the shipped default, not an error: the gate stays inert and the poll picks the
// list up whenever the generator (tools/Export-IpBlocklist.ps1) first writes it. No restart needed.
if (File.Exists(_path))
{
Reload(); // synchronous prime; empty on failure (fail-open)
}
else
{
logger.Information("FileBlocklist inert: no blocklist at \"{Path}\"; polling every {Interval}", _path, _interval);
}
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
}
public static void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
public static bool IsBanned(IPAddress ip) => _snapshot.IsBanned(ip);
// Test hook: inject a snapshot without file I/O.
public static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot;
private static async ValueTask PollLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(_interval, token);
}
catch (OperationCanceledException)
{
return;
}
try
{
if (ChangedSinceLastLoad())
{
while (World.Saving || World.WorldState == WorldState.PendingSave)
{
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
Reload();
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
logger.Warning(e, "FileBlocklist reload check failed; keeping last snapshot ({Count})", Count);
}
}
}
private static bool ChangedSinceLastLoad()
{
try
{
var info = new FileInfo(_path);
if (!info.Exists)
{
return false;
}
if (info.LastWriteTimeUtc == _lastWriteUtc)
{
return false; // cheapest guard
}
}
catch
{
return false;
}
return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated;
}
private static void Reload()
{
// Capture the mtime/header BEFORE Load() so the markers describe the version we're about to
// parse, not whatever the producer may have atomically swapped in mid-parse. If a swap happens
// mid-parse, the markers describe the old-or-equal version, so the next poll detects the change
// and reloads again -- this errs toward reloading and never skips a version.
var writeUtc = default(DateTime);
try
{
writeUtc = new FileInfo(_path).LastWriteTimeUtc;
}
catch
{
/* keep default */
}
BlocklistFile.TryReadHeader(_path, out var h);
var next = BlocklistFile.Load(_path, out var parsed, out var skipped);
_snapshot = next; // single volatile swap; readers see old or new whole
_lastGenerated = h.Generated;
_lastWriteUtc = writeUtc;
logger.Information("FileBlocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}",
parsed, next.Count, skipped, h.Generated);
}
}