using System.Text;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
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";
///
/// Whether anonymous sessions may connect at all (#520). When the
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
/// must present an API key.
///
///
///
/// Defaults to deliberately, which preserves today's behaviour.
/// Flipping the default would silently deny every client that has no key the moment this
/// code deploys — including ScadaBridge's Data Connection Layer, which consumes this address
/// space from another repo. Turning it off is the deployment-time decision that makes keys
/// mandatory, and it belongs in the rollout once keys have been issued.
///
///
/// Note this controls admission, not authority. An admitted anonymous session is a
/// normal principal (anonymous) whose grants come from NodeAcl rows naming it,
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
/// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole
/// address space, because reads carry no authorization check yet.
///
///
public bool AllowAnonymous { get; set; } = true;
///
/// 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,
/// builds the programmatically (security profiles + user
/// token policies + PKI stores), starts a , attaches the
/// ImpersonateUser hook for UserName-token authentication via
/// , and publishes the redundancy peer URIs through
/// Server.ServerArray. See docs/OpcUaServer.md and docs/security.md.
///
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.
/// A task that represents the asynchronous 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, _options.AllowAnonymous);
///
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
/// the full SDK. Side-effects are confined to mutating
/// and logging.
///
///
/// SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).
/// The SDK raises this handler SYNCHRONOUSLY — it is a plain void delegate, and there is no
/// async impersonation callback in this SDK version. Worse, SessionManager.ActivateSessionAsync
/// invokes it while holding a single SessionManager-wide event lock (m_eventLock), so a
/// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session
/// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to
/// raise the handler). Consequently the block-bridge below
/// (GetAwaiter().GetResult() under ) is irreducible: the
/// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
/// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
/// inside the authenticator — NOT here. (#520 replaced the LDAP authenticator with
/// ApiKeyOpcUaUserAuthenticator, whose verification does no network I/O at all, so the
/// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
///
/// 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,
bool allowAnonymous = true)
{
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
return;
}
if (args.NewIdentity is not UserNameIdentityToken token)
{
// 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 RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
token.UserName, result.Principal, string.Join(",", result.Roles));
}
///
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
/// anonymous principal has been granted (#520).
///
///
/// Before #520 this path simply returned, leaving the SDK's default identity in place: anonymous
/// always connected and carried no roles. Two things change. Anonymous can now be refused
/// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it
/// can be given read or write like any key. The identity attached is the same
/// the UserName path uses, so every downstream gate reads
/// it identically and there is no anonymous-shaped branch in any of them.
///
private static void HandleAnonymousImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
bool allowAnonymous,
AnonymousIdentityToken token)
{
if (!allowAnonymous)
{
logger.LogInformation(
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is disabled on this server.");
return;
}
OpcUaUserAuthResult result;
try
{
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Fail closed. An anonymous session whose grants could not be resolved must not fall through
// to the SDK default identity, because that would silently ADMIT it — the pre-#520
// behaviour — at exactly the moment we could not determine what it is allowed to do.
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is unavailable.");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Anonymous access is denied.");
return;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, apiKeyId: null);
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
OpcUaPrincipals.Anonymous,
result.Roles.Count == 0 ? "no roles" : 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(_options.AllowAnonymous))
{
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.
/// The SDK security policies for the requested profiles.
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. The real LDAP-bound validator is wired through
/// on StandardServer.SessionManager.ImpersonateUser
/// (see / ); the Host
/// project supplies the LDAP adapter, and denies all
/// UserName logins when none is registered.
///
/// The Anonymous and UserName token policies.
internal static IEnumerable BuildUserTokenPolicies(bool allowAnonymous = true)
{
// Withholding the policy is what makes the refusal visible to a client at DISCOVERY rather than
// only at activation: it simply is not offered an anonymous option. HandleImpersonation still
// rejects anonymous activation independently, because a client may attempt a token type the
// endpoint never advertised.
if (allowAnonymous)
{
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.
/// A task that represents the asynchronous operation.
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"); }
}
}
}