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.
120 lines
6.1 KiB
C#
120 lines
6.1 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Http.Features;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|
|
|
/// <summary>
|
|
/// Endpoint filter applied to <c>POST /api/{methodName}</c> that enforces two
|
|
/// cross-cutting guards before the request handler runs:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><description>
|
|
/// Request body size cap. Oversized bodies are rejected with HTTP
|
|
/// 413 before being buffered into a <c>JsonDocument</c> — and before any relay,
|
|
/// so a standby never buffers or forwards a body the active node would refuse.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// Active-node gating. Method scripts execute only on the active central node.
|
|
/// A standby RELAYS the request to the active peer through
|
|
/// <see cref="IStandbyRequestForwarder"/> when peers are configured
|
|
/// (<see cref="InboundApiOptions.StandbyForwardPeerEndpoints"/>), and otherwise
|
|
/// returns HTTP 503 <c>STANDBY_NODE</c>. A request that already carries
|
|
/// <see cref="HttpStandbyRequestForwarder.ForwardedRequestHeader"/> is never
|
|
/// relayed again (one-hop loop guard) — it gets the 503, stamped with
|
|
/// <see cref="HttpStandbyRequestForwarder.StandbyResponseHeader"/> so the relaying
|
|
/// peer knows to try its next endpoint.
|
|
/// </description></item>
|
|
/// </list>
|
|
/// </summary>
|
|
public sealed class InboundApiEndpointFilter : IEndpointFilter
|
|
{
|
|
/// <summary>
|
|
/// <see cref="HttpContext.Items"/> key set when this node relayed the request to the
|
|
/// active peer. <c>AuditWriteMiddleware</c> reads it to skip its own audit row (the
|
|
/// active node audits the call it executed).
|
|
/// </summary>
|
|
public const string RelayedToActiveItemKey = "ScadaBridge.InboundApi.RelayedToActive";
|
|
|
|
private readonly ILogger<InboundApiEndpointFilter> _logger;
|
|
private readonly IOptionsMonitor<InboundApiOptions> _options;
|
|
|
|
/// <summary>Initializes a new <see cref="InboundApiEndpointFilter"/> with the given logger and options.</summary>
|
|
/// <param name="logger">Logger for request rejection diagnostics.</param>
|
|
/// <param name="options">Live-reloadable inbound API options, read per-request.</param>
|
|
public InboundApiEndpointFilter(
|
|
ILogger<InboundApiEndpointFilter> logger,
|
|
IOptionsMonitor<InboundApiOptions> options)
|
|
{
|
|
_logger = logger;
|
|
_options = options;
|
|
}
|
|
|
|
/// <summary>Applies the request body size check and active-node gating/relay before delegating to the next filter or handler.</summary>
|
|
/// <param name="context">The endpoint filter invocation context containing the HTTP context and arguments.</param>
|
|
/// <param name="next">The next filter or endpoint handler in the pipeline.</param>
|
|
/// <returns>A value task that resolves to the filter pipeline result, a relayed peer response, or an error result if gating fails.</returns>
|
|
public async ValueTask<object?> InvokeAsync(
|
|
EndpointFilterInvocationContext context,
|
|
EndpointFilterDelegate next)
|
|
{
|
|
var httpContext = context.HttpContext;
|
|
|
|
// Cap the request body size FIRST. Reject an over-limit body up
|
|
// front via Content-Length; also lower the per-request max body size so a
|
|
// chunked/unknown-length stream is cut off by Kestrel as it is read. Runs
|
|
// before the active-node check so a standby never relays an oversized body.
|
|
var maxBytes = _options.CurrentValue.MaxRequestBodyBytes;
|
|
if (httpContext.Request.ContentLength is { } declaredLength && declaredLength > maxBytes)
|
|
{
|
|
_logger.LogWarning(
|
|
"Inbound API request rejected — body length {Length} exceeds limit {Limit}",
|
|
declaredLength, maxBytes);
|
|
return Results.Json(
|
|
new { error = "Request body too large", code = "BODY_TOO_LARGE" },
|
|
statusCode: StatusCodes.Status413PayloadTooLarge);
|
|
}
|
|
|
|
var sizeFeature = httpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
|
if (sizeFeature is { IsReadOnly: false })
|
|
{
|
|
sizeFeature.MaxRequestBodySize = maxBytes;
|
|
}
|
|
|
|
// Active-node gating. The gate is optional — when no IActiveNodeGate is
|
|
// registered (non-clustered host / tests) the API is served locally.
|
|
var gate = httpContext.RequestServices.GetService<IActiveNodeGate>();
|
|
if (gate is { IsActiveNode: false })
|
|
{
|
|
var alreadyForwarded = httpContext.Request.Headers
|
|
.ContainsKey(HttpStandbyRequestForwarder.ForwardedRequestHeader);
|
|
var forwarder = httpContext.RequestServices.GetService<IStandbyRequestForwarder>();
|
|
|
|
if (!alreadyForwarded && forwarder is { IsConfigured: true })
|
|
{
|
|
_logger.LogDebug("Inbound API request on a standby node — relaying to the active peer");
|
|
var result = await forwarder.ForwardAsync(httpContext, httpContext.RequestAborted).ConfigureAwait(false);
|
|
if (result is HttpStandbyRequestForwarder.RelayedResult)
|
|
httpContext.Items[RelayedToActiveItemKey] = true;
|
|
return result;
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
alreadyForwarded
|
|
? "Inbound API request rejected — relayed here by a peer, but this node is also a standby (loop guard)"
|
|
: "Inbound API request rejected — this node is a standby (not the active central node) and no relay peers are configured");
|
|
// A verified internal refusal has not served the call. Its originating relay
|
|
// owns the single failure audit if all peers refuse; unsigned callers cannot suppress it.
|
|
if (RelayProvenance.IsVerified(httpContext)) httpContext.Items[RelayedToActiveItemKey] = true;
|
|
httpContext.Response.Headers[HttpStandbyRequestForwarder.StandbyResponseHeader] = "1";
|
|
return Results.Json(
|
|
new { error = "Inbound API is only available on the active central node", code = "STANDBY_NODE" },
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
return await next(context);
|
|
}
|
|
}
|