Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
T
Joseph Doherty fae960c157
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
fix(opcua): migrate OPC UA server to Opc.Ua SDK 1.5.378 (resolves startup TypeLoadException)
Opc.Ua.Server was pinned 1.5.374.126 while Client/Configuration were 1.5.378.106, so the
published Host unified Opc.Ua.Core to 1.5.378 (which dropped Opc.Ua.INodeIdFactory that Server
1.5.374 referenced). Every driver-role node (and the fused site nodes) failed to start the OPC
UA server with TypeLoadException, leaving the OPC data plane dead and the site UIs at 503.

Align all OPC UA packages to 1.5.378.106 (bump Server; drop the Opc.Ua.Configuration/Client
VersionOverrides in OpcUaServer + its integration tests) and port the server host to the
1.5.378 async API:
- ApplicationInstance requires an ITelemetryContext ctor (DefaultTelemetry.Create)
- Start/Stop/LoadApplicationConfiguration/Validate -> async; CheckApplicationInstanceCertificate
  -> CheckApplicationInstanceCertificatesAsync
- ImpersonateEventHandler is now (ISession, ImpersonateEventArgs)
- UserNameIdentityToken.DecryptedPassword is now byte[] (UTF-8 decode)
- tests ported (byte[] passwords; async discovery/session/read client API)

Verified: full solution builds, OpcUaServer unit tests 52/52, and in docker-dev all six OPC
endpoints (4840-4845) listen and the site UIs return 302 (were 503). End-to-end OPC behaviour
(read/write/subscribe/security under 1.5.378) still needs a functional client test.
2026-06-04 12:56:18 -04:00

457 lines
21 KiB
C#

