fix(opcua): don't register with a discovery server unless configured (#876)
v2-ci / unit-tests (push) Has been cancelled

OpcUaApplicationHost left ServerConfiguration.MaxRegistrationInterval at the
SDK default (30000) with a null RegistrationEndpoint, so SDK 2.0.1's
StandardServer armed its registration timer against opc.tcp://localhost:4840,
which on an OtOpcUa node is the server itself (no LDS). It answered
BadServiceUnsupported and the SDK retried forever, visible since #874.

Registration is now opt-in: ApplyDiscoveryRegistration sets
MaxRegistrationInterval = 0 (the value that keeps StandardServer from arming
the timer) unless OpcUa:DiscoveryServerUrl is set, in which case the server
registers against that URL every OpcUa:DiscoveryRegistrationIntervalMs
(default 30000) over SignAndEncrypt/Basic256Sha256, like the SDK's own
default endpoint. The validator requires an absolute opc.tcp:// URL and a
positive interval when the URL is set. Documented in Configuration.md and
OpcUaServer.md.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-12 06:29:35 -05:00
parent 182fb353e3
commit f127f45428
6 changed files with 208 additions and 0 deletions
@@ -30,6 +30,18 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
// #876: discovery registration is opt-in; when configured, the URL must be an absolute opc.tcp URI and the
// interval positive (0 would silently disable the registration that was asked for).
if (!string.IsNullOrWhiteSpace(o.DiscoveryServerUrl))
{
builder.RequireThat(
Uri.TryCreate(o.DiscoveryServerUrl.Trim(), UriKind.Absolute, out var discoveryUri)
&& discoveryUri.Scheme == "opc.tcp",
"OpcUa:DiscoveryServerUrl must be an absolute opc.tcp:// URL (or empty to disable discovery registration)");
builder.RequireThat(o.DiscoveryRegistrationIntervalMs > 0,
"OpcUa:DiscoveryRegistrationIntervalMs must be greater than 0 while OpcUa:DiscoveryServerUrl is set");
}
// #833 review: an enabled credential lockout needs positive durations — a zero LockoutSeconds would lock
// nothing, a zero FailureWindowSeconds would never accumulate. MaxFailedAttempts <= 0 disables it.
var lockout = o.CredentialLockout;
@@ -107,6 +107,25 @@ public sealed class OpcUaApplicationHostOptions
/// </summary>
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
/// <summary>
/// URL of an OPC UA discovery server (LDS / GDS, e.g. <c>opc.tcp://lds-host:4840</c>) this server
/// registers with via <c>RegisterServer2</c> (<c>OpcUa:DiscoveryServerUrl</c>, lmxopcua#876).
/// <see langword="null"/> or blank (the default) disables discovery registration entirely.
/// </summary>
/// <remarks>
/// The SDK's own default is to register every 30 s with an LDS at <c>opc.tcp://localhost:4840</c>, which on
/// an OtOpcUa node is this server itself (no LDS) — it answered <c>BadServiceUnsupported</c> and the SDK
/// retried forever. Registration is therefore opt-in.
/// </remarks>
public string? DiscoveryServerUrl { get; set; }
/// <summary>
/// Maximum interval in milliseconds between <c>RegisterServer2</c> calls while
/// <see cref="DiscoveryServerUrl"/> is set (<c>OpcUa:DiscoveryRegistrationIntervalMs</c>; default 30000, the
/// SDK's own default). Ignored when no discovery server is configured.
/// </summary>
public int DiscoveryRegistrationIntervalMs { get; set; } = 30_000;
/// <summary>
/// SDK request-processing and transport quotas (<c>OpcUa:Quotas:*</c>, review 01 P6). The defaults are
/// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's
@@ -614,6 +633,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
};
ApplyRequestQuotas(serverConfig, _options.Quotas);
DisableSdkAuthenticationLockout(serverConfig);
ApplyDiscoveryRegistration(serverConfig, _options);
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
@@ -658,6 +678,36 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) =>
serverConfig.MaxFailedAuthenticationAttempts = 0;
/// <summary>
/// Configures discovery-server registration (lmxopcua#876). Without <see cref="OpcUaApplicationHostOptions.DiscoveryServerUrl"/>
/// registration is OFF: <c>MaxRegistrationInterval = 0</c> is what makes the SDK (2.0.1
/// <c>StandardServer.StartApplicationAsync</c>) skip its registration timer, and the endpoint stays null.
/// With a URL, the server registers against it every
/// <see cref="OpcUaApplicationHostOptions.DiscoveryRegistrationIntervalMs"/>; the endpoint mirrors the SDK's own
/// default (SignAndEncrypt / Basic256Sha256, refreshed from the discovery server before connecting).
/// </summary>
/// <param name="serverConfig">The configuration to set.</param>
/// <param name="options">The host options carrying the discovery settings.</param>
internal static void ApplyDiscoveryRegistration(ServerConfiguration serverConfig, OpcUaApplicationHostOptions options)
{
if (string.IsNullOrWhiteSpace(options.DiscoveryServerUrl))
{
serverConfig.MaxRegistrationInterval = 0;
serverConfig.RegistrationEndpoint = null;
return;
}
serverConfig.MaxRegistrationInterval = options.DiscoveryRegistrationIntervalMs;
var endpoint = new EndpointDescription
{
EndpointUrl = options.DiscoveryServerUrl.Trim(),
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
};
endpoint.Server.ApplicationType = ApplicationType.DiscoveryServer;
serverConfig.RegistrationEndpoint = endpoint;
}
/// <summary>
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
/// 5 / 100 / 200) to the SDK server configuration.