Per-hop current state with code evidence (browser/Traefik/Kestrel, CLI, central<->site gRPC + PSK, LocalDb sync, Akka remoting, OPC UA / MxGateway), the recommended mode per hop (Kestrel HTTPS + ALPN, gRPC over TLS with validation and the PSK retained, Akka DotNetty SSL with a cert per node and mutual auth), certificate issuance/storage/rotation, rig and wonder impact, a staged rollout, rollback, a test plan and 13 open owner decisions. Findings: the central ASPNETCORE_URLS re-bind drops the scheme (an https URL binds plaintext); Akka.NET has no secure-cookie, so mTLS is the only remoting authentication. Component-ClusterInfrastructure documents the new RemotingTls option. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
35 KiB
TLS production profile: Traefik, Kestrel, gRPC and Akka remoting (design, 2026-11-09)
Status: DESIGN, plus opt-in groundwork that is OFF by default (ScadaBridge#64, register row 128). No deployed configuration changes. No environment is switched to TLS by this document or by the code that ships with it. The revisit trigger in the register is unchanged: the first deployment onto a network that is not wholly inside the plant security boundary.
Groundwork in the same change (section 9): an opt-in Akka.Remote TLS block
(ScadaBridge:Cluster:RemotingTls), a fix that lets the control-plane PSK credentials work on an
https:// gRPC endpoint, and a fix to the bootstrap guard's seed parser for akka.ssl.tcp. With
the defaults, the Akka HOCON document is byte-identical to before (12 rendered documents compared
by SHA). Kestrel configuration is not touched.
Every "current state" claim below cites the code it was read from. Anything about the wonder environment that the code cannot show is listed as an open owner decision in section 11, not assumed.
1. Hops in scope
| # | Hop | Transport today | Listener / client |
|---|---|---|---|
| H1 | Browser to Central UI (and Inbound API callers) | HTTP/1.1, plaintext | Traefik web :80 to Kestrel :5000 (rig); Kestrel :8085 directly (wonder) |
| H2 | CLI to /management |
HTTP, plaintext; Basic or bearer token | same Kestrel HTTP surface as H1 |
| H3 | Central and site gRPC control plane | h2c (HTTP/2 without TLS) + per-site PSK | site :8083, central :8083 |
| H3b | LocalDb pair sync (site node to site node) | h2c + bearer API key | shares the site :8083 listener |
| H4 | Akka remoting within a pair | DotNetty TCP, plaintext, no authentication | central :8081, site :8082 |
| H5 | Site to external OPC UA server | OPC UA SecureChannel; None by default |
DCL RealOpcUaClient |
| H5b | Site to MxGateway | gRPC; h2c by default, TLS when configured | DCL RealMxGatewayClient |
| H6 | Standby central to active central (Inbound API relay) | HTTP, plaintext | StandbyForwardPeerEndpoints |
| H7 | Prometheus / Overview to /health/*, /metrics |
HTTP/1.1, plaintext | site :8084, central HTTP surface |
H6 and H7 follow the same Kestrel decision as H1. SQL Server and LDAP connections are out of scope here. They are noted in section 10.
2. Current state, with evidence
H1: Browser to Traefik to Central UI
- Rig Traefik is HTTP only.
docker/traefik/traefik.ymldeclares one entrypoint,web: address: ":80", andapi: insecure: true.docker/traefik/dynamic.ymlroutesPathPrefix(/)tohttp://scadabridge-central-a:5000andhttp://scadabridge-central-b:5000, health-checked on/health/active. Host port9000:80(docker/docker-compose.yml). - Traefik exists only on the docker rigs. The wonder deployment records show centrals served
directly by Kestrel on
:8085, with no load balancer in front (docs/deployment/deployment-records/2026-08-20-wonder-app-vq03-vq04.md;scadaproj/deployment.mdsection 7.1, port 8085 "ScadaBridge Central: CentralUI + Management + Inbound API"). QA users addressvq03:8085orvq04:8085by name. - Kestrel central HTTP surface.
Program.cs(Central branch) readsASPNETCORE_URLS, falling back tohttp://+:5000, and re-binds each port withoptions.ListenAnyIP(httpPort, ...)andHttpProtocols.Http1AndHttp2. It does this because the explicit gRPC listener switches Kestrel into explicit-endpoints mode. - Finding F1: the scheme in
ASPNETCORE_URLSis silently dropped.Program.ParseHttpBindPortskeeps only the port (its own remarks: "only the port matters"), and the re-bind never callsUseHttps. SoASPNETCORE_URLS=https://+:443today binds plaintext HTTP on 443, with no startup error. Any TLS profile must close this. Either honour the scheme (bind TLS with a configured certificate) or refuse to boot on anhttpsURL that has no certificate. - Session cookie.
SecurityOptions.RequireHttpsCookiedefaults totrue, which sets the cookie'sSecureflag (src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs). Both rig centrals set it tofalse(docker/central-node-*/appsettings.Central.json). When it is false the code logs a startup warning and usesSameAsRequest(Security/ServiceCollectionExtensions.cs). The value on the wonder boxes is not recorded in the repo; see section 11. - Forwarded headers.
ForwardedHeadersSetuphonoursX-Forwarded-For | X-Forwarded-Protofrom configured proxies only, and is disabled by default. The rig enables it for172.16.0.0/12. This matters for TLS that terminates at a proxy: withX-Forwarded-Proto: httpstrusted, the app sees the request as HTTPS, so the session cookie isSecureeven underSameAsRequest.
H2: CLI to management
ManagementHttpClienttakes any absolutehttporhttpsURL (CLI/Commands/CommandHelpers.cs: "Expected an absolute http/https URL"). It exchanges credentials for a bearer token atPOST /auth/token, falling back to HTTP Basic (CLI/ManagementHttpClient.csheader remarks). Overhttpboth credential forms travel in cleartext.- It uses a plain
SocketsHttpHandler { ConnectTimeout, AllowAutoRedirect = false }, with no certificate-validation override anywhere in the CLI. So anhttpsURL already gets OS-trust-store validation. No CLI code change is needed for TLS. Operators change the URL, and the box must trust the issuing CA.
H3: Central and site gRPC control plane
-
Site listener.
SiteKestrelSetup.ConfigurebindsListenAnyIP(grpcPort)withHttpProtocols.Http2only (h2c), plusListenAnyIP(metricsPort)withHttp1AndHttp2. Neither callsUseHttps. -
Central listener.
Program.csbindsListenAnyIP(centralGrpcPort)withHttp2only (h2c). The comment states: "gRPC does NOT go through Traefik (HTTP/1 only); sites reach this port by container name". -
Endpoint validation already allows https.
GrpcEndpointRulesaccepts "an absolute http:// or https:// URL" (Commons/Validation/GrpcEndpointRules.cs). The site addresses are DB rows (docker/seed-sites.shsets--grpc-node-a-address "http://scadabridge-site-a-a:8083"). The site-to-central list isScadaBridge:Communication:CentralGrpcEndpointsin eachappsettings.Site.json. -
Client builders. These build channels:
SitePairChannelProvider.BuildChannel: central to site commands.SiteStreamGrpcClient: central to site streams.CentralChannelProvider: site to central control.GrpcPull{AuditEvents,SiteCalls,TraceEvents}Client: central to site audit pulls, in AuditLog.
All of them build a
SocketsHttpHandlerwith keepalive, then call.WithMessageLimits()and.WithSiteCredentials(...). -
PSK.
ControlPlaneCredentials.WithSiteCredentialsattachesAuthorization: Bearer <psk>andx-scadabridge-siteas call credentials (Communication/Grpc/ISitePskProvider.cs). On the server,ControlPlaneAuthInterceptor(site) andCentralControlAuthInterceptor(central) are fail-closed. Central resolves one key per site fromScadaBridge:Communication:SitePsksor the secretSB-GRPC-PSK-<siteId>(Host/SitePskProvider.cs). Gitea #96 (3a31dd2d) added in-place rotation:SitePskRotationInvalidator, plusPskRejectionRefreshHandler, which drops the cached key on aPermissionDenied/Unauthenticatedresponse. -
Finding F2: the PSK composition could not run over TLS. Fixed for 4 of 7 builders in this change. The credentials were always composed over
ChannelCredentials.Insecure, withUnsafeUseInsecureChannelCallCredentials = true. Grpc.Net.Client 2.76 refuses that on anhttpsaddress, with the error "Channel is configured with insecure channel credentials and can't use a HttpClient with a 'https' scheme". So every https endpoint threw at channel construction. InSitePairChannelProviderthat throw is caught and the address logged as malformed, so a TLS site would have been silently undialable. The class comment claimed TLS "requires no change to this code"; it was wrong. The new testControlPlaneTlsEndToEndTests.Https_channel_with_insecure_composition_throws_at_constructionpins the failure. Section 9 describes the fix. Still open: the three AuditLog pull clients. Each needs a one-argument change (WithSiteCredentials(_pskProvider, siteId, endpoint)). They were left untouched on purpose because AuditLog is being changed concurrently for #42. Until they get the argument, audit pulls from anhttpssite fail at channel build.
H3b: LocalDb pair sync
- Sync rides the site's :8083 listener. The dialling half's
LocalDb:Replication:PeerAddressishttp://scadabridge-site-a-b:8083(docker/site-a-node-a/appsettings.Site.json). - The LocalDb library (
ZB.MOM.WW.LocalDb.Replication/SyncBackgroundService.DialPeer) buildsGrpcChannel.ForAddress(PeerAddress, …)with no channel credentials, and sends its key as anAuthorizationmetadata header. AnhttpsPeerAddress therefore works unchanged, using OS-trust-store validation. But a site's :8083 cannot be TLS for central and h2c for its peer: the pair's PeerAddress must flip in the same step as the listener.
H4: Akka remoting within a pair
-
AkkaHostedService.BuildHoconemitsakka.remote.dot-netty.tcp { hostname, port }and a transport failure detector. It emits no SSL keys, so Akka's reference defaultenable-ssl = falseapplies. Ports are central 8081 and site 8082; the rig publishes them to the host (9011:8081,9021:8082, and so on). -
No authentication. Akka.Remote 1.5.62 (
Directory.Packages.props) has no shared-secret or "secure cookie" mechanism; the stringssecure-cookieandrequire-cookiedo not exist inAkka.Remote.dll.untrusted-modeexists but is off, and it only filters system messages and actor selections. Anyone who can reach :8081/:8082 can join the cluster or message its actors. Mutual TLS is the only authentication Akka.Remote offers. The issue text says "Akkaenable-ssl+ secure cookie"; the cookie half does not exist in Akka.NET. -
What Akka.Remote 1.5.62 supports (read from its embedded reference config and strings):
enable-sslssl.certificate.{path,password,flags}, or{use-thumbprint-over-file, thumbprint, store-name, store-location}ssl.suppress-validationssl.require-mutual-authentication, default truessl.validate-certificate-hostname, default false- a programmatic
DotNettySslSetupwith aCustomValidator, plus helpers for chain, issuer, subject, hostname and pinned-certificate validation
At startup it refuses a certificate without an accessible private key.
-
The address scheme changes under TLS. With SSL on, node addresses are
akka.ssl.tcp://…. The new testHoconRemotingTlsTests.Enabled_document_boots_a_tls_transport_with_akka_ssl_tcp_addresseschecks this by booting a realActorSystemon the emitted document. Effects on code that parses seeds:StartupValidator.SeedNodeIsSelfandSeedNodePort/Hostuse@and the last:, so they do not depend on the scheme. They still work.ClusterBootstrapGuard.SeedPatternwas^akka(?:\.[a-z0-9]+)?://, a single dot segment. Finding F3: it did not matchakka.ssl.tcp, so with TLS on the guard would have parsed no seeds and silently never engaged. Fixed in this change.ClusterBootstrapCoordinatoruses Akka's ownAddress.Parse, which handles the TLS scheme.- Overview's
LeaderResolver.HostOf(scadaproj) parses after@, so it does not depend on the scheme either.
H5: Site to OPC UA and MxGateway
- OPC UA uses OPC UA SecureChannel, not TLS.
OpcUaEndpointConfig.SecurityModedefaults toNone. The values areNone,SignandSignAndEncrypt.AutoAcceptUntrustedCertsdefaults to false, and when on it logs a man-in-the-middle warning (RealOpcUaClient). The application PKI stores come fromScadaBridge:OpcUa:{TrustedIssuer,TrustedPeer,RejectedCertificate}StorePath.OpcUaGlobalOptionssays empty paths "fall back to a default under Path.GetTempPath()". A temp path is the wrong home for a production trust list, so the production profile should set these explicitly. The rig simulators run--unsecuretransport --autoaccept(deployment.mdsection 3.3). - MxGateway. Each connection carries
Endpoint(defaulthttp://localhost:5000),UseTls,CaFileandServerName(Commons/Types/DataConnections/MxGatewayEndpointConfig.cs). These are passed straight toMxGatewayClientOptions(RealMxGatewayClient.ConnectAsync). TLS already works per connection, with a private-CA file. No ScadaBridge code change is needed. Enabling it depends on the gateway serving TLS (its…Http__Url=https://…;deployment.mdmxgw section).
H6 and H7: relay and health
InboundApiOptions.StandbyForwardPeerEndpointsacceptshttporhttpsand is used through a namedHttpClient(InboundAPI/ServiceCollectionExtensions.cs) with default validation. It flips when the central surface flips.- The site metrics and health listener (:8084) is HTTP/1.1 by design, because Prometheus and the
Overview dashboard poll it. It carries no credentials (
/health/*is anonymous). Making it TLS is optional, and section 11 lists it as an owner decision.
3. Threat summary (why each hop matters)
| Hop | Credential or data exposed in cleartext today | Integrity or authentication gap |
|---|---|---|
| H1 | session cookie (when RequireHttpsCookie=false), LDAP password at login, Inbound API sbk_ keys |
none at the transport layer |
| H2 | operator LDAP password (Basic) or bearer token | none at the transport layer |
| H3 | per-site PSK (bearer), deployments incl. encrypted secret artifacts, audit rows | the PSK authenticates the caller, but it is replayable by anyone on the path |
| H3b | LocalDb API key, all replicated site rows | as H3 |
| H4 | every cluster message | no authentication at all: a reachable host can join |
| H5 | OPC UA values; MxGateway API key + writes | per connection; secure modes exist but are off by default |
The PSK work (#96 and its predecessors) limits who may call the control plane. It does nothing for confidentiality, and a key captured on the wire is valid until it is rotated. H4 is the weakest link: unauthenticated cluster membership.
4. Recommended TLS mode per hop
4.1 Kestrel: HTTPS with ALPN (H1, H2, H6, H7 and the gRPC listeners)
- Add a node-level certificate option,
ScadaBridge:Node:Tls, with the same shape asRemotingTls:CertificatePath+CertificatePassword, orCertificateThumbprint+StoreNameStoreLocation. Apply it withlistenOptions.UseHttps(cert)to every listener the node binds.
- Central HTTP surface:
Http1AndHttp2on one TLS port. ALPN negotiates h2 for gRPC-capable clients and HTTP/1.1 for browsers. This is the family pattern: HistorianGateway runs oneKestrel:Endpoints:HttpswithHttp1AndHttp2. - gRPC ports (central :8083, site :8083):
Http2over TLS. Clients negotiate h2 by ALPN, and no h2c remains. - Site :8084: optional (section 11).
- Close F1:
ParseHttpBindPortsmust return the scheme as well as the port, and anhttpsentry must bind TLS. With no certificate configured it must refuse to boot, so it never downgrades silently. - Keep TLS configuration in code, as the existing listeners are, not in
Kestrel:Endpoints. The explicit-endpoints comment inProgram.csalready documents one surprise from mixing the two sources. A config-section endpoint added on top of code-bound endpoints is a second surprise waiting to happen. It is unverified whether Kestrel unions the two sources or replaces one with the other. - HSTS (
UseHsts) only after an environment is confirmed HTTPS-only. Section 11 has the decision.
4.2 Traefik (rig): TLS entrypoint, re-encrypted backend
- Add a
websecureentrypoint on :443 (host9443), with a file-providertls.certificatesentry that holds the lab certificate. Redirectwebtowebsecure. - Backend: in the production profile, Traefik dials
https://scadabridge-central-{a,b}:5000, with aserversTransportwhoserootCAsis the lab CA. TLS runs end to end and the same Kestrel TLS path is exercised on the rig. Plaintext on the internal docker network is an acceptable rig-only shortcut, but it would leave F1 and §4.1 untested on the rig. - Keep
ForwardedHeaderstrusting Traefik. SetRequireHttpsCookieback to its default,true, on every TLS-fronted node. - Turn off
api.insecure(the dashboard) or bind it to loopback in any non-rig use. - gRPC stays off Traefik, as it is today. Nodes dial each other directly.
4.3 gRPC: TLS with certificate validation; PSK retained
- Endpoints become
https://host:8083: the site rowsGrpcNodeAAddress/GrpcNodeBAddress,CentralGrpcEndpoints, andLocalDb:Replication:PeerAddress. - Clients validate the server chain and hostname with
SocketsHttpHandlerdefaults. NoRemoteCertificateValidationCallbackoverride and no "accept any" switch is to be added. Hostnames in endpoints must be SANs on the server certificate. - Keep the PSK. TLS gives confidentiality and server authentication. The PSK stays the per-site
client identity, which central's interceptor keys per-site authorization on. With TLS the PSK
stops being replayable from the wire.
WithSiteCredentialsnow composes it overChannelCredentials.SecureSslfor https (section 9). - Client-certificate mTLS on gRPC is not recommended for the first step. It would duplicate the PSK's job and add a client-certificate distribution path to every site. Section 11 lists it as an owner option.
4.4 Akka.Remote: DotNetty SSL, one certificate per node, mutual authentication
- Set
ScadaBridge:Cluster:RemotingTls:Enabled = truewith this node's own certificate.RequireMutualAuthentication = true(the default). Chain validation is on and cannot be turned off through ScadaBridge configuration. Thesuppress-validationkey is always emitted asfalse. - All seeds use
akka.ssl.tcp://scadabridge@host:port.ClusterOptionsValidatorrefuses a TLS-enabled config that still has anakka.tcpseed. Without that check, the pair would retry joins forever with no startup error. ValidateCertificateHostname = falseat first, which is Akka's default; the trust anchor is a dedicated issuing CA. Turn it on once everyNodeHostname/seed host is confirmed as a SAN on the peer's certificate.- If the CA is a broad corporate CA, chain trust alone admits any host holding a certificate from
that CA. In that case add issuer or subject pinning through
DotNettySslSetup.CustomValidator. That is a code change inGetOrCreateActorSystem:ActorSystem.Createwith aBootstrapSetup, which section 9 does not include. Section 11 has the decision. - Both nodes of a pair flip together. A TLS node and a plaintext node cannot talk to each other. This matches the existing rule that a site pair stops and starts together (LocalDb Phase 2).
4.5 Site to OPC UA and MxGateway
- OPC UA: set
SecurityMode = SignAndEncryptper connection, wherever the server offers it (OtOpcUa does). Set explicitScadaBridge:OpcUa:*StorePathdirectories under the node's data root, not the temp default. Trust the server certificates explicitly.AutoAcceptUntrustedCertsstays false. - MxGateway: use an
https://endpoint withUseTls = true,CaFile(the issuing CA) andServerNamematching the gateway certificate. This needs no code change, only the gateway serving TLS.
5. Certificates: issuance, storage, rotation
5.1 One certificate per node
- Subject Alternative Names: the node's
NodeHostnameexactly as it appears in seeds and gRPC endpoints (the FQDN on wonder, e.g.wonder-app-vq03.zmr.zimmer.com), plus the short name. On the rig, the container name (scadabridge-site-a-a). - Extended Key Usage:
serverAuthandclientAuth. Akka mTLS makes every node both a TLS server and a TLS client. Kestrel and gRPC needserverAuth. - One certificate per node serves both Kestrel and Akka on that node. On the co-located wonder boxes, the central and site services on one box can share the box certificate. Both services run on the same host name, so the SANs are identical.
5.2 Issuance
- wonder: this needs a CA that the boxes trust. Whether a corporate PKI exists (for example AD Certificate Services with auto-enrolment) and is available to these boxes is not knowable from the code. It is open decision D1.
- Rig: use a lab CA generated by a new script,
docker/tls/gen-lab-ca.sh(openssl, idempotent, output gitignored). It issues one certificate per container. Both the CA and the leaves are clearly marked lab-only, like the rig's other DEV-ONLY credentials. - Trust on Linux containers: Akka's chain validation and
SocketsHttpHandlerboth use the OS trust store. So the rig image must install the lab CA. Mount it into/usr/local/share/ca-certificates/and runupdate-ca-certificatesat container start. Do not bake it into the image, because the image is shared with non-rig uses.
5.3 Where certificates and keys live
| Environment | Certificate + private key | Secret material |
|---|---|---|
| wonder (Windows) | LocalMachine\My, referenced by thumbprint (CertificateThumbprint; StoreLocation = local-machine). No key file on disk. The private key ACL grants read to the service account. |
none: the store holds the key |
| wonder, fallback | .pfx outside the content root (same rule as Secrets:SqlitePath: absolute, not under app\, which an upgrade replaces) |
password as ${secret:SB-TLS-PFX-PASSWORD}, resolved from the Secrets store by the pre-host expander |
| rig (Linux) | .pfx on a read-only bind mount (/certs/<node>.pfx) |
DEV-ONLY password in compose env, like the mesh PSKs |
Storing the PFX bytes themselves in the Secrets store is not recommended. Akka's HOCON path wants a file, and Kestrel would need a materialised copy anyway. The Secrets store is the right home for the password.
Do not log the HOCON document. With a file certificate it contains the PFX password, which
BuildRemotingTlsBlock escapes but does not redact. GetOrCreateActorSystem logs only individual
fields today, and akka.log-config-on-start is off by default. Both must stay that way.
5.4 Rotation
- Leaf rotation under the same CA: the peer validates the chain, not a pinned leaf. A node can
therefore get a new leaf and restart on its own, without coordinating with its partner:
- Kestrel picks up the new certificate on restart. A
ServerCertificateSelectorcould make that hot later. - Akka.Remote reads its certificate only at
ActorSystemstart, so it needs a restart. - On a pair, restart one node at a time. A one-node restart is ordinary failover. A pair-together stop is required only for the plaintext/TLS flip, not for a leaf rotation.
- Kestrel picks up the new certificate on restart. A
- CA rotation: first distribute a trust bundle with both the old and the new CA to every node. Then rotate the leaves. Then remove the old CA.
- Expiry monitoring: add a startup log and a
/health/readydegraded entry when the node certificate expires in fewer than N days. That is follow-on work. Who owns renewal is decision D9.
6. Impact on the docker rig and the wonder boxes
6.1 Rig (docker/, docker-env2/)
- New:
docker/tls/(CA script, gitignored output), Traefikwebsecure+ certificates,RemotingTlsblocks andakka.ssl.tcpseeds in each node's appsettings,httpssite addresses inseed-sites.sh,httpsCentralGrpcEndpointsand LocalDb PeerAddress, and CA trust in the containers. - Host ports: add 9443 for Traefik TLS. Existing 9000 stays open (redirect) until the profile is the default.
- Scripts that probe
http://localhost:900x, the CLI recipes and the Playwright suites need a base-URL switch. Their certificate trust must come from the lab CA, never from "ignore errors". - Rig posture, mirroring the LocalDb rollout: site-a on TLS, site-b and site-c on h2c, so both states are proven side by side. Central must dial both: per-site endpoints make that natural.
6.2 wonder (DEV vd03, QA vq03/vq04)
- No Traefik. Kestrel terminates TLS itself on the central port (:8085 today; whether to move to 443 is decision D4).
- Certificates in
LocalMachine\My(if PKI is available, D1/D2). Thumbprints go inappsettings.{Central,Site}.json, which are preserved across binary swaps per the deployment records. - The site rows in the shared ConfigDb change to
https://wonder-app-vq0X…:8083through the CLIsite update. That is a data change, reversible the same way. - Remote access caveat: under the 2026-09-28 WinRM scope, certificate installation counts as a deploy step and is allowed only as part of a deploy the user asked for.
- Overview: when the central surface goes TLS, its registry
BaseUrls becomehttps. Its leader matching does not depend on the scheme (section 2, H4).
7. Staged rollout
Each stage is independently reversible (section 8) and proven before the next one starts.
| Stage | What | Where | Gate |
|---|---|---|---|
| 0 | This change: design + default-off groundwork (Akka TLS block, https-capable PSK channels, guard regex) | code only | unit + real-transport tests green; default HOCON byte-identical |
| 1 | Code: ScadaBridge:Node:Tls for Kestrel; close F1; the AuditLog pull clients take the endpoint (after #42 lands); optional cert-expiry health entry |
code only, default off | unit + real-Kestrel TLS tests; default Kestrel binding unchanged |
| 2 | Rig lab CA + Traefik websecure (re-encrypting backend) + central Kestrel TLS |
docker | browser + CLI over https; Secure cookie observed; plaintext :5000 refused once flipped |
| 3 | Rig site-a: gRPC https (both directions + LocalDb PeerAddress) and Akka TLS on the site-a pair | docker site-a only | section 10 rig checks; failover drill on site-a; site-b/c unchanged |
| 4 | Rig: central pair Akka TLS; then all sites | docker | full failover drill (docker/failover-drill.sh) |
| 5 | DEV vd03 (single node: Akka TLS is self-only; gRPC + Kestrel TLS) | wonder DEV | post-deploy https /health/ready, CLI, MES test machines |
| 6 | QA vq03/vq04 pair | wonder QA | pair stop/start together; failover drill in a window |
Migration detail for the central gRPC port: during stage 3, central must answer both TLS sites and
h2c sites on site-to-central calls. A single Kestrel port is either TLS or not. Stage 1 therefore
adds an optional second central gRPC listener (for example CentralGrpcTlsPort). Sites move to
it one at a time, and the h2c port is removed once no site uses it.
The central-to-site direction has no such problem, because each site address is its own row.
8. Rollback
Every switch is configuration, and every default is off:
- Akka: set
RemotingTls:Enabled=falseand revert the seeds toakka.tcp. Stop and start the pair together. - gRPC: revert the site rows to
http://(site update), revertCentralGrpcEndpointsandPeerAddress, then restart. The PSK is untouched throughout, so auth never lapses during rollback. - Kestrel and Traefik: revert
ASPNETCORE_URLSand the nodeTlsblock, and revert the Traefik files. - Cookie:
RequireHttpsCookie=truemust stay true while the surface is TLS. Rolling the surface back to HTTP is the only case where setting it false again is justified. - HSTS: browsers keep enforcing HTTPS for the max-age after rollback. This is why HSTS is enabled last, with a short max-age first (decision D10).
- No schema change is involved at any stage. On wonder, the previous
appsettings*.jsonis already preserved inapp.bak.<ts>by the standard deploy recipe.
9. Groundwork shipped with this design (default OFF)
ScadaBridge:Cluster:RemotingTls(ClusterRemotingTlsOptionsin ClusterInfrastructure).- Fields:
Enabled,CertificatePath/CertificatePasswordorCertificateThumbprint/StoreName/StoreLocation,RequireMutualAuthentication(default true),ValidateCertificateHostname(default false). AkkaHostedService.BuildRemotingTlsBlockrendersenable-ssl+ssl { … }insidedot-netty.tcp, only whenEnabled.ClusterOptionsValidator(TLS on only) requires:- exactly one certificate source;
- a legal store location;
akka.ssl.tcp://on every seed.
- Byte-identity proof: before the change, the rendered HOCON was captured for 12
configurations:
auto-down/leader-auto-down/keep-oldest, bootstrap guard on and off, Central and Site. After the change all 12 were re-rendered and diffed: identical. A permanent test (Disabled_block_with_fields_is_byte_identical_to_default) pins it: a disabled block with every field set renders exactly the default document.
- Fields:
- https-capable PSK channels.
ControlPlaneCredentials.WithSiteCredentials(..., endpoint)composes overChannelCredentials.SecureSslfor anhttpsendpoint. For anything else, including callers that do not pass the endpoint, it keeps the previous insecure composition with the explicit opt-in, unchanged.SitePairChannelProvider,SiteStreamGrpcClientandCentralChannelProviderpass their endpoint. The three AuditLog pull clients are deliberately untouched (F2). With today'shttpendpoints, behaviour is unchanged. ClusterBootstrapGuard.SeedPatternaccepts any number of scheme segments, soakka.ssl.tcpparses (F3).- Not changed:
Program.cs,SiteKestrelSetup.cs, Traefik and every appsettings and compose file. Kestrel binding is identical by construction.
Tests:
HoconRemotingTlsTests(5): includes booting a real TLSActorSystemfrom a generated certificate. A mutation check (forcingenable-ssl = false) turns it red withakka.tcp.ClusterRemotingTlsValidatorTests(8).ClusterBootstrapGuardTests(+2 forakka.ssl.tcp).ControlPlaneTlsEndToEndTests(6). These use real Kestrel withHttp2over TLS (ALPN), the realControlPlaneAuthInterceptor, and a pinned test certificate:- the correct PSK is accepted over TLS;
- a wrong PSK is still
PermissionDeniedover TLS; - an untrusted server certificate fails with an
AuthenticationExceptioncause; - the pre-fix composition throws on https;
- the h2c composition is unchanged on http.
HttpsEndpointChannelTests(9). These check that all three production builders accepthttps. A mutation check (disabling the TLS branch) turns the three construction tests red.
10. Test plan for stages 1–6
Unit and in-process: extend the stage-0 pattern to the Kestrel Tls block:
- certificate loading from a file and from the store (the store path is Windows-only, so skip it elsewhere);
- F1: an
httpsURL with no certificate must refuse to boot; - ALPN: a real Kestrel
Http1AndHttp2TLS listener serves an HTTP/1.1 GET and an h2 gRPC call on one port.
Rig, per hop (each with a negative control, so a check that "passes" is shown to be able to fail):
- H1:
curl --cacert lab-ca.pem https://localhost:9443/health/readyreturns 200. Login over https sets the cookieSecure(read theSet-Cookie).http://localhost:9000redirects. Traefik to backend is re-encrypted: a plaintext probe tocentral-a:5000is refused once flipped. - H2: CLI against the https URL with the lab CA trusted succeeds. With the CA not trusted, it fails with a certificate error and never falls back to http.
- H3:
openssl s_client -alpn h2 -connect site-a-a:8083shows TLS 1.2 or later andh2.- An h2c client against the TLS port fails.
- Central to site commands, streams, audit pulls and site to central heartbeats all flow for site-a over TLS while site-b stays h2c.
- The PSK is still enforced: a wrong key over TLS gives
PermissionDenied. - The #96 rotation drill passes over TLS.
- H3b: LocalDb
LocalDbReplicationConnectedis true on the site-a pair over https.MaxBatchBytesbehaviour is unchanged. - H4:
- The pair forms with
akka.ssl.tcpaddresses (/health/readyshows memberCount 2). - Negative: a third container with a certificate from a different CA cannot join. It gets a handshake failure in its log and is never Up.
- Negative: a container with no certificate cannot join, which shows mTLS is enforced.
- Expired-certificate node: refused.
- Failover drill timings within the existing
StableAfterbudget. A TLS handshake on reconnect must not push detection past it.
- The pair forms with
- H5: one OPC UA connection on
SignAndEncryptwith an explicitly trusted server certificate. One MxGateway connection withUseTlsandCaFile.
Rotation drill: replace one node's leaf certificate under the same CA, restart that node only, and confirm the pair re-forms. Then do a CA roll with a two-CA trust bundle.
wonder (stages 5–6): run the standard deploy verification, but over https: /health/ready on
both centrals and sites, CLI site list, the inbound API probe recipe and the MES test machines.
Then run a QA failover in an agreed window.
11. Open decisions for the owner
Each decision has a recommendation, marked (rec).
- D1. PKI availability on the wonder network. Is there a corporate CA, such as AD CS with
auto-enrolment, that will issue certificates to
wonder-app-*withserverAuth+clientAuth? The code cannot show this.- (rec) If yes, use it. If no, use a ScadaBridge-dedicated private CA managed offline, with its
root installed into
LocalMachine\Rooton each box.
- (rec) If yes, use it. If no, use a ScadaBridge-dedicated private CA managed offline, with its
root installed into
- D2. Where the key lives on Windows.
- (rec)
LocalMachine\Myby thumbprint. The private key ACL grants read to the service account. - Alternative: a PFX outside the content root, with its password in the Secrets store.
- (rec)
- D3. The ScadaBridge service account on the wonder boxes. It decides the private key ACL. The deployment records show Windows services but not the account.
- D4. The production front door.
- (rec) Kestrel terminates TLS directly, as wonder works today.
- Alternatives: Traefik, IIS/ARR, or an F5/hardware load balancer.
- Also decide whether to keep port :8085 or move to 443.
- D5. Has the revisit trigger been met? Are the current wonder networks wholly inside the plant security boundary? If yes, this stays design-only and nothing is flipped.
- D6. Client auth on gRPC.
- (rec) PSK over TLS.
- Alternative: add client-certificate mTLS.
- D7. Akka trust scope.
- (rec) If the issuing CA is broad (corporate-wide), add issuer or subject pinning through
DotNettySslSetup.CustomValidator. That is a code change. - If the CA is ScadaBridge-dedicated, chain trust is enough.
- Also decide when to turn on
ValidateCertificateHostname.
- (rec) If the issuing CA is broad (corporate-wide), add issuer or subject pinning through
- D8. Site :8084 metrics and health over TLS. Prometheus and Overview would need the CA. Today the
listener carries no credentials.
- (rec) Leave it HTTP for the first rollout.
- D9. Certificate lifetime, renewal cadence, and who owns renewal and expiry alerts.
- D10. HSTS. Once it is on, browsers refuse plain HTTP for the max-age.
- (rec) Enable it last, with a short max-age first.
- D11. Rig Traefik backend.
- (rec) Re-encrypt to Kestrel.
- Alternative: terminate TLS at Traefik with plaintext on the docker network.
- D12. OPC UA and MxGateway. Move OtOpcUa connections to
SignAndEncrypt, and serve mxgw over TLS. These depend on those services' own rollout. - D13. SQL Server and LDAP (out of scope here). The rig uses
TrustServerCertificate=trueand GLAuth withTransport=None. Production equivalents need their own decision.