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:
parent
4ec488f59b
commit
6249a20f15
13 changed files with 1471 additions and 0 deletions
73
Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs
Normal file
73
Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
149
Projects/Server/Network/Bans/BanChannel.cs
Normal file
149
Projects/Server/Network/Bans/BanChannel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
Projects/Server/Network/Bans/BanConfiguration.cs
Normal file
102
Projects/Server/Network/Bans/BanConfiguration.cs
Normal 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);
|
||||
}
|
||||
55
Projects/Server/Network/Bans/IBanReporter.cs
Normal file
55
Projects/Server/Network/Bans/IBanReporter.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
65
Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs
Normal file
65
Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs
Normal 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; }
|
||||
}
|
||||
135
Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs
Normal file
135
Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs
Normal 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();
|
||||
}
|
||||
32
Projects/UOContent/Misc/CrowdSec/CrowdSecBans.cs
Normal file
32
Projects/UOContent/Misc/CrowdSec/CrowdSecBans.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
108
Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs
Normal file
108
Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs
Normal 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);
|
||||
}
|
||||
371
Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs
Normal file
371
Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue