using System.Text.Json; using Akka.Actor; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.ManagementService; public static class ManagementEndpoints { private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromSeconds(30); /// /// Shared options for the per-request command payload JSON parse in /// — avoids /// allocating a new per inbound management /// request. Settings preserved exactly. /// private static readonly JsonSerializerOptions CommandPayloadJsonOptions = new() { PropertyNameCaseInsensitive = true }; /// /// Resolves the ManagementActor Ask timeout from configuration. /// Falls back to /// when options are absent or the configured value is not strictly positive — a /// zero/negative timeout would make every management call fail immediately. /// /// The management service options, or null if not configured. /// The configured timeout, or when none is set. public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options) { if (options is { CommandTimeout: { Ticks: > 0 } configured }) return configured; return DefaultAskTimeout; } private static readonly TimeSpan DefaultLongRunningTimeout = TimeSpan.FromMinutes(5); /// Transport/deploy commands legitimately run for minutes (the CLI already /// uses a 5-minute client timeout for bundles); a 30 s server-side Ask 504s while the /// piped ProcessCommand keeps applying — the caller then retries and double-applies /// (arch-review S1). These command types get LongRunningCommandTimeout instead. private static readonly HashSet LongRunningCommands = [ typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand), typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand), // A bulk site (or area-subtree) deploy is by construction the longest command in the set — // it is N instance deploys, so it must never inherit the ordinary Ask // timeout or the caller would 504 while the batch is still applying. typeof(MgmtDeploySiteCommand), typeof(MgmtDeployAreaCommand), // Flow Debugger (design 2026-09-05 §7.1): a Live run lasts up to the method timeout // (+ grace) or the value-change wait window — never the ordinary Ask timeout. typeof(FlowDebugRunMethodCommand), typeof(FlowDebugRunValueChangeCommand), // script entry (design 2026-09-08): a direct call lasts up to the script's timeout // (+ grace), a write kind up to its wait window. typeof(FlowDebugRunScriptCommand), ]; /// /// Resolves the ManagementActor Ask timeout for a specific command type. Long-running /// transport/deploy commands use /// (default 5 min); all other commands fall through to . /// /// The management service options, or null if not configured. /// The runtime type of the command being dispatched. /// The resolved Ask timeout for the command. public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType) => LongRunningCommands.Contains(commandType) ? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout) + (commandType == typeof(MgmtDeploySiteCommand) || commandType == typeof(MgmtDeployAreaCommand) ? TimeSpan.FromMinutes(1) : TimeSpan.Zero) : ResolveAskTimeout(options); /// Registers the POST /management endpoint on the given route builder. /// The route builder to add the endpoint to. /// The same instance for chaining. public static IEndpointRouteBuilder MapManagementAPI(this IEndpointRouteBuilder endpoints) { endpoints.MapPost("/management", (Delegate)HandleRequest); return endpoints; } /// /// Per-request body-size ceiling for the management endpoint. ASP.NET Core's /// default cap is ~30 MB and would reject Transport Import calls -- a /// 100 MB raw bundle base64-inflates to ~140 MB plus envelope. 200 MB is /// comfortable without going unbounded. /// private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024; /// /// Bodies above this size (or of unknown size) must hold . /// Ordinary management commands are a few KB; only Transport bundle import/preview /// carries a multi-megabyte base64 payload. /// internal const long LargeRequestThresholdBytes = 16L * 1024 * 1024; /// /// Single-flight gate for LARGE management requests at the HTTP edge (review 2026-10-21 /// 05/M2). The actor's TransportGate serialises the bundle handlers, but it is /// reached only after the endpoint has already parsed the body — so concurrent imports /// each materialised their payload here and then pinned it while queued at the actor /// gate. Acquired with Wait(0) BEFORE the body is read and held until the actor /// replies; a second large request is refused with 409 IMPORT_IN_PROGRESS instead /// of queueing another copy. Per node (static), like TransportGate. /// internal static readonly SemaphoreSlim LargeRequestGate = new(1, 1); internal static async Task HandleRequest(HttpContext context) { var logger = context.RequestServices.GetRequiredService>(); // 0. Raise the per-request body-size cap before any body is read. // The feature is only writable before the request body has been touched. var maxBodyFeature = context.Features.Get(); if (maxBodyFeature is { IsReadOnly: false }) { maxBodyFeature.MaxRequestBodySize = MaxManagementRequestBodyBytes; } // 1-3. Authenticate: dev/test DisableLogin bypass → else HTTP Basic → LDAP → roles. // Centralised in ManagementAuthenticator so every CLI surface honours DisableLogin // identically — the cookie auto-login (AutoLoginAuthenticationHandler) only covers the // interactive UI, not this Basic-Auth surface. var auth = await ManagementAuthenticator.AuthenticateAsync(context); if (auth.Failure is not null) { return auth.Failure; } var authenticatedUser = auth.User!; // 4. Large bodies (bundle import/preview) are single-flight per node, decided // BEFORE the body is read so a refused request never materialises its payload // (review 2026-10-21 05/M2). Unknown length (chunked) counts as large. var isLarge = context.Request.ContentLength is not { } length || length > LargeRequestThresholdBytes; if (isLarge && !LargeRequestGate.Wait(0)) { return Results.Json(new { error = "Another large management request (a bundle import or preview) is already in progress on this node. Retry when it completes.", code = "IMPORT_IN_PROGRESS", }, statusCode: 409); } try { return await ParseAndDispatchAsync(context, authenticatedUser, logger); } finally { if (isLarge) LargeRequestGate.Release(); } } private static async Task ParseAndDispatchAsync( HttpContext context, AuthenticatedUser authenticatedUser, ILogger logger) { // Parse the command straight off the request stream: one UTF-8 pass into the typed // command, no ReadToEnd string / JsonDocument / GetRawText copies (review 2026-10-21 05/M2). var parse = await ParseCommandAsync(context.Request.Body, context.RequestAborted); if (!parse.Success) { return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400); } var command = parse.Command!; // 5. Dispatch to ManagementActor var holder = context.RequestServices.GetRequiredService(); if (holder.ActorRef == null) { return Results.Json(new { error = "Management service not ready.", code = "SERVICE_UNAVAILABLE" }, statusCode: 503); } var correlationId = Guid.NewGuid().ToString("N"); // Carry the caller's remote IP so the Transport bundle handlers can enforce // the per-IP-per-hour unlock throttle (register row 44). ForwardedHeaders has // already rewritten this from X-Forwarded-For for CONFIGURED proxies only // (Program.cs), so it is a real client address in the shipped topologies and // the proxy's address behind an untrusted one. var envelope = new ManagementEnvelope( authenticatedUser, command, correlationId, context.Connection.RemoteIpAddress?.ToString()); var askTimeout = ResolveAskTimeout( context.RequestServices.GetService>()?.Value, command.GetType()); object response; try { response = await holder.ActorRef.Ask(envelope, askTimeout); } catch (Exception ex) { logger.LogError(ex, "ManagementActor Ask timed out or failed (CorrelationId={CorrelationId})", correlationId); return Results.Json(new { error = "Request timed out.", code = "TIMEOUT" }, statusCode: 504); } // 6. Map response return response switch { ManagementSuccess success => Results.Text(success.JsonData, "application/json", statusCode: 200), ManagementError error => Results.Json(new { error = error.Error, code = error.ErrorCode }, statusCode: 400), ManagementUnauthorized unauth => Results.Json(new { error = unauth.Message, code = "UNAUTHORIZED" }, statusCode: 403), _ => Results.Json(new { error = "Unexpected response.", code = "INTERNAL_ERROR" }, statusCode: 500) }; } /// /// Result of parsing a management request body into a strongly-typed command. /// public readonly record struct CommandParseResult( bool Success, object? Command, string? ErrorMessage, string? ErrorCode) { /// Creates a successful parse result wrapping the given command. /// The strongly-typed command object that was parsed. /// A successful containing the parsed command. public static CommandParseResult Ok(object command) => new(true, command, null, null); /// Creates a failed parse result with the given error message. /// Human-readable description of the parse failure. /// A failed with the error message and BAD_REQUEST code. public static CommandParseResult Fail(string message) => new(false, null, message, "BAD_REQUEST"); } /// /// Parses a management request body — a JSON object with a command name and an /// optional payload — into the strongly-typed command record. Shares /// 's single-pass reader, so string and stream callers get /// identical results. /// /// The raw JSON request body string. /// A with the deserialized command on success, or an error on failure. public static CommandParseResult ParseCommand(string body) { try { return Complete(JsonSerializer.Deserialize(body, EnvelopeJsonOptions)); } catch (Exception ex) { return Failure(ex); } } /// /// Stream variant of used by the HTTP endpoint: the body /// is read once as UTF-8 and the payload is deserialised directly into the command type as /// soon as command has been seen (the CLI sends command first). A payload that /// precedes command is buffered as a and deserialised after. /// /// The request body stream. /// Cancels the read. /// A with the deserialized command on success, or an error on failure. public static async Task ParseCommandAsync(Stream body, CancellationToken cancellationToken = default) { try { return Complete(await JsonSerializer.DeserializeAsync(body, EnvelopeJsonOptions, cancellationToken)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { return Failure(ex); } } private static readonly JsonSerializerOptions EnvelopeJsonOptions = new() { Converters = { new CommandRequestBodyConverter() }, }; private static CommandParseResult Complete(CommandRequestBody? parsed) { if (parsed is null || !parsed.HasCommand) return CommandParseResult.Fail("Missing 'command' field."); if (string.IsNullOrWhiteSpace(parsed.CommandName)) return CommandParseResult.Fail("Empty 'command' field."); if (parsed.CommandType is null) return CommandParseResult.Fail($"Unknown command: '{parsed.CommandName}'."); try { // Payload was either deserialised in-stream, buffered because it preceded the // command name, or absent (deserialise the empty object). var command = parsed.Command ?? (parsed.DeferredPayload is { } deferred ? deferred.Deserialize(parsed.CommandType, CommandPayloadJsonOptions) : JsonSerializer.Deserialize("{}", parsed.CommandType, CommandPayloadJsonOptions)); return CommandParseResult.Ok(command!); } catch (Exception ex) { return CommandParseResult.Fail($"Failed to deserialize payload: {ex.Message}"); } } private static CommandParseResult Failure(Exception ex) => ex is PayloadDeserializationException payload ? CommandParseResult.Fail($"Failed to deserialize payload: {payload.InnerException?.Message ?? payload.Message}") : CommandParseResult.Fail("Invalid JSON body."); /// The top-level request shape as read by . private sealed class CommandRequestBody { public bool HasCommand { get; set; } public string? CommandName { get; set; } public Type? CommandType { get; set; } public object? Command { get; set; } public JsonElement? DeferredPayload { get; set; } } /// Distinguishes a bad payload from malformed JSON in the parse error. private sealed class PayloadDeserializationException(Exception inner) : Exception(inner.Message, inner); /// /// Reads {"command": "...", "payload": {...}} in one pass. Property names are /// matched case-sensitively, as the previous JsonDocument.TryGetProperty parse did; /// unknown top-level properties are skipped. /// private sealed class CommandRequestBodyConverter : System.Text.Json.Serialization.JsonConverter { public override CommandRequestBody Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Expected a JSON object."); var result = new CommandRequestBody(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) return result; if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException("Expected a property name."); var isCommand = reader.ValueTextEquals("command"); var isPayload = !isCommand && reader.ValueTextEquals("payload"); reader.Read(); if (isCommand) { result.HasCommand = true; result.CommandName = reader.TokenType == JsonTokenType.String ? reader.GetString() : reader.TokenType == JsonTokenType.Null ? null : throw new JsonException("'command' must be a string."); result.CommandType = string.IsNullOrWhiteSpace(result.CommandName) ? null : ManagementCommandRegistry.Resolve(result.CommandName); } else if (isPayload && result.CommandType is not null) { try { result.Command = JsonSerializer.Deserialize(ref reader, result.CommandType, CommandPayloadJsonOptions); } catch (Exception ex) { throw new PayloadDeserializationException(ex); } result.DeferredPayload = null; } else if (isPayload) { result.DeferredPayload = JsonElement.ParseValue(ref reader); } else { reader.Skip(); } } throw new JsonException("Unterminated JSON object."); } public override void Write(Utf8JsonWriter writer, CommandRequestBody value, JsonSerializerOptions options) => throw new NotSupportedException(); } }