ModernUO/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs
Kamron Batman bd44ef4026
fix(crowdsec): send a payload LAPI accepts (500 on alerts, 401 on auth)
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>
2026-07-27 23:30:32 -07:00

152 lines
6.3 KiB
C#

/*************************************************************************
* 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.Globalization;
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 };
/// <summary>
/// The <c>crowdsec/</c> prefix is load-bearing: LAPI's default watcher profile matches on it and
/// answers 401 for anything else, so this cannot be a plain product string.
/// </summary>
internal const string UserAgent = "crowdsec/ModernUO-watcher-1.0";
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", UserAgent);
_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)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var login = await response.Content.ReadFromJsonAsync<CrowdSecLoginResponse>(_jsonOptions, token)
.ConfigureAwait(false);
_token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token.");
// LAPI returns an RFC3339 expiry. Parse it invariantly for the same reason we format invariantly:
// the current culture must not decide whether a machine-readable timestamp is understood.
_tokenExpiresUtc = DateTime.TryParse(
login.Expire,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal,
out var exp
) ? exp : 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).ConfigureAwait(false);
}
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).ConfigureAwait(false);
}
/// <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).ConfigureAwait(false);
using var first = build();
using var response = await _http.SendAsync(first, token).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized)
{
response.EnsureSuccessStatusCode();
return;
}
// Token rejected mid-flight: force a re-login and retry once.
_token = null;
await EnsureAuthAsync(token).ConfigureAwait(false);
using var retry = build();
using var retryResponse = await _http.SendAsync(retry, token).ConfigureAwait(false);
retryResponse.EnsureSuccessStatusCode();
}
public void Dispose() => _http.Dispose();
}