9a87d7a328
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
250 lines
12 KiB
C#
250 lines
12 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// Shared options for the per-request command payload JSON parse in
|
|
/// <see cref="ParseCommand"/> (perf remediation, arch-review WP1.5) — avoids
|
|
/// allocating a new <see cref="JsonSerializerOptions"/> per inbound management
|
|
/// request. Settings preserved exactly.
|
|
/// </summary>
|
|
private static readonly JsonSerializerOptions CommandPayloadJsonOptions =
|
|
new() { PropertyNameCaseInsensitive = true };
|
|
|
|
/// <summary>
|
|
/// Resolves the ManagementActor Ask timeout from configuration.
|
|
/// Falls back to <see cref="DefaultAskTimeout"/>
|
|
/// when options are absent or the configured value is not strictly positive — a
|
|
/// zero/negative timeout would make every management call fail immediately.
|
|
/// </summary>
|
|
/// <param name="options">The management service options, or <c>null</c> if not configured.</param>
|
|
/// <returns>The configured timeout, or <see cref="DefaultAskTimeout"/> when none is set.</returns>
|
|
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);
|
|
|
|
/// <summary>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.</summary>
|
|
private static readonly HashSet<Type> 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),
|
|
];
|
|
|
|
/// <summary>
|
|
/// Resolves the ManagementActor Ask timeout for a specific command type. Long-running
|
|
/// transport/deploy commands use <see cref="ManagementServiceOptions.LongRunningCommandTimeout"/>
|
|
/// (default 5 min); all other commands fall through to <see cref="ResolveAskTimeout(ManagementServiceOptions?)"/>.
|
|
/// </summary>
|
|
/// <param name="options">The management service options, or <c>null</c> if not configured.</param>
|
|
/// <param name="commandType">The runtime type of the command being dispatched.</param>
|
|
/// <returns>The resolved Ask timeout for the command.</returns>
|
|
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);
|
|
|
|
/// <summary>Registers the <c>POST /management</c> endpoint on the given route builder.</summary>
|
|
/// <param name="endpoints">The route builder to add the endpoint to.</param>
|
|
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
|
|
public static IEndpointRouteBuilder MapManagementAPI(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
endpoints.MapPost("/management", (Delegate)HandleRequest);
|
|
return endpoints;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024;
|
|
|
|
private static async Task<IResult> HandleRequest(HttpContext context)
|
|
{
|
|
var logger = context.RequestServices.GetRequiredService<ILogger<ManagementActorHolder>>();
|
|
|
|
// 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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
|
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<ManagementActorHolder>();
|
|
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<IOptions<ManagementServiceOptions>>()?.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)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of parsing a management request body into a strongly-typed command.
|
|
/// </summary>
|
|
public readonly record struct CommandParseResult(
|
|
bool Success, object? Command, string? ErrorMessage, string? ErrorCode)
|
|
{
|
|
/// <summary>Creates a successful parse result wrapping the given command.</summary>
|
|
/// <param name="command">The strongly-typed command object that was parsed.</param>
|
|
/// <returns>A successful <see cref="CommandParseResult"/> containing the parsed command.</returns>
|
|
public static CommandParseResult Ok(object command) => new(true, command, null, null);
|
|
/// <summary>Creates a failed parse result with the given error message.</summary>
|
|
/// <param name="message">Human-readable description of the parse failure.</param>
|
|
/// <returns>A failed <see cref="CommandParseResult"/> with the error message and <c>BAD_REQUEST</c> code.</returns>
|
|
public static CommandParseResult Fail(string message) => new(false, null, message, "BAD_REQUEST");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a management request body — a JSON object with a <c>command</c> name and an
|
|
/// optional <c>payload</c> — into the strongly-typed command record. The parsed
|
|
/// <see cref="JsonDocument"/> is disposed deterministically and the missing-payload
|
|
/// case does not allocate a throwaway document.
|
|
/// </summary>
|
|
/// <param name="body">The raw JSON request body string.</param>
|
|
/// <returns>A <see cref="CommandParseResult"/> with the deserialized command on success, or an error on failure.</returns>
|
|
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("{}");
|
|
}
|
|
}
|
|
}
|