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
+2
View File
@@ -74,6 +74,8 @@ Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the
| `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. Must contain ≥1. Profile detail in [`security.md`](security.md). |
| `AutoAcceptUntrustedClientCertificates` | bool | `false` | Auto-trust unknown client certs on first connect (dev convenience). Not validated. See [`security.md`](security.md). |
| `PeerApplicationUris` | list of string | `[]` (empty) | Partner node `ApplicationUri`s published in `Server.ServerArray` for redundancy discovery. See [`Redundancy.md`](Redundancy.md). |
| `DiscoveryServerUrl` | string? | `null` | OPC UA discovery server (LDS/GDS) to register with via `RegisterServer2`, e.g. `opc.tcp://lds-host:4840`. **Unset = no registration** (#876 — the SDK default registered with `opc.tcp://localhost:4840`, i.e. the server itself, and logged `BadServiceUnsupported` forever). Must be an absolute `opc.tcp://` URL when set (validated). Ignored when `ApplicationConfigPath` is set (the XML's own `RegistrationEndpoint`/`MaxRegistrationInterval` apply). |
| `DiscoveryRegistrationIntervalMs` | int | `30000` | Maximum interval between `RegisterServer2` calls while `DiscoveryServerUrl` is set (SDK default). Must be `> 0` while `DiscoveryServerUrl` is set (validated); ignored otherwise. |
| `CredentialLockout:MaxFailedAttempts` | int | `5` | Failed UserName (API-key) activations for ONE credential identity (key id, else username) from one client before that client is refused that identity `BadUserAccessDenied` (#833, per-identity since #844 — a success clears only its own identity's count). Only definite bad credentials count; store timeouts / miss-budget refusals / authenticator faults do not (#845). `≤ 0` disables the whole lockout. **Never refuses Anonymous.** See [`security.md`](security.md#failed-credential-lockout-opcuacredentiallockout). |
| `CredentialLockout:MaxFailedAttemptsPerClient` | int | `0` (⇒ `2 × MaxFailedAttempts`) | Failed UserName activations from one client across EVERY identity before all its UserName activations are refused (#844). **A success never resets it**; it ages out through `FailureWindowSeconds`. Must be `0` or `>= MaxFailedAttempts` (validated). |
| `CredentialLockout:FailureWindowSeconds` | int | `60` | A failure more than this long after the client's previous one starts a fresh count. Must be `> 0` while the lockout is enabled (validated). |
+4
View File
@@ -93,6 +93,10 @@ Certificate stores are directory-based under `OpcUaApplicationHostOptions.PkiSto
`OpcUaApplicationHostOptions.AutoAcceptUntrustedClientCertificates` (default `false`) controls whether unknown client certificates are auto-trusted on first connection; production deployments leave it off and operators promote peers via the Admin UI. The application instance certificate is auto-created (SDK defaults: 2048-bit, 12-month lifetime) on first start against a fresh PKI tree, and the server certificate is always created — even for `None`-only deployments — because `UserName` token encryption needs it.
### Discovery-server registration
Registration with an OPC UA discovery server (LDS/GDS, `RegisterServer2`) is **off unless `OpcUa:DiscoveryServerUrl` is set** (#876). `OpcUaApplicationHost.ApplyDiscoveryRegistration` sets `ServerConfiguration.MaxRegistrationInterval = 0` by default — the value that keeps SDK 2.0.1's `StandardServer` from arming its registration timer. The SDK's own default (30 s, endpoint `opc.tcp://localhost:4840`) pointed at the server itself and logged `RegisterServer2 failed … BadServiceUnsupported` with backoff forever. With a URL set, the server registers against it every `OpcUa:DiscoveryRegistrationIntervalMs` (default 30000) over SignAndEncrypt / Basic256Sha256; the LDS must trust the server's application certificate. See [`Configuration.md`](Configuration.md).
## Key source files
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` — `StandardServer` subclass wiring the single node manager
@@ -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.
@@ -112,4 +112,23 @@ public sealed class OpcUaApplicationHostOptionsValidatorTests
result.Succeeded.ShouldBe(valid);
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:MaxFailedAttemptsPerClient"));
}
/// <summary>#876: discovery registration is opt-in; a configured URL must be opc.tcp and the interval positive.</summary>
[Theory]
[InlineData(null, 30_000, true)]
[InlineData("", 0, true)]
[InlineData("opc.tcp://lds-host:4840", 30_000, true)]
[InlineData("http://lds-host:4840", 30_000, false)]
[InlineData("lds-host:4840", 30_000, false)]
[InlineData("opc.tcp://lds-host:4840", 0, false)]
public void Discovery_server_url_must_be_opc_tcp_with_a_positive_interval(string? url, int intervalMs, bool valid)
{
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
{
DiscoveryServerUrl = url, DiscoveryRegistrationIntervalMs = intervalMs,
});
result.Succeeded.ShouldBe(valid);
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:Discovery"));
}
}
@@ -0,0 +1,121 @@
using System.Reflection;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// lmxopcua#876 — discovery-server (LDS) registration is opt-in. The SDK default (<c>MaxRegistrationInterval</c>
/// 30000 with a null <c>RegistrationEndpoint</c>) registers every 30 s with <c>opc.tcp://localhost:4840</c>, which on
/// an OtOpcUa node is the server itself: it answered <c>BadServiceUnsupported</c> and the SDK retried forever.
/// </summary>
public sealed class OpcUaApplicationHostDiscoveryRegistrationTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-lds-{Guid.NewGuid():N}");
public void Dispose()
{
try { if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, true); }
catch { /* best effort */ }
}
[Fact]
public void Default_options_disable_registration()
{
var config = new ServerConfiguration();
config.MaxRegistrationInterval.ShouldBeGreaterThan(0, "precondition: the SDK default registers");
OpcUaApplicationHost.ApplyDiscoveryRegistration(config, new OpcUaApplicationHostOptions());
config.MaxRegistrationInterval.ShouldBe(0);
config.RegistrationEndpoint.ShouldBeNull();
}
[Fact]
public void Configured_discovery_server_is_registered_with_at_the_configured_interval()
{
var config = new ServerConfiguration();
OpcUaApplicationHost.ApplyDiscoveryRegistration(config, new OpcUaApplicationHostOptions
{
DiscoveryServerUrl = " opc.tcp://lds-host:4840 ", DiscoveryRegistrationIntervalMs = 45_000,
});
config.MaxRegistrationInterval.ShouldBe(45_000);
config.RegistrationEndpoint.ShouldNotBeNull();
config.RegistrationEndpoint!.EndpointUrl.ShouldBe("opc.tcp://lds-host:4840");
config.RegistrationEndpoint.Server.ApplicationType.ShouldBe(ApplicationType.DiscoveryServer);
}
/// <summary>The real build path: a host started with default options builds a non-registering configuration and
/// the started SDK server never arms its registration timer.</summary>
[Fact]
public async Task Started_host_with_default_options_does_not_register()
{
var (host, server) = await StartAsync(o => { });
try
{
var serverConfig = host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!;
serverConfig.MaxRegistrationInterval.ShouldBe(0);
serverConfig.RegistrationEndpoint.ShouldBeNull();
RegistrationTimer(server).ShouldBeNull("the SDK must not arm its RegisterServer2 timer");
}
finally
{
await host.DisposeAsync();
}
}
[Fact]
public async Task Started_host_with_a_discovery_server_registers_with_it()
{
var ldsUrl = $"opc.tcp://127.0.0.1:{OpcUaHostStart.AllocateFreePort()}";
var (host, server) = await StartAsync(o =>
{
o.DiscoveryServerUrl = ldsUrl;
o.DiscoveryRegistrationIntervalMs = 60_000;
});
try
{
var serverConfig = host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!;
serverConfig.MaxRegistrationInterval.ShouldBe(60_000);
serverConfig.RegistrationEndpoint!.EndpointUrl.ShouldBe(ldsUrl);
RegistrationTimer(server).ShouldNotBeNull("positive control: a configured discovery server arms the timer");
}
finally
{
await host.DisposeAsync();
}
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> StartAsync(Action<OpcUaApplicationHostOptions> configure) =>
await OpcUaHostStart.StartAsync(
port =>
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.DiscoveryRegistrationTest",
ApplicationUri = $"urn:OtOpcUa.DiscoveryRegistrationTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
};
configure(options);
return new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
},
() => new OtOpcUaSdkServer(), Ct);
/// <summary>The SDK 2.0.1 <c>StandardServer.m_registrationTimer</c>, created only when
/// <c>MaxRegistrationInterval &gt; 0</c>.</summary>
private static object? RegistrationTimer(StandardServer server)
{
var field = typeof(StandardServer).GetField("m_registrationTimer", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("StandardServer.m_registrationTimer not found — SDK internals changed; re-check #876.");
return field.GetValue(server);
}
}