fix(client): keep advertised port for a port-less URL; narrow history refusal catch to unwrapped faults (L4 review)

This commit is contained in:
Joseph Doherty
2026-10-11 10:48:20 -04:00
parent 3067146be3
commit 045a21da14
3 changed files with 33 additions and 4 deletions
@@ -183,9 +183,10 @@ public class HistoryReadCommand : CommandBase
await console.Output.WriteLineAsync(); await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{values.Count} values returned."); await console.Output.WriteLineAsync($"{values.Count} values returned.");
} }
catch (ServiceResultException ex) catch (ServiceResultException ex) when (ex.InnerException is null)
{ {
// e.g. HistoryReadStatus.ThrowIfRefused: a refusal is an operator-facing outcome, not a crash. // e.g. HistoryReadStatus.ThrowIfRefused: a refusal is an operator-facing outcome, not a crash.
// A wrapped failure (certificate validation, identity rejection on connect) keeps its full detail.
throw new CommandException(ex.Message); throw new CommandException(ex.Message);
} }
finally finally
@@ -92,13 +92,15 @@ internal static class EndpointSelector
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport. // Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
var serverUri = new Uri(best.EndpointUrl); var serverUri = new Uri(best.EndpointUrl);
var requestedUri = new Uri(endpointUrl); var requestedUri = new Uri(endpointUrl);
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
if (string.Equals(serverUri.Scheme, requestedUri.Scheme, StringComparison.OrdinalIgnoreCase) if (string.Equals(serverUri.Scheme, requestedUri.Scheme, StringComparison.OrdinalIgnoreCase)
&& (serverUri.Host != requestedUri.Host || serverUri.Port != requestedUri.Port)) && (serverUri.Host != requestedUri.Host || serverUri.Port != port))
{ {
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = requestedUri.Port }; var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = port };
best.EndpointUrl = builder.ToString(); best.EndpointUrl = builder.ToString();
Logger.Debug("Rewrote endpoint from {ServerHost}:{ServerPort} to {RequestedHost}:{RequestedPort}", Logger.Debug("Rewrote endpoint from {ServerHost}:{ServerPort} to {RequestedHost}:{RequestedPort}",
serverUri.Host, serverUri.Port, requestedUri.Host, requestedUri.Port); serverUri.Host, serverUri.Port, requestedUri.Host, port);
} }
return best; return best;
@@ -197,4 +197,30 @@ public class EndpointSelectorTests
best.EndpointUrl.ShouldBe("opc.tcp://server:4840"); best.EndpointUrl.ShouldBe("opc.tcp://server:4840");
} }
/// <summary>A URL typed without a port keeps the advertised port (opc.tcp has no default; Uri.Port is -1).</summary>
[Fact]
public void SelectBest_KeepsAdvertisedPort_WhenRequestedUrlHasNoPort()
{
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
var uri = new Uri(best.EndpointUrl);
uri.Host.ShouldBe("localhost");
uri.Port.ShouldBe(4840);
}
/// <summary>An IPv6 host is rewritten with its brackets intact.</summary>
[Fact]
public void SelectBest_RewritesIpv6Host()
{
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
var uri = new Uri(best.EndpointUrl);
uri.Host.ShouldBe("[::1]");
uri.Port.ShouldBe(4841);
}
} }