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
This commit is contained in:
Joseph Doherty
2026-11-09 17:22:57 -05:00
parent 3ff463b6fd
commit 7912d793e2
4 changed files with 357 additions and 20 deletions
@@ -36,4 +36,38 @@ public class NodeOptions
/// 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);
}