feat(siteruntime): per-node CertStore actor + trust broadcast to both site nodes (T17)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node OPC UA certificate-store actor (T17 / D6). Runs on EVERY site
|
||||
/// node (NOT a singleton) at a well-known name so the Deployment Manager
|
||||
/// singleton can address it on each node via <see cref="ActorSelection"/>.
|
||||
///
|
||||
/// Cert trust is site-local: the trusted-peer PKI store is a directory on each
|
||||
/// node's file system. A trust/remove decision must therefore reach BOTH site
|
||||
/// nodes or the two stores diverge across failover (D6). The singleton fans the
|
||||
/// per-node <see cref="WriteCertToLocalStore"/> / <see cref="RemoveCertFromLocalStore"/>
|
||||
/// message out; this actor performs the actual file write/delete/enumerate.
|
||||
///
|
||||
/// NOTE on the script-trust forbidden-IO rule: that rule applies only to USER
|
||||
/// scripts. This is a framework/DCL actor doing legitimate PKI store file I/O,
|
||||
/// exactly as the underlying OPC UA stack does when it persists rejected certs.
|
||||
/// </summary>
|
||||
public class CertStoreActor : ReceiveActor
|
||||
{
|
||||
/// <summary>
|
||||
/// Well-known actor name. The Deployment Manager singleton addresses each
|
||||
/// site node's instance via <c>{member.Address}/user/{WellKnownName}</c>.
|
||||
/// </summary>
|
||||
public const string WellKnownName = "cert-store";
|
||||
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly string _trustedStoreDir;
|
||||
private readonly string _rejectedStoreDir;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the actor with the deployment-wide OPC UA options used to
|
||||
/// resolve the trusted-peer and rejected store directories.
|
||||
/// </summary>
|
||||
/// <param name="opcUaGlobalOptions">Deployment-wide OPC UA options (store paths).</param>
|
||||
public CertStoreActor(OpcUaGlobalOptions opcUaGlobalOptions)
|
||||
{
|
||||
// Resolve store directories with the SAME fallback logic
|
||||
// RealOpcUaClient.ResolveStorePath uses, so a cert trusted here lands in
|
||||
// the exact store the OPC UA client validates against. That helper is a
|
||||
// private static method on RealOpcUaClient; the tiny logic is replicated
|
||||
// here rather than widening its visibility.
|
||||
_trustedStoreDir = ResolveStorePath(opcUaGlobalOptions.TrustedPeerStorePath, "trusted");
|
||||
_rejectedStoreDir = ResolveStorePath(opcUaGlobalOptions.RejectedCertificateStorePath, "rejected");
|
||||
|
||||
Receive<WriteCertToLocalStore>(HandleWrite);
|
||||
Receive<RemoveCertFromLocalStore>(HandleRemove);
|
||||
Receive<ListLocalCerts>(_ => HandleList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replicates <c>RealOpcUaClient.ResolveStorePath</c>: an empty configured
|
||||
/// path falls back to <c>%TEMP%/ScadaBridge/pki/<leaf></c>.
|
||||
/// </summary>
|
||||
private static string ResolveStorePath(string configured, string fallbackLeaf) =>
|
||||
string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
private void HandleWrite(WriteCertToLocalStore msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_trustedStoreDir);
|
||||
var der = Convert.FromBase64String(msg.DerBase64);
|
||||
var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint));
|
||||
File.WriteAllBytes(path, der);
|
||||
_log.Info("Trusted server certificate {Thumbprint} written to {Path}", msg.Thumbprint, path);
|
||||
Sender.Tell(new LocalCertOpAck(true, null, null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "Failed to write trusted server certificate {Thumbprint}", msg.Thumbprint);
|
||||
Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRemove(RemoveCertFromLocalStore msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint));
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
_log.Info("Trusted server certificate {Thumbprint} removed from {Path}", msg.Thumbprint, path);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Info("Trusted server certificate {Thumbprint} not present; remove is a no-op", msg.Thumbprint);
|
||||
}
|
||||
Sender.Tell(new LocalCertOpAck(true, null, null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "Failed to remove trusted server certificate {Thumbprint}", msg.Thumbprint);
|
||||
Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleList()
|
||||
{
|
||||
try
|
||||
{
|
||||
var certs = new List<TrustedCertInfo>();
|
||||
certs.AddRange(EnumerateStore(_trustedStoreDir, rejected: false));
|
||||
certs.AddRange(EnumerateStore(_rejectedStoreDir, rejected: true));
|
||||
Sender.Tell(new LocalCertOpAck(true, null, certs));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "Failed to list certificates from PKI stores");
|
||||
Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<TrustedCertInfo> EnumerateStore(string storeDir, bool rejected)
|
||||
{
|
||||
if (!Directory.Exists(storeDir))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(storeDir)
|
||||
.Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
TrustedCertInfo? info = null;
|
||||
try
|
||||
{
|
||||
var cert = X509CertificateLoader.LoadCertificate(File.ReadAllBytes(file));
|
||||
info = new TrustedCertInfo(
|
||||
cert.Thumbprint,
|
||||
cert.Subject,
|
||||
cert.Issuer,
|
||||
cert.NotBefore.ToUniversalTime(),
|
||||
cert.NotAfter.ToUniversalTime(),
|
||||
rejected);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A malformed file in the store should not abort the whole listing.
|
||||
_log.Warning(ex, "Skipping unreadable certificate file {File}", file);
|
||||
}
|
||||
|
||||
if (info is not null)
|
||||
{
|
||||
yield return info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FileNameFor(string thumbprint) => $"{thumbprint}.der";
|
||||
}
|
||||
Reference in New Issue
Block a user