docs(opcuaclient): document the shared endpoint-URL rewrite and how to run the NAT tests (#814)
docs/drivers/OpcUaClient.md, docs/plans/2026-05-28-driver-browsers-design.md and docs/Client.CLI.md: one OpcUaEndpointUrlRewriter for driver, AdminUI browser and Client CLI/UI; configured host+port win, advertised path kept for opc.tcp; policy/mode selection and certificate validation unchanged; NAT test recipe against the #813 fixture.
This commit is contained in:
+5
-1
@@ -60,7 +60,11 @@ otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operat
|
||||
**Mapped ports and internal hostnames.** Endpoint discovery rewrites the host *and port* of the endpoint the server
|
||||
advertises to the URL you passed (when the scheme matches), so connecting through a port mapping works even though the
|
||||
server advertises its internal address (for example the docker-dev rig: `-u opc.tcp://localhost:4841` reaches the
|
||||
node mapped to 4841, although every container advertises `:4840`).
|
||||
node mapped to 4841, although every container advertises `:4840`). The advertised path is kept, a URL typed without
|
||||
a port keeps the advertised port, and only the address changes — the security mode you asked for, the policy choice
|
||||
and certificate validation are unaffected. Since #812 this is the same rewrite (`OpcUaEndpointUrlRewriter`) the
|
||||
OpcUaClient driver and the AdminUI browse picker use; see `docs/drivers/OpcUaClient.md` §"Endpoint address". Unlike
|
||||
those, the CLI fails loudly if the server advertises an endpoint with no or an unparseable URL.
|
||||
|
||||
When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure.
|
||||
|
||||
|
||||
@@ -84,15 +84,62 @@ factory, the Test-Connect probe and the browse picker (unknown keys are skipped)
|
||||
`ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten
|
||||
the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing).
|
||||
|
||||
### Endpoint address: the configured host and port win (#676)
|
||||
### Endpoint address: the configured host and port win (#676, #806, #812)
|
||||
|
||||
GetEndpoints returns each endpoint with the URL the **server** believes it lives at. Behind NAT, a port map, a
|
||||
container port publish or a proxy that address is unreachable from the client — or reachable but bypassing the proxy.
|
||||
The driver therefore rewrites the selected endpoint's **host and port** to the configured URL's whenever the schemes
|
||||
match, keeping the advertised path (the SDK's own `CoreClientUtils.SelectEndpoint` rule, which the driver's
|
||||
policy-aware endpoint selection used to bypass). A configured URL with **no port** keeps the advertised port (copying
|
||||
the port-less `-1` would have sent the session to 4840). A different scheme is left alone; an unparseable advertised
|
||||
URL falls back to the configured one. `OpcUaClientDriver.RewriteEndpointUrl`.
|
||||
The selected endpoint's URL is therefore rewritten before the session opens. **One rule, one implementation** —
|
||||
`OpcUaEndpointUrlRewriter.Rewrite` in `Driver.OpcUaClient.Contracts` (dependency-light: no OPC UA SDK) — used by all
|
||||
three OPC UA client surfaces in this repo:
|
||||
|
||||
| Surface | Call site |
|
||||
|---|---|
|
||||
| Runtime driver | `OpcUaEndpointSelection.SelectMatching` (`Driver.OpcUaClient.Session`) via `OpcUaClientDriver` (#676) |
|
||||
| AdminUI `/raw` live-browse picker | the same `SelectMatching` via `OpcUaClientDriverBrowser.SelectBrowseEndpoint` (#806) — so the picker connects exactly where the deployed driver will |
|
||||
| Client CLI / UI | `EndpointSelector.SelectBest` (`Client.Shared`) (#812 — it used to carry its own copy) |
|
||||
|
||||
The rule, for `opc.tcp` (and any scheme, as long as advertised and configured schemes **match**):
|
||||
|
||||
- the **configured host and port** replace the advertised ones and the **advertised path is kept** (the server's to
|
||||
choose; several endpoints may share a host:port) — the SDK's own `CoreClientUtils.SelectEndpoint` rule;
|
||||
- a configured URL with **no port** keeps the advertised port (copying the port-less `-1` would have sent the
|
||||
session to 4840);
|
||||
- when host (case-insensitively — DNS is) and port already match, the advertised string is returned **verbatim**;
|
||||
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).
|
||||
|
||||
**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
|
||||
`(SecurityPolicy, SecurityMode)` pair and fail loudly when nothing matches (the CLI/UI keeps its prefer-
|
||||
`Basic256Sha256`-within-the-requested-mode choice) — and the selected endpoint's **server certificate and
|
||||
user-token policies** are the advertised ones. **Certificate validation is unchanged**: the secure channel still
|
||||
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.
|
||||
|
||||
**Running the NAT tests.** `NatPublishedEndpointBrowseTests`
|
||||
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests`, `Fixture=opc-plc-nat`) run against
|
||||
an opc-plc whose advertised host is private (`*.invalid`) **and** whose published port differs from its internal
|
||||
one; each test first asserts that premise, so a fixture advertising a reachable address cannot pass them vacuously.
|
||||
They skip unless `OPCUA_NAT_ENDPOINT` is set and accepts TCP. The shared-host fixture (#813, stack
|
||||
`/opt/otopcua-opcuaclient-nat`, compose in `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/`):
|
||||
|
||||
```bash
|
||||
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose up -d'
|
||||
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.
|
||||
|
||||
### Session lifetime
|
||||
|
||||
@@ -396,6 +443,8 @@ They are not part of `DriverHealth` or the
|
||||
- **Integration fixture** — exercises the driver against a reference OPC UA
|
||||
server (opc-plc) on the shared docker host; see
|
||||
[OpcUaClient-Test-Fixture.md](OpcUaClient-Test-Fixture.md) for the coverage map.
|
||||
- **NAT / port-publish browse** — `NatPublishedEndpointBrowseTests` against the port-NAT opc-plc
|
||||
(`OPCUA_NAT_ENDPOINT`); see "Running the NAT tests" under the endpoint-address section above.
|
||||
- **Wire tests against the in-process server** — `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`
|
||||
`OpcUaClientDriver*WireTests` (no Docker). `OpcUaClientDriverStallWireTests` (#676) covers the in-place blip that
|
||||
used to be live-gate-only: the driver connects through `TcpStallProxy`, an in-test TCP forwarder whose pause holds
|
||||
|
||||
@@ -142,6 +142,15 @@ Razor picker body BrowserSessionService IDriverBrowser
|
||||
- Builds a **separate** `ApplicationConfiguration` from the runtime driver — PKI root at `%LocalAppData%/OtOpcUa/adminui-browse-pki/` (separate cert store)
|
||||
- `ApplicationName = "OtOpcUa AdminUI Browse"`, `ApplicationUri = "urn:OtOpcUa:AdminUI:Browse"`
|
||||
- Endpoint selection: same `DiscoveryClient.GetEndpointsAsync` → filter `(policy, mode)` as the runtime driver
|
||||
— literally the same code since #806: `OpcUaEndpointSelection.SelectMatching` (`Driver.OpcUaClient.Session`), which
|
||||
then rewrites the chosen endpoint's URL through the shared `OpcUaEndpointUrlRewriter` (`Driver.OpcUaClient.Contracts`;
|
||||
also used by the Client CLI/UI since #812). For `opc.tcp`, the **operator-configured host and port** replace the
|
||||
advertised ones and the **advertised path is kept** (a port-less configured URL keeps the advertised port; a different
|
||||
scheme is left alone). Before #806 the browser dialled the advertised URL verbatim, so behind NAT / a container port
|
||||
publish it failed DNS on the server's private hostname where the deployed driver connected fine.
|
||||
Only the address changes: policy + mode selection, the advertised server certificate and user-token policies, and
|
||||
certificate validation against the browse PKI are exactly as before. Full rule: `docs/drivers/OpcUaClient.md`
|
||||
§"Endpoint address".
|
||||
- One endpoint only (no failover) — interactive use; user retries with different URL on failure
|
||||
- Bounded by `OpcUaClientDriverOptions.PerEndpointConnectTimeout` (clamped [5, 30]s)
|
||||
|
||||
@@ -265,6 +274,14 @@ Razor picker body BrowserSessionService IDriverBrowser
|
||||
|
||||
### Integration tests (opt-in, fixture-gated)
|
||||
- `tests/Drivers/.../OpcUaClient.Browser.IntegrationTests/`: end-to-end against opc-plc, 3-level expand + round-trip resolve. Skipped unless `OPCUA_SIM_ENDPOINT` set
|
||||
- Same project, `NatPublishedEndpointBrowseTests` (`Fixture=opc-plc-nat`, #806): browse through a port NAT — an opc-plc
|
||||
advertising a private `.invalid` host and an internal port different from the published one. Skipped unless
|
||||
`OPCUA_NAT_ENDPOINT` is set; the shared-host fixture (#813) is `opc.tcp://10.100.0.35:50010`
|
||||
(`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`).
|
||||
- No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred)
|
||||
|
||||
### Specific regression tests
|
||||
|
||||
Reference in New Issue
Block a user