using System.Text; 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 /// (perf remediation, arch-review WP1.5) — 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 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), // 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) ? 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; private 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. Parse command from request body string body; using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8)) { body = await reader.ReadToEndAsync(); } var parse = ParseCommand(body); 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. The parsed /// is disposed deterministically and the missing-payload /// case does not allocate a throwaway document. /// /// The raw JSON request body string. /// A with the deserialized command on success, or an error on failure. public static CommandParseResult ParseCommand(string body) { using JsonDocument doc = ParseDocument(body, out var parseError); if (parseError != null) return CommandParseResult.Fail(parseError); if (!doc.RootElement.TryGetProperty("command", out var commandNameElement)) return CommandParseResult.Fail("Missing 'command' field."); var commandName = commandNameElement.GetString(); if (string.IsNullOrWhiteSpace(commandName)) return CommandParseResult.Fail("Empty 'command' field."); var commandType = ManagementCommandRegistry.Resolve(commandName); if (commandType == null) return CommandParseResult.Fail($"Unknown command: '{commandName}'."); try { // Missing payload: deserialize from the empty-object literal rather than // allocating (and leaking) a throwaway JsonDocument. var payloadJson = doc.RootElement.TryGetProperty("payload", out var p) ? p.GetRawText() : "{}"; var command = JsonSerializer.Deserialize(payloadJson, commandType, CommandPayloadJsonOptions)!; return CommandParseResult.Ok(command); } catch (Exception ex) { return CommandParseResult.Fail($"Failed to deserialize payload: {ex.Message}"); } } private static JsonDocument ParseDocument(string body, out string? error) { try { error = null; return JsonDocument.Parse(body); } catch { error = "Invalid JSON body."; return JsonDocument.Parse("{}"); } } }