Introduces IConnectionFilter and the ConnectionFilters registry: one seam the accept path consults per inbound socket, so core no longer has to know where a gate's data comes from. The registry is a plain array walked by an indexed loop, so the hot path has no enumerator, no closure and no allocation; an interface dispatch is noise next to the accept syscall. Filters register during the Configure sweep, cheapest first, and the first denial short-circuits. A filter that throws on the accept path is unregistered and the connection fails open. A filter that faults once faults for every subsequent connection, so leaving it registered would mean an exception and a log line per accept -- exactly the amplification an attacker wants -- and a broken filter must not be able to deny every connection either. With that seam in place the whole file blocklist moves to UOContent: it is policy (which feeds, when to promote, what to report) built on an external file format with an external producer, and core does not need any of it. Firewall stays in core -- it is long-standing public API, it is what an admin reaches for manually, and a shard running without UOContent still has to be able to block an address -- but it now reaches the accept path through the same registry via a small adapter, so the two remain separate implementations rather than one conflated store. Blocklist policy moves out of bans.json into a UOContent Configuration/ blocklist.json, next to crowdsec.json. bans.json keeps only what core decides: reportRateLimitTrips and autoBanDuration. Cleanups found while moving the code: - BanChannel.Stop() persisted the Firewall. A contribution coordinator has no business saving an enforcement store; that is now the firewall filter's Stop. - BlocklistGate.Evaluate took a `whitelisted` flag that was hardcoded false at its only call site, and no whitelist concept exists anywhere in core. Dropped. - FileBlocklist was a static holding a snapshot and a promote-guard, which forced its tests onto the sequential collection and a LoadForTesting reset hook. It is now an instance, so each test owns its own state and they run in parallel. BlocklistGate folds into it: the pure decision survives as an internal Evaluate, which is all the separate type ever provided. - The promote-guard sweep timer was registered from NetState.Configure, two files from the guard it swept, and ran even with the blocklist disabled. It now starts with the filter that owns it. Core sheds ~570 source and ~340 test lines for ~90 of seam. 1344 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5 KiB
C#
146 lines
5 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: BanChannel.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.Net;
|
|
using System.Threading;
|
|
using Server.Logging;
|
|
|
|
namespace Server.Network.Bans;
|
|
|
|
/// <summary>
|
|
/// Coordinates the configured <see cref="IBanReporter"/> contribution sinks. Enforcement is NOT here —
|
|
/// the accept path asks <see cref="ConnectionFilters"/>. This channel only fans locally-decided bans out
|
|
/// to external systems (CrowdSec), which distribute them to OS-level bouncers.
|
|
/// </summary>
|
|
public static class BanChannel
|
|
{
|
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel));
|
|
|
|
private static IBanReporter[] _reporters = [];
|
|
|
|
public static IReadOnlyList<IBanReporter> Reporters => _reporters;
|
|
|
|
public static void Configure()
|
|
{
|
|
// Load ban policy for the accept path. Reporters are NOT built here — content registers them
|
|
// via Register() during the same Configure() sweep, in any order relative to this call, so we
|
|
// must not clobber any registrations that may already have arrived.
|
|
BanConfiguration.Configure();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a contribution sink from content (inversion of control). Idempotent by
|
|
/// <see cref="IBanReporter.Name"/>: a second registration of the same name is ignored. Configures the
|
|
/// reporter immediately so it is ready before <see cref="Start"/>.
|
|
/// </summary>
|
|
public static void Register(IBanReporter reporter)
|
|
{
|
|
if (reporter == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var existing in _reporters)
|
|
{
|
|
if (existing.Name == reporter.Name)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
reporter.Configure();
|
|
|
|
var updated = new IBanReporter[_reporters.Length + 1];
|
|
Array.Copy(_reporters, updated, _reporters.Length);
|
|
updated[^1] = reporter;
|
|
_reporters = updated;
|
|
|
|
logger.Information("Ban channel registered reporter '{Name}'", reporter.Name);
|
|
}
|
|
|
|
internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? [];
|
|
|
|
public static void Start(CancellationToken token)
|
|
{
|
|
foreach (var reporter in _reporters)
|
|
{
|
|
try
|
|
{
|
|
reporter.Start(token);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// A broken contribution path must not crash boot — enforcement is local and unaffected.
|
|
logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void Stop()
|
|
{
|
|
foreach (var reporter in _reporters)
|
|
{
|
|
try
|
|
{
|
|
reporter.Stop();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Fans a locally-decided ban out to every reporter. Non-blocking; never throws.</summary>
|
|
public static void Report(IPAddress ip, TimeSpan ttl, string reason)
|
|
{
|
|
var reporters = _reporters;
|
|
for (var i = 0; i < reporters.Length; i++)
|
|
{
|
|
try
|
|
{
|
|
reporters[i].Report(ip, ttl, reason);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Fans a retraction (manual unban) out to every retract-capable reporter.</summary>
|
|
public static void Retract(IPAddress ip)
|
|
{
|
|
var reporters = _reporters;
|
|
for (var i = 0; i < reporters.Length; i++)
|
|
{
|
|
if (!reporters[i].CanRetract)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
reporters[i].Retract(ip);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name);
|
|
}
|
|
}
|
|
}
|
|
}
|