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; /// /// 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 + /// — the wiring in BuildConfigurationAsync is profile-agnostic. /// public enum OpcUaSecurityProfile { /// No signing or encryption. Dev / isolated networks only. None, /// Basic256Sha256 + Sign. Messages signed, payload visible on the wire. Basic256Sha256Sign, /// Basic256Sha256 + SignAndEncrypt. Full transport protection. Basic256Sha256SignAndEncrypt, } /// Configuration options for OPC UA application hosting. public sealed class OpcUaApplicationHostOptions { /// Gets or sets the application name (default "OtOpcUa"). public string ApplicationName { get; set; } = "OtOpcUa"; /// Gets or sets the application URI (default "urn:OtOpcUa"). public string ApplicationUri { get; set; } = "urn:OtOpcUa"; /// Gets or sets the product URI (default "https://zb.com/otopcua"). public string ProductUri { get; set; } = "https://zb.com/otopcua"; /// Listening port for the binary endpoint (default 4840). public int OpcUaPort { get; set; } = 4840; /// Hostname or IP advertised in endpoint descriptions. public string PublicHostname { get; set; } = "0.0.0.0"; /// Application config XML path; when set, loaded instead of building from defaults. public string? ApplicationConfigPath { get; set; } /// /// Root of the application's PKI hierarchy. Sub-stores (own, issuer, /// trusted, rejected) 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. /// public string PkiStoreRoot { get; set; } = "pki"; /// /// 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. /// public IList EnabledSecurityProfiles { get; set; } = new List { OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256Sign, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, }; /// /// 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 None endpoints, which don't exchange certs. /// public bool AutoAcceptUntrustedClientCertificates { get; set; } /// /// Peer server URIs published in Server.ServerArray after start, in addition to /// the local . 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. /// public IList PeerApplicationUris { get; set; } = new List(); } /// /// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap. /// Owns the + lifetime /// and starts a with the supplied node-manager factory. /// /// Full extraction from legacy OtOpcUa.Server (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. /// public sealed class OpcUaApplicationHost : IAsyncDisposable { private readonly OpcUaApplicationHostOptions _options; private readonly IOpcUaUserAuthenticator _userAuthenticator; private readonly ILogger _logger; private ApplicationInstance? _application; private StandardServer? _server; private ImpersonateEventHandler? _impersonateHandler; /// Initializes a new instance of the OPC UA application host. /// The host configuration options. /// The logger for diagnostic output. /// An optional user authenticator for UserName tokens; uses null implementation if not provided. public OpcUaApplicationHost( OpcUaApplicationHostOptions options, ILogger logger, IOpcUaUserAuthenticator? userAuthenticator = null) { _options = options; _logger = logger; _userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance; } /// Gets the OPC Foundation application instance, or null if not yet started. public ApplicationInstance? ApplicationInstance => _application; /// Gets the OPC UA server instance, or null if not yet started. public StandardServer? Server => _server; /// Starts the OPC UA application and server. /// The standard server instance to start. /// A cancellation token for the operation. 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); } /// /// Subscribes to after the SDK has its /// SessionManager ready (only after _application.Start). Anonymous tokens /// pass through; UserName tokens hit and, on /// success, attach a with the mapped role-set to the session /// so downstream ACL checks can read it via OperationContext.UserIdentity. /// /// The SDK calls ImpersonateUser synchronously off the session-activation /// thread, so the authenticator's async work is run via GetAwaiter().GetResult(). /// LDAP binds typically complete in <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 /// . /// 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; } /// /// Publishes via the OPC UA /// standard Server.ServerArray property (NodeId i=2254) so warm-redundancy clients /// can discover the partner endpoint. /// /// The wire-served value of Server.ServerArray comes from /// (an ) via the /// SDK's OnReadServerArray callback — writes to /// ServerObject.ServerArray.Value are NOT what clients read. The SDK auto-populates /// slot 0 with the local ApplicationUri on ApplicationInstance.Start; 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. /// 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(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 { _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); /// /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting /// the full SDK. Side-effects are confined to mutating /// and logging. /// /// The user authenticator to validate credentials. /// The impersonation event arguments to process. /// The logger for diagnostic output. 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)); } /// /// Guarantees the application instance certificate exists in {PkiStoreRoot}/own. /// 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. /// 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 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; } /// /// Maps each configured to a SDK /// . Duplicate profiles are silently de-duped because /// the SDK rejects duplicate (policy,mode) pairs at Validate 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. /// /// The security profiles to build policies for. internal static IEnumerable BuildSecurityPolicies(IEnumerable profiles) { var seen = new HashSet(); 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, }; } } /// /// 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 /// StandardServer.SessionManager.ImpersonateUser. /// internal static IEnumerable BuildUserTokenPolicies() { yield return new UserTokenPolicy(UserTokenType.Anonymous) { PolicyId = "anonymous", SecurityPolicyUri = SecurityPolicies.None, }; yield return new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256", SecurityPolicyUri = SecurityPolicies.Basic256Sha256, }; } /// Disposes the application host and cleans up resources. 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"); } } } }