using System.Text;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
/// </summary>
public enum OpcUaSecurityProfile
{
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
None,
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
Basic256Sha256Sign,
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
Basic256Sha256SignAndEncrypt,
}
/// <summary>Configuration options for OPC UA application hosting.</summary>
public sealed class OpcUaApplicationHostOptions
{
/// <summary>Gets or sets the application name (default "OtOpcUa").</summary>
public string ApplicationName { get; set; } = "OtOpcUa";
/// <summary>Gets or sets the application URI (default "urn:OtOpcUa").</summary>
public string ApplicationUri { get; set; } = "urn:OtOpcUa";
/// <summary>Gets or sets the product URI (default "https://zb.com/otopcua").</summary>
public string ProductUri { get; set; } = "https://zb.com/otopcua";
/// <summary>Listening port for the binary endpoint (default 4840).</summary>
public int OpcUaPort { get; set; } = 4840;
/// <summary>Hostname or IP advertised in endpoint descriptions.</summary>
public string PublicHostname { get; set; } = "0.0.0.0";
/// <summary>Application config XML path; when set, loaded instead of building from defaults.</summary>
public string? ApplicationConfigPath { get; set; }
/// <summary>
/// Root of the application's PKI hierarchy. Sub-stores (<c>own</c>, <c>issuer</c>,
/// <c>trusted</c>, <c>rejected</c>) are created under this path on first start. Defaults
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
/// </summary>
public string PkiStoreRoot { get; set; } = "pki";
/// <summary>
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
/// descriptor per profile and clients choose at session open. Default = all three
/// baseline profiles (None + Basic256Sha256 in both modes); production deployments
/// typically drop None.
/// </summary>
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
};
/// <summary>
/// When true, unknown client certificates are auto-added to the trusted store on first
/// connection. Convenient for dev; should be false in production (operators promote via
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; set; }
/// <summary>
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
/// deployments so OPC UA clients can discover the partner endpoint via the standard
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
/// is always element 0.
/// </summary>
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
}
/// <summary>
/// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap.
/// Owns the <see cref="ApplicationInstance"/> + <see cref="ApplicationConfiguration"/> lifetime
/// and starts a <see cref="StandardServer"/> with the supplied node-manager factory.
///
/// Full extraction from legacy <c>OtOpcUa.Server</c> (security wiring, ScriptedAlarmDescriptor
/// pipeline, ResilienceController, history backend, observability hooks) is tracked as
/// follow-up F13. This facade compiles + boots the SDK so Task 53 can wire the fused Host's
/// driver-role startup against it.
/// </summary>
public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaApplicationHostOptions _options;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
private StandardServer? _server;
private ImpersonateEventHandler? _impersonateHandler;
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
/// <param name="options">The host configuration options.</param>
/// <param name="logger">The logger for diagnostic output.</param>
/// <param name="userAuthenticator">An optional user authenticator for UserName tokens; uses null implementation if not provided.</param>
public OpcUaApplicationHost(
OpcUaApplicationHostOptions options,
ILogger<OpcUaApplicationHost> logger,
IOpcUaUserAuthenticator? userAuthenticator = null)
{
_options = options;
_logger = logger;
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
}
/// <summary>Gets the OPC Foundation application instance, or null if not yet started.</summary>
public ApplicationInstance? ApplicationInstance => _application;
/// <summary>Gets the OPC UA server instance, or null if not yet started.</summary>
public StandardServer? Server => _server;
/// <summary>Starts the OPC UA application and server.</summary>
/// <param name="server">The standard server instance to start.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
{
_server = server;
// 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor
// is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps
// the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap.
_application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = _options.ApplicationName,
ApplicationType = ApplicationType.Server,
ConfigSectionName = "OtOpcUa",
};
_ = await BuildConfigurationAsync(cancellationToken);
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
await _application.StartAsync(server).ConfigureAwait(false);
AttachUserAuthenticator();
PopulateServerArray();
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
_options.PublicHostname, _options.OpcUaPort);
}
/// <summary>
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
///
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
/// LDAP binds typically complete in &lt;100 ms; if a backing store ever gets that slow
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
/// </summary>
private void AttachUserAuthenticator()
{
var sessionManager = _server?.CurrentInstance?.SessionManager;
if (sessionManager is null)
{
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
return;
}
_impersonateHandler = OnImpersonateUser;
sessionManager.ImpersonateUser += _impersonateHandler;
}
/// <summary>
/// Publishes <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> via the OPC UA
/// standard <c>Server.ServerArray</c> property (NodeId i=2254) so warm-redundancy clients
/// can discover the partner endpoint.
///
/// The wire-served value of <c>Server.ServerArray</c> comes from
/// <see cref="IServerInternal.ServerUris"/> (an <see cref="Opc.Ua.StringTable"/>) via the
/// SDK's <c>OnReadServerArray</c> callback — writes to
/// <c>ServerObject.ServerArray.Value</c> are NOT what clients read. The SDK auto-populates
/// slot 0 with the local <c>ApplicationUri</c> on <c>ApplicationInstance.Start</c>; we
/// append the configured peers at slots 1, 2, … here.
///
/// The address-space property is also mirrored for in-process readers (the unit-test
/// observation seam) and as a defensive belt-and-braces measure.
/// </summary>
private void PopulateServerArray()
{
var internalData = _server?.CurrentInstance;
if (internalData is null) return;
// Wire path: append peers to IServerInternal.ServerUris — this is what
// OnReadServerArray serves to remote clients reading VariableIds.Server_ServerArray.
var serverUris = internalData.ServerUris;
var existing = new HashSet<string>(StringComparer.Ordinal);
for (uint i = 0; i < (uint)serverUris.Count; i++)
{
var existingUri = serverUris.GetString(i);
if (existingUri is not null) existing.Add(existingUri);
}
foreach (var peer in _options.PeerApplicationUris)
{
if (string.IsNullOrWhiteSpace(peer)) continue;
if (existing.Contains(peer)) continue;
serverUris.Append(peer);
existing.Add(peer);
}
// In-process mirror: ServerObject.ServerArray.Value is consulted by some tests and
// tooling that read the SDK's address-space model directly rather than going through
// a session. Harmless on the wire (the SDK ignores it) but useful in-VM.
var serverObject = internalData.ServerObject;
if (serverObject is not null)
{
var uris = new List<string> { _options.ApplicationUri };
foreach (var peer in _options.PeerApplicationUris)
{
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
uris.Add(peer);
}
serverObject.ServerArray.Value = uris.ToArray();
}
}
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
HandleImpersonation(_userAuthenticator, args, _logger);
/// <summary>
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
/// and logging.
/// </summary>
/// <param name="authenticator">The user authenticator to validate credentials.</param>
/// <param name="args">The impersonation event arguments to process.</param>
/// <param name="logger">The logger for diagnostic output.</param>
internal static void HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger)
{
if (args.NewIdentity is not UserNameIdentityToken token)
{
// Anonymous + X509 tokens — let the SDK's default validation stand.
return;
}
string password;
try
{
// 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords
// are UTF-8 on the wire.
var decryptedBytes = token.DecryptedPassword;
password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes);
}
catch (Exception ex)
{
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"UserName token decryption failed");
return;
}
OpcUaUserAuthResult result;
try
{
result = authenticator
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Authentication failed");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
token.UserName, result.Error);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Invalid credentials");
return;
}
args.Identity = new UserIdentity(token);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
token.UserName, string.Join(",", result.Roles));
}
/// <summary>
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
/// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must
/// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the
/// remaining endpoint-security, user-token validator, and observability wiring stays in
/// the follow-up queue.
/// </summary>
private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken)
{
// silent: false → SDK logs cert creation events through its own trace plumbing.
// minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime).
var ok = await _application!.CheckApplicationInstanceCertificatesAsync(
false, null, cancellationToken).ConfigureAwait(false);
if (!ok)
{
throw new InvalidOperationException(
$"OPC UA application certificate validation failed for {_options.ApplicationName}. " +
$"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}");
}
}
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
{
return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct);
}
var serverConfig = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
};
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
{
serverConfig.SecurityPolicies.Add(policy);
}
foreach (var token in BuildUserTokenPolicies())
{
serverConfig.UserTokenPolicies.Add(token);
}
var config = new ApplicationConfiguration
{
ApplicationName = _options.ApplicationName,
ApplicationUri = _options.ApplicationUri,
ProductUri = _options.ProductUri,
ApplicationType = ApplicationType.Server,
ServerConfiguration = serverConfig,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = "Directory",
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
SubjectName = $"CN={_options.ApplicationName}",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
},
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
TraceConfiguration = new TraceConfiguration(),
};
await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false);
_application!.ApplicationConfiguration = config;
return config;
}
/// <summary>
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
/// falls back to a single None entry so the server doesn't refuse to start with no
/// listening endpoints — the misconfiguration is logged and very visible.
/// </summary>
/// <param name="profiles">The security profiles to build policies for.</param>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
var any = false;
foreach (var profile in profiles)
{
if (!seen.Add(profile)) continue;
any = true;
yield return profile switch
{
OpcUaSecurityProfile.None => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
};
}
if (!any)
{
yield return new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
};
}
}
/// <summary>
/// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
/// the server certificate (see docs/security.md "UserName token encryption") so the
/// policy works on None endpoints too. F13c will plug a real LDAP-bound validator into
/// <c>StandardServer.SessionManager.ImpersonateUser</c>.
/// </summary>
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
{
yield return new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "anonymous",
SecurityPolicyUri = SecurityPolicies.None,
};
yield return new UserTokenPolicy(UserTokenType.UserName)
{
PolicyId = "username_basic256sha256",
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
};
}
/// <summary>Disposes the application host and cleans up resources.</summary>
public async ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
{
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
}
_impersonateHandler = null;
if (_application is not null)
{
// 1.5.378: ApplicationInstance.Stop() → StopAsync().
try { await _application.StopAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
}
}
}