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();
}
}