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:
Joseph Doherty
2026-08-28 13:34:20 -04:00
parent f07ba5cff5
commit a9b23556eb
16 changed files with 839 additions and 31 deletions
@@ -83,4 +83,38 @@ public class InboundApiOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("ApiMethodCacheTtl", result.FailureMessage);
}
// --- Standby forwarding (2026-08-28) ---
[Fact]
public void StandbyForwardPeerEndpoints_AbsoluteHttpUrls_AreValid()
{
var result = Validate(new InboundApiOptions
{
StandbyForwardPeerEndpoints = { "http://central-b:5000", "https://central-b.example/" },
});
Assert.True(result.Succeeded, result.FailureMessage);
}
[Theory]
[InlineData("central-b:5000")]
[InlineData("/api")]
[InlineData("ftp://central-b")]
[InlineData("")]
public void StandbyForwardPeerEndpoints_NonHttpOrRelative_IsRejected(string peer)
{
var result = Validate(new InboundApiOptions { StandbyForwardPeerEndpoints = { peer } });
Assert.True(result.Failed);
Assert.Contains("StandbyForwardPeerEndpoints", result.FailureMessage);
}
[Fact]
public void ZeroStandbyForwardTimeout_IsRejected()
{
var result = Validate(new InboundApiOptions { StandbyForwardTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("StandbyForwardTimeout", result.FailureMessage);
}
}