From 6249a20f155a0d9d0d7c33252affc2430887c525 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:37:42 -0700 Subject: [PATCH] feat(bans): contribute-first ban channel with CrowdSec reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pluggable ban seam: BanChannel fans locally-decided bans out to IBanReporter sinks (Report/Retract) and the accept path enforces locally. CrowdSec is a write-only reporter living in UOContent (registered into the Core seam via BanChannel.Register) — it batches decisions onto a bounded, coalescing, drop-on-overflow queue and POSTs /v1/alerts with watcher creds, retry/backoff, and a bounded flush-on-stop. CrowdSec never enforces in-app. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/Network/Bans/BanChannelTests.cs | 73 ++++ .../Network/Bans/BanConfigurationTests.cs | 44 +++ Projects/Server/Network/Bans/BanChannel.cs | 149 +++++++ .../Server/Network/Bans/BanConfiguration.cs | 102 +++++ Projects/Server/Network/Bans/IBanReporter.cs | 55 +++ .../Network/Bans/CrowdSecAlertClientTests.cs | 51 +++ .../Bans/CrowdSecConfigurationTests.cs | 82 ++++ .../Network/Bans/CrowdSecReporterTests.cs | 204 ++++++++++ .../UOContent/Misc/CrowdSec/CrowdSecAlert.cs | 65 +++ .../Misc/CrowdSec/CrowdSecAlertClient.cs | 135 +++++++ .../UOContent/Misc/CrowdSec/CrowdSecBans.cs | 32 ++ .../Misc/CrowdSec/CrowdSecConfiguration.cs | 108 +++++ .../Misc/CrowdSec/CrowdSecReporter.cs | 371 ++++++++++++++++++ 13 files changed, 1471 insertions(+) create mode 100644 Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs create mode 100644 Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs create mode 100644 Projects/Server/Network/Bans/BanChannel.cs create mode 100644 Projects/Server/Network/Bans/BanConfiguration.cs create mode 100644 Projects/Server/Network/Bans/IBanReporter.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecBans.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs new file mode 100644 index 000000000..fa1b4f5dc --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class BanChannelTests +{ + private sealed class FakeReporter : IBanReporter + { + public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; + public readonly List Retractions = []; + public bool ThrowOnReport; + + public string Name => "fake"; + public bool CanRetract => true; + public void Configure() { } + public void Start(CancellationToken token) { } + public void Stop() { } + + public void Report(IPAddress address, TimeSpan ttl, string reason) + { + if (ThrowOnReport) + { + throw new InvalidOperationException("boom"); + } + + Reports.Add((address, ttl, reason)); + } + + public void Retract(IPAddress address) => Retractions.Add(address); + } + + [Fact] + public void Report_FansOutToAllReporters() + { + var a = new FakeReporter(); + var b = new FakeReporter(); + BanChannel.ConfigureForTesting([a, b]); + + BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "rate-limit"); + + Assert.Single(a.Reports); + Assert.Single(b.Reports); + Assert.Equal("rate-limit", a.Reports[0].reason); + } + + [Fact] + public void Report_SwallowsReporterException() + { + var bad = new FakeReporter { ThrowOnReport = true }; + var good = new FakeReporter(); + BanChannel.ConfigureForTesting([bad, good]); + + BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "manual"); + + Assert.Single(good.Reports); // the throwing reporter does not block the others + } + + [Fact] + public void Retract_ReachesRetractCapableReporters() + { + var a = new FakeReporter(); + BanChannel.ConfigureForTesting([a]); + + BanChannel.Retract(IPAddress.Parse("9.9.9.9")); + + Assert.Single(a.Retractions); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs new file mode 100644 index 000000000..ee232b1df --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests; + +public class BanConfigurationTests +{ + // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so + // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently + // binds nothing. These tests round-trip through the exact options the loader uses. + + [Fact] + public void BanSettings_RoundTripsThroughJsonConfig() + { + var original = new BanSettings + { + ReportRateLimitTrips = false, + AutoBanDuration = TimeSpan.FromHours(2) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"reportRateLimitTrips\"", json); + Assert.Contains("\"autoBanDuration\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips); + Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives + } + + [Fact] + public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan() + { + var settings = new BanSettings(); + + Assert.True(settings.ReportRateLimitTrips); + Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration); + } +} diff --git a/Projects/Server/Network/Bans/BanChannel.cs b/Projects/Server/Network/Bans/BanChannel.cs new file mode 100644 index 000000000..d40e52e0d --- /dev/null +++ b/Projects/Server/Network/Bans/BanChannel.cs @@ -0,0 +1,149 @@ +/************************************************************************* + * 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 . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Logging; + +namespace Server.Network.Bans; + +/// +/// Coordinates the configured contribution sinks. Enforcement is NOT here — +/// the accept path calls directly. This channel only fans locally-decided +/// bans out to external systems (CrowdSec), which distribute them to OS-level bouncers. +/// +public static class BanChannel +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel)); + + private static IBanReporter[] _reporters = []; + + public static IReadOnlyList 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(); + } + + /// + /// Registers a contribution sink from content (inversion of control). Idempotent by + /// : a second registration of the same name is ignored. Configures the + /// reporter immediately so it is ready before . + /// + 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); + } + } + + // Flush the local firewall's pending writes on the way down. + Firewall.Save(); + } + + /// Fans a locally-decided ban out to every reporter. Non-blocking; never throws. + 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); + } + } + } + + /// Fans a retraction (manual unban) out to every retract-capable reporter. + 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); + } + } + } +} diff --git a/Projects/Server/Network/Bans/BanConfiguration.cs b/Projects/Server/Network/Bans/BanConfiguration.cs new file mode 100644 index 000000000..f14f35b0c --- /dev/null +++ b/Projects/Server/Network/Bans/BanConfiguration.cs @@ -0,0 +1,102 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanConfiguration.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.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/bans.json (matching the per-feature +/// JSON config pattern used by AssistantConfiguration). Loaded once; a missing file writes a +/// local-only, fail-open template so operators have something to edit. +/// +public static class BanConfiguration +{ + private const string _path = "Configuration/bans.json"; + + public static BanSettings Settings { get; private set; } + + public static void Configure() + { + // Idempotent: this Configure() is auto-discovered and also called explicitly by BanChannel, + // so guard against a redundant second load. + if (Settings != null) + { + return; + } + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new BanSettings + { + ReportRateLimitTrips = true, + AutoBanDuration = TimeSpan.FromHours(4) + }; + + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// Ban-channel policy: which reporters receive contributions, and how auto-detections are handled. +public record BanSettings +{ + /// Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set. + [JsonPropertyName("reportRateLimitTrips")] + public bool ReportRateLimitTrips { get; set; } = true; + + /// Duration reported for an auto-detected (rate-limit) ban. + [JsonPropertyName("autoBanDuration")] + public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4); + + /// Path to the local blocklist file, relative to . + [JsonPropertyName("blocklistFile")] + public string BlocklistFile { get; set; } = ""; + + /// How often the blocklist file is re-read for changes. + [JsonPropertyName("blocklistReloadInterval")] + public TimeSpan BlocklistReloadInterval { get; set; } = TimeSpan.FromSeconds(60); + + /// Whether blocklist hits are contributed to reporters. + [JsonPropertyName("reportBlocklistHits")] + public bool ReportBlocklistHits { get; set; } = true; + + /// Duration reported for a blocklist-matched ban. + [JsonPropertyName("blocklistBanDuration")] + public TimeSpan BlocklistBanDuration { get; set; } = TimeSpan.FromHours(6); + + /// + /// How long the accept-path guard suppresses re-reporting a promoted IP. This only needs to bridge + /// the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops repeat + /// traffic. Decoupled from so the guard doesn't remember hours of + /// distinct IPs. + /// + [JsonPropertyName("blocklistPromoteSuppression")] + public TimeSpan BlocklistPromoteSuppression { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/Projects/Server/Network/Bans/IBanReporter.cs b/Projects/Server/Network/Bans/IBanReporter.cs new file mode 100644 index 000000000..c0f82c62b --- /dev/null +++ b/Projects/Server/Network/Bans/IBanReporter.cs @@ -0,0 +1,55 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IBanReporter.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.Net; +using System.Threading; + +namespace Server.Network.Bans; + +/// +/// A contribution sink behind . Reporters receive locally-decided bans +/// (manual admin bans, rate-limit trips) and forward them to an external system (e.g. CrowdSec), +/// which distributes them to OS-level bouncers. Reporters never answer the accept-path membership +/// query — enforcement is the local 's job. +/// +public interface IBanReporter +{ + /// Stable id for logging/config (e.g. crowdsec). + string Name { get; } + + /// Reads configuration. No network or file I/O here. + void Configure(); + + /// Starts background delivery. The token is cancelled on shutdown. + void Start(CancellationToken token); + + /// Flushes and tears down background delivery. + void Stop(); + + /// + /// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only + /// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O. + /// + /// or negative = use the reporter's default duration. + /// Short slug (manual, rate-limit) used as the scenario suffix. + void Report(IPAddress address, TimeSpan ttl, string reason); + + /// True if this reporter can retract a previously-reported ban. + bool CanRetract { get; } + + /// Enqueues a retraction (e.g. a manual unban). No-op if unsupported. + void Retract(IPAddress address); +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs new file mode 100644 index 000000000..f8a990f25 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs @@ -0,0 +1,51 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlertClientTests.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.Net; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecAlertClientTests +{ + [Fact] + public void BuildDeleteQuery_EscapesOrigin() + { + // origin is operator-controlled config (crowdsec.json), so it must be treated as untrusted + // input going into the URL, same as any other interpolated value. + var query = CrowdSecAlertClient.BuildDeleteQuery("modern uo/test&x=1", IPAddress.Parse("1.2.3.4")); + + Assert.Equal("/v1/decisions?origin=modern%20uo%2Ftest%26x%3D1&ip=1.2.3.4", query); + } + + [Fact] + public void BuildDeleteQuery_PlainOrigin_Ipv4() + { + var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("192.168.1.1")); + + Assert.Equal("/v1/decisions?origin=modernuo&ip=192.168.1.1", query); + } + + [Fact] + public void BuildDeleteQuery_Ipv6_IsEscaped() + { + // IPv6 textual form contains ':', which Uri.EscapeDataString percent-encodes like any other + // reserved character — confirms the escaping is applied uniformly, not just for IPv4. + var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("2001:db8::1")); + + Assert.Equal("/v1/decisions?origin=modernuo&ip=2001%3Adb8%3A%3A1", query); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs new file mode 100644 index 000000000..dd331ac6d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs @@ -0,0 +1,82 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecConfigurationTests.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.Text.Json; +using Server.Json; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecConfigurationTests +{ + // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so + // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently + // binds nothing. These tests round-trip through the exact options the loader uses. + + [Fact] + public void CrowdSecSettings_RoundTripsThroughJsonConfig() + { + var original = new CrowdSecSettings + { + LapiUrl = "http://10.0.0.5:9090", + MachineId = "shard", + Password = "secret", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168), + FlushInterval = TimeSpan.FromSeconds(1), + MaxQueue = 10000 + }; + + var json = JsonConfig.Serialize(original); + + // camelCase property names must be present (not PascalCase) or the case-sensitive reader binds nothing. + Assert.Contains("\"lapiUrl\"", json); + Assert.Contains("\"machineId\"", json); + Assert.Contains("\"password\"", json); + Assert.Contains("\"origin\"", json); + Assert.Contains("\"manualBanDuration\"", json); + Assert.Contains("\"flushInterval\"", json); + Assert.Contains("\"maxQueue\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.LapiUrl, restored.LapiUrl); + Assert.Equal(original.MachineId, restored.MachineId); + Assert.Equal(original.Password, restored.Password); + Assert.Equal(original.Origin, restored.Origin); + Assert.Equal(original.ManualBanDuration, restored.ManualBanDuration); // TimeSpan survives + Assert.Equal(original.FlushInterval, restored.FlushInterval); // TimeSpan survives + Assert.Equal(original.MaxQueue, restored.MaxQueue); + Assert.True(restored.ReportingEnabled); + } + + [Fact] + public void CrowdSecSettings_Defaults_AreReportingDisabledLocalLoopback() + { + var settings = new CrowdSecSettings(); + + Assert.Equal("http://127.0.0.1:8080", settings.LapiUrl); + Assert.Equal("", settings.MachineId); + Assert.Equal("", settings.Password); + Assert.Equal("modernuo", settings.Origin); + Assert.Equal(TimeSpan.FromHours(168), settings.ManualBanDuration); + Assert.Equal(TimeSpan.FromSeconds(1), settings.FlushInterval); + Assert.Equal(10000, settings.MaxQueue); + Assert.False(settings.ReportingEnabled); // empty machineId/password => inert + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs new file mode 100644 index 000000000..937be3fb5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -0,0 +1,204 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecReporterTests.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 System.Threading.Tasks; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecReporterTests +{ + private static CrowdSecSettings Settings() => new() + { + MachineId = "shard", + Password = "secret", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168) + }; + + [Fact] + public void BuildAlerts_DedupsByIp() + { + var now = DateTime.UnixEpoch; + var items = new List + { + new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), + new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), + new(IPAddress.Parse("2.2.2.2"), TimeSpan.FromHours(1), "rate-limit", false) + }; + + var alerts = CrowdSecReporter.BuildAlerts(items, Settings(), now); + + Assert.Equal(2, alerts.Count); + Assert.All(alerts, a => Assert.Single(a.Decisions)); + Assert.Contains(alerts, a => a.Source.Value == "1.1.1.1"); + Assert.Contains(alerts, a => a.Source.Value == "2.2.2.2"); + } + + [Fact] + public void BuildAlerts_ScenarioFromReason_OriginFromSettings() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("3.3.3.3"), TimeSpan.FromHours(1), "manual", false)], + Settings(), + DateTime.UnixEpoch); + + var decision = Assert.Single(alerts).Decisions[0]; + Assert.Equal("modernuo/manual", alerts[0].Scenario); + Assert.Equal("modernuo", decision.Origin); + Assert.Equal("ban", decision.Type); + Assert.Equal("Ip", decision.Scope); + Assert.Equal("3.3.3.3", decision.Value); + } + + [Fact] + public void BuildAlerts_ScenarioFromReason_Blocklist() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("5.5.5.5"), TimeSpan.FromHours(1), "blocklist", false)], + Settings(), + DateTime.UnixEpoch); + + var decision = Assert.Single(alerts).Decisions[0]; + Assert.Equal("modernuo/blocklist", alerts[0].Scenario); + Assert.Equal("modernuo/blocklist", decision.Scenario); + } + + [Fact] + public void FormatDuration_UsesSeconds_FloorsAtOne() + { + Assert.Equal("3600s", CrowdSecReporter.FormatDuration(TimeSpan.FromHours(1))); + Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.Zero)); + Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.FromMilliseconds(10))); + } + + [Fact] + public void Report_WhenQueueFull_DropsAndCounts() + { + var reporter = new CrowdSecReporter(new NullAlertClient(), new CrowdSecSettings + { + MachineId = "shard", + Password = "secret", + MaxQueue = 2 + }); + // Do NOT Start() the drain — so the queue fills and overflows deterministically. + + for (var i = 0; i < 10; i++) + { + reporter.Report(IPAddress.Parse("4.4.4." + i), TimeSpan.FromHours(1), "rate-limit"); + } + + Assert.True(reporter.DroppedCount >= 8); + } + + // Stop() without a prior Start() drives FlushRemainingOnStop() synchronously (no drain task, no + // Task.Delay backoff involved), so this is deterministic — no wall-clock timing dependency. + [Fact] + public void Stop_FlushesQueuedReports_ViaClient() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Report(IPAddress.Parse("6.6.6.6"), TimeSpan.FromHours(1), "rate-limit"); + reporter.Stop(); + + var posted = Assert.Single(client.Posted); + Assert.Equal("6.6.6.6", Assert.Single(posted).Source.Value); + Assert.Equal(0, reporter.SendFailureCount); + } + + [Fact] + public void Stop_FlushesQueuedRetracts_ViaClient() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Retract(IPAddress.Parse("8.8.8.8")); + reporter.Stop(); + + Assert.Equal(IPAddress.Parse("8.8.8.8"), Assert.Single(client.Deleted)); + Assert.Equal(0, reporter.SendFailureCount); + } + + [Fact] + public void Stop_WhenFlushSendFails_CountsSendFailure() + { + var reporter = new CrowdSecReporter(new ThrowingAlertClient(), Settings()); + + reporter.Report(IPAddress.Parse("7.7.7.7"), TimeSpan.FromHours(1), "rate-limit"); + reporter.Stop(); + + Assert.Equal(1, reporter.SendFailureCount); + } + + [Fact] + public void Stop_WithEmptyQueue_DoesNotInvokeClientOrFail() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Stop(); + + Assert.Empty(client.Posted); + Assert.Equal(0, reporter.SendFailureCount); + } + + private sealed class NullAlertClient : ICrowdSecAlertClient + { + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => + ValueTask.CompletedTask; + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } + + private sealed class RecordingAlertClient : ICrowdSecAlertClient + { + public List> Posted { get; } = []; + public List Deleted { get; } = []; + + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + Posted.Add(alerts); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) + { + Deleted.Add(ip); + return ValueTask.CompletedTask; + } + + public void Dispose() { } + } + + private sealed class ThrowingAlertClient : ICrowdSecAlertClient + { + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => + throw new InvalidOperationException("simulated LAPI outage"); + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs new file mode 100644 index 000000000..db0d8d80f --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs @@ -0,0 +1,65 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlert.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.Text.Json.Serialization; + +namespace Server.Network.Bans.CrowdSec; + +/// One CrowdSec alert (POST /v1/alerts takes an array of these). +public sealed class CrowdSecAlert +{ + [JsonPropertyName("scenario")] public string Scenario { get; set; } + [JsonPropertyName("message")] public string Message { get; set; } + [JsonPropertyName("events_count")] public int EventsCount { get; set; } = 1; + [JsonPropertyName("start_at")] public string StartAt { get; set; } + [JsonPropertyName("stop_at")] public string StopAt { get; set; } + [JsonPropertyName("capacity")] public int Capacity { get; set; } + [JsonPropertyName("leakspeed")] public string LeakSpeed { get; set; } = "0s"; + [JsonPropertyName("simulated")] public bool Simulated { get; set; } + [JsonPropertyName("events")] public object[] Events { get; set; } = []; + [JsonPropertyName("remediation")] public bool Remediation { get; set; } = true; + [JsonPropertyName("source")] public CrowdSecSource Source { get; set; } + [JsonPropertyName("decisions")] public CrowdSecDecisionDto[] Decisions { get; set; } +} + +public sealed class CrowdSecSource +{ + [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; + [JsonPropertyName("value")] public string Value { get; set; } +} + +public sealed class CrowdSecDecisionDto +{ + [JsonPropertyName("origin")] public string Origin { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } = "ban"; + [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; + [JsonPropertyName("value")] public string Value { get; set; } + [JsonPropertyName("duration")] public string Duration { get; set; } + [JsonPropertyName("scenario")] public string Scenario { get; set; } +} + +/// Watcher login request/response for POST /v1/watchers/login. +public sealed class CrowdSecLoginRequest +{ + [JsonPropertyName("machine_id")] public string MachineId { get; set; } + [JsonPropertyName("password")] public string Password { get; set; } +} + +public sealed class CrowdSecLoginResponse +{ + [JsonPropertyName("code")] public int Code { get; set; } + [JsonPropertyName("token")] public string Token { get; set; } + [JsonPropertyName("expire")] public string Expire { get; set; } +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs new file mode 100644 index 000000000..38af6b319 --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs @@ -0,0 +1,135 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlertClient.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.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Server.Network.Bans.CrowdSec; + +/// Reporter-side LAPI operations, mockable for tests. +public interface ICrowdSecAlertClient : IDisposable +{ + ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token); + ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token); +} + +/// +/// CrowdSec LAPI watcher client: authenticates with machine credentials and posts/deletes decisions. +/// Holds a JWT refreshed on expiry or a 401. +/// +public sealed class CrowdSecAlertClient : ICrowdSecAlertClient +{ + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly HttpClient _http; + private readonly string _machineId; + private readonly string _password; + + private string _token; + private DateTime _tokenExpiresUtc = DateTime.MinValue; + + public CrowdSecAlertClient(CrowdSecSettings settings) + { + var baseUri = new Uri(settings.LapiUrl, UriKind.Absolute); // fails loud on malformed url + _http = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromSeconds(30) }; + _http.DefaultRequestHeaders.Add("User-Agent", "ModernUO-watcher/1.0"); + _machineId = settings.MachineId; + _password = settings.Password; + } + + private async Task EnsureAuthAsync(CancellationToken token) + { + if (_token != null && DateTime.UtcNow < _tokenExpiresUtc - TimeSpan.FromMinutes(1)) + { + return; + } + + var request = new CrowdSecLoginRequest { MachineId = _machineId, Password = _password }; + using var response = await _http.PostAsJsonAsync("/v1/watchers/login", request, _jsonOptions, token); + response.EnsureSuccessStatusCode(); + + var login = await response.Content.ReadFromJsonAsync(_jsonOptions, token); + _token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token."); + _tokenExpiresUtc = DateTime.TryParse(login.Expire, out var exp) ? exp.ToUniversalTime() : DateTime.UtcNow.AddHours(1); + } + + private void Authorize(HttpRequestMessage message) => + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + + public async ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + if (alerts.Count == 0) + { + return; + } + + await SendWithRetryAsync(() => + { + var message = new HttpRequestMessage(HttpMethod.Post, "/v1/alerts") + { + Content = JsonContent.Create(alerts, options: _jsonOptions) + }; + Authorize(message); + return message; + }, token); + } + + public async ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) + { + var query = BuildDeleteQuery(origin, ip); + await SendWithRetryAsync(() => + { + var message = new HttpRequestMessage(HttpMethod.Delete, query); + Authorize(message); + return message; + }, token); + } + + /// + /// Builds the decisions-delete query string. is operator-controlled config + /// (crowdsec.json), so it must be escaped like any other untrusted value going into a URL. + /// + internal static string BuildDeleteQuery(string origin, IPAddress ip) => + $"/v1/decisions?origin={Uri.EscapeDataString(origin)}&ip={Uri.EscapeDataString(ip.ToString())}"; + + private async ValueTask SendWithRetryAsync(Func build, CancellationToken token) + { + await EnsureAuthAsync(token); + + using var first = build(); + using var response = await _http.SendAsync(first, token); + if (response.StatusCode != HttpStatusCode.Unauthorized) + { + response.EnsureSuccessStatusCode(); + return; + } + + // Token rejected mid-flight: force a re-login and retry once. + _token = null; + await EnsureAuthAsync(token); + using var retry = build(); + using var retryResponse = await _http.SendAsync(retry, token); + retryResponse.EnsureSuccessStatusCode(); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecBans.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecBans.cs new file mode 100644 index 000000000..c02ddeecb --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecBans.cs @@ -0,0 +1,32 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecBans.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 Server.Network.Bans; +using Server.Network.Bans.CrowdSec; + +namespace Server.Misc; + +/// +/// Registers the CrowdSec ban reporter with the Core during the Configure sweep. +/// Registration is unconditional: the reporter self-disables when crowdsec.json has no credentials +/// (see CrowdSecSettings.ReportingEnabled), so no config gate is needed here. +/// +public static class CrowdSecBans +{ + public static void Configure() + { + BanChannel.Register(new CrowdSecReporter()); + } +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs new file mode 100644 index 000000000..a48671ee6 --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs @@ -0,0 +1,108 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecConfiguration.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.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans.CrowdSec; + +/// +/// Loads the from Configuration/crowdsec.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a disabled-by-default template so operators have something to edit. +/// +public static class CrowdSecConfiguration +{ + private const string _path = "Configuration/crowdsec.json"; + + public static CrowdSecSettings Settings { get; private set; } + + public static void Configure() + { + // Idempotent: this Configure() is auto-discovered and also called explicitly by the reporter, + // so guard against a redundant second load. + if (Settings != null) + { + return; + } + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new CrowdSecSettings + { + LapiUrl = "http://127.0.0.1:8080", + MachineId = "", + Password = "", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168), + FlushInterval = TimeSpan.FromSeconds(1), + MaxQueue = 10000 + }; + + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . Read once at Configure(). +/// The reporter is inert unless is true. +/// +public record CrowdSecSettings +{ + /// LAPI endpoint. Default http://127.0.0.1:8080. + [JsonPropertyName("lapiUrl")] + public string LapiUrl { get; set; } = "http://127.0.0.1:8080"; + + /// Watcher machine id from cscli machines add. Empty disables reporting. + [JsonPropertyName("machineId")] + public string MachineId { get; set; } = ""; + + /// Watcher password paired with . + [JsonPropertyName("password")] + public string Password { get; set; } = ""; + + /// Decision origin stamped on our contributions. Default modernuo. + [JsonPropertyName("origin")] + public string Origin { get; set; } = "modernuo"; + + /// Duration for manual admin bans pushed to CrowdSec. Default 168h (renewable). Finite so a missed retract self-heals. + [JsonPropertyName("manualBanDuration")] + public TimeSpan ManualBanDuration { get; set; } = TimeSpan.FromHours(168); + + /// Max time the drain coalesces before flushing a batch. Default 1s. + [JsonPropertyName("flushInterval")] + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// Bounded contribution queue capacity; overflow is dropped (counted). Default 10000. + [JsonPropertyName("maxQueue")] + public int MaxQueue { get; set; } = 10000; + + [JsonIgnore] + public bool ReportingEnabled => !string.IsNullOrWhiteSpace(MachineId) && !string.IsNullOrWhiteSpace(Password); +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs new file mode 100644 index 000000000..1e44dcf1c --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs @@ -0,0 +1,371 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecReporter.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 System.Threading.Channels; +using System.Threading.Tasks; +using Server.Logging; + +namespace Server.Network.Bans.CrowdSec; + +/// +/// Contributes locally-decided bans to CrowdSec via the LAPI alerts API. Non-blocking on the accept +/// path: enqueues onto a bounded, drop-on-overflow channel drained by a single +/// background task that coalesces by IP and POSTs batched alerts. +/// +public sealed class CrowdSecReporter : IBanReporter +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(CrowdSecReporter)); + + internal readonly record struct ReportItem(IPAddress Ip, TimeSpan Ttl, string Reason, bool Retract); + + // Bounded retry for transient LAPI failures during a drain send (network blips, 5xx). Distinct from + // CrowdSecAlertClient.SendWithRetryAsync's single 401-relogin retry, which is an auth concern. + private static readonly TimeSpan[] _retryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)]; + + private ICrowdSecAlertClient _client; + private CrowdSecSettings _settings; + private Channel _queue; + private CancellationTokenSource _cts; + private Task _drainTask; + private int _dropped; + private int _sendFailures; + + public CrowdSecReporter() + { + } + + // Test/embedding ctor with an injected client + settings. + internal CrowdSecReporter(ICrowdSecAlertClient client, CrowdSecSettings settings) + { + _client = client; + _settings = settings; + _queue = CreateQueue(settings.MaxQueue); + } + + public string Name => "crowdsec"; + public bool CanRetract => true; + public int DroppedCount => _dropped; + + /// + /// Batches ultimately dropped after the bounded transient-retry in gave up. + /// Distinct from (queue-overflow drops on the accept path): this counts + /// sustained LAPI outages so operators can see contribution loss instead of it being silent. + /// + public int SendFailureCount => _sendFailures; + + public void Configure() + { + CrowdSecConfiguration.Configure(); + _settings ??= CrowdSecConfiguration.Settings; + } + + public void Start(CancellationToken token) + { + if (!_settings.ReportingEnabled) + { + logger.Information("CrowdSec reporter disabled (machineId/password empty in crowdsec.json)"); + return; + } + + _client ??= new CrowdSecAlertClient(_settings); + _queue ??= CreateQueue(_settings.MaxQueue); + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + _drainTask = Task.Run(() => DrainLoop(_cts.Token), _cts.Token); + } + + public void Stop() + { + _cts?.Cancel(); + + // Main.HandleClosed cancels ClosingTokenSource BEFORE calling BanChannel.Stop(), so by the time + // we get here the drain loop has almost always already observed cancellation and is unwinding. + // Wait a short bounded grace period for it to actually EXIT before we touch the SingleReader channel + // ourselves — flushing while the drain is still a live reader would be unsafe. + var drainExited = true; + try + { + // Task.Wait(timeout) returns false only on timeout (task still running); true when completed; + // throws when the task faulted/cancelled (also completed). So drainExited is false only while + // the drain is genuinely still alive. + drainExited = _drainTask == null || _drainTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // A faulted/cancelled wait means the drain task has completed — it is no longer reading the + // channel, so the flush below is safe. + } + + _cts?.Dispose(); + _cts = null; + + // Only read the SingleReader channel once the drain has provably stopped reading it. + if (drainExited) + { + FlushRemainingOnStop(); + } + + _client?.Dispose(); + _drainTask = null; + } + + /// + /// Best-effort bounded flush of whatever contribution items are still queued at shutdown. Runs on a + /// fresh, short-lived token — NOT the drain loop's (already-cancelled) token — since a cancelled token + /// would make the send fail immediately. Pending reports are deduped via and + /// sent as a single batch; pending retracts are issued as individual (deduped) DELETEs so an admin's + /// explicit unban still propagates on a clean shutdown instead of lingering until + /// elapses. One shared short budget bounds the whole + /// flush: a shutdown must never hang on this, and any leftover retracts still self-heal via that duration. + /// + private void FlushRemainingOnStop() + { + if (_queue == null || _client == null) + { + return; + } + + _queue.Writer.TryComplete(); + + var reports = new List(); + var retracts = new List(); + while (_queue.Reader.TryRead(out var item)) + { + (item.Retract ? retracts : reports).Add(item); + } + + if (reports.Count == 0 && retracts.Count == 0) + { + return; + } + + // One shared, short budget bounds the entire flush so shutdown can never hang on it. + using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + if (reports.Count > 0) + { + var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); + try + { + _client.PostAlertsAsync(alerts, flushCts.Token).GetAwaiter().GetResult(); + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop reports failed"); + RecordSendFailure(alerts.Count); + } + } + + var seen = new HashSet(); + foreach (var retract in retracts) + { + if (flushCts.IsCancellationRequested) + { + break; // out of budget; the rest self-heal via ManualBanDuration + } + + if (!seen.Add(retract.Ip.ToString())) + { + continue; + } + + try + { + _client.DeleteDecisionsAsync(_settings.Origin, retract.Ip, flushCts.Token).GetAwaiter().GetResult(); + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop retract failed for {Address}", retract.Ip); + RecordSendFailure(1); + } + } + } + + public void Report(IPAddress address, TimeSpan ttl, string reason) => + Enqueue(new ReportItem(address, ttl, reason, false)); + + public void Retract(IPAddress address) => + Enqueue(new ReportItem(address, TimeSpan.Zero, "retract", true)); + + private void Enqueue(ReportItem item) + { + if (_queue == null || !_queue.Writer.TryWrite(item)) + { + Interlocked.Increment(ref _dropped); + } + } + + // FullMode.Wait (the default) makes TryWrite return false immediately when the channel is full + // instead of blocking the caller — exactly the non-blocking drop-on-overflow behavior the accept + // path requires. DropWrite would silently discard the new item and always report success, which + // would make overflow undetectable. + private static Channel CreateQueue(int capacity) => + Channel.CreateBounded(new BoundedChannelOptions(Math.Max(1, capacity)) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true + }); + + private async ValueTask DrainLoop(CancellationToken token) + { + var reader = _queue.Reader; + + while (!token.IsCancellationRequested) + { + try + { + if (!await reader.WaitToReadAsync(token)) + { + return; + } + + // Coalesce a burst before flushing. + await Task.Delay(_settings.FlushInterval, token); + + var reports = new List(); + var retracts = new List(); + while (reader.TryRead(out var item)) + { + (item.Retract ? retracts : reports).Add(item); + } + + if (reports.Count > 0) + { + var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); + if (!await SendWithBoundedRetryAsync(() => _client.PostAlertsAsync(alerts, token), token)) + { + RecordSendFailure(alerts.Count); + } + } + + foreach (var retract in retracts) + { + var ip = retract.Ip; + if (!await SendWithBoundedRetryAsync(() => _client.DeleteDecisionsAsync(_settings.Origin, ip, token), token)) + { + RecordSendFailure(1); + } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + // Contribution is auxiliary: log and keep draining. Never crash the shard. + logger.Warning(e, "CrowdSec contribution flush failed; dropped this batch"); + } + } + } + + /// + /// Sends with up to 3 attempts total (1 initial + 2 retries), backing off 1s then 2s between + /// attempts, for transient LAPI failures (network blips, 5xx). Backoff uses + /// so it never blocks the thread; a cancellation during backoff propagates as + /// so the drain loop exits cleanly. Returns false (never + /// throws for a send failure) once attempts are exhausted, so the caller can count the drop and keep + /// draining instead of losing the rest of the batch/queue. + /// + private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) + { + for (var attempt = 0; ; attempt++) + { + try + { + await send(); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + if (attempt >= _retryDelays.Length) + { + logger.Warning(e, "CrowdSec send failed after {Attempts} attempt(s); giving up", attempt + 1); + return false; + } + + var delay = _retryDelays[attempt]; + logger.Warning(e, "CrowdSec send failed (attempt {Attempt}); retrying in {Delay}", attempt + 1, delay); + await Task.Delay(delay, token); + } + } + } + + private void RecordSendFailure(int itemCount) + { + var total = Interlocked.Increment(ref _sendFailures); + logger.Warning( + "CrowdSec contribution batch dropped after retries ({Items} item(s)); total dropped batches: {Total}", + itemCount, + total + ); + } + + /// Coalesces items by IP (last write wins) and builds one alert per unique address. + internal static List BuildAlerts(IEnumerable items, CrowdSecSettings settings, DateTime nowUtc) + { + var byIp = new Dictionary(); + foreach (var item in items) + { + byIp[item.Ip.ToString()] = item; + } + + var timestamp = nowUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var alerts = new List(byIp.Count); + + foreach (var (value, item) in byIp) + { + var ttl = item.Reason == "manual" || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl; + var scenario = $"{settings.Origin}/{item.Reason}"; + + alerts.Add(new CrowdSecAlert + { + Scenario = scenario, + Message = $"ModernUO {item.Reason} ban for {value}", + StartAt = timestamp, + StopAt = timestamp, + Source = new CrowdSecSource { Scope = "Ip", Value = value }, + Decisions = + [ + new CrowdSecDecisionDto + { + Origin = settings.Origin, + Type = "ban", + Scope = "Ip", + Value = value, + Duration = FormatDuration(ttl), + Scenario = scenario + } + ] + }); + } + + return alerts; + } + + /// CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient. + internal static string FormatDuration(TimeSpan ttl) + { + var seconds = (long)ttl.TotalSeconds; + return $"{Math.Max(1, seconds)}s"; + } +}