feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)

#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.

Policy: certificateValidation.validateServerHostName, tri-state. Unset =
OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the
pre-#816 behaviour, so nothing changes on upgrade; true = validate the
configured host; false = explicit NAT/port-publish opt-out. Threaded into the
driver, the AdminUI browser (same JSON) and the AdminUI form (a select that
keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName
and the CLI --validate-host-name. A driver refusal names the opt-out.

#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
This commit is contained in:
Joseph Doherty
2026-11-11 01:33:00 -05:00
parent bde6cce2fc
commit ae91e6b7f7
30 changed files with 648 additions and 59 deletions
+1
View File
@@ -39,6 +39,7 @@ All commands accept these options:
| `-P` / `--password` | Password for `UserName` token authentication |
| `-S` / `--security` | Transport security mode: `none`, `sign`, `encrypt`, `signandencrypt` (default: `none`) |
| `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy |
| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Secured connections only; off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits |
| `--verbose` | Enable debug-level Serilog console logging (default: warning) |
**`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and
+68 -9
View File
@@ -108,7 +108,14 @@ The rule, for `opc.tcp` (and any scheme, as long as advertised and configured sc
a real rewrite keeps the advertised path's percent-escaping (`AbsoluteUri`, #812);
- a **different scheme** (an `https` endpoint next to an `opc.tcp` one) is a different transport and is left alone;
- an unusable advertised URL (null / blank / unparseable) falls back to the configured one in the driver and the
browser; the Client CLI/UI instead reports it (it validates before calling the rewriter).
browser; the Client CLI/UI instead reports it (it validates before calling the rewriter);
- an **IPv6 zone index** on the configured host (`opc.tcp://[fe80::1%eth0]:4840`) is **kept, verbatim** (#819).
.NET's `Uri` parses the zone but drops it from `Host`/`Authority`/`AbsoluteUri`/`ToString()` — only
`DnsSafeHost`/`IdnHost` carry it — so the rewriter splices it back into the string. The SDK dials
`new Uri(EndpointUrl).IdnHost` (`TcpByteTransport` → `DnsEndPoint`), which keeps it, so a link-local endpoint now
routes; before #819 the session dialled a zone-less `fe80::` address. The zone text is not normalised: write the
form your OS resolves (`%eth0` / `%4`). .NET does **not** decode RFC 6874's `%25eth0` (its `DnsSafeHost` is
`fe80::1%25eth0`), so that form is passed through as written — GetEndpoints and the session see the same string.
**What the rewrite does NOT touch.** It only chooses the network address. The **security policy and mode** are
still selected from the server's advertisement exactly as before — the driver and browser filter on the configured
@@ -118,7 +125,51 @@ user-token policies** are the advertised ones. **Certificate validation is uncha
validates the certificate of the server actually reached, against the same trust store (the driver's PKI, the
browse picker's separate `adminui-browse-pki`, the CLI's own) with the same `AutoAcceptCertificates` semantics. The
NAT tests below pin that a secure browse through the NAT is refused on an untrusted certificate and succeeds once it
is trusted.
is trusted. The optional **host-name** check (#816, next section) is the one place the rewrite matters to validation:
it runs against the rewritten URL, i.e. the **configured** host.
### Server certificate host name (#816)
**Finding (SDK 2.0.1).** The SDK only checks that the server certificate names the host you connected to when the
session is created with `checkDomain: true`. All three surfaces — the driver (`OpenSessionOnEndpointAsync`), the
AdminUI browser (`OpcUaClientDriverBrowser.OpenAsync`) and Client.Shared (`DefaultSessionFactory`) — called the
8-argument `DefaultSessionFactory.CreateAsync(config, endpoint, updateBeforeConnect, sessionName, …)`, which
forwards `checkDomain: false`. So **no host-name check ever ran**; trust was the trusted-peer / issuer store alone.
That is why, in #813, a trusted NAT certificate for `otopcua-opc-plc-nat-private.invalid` browsed fine over an IP.
Where the check lives when enabled: `Session.OpenAsync` → after `CertificateManager.ValidateAsync` accepts the chain,
`if (checkDomain) CertificateValidationExtensions.ValidateDomains(validator, serverCert, session.ConfiguredEndpoint)`
→ `CertificateValidationCore.ValidateDomains` → `CertificateValidationHelpers.FindDomain`, which compares
`ConfiguredEndpoint.EndpointUrl.IdnHost` with the certificate's DNS / IP SANs (IP literals normalised; a `localhost`
/ `127.0.0.1` / `::1` URL is compared as the machine's own host name + FQDN). A miss is `BadCertificateHostNameInvalid`
("The domain '…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook
first. Consequences:
- **It checks the CONFIGURED host.** `ConfiguredEndpoint.EndpointUrl` is the URL `OpcUaEndpointSelection` /
`EndpointSelector` rewrote, so the advertised host the server named itself never takes part.
- **Secured sessions only** — the block runs only when the channel (or the user-token policy) needs the server
certificate; a `None` session validates no certificate at all.
- **`autoAcceptCertificates` accepts a mismatch too** (the driver's auto-accept `AcceptError` handler returns `true`
for everything but revocation). The Client CLI always auto-accepts.
- Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no
certificate or host-name check.
**Policy: `certificateValidation.validateServerHostName`** (driver JSON; AdminUI driver form "Server host-name check";
the browser reads the same JSON):
| Value | Meaning |
|---|---|
| absent / `null` (**default**) | `OpcUaCertificateValidationOptions.DefaultValidateServerHostName` = **off** — exactly the pre-#816 behaviour, so no existing deployment changes on upgrade. The AdminUI form keeps it absent ("Default (off)") rather than writing a concrete `false`. |
| `true` | Validate: the certificate must list the configured endpoint host. The driver's connect failure then appends the remedy (fix the certificate, connect by a name it lists, or opt out). |
| `false` | Explicit opt-out — the NAT / container port-publish / connect-by-an-IP-the-cert-omits case. |
When to turn it on: it earns its keep when trust comes from a **CA** (a trusted issuer vouches for every certificate
it signed, so only the host check ties the certificate to *this* server). With the server's own self-signed
certificate pinned in the trusted-peer store it adds little — the pin already identifies the server. Client
CLI: `--validate-host-name` (`ConnectionSettings.ValidateServerHostName`, default `false`). Pinned by
`ServerHostNameValidationTests` (driver vs. the plain opc-plc, whose certificate names only its container id) and
`NatPublishedEndpointBrowseTests` (browser vs. the NAT opc-plc): unset/`false` connect, `true` is refused naming the
configured host.
**Running the NAT tests.** `NatPublishedEndpointBrowseTests`
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests`, `Fixture=opc-plc-nat`) run against
@@ -133,13 +184,20 @@ OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat
```
The secure case asserts a certificate **refusal** by default. To make it positive, trust the fixture's server
certificate in the browse PKI and set `OPCUA_NAT_SERVER_CERT_TRUSTED=1` — the certificate is regenerated whenever
the container is recreated, so fetch the current one:
`ssh dohertj2@10.100.0.35 'docker exec otopcua-opc-plc-nat sh -c "cat /app/pki/own/certs/*.der"' > "<LocalAppData>/OtOpcUa/adminui-browse-pki/trusted/certs/opc-plc-nat.der"`
(`~/Library/Application Support/...` on macOS, `~/.local/share/...` on Linux), and delete it again afterwards. CI
(`v2-integration.yml`) starts its own throwaway NAT copy (`scripts/ci/integration-fixtures.sh`); `v2-e2e.yml` points at
the shared one. Neither trusts the certificate, so CI runs the refusal form.
Every secure browse there runs on a **throwaway browse PKI** (an internal PKI-root seam on the browser), never your
`adminui-browse-pki`. The refusal case trusts nothing and always runs. The positive case (unset / `false` host check)
and the host-name refusal (`true`) trust the fixture's **public** certificate (#818), from:
1. `OPCUA_NAT_SERVER_CERT` — a DER or PEM file; must be the certificate the server presents, or the test **fails**; else
2. the committed `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der` — the shared-host
stack's certificate, stable because its PKI lives on the `opc-plc-nat-pki` volume. If it is not the certificate
the server presents (another NAT fixture, or the stack's certificate was rolled) these tests **skip**.
The private key never leaves the volume. The committed certificate expires **2027-11-10**; to roll it, see the comment
at the top of `Docker/nat/docker-compose.yml`. CI (`v2-integration.yml`) starts its own throwaway NAT copy
(`scripts/ci/integration-fixtures.sh`), copies that container's public certificate out with `docker exec` and exports
`OPCUA_NAT_SERVER_CERT`, so it runs the positive cases too; `v2-e2e.yml` points at the shared stack and uses the
committed certificate.
### Session lifetime
@@ -379,6 +437,7 @@ also refuses them explicitly — defence in depth (#277), not a behaviour change
|---|---|---|
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Secured sessions only; accepted anyway under `autoAcceptCertificates`. `false` = explicit NAT opt-out. See "Server certificate host name" above. |
### Subscription tuning (#273)
@@ -280,8 +280,9 @@ Razor picker body BrowserSessionService IDriverBrowser
(`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose up -d'`, compose in
`tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/`), and CI starts a throwaway copy. Run:
`OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat`.
The secure case asserts a certificate refusal unless the fixture cert is trusted in the browse PKI and
`OPCUA_NAT_SERVER_CERT_TRUSTED=1` (see `docs/drivers/OpcUaClient.md`).
The secure cases run on a throwaway browse PKI: a refusal with nothing trusted, and (#818) positive / #816
host-name cases that trust the fixture's public certificate (`OPCUA_NAT_SERVER_CERT`, or the committed
`Docker/nat/opc-plc-nat-server.der`) — see `docs/drivers/OpcUaClient.md`. (`OPCUA_NAT_SERVER_CERT_TRUSTED` is retired.)
- No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred)
### Specific regression tests