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; /// /// Endpoint filter applied to POST /api/{methodName} that enforces two /// cross-cutting guards before the request handler runs: /// /// /// /// Request body size cap. Oversized bodies are rejected with HTTP /// 413 before being buffered into a JsonDocument — and before any relay, /// so a standby never buffers or forwards a body the active node would refuse. /// /// /// Active-node gating. Method scripts execute only on the active central node. /// A standby RELAYS the request to the active peer through /// when peers are configured /// (), and otherwise /// returns HTTP 503 STANDBY_NODE. A request that already carries /// is never /// relayed again (one-hop loop guard) — it gets the 503, stamped with /// so the relaying /// peer knows to try its next endpoint. /// /// /// public sealed class InboundApiEndpointFilter : IEndpointFilter { /// /// key set when this node relayed the request to the /// active peer. AuditWriteMiddleware reads it to skip its own audit row (the /// active node audits the call it executed). /// public const string RelayedToActiveItemKey = "ScadaBridge.InboundApi.RelayedToActive"; private readonly ILogger _logger; private readonly IOptionsMonitor _options; /// Initializes a new with the given logger and options. /// Logger for request rejection diagnostics. /// Live-reloadable inbound API options, read per-request. public InboundApiEndpointFilter( ILogger logger, IOptionsMonitor options) { _logger = logger; _options = options; } /// Applies the request body size check and active-node gating/relay before delegating to the next filter or handler. /// The endpoint filter invocation context containing the HTTP context and arguments. /// The next filter or endpoint handler in the pipeline. /// A value task that resolves to the filter pipeline result, a relayed peer response, or an error result if gating fails. public async ValueTask 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(); 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(); if (gate is { IsActiveNode: false }) { var alreadyForwarded = httpContext.Request.Headers .ContainsKey(HttpStandbyRequestForwarder.ForwardedRequestHeader); var forwarder = httpContext.RequestServices.GetService(); 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); } }