using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.CLI; /// /// HTTP client for the central management surfaces (POST /management, the audit REST /// endpoints). Authenticates with a bearer token exchanged once per process at /// POST /auth/token, falling back to HTTP Basic when the server does not offer bearer /// tokens (review 2026-10-21 05/U1, 05/P1). /// /// /// /// Bearer flow. The first call a process makes for a given URL + credential pair posts /// the credentials to /auth/token (one LDAP bind) and keeps the returned JWT in memory /// (; never persisted or logged). Every later call — from this or any /// other client instance in the same process — sends Authorization: Bearer, which the /// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call /// answered 401 re-exchanges once (unless the token was minted for that very call) and, /// if still refused, retries with Basic. /// /// /// Basic fallback. A server whose /auth/token is absent (404/405), disabled /// (login-disabled deployment, 404) or does not advertise management_bearer: true (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 (401) and a throttle lockout (429) from the exchange are /// returned as-is — retrying them with Basic would only add a second failed bind against the /// login throttle. /// /// /// SCADABRIDGE_AUTH_MODE=basic disables the exchange (Basic on every call). /// /// public class ManagementHttpClient : IDisposable { /// Environment variable selecting the auth mode: token (default) or basic. public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE"; /// /// Environment variable that, when 1/true, 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. /// public const string VerboseEnvVar = "SCADABRIDGE_VERBOSE"; /// Re-exchange a token this long before its stated expiry. 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; /// /// 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. /// /// /// It is deliberately NOT an overall request timeout. /// caps the whole request/response, so setting /// it to any fixed value silently truncates every caller whose own per-call /// TimeSpan timeout argument is longer: the effective deadline becomes /// min(HttpClient.Timeout, caller timeout). That is exactly what a 30 s /// client timeout did to deploy site's 5-minute bulk deploy and to the /// five-minute bundle export/preview/import calls — each printed a fake /// 504 Request timed out at 30 s while the server carried on working. /// is therefore /// and the per-call /// in /// // /// is the SINGLE overall deadline — it bounds connect too, since the token is /// passed into the send itself. /// /// /// /// The connect bound lives on /// instead, which is connect-scoped and so cannot truncate a long-running /// request that has already reached the server. Overridable via the /// SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS environment variable, /// consistent with how every other CLI setting is overridden (see /// ) — kept self-contained here (no /// /command-file plumbing) since CLI commands are owned /// by a separate work package this phase. /// /// public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(30); /// Test seam — the effective this instance was constructed with. internal TimeSpan EffectiveTimeout { get; } /// /// Resolves the effective connect timeout: the /// SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS environment variable when set /// to a positive integer, otherwise . /// /// The connect timeout to apply to the socket handler. 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; } /// /// Initializes a new instance of the class with /// an INFINITE (each call supplies its own /// deadline) over a whose /// is /// . /// /// The base URL for the management API. /// The username for HTTP Basic authentication. /// The password for HTTP Basic authentication. public ManagementHttpClient(string baseUrl, string username, string password) : this( CreateDefaultHttpClient(), baseUrl, username, password, IsBasicAuthModeForced() ? null : CliTokenStore.Shared) { } /// /// Builds the production : infinite overall timeout (each call /// carries its own deadline), connect bounded by , and /// redirects NOT followed — a 307/308 from a proxy would otherwise re-POST the /// /auth/token form, i.e. the credentials, to wherever the redirect points. /// /// The configured client. internal static HttpClient CreateDefaultHttpClient() => new(new SocketsHttpHandler { ConnectTimeout = ResolveConnectTimeout(), AllowAutoRedirect = false }) { Timeout = Timeout.InfiniteTimeSpan }; /// /// True when is basic, which disables the token /// exchange for the process. /// /// Whether HTTP Basic is forced on every call. internal static bool IsBasicAuthModeForced() => string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase); /// /// Test-only constructor that accepts a pre-built (typically /// over a stub ) so the request/response handling can /// be exercised without a live server. /// /// The HTTP client to use for requests. /// The base URL for the management API. /// The username for HTTP Basic authentication. /// The password for HTTP Basic authentication. /// /// Bearer-token store to exchange credentials through; (the default) /// sends HTTP Basic on every call, which keeps stub-handler tests to a single request. /// 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); } /// The HTTP Basic credential value (base64 user:password), for the debug-stream hub fallback. internal string BasicCredentials => _basic.Parameter!; /// Outcome of . /// The bearer token, or when the caller should use Basic. /// A credential/throttle failure from the exchange that must be reported, not retried with Basic. internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure); /// /// Returns a usable bearer token for this client's credentials, exchanging at /// /auth/token if none is cached — used by the debug-stream hub, which cannot replay a /// rejected handshake. is null when the caller should fall /// back to Basic; is set for bad credentials or a lockout. /// /// Cancels the exchange. /// The token, a fall-back-to-Basic signal, or a failure to report. internal async Task 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); /// /// Picks the Authorization header for the next request: the cached bearer token, a freshly /// exchanged one, or Basic. A non-null is the exchange's own /// 401/429 response, to be surfaced to the caller as-is. /// /// Exchange even though a cached token looks usable (the server refused it). /// /// 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. /// /// Cancellation token. private async Task 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("username", _username), new KeyValuePair("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; } } /// /// Sends a request built by with the chosen credentials, /// applying the bearer refresh-once-then-Basic policy on a 401. /// private async Task SendAuthenticatedAsync( Func 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 SendWithAsync( Func buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct) { var request = buildRequest(); request.Headers.Authorization = header; return _httpClient.SendAsync(request, completion, ct); } private static async Task 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); } /// /// Sends a management command to the management API. /// /// The command name to execute. /// The command payload. /// The request timeout. /// A management response containing status and data. public async Task 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); } /// /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit /// /api/audit/query endpoint) and returns the /// response body. Unlike , this does not wrap the call /// in the POST /management command envelope — the audit endpoints are plain /// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared. /// /// Path relative to the base URL, with query string. /// The request timeout. /// A management response containing status and data. public async Task 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); } /// /// Issues a plain HTTP POST against a REST endpoint (e.g. the audit /// maintenance endpoints) with a JSON body and returns the response. Unlike /// , this does not wrap the call in the /// POST /management command envelope — these are plain REST resources. /// Authentication (bearer token or HTTP Basic) and the base address are shared. /// /// Path relative to the base URL. /// The JSON body to send, or null for an empty body. /// The request timeout. /// A management response containing status and data. public async Task 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); } /// /// Issues a plain HTTP GET and returns the raw /// so the caller can stream the response body without buffering it in memory — used /// by audit export, where the response can be many megabytes. The caller owns /// disposing the returned message. The /// option ensures the body is not pre-buffered. /// /// Path relative to the base URL, with query string. /// A cancellation token that can be used to cancel the operation. /// The raw HTTP response message for streaming. public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken) => await SendAuthenticatedAsync( () => new HttpRequestMessage(HttpMethod.Get, relativePath), HttpCompletionOption.ResponseHeadersRead, cancellationToken); /// /// Disposes the underlying HTTP client. /// public void Dispose() => _httpClient.Dispose(); } public record ManagementResponse(int StatusCode, string? JsonData, string? Error, string? ErrorCode);