Auto: opcuaclient-6 — Discovery URL FindServers

Adds optional `DiscoveryUrl` knob to OpcUaClientDriverOptions. When set,
the driver runs `DiscoveryClient.CreateAsync` + `FindServersAsync` +
`GetEndpointsAsync` against that URL during InitializeAsync and prepends
the discovered endpoint URLs (filtered to matching SecurityPolicy +
SecurityMode) to the failover candidate list. De-duplicates URLs that
appear in both discovered and static lists (case-insensitive). Discovery
failures are non-fatal — falls back to statically configured candidates.

The doc comment notes that FindServers requires SecurityMode=None on the
discovery channel per OPC UA spec, even when the data channel uses Sign
or SignAndEncrypt.

Closes #278

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-25 20:10:59 -04:00
parent e879b3ae90
commit 0f509fbd3a
3 changed files with 239 additions and 4 deletions

View File

@@ -127,7 +127,29 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
try
{
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
var candidates = ResolveEndpointCandidates(_options);
// When DiscoveryUrl is set, run FindServers + GetEndpoints first and merge the
// discovered URLs into the candidate list before the failover sweep. Discovery
// failures are non-fatal: log + fall through to the statically configured
// candidates so a transient LDS outage doesn't block init.
IReadOnlyList<string> discovered = [];
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
{
try
{
discovered = await DiscoverEndpointsAsync(
appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
cancellationToken).ConfigureAwait(false);
}
catch (Exception)
{
// Swallow + continue with static candidates; the failover sweep error
// (if all static candidates also fail) will surface the situation.
discovered = [];
}
}
var candidates = ResolveEndpointCandidates(_options, discovered);
var identity = BuildUserIdentity(_options);
@@ -390,10 +412,116 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
/// existing single-endpoint configs keep working without migration.
/// </summary>
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
ResolveEndpointCandidates(opts, []);
/// <summary>
/// Resolve the ordered failover candidate list with optional discovery results.
/// Discovered URLs are <b>prepended</b> to the static candidate list so a discovery
/// sweep gets first-attempt priority over hand-rolled fallbacks. When the static
/// list is empty (no <see cref="OpcUaClientDriverOptions.EndpointUrls"/> AND only
/// the default <see cref="OpcUaClientDriverOptions.EndpointUrl"/>), the discovered
/// URLs replace the static candidate entirely so a pure-discovery deployment doesn't
/// need a hard-coded fallback URL. Duplicates are removed (case-insensitive on the
/// URL string) so a discovered URL that also appears in <c>EndpointUrls</c> isn't
/// attempted twice in a row.
/// </summary>
internal static IReadOnlyList<string> ResolveEndpointCandidates(
OpcUaClientDriverOptions opts,
IReadOnlyList<string> discovered)
{
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
return [opts.EndpointUrl];
var staticList = opts.EndpointUrls is { Count: > 0 }
? (IReadOnlyList<string>)opts.EndpointUrls
: [opts.EndpointUrl];
if (discovered.Count == 0) return staticList;
// Discovered first; merge static after with case-insensitive de-dup so a single
// server that appears in both lists doesn't cause two consecutive identical attempts.
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<string>(discovered.Count + staticList.Count);
foreach (var u in discovered)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u))
merged.Add(u);
foreach (var u in staticList)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u))
merged.Add(u);
return merged;
}
/// <summary>
/// Run OPC UA discovery against <paramref name="discoveryUrl"/>: <c>FindServers</c>
/// enumerates every server registered with the LDS (or just the one server when
/// <paramref name="discoveryUrl"/> points at a server directly), then
/// <c>GetEndpoints</c> on each server's discovery URL pulls its full endpoint list.
/// Endpoints are filtered to those matching the requested policy + mode before being
/// returned.
/// </summary>
/// <remarks>
/// <b>SecurityMode=None on the discovery channel</b> is mandated by the OPC UA spec —
/// discovery is unauthenticated even when the steady-state session uses Sign or
/// SignAndEncrypt. <c>DiscoveryClient.CreateAsync</c> opens an unsecured channel by
/// default; we don't override that here.
/// </remarks>
internal static async Task<IReadOnlyList<string>> DiscoverEndpointsAsync(
ApplicationConfiguration appConfig,
string discoveryUrl,
OpcUaSecurityPolicy policy,
OpcUaSecurityMode mode,
CancellationToken ct)
{
var wantedPolicyUri = MapSecurityPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var results = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// FindServers against the LDS / server discovery endpoint. Returned ApplicationDescriptions
// each carry one or more DiscoveryUrls (typically one per network interface).
ApplicationDescriptionCollection servers;
using (var lds = await DiscoveryClient.CreateAsync(
appConfig, new Uri(discoveryUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false))
{
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
}
foreach (var server in servers)
{
if (server.DiscoveryUrls is null) continue;
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
{
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
EndpointDescriptionCollection endpoints;
try
{
using var ep = await DiscoveryClient.CreateAsync(
appConfig, new Uri(serverDiscoveryUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
}
catch
{
// One unreachable server in the LDS list shouldn't blow up the whole
// sweep — skip it and keep going.
continue;
}
foreach (var e in endpoints)
{
if (e.SecurityPolicyUri != wantedPolicyUri) continue;
if (e.SecurityMode != wantedMode) continue;
if (string.IsNullOrWhiteSpace(e.EndpointUrl)) continue;
if (seen.Add(e.EndpointUrl)) results.Add(e.EndpointUrl);
}
}
}
return results;
}
/// <summary>

View File

@@ -39,6 +39,34 @@ public sealed class OpcUaClientDriverOptions
/// </summary>
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
/// <summary>
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
/// and prepends the discovered endpoint URLs to the failover candidate list. When
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
/// a fallback), the discovered URLs replace the candidate list entirely so a
/// discovery-driven deployment can be configured without specifying any endpoints
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
/// statically configured candidates.
/// </summary>
/// <remarks>
/// <para>
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
/// session is bound to the configured policy.
/// </para>
/// <para>
/// Endpoints returned by discovery are filtered to those matching
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
/// the candidate list, so a discovery sweep against a multi-policy server only
/// surfaces endpoints the driver could actually connect to.
/// </para>
/// </remarks>
public string? DiscoveryUrl { get; init; }
/// <summary>
/// Security policy to require when selecting an endpoint. Either a
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for

View File

@@ -55,6 +55,85 @@ public sealed class OpcUaClientFailoverTests
"pre-connect the dashboard should show the first candidate URL so operators can link back");
}
[Fact]
public void DiscoveryUrl_defaults_null_so_existing_configs_are_unaffected()
{
var opts = new OpcUaClientDriverOptions();
opts.DiscoveryUrl.ShouldBeNull();
}
[Fact]
public void ResolveEndpointCandidates_prepends_discovered_urls_before_static_candidates()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://static1:4840", "opc.tcp://static2:4841"],
};
var discovered = new[] { "opc.tcp://discovered1:4840", "opc.tcp://discovered2:4841" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list.Count.ShouldBe(4);
list[0].ShouldBe("opc.tcp://discovered1:4840");
list[1].ShouldBe("opc.tcp://discovered2:4841");
list[2].ShouldBe("opc.tcp://static1:4840");
list[3].ShouldBe("opc.tcp://static2:4841");
}
[Fact]
public void ResolveEndpointCandidates_dedupes_url_appearing_in_both_discovered_and_static()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://shared:4840", "opc.tcp://static:4841"],
};
var discovered = new[] { "opc.tcp://shared:4840", "opc.tcp://only-discovered:4842" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list.Count.ShouldBe(3);
list[0].ShouldBe("opc.tcp://shared:4840");
list[1].ShouldBe("opc.tcp://only-discovered:4842");
list[2].ShouldBe("opc.tcp://static:4841");
}
[Fact]
public void ResolveEndpointCandidates_dedup_is_case_insensitive()
{
// Discovery URLs sometimes return uppercase hostnames; static config typically has
// lowercase. The de-dup should treat them as the same URL so the failover sweep
// doesn't attempt the same host twice in a row.
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://host:4840"],
};
var discovered = new[] { "OPC.TCP://HOST:4840" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list.Count.ShouldBe(1);
}
[Fact]
public void ResolveEndpointCandidates_with_only_default_endpoint_is_replaced_by_discovery()
{
// No EndpointUrls list, default EndpointUrl — the static "candidate" is the default
// localhost shortcut. When discovery returns URLs they should still be prepended
// (the localhost default isn't worth filtering out specially since it's harmless to
// try last and it's still a valid configured fallback).
var opts = new OpcUaClientDriverOptions(); // EndpointUrl=opc.tcp://localhost:4840 default
var discovered = new[] { "opc.tcp://discovered:4840" };
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
list[0].ShouldBe("opc.tcp://discovered:4840");
list.ShouldContain("opc.tcp://localhost:4840");
}
[Fact]
public void ResolveEndpointCandidates_no_discovered_falls_back_to_static_behaviour()
{
var opts = new OpcUaClientDriverOptions
{
EndpointUrls = ["opc.tcp://only:4840"],
};
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, []);
list.Count.ShouldBe(1);
list[0].ShouldBe("opc.tcp://only:4840");
}
[Fact]
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
{