180 lines
7.3 KiB
C#
180 lines
7.3 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace ScadaLink.CLI.Commands;
|
|
|
|
/// <summary>
|
|
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
|
|
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
|
|
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
|
|
/// </summary>
|
|
public sealed class AuditQueryArgs
|
|
{
|
|
public string? Since { get; set; }
|
|
public string? Until { get; set; }
|
|
public string? Channel { get; set; }
|
|
public string? Kind { get; set; }
|
|
public string? Status { get; set; }
|
|
public string? Site { get; set; }
|
|
public string? Target { get; set; }
|
|
public string? Actor { get; set; }
|
|
public string? CorrelationId { get; set; }
|
|
public bool ErrorsOnly { get; set; }
|
|
public int PageSize { get; set; } = 100;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure helpers for the <c>audit query</c> subcommand: time-spec resolution, query-string
|
|
/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring
|
|
/// so each piece is unit-testable without standing up the command tree.
|
|
/// </summary>
|
|
public static class AuditQueryHelpers
|
|
{
|
|
// <number><unit> where unit is s/m/h/d — a relative offset back from "now".
|
|
private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
|
|
|
|
/// <summary>
|
|
/// Resolves a time-spec to an absolute <see cref="DateTimeOffset"/>. Accepts a
|
|
/// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
|
|
/// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
|
|
/// </summary>
|
|
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
|
|
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(spec))
|
|
throw new FormatException("Empty time value.");
|
|
|
|
var match = RelativeSpec.Match(spec.Trim());
|
|
if (match.Success)
|
|
{
|
|
var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
|
var offset = match.Groups[2].Value switch
|
|
{
|
|
"s" => TimeSpan.FromSeconds(amount),
|
|
"m" => TimeSpan.FromMinutes(amount),
|
|
"h" => TimeSpan.FromHours(amount),
|
|
"d" => TimeSpan.FromDays(amount),
|
|
_ => throw new FormatException($"Unknown time unit in '{spec}'."),
|
|
};
|
|
return now - offset;
|
|
}
|
|
|
|
if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute))
|
|
{
|
|
return absolute;
|
|
}
|
|
|
|
throw new FormatException(
|
|
$"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
|
|
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c>
|
|
/// maps to <c>status=Failed</c> (the server takes a single status value).
|
|
/// </summary>
|
|
public static string BuildQueryString(
|
|
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
|
|
{
|
|
var parts = new List<string>();
|
|
|
|
void Add(string key, string? value)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(args.Since))
|
|
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
|
|
if (!string.IsNullOrWhiteSpace(args.Until))
|
|
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
|
|
|
|
Add("channel", args.Channel);
|
|
Add("kind", args.Kind);
|
|
|
|
// --errors-only is a convenience shorthand for the single-value Failed status
|
|
// filter. The server's status filter accepts one value, so --errors-only and an
|
|
// explicit --status are mutually exclusive in effect; --errors-only wins.
|
|
Add("status", args.ErrorsOnly ? "Failed" : args.Status);
|
|
|
|
Add("sourceSiteId", args.Site);
|
|
Add("target", args.Target);
|
|
Add("actor", args.Actor);
|
|
Add("correlationId", args.CorrelationId);
|
|
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
|
|
|
|
if (afterOccurredAtUtc.HasValue)
|
|
Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture));
|
|
Add("afterEventId", afterEventId);
|
|
|
|
return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes the query: GETs <c>/api/audit/query</c>, renders each page with
|
|
/// <paramref name="formatter"/>, and — when <paramref name="fetchAll"/> is set —
|
|
/// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
|
|
/// process exit code (0 success, non-zero on HTTP/transport error).
|
|
/// </summary>
|
|
public static async Task<int> RunQueryAsync(
|
|
ManagementHttpClient client,
|
|
AuditQueryArgs args,
|
|
bool fetchAll,
|
|
IAuditFormatter formatter,
|
|
TextWriter output,
|
|
DateTimeOffset now)
|
|
{
|
|
DateTimeOffset? afterOccurredAtUtc = null;
|
|
string? afterEventId = null;
|
|
|
|
while (true)
|
|
{
|
|
var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId);
|
|
var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30));
|
|
|
|
if (response.JsonData == null)
|
|
{
|
|
OutputFormatter.WriteError(
|
|
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
|
|
return 1;
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(response.JsonData);
|
|
var root = doc.RootElement;
|
|
|
|
var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array
|
|
? evts.EnumerateArray().ToList()
|
|
: new List<JsonElement>();
|
|
formatter.WritePage(events, output);
|
|
output.Flush();
|
|
|
|
if (!fetchAll)
|
|
return 0;
|
|
|
|
if (!root.TryGetProperty("nextCursor", out var cursor)
|
|
|| cursor.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1)
|
|
&& c1.ValueKind == JsonValueKind.String
|
|
? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
|
|
: null;
|
|
afterEventId = cursor.TryGetProperty("afterEventId", out var c2)
|
|
&& c2.ValueKind == JsonValueKind.String
|
|
? c2.GetString()
|
|
: null;
|
|
|
|
// A malformed cursor (object present but missing both keys) would loop
|
|
// forever — treat it as the end of results.
|
|
if (afterOccurredAtUtc == null && afterEventId == null)
|
|
return 0;
|
|
}
|
|
}
|
|
}
|