The AdminUI OPC UA browse picker connected to the EndpointUrl the server
advertised in GetEndpoints verbatim, so behind NAT / a container port
publish it dialled the server's private hostname + internal port and
failed DNS where the runtime driver (which rewrites per #676) connected.
- Move the rewrite to the shared OpcUaEndpointUrlRewriter (.Contracts,
pure Uri logic, no SDK dependency) and the policy+mode selection to
OpcUaEndpointSelection (.Session leaf). Driver and browser both select
through it, so the browse connects exactly where the driver does.
- Policy/mode filter, server certificate and strict browse PKI
(no auto-accept) are unchanged.
- Unit tests: rewrite (host/port differ, same, IPv6 both sides, path
kept, port-less, scheme mismatch untouched), browser selection seam.
- Live NAT tests (Fixture=opc-plc-nat, OPCUA_NAT_ENDPOINT): browse via a
published port with a premise check that the advertised host:port
differs and does not resolve, nsu= round-trip, unreachable endpoint
fails cleanly, SignAndEncrypt reaches the server and is refused
BadCertificateUntrusted. Verified red without the rewrite.
Refs #806
Against opc-plc (opc.tcp://10.100.0.35:50000): a shutdown with three live
subscriptions is bounded and the same instance re-initialises and reads (O-9 +
lifecycle generation through the real SDK); a server-refused write
(BadTypeMismatch) passes through and keeps the driver Healthy with its stamp
advanced (health contract); the browse side-panel reports a live variable's type,
shape and access (O-5).
Against the MTConnect fixture Agent: the headers-only ping reaches it and fails on
a dead port (T-7); a delivering /sample stream serves reads and reports its lag and
effective count on the diagnostics bag (T-4/T-5).
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT