Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52997ee164 | |||
| 21eac21409 | |||
| 8b08566f41 |
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Central <see cref="Meter"/> + <see cref="ActivitySource"/> definitions for OtOpcUa.
|
||||
/// All Akka actors, the OPC UA publish path, and the deploy coordinator emit through these
|
||||
/// pre-created instruments so a single OpenTelemetry / Prometheus binding in <c>Host</c>
|
||||
/// catches everything. No exporter is required — instruments are no-op until a listener
|
||||
/// attaches, so tests and dev hosts pay nothing for instrumentation that nobody scrapes.
|
||||
///
|
||||
/// Instrument names follow the OpenTelemetry semantic convention pattern
|
||||
/// <c>otopcua.<subsystem>.<event></c>. Subsystem is one of: deploy, driver,
|
||||
/// virtualtag, scriptedalarm, opcua, redundancy.
|
||||
/// </summary>
|
||||
public static class OtOpcUaTelemetry
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.OtOpcUa";
|
||||
public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa";
|
||||
|
||||
/// <summary>Singleton <see cref="Meter"/> all counters/histograms hang off.</summary>
|
||||
public static readonly Meter Meter = new(MeterName);
|
||||
|
||||
/// <summary>Singleton <see cref="ActivitySource"/> used to start spans wrapping deploy/apply/rebuild.</summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
// ---------------- Deployment / driver-host coordination ----------------
|
||||
|
||||
/// <summary>Incremented every time DriverHostActor finishes applying a deployment (Ack or Reject).</summary>
|
||||
public static readonly Counter<long> DeploymentApplied =
|
||||
Meter.CreateCounter<long>("otopcua.deploy.applied", unit: "{deployment}",
|
||||
description: "Deployments applied by a driver-role node (outcome=ack|reject).");
|
||||
|
||||
/// <summary>Time from DriverHostActor receiving DispatchDeployment to emitting the ack/reject.</summary>
|
||||
public static readonly Histogram<double> DeploymentApplyDurationSec =
|
||||
Meter.CreateHistogram<double>("otopcua.deploy.apply.duration", unit: "s",
|
||||
description: "Driver-role apply latency from DispatchDeployment → Ack/Reject.");
|
||||
|
||||
/// <summary>DriverInstanceActor spawn count (added=new instance; stop=disposed).</summary>
|
||||
public static readonly Counter<long> DriverInstanceLifecycle =
|
||||
Meter.CreateCounter<long>("otopcua.driver.lifecycle", unit: "{event}",
|
||||
description: "DriverInstanceActor lifecycle transitions (event=spawn|stop|fault).");
|
||||
|
||||
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
|
||||
|
||||
public static readonly Counter<long> VirtualTagEval =
|
||||
Meter.CreateCounter<long>("otopcua.virtualtag.eval", unit: "{eval}",
|
||||
description: "Virtual-tag evaluations attempted (outcome=ok|fail|skip).");
|
||||
|
||||
public static readonly Counter<long> ScriptedAlarmTransition =
|
||||
Meter.CreateCounter<long>("otopcua.scriptedalarm.transition", unit: "{transition}",
|
||||
description: "Scripted-alarm state transitions (state=active|acknowledged|inactive).");
|
||||
|
||||
// ---------------- OPC UA address-space + redundancy ----------------
|
||||
|
||||
public static readonly Counter<long> OpcUaSinkWrite =
|
||||
Meter.CreateCounter<long>("otopcua.opcua.sink.write", unit: "{write}",
|
||||
description: "Writes that landed in IOpcUaAddressSpaceSink (kind=value|alarm|rebuild).");
|
||||
|
||||
public static readonly Counter<long> ServiceLevelChange =
|
||||
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
|
||||
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
|
||||
|
||||
// ---------------- Convenience helpers ----------------
|
||||
|
||||
/// <summary>
|
||||
/// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns
|
||||
/// null when no listener is attached so the call site stays cheap on undecorated builds.
|
||||
/// </summary>
|
||||
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
||||
activity?.SetTag("otopcua.deployment_id", deploymentId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply).</summary>
|
||||
public static Activity? StartAddressSpaceRebuildSpan()
|
||||
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Wires the OtOpcUa Meter + ActivitySource into OpenTelemetry and exposes a Prometheus
|
||||
/// scrape endpoint at <c>/metrics</c> on the host pipeline. F13d slice — only the meter +
|
||||
/// activity source declared in <see cref="OtOpcUaTelemetry"/> are surfaced; per-Akka
|
||||
/// internals + ASP.NET request metrics stay off by default to keep the scrape payload
|
||||
/// scoped to OtOpcUa-owned signals.
|
||||
/// </summary>
|
||||
public static class ObservabilityExtensions
|
||||
{
|
||||
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services)
|
||||
{
|
||||
services.AddOpenTelemetry()
|
||||
.WithMetrics(b => b
|
||||
.AddMeter(OtOpcUaTelemetry.MeterName)
|
||||
.AddPrometheusExporter())
|
||||
.WithTracing(b => b
|
||||
.AddSource(OtOpcUaTelemetry.ActivitySourceName));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mounts the Prometheus scrape endpoint on the existing ASP.NET pipeline. Call after
|
||||
/// <c>app.UseAuthentication/UseAuthorization</c> if metrics access should require auth;
|
||||
/// the default leaves it unauthenticated for local Prometheus scrapes.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPrometheusScrapingEndpoint("/metrics");
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
||||
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
||||
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
||||
/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
|
||||
/// them off <c>OperationContext.UserIdentity</c> downstream.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticator(
|
||||
ILdapAuthService ldap,
|
||||
ILogger<LdapOpcUaUserAuthenticator> logger)
|
||||
: IOpcUaUserAuthenticator
|
||||
{
|
||||
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
||||
if (!result.Success)
|
||||
{
|
||||
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
||||
}
|
||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
|
||||
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
@@ -21,6 +22,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
||||
|
||||
@@ -30,10 +32,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
public OtOpcUaServerHostedService(
|
||||
IConfiguration configuration,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_deferredSink = deferredSink;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
||||
}
|
||||
@@ -44,7 +48,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_configuration.GetSection("OpcUa").Bind(options);
|
||||
|
||||
_server = new OtOpcUaSdkServer();
|
||||
_appHost = new OpcUaApplicationHost(options, _loggerFactory.CreateLogger<OpcUaApplicationHost>());
|
||||
_appHost = new OpcUaApplicationHost(
|
||||
options,
|
||||
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
|
||||
_userAuthenticator);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -11,10 +11,13 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.OtOpcUa.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -56,6 +59,15 @@ if (hasDriver)
|
||||
builder.Services.AddSingleton<DeferredAddressSpaceSink>();
|
||||
builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp =>
|
||||
sp.GetRequiredService<DeferredAddressSpaceSink>());
|
||||
|
||||
// F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use.
|
||||
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
|
||||
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
|
||||
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
|
||||
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||
|
||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
@@ -83,6 +95,7 @@ if (hasAdmin)
|
||||
}
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -98,6 +111,7 @@ if (hasAdmin)
|
||||
}
|
||||
|
||||
app.MapOtOpcUaHealth();
|
||||
app.MapOtOpcUaMetrics();
|
||||
|
||||
Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})",
|
||||
string.Join(",", roles), hasAdmin, hasDriver);
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,9 +2,26 @@ 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,
|
||||
}
|
||||
|
||||
public sealed class OpcUaApplicationHostOptions
|
||||
{
|
||||
public string ApplicationName { get; set; } = "OtOpcUa";
|
||||
@@ -26,6 +43,26 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// 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>
|
||||
@@ -41,16 +78,20 @@ public sealed class OpcUaApplicationHostOptions
|
||||
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;
|
||||
|
||||
public OpcUaApplicationHost(
|
||||
OpcUaApplicationHostOptions options,
|
||||
ILogger<OpcUaApplicationHost> logger)
|
||||
ILogger<OpcUaApplicationHost> logger,
|
||||
IOpcUaUserAuthenticator? userAuthenticator = null)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
|
||||
}
|
||||
|
||||
public ApplicationInstance? ApplicationInstance => _application;
|
||||
@@ -70,10 +111,99 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _application.Start(server).ConfigureAwait(false);
|
||||
|
||||
AttachUserAuthenticator();
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session 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>
|
||||
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
|
||||
{
|
||||
password = token.DecryptedPassword ?? string.Empty;
|
||||
}
|
||||
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
|
||||
@@ -103,21 +233,30 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true);
|
||||
}
|
||||
|
||||
// Minimal defaults — security and certificate stores hardcoded to local files in
|
||||
// the app's working directory. Full security wiring stays in legacy Server until F13.
|
||||
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 = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
},
|
||||
ServerConfiguration = serverConfig,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
@@ -129,7 +268,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
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 = false,
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas(),
|
||||
ClientConfiguration = new ClientConfiguration(),
|
||||
@@ -141,8 +280,80 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
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>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
public 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;
|
||||
|
||||
try { _application?.Stop(); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
|
||||
/// application cert) and hands the cleartext username + password to this seam. Implementations
|
||||
/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
|
||||
///
|
||||
/// Production implementation lives in the Host project (wraps <c>ILdapAuthService</c>); the
|
||||
/// <see cref="NullOpcUaUserAuthenticator"/> default rejects every attempt so misconfigured
|
||||
/// dev nodes don't silently accept credentials.
|
||||
/// </summary>
|
||||
public interface IOpcUaUserAuthenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves cleartext UserName credentials against the configured backing store. Must not
|
||||
/// throw — callers turn results into <c>ImpersonateEventArgs.IdentityValidationError</c>
|
||||
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
|
||||
/// path where it surfaces as a generic <c>BadInternalError</c>.
|
||||
/// </summary>
|
||||
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||
public sealed record OpcUaUserAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<string> Roles,
|
||||
string? Error)
|
||||
{
|
||||
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
|
||||
new(true, displayName, roles, null);
|
||||
|
||||
public static OpcUaUserAuthResult Deny(string error) =>
|
||||
new(false, null, Array.Empty<string>(), error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default deny-all authenticator. Wired by <c>OpcUaApplicationHost</c> when no production
|
||||
/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
|
||||
/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
|
||||
/// </summary>
|
||||
public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
public static readonly NullOpcUaUserAuthenticator Instance = new();
|
||||
private NullOpcUaUserAuthenticator() { }
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
@@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -239,6 +241,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_applyingDeploymentId = deploymentId;
|
||||
Become(Applying);
|
||||
|
||||
using var span = OtOpcUaTelemetry.StartDeployApplySpan(deploymentId.ToString());
|
||||
span?.SetTag("otopcua.node_id", _localNode.ToString());
|
||||
span?.SetTag("otopcua.revision", revision.ToString());
|
||||
span?.SetTag("otopcua.correlation_id", correlation.ToString());
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Persist Applying row (idempotent on PK).
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applying, failureReason: null);
|
||||
|
||||
@@ -252,6 +260,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// composition. The publish actor handles the load-compose-diff-apply pipeline; we
|
||||
// just forward the same correlation id so the audit trail joins up.
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation));
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "ack"));
|
||||
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})",
|
||||
_localNode, deploymentId, revision, _children.Count);
|
||||
}
|
||||
@@ -259,10 +268,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Failed, ex.Message);
|
||||
SendAck(deploymentId, ApplyAckOutcome.Failed, ex.Message, correlation);
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "reject"));
|
||||
span?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_log.Error(ex, "DriverHost {Node}: apply of {Id} failed", _localNode, deploymentId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
OtOpcUaTelemetry.DeploymentApplyDurationSec.Record(sw.Elapsed.TotalSeconds);
|
||||
_applyingDeploymentId = null;
|
||||
Become(Steady);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -82,6 +83,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_driver = driver;
|
||||
_driverInstanceId = driver.DriverInstanceId;
|
||||
_reconnectInterval = reconnectInterval;
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
||||
new KeyValuePair<string, object?>("driver_type", driver.DriverType));
|
||||
if (startStubbed)
|
||||
{
|
||||
Context.GetLogger().Info("[DEV-STUB] driver={Name} type={Type}",
|
||||
@@ -314,5 +318,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
DetachSubscription();
|
||||
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", "stop"),
|
||||
new KeyValuePair<string, object?>("driver_type", _driver.DriverType));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
@@ -124,6 +125,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
{
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -137,6 +139,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
{
|
||||
_sink.WriteAlarmState(msg.AlarmNodeId, msg.Active, msg.Acknowledged, msg.TimestampUtc);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -146,12 +149,19 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
// Two modes: when dbFactory + applier are wired, do a real diff-and-apply pass against
|
||||
// the latest deployment artifact. Without them, fall back to a raw sink rebuild — the
|
||||
// F10b/dev path before the integration completes.
|
||||
if (_dbFactory is null || _applier is null)
|
||||
{
|
||||
try { _sink.RebuildAddressSpace(); }
|
||||
try
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
|
||||
@@ -175,6 +185,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
|
||||
var outcome = _applier.Apply(plan);
|
||||
_lastApplied = composition;
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
||||
}
|
||||
@@ -211,6 +222,8 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
try
|
||||
{
|
||||
_serviceLevel.Publish(msg.ServiceLevel);
|
||||
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
|
||||
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
|
||||
_log.Debug("OpcUaPublish: ServiceLevel={Level}", msg.ServiceLevel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
@@ -173,6 +174,9 @@ public sealed class ScriptedAlarmActor : ReceiveActor
|
||||
_ => next.ToString(),
|
||||
};
|
||||
|
||||
OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
|
||||
new KeyValuePair<string, object?>("state", kind.ToLowerInvariant()));
|
||||
|
||||
var evt = new AlarmTransitionEvent(
|
||||
AlarmId: _config.AlarmId,
|
||||
EquipmentPath: _config.EquipmentPath,
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
@@ -95,24 +96,35 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "VirtualTag {Id}: evaluator threw", _virtualTagId);
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
|
||||
PublishLog("Error", $"evaluator threw: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
|
||||
PublishLog("Warning", result.Reason ?? "evaluator failure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip no-change results. Real evaluator returns Ok(value); Null returns NoChange — both
|
||||
// safe because Null never produces a fresh value.
|
||||
if (ReferenceEquals(result, VirtualTagEvalResult.NoChange)) return;
|
||||
if (ReferenceEquals(result, VirtualTagEvalResult.NoChange))
|
||||
{
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasLastValue && Equals(_lastValue, result.Value)) return;
|
||||
if (_hasLastValue && Equals(_lastValue, result.Value))
|
||||
{
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
|
||||
return;
|
||||
}
|
||||
|
||||
_hasLastValue = true;
|
||||
_lastValue = result.Value;
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "ok"));
|
||||
var evalResult = new EvaluationResult(_virtualTagId, result.Value, msg.TimestampUtc, CorrelationId.NewId());
|
||||
Context.Parent.Tell(evalResult);
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
|
||||
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
|
||||
/// backend exceptions into a denial rather than letting them escape into the SDK.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("Alice");
|
||||
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Invalid username or password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||
{
|
||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("backend");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("alice");
|
||||
}
|
||||
|
||||
private sealed class FakeLdap : ILdapAuthService
|
||||
{
|
||||
private readonly Func<string, LdapAuthResult> _handler;
|
||||
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
|
||||
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
|
||||
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
=> Task.FromResult(_handler(username));
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F13c — verifies the impersonation handler routes UserName tokens through
|
||||
/// <see cref="IOpcUaUserAuthenticator"/> and translates its result into the SDK's
|
||||
/// <see cref="ImpersonateEventArgs"/> shape (granted identity vs. rejection status).
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostImpersonationTests
|
||||
{
|
||||
private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
|
||||
private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
|
||||
|
||||
[Fact]
|
||||
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = "secret" };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(
|
||||
OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
args.Identity.ShouldNotBeNull();
|
||||
args.IdentityValidationError.ShouldBeNull();
|
||||
authenticator.LastUsername.ShouldBe("alice");
|
||||
authenticator.LastPassword.ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = "wrong" };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials"));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default()
|
||||
{
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
// Handler leaves anonymous tokens untouched — no identity, no validation error.
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.ShouldBeNull();
|
||||
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = "x" };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleImpersonation_null_username_treated_as_empty_string()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = "abc" };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
authenticator.LastUsername.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullOpcUaUserAuthenticator_always_denies()
|
||||
{
|
||||
var result = await NullOpcUaUserAuthenticator.Instance
|
||||
.AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator
|
||||
{
|
||||
public string? LastUsername { get; private set; }
|
||||
public string? LastPassword { get; private set; }
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
LastUsername = username;
|
||||
LastPassword = password;
|
||||
return Task.FromResult(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
|
||||
{
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
=> Task.FromException<OpcUaUserAuthResult>(ex);
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F13b — verifies <see cref="OpcUaApplicationHost"/> publishes one
|
||||
/// <see cref="ServerSecurityPolicy"/> per <see cref="OpcUaSecurityProfile"/> and emits both
|
||||
/// Anonymous and UserName <see cref="UserTokenPolicy"/> entries. The pure-builder tests run
|
||||
/// cross-platform without touching disk; the boot-verify test reuses the F13a PKI pattern.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostSecurityTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-pki-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public void BuildSecurityPolicies_default_set_emits_all_three_baseline_profiles()
|
||||
{
|
||||
var policies = OpcUaApplicationHost.BuildSecurityPolicies(new[]
|
||||
{
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
}).ToList();
|
||||
|
||||
policies.Count.ShouldBe(3);
|
||||
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
policies[1].SecurityMode.ShouldBe(MessageSecurityMode.Sign);
|
||||
policies[1].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
policies[2].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
policies[2].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSecurityPolicies_dedupes_repeated_profiles()
|
||||
{
|
||||
var policies = OpcUaApplicationHost.BuildSecurityPolicies(new[]
|
||||
{
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
OpcUaSecurityProfile.None,
|
||||
}).ToList();
|
||||
|
||||
policies.Count.ShouldBe(2);
|
||||
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
policies[1].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSecurityPolicies_empty_input_falls_back_to_none()
|
||||
{
|
||||
var policies = OpcUaApplicationHost.BuildSecurityPolicies(Array.Empty<OpcUaSecurityProfile>()).ToList();
|
||||
|
||||
policies.Count.ShouldBe(1);
|
||||
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUserTokenPolicies_emits_anonymous_and_username()
|
||||
{
|
||||
var tokens = OpcUaApplicationHost.BuildUserTokenPolicies().ToList();
|
||||
|
||||
tokens.Count.ShouldBe(2);
|
||||
tokens.ShouldContain(t => t.TokenType == UserTokenType.Anonymous && t.PolicyId == "anonymous");
|
||||
var userName = tokens.Single(t => t.TokenType == UserTokenType.UserName);
|
||||
userName.PolicyId.ShouldBe("username_basic256sha256");
|
||||
userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_populates_ServerConfiguration_with_all_enabled_profiles()
|
||||
{
|
||||
await using var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.SecAll",
|
||||
ApplicationUri = $"urn:OtOpcUa.SecAll:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
EnabledSecurityProfiles =
|
||||
{
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
},
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
await host.StartAsync(new StandardServer(), Ct);
|
||||
|
||||
var config = host.ApplicationInstance!.ApplicationConfiguration;
|
||||
config.ServerConfiguration.SecurityPolicies.Count.ShouldBe(3);
|
||||
config.ServerConfiguration.UserTokenPolicies.Count.ShouldBe(2);
|
||||
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeTrue();
|
||||
|
||||
var modes = config.ServerConfiguration.SecurityPolicies
|
||||
.Select(p => p.SecurityMode)
|
||||
.OrderBy(m => (int)m)
|
||||
.ToArray();
|
||||
modes.ShouldBe(new[] { MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_with_only_signandencrypt_omits_None_endpoint()
|
||||
{
|
||||
await using var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.SecHardened",
|
||||
ApplicationUri = $"urn:OtOpcUa.SecHardened:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt },
|
||||
AutoAcceptUntrustedClientCertificates = false,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
await host.StartAsync(new StandardServer(), Ct);
|
||||
|
||||
var policies = host.ApplicationInstance!.ApplicationConfiguration.ServerConfiguration.SecurityPolicies;
|
||||
policies.Count.ShouldBe(1);
|
||||
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
host.ApplicationInstance.ApplicationConfiguration.SecurityConfiguration
|
||||
.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// F13d — verifies the instrumentation sites actually emit on the central
|
||||
/// <see cref="OtOpcUaTelemetry"/> meter + activity source. Each test attaches a one-shot
|
||||
/// listener, exercises the instrumented path, then asserts the recorded measurement matches.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void VirtualTagActor_evaluation_emits_otopcua_virtualtag_eval_counter()
|
||||
{
|
||||
using var recorder = new MeterRecorder("otopcua.virtualtag.eval");
|
||||
var parent = CreateTestProbe();
|
||||
var evaluator = new ConstEval(42);
|
||||
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-tel-1", "expr", evaluator: evaluator));
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
|
||||
recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
|
||||
recorder.WithTag("outcome", "ok").ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaPublishActor_AttributeValueUpdate_emits_sink_write_counter()
|
||||
{
|
||||
using var recorder = new MeterRecorder("otopcua.opcua.sink.write");
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink,
|
||||
serviceLevel: NullServiceLevelPublisher.Instance,
|
||||
subscribeRedundancyTopic: false,
|
||||
localNode: Commons.Types.NodeId.Parse("test-node")));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||
NodeId: "ns=2;s=tag-1",
|
||||
Value: 42,
|
||||
Quality: OpcUaQuality.Good,
|
||||
TimestampUtc: DateTime.UtcNow));
|
||||
|
||||
AwaitAssertion(() =>
|
||||
{
|
||||
recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
|
||||
recorder.WithTag("kind", "value").ShouldBeGreaterThanOrEqualTo(1);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildAddressSpace_starts_an_address_space_rebuild_span()
|
||||
{
|
||||
using var spanRecorder = new ActivityRecorder("otopcua.opcua.address_space_rebuild");
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink,
|
||||
serviceLevel: NullServiceLevelPublisher.Instance,
|
||||
subscribeRedundancyTopic: false,
|
||||
localNode: Commons.Types.NodeId.Parse("test-node")));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(Commons.Types.CorrelationId.NewId()));
|
||||
|
||||
AwaitAssertion(() => spanRecorder.Activities.ShouldContain(a => a.OperationName == "otopcua.opcua.address_space_rebuild"));
|
||||
}
|
||||
|
||||
private void AwaitAssertion(Action assertion)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
Exception? last = null;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try { assertion(); return; }
|
||||
catch (Exception ex) { last = ex; Thread.Sleep(25); }
|
||||
}
|
||||
if (last is not null) throw last;
|
||||
}
|
||||
|
||||
/// <summary>Listens to a single instrument by name and tallies the values + tags.</summary>
|
||||
private sealed class MeterRecorder : IDisposable
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly MeterListener _listener;
|
||||
private long _total;
|
||||
private readonly List<KeyValuePair<string, object?>[]> _tagSets = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public MeterRecorder(string instrumentName)
|
||||
{
|
||||
_name = instrumentName;
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == _name)
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_total += value;
|
||||
_tagSets.Add(tags.ToArray());
|
||||
}
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public long Total { get { lock (_gate) return _total; } }
|
||||
|
||||
public int WithTag(string key, string value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _tagSets.Count(set => set.Any(t => t.Key == key && Equals(t.Value, value)));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Listens to a single ActivitySource by name and stores started Activities.</summary>
|
||||
private sealed class ActivityRecorder : IDisposable
|
||||
{
|
||||
private readonly string _operationName;
|
||||
private readonly ActivityListener _listener;
|
||||
private readonly List<Activity> _activities = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public ActivityRecorder(string operationName)
|
||||
{
|
||||
_operationName = operationName;
|
||||
_listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name == OtOpcUaTelemetry.ActivitySourceName,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
ActivityStarted = activity =>
|
||||
{
|
||||
if (activity.OperationName == _operationName)
|
||||
{
|
||||
lock (_gate) _activities.Add(activity);
|
||||
}
|
||||
}
|
||||
};
|
||||
ActivitySource.AddActivityListener(_listener);
|
||||
}
|
||||
|
||||
public IReadOnlyList<Activity> Activities { get { lock (_gate) return _activities.ToArray(); } }
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
private sealed class ConstEval(object? value) : IVirtualTagEvaluator
|
||||
{
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.Ok(value);
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public int Writes { get; private set; }
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
|
||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user