fix(opcua): don't register with a discovery server unless configured (#876)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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). |
|
| `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). |
|
| `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). |
|
| `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: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: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). |
|
| `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). |
|
||||||
|
|||||||
@@ -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.
|
`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
|
## Key source files
|
||||||
|
|
||||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` — `StandardServer` subclass wiring the single node manager
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` — `StandardServer` subclass wiring the single node manager
|
||||||
|
|||||||
+12
@@ -30,6 +30,18 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<
|
|||||||
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
|
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
|
||||||
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
|
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
|
// #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.
|
// nothing, a zero FailureWindowSeconds would never accumulate. MaxFailedAttempts <= 0 disables it.
|
||||||
var lockout = o.CredentialLockout;
|
var lockout = o.CredentialLockout;
|
||||||
|
|||||||
@@ -107,6 +107,25 @@ public sealed class OpcUaApplicationHostOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
|
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>
|
/// <summary>
|
||||||
/// SDK request-processing and transport quotas (<c>OpcUa:Quotas:*</c>, review 01 P6). The defaults are
|
/// 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
|
/// 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);
|
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
||||||
DisableSdkAuthenticationLockout(serverConfig);
|
DisableSdkAuthenticationLockout(serverConfig);
|
||||||
|
ApplyDiscoveryRegistration(serverConfig, _options);
|
||||||
|
|
||||||
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
|
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
|
||||||
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
||||||
@@ -658,6 +678,36 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) =>
|
internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) =>
|
||||||
serverConfig.MaxFailedAuthenticationAttempts = 0;
|
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>
|
/// <summary>
|
||||||
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
|
/// 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.
|
/// 5 / 100 / 200) to the SDK server configuration.
|
||||||
|
|||||||
+19
@@ -112,4 +112,23 @@ public sealed class OpcUaApplicationHostOptionsValidatorTests
|
|||||||
result.Succeeded.ShouldBe(valid);
|
result.Succeeded.ShouldBe(valid);
|
||||||
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:MaxFailedAttemptsPerClient"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+121
@@ -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 > 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user