diff --git a/docs/Configuration.md b/docs/Configuration.md index 1f8fa6bc3..c6e49f3ea 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -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). | diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index e87bee1f1..29d17e817 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs index 50f16c71f..dde2b1d6e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs @@ -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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index c51761423..7d78429c4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -107,6 +107,25 @@ public sealed class OpcUaApplicationHostOptions /// public IList PeerApplicationUris { get; set; } = new List(); + /// + /// URL of an OPC UA discovery server (LDS / GDS, e.g. opc.tcp://lds-host:4840) this server + /// registers with via RegisterServer2 (OpcUa:DiscoveryServerUrl, lmxopcua#876). + /// or blank (the default) disables discovery registration entirely. + /// + /// + /// The SDK's own default is to register every 30 s with an LDS at opc.tcp://localhost:4840, which on + /// an OtOpcUa node is this server itself (no LDS) — it answered BadServiceUnsupported and the SDK + /// retried forever. Registration is therefore opt-in. + /// + public string? DiscoveryServerUrl { get; set; } + + /// + /// Maximum interval in milliseconds between RegisterServer2 calls while + /// is set (OpcUa:DiscoveryRegistrationIntervalMs; default 30000, the + /// SDK's own default). Ignored when no discovery server is configured. + /// + public int DiscoveryRegistrationIntervalMs { get; set; } = 30_000; + /// /// SDK request-processing and transport quotas (OpcUa:Quotas:*, 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; + /// + /// Configures discovery-server registration (lmxopcua#876). Without + /// registration is OFF: MaxRegistrationInterval = 0 is what makes the SDK (2.0.1 + /// StandardServer.StartApplicationAsync) skip its registration timer, and the endpoint stays null. + /// With a URL, the server registers against it every + /// ; the endpoint mirrors the SDK's own + /// default (SignAndEncrypt / Basic256Sha256, refreshed from the discovery server before connecting). + /// + /// The configuration to set. + /// The host options carrying the discovery settings. + 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; + } + /// /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at /// 5 / 100 / 200) to the SDK server configuration. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs index 0d8e5b684..87ad534b7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs @@ -112,4 +112,23 @@ public sealed class OpcUaApplicationHostOptionsValidatorTests result.Succeeded.ShouldBe(valid); if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:MaxFailedAttemptsPerClient")); } + + /// #876: discovery registration is opt-in; a configured URL must be opc.tcp and the interval positive. + [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")); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDiscoveryRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDiscoveryRegistrationTests.cs new file mode 100644 index 000000000..335dc3c66 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDiscoveryRegistrationTests.cs @@ -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; + +/// +/// lmxopcua#876 — discovery-server (LDS) registration is opt-in. The SDK default (MaxRegistrationInterval +/// 30000 with a null RegistrationEndpoint) registers every 30 s with opc.tcp://localhost:4840, which on +/// an OtOpcUa node is the server itself: it answered BadServiceUnsupported and the SDK retried forever. +/// +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); + } + + /// 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. + [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 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.Instance); + }, + () => new OtOpcUaSdkServer(), Ct); + + /// The SDK 2.0.1 StandardServer.m_registrationTimer, created only when + /// MaxRegistrationInterval > 0. + 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); + } +}