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>
182 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|