fix(inbound-api): resolve InboundAPI-002,004,006,008 — disconnect vs timeout, body size limit, active-node gate; surface InboundAPI-007

This commit is contained in:
Joseph Doherty
2026-05-16 21:22:01 -04:00
parent 6563511b5f
commit da955042aa
10 changed files with 462 additions and 20 deletions

View File

@@ -18,7 +18,10 @@ public static class EndpointExtensions
{
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest);
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest)
// InboundAPI-006 / InboundAPI-008: active-node gating + request body
// size cap are enforced by the endpoint filter before the handler runs.
.AddEndpointFilter<InboundApiEndpointFilter>();
return endpoints;
}
@@ -86,6 +89,14 @@ public static class EndpointExtensions
if (!scriptResult.Success)
{
// InboundAPI-004: a client-aborted request is not a script failure.
// Do not pollute the failure log (reserved for genuine script errors)
// and do not attempt to write a 500 body to an already-gone connection.
if (httpContext.RequestAborted.IsCancellationRequested)
{
return Results.Empty;
}
// WP-5: 500 for script failures, safe error message
logger.LogWarning(
"Inbound API script failure for method {Method}: {Error}",

View File

@@ -0,0 +1,24 @@
namespace ScadaLink.InboundAPI;
/// <summary>
/// InboundAPI-008: abstraction the inbound API endpoint uses to determine whether
/// this node is the active (cluster-leader) central node.
///
/// The design states the inbound API is "Central cluster only (active node)" and
/// "fails over with it". A standby central node must not execute method scripts or
/// <c>Route.To()</c> calls — that can race the active node or run against stale
/// singleton state. <see cref="InboundApiEndpointFilter"/> consults this gate and
/// returns HTTP 503 from a standby so Traefik/clients only reach the live node.
///
/// The implementation lives in the Host (it needs Akka cluster state); when no
/// implementation is registered, the endpoint defaults to "allow" so non-clustered
/// hosts and tests are unaffected.
/// </summary>
public interface IActiveNodeGate
{
/// <summary>
/// <c>true</c> when this node is the active central node and may serve the
/// inbound API; <c>false</c> on a standby node.
/// </summary>
bool IsActiveNode { get; }
}

View File

@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ScadaLink.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>
/// InboundAPI-008 — active-node gating. The inbound API is central-active-node-only;
/// a standby node returns HTTP 503 so it never executes method scripts.
/// </description></item>
/// <item><description>
/// InboundAPI-006 — request body size cap. Oversized bodies are rejected with HTTP
/// 413 before being buffered into a <c>JsonDocument</c>.
/// </description></item>
/// </list>
/// </summary>
public sealed class InboundApiEndpointFilter : IEndpointFilter
{
private readonly ILogger<InboundApiEndpointFilter> _logger;
private readonly InboundApiOptions _options;
public InboundApiEndpointFilter(
ILogger<InboundApiEndpointFilter> logger,
IOptions<InboundApiOptions> options)
{
_logger = logger;
_options = options.Value;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
// InboundAPI-008: 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" },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
// InboundAPI-006: cap the request body size. 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.
var maxBytes = _options.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" },
statusCode: StatusCodes.Status413PayloadTooLarge);
}
var sizeFeature = httpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (sizeFeature is { IsReadOnly: false })
{
sizeFeature.MaxRequestBodySize = maxBytes;
}
return await next(context);
}
}

View File

@@ -2,5 +2,19 @@ namespace ScadaLink.InboundAPI;
public class InboundApiOptions
{
/// <summary>
/// Default cap on the inbound API request body, in bytes (InboundAPI-006).
/// </summary>
public const long DefaultMaxRequestBodyBytes = 1L * 1024 * 1024; // 1 MiB
public TimeSpan DefaultMethodTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// InboundAPI-006: maximum accepted request body size for <c>POST /api/{methodName}</c>.
/// Requests whose body exceeds this are rejected with HTTP 413 before being
/// buffered into a <see cref="System.Text.Json.JsonDocument"/>. The inbound API
/// has no rate limiting (a deliberate design choice), so an explicit, modest cap
/// bounds per-request allocations.
/// </summary>
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
}

View File

@@ -142,11 +142,17 @@ public class InboundScriptExecutor
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
// InboundAPI-004: keep the timeout source and the request-abort source
// separable. A single linked CTS makes a genuine client disconnect
// indistinguishable from a method timeout, so a normal disconnect would be
// logged and reported as "Script execution timed out". Use a dedicated
// timeout CTS, linked with the request token, so the two can be told apart.
using var timeoutCts = new CancellationTokenSource(timeout);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
var context = new InboundScriptContext(parameters, route, cts.Token);
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
@@ -170,8 +176,18 @@ public class InboundScriptExecutor
}
catch (OperationCanceledException)
{
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
return new InboundScriptResult(false, null, "Script execution timed out");
// InboundAPI-004: distinguish a genuine method timeout from a client
// abort. Only the timeout CTS firing is a real timeout; if the caller's
// request token fired, the client disconnected — do not pollute the
// timeout log (reserved for genuine script-execution timeouts).
if (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
return new InboundScriptResult(false, null, "Script execution timed out");
}
_logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name);
return new InboundScriptResult(false, null, "Request cancelled by client");
}
catch (Exception ex)
{

View File

@@ -10,6 +10,10 @@ public static class ServiceCollectionExtensions
services.AddSingleton<InboundScriptExecutor>();
services.AddScoped<RouteHelper>();
// InboundAPI-006 / InboundAPI-008: endpoint filter enforcing the request
// body size cap and active-node gating for POST /api/{methodName}.
services.AddSingleton<InboundApiEndpointFilter>();
return services;
}
}