Contributing a ban to CrowdSec failed against a real LAPI. Three independent
defects, each sufficient on its own:
scenario_hash / scenario_version were never serialized. LAPI dereferences both
unconditionally when persisting an alert, so omitting them is a nil deref and a
500 rather than a validation error. Both are now emitted with the values a
watcher without a hub scenario is expected to send ("" and "1.0").
start_at/stop_at were formatted without an IFormatProvider. ':' is the time
separator *specifier* in a custom .NET format string, not a literal, so a shard
running under a culture like fi-FI emitted "T15.04.05.123Z" -- which Go's
time.RFC3339 rejects, producing another 500. Non-Gregorian cultures (th-TH,
ar-SA) would also shift the year. Formatting is now pinned to InvariantCulture
in FormatTimestamp, which additionally converts non-UTC input, since the
trailing 'Z' is a literal and was previously an unchecked claim.
The User-Agent was a plain product string. LAPI's default watcher profile
matches the "crowdsec/" prefix and answers 401 without it, so the header is a
protocol constraint; it is now an internal const carrying that reason.
Also fixes the same culture bug in the login-expiry parse: a bare
DateTime.TryParse on LAPI's RFC3339 expire silently fails under a mismatched
culture and falls back to a fabricated UtcNow+1h, pushing re-auth past the real
expiry and costing a 401-relogin round trip on every send.
capacity now defaults to 1 instead of 0, matching the one-decision-per-alert
shape actually being sent.
The resulting payload is field-for-field identical to a hand-verified request
that LAPI accepts. Regression tests assert the required scenario fields on the
serialized JSON rather than the DTO, since the DTO is not what goes on the
wire, and cover the timestamp across fi-FI/th-TH/ar-SA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
285 lines
11 KiB
C#
285 lines
11 KiB
C#
/*************************************************************************
|
|
* 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.Globalization;
|
|
using System.Net;
|
|
using System.Text.Json;
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LAPI dereferences scenario_hash/scenario_version unconditionally when persisting an alert, so an
|
|
/// omitted field is a 500, not a validation error. Asserted on the serialized payload rather than the
|
|
/// DTO because that is what actually goes on the wire.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildAlerts_SerializedPayload_CarriesRequiredScenarioFields()
|
|
{
|
|
var alerts = CrowdSecReporter.BuildAlerts(
|
|
[new(IPAddress.Parse("9.9.9.9"), TimeSpan.FromHours(1), "rate-limit", false)],
|
|
Settings(),
|
|
DateTime.UnixEpoch);
|
|
|
|
var payload = JsonSerializer.SerializeToNode(alerts)!.AsArray()[0]!.AsObject();
|
|
|
|
Assert.True(payload.ContainsKey("scenario_hash"));
|
|
Assert.True(payload.ContainsKey("scenario_version"));
|
|
Assert.Equal(JsonValueKind.String, payload["scenario_hash"]!.GetValue<JsonElement>().ValueKind);
|
|
Assert.Equal(JsonValueKind.String, payload["scenario_version"]!.GetValue<JsonElement>().ValueKind);
|
|
Assert.Equal(1, payload["capacity"]!.GetValue<int>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// ':' is the time-separator specifier in a custom .NET format string, so a shard under fi-FI used to
|
|
/// emit "T00.00.00.000Z" — which Go's time.RFC3339 rejects, and LAPI answers 500 for. th-TH additionally
|
|
/// shifts the year via the Buddhist calendar.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("fi-FI")]
|
|
[InlineData("th-TH")]
|
|
[InlineData("ar-SA")]
|
|
public void FormatTimestamp_IsIso8601_RegardlessOfCulture(string culture)
|
|
{
|
|
var previous = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(DateTime.UnixEpoch));
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = previous;
|
|
}
|
|
}
|
|
|
|
/// <summary>A non-UTC input must still be stamped as UTC — the trailing 'Z' is a literal, not a claim.</summary>
|
|
[Fact]
|
|
public void FormatTimestamp_ConvertsNonUtcInput()
|
|
{
|
|
var local = new DateTimeOffset(1970, 1, 1, 2, 0, 0, TimeSpan.FromHours(2)).LocalDateTime;
|
|
|
|
Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(local));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The drain task must track the loop's lifetime, not just its first await — a ValueTask-returning
|
|
/// drain loop passed to Task.Run yields a Task<ValueTask> that completes immediately, which makes
|
|
/// Stop()'s drain-exited handshake a no-op. With an empty queue the loop parks on WaitToReadAsync,
|
|
/// so a correctly unwrapped task cannot win this race; a slow pool only under-detects.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait()
|
|
{
|
|
var reporter = new CrowdSecReporter(new NullAlertClient(), Settings());
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
reporter.Start(cts.Token);
|
|
|
|
var drain = reporter.DrainTaskForTesting;
|
|
Assert.NotNull(drain);
|
|
|
|
var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500)));
|
|
Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running");
|
|
|
|
reporter.Stop();
|
|
|
|
Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited");
|
|
}
|
|
|
|
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() { }
|
|
}
|
|
}
|