e968566e9d
- CreateDefaultHttpClient sets AllowAutoRedirect=false so a 307/308 cannot re-POST the /auth/token credential form elsewhere. - A forced re-exchange after a 401 carries the refused token; inside the gate it reuses a token another caller already re-exchanged instead of binding again. The BasicOnly switch is written under the same gate. - SCADABRIDGE_VERBOSE=1 prints a stderr note when a fresh token is refused and the CLI falls back to Basic. - debug stream's AccessTokenProvider retries the exchange once and documents the null-token degradation (the hub refuses, automatic reconnect retries). - Tests: concurrent refusal reuses the peer refresh (2 exchanges, not 3); real-socket redirect is not followed.
608 lines
28 KiB
C#
608 lines
28 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
|
|
|
/// <summary>
|
|
/// HTTP client for the central management surfaces (<c>POST /management</c>, the audit REST
|
|
/// endpoints). Authenticates with a bearer token exchanged once per process at
|
|
/// <c>POST /auth/token</c>, falling back to HTTP Basic when the server does not offer bearer
|
|
/// tokens (review 2026-10-21 05/U1, 05/P1).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Bearer flow.</b> The first call a process makes for a given URL + credential pair posts
|
|
/// the credentials to <c>/auth/token</c> (one LDAP bind) and keeps the returned JWT in memory
|
|
/// (<see cref="CliTokenStore"/>; never persisted or logged). Every later call — from this or any
|
|
/// other client instance in the same process — sends <c>Authorization: Bearer</c>, which the
|
|
/// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call
|
|
/// answered <c>401</c> re-exchanges once (unless the token was minted for that very call) and,
|
|
/// if still refused, retries with Basic.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Basic fallback.</b> A server whose <c>/auth/token</c> is absent (404/405), disabled
|
|
/// (login-disabled deployment, 404) or does not advertise <c>management_bearer: true</c> (an
|
|
/// older server, whose tokens no management surface accepts) is remembered as Basic-only for
|
|
/// the rest of the process. A transient exchange failure (5xx) uses Basic for that call only.
|
|
/// Bad credentials (<c>401</c>) and a throttle lockout (<c>429</c>) from the exchange are
|
|
/// returned as-is — retrying them with Basic would only add a second failed bind against the
|
|
/// login throttle.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>SCADABRIDGE_AUTH_MODE=basic</c> disables the exchange (Basic on every call).
|
|
/// </para>
|
|
/// </remarks>
|
|
public class ManagementHttpClient : IDisposable
|
|
{
|
|
/// <summary>Environment variable selecting the auth mode: <c>token</c> (default) or <c>basic</c>.</summary>
|
|
public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE";
|
|
|
|
/// <summary>
|
|
/// Environment variable that, when <c>1</c>/<c>true</c>, makes the client note on stderr
|
|
/// when it abandons a refused bearer token for HTTP Basic (e.g. central nodes configured
|
|
/// with different signing keys). The CLI has no other verbose switch.
|
|
/// </summary>
|
|
public const string VerboseEnvVar = "SCADABRIDGE_VERBOSE";
|
|
|
|
/// <summary>Re-exchange a token this long before its stated expiry.</summary>
|
|
internal static readonly TimeSpan TokenRefreshMargin = TimeSpan.FromSeconds(60);
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly AuthenticationHeaderValue _basic;
|
|
private readonly string _username;
|
|
private readonly string _password;
|
|
private readonly CliTokenStore.Session? _session;
|
|
|
|
/// <summary>
|
|
/// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS
|
|
/// connection attempt to a black-holed management endpoint may hang before the
|
|
/// call fails.
|
|
///
|
|
/// <para>
|
|
/// It is deliberately NOT an overall request timeout.
|
|
/// <see cref="HttpClient.Timeout"/> caps the whole request/response, so setting
|
|
/// it to any fixed value silently truncates every caller whose own per-call
|
|
/// <c>TimeSpan timeout</c> argument is longer: the effective deadline becomes
|
|
/// <c>min(HttpClient.Timeout, caller timeout)</c>. That is exactly what a 30 s
|
|
/// client timeout did to <c>deploy site</c>'s 5-minute bulk deploy and to the
|
|
/// five-minute <c>bundle</c> export/preview/import calls — each printed a fake
|
|
/// <c>504 Request timed out</c> at 30 s while the server carried on working.
|
|
/// <see cref="HttpClient.Timeout"/> is therefore
|
|
/// <see cref="Timeout.InfiniteTimeSpan"/> and the per-call
|
|
/// <see cref="CancellationTokenSource"/> in
|
|
/// <see cref="SendCommandAsync"/>/<see cref="SendGetAsync"/>/<see cref="SendPostAsync"/>
|
|
/// is the SINGLE overall deadline — it bounds connect too, since the token is
|
|
/// passed into the send itself.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The connect bound lives on <see cref="SocketsHttpHandler.ConnectTimeout"/>
|
|
/// instead, which is connect-scoped and so cannot truncate a long-running
|
|
/// request that has already reached the server. Overridable via the
|
|
/// <c>SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS</c> environment variable,
|
|
/// consistent with how every other CLI setting is overridden (see
|
|
/// <see cref="CliConfig"/>) — kept self-contained here (no
|
|
/// <see cref="CliConfig"/>/command-file plumbing) since CLI commands are owned
|
|
/// by a separate work package this phase.
|
|
/// </para>
|
|
/// </summary>
|
|
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>Test seam — the effective <see cref="HttpClient.Timeout"/> this instance was constructed with.</summary>
|
|
internal TimeSpan EffectiveTimeout { get; }
|
|
|
|
/// <summary>
|
|
/// Resolves the effective connect timeout: the
|
|
/// <c>SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS</c> environment variable when set
|
|
/// to a positive integer, otherwise <see cref="DefaultConnectTimeout"/>.
|
|
/// </summary>
|
|
/// <returns>The connect timeout to apply to the socket handler.</returns>
|
|
internal static TimeSpan ResolveConnectTimeout()
|
|
{
|
|
var env = Environment.GetEnvironmentVariable("SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS");
|
|
if (!string.IsNullOrWhiteSpace(env)
|
|
&& int.TryParse(env, out var seconds)
|
|
&& seconds > 0)
|
|
{
|
|
return TimeSpan.FromSeconds(seconds);
|
|
}
|
|
|
|
return DefaultConnectTimeout;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ManagementHttpClient"/> class with
|
|
/// an INFINITE <see cref="HttpClient.Timeout"/> (each call supplies its own
|
|
/// deadline) over a <see cref="SocketsHttpHandler"/> whose
|
|
/// <see cref="SocketsHttpHandler.ConnectTimeout"/> is
|
|
/// <see cref="ResolveConnectTimeout"/>.
|
|
/// </summary>
|
|
/// <param name="baseUrl">The base URL for the management API.</param>
|
|
/// <param name="username">The username for HTTP Basic authentication.</param>
|
|
/// <param name="password">The password for HTTP Basic authentication.</param>
|
|
public ManagementHttpClient(string baseUrl, string username, string password)
|
|
: this(
|
|
CreateDefaultHttpClient(),
|
|
baseUrl, username, password,
|
|
IsBasicAuthModeForced() ? null : CliTokenStore.Shared)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the production <see cref="HttpClient"/>: infinite overall timeout (each call
|
|
/// carries its own deadline), connect bounded by <see cref="ResolveConnectTimeout"/>, and
|
|
/// redirects NOT followed — a 307/308 from a proxy would otherwise re-POST the
|
|
/// <c>/auth/token</c> form, i.e. the credentials, to wherever the redirect points.
|
|
/// </summary>
|
|
/// <returns>The configured client.</returns>
|
|
internal static HttpClient CreateDefaultHttpClient() =>
|
|
new(new SocketsHttpHandler { ConnectTimeout = ResolveConnectTimeout(), AllowAutoRedirect = false })
|
|
{
|
|
Timeout = Timeout.InfiniteTimeSpan
|
|
};
|
|
|
|
/// <summary>
|
|
/// True when <see cref="AuthModeEnvVar"/> is <c>basic</c>, which disables the token
|
|
/// exchange for the process.
|
|
/// </summary>
|
|
/// <returns>Whether HTTP Basic is forced on every call.</returns>
|
|
internal static bool IsBasicAuthModeForced() =>
|
|
string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
|
|
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
|
|
/// be exercised without a live server.
|
|
/// </summary>
|
|
/// <param name="httpClient">The HTTP client to use for requests.</param>
|
|
/// <param name="baseUrl">The base URL for the management API.</param>
|
|
/// <param name="username">The username for HTTP Basic authentication.</param>
|
|
/// <param name="password">The password for HTTP Basic authentication.</param>
|
|
/// <param name="tokenStore">
|
|
/// Bearer-token store to exchange credentials through; <see langword="null"/> (the default)
|
|
/// sends HTTP Basic on every call, which keeps stub-handler tests to a single request.
|
|
/// </param>
|
|
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password, CliTokenStore? tokenStore = null)
|
|
{
|
|
_httpClient = httpClient;
|
|
// Test seam: exposes the constructed HttpClient's effective Timeout
|
|
// without requiring reflection.
|
|
EffectiveTimeout = httpClient.Timeout;
|
|
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
|
_username = username;
|
|
_password = password;
|
|
_basic = new AuthenticationHeaderValue(
|
|
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
|
|
_session = tokenStore?.For(baseUrl, username, password);
|
|
}
|
|
|
|
/// <summary>The HTTP Basic credential value (base64 <c>user:password</c>), for the debug-stream hub fallback.</summary>
|
|
internal string BasicCredentials => _basic.Parameter!;
|
|
|
|
/// <summary>Outcome of <see cref="TryGetBearerTokenAsync"/>.</summary>
|
|
/// <param name="Token">The bearer token, or <see langword="null"/> when the caller should use Basic.</param>
|
|
/// <param name="Failure">A credential/throttle failure from the exchange that must be reported, not retried with Basic.</param>
|
|
internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure);
|
|
|
|
/// <summary>
|
|
/// Returns a usable bearer token for this client's credentials, exchanging at
|
|
/// <c>/auth/token</c> if none is cached — used by the debug-stream hub, which cannot replay a
|
|
/// rejected handshake. <see cref="BearerResult.Token"/> is null when the caller should fall
|
|
/// back to Basic; <see cref="BearerResult.Failure"/> is set for bad credentials or a lockout.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancels the exchange.</param>
|
|
/// <returns>The token, a fall-back-to-Basic signal, or a failure to report.</returns>
|
|
internal async Task<BearerResult> TryGetBearerTokenAsync(CancellationToken cancellationToken)
|
|
{
|
|
var auth = await AcquireAuthAsync(forceExchange: false, refusedToken: null, cancellationToken).ConfigureAwait(false);
|
|
if (auth.Failure is not null)
|
|
{
|
|
var failure = await ToManagementResponseAsync(auth.Failure, cancellationToken).ConfigureAwait(false);
|
|
auth.Failure.Dispose();
|
|
return new BearerResult(null, failure);
|
|
}
|
|
|
|
return new BearerResult(auth.Header.Scheme == "Bearer" ? auth.Header.Parameter : null, null);
|
|
}
|
|
|
|
private readonly record struct AuthChoice(AuthenticationHeaderValue Header, bool FreshlyMinted, HttpResponseMessage? Failure);
|
|
|
|
/// <summary>
|
|
/// Picks the Authorization header for the next request: the cached bearer token, a freshly
|
|
/// exchanged one, or Basic. A non-null <see cref="AuthChoice.Failure"/> is the exchange's own
|
|
/// 401/429 response, to be surfaced to the caller as-is.
|
|
/// </summary>
|
|
/// <param name="forceExchange">Exchange even though a cached token looks usable (the server refused it).</param>
|
|
/// <param name="refusedToken">
|
|
/// The token the server just refused. If another caller has already replaced it by the time
|
|
/// the gate is acquired, that replacement is returned instead of exchanging again.
|
|
/// </param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
private async Task<AuthChoice> AcquireAuthAsync(bool forceExchange, string? refusedToken, CancellationToken ct)
|
|
{
|
|
var session = _session;
|
|
if (session is null || session.BasicOnly)
|
|
return new AuthChoice(_basic, false, null);
|
|
|
|
try
|
|
{
|
|
await session.Gate.WaitAsync(ct).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException ex) when (ex is not TaskCanceledException)
|
|
{
|
|
// Callers map TaskCanceledException to their TIMEOUT result, as for the send itself.
|
|
throw new TaskCanceledException(ex.Message, ex, ct);
|
|
}
|
|
|
|
try
|
|
{
|
|
if (session.BasicOnly)
|
|
return new AuthChoice(_basic, false, null);
|
|
if (!forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow))
|
|
return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), false, null);
|
|
if (forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow)
|
|
&& !string.Equals(session.Token, refusedToken, StringComparison.Ordinal))
|
|
{
|
|
// A concurrent caller already re-exchanged: use its token (as freshly minted, so a
|
|
// second refusal falls back to Basic rather than exchanging yet again).
|
|
return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), true, null);
|
|
}
|
|
|
|
session.Token = null;
|
|
using var form = new FormUrlEncodedContent(
|
|
[
|
|
new KeyValuePair<string, string>("username", _username),
|
|
new KeyValuePair<string, string>("password", _password),
|
|
]);
|
|
var response = await _httpClient.PostAsync("auth/token", form, ct).ConfigureAwait(false);
|
|
|
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests)
|
|
return new AuthChoice(_basic, false, response);
|
|
|
|
using (response)
|
|
{
|
|
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
|
|
{
|
|
session.BasicOnly = true;
|
|
return new AuthChoice(_basic, false, null);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
return new AuthChoice(_basic, false, null); // transient: Basic for this call only
|
|
|
|
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
if (!TryParseToken(body, out var token, out var lifetime))
|
|
{
|
|
// An older server: /auth/token exists but no management surface takes its tokens.
|
|
session.BasicOnly = true;
|
|
return new AuthChoice(_basic, false, null);
|
|
}
|
|
|
|
session.Token = token;
|
|
var margin = lifetime > TokenRefreshMargin * 2 ? TokenRefreshMargin : TimeSpan.Zero;
|
|
session.RefreshAfter = DateTimeOffset.UtcNow + lifetime - margin;
|
|
return new AuthChoice(new AuthenticationHeaderValue("Bearer", token), true, null);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
session.Gate.Release();
|
|
}
|
|
}
|
|
|
|
private static bool TryParseToken(string body, out string token, out TimeSpan lifetime)
|
|
{
|
|
token = string.Empty;
|
|
lifetime = TimeSpan.FromMinutes(15);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(body);
|
|
var root = doc.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object
|
|
|| !root.TryGetProperty("management_bearer", out var bearer) || bearer.ValueKind != JsonValueKind.True
|
|
|| !root.TryGetProperty("access_token", out var accessToken) || accessToken.ValueKind != JsonValueKind.String
|
|
|| string.IsNullOrWhiteSpace(accessToken.GetString()))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
token = accessToken.GetString()!;
|
|
if (root.TryGetProperty("expires_in", out var expiresIn) && expiresIn.TryGetInt32(out var seconds) && seconds > 0)
|
|
lifetime = TimeSpan.FromSeconds(seconds);
|
|
return true;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a request built by <paramref name="buildRequest"/> with the chosen credentials,
|
|
/// applying the bearer refresh-once-then-Basic policy on a 401.
|
|
/// </summary>
|
|
private async Task<HttpResponseMessage> SendAuthenticatedAsync(
|
|
Func<HttpRequestMessage> buildRequest, HttpCompletionOption completion, CancellationToken ct)
|
|
{
|
|
var auth = await AcquireAuthAsync(forceExchange: false, refusedToken: null, ct).ConfigureAwait(false);
|
|
if (auth.Failure is not null)
|
|
return auth.Failure;
|
|
|
|
var response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
|
|
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
|
|
return response;
|
|
|
|
// The server refused our bearer token. A token cached from an earlier call may simply be
|
|
// stale (expired, signing key rotated): re-exchange once and retry.
|
|
if (!auth.FreshlyMinted)
|
|
{
|
|
response.Dispose();
|
|
auth = await AcquireAuthAsync(forceExchange: true, refusedToken: auth.Header.Parameter, ct).ConfigureAwait(false);
|
|
if (auth.Failure is not null)
|
|
return auth.Failure;
|
|
response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
|
|
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
|
|
return response;
|
|
}
|
|
|
|
// A token minted moments ago is still refused (e.g. the node that answered does not share
|
|
// the signing key): fall back to Basic and stop exchanging for this process.
|
|
response.Dispose();
|
|
response = await SendWithAsync(buildRequest, _basic, completion, ct).ConfigureAwait(false);
|
|
if (response.StatusCode != HttpStatusCode.Unauthorized && _session is not null)
|
|
{
|
|
await MarkBasicOnlyAsync(_session, ct).ConfigureAwait(false);
|
|
if (IsVerbose())
|
|
{
|
|
Console.Error.WriteLine(
|
|
"note: the server refused a freshly issued bearer token; using HTTP Basic for the rest of this run "
|
|
+ "(check that both central nodes share ScadaBridge:Security:JwtSigningKey).");
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Written under the session gate so it cannot interleave with an in-flight exchange.
|
|
private static async Task MarkBasicOnlyAsync(CliTokenStore.Session session, CancellationToken ct)
|
|
{
|
|
await session.Gate.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
session.BasicOnly = true;
|
|
session.Token = null;
|
|
}
|
|
finally
|
|
{
|
|
session.Gate.Release();
|
|
}
|
|
}
|
|
|
|
private static bool IsVerbose()
|
|
{
|
|
var value = Environment.GetEnvironmentVariable(VerboseEnvVar)?.Trim();
|
|
return value == "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private Task<HttpResponseMessage> SendWithAsync(
|
|
Func<HttpRequestMessage> buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct)
|
|
{
|
|
var request = buildRequest();
|
|
request.Headers.Authorization = header;
|
|
return _httpClient.SendAsync(request, completion, ct);
|
|
}
|
|
|
|
private static async Task<ManagementResponse> ToManagementResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
|
{
|
|
var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
if (response.IsSuccessStatusCode)
|
|
return new ManagementResponse((int)response.StatusCode, responseBody, null, null);
|
|
|
|
string? error;
|
|
string? code = null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
|
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
|
}
|
|
catch
|
|
{
|
|
error = responseBody;
|
|
}
|
|
|
|
return new ManagementResponse((int)response.StatusCode, null, error, code);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a management command to the management API.
|
|
/// </summary>
|
|
/// <param name="commandName">The command name to execute.</param>
|
|
/// <param name="payload">The command payload.</param>
|
|
/// <param name="timeout">The request timeout.</param>
|
|
/// <returns>A management response containing status and data.</returns>
|
|
public async Task<ManagementResponse> SendCommandAsync(string commandName, object payload, TimeSpan timeout)
|
|
{
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
|
|
var body = JsonSerializer.Serialize(new { command = commandName, payload },
|
|
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
|
|
|
HttpResponseMessage httpResponse;
|
|
try
|
|
{
|
|
httpResponse = await SendAuthenticatedAsync(
|
|
() => new HttpRequestMessage(HttpMethod.Post, "management")
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
},
|
|
HttpCompletionOption.ResponseContentRead, cts.Token);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
|
}
|
|
|
|
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
|
|
|
|
if (httpResponse.IsSuccessStatusCode)
|
|
{
|
|
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
|
|
}
|
|
|
|
// Parse error response
|
|
string? error = null;
|
|
string? code = null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
|
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
|
}
|
|
catch
|
|
{
|
|
error = responseBody;
|
|
}
|
|
|
|
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a plain HTTP <c>GET</c> against a REST endpoint (e.g. the audit
|
|
/// <c>/api/audit/query</c> endpoint) and returns the
|
|
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
|
|
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
|
|
/// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared.
|
|
/// </summary>
|
|
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
|
/// <param name="timeout">The request timeout.</param>
|
|
/// <returns>A management response containing status and data.</returns>
|
|
public async Task<ManagementResponse> SendGetAsync(string relativePath, TimeSpan timeout)
|
|
{
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
|
|
HttpResponseMessage httpResponse;
|
|
try
|
|
{
|
|
httpResponse = await SendAuthenticatedAsync(
|
|
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
|
HttpCompletionOption.ResponseContentRead, cts.Token);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
|
}
|
|
|
|
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
|
|
|
|
if (httpResponse.IsSuccessStatusCode)
|
|
{
|
|
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
|
|
}
|
|
|
|
string? error = null;
|
|
string? code = null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
|
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
|
}
|
|
catch
|
|
{
|
|
error = responseBody;
|
|
}
|
|
|
|
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a plain HTTP <c>POST</c> against a REST endpoint (e.g. the audit
|
|
/// maintenance endpoints) with a JSON body and returns the response. Unlike
|
|
/// <see cref="SendCommandAsync"/>, this does not wrap the call in the
|
|
/// <c>POST /management</c> command envelope — these are plain REST resources.
|
|
/// Authentication (bearer token or HTTP Basic) and the base address are shared.
|
|
/// </summary>
|
|
/// <param name="relativePath">Path relative to the base URL.</param>
|
|
/// <param name="body">The JSON body to send, or <c>null</c> for an empty body.</param>
|
|
/// <param name="timeout">The request timeout.</param>
|
|
/// <returns>A management response containing status and data.</returns>
|
|
public async Task<ManagementResponse> SendPostAsync(string relativePath, string? body, TimeSpan timeout)
|
|
{
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
|
|
HttpResponseMessage httpResponse;
|
|
try
|
|
{
|
|
httpResponse = await SendAuthenticatedAsync(
|
|
() => new HttpRequestMessage(HttpMethod.Post, relativePath)
|
|
{
|
|
Content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"),
|
|
},
|
|
HttpCompletionOption.ResponseContentRead, cts.Token);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
|
}
|
|
|
|
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
|
|
|
|
if (httpResponse.IsSuccessStatusCode)
|
|
{
|
|
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
|
|
}
|
|
|
|
string? error = null;
|
|
string? code = null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(responseBody);
|
|
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
|
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
|
}
|
|
catch
|
|
{
|
|
error = responseBody;
|
|
}
|
|
|
|
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a plain HTTP <c>GET</c> and returns the raw <see cref="HttpResponseMessage"/>
|
|
/// so the caller can stream the response body without buffering it in memory — used
|
|
/// by <c>audit export</c>, where the response can be many megabytes. The caller owns
|
|
/// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/>
|
|
/// option ensures the body is not pre-buffered.
|
|
/// </summary>
|
|
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
|
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
|
/// <returns>The raw HTTP response message for streaming.</returns>
|
|
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
|
|
=> await SendAuthenticatedAsync(
|
|
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
|
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Disposes the underlying HTTP client.
|
|
/// </summary>
|
|
public void Dispose() => _httpClient.Dispose();
|
|
}
|
|
|
|
public record ManagementResponse(int StatusCode, string? JsonData, string? Error, string? ErrorCode);
|