3 Commits

Author SHA1 Message Date
Joseph Doherty 86d129de6c fix(dcl): harden endpoint host-rewrite — IPv6 brackets + portless URLs
Code review of a1abbff7 found two edge cases: a discovery/advertised URL with no
explicit port emitted a malformed ':-1' (opc.tcp has no Uri default port), and an
IPv6 literal host could lose/double its brackets. Omit the port when neither URL
carries one; bracket an IPv6 host only when missing. +4 tests (12 total).
2026-07-23 16:58:44 -04:00
Joseph Doherty dbec3ee263 docs(plans): OtOpcUa v3 raw-path live-gate PASS (#14) — fix validated live, B/C deferred 2026-07-23 16:54:48 -04:00
Joseph Doherty a1abbff760 fix(dcl): rewrite advertised OPC UA endpoint host to the reachable one
An OPC UA server advertises its base address from its own config, not the route
the client took — commonly a 0.0.0.0 wildcard bind or an internal container/NAT
hostname. The OPC Foundation session dials EndpointDescription.EndpointUrl
verbatim, so a 0.0.0.0 advertisement resolved to the client's own loopback and
the connect failed. RealOpcUaClient now swaps the advertised authority for the
reachable host/port (ConnectAsync + VerifyEndpointAsync), preserving scheme+path;
no-op when already reachable. Surfaced live-gating OtOpcUa v3 (#14).
2026-07-23 16:43:45 -04:00
3 changed files with 236 additions and 0 deletions
@@ -0,0 +1,79 @@
# OtOpcUa v3 raw-path live-gate (Gitea #14) — PASS
**Date:** 2026-07-23 · **Scope:** the raw-subtree slice of #14 (decision: "raw gate now, flag B/C").
**Rig:** ScadaBridge docker rig (`scadabridge:latest`, rebuilt with the host-rewrite fix) against the
**live `otopcua-dev` v3 cluster** (`otopcua-host:dev`, built 2026-07-23, branch `feat/mesh-phase5`,
past the v3 merge `ec6598ce`). Related: scope doc `2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md`,
phase-2 plan `2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md`.
## Headline
ScadaBridge is confirmed **structurally v3-safe against a real dual-namespace server** — durable
`nsu=` references are emitted by browse, stored on bindings, and resolved live against the server's
`NamespaceArray` to Good-quality reads. The gate also **surfaced and fixed a real DCL robustness
bug** (couldn't dial a server advertising `0.0.0.0`). Items **B/C remain deferred** — the dev
cluster has no UNS nodes and no native alarms to gate them against.
## Environment findings
- v3 cluster publishes both namespaces: `ns=2 = https://zb.com/otopcua/raw`,
`ns=3 = https://zb.com/otopcua/uns`. **Raw lands at ns=2 — the exact index collision scope §4
item A predicted** (v2's sole namespace and v3 raw both at ns=2).
- Raw tree is **sparsely populated**: one materialized live Variable,
`nsu=https://zb.com/otopcua/raw;s=sa-modbus2/plc/SiteAOnly` (Int16, AccessLevel=1 → **read-only**).
Other drivers (calc1, opcua1, pymodbus, gate-modbus, Area1) expose folders but no live leaf tags.
- **UNS (ns=3) is empty** — `ns=3;s=OtOpcUa``BadNodeIdUnknown`; no Equipment authored.
- **No native alarms** — zero `HasCondition`/`HasEventSource` refs anywhere in the raw tree.
## The fix this gate produced
`RealOpcUaClient` took the server-advertised `EndpointDescription.EndpointUrl` verbatim for the
session channel. OtOpcUa advertises `opc.tcp://0.0.0.0:4840/OtOpcUa` (its
`OpcUaApplicationHostOptions.PublicHostname` defaults to `0.0.0.0`), and dialling `0.0.0.0` from
inside the client container hits its own loopback → connect fails. Added
`RealOpcUaClient.RewriteEndpointHostForReachability` (swaps the advertised authority for the
reachable discovery host/port, preserves scheme+path, no-op when already reachable), called in both
`ConnectAsync` and `VerifyEndpointAsync`. Mirrors `CoreClientUtils.SelectEndpoint` and OtOpcUa's own
client (`IEndpointDiscovery`). Commit `a1abbff7` on `fix/opcua-discovery-host-rewrite`; 9 unit tests
(`RealOpcUaClientEndpointRewriteTests`).
## Wiring (how the gate was run)
1. Rebuilt `scadabridge:latest` with the fix (`bash docker/deploy.sh`).
2. `docker network connect otopcua-dev_default scadabridge-site-a-a` (and `-b`) — bridged the two
docker networks so site-a can resolve `otopcua-dev-site-a-1-1`.
3. `data-connection create --site-id 1 --name OtOpcUa-v3-raw --protocol OpcUa`
`endpointUrl=opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa`, securityMode none (id **3044**).
4. `deploy artifacts --site-id 1` — pushed the def to site-a's LocalDb + made it live in the DCL.
5. Template **2148** (`OtOpcUaV3Probe`) + attribute `SiteA` (Int32,
`--data-source nsu=https://zb.com/otopcua/raw;s=sa-modbus2/plc/SiteAOnly`); instance **98**
(`otopcua-v3-probe-1`) on site-a; `set-bindings [["SiteA",3044]]`; `deploy instance`.
## Checks
| # | Check | Result |
|---|-------|--------|
| 1 | Connect via `ConnectAsync` (through the rewrite fix) | **PASS**`data-connection browse` returned the address space |
| 2 | Connect via `VerifyEndpointAsync` (second rewrite call site) | **PASS**`verify-endpoint``{"success":true}` |
| 3 | Browse emits durable `nsu=` throughout | **PASS** — root `nsu=…/raw;s=OtOpcUa`, drivers + `…;s=sa-modbus2/plc/SiteAOnly` |
| 4 | Both namespaces visible / discoverable | **PASS (partial)** — raw browsable; uns registered but empty (nothing to browse) |
| 5 | Durable `nsu=` binding resolves live | **PASS** — attribute stores `nsu=…`, subscription resolves it against the live `NamespaceArray` |
| 6 | Read round-trip, Good quality, live | **PASS**`SiteA` Good, values changing 59689→59806→59822→59834 across snapshots |
| 7 | Write round-trip | **N/A** — the only materialized tag is read-only (AccessLevel=1); no writable v3 tag to gate |
## Deferred (unchanged — need OtOpcUa-side authoring on the dev cluster)
- **Item A** — re-author legacy `ns=2` bindings to `nsu=` (data; the collision is confirmed real here).
- **Item B** — native-alarm dedup across raw+uns fan-out: **no native alarms exist** to drive it.
- **Item C** — UNS-bound → RawPath alarm routing: **no UNS nodes and no alarms exist** to drive it.
To gate B/C, the OtOpcUa dev cluster needs UNS Equipment authored (populate ns=3, referencing raw
tags) and at least one alarm-configured tag raising a Part-9 condition that fans to raw + equipment
notifiers.
## Rig artifacts created (non-default rig state)
- ScadaBridge site-a-a / site-a-b attached to `otopcua-dev_default` (dropped on next `docker/deploy.sh`).
- Site-a config: DataConnection **3044** `OtOpcUa-v3-raw`, template **2148**, instance **98** (deployed).
Harmless; remove with `instance delete 98` / `template delete 2148` / `data-connection delete 3044`
if a clean rig is wanted.
@@ -123,6 +123,11 @@ public class RealOpcUaClient : IOpcUaClient
endpoint = new EndpointDescription(endpointUrl);
}
// The server advertises its own base address, which is frequently unroutable from here
// (a 0.0.0.0 wildcard bind, or an internal container/NAT hostname). Swap it back to the
// host the operator actually reached, or the session channel dials an unreachable address.
RewriteEndpointHostForReachability(endpoint, endpointUrl);
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
@@ -157,6 +162,52 @@ public class RealOpcUaClient : IOpcUaClient
await _subscription.CreateAsync(cancellationToken);
}
/// <summary>
/// Rewrites a discovered endpoint's host/port to match the reachable URL the operator
/// configured, when the server advertised a different (unroutable) one.
/// </summary>
/// <remarks>
/// An OPC UA server advertises its base address from its OWN configuration, not the route the
/// client took to reach it — so <c>GetEndpoints</c> routinely returns a wildcard bind
/// (<c>opc.tcp://0.0.0.0:4840/…</c>) or an internal container/NAT hostname the client cannot
/// dial. The OPC Foundation session opens its channel to <see cref="EndpointDescription.EndpointUrl"/>
/// verbatim, so a 0.0.0.0 advertisement resolves to the client's OWN loopback and the connect
/// fails. This swaps only the authority (host + port) to the reachable one, preserving the
/// advertised scheme and path (e.g. <c>/OtOpcUa</c>). Mirrors <c>CoreClientUtils.SelectEndpoint</c>.
/// A no-op when the advertised host/port is already reachable (so an opc-plc server that
/// advertises its real container name is left untouched).
/// </remarks>
internal static void RewriteEndpointHostForReachability(EndpointDescription? endpoint, string discoveryUrl)
{
if (endpoint is null || string.IsNullOrWhiteSpace(endpoint.EndpointUrl))
return;
if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var reachable))
return;
if (!Uri.TryCreate(endpoint.EndpointUrl, UriKind.Absolute, out var advertised))
return;
// Already reachable — leave it exactly as advertised.
if (string.Equals(advertised.Host, reachable.Host, StringComparison.OrdinalIgnoreCase)
&& advertised.Port == reachable.Port)
return;
// An IPv6 literal must keep its brackets, or the reconstructed authority (`::1:4840`) is
// ambiguous and unparseable. Uri.Host may or may not include them depending on the scheme,
// so bracket only when missing (never double-wrap).
var host = reachable.Host;
if (reachable.HostNameType == UriHostNameType.IPv6 && !host.StartsWith('['))
host = $"[{host}]";
// opc.tcp has no default port known to Uri, so Uri.Port is -1 when the text omits one.
// Prefer the reachable URL's port, fall back to the advertised one, and emit no ":port"
// at all when neither carries one (rather than a malformed ":-1").
var port = reachable.Port >= 0 ? reachable.Port : advertised.Port;
var authority = port >= 0 ? $"{host}:{port}" : host;
// Authority-only advertisements parse to AbsolutePath "/" — drop it so we don't append a
// spurious trailing slash the server never advertised.
var path = advertised.AbsolutePath == "/" ? string.Empty : advertised.AbsolutePath;
endpoint.EndpointUrl = $"{advertised.Scheme}://{authority}{path}";
}
/// <summary>
/// Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a
/// long-lived connection — connect, capture the server certificate if it is untrusted,
@@ -258,6 +309,10 @@ public class RealOpcUaClient : IOpcUaClient
endpoint = new EndpointDescription(endpointUrl);
}
// Same reachability rewrite as ConnectAsync — a probe against a server advertising a
// 0.0.0.0 / NAT base address must dial the reachable host, not the advertised one.
RewriteEndpointHostForReachability(endpoint, endpointUrl);
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
@@ -0,0 +1,102 @@
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// Covers <see cref="RealOpcUaClient.RewriteEndpointHostForReachability"/> — the fix for OPC UA
/// servers (e.g. OtOpcUa v3) that advertise an unroutable base address (a 0.0.0.0 wildcard bind or
/// an internal container/NAT hostname). The session must dial the host the operator reached, not
/// the advertised one, while preserving the advertised scheme + path.
/// </summary>
public class RealOpcUaClientEndpointRewriteTests
{
private static string? Rewrite(string advertised, string discovery)
{
var ep = new EndpointDescription(advertised);
RealOpcUaClient.RewriteEndpointHostForReachability(ep, discovery);
return ep.EndpointUrl;
}
[Fact]
public void Rewrites_0_0_0_0_advertisement_to_the_reachable_host_preserving_path()
{
Assert.Equal(
"opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa",
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa"));
}
[Fact]
public void Rewrites_internal_nat_hostname_to_the_reachable_host()
{
Assert.Equal(
"opc.tcp://plc.example.com:4840/OtOpcUa",
Rewrite("opc.tcp://be5667fab9da:4840/OtOpcUa", "opc.tcp://plc.example.com:4840/OtOpcUa"));
}
[Fact]
public void Rewrites_port_too_when_the_advertised_port_differs()
{
Assert.Equal(
"opc.tcp://gateway:51210/OtOpcUa",
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://gateway:51210/OtOpcUa"));
}
[Fact]
public void Leaves_an_already_reachable_advertisement_untouched()
{
// opc-plc advertises its real container name — must not be rewritten.
Assert.Equal(
"opc.tcp://scadabridge-opcua:50000",
Rewrite("opc.tcp://scadabridge-opcua:50000", "opc.tcp://scadabridge-opcua:50000"));
}
[Fact]
public void Handles_authority_only_advertisement_without_appending_a_trailing_slash()
{
Assert.Equal(
"opc.tcp://host-b:4840",
Rewrite("opc.tcp://0.0.0.0:4840", "opc.tcp://host-b:4840"));
}
[Fact]
public void Re_wraps_an_ipv6_reachable_host_in_brackets()
{
Assert.Equal(
"opc.tcp://[::1]:4840/OtOpcUa",
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://[::1]:4840/OtOpcUa"));
}
[Fact]
public void Emits_no_port_when_neither_url_carries_one()
{
// opc.tcp has no default port, so Uri.Port is -1 on both — must not emit ":-1".
Assert.Equal(
"opc.tcp://myhost/UaServer",
Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost/UaServer"));
}
[Fact]
public void Uses_the_reachable_port_when_the_advertisement_omits_one()
{
Assert.Equal(
"opc.tcp://myhost:4840/UaServer",
Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost:4840/UaServer"));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-uri")]
public void Leaves_endpoint_unchanged_when_discovery_url_is_unusable(string discovery)
{
Assert.Equal("opc.tcp://0.0.0.0:4840/OtOpcUa", Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", discovery));
}
[Fact]
public void Null_endpoint_is_a_no_op()
{
// Must not throw.
RealOpcUaClient.RewriteEndpointHostForReachability(null, "opc.tcp://host:4840");
}
}