087a969ee6
Pulled forward from stage 7 because without it the feature is unusable and un-live-gateable: there was no way to create a key short of hand-inserting a row and computing an HMAC by hand. The minting logic is an IApiKeyAdminService in Core.Authorization, NOT logic in the Razor page — this repo has no bUnit, so anything only a page can reach is untestable, and minting credentials is not where that belongs. The page is a thin shell over it. Moved to Core.Authorization so both ends can reach them: ApiKeyOptions (was in Host) and OpcUaPrincipals (was in OpcUaServer.Security). The OPC UA server resolves principals; the AdminUI must refuse to mint a key claiming a reserved one. OpcUaServer already gained a Core reference in stage 1b, so no new edge — AdminUI gains one. ApiKeyOptions is now bound UNCONDITIONALLY rather than in the driver branch: a driver node needs the pepper to VERIFY keys and an admin node needs it to MINT them, and getting that wrong on either side is a silent authentication failure rather than a startup error. Validation rules, all covered: the reserved 'anonymous' principal is refused case-insensitively (so "ANONYMOUS" cannot slip past into a distinct principal that the evaluator would then match case-INsensitively against anonymous's grants); blank name/principal; a principal longer than the column, rejected rather than truncated, because silent truncation yields a key that authenticates and is granted nothing with no visible cause; a past expiry. Generation runs BEFORE the DbContext is opened so an unconfigured pepper surfaces as a clear error instead of a stored row whose hash nothing can verify. The page shows the plaintext ONCE and says so; it is held in component state and written nowhere else, because the server keeps only a hash. Revocation is flagged in the UI as taking effect only after the next deployment — driver-only nodes have no ConfigDb, so keys ride the artifact. FleetAdmin-only (Administrator), not ConfigEditor: this mints credentials. Core.Tests 68 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
576 lines
28 KiB
C#
576 lines
28 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.Core.Authorization;
|
|
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>
|
|
/// Whether anonymous sessions may connect at all (#520). When <see langword="false"/> the
|
|
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
|
|
/// must present an API key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Defaults to <see langword="true"/> deliberately, which preserves today's behaviour.</b>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
|
|
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public bool AllowAnonymous { get; set; } = true;
|
|
|
|
/// <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,
|
|
/// builds the <see cref="ApplicationConfiguration"/> programmatically (security profiles + user
|
|
/// token policies + PKI stores), starts a <see cref="StandardServer"/>, attaches the
|
|
/// <c>ImpersonateUser</c> hook for UserName-token authentication via
|
|
/// <see cref="IOpcUaUserAuthenticator"/>, and publishes the redundancy peer URIs through
|
|
/// <c>Server.ServerArray</c>. See <c>docs/OpcUaServer.md</c> and <c>docs/security.md</c>.
|
|
/// </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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
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 <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, _options.AllowAnonymous);
|
|
|
|
/// <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>
|
|
/// <remarks>
|
|
/// <b>SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).</b>
|
|
/// The SDK raises this handler SYNCHRONOUSLY — it is a plain <c>void</c> delegate, and there is no
|
|
/// async impersonation callback in this SDK version. Worse, <c>SessionManager.ActivateSessionAsync</c>
|
|
/// invokes it while holding a single <c>SessionManager</c>-wide event lock (<c>m_eventLock</c>), 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 <see cref="IOpcUaUserAuthenticator"/> block-bridge below
|
|
/// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) 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
|
|
/// <c>ApiKeyOpcUaUserAuthenticator</c>, 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.)
|
|
/// </remarks>
|
|
/// <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,
|
|
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);
|
|
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
|
|
token.UserName, string.Join(",", result.Roles));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
|
|
/// <c>anonymous</c> principal has been granted (#520).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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
|
|
/// <see cref="RoleCarryingUserIdentity"/> the UserName path uses, so every downstream gate reads
|
|
/// it identically and there is no anonymous-shaped branch in any of them.
|
|
/// </remarks>
|
|
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);
|
|
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
|
|
OpcUaPrincipals.Anonymous,
|
|
result.Roles.Count == 0 ? "no roles" : 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(_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;
|
|
}
|
|
|
|
/// <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>
|
|
/// <returns>The SDK security policies for the requested profiles.</returns>
|
|
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. The real LDAP-bound validator is wired through
|
|
/// <see cref="IOpcUaUserAuthenticator"/> on <c>StandardServer.SessionManager.ImpersonateUser</c>
|
|
/// (see <see cref="AttachUserAuthenticator"/> / <see cref="HandleImpersonation"/>); the Host
|
|
/// project supplies the LDAP adapter, and <see cref="NullOpcUaUserAuthenticator"/> denies all
|
|
/// UserName logins when none is registered.
|
|
/// </summary>
|
|
/// <returns>The Anonymous and UserName token policies.</returns>
|
|
internal static IEnumerable<UserTokenPolicy> 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,
|
|
};
|
|
}
|
|
|
|
/// <summary>Disposes the application host and cleans up resources.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
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"); }
|
|
}
|
|
}
|
|
}
|