diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs
index c15217a60..68f98169d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs
@@ -46,6 +46,33 @@ public static class OpcUaEndpointUrlRewriter
return advertisedUrl;
var builder = new UriBuilder(advertised) { Host = configured.DnsSafeHost, Port = port };
// AbsoluteUri, not ToString(): ToString() un-escapes, turning an advertised "/x%20y" into "/x y" (#812).
- return builder.Uri.AbsoluteUri;
+ return RestoreZoneIndex(builder.Uri, configured);
+ }
+
+ ///
+ /// Gitea #819 — keep an IPv6 zone index ([fe80::1%eth0]) from the configured host. .NET's
+ /// parses the zone (only / carry it) but drops it from
+ /// , , and ToString(), so
+ /// any re-serialised URL loses it. The SDK dials new Uri(EndpointUrl).IdnHost, which DOES carry the zone
+ /// — so the zone is spliced back into the string, verbatim as the operator wrote it (%eth0, %4, or
+ /// an RFC 6874 %25eth0: not normalised, so the session dials exactly what GetEndpoints just reached).
+ /// Without it a link-local endpoint is unroutable: the session would dial a zone-less fe80:: address.
+ ///
+ /// The rewritten URL (configured host, zone already lost).
+ /// The operator-configured URL.
+ /// 's absolute URI with the configured zone index restored.
+ private static string RestoreZoneIndex(Uri rebuilt, Uri configured)
+ {
+ var absolute = rebuilt.AbsoluteUri;
+ if (configured.HostNameType != UriHostNameType.IPv6) return absolute;
+ var zoned = configured.DnsSafeHost; // "fe80::1%eth0" — the only accessor that keeps it
+ if (zoned.IndexOf('%') < 0) return absolute;
+
+ var bracketed = rebuilt.Host; // "[fe80::1]"
+ var authorityStart = absolute.IndexOf("://", StringComparison.Ordinal);
+ if (authorityStart < 0) return absolute;
+ var hostAt = absolute.IndexOf(bracketed, authorityStart + 3, StringComparison.OrdinalIgnoreCase);
+ if (hostAt < 0) return absolute;
+ return absolute[..hostAt] + "[" + zoned + "]" + absolute[(hostAt + bracketed.Length)..];
}
}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs
index 84d861b46..4ba60048a 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs
@@ -42,8 +42,10 @@ public sealed class EndpointSelectorRewriteParityTests
[InlineData("opc.tcp://plc-a:4840/Server", "opc.tcp://PLC-A:4840/", "opc.tcp://plc-a:4840/Server")]
// A real rewrite keeps the advertised path's percent-escaping intact.
[InlineData("opc.tcp://a:4840/x%20y?q=1", "opc.tcp://b:1/", "opc.tcp://b:1/x%20y?q=1")]
- // An IPv6 zone index on the configured host is dropped (UriBuilder behaviour, same in both copies).
- [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1]:4841/OtOpcUa")]
+ // #819: an IPv6 zone index on the configured host is KEPT, verbatim (it used to be dropped — UriBuilder behaviour,
+ // preserved deliberately through #812 — leaving link-local endpoints unroutable).
+ [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1%25eth0]:4841/OtOpcUa")]
+ [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%eth0]:4841", "opc.tcp://[fe80::1%eth0]:4841/OtOpcUa")]
public void SelectBest_rewrites_exactly_as_before(string advertised, string configured, string expected)
{
Select(advertised, configured).ShouldBe(expected);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs
index 53e9b7a9c..2d27d1f27 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs
@@ -94,6 +94,43 @@ public sealed class OpcUaClientEndpointRewriteTests
.ShouldBe("opc.tcp://[fd00::1]:54840/OtOpcUa");
}
+ ///
+ /// Gitea #819 — a configured IPv6 zone index survives the rewrite verbatim. .NET's drops it from
+ /// every re-serialisation (Host, Authority, AbsoluteUri, ToString), so before #819 a link-local endpoint came out
+ /// zone-less and unroutable.
+ ///
+ [Theory]
+ [InlineData("opc.tcp://[fe80::1%eth0]:4841", "opc.tcp://[fe80::1%eth0]:4841/OtOpcUa")]
+ [InlineData("opc.tcp://[fe80::1%4]:4841/", "opc.tcp://[fe80::1%4]:4841/OtOpcUa")]
+ // RFC 6874's encoded delimiter is kept as written, not normalised: the session dials what GetEndpoints reached.
+ [InlineData("opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1%25eth0]:4841/OtOpcUa")]
+ // Port-less configured URL: the advertised port is kept, the zone still is.
+ [InlineData("opc.tcp://[fe80::1%eth0]", "opc.tcp://[fe80::1%eth0]:4840/OtOpcUa")]
+ public void An_IPv6_zone_index_on_the_configured_host_is_preserved(string configured, string expected)
+ {
+ OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", configured).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void A_zone_index_reaches_the_host_the_SDK_dials()
+ {
+ // The SDK's TCP transport connects to new Uri(EndpointDescription.EndpointUrl).IdnHost (ConfiguredEndpoint
+ // re-parses the string), so the zone must survive into that accessor — and the advertised path with it.
+ var rewritten = OpcUaEndpointUrlRewriter.Rewrite(
+ "opc.tcp://[fe80::9]:4840/Ua", "opc.tcp://[fe80::1%eth0]:4841");
+ var endpoint = new ConfiguredEndpoint(null, new EndpointDescription { EndpointUrl = rewritten }, EndpointConfiguration.Create());
+ endpoint.EndpointUrl!.IdnHost.ShouldBe("fe80::1%eth0");
+ endpoint.EndpointUrl.Port.ShouldBe(4841);
+ endpoint.EndpointUrl.AbsolutePath.ShouldBe("/Ua");
+ }
+
+ [Fact]
+ public void A_zone_matching_no_op_returns_the_advertised_string_verbatim()
+ {
+ OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://[fe80::1%eth0]:4840/Ua", "opc.tcp://[fe80::1%eth0]:4840/")
+ .ShouldBe("opc.tcp://[fe80::1%eth0]:4840/Ua");
+ }
+
[Fact]
public void An_IPv6_advertised_host_is_replaced_by_a_configured_name()
{