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:
@@ -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}",
|
||||
|
||||
24
src/ScadaLink.InboundAPI/IActiveNodeGate.cs
Normal file
24
src/ScadaLink.InboundAPI/IActiveNodeGate.cs
Normal 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; }
|
||||
}
|
||||
78
src/ScadaLink.InboundAPI/InboundApiEndpointFilter.cs
Normal file
78
src/ScadaLink.InboundAPI/InboundApiEndpointFilter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user