/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: AutoDenylist.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 . *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using Server.Logging;
using Server.Network.Bans;
namespace Server.Network;
///
/// A short-lived, in-memory denylist of addresses the shard itself just caught misbehaving.
///
///
/// The local half of promotion. Contributing to CrowdSec only helps once an OS bouncer reacts; until then
/// every reconnect costs a socket, a buffer and a NetState slot — and the verdicts that matter most
/// are reachable only after reading bytes, like a zero seed. It is also the whole defense on a shard running
/// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban
/// without a ban's review. Only verdicts are held.
///
///
/// A hold is never refreshed, so every expiry is insertion + duration and the ring is sorted by
/// construction. Retiring lapsed entries is therefore the number expiring rather than the number held, which
/// is what lets the cap be sized for the flood instead of for a scan.
///
public static class AutoDenylist
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist));
// Membership only (normalized v6 bits). Loop-only. The expiry lives beside the key in the ring, so
// there is exactly one copy of it and the two cannot disagree.
private static readonly HashSet _held = [];
// The same keys in expiry order. Parallel arrays rather than an array of structs: UInt128 forces
// 16-byte alignment, so a packed (key, expiry) struct costs 32 bytes where these cost 24 -- and the
// drain reads only the long[], 8 sequential bytes per entry.
private static UInt128[] _ringKeys = [];
private static long[] _ringExpiry = [];
private static int _ringHead;
private static int _ringCount;
private static bool _enabled;
private static long _durationMs;
private static int _maxEntries;
private static bool _warnedFull;
public static int Count => _held.Count;
// Test seam: the ring and the set hold the same entries, and nothing else may assume it.
internal static int RingCount => _ringCount;
public static void Configure()
{
AutoDenylistConfiguration.Load();
var s = AutoDenylistConfiguration.Settings;
_enabled = s.Enabled && s.Duration > TimeSpan.Zero && s.MaxEntries > 0;
if (!_enabled)
{
return;
}
_durationMs = (long)s.Duration.TotalMilliseconds;
_maxEntries = s.MaxEntries;
ConnectionFilters.Register(new AutoDenylistFilter());
BanChannel.Register(new AutoDenylistReporter());
}
///
/// Holds an address for the configured duration. Ignores non-behavioural verdicts and refuses to grow
/// past the cap: the flood this exists for must not become a memory leak.
///
public static void Hold(IPAddress address, string reason) => Hold(address, reason, Core.TickCount);
internal static bool Hold(IPAddress address, string reason, long nowTicks)
{
if (!_enabled || address == null || !BanReasons.IsBehavioral(reason))
{
return false;
}
Drain(nowTicks);
var key = address.ToUInt128();
// Deliberately not refreshed: the first detection sets the expiry and later ones leave it alone.
// That keeps insertion order equal to expiry order, which is why the drain can stop at the first
// live record. A flooder whose hold lapses trips the rate limiter on its next attempt -- which
// runs ahead of the connection filters -- and is held again.
if (!_held.Add(key))
{
return true;
}
// Drain already reclaimed everything reclaimable, so being over now means genuinely full.
if (_held.Count > _maxEntries)
{
_held.Remove(key);
if (!_warnedFull)
{
_warnedFull = true;
logger.Warning(
"Auto-denylist is full at {Max} addresses; further detections are disconnected but not held",
_maxEntries
);
}
return false;
}
Push(key, nowTicks + _durationMs);
return true;
}
public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount);
///
/// The accept-path decision, split out so the policy can be tested without a clock. Drains first: the
/// expiry lives in the ring, not beside the membership, so a lapsed hold has to be retired here rather
/// than expired on read. One array read when nothing has lapsed.
///
internal static bool IsDenied(IPAddress address, long nowTicks)
{
if (!_enabled || address == null)
{
return false;
}
Drain(nowTicks);
return _held.Contains(address.ToUInt128());
}
/// Releases an address early, e.g. when an operator retracts a ban.
public static void Release(IPAddress address)
{
if (!_enabled || address == null)
{
return;
}
var key = address.ToUInt128();
if (_held.Remove(key))
{
// The ring record has to go too. Nothing records that this key was released, so if it were
// detected again before the old record lapsed, that record would retire the new hold early.
// O(n), but this is an operator retraction, not the accept path.
PurgeRing(key);
}
}
///
/// Retires everything that has lapsed. Expiries only ever increase along the ring, so the first live
/// record ends the scan and the cost is the number actually expiring, not the number held.
///
internal static void Drain(long nowTicks)
{
var before = _ringCount;
// Subtraction, never a direct compare: tick counts wrap. See dev-docs/tick-counts.md.
while (_ringCount > 0 && _ringExpiry[_ringHead] - nowTicks <= 0)
{
_held.Remove(_ringKeys[_ringHead]);
_ringHead = _ringHead + 1 == _ringKeys.Length ? 0 : _ringHead + 1;
_ringCount--;
}
if (_ringCount != before)
{
_warnedFull = false;
}
}
private static void Push(UInt128 key, long expiry)
{
if (_ringCount == _ringKeys.Length)
{
Grow();
}
var tail = _ringHead + _ringCount;
if (tail >= _ringKeys.Length)
{
tail -= _ringKeys.Length;
}
_ringKeys[tail] = key;
_ringExpiry[tail] = expiry;
_ringCount++;
}
private static void Grow()
{
// Capped at the entry cap: Push only runs below it, so the ring never needs more, and doubling
// past it would reserve roughly twice the slots it can ever use.
var size = Math.Min(Math.Max(64, _ringKeys.Length * 2), _maxEntries);
var keys = new UInt128[size];
var expiry = new long[size];
for (var i = 0; i < _ringCount; i++)
{
var from = _ringHead + i;
if (from >= _ringKeys.Length)
{
from -= _ringKeys.Length;
}
keys[i] = _ringKeys[from];
expiry[i] = _ringExpiry[from];
}
_ringKeys = keys;
_ringExpiry = expiry;
_ringHead = 0;
}
private static void PurgeRing(UInt128 key)
{
var capacity = _ringKeys.Length;
for (var i = 0; i < _ringCount; i++)
{
var at = _ringHead + i;
if (at >= capacity)
{
at -= capacity;
}
if (_ringKeys[at] != key)
{
continue;
}
// Close the gap so the ring stays contiguous and expiry-ordered.
for (var j = i; j < _ringCount - 1; j++)
{
var to = _ringHead + j;
if (to >= capacity)
{
to -= capacity;
}
var from = to + 1 == capacity ? 0 : to + 1;
_ringKeys[to] = _ringKeys[from];
_ringExpiry[to] = _ringExpiry[from];
}
_ringCount--;
return;
}
}
internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries)
{
_held.Clear();
_ringKeys = [];
_ringExpiry = [];
_ringHead = 0;
_ringCount = 0;
_enabled = enabled;
_durationMs = durationMs;
_maxEntries = maxEntries;
_warnedFull = false;
}
}
/// Accept-path gate for .
public sealed class AutoDenylistFilter : IConnectionFilter
{
private Timer _sweepTimer;
public string Name => "auto-denylist";
public void Register()
{
}
public void Start(CancellationToken token)
{
// Only reclaims memory: Hold and IsDenied both drain, so this matters on a shard that has gone
// quiet after a flood and would otherwise hold the ring until someone next connects.
_sweepTimer = Timer.DelayCall(
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(1),
() => AutoDenylist.Drain(Core.TickCount)
);
}
public void Stop()
{
// Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one.
_sweepTimer?.Stop();
_sweepTimer = null;
}
public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address);
}
///
/// Feeds from the ban channel. A reporter rather than a direct call, because the
/// detection sites live in the engine and must not reach into content.
///
public sealed class AutoDenylistReporter : IBanReporter
{
public string Name => "auto-denylist";
public bool CanRetract => true;
public void Register()
{
}
public void Start(CancellationToken token)
{
}
public void Stop()
{
}
///
/// The contributed is ignored: how long a bouncer should ban an address is a
/// different question from how long this shard holds it at accept.
///
public void Report(IPAddress address, TimeSpan ttl, string reason) => AutoDenylist.Hold(address, reason);
public void Retract(IPAddress address) => AutoDenylist.Release(address);
}