feat(inbound-api): standby central node relays POST /api/* to the active peer instead of 503
A caller addressing a standby central node directly (or hitting Traefik inside its /health/active probe window after a failover) used to get 503 STANDBY_NODE. The standby now relays the request verbatim to the active peer over HTTP (IStandbyRequestForwarder / HttpStandbyRequestForwarder) and replays the response; method scripts and Route.To() still execute only on the active node. - ScadaBridge:InboundApi:StandbyForwardPeerEndpoints (static list of the OTHER central node's HTTP base URL; empty = old 503 behaviour) + StandbyForwardTimeout (60s), both startup-validated. - Peers tried in order: peer 503 stamped X-ScadaBridge-Standby, connection failure or timeout → next peer; all fail → 503 ACTIVE_NODE_UNREACHABLE. A 503 without the marker is the active node's own answer and is replayed. - One-hop loop guard via X-ScadaBridge-Forwarded; 413 body cap now runs BEFORE the active-node check so a standby never buffers/relays an oversized body. - AuditWriteMiddleware skips its own ApiInbound row for a relayed request so a call is audited once, on the node that executed it. - Rig + env2 central nodes configured; spec (Component-InboundAPI.md → Standby forwarding), Component-Host.md options row, CLAUDE.md updated. - 20 new tests (filter decision + relay mechanics + validator). Claude-Session: https://claude.ai/code/session_019ZxXT5sxZs5XrtdwYzJDPV
This commit is contained in:
@@ -12,17 +12,32 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// Active-node gating. The inbound API is central-active-node-only;
|
||||
/// a standby node returns HTTP 503 so it never executes method scripts.
|
||||
/// 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>
|
||||
/// Request body size cap. Oversized bodies are rejected with HTTP
|
||||
/// 413 before being buffered into a <c>JsonDocument</c>.
|
||||
/// 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;
|
||||
|
||||
@@ -37,32 +52,20 @@ public sealed class InboundApiEndpointFilter : IEndpointFilter
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>Applies active-node gating and request body size checks before delegating to the next filter or handler.</summary>
|
||||
/// <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, or an error result if gating fails.</returns>
|
||||
/// <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;
|
||||
|
||||
// Refuse to serve the inbound API on a standby central node.
|
||||
// The gate is optional — when no IActiveNodeGate is registered (non-clustered
|
||||
// host / tests) the API is served, preserving prior behaviour.
|
||||
var gate = httpContext.RequestServices.GetService<IActiveNodeGate>();
|
||||
if (gate is { IsActiveNode: false })
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Inbound API request rejected — this node is a standby (not the active central node)");
|
||||
return Results.Json(
|
||||
new { error = "Inbound API is only available on the active central node", code = "STANDBY_NODE" },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
// Cap the request body size. Reject an over-limit body up
|
||||
// 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.
|
||||
// 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)
|
||||
{
|
||||
@@ -80,6 +83,33 @@ public sealed class InboundApiEndpointFilter : IEndpointFilter
|
||||
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 })
|
||||
{
|
||||
httpContext.Items[RelayedToActiveItemKey] = true;
|
||||
_logger.LogDebug("Inbound API request on a standby node — relaying to the active peer");
|
||||
return await forwarder.ForwardAsync(httpContext, httpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_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");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user