feat(bans): contribute-first ban channel with CrowdSec reporter

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) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-07-23 20:37:42 -07:00
parent 4ec488f59b
commit 6249a20f15
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
13 changed files with 1471 additions and 0 deletions

View file

@ -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<IPAddress> 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);
}
}

View file

@ -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<BanSettings>(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);
}
}

View file

@ -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 <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 calls <see cref="Firewall.IsBlocked"/> directly. 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);
}
}
// Flush the local firewall's pending writes on the way down.
Firewall.Save();
}
/// <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);
}
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Network.Bans;
/// <summary>
/// Loads the <see cref="BanSettings"/> from <c>Configuration/bans.json</c> (matching the per-feature
/// JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file writes a
/// local-only, fail-open template so operators have something to edit.
/// </summary>
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<BanSettings>(path);
}
else
{
Settings = new BanSettings
{
ReportRateLimitTrips = true,
AutoBanDuration = TimeSpan.FromHours(4)
};
Save();
}
}
private static void Save()
{
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
}
}
/// <summary>Ban-channel policy: which reporters receive contributions, and how auto-detections are handled.</summary>
public record BanSettings
{
/// <summary>Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set.</summary>
[JsonPropertyName("reportRateLimitTrips")]
public bool ReportRateLimitTrips { get; set; } = true;
/// <summary>Duration reported for an auto-detected (rate-limit) ban.</summary>
[JsonPropertyName("autoBanDuration")]
public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4);
/// <summary>Path to the local blocklist file, relative to <see cref="Core.BaseDirectory"/>.</summary>
[JsonPropertyName("blocklistFile")]
public string BlocklistFile { get; set; } = "";
/// <summary>How often the blocklist file is re-read for changes.</summary>
[JsonPropertyName("blocklistReloadInterval")]
public TimeSpan BlocklistReloadInterval { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Whether blocklist hits are contributed to reporters.</summary>
[JsonPropertyName("reportBlocklistHits")]
public bool ReportBlocklistHits { get; set; } = true;
/// <summary>Duration reported for a blocklist-matched ban.</summary>
[JsonPropertyName("blocklistBanDuration")]
public TimeSpan BlocklistBanDuration { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// 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 <see cref="BlocklistBanDuration"/> so the guard doesn't remember hours of
/// distinct IPs.
/// </summary>
[JsonPropertyName("blocklistPromoteSuppression")]
public TimeSpan BlocklistPromoteSuppression { get; set; } = TimeSpan.FromSeconds(60);
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
using System.Threading;
namespace Server.Network.Bans;
/// <summary>
/// A contribution sink behind <see cref="BanChannel"/>. 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 <see cref="Firewall"/>'s job.
/// </summary>
public interface IBanReporter
{
/// <summary>Stable id for logging/config (e.g. <c>crowdsec</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. No network or file I/O here.</summary>
void Configure();
/// <summary>Starts background delivery. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down background delivery.</summary>
void Stop();
/// <summary>
/// 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.
/// </summary>
/// <param name="ttl"><see cref="TimeSpan.Zero"/> or negative = use the reporter's default duration.</param>
/// <param name="reason">Short slug (<c>manual</c>, <c>rate-limit</c>) used as the scenario suffix.</param>
void Report(IPAddress address, TimeSpan ttl, string reason);
/// <summary>True if this reporter can retract a previously-reported ban.</summary>
bool CanRetract { get; }
/// <summary>Enqueues a retraction (e.g. a manual unban). No-op if unsupported.</summary>
void Retract(IPAddress address);
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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);
}
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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<CrowdSecSettings>(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
}
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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<CrowdSecReporter.ReportItem>
{
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<CrowdSecAlert> 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<IReadOnlyList<CrowdSecAlert>> Posted { get; } = [];
public List<IPAddress> Deleted { get; } = [];
public ValueTask PostAlertsAsync(IReadOnlyList<CrowdSecAlert> 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<CrowdSecAlert> alerts, CancellationToken token) =>
throw new InvalidOperationException("simulated LAPI outage");
public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) =>
ValueTask.CompletedTask;
public void Dispose() { }
}
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Text.Json.Serialization;
namespace Server.Network.Bans.CrowdSec;
/// <summary>One CrowdSec alert (<c>POST /v1/alerts</c> takes an array of these).</summary>
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; }
}
/// <summary>Watcher login request/response for <c>POST /v1/watchers/login</c>.</summary>
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; }
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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;
/// <summary>Reporter-side LAPI operations, mockable for tests.</summary>
public interface ICrowdSecAlertClient : IDisposable
{
ValueTask PostAlertsAsync(IReadOnlyList<CrowdSecAlert> alerts, CancellationToken token);
ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token);
}
/// <summary>
/// CrowdSec LAPI watcher client: authenticates with machine credentials and posts/deletes decisions.
/// Holds a JWT refreshed on expiry or a 401.
/// </summary>
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<CrowdSecLoginResponse>(_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<CrowdSecAlert> 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);
}
/// <summary>
/// Builds the decisions-delete query string. <paramref name="origin"/> is operator-controlled config
/// (crowdsec.json), so it must be escaped like any other untrusted value going into a URL.
/// </summary>
internal static string BuildDeleteQuery(string origin, IPAddress ip) =>
$"/v1/decisions?origin={Uri.EscapeDataString(origin)}&ip={Uri.EscapeDataString(ip.ToString())}";
private async ValueTask SendWithRetryAsync(Func<HttpRequestMessage> 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();
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using Server.Network.Bans;
using Server.Network.Bans.CrowdSec;
namespace Server.Misc;
/// <summary>
/// Registers the CrowdSec ban reporter with the Core <see cref="BanChannel"/> during the Configure sweep.
/// Registration is unconditional: the reporter self-disables when <c>crowdsec.json</c> has no credentials
/// (see <c>CrowdSecSettings.ReportingEnabled</c>), so no config gate is needed here.
/// </summary>
public static class CrowdSecBans
{
public static void Configure()
{
BanChannel.Register(new CrowdSecReporter());
}
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Network.Bans.CrowdSec;
/// <summary>
/// Loads the <see cref="CrowdSecSettings"/> from <c>Configuration/crowdsec.json</c> (matching the
/// per-feature JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file
/// writes a disabled-by-default template so operators have something to edit.
/// </summary>
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<CrowdSecSettings>(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);
}
}
/// <summary>
/// Bound configuration for <see cref="CrowdSecReporter"/>. Read once at <c>Configure()</c>.
/// The reporter is inert unless <see cref="ReportingEnabled"/> is true.
/// </summary>
public record CrowdSecSettings
{
/// <summary>LAPI endpoint. Default <c>http://127.0.0.1:8080</c>.</summary>
[JsonPropertyName("lapiUrl")]
public string LapiUrl { get; set; } = "http://127.0.0.1:8080";
/// <summary>Watcher machine id from <c>cscli machines add</c>. Empty disables reporting.</summary>
[JsonPropertyName("machineId")]
public string MachineId { get; set; } = "";
/// <summary>Watcher password paired with <see cref="MachineId"/>.</summary>
[JsonPropertyName("password")]
public string Password { get; set; } = "";
/// <summary>Decision <c>origin</c> stamped on our contributions. Default <c>modernuo</c>.</summary>
[JsonPropertyName("origin")]
public string Origin { get; set; } = "modernuo";
/// <summary>Duration for manual admin bans pushed to CrowdSec. Default 168h (renewable). Finite so a missed retract self-heals.</summary>
[JsonPropertyName("manualBanDuration")]
public TimeSpan ManualBanDuration { get; set; } = TimeSpan.FromHours(168);
/// <summary>Max time the drain coalesces before flushing a batch. Default 1s.</summary>
[JsonPropertyName("flushInterval")]
public TimeSpan FlushInterval { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>Bounded contribution queue capacity; overflow is dropped (counted). Default 10000.</summary>
[JsonPropertyName("maxQueue")]
public int MaxQueue { get; set; } = 10000;
[JsonIgnore]
public bool ReportingEnabled => !string.IsNullOrWhiteSpace(MachineId) && !string.IsNullOrWhiteSpace(Password);
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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;
/// <summary>
/// Contributes locally-decided bans to CrowdSec via the LAPI alerts API. Non-blocking on the accept
/// path: <see cref="Report"/> enqueues onto a bounded, drop-on-overflow channel drained by a single
/// background task that coalesces by IP and POSTs batched alerts.
/// </summary>
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<ReportItem> _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;
/// <summary>
/// Batches ultimately dropped after the bounded transient-retry in <see cref="DrainLoop"/> gave up.
/// Distinct from <see cref="DroppedCount"/> (queue-overflow drops on the accept path): this counts
/// sustained LAPI outages so operators can see contribution loss instead of it being silent.
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="BuildAlerts"/> 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
/// <see cref="CrowdSecSettings.ManualBanDuration"/> 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.
/// </summary>
private void FlushRemainingOnStop()
{
if (_queue == null || _client == null)
{
return;
}
_queue.Writer.TryComplete();
var reports = new List<ReportItem>();
var retracts = new List<ReportItem>();
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<string>();
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<ReportItem> CreateQueue(int capacity) =>
Channel.CreateBounded<ReportItem>(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<ReportItem>();
var retracts = new List<ReportItem>();
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");
}
}
}
/// <summary>
/// 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 <see cref="Task.Delay"/>
/// so it never blocks the thread; a cancellation during backoff propagates as
/// <see cref="OperationCanceledException"/> 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.
/// </summary>
private static async ValueTask<bool> SendWithBoundedRetryAsync(Func<ValueTask> 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
);
}
/// <summary>Coalesces items by IP (last write wins) and builds one alert per unique address.</summary>
internal static List<CrowdSecAlert> BuildAlerts(IEnumerable<ReportItem> items, CrowdSecSettings settings, DateTime nowUtc)
{
var byIp = new Dictionary<string, ReportItem>();
foreach (var item in items)
{
byIp[item.Ip.ToString()] = item;
}
var timestamp = nowUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
var alerts = new List<CrowdSecAlert>(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;
}
/// <summary>CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient.</summary>
internal static string FormatDuration(TimeSpan ttl)
{
var seconds = (long)ttl.TotalSeconds;
return $"{Math.Max(1, seconds)}s";
}
}