Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs
T
Joseph Doherty 7912d793e2 fix(host): central https URLs get TLS from a node certificate or refuse to boot (ScadaBridge#108)
Central re-bound its HTTP surface from ASPNETCORE_URLS keeping only the
port, so https://+:443 silently served plaintext HTTP. The parser now keeps
the scheme (ParseHttpBindEndpoints); an https entry binds UseHttps with the
node certificate from the new ScadaBridge:Node:Tls section (CertificatePath
+ CertificatePassword, or CertificateThumbprint + StoreName + StoreLocation;
default unset) and the node refuses to boot, with a clear error, when https
is requested without a certificate. http entries bind exactly as before.

Tests: scheme parsing, refuse-to-boot, http endpoint config identical to the
legacy inline binding (reflected effective Kestrel listen options), and a
real Kestrel HTTPS listener with a generated .pfx serving h2 and HTTP/1.1 by
ALPN while plaintext to the TLS port fails. Reverting the fix turns the
refuse-to-boot and real-listener tests red.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:22:57 -05:00

74 lines
3.9 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.Host;
public class NodeOptions
{
/// <summary>Gets or sets the node role (e.g. Central or Site).</summary>
public string Role { get; set; } = string.Empty;
/// <summary>Gets or sets the hostname or IP address this node advertises to the Akka cluster.</summary>
public string NodeHostname { get; set; } = string.Empty;
/// <summary>
/// Operator-configured semantic node name used to stamp the SourceNode
/// column on audit rows. Conventional values are <c>node-a</c>/<c>node-b</c>
/// on site nodes and <c>central-a</c>/<c>central-b</c> on central nodes,
/// but the value is a free-form label — no validation is enforced.
/// </summary>
public string NodeName { get; set; } = string.Empty;
/// <summary>Gets or sets the site identifier for site nodes; null for central nodes.</summary>
public string? SiteId { get; set; }
/// <summary>Gets or sets the Akka.NET remoting port for cluster communication.</summary>
public int RemotingPort { get; set; } = 8081;
/// <summary>Gets or sets the gRPC port for the site stream server.</summary>
public int GrpcPort { get; set; } = 8083;
/// <summary>
/// HTTP/2 (h2c) port the CENTRAL node listens on for the site→central
/// <c>CentralControlService</c> gRPC control plane. Default 8083 — deliberately symmetric
/// with the site <see cref="GrpcPort"/>, since a node is either central or a site and the
/// two never share a process. This listener is distinct from central's <c>:5000</c> HTTP/1
/// surface (Central UI, Management/Inbound API), which stays exactly as-is: gRPC does NOT go
/// through Traefik (HTTP/1 only). Ignored on site nodes.
/// </summary>
public int CentralGrpcPort { get; set; } = 8083;
/// <summary>
/// HTTP/1.1 port serving the Prometheus /metrics scrape endpoint on site nodes.
/// Defaults to 8084 — deliberately distinct from <see cref="RemotingPort"/> (8082)
/// and <see cref="GrpcPort"/> (8083) so the Kestrel metrics listener never contends
/// with the Akka remoting port a site node binds.
/// </summary>
public int MetricsPort { get; set; } = 8084;
/// <summary>
/// This node's own TLS certificate for the Kestrel HTTP surface (ScadaBridge#108, design
/// 2026-11-09-tls-production-profile stage 1). Default: nothing set. Required if (and only if)
/// <c>ASPNETCORE_URLS</c> contains an <c>https://</c> entry; central then refuses to boot
/// without it. Applies to central's HTTP/1.1+HTTP/2 surface only; the gRPC listener is unchanged.
/// </summary>
public NodeTlsOptions Tls { get; set; } = new();
}
/// <summary>
/// Certificate source for <see cref="NodeOptions.Tls"/>: a .pfx path plus password, OR a
/// certificate-store thumbprint. Same shape as the cluster's <c>RemotingTls</c> certificate fields.
/// </summary>
public sealed class NodeTlsOptions
{
/// <summary>Path to a PKCS#12 (.pfx) file with the private key. Set this OR <see cref="CertificateThumbprint"/>.</summary>
public string? CertificatePath { get; set; }
/// <summary>Password for <see cref="CertificatePath"/>; write it as <c>${secret:NAME}</c>, never a literal.</summary>
public string? CertificatePassword { get; set; }
/// <summary>Thumbprint of a certificate in the OS store. Set this OR <see cref="CertificatePath"/>.</summary>
public string? CertificateThumbprint { get; set; }
/// <summary>Store name for <see cref="CertificateThumbprint"/>. Default <c>My</c>.</summary>
public string StoreName { get; set; } = "My";
/// <summary>Store location for <see cref="CertificateThumbprint"/>: <c>local-machine</c> (default) or <c>current-user</c>.</summary>
public string StoreLocation { get; set; } = "local-machine";
/// <summary>Whether any certificate source field is set.</summary>
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(CertificatePath) || !string.IsNullOrWhiteSpace(CertificateThumbprint);
}