using System.Net;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
/// Production : relays a standby-node inbound
/// API request to the active central node over plain HTTP using the named
/// client.
///
/// Peer selection. The standby does not know which peer is active — it
/// only knows it is not. It therefore tries
/// in order and accepts the
/// first response that is not itself a standby refusal. A peer that is also a standby
/// (mid-failover, or a misconfigured list that includes this node) answers 503 with the
/// marker, which the forwarder treats like a
/// connection failure: try the next peer. Connection errors and per-attempt timeouts
/// () also move to the next peer.
/// When every peer fails the caller gets 503 ACTIVE_NODE_UNREACHABLE.
///
/// Loop guard. Every relayed request carries
/// . never
/// forwards a request that already carries it, so a pair in which neither node is
/// active (a transient during failover) bounces at most one hop, never ping-pongs.
///
/// Fidelity. Method, path, query string, body and headers are relayed
/// verbatim (hop-by-hop headers and Host excepted), so the active node
/// authenticates the caller's own Authorization/X-API-Key credential and
/// applies its own body cap and method timeout. The active node's status, content type
/// and body are replayed to the caller unchanged. The body is buffered once on the
/// standby (bounded by , which the
/// filter enforces before calling this class) so it can be re-sent to a second peer.
///
public sealed class HttpStandbyRequestForwarder : IStandbyRequestForwarder
{
/// Name of the client used for relays.
public const string HttpClientName = "ScadaBridge.InboundApi.StandbyForward";
///
/// Request header stamped on every relayed request (value: the forwarding node's
/// host name when known, else 1). The filter refuses to forward a request
/// that already carries it.
///
public const string ForwardedRequestHeader = "X-ScadaBridge-Forwarded";
///
/// Response header the filter adds to its own 503 STANDBY_NODE reply so a
/// forwarding peer can distinguish "that node is also standby — try the next one"
/// from a genuine 503 the active node's method script produced.
///
public const string StandbyResponseHeader = "X-ScadaBridge-Standby";
/// Machine-readable code when no configured peer served the relay.
public const string ActiveNodeUnreachableCode = "ACTIVE_NODE_UNREACHABLE";
private static readonly HashSet HopByHopRequestHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Host", "Content-Length", "Transfer-Encoding", "Connection", "Keep-Alive",
"Proxy-Connection", "Upgrade", "TE", "Trailer", "Expect",
};
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptionsMonitor _options;
private readonly ILogger _logger;
/// Initializes a new .
/// Factory for the named relay client.
/// Live-reloadable inbound API options (peer list + timeout are read per relay).
/// Logger for relay diagnostics.
public HttpStandbyRequestForwarder(
IHttpClientFactory httpClientFactory,
IOptionsMonitor options,
ILogger logger)
{
_httpClientFactory = httpClientFactory;
_options = options;
_logger = logger;
}
///
public bool IsConfigured => _options.CurrentValue.StandbyForwardPeerEndpoints.Count > 0;
///
public async Task ForwardAsync(HttpContext context, CancellationToken cancellationToken)
{
var opts = _options.CurrentValue;
var peers = opts.StandbyForwardPeerEndpoints;
if (peers.Count == 0)
return Unreachable();
// Buffer the body once so it can be replayed to a second peer. The filter has
// already enforced MaxRequestBodyBytes (Content-Length up front, and the
// per-request Kestrel cap for chunked bodies), so this is bounded.
byte[] body;
using (var buffer = new MemoryStream())
{
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
body = buffer.ToArray();
}
var client = _httpClientFactory.CreateClient(HttpClientName);
var pathAndQuery = context.Request.Path.Value + context.Request.QueryString.Value;
var forwardedBy = string.IsNullOrEmpty(context.Request.Host.Host) ? "1" : context.Request.Host.Host;
foreach (var peer in peers)
{
cancellationToken.ThrowIfCancellationRequested();
using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
attemptCts.CancelAfter(opts.StandbyForwardTimeout);
try
{
using var request = BuildRequest(context, peer, pathAndQuery, body, forwardedBy);
var response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, attemptCts.Token)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.ServiceUnavailable
&& response.Headers.Contains(StandbyResponseHeader))
{
response.Dispose();
_logger.LogInformation(
"Inbound API relay: peer {Peer} is also a standby — trying the next peer", peer);
continue;
}
var responseBody = await response.Content
.ReadAsByteArrayAsync(attemptCts.Token)
.ConfigureAwait(false);
var contentType = response.Content.Headers.ContentType?.ToString();
var status = (int)response.StatusCode;
response.Dispose();
_logger.LogDebug(
"Inbound API relay: served {Path} via active peer {Peer} → HTTP {Status}",
context.Request.Path, peer, status);
return new RelayedResult(status, contentType, responseBody);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw; // caller went away — nothing to answer
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Inbound API relay: peer {Peer} did not answer within {Timeout} — trying the next peer",
peer, opts.StandbyForwardTimeout);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex,
"Inbound API relay: peer {Peer} unreachable — trying the next peer", peer);
}
}
_logger.LogWarning(
"Inbound API relay: no configured peer served {Path} ({PeerCount} tried)",
context.Request.Path, peers.Count);
return Unreachable();
}
private static HttpRequestMessage BuildRequest(
HttpContext context, string peer, string pathAndQuery, byte[] body, string forwardedBy)
{
var request = new HttpRequestMessage(
new HttpMethod(context.Request.Method),
new Uri(new Uri(peer.TrimEnd('/') + "/"), pathAndQuery.TrimStart('/')));
var content = new ByteArrayContent(body);
foreach (var header in context.Request.Headers)
{
if (HopByHopRequestHeaders.Contains(header.Key)
|| string.Equals(header.Key, ForwardedRequestHeader, StringComparison.OrdinalIgnoreCase))
continue;
var values = header.Value.ToArray();
if (!request.Headers.TryAddWithoutValidation(header.Key, values))
content.Headers.TryAddWithoutValidation(header.Key, values);
}
if (content.Headers.ContentType == null
&& MediaTypeHeaderValue.TryParse(context.Request.ContentType, out var mediaType))
content.Headers.ContentType = mediaType;
request.Content = content;
request.Headers.TryAddWithoutValidation(ForwardedRequestHeader, forwardedBy);
return request;
}
private static IResult Unreachable() => Results.Json(
new
{
error = "Inbound API request could not be relayed to the active central node",
code = ActiveNodeUnreachableCode,
},
statusCode: StatusCodes.Status503ServiceUnavailable);
///
/// Replays a peer's status, content type and body to the original caller.
/// Exposed so tests can assert on what the relay produced without a server.
///
public sealed class RelayedResult : IResult, IStatusCodeHttpResult
{
/// Initializes a new .
/// The peer's HTTP status.
/// The peer's Content-Type, if any.
/// The peer's response body.
public RelayedResult(int statusCode, string? contentType, byte[] body)
{
StatusCode = statusCode;
ContentType = contentType;
Body = body;
}
///
public int? StatusCode { get; }
/// The peer's Content-Type, replayed verbatim.
public string? ContentType { get; }
/// The peer's response body, replayed verbatim.
public byte[] Body { get; }
///
public async Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = StatusCode ?? StatusCodes.Status200OK;
if (ContentType != null)
httpContext.Response.ContentType = ContentType;
httpContext.Response.ContentLength = Body.Length;
await httpContext.Response.Body.WriteAsync(Body, httpContext.RequestAborted).ConfigureAwait(false);
}
}
}