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() {