diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 1100a22f..f4953b55 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -270,6 +270,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(HandleRemoveServerCert); Receive(HandleListServerCerts); + // UA1: cert-trust reconcile-on-join. A (re)joining site node may have + // missed a trust broadcast while it was down, leaving its PKI store + // divergent forever. The singleton reads its own trusted store and pushes + // each cert to the joining node so the two per-node stores reconverge. + Receive(HandleMemberUp); + Receive(HandleSiteNodeJoined); + Receive(HandleLocalCertExport); + // Internal startup messages Receive(HandleStartupConfigsLoaded); // S5: re-attempt the startup deployed-config load after a transient @@ -306,9 +314,28 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers _healthCollector?.SetActiveNode(true); _logger.LogInformation("DeploymentManagerActor starting — loading deployed configs from SQLite..."); + // UA1: watch for site nodes coming Up so we can reconcile cert trust onto + // them. Cluster-only concern — a no-op in the unit-test ActorSystem which + // has no cluster provider. + if (ClusterAvailable(Context.System)) + { + Cluster.Get(Context.System).Subscribe(Self, + ClusterEvent.SubscriptionInitialStateMode.InitialStateAsEvents, + typeof(ClusterEvent.MemberUp)); + } + LoadDeployedConfigs(); } + /// + /// True when this is running the cluster ref-provider. + /// The unit-test harness uses the local provider, where + /// would throw — the cert reconcile-on-join subscription is skipped there. + /// + private static bool ClusterAvailable(ActorSystem system) => + system is ExtendedActorSystem ext && + ext.Provider.GetType().Name.Contains("Cluster", StringComparison.Ordinal); + /// /// Loads all deployed configs off the actor thread and pipes the result back as a /// . Called from and re-invoked @@ -330,6 +357,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers protected override void PostStop() { _healthCollector?.SetActiveNode(false); + if (ClusterAvailable(Context.System)) + { + Cluster.Get(Context.System).Unsubscribe(Self); + } base.PostStop(); } @@ -1258,6 +1289,77 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers }).PipeTo(sender); } + /// + /// The site node most recently observed coming Up, awaiting its trusted-cert + /// reconcile push. A site cluster is two nodes, so at most one peer reconciles + /// at a time; a single pending slot is sufficient (a second join before the + /// first export returns simply reconciles the newer node — both then converge). + /// + private Address? _pendingCertReconcileTarget; + + /// + /// UA1: a site node came Up. On the singleton this is the trigger to reconcile + /// cert trust onto that node. Self and non-site members are ignored — self is + /// already the source of truth, and only site nodes host a CertStoreActor. + /// + private void HandleMemberUp(ClusterEvent.MemberUp evt) + { + var member = evt.Member; + if (!member.HasRole(SiteClusterRole)) + return; + if (member.Address.Equals(Cluster.Get(Context.System).SelfAddress)) + return; + + Self.Tell(new SiteNodeJoined(member.Address)); + } + + /// + /// Reads this node's own trusted-peer store (via the LOCAL CertStoreActor) so + /// its contents can be pushed to the joined node. The export reply is handled + /// by , which uses the target captured here. + /// + private void HandleSiteNodeJoined(SiteNodeJoined msg) + { + _pendingCertReconcileTarget = msg.Address; + Context.ActorSelection($"/user/{CertStoreActor.WellKnownName}") + .Tell(new ExportLocalTrustedCerts()); + } + + /// + /// Pushes each locally-trusted certificate to the joined node's CertStoreActor. + /// Additive union only: removals are NOT reconciled here (there is no central + /// persistence of cert trust yet — a logged follow-up), and each write is + /// idempotent (overwrites by thumbprint), so re-pushing an already-trusted cert + /// is harmless. Fire-and-forget: a failed push heals on the next MemberUp or a + /// manual re-broadcast. + /// + private void HandleLocalCertExport(LocalCertExport export) + { + var target = _pendingCertReconcileTarget; + _pendingCertReconcileTarget = null; + if (target is null) + return; + + if (!export.Success) + { + _logger.LogWarning( + "Cert-trust reconcile: could not read local trusted store for joining node {Address}: {Error}", + target, export.Error); + return; + } + + var basePath = new RootActorPath(target) / "user" / CertStoreActor.WellKnownName; + foreach (var cert in export.Certs) + { + Context.ActorSelection(basePath).Tell( + new WriteCertToLocalStore(Convert.ToBase64String(cert.DerBytes), cert.Thumbprint)); + } + + _logger.LogInformation( + "Cert-trust reconcile: pushed {Count} trusted cert(s) to joining node {Address}", + export.Certs.Count, target); + } + // ── DCL connection management ── /// @@ -1928,6 +2030,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // ── Internal messages ── + /// + /// UA1: a site cluster node has come Up and should receive a cert-trust + /// reconcile push. Derived from (self and + /// non-site members already filtered out) so the reconcile flow is unit-testable + /// without constructing a real cluster membership event. + /// + internal sealed record SiteNodeJoined(Address Address); + internal record StartupConfigsLoaded(List Configs, string? Error); /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs new file mode 100644 index 00000000..20dbf736 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs @@ -0,0 +1,105 @@ +using Akka.Actor; +using Akka.Cluster; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// UA1: cert-trust reconcile-on-join. When a site node (re)joins the cluster, the +/// Deployment Manager singleton reads its own trusted-peer store and pushes each +/// trusted certificate to the joining node's so the +/// two per-node PKI stores converge — without this, a node that missed a trust +/// broadcast while it was down stays permanently divergent. +/// +/// Needs a cluster provider (the DM subscribes to MemberUp in PreStart and +/// addresses the joined node via a remote actor path), so this lives in its own +/// cluster-enabled ActorSystem rather than the non-cluster DeploymentManagerActorTests. +/// +public class DeploymentManagerCertReconcileTests : TestKit, IDisposable +{ + // In-memory TestTransport (not dot-netty): the cluster extension loads and + // SelfAddress is available, but no socket is bound and the node never has to + // form a real two-node cluster. Role must be "Site" (SiteClusterRole). + private const string ClusterConfig = @" +akka { + actor { provider = cluster } + remote { + enabled-transports = [""akka.remote.test""] + test { + transport-class = ""Akka.Remote.Transport.TestTransport, Akka.Remote"" + applied-adapters = [] + registry-key = dm-cert-reconcile-test + local-address = ""test://dm-cert@localhost:1"" + maximum-payload-bytes = 128000b + scheme-identifier = test + } + } + cluster { roles = [""Site""] } + loglevel = WARNING +}"; + + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly string _dbFile; + + public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile") + { + _dbFile = Path.Combine(Path.GetTempPath(), $"dm-cert-test-{Guid.NewGuid():N}.db"); + _storage = new SiteStorageService( + $"Data Source={_dbFile}", NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService( + NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + void IDisposable.Dispose() + { + Shutdown(); + try { File.Delete(_dbFile); } catch { /* cleanup */ } + } + + /// Forwards every message it receives to a probe, preserving the original sender. + private sealed class ForwardingActor : ReceiveActor + { + public ForwardingActor(IActorRef target) => ReceiveAny(msg => target.Forward(msg)); + } + + private IActorRef CreateDeploymentManager() => + ActorOf(Props.Create(() => new DeploymentManagerActor( + _storage, _compilationService, _sharedScriptLibrary, + null, new SiteRuntimeOptions(), NullLogger.Instance, + null, null, null, null, null, null, null, null))); + + [Fact] + public void SiteNodeJoined_PushesLocalTrustedCertsToJoinedNode() + { + // Stand a forwarder at the well-known cert-store name so both the local + // export Ask and the remote-path write land on our probe (self address in + // this single-node test resolves the remote path back to the local actor). + var certStoreProbe = CreateTestProbe(); + Sys.ActorOf(Props.Create(() => new ForwardingActor(certStoreProbe.Ref)), CertStoreActor.WellKnownName); + + var dm = CreateDeploymentManager(); + + dm.Tell(new DeploymentManagerActor.SiteNodeJoined(Cluster.Get(Sys).SelfAddress)); + + // 1) The singleton reads its own trusted store. + certStoreProbe.ExpectMsg(); + + // 2) Feed the export back; the singleton pushes each cert to the joined node. + dm.Tell(new LocalCertExport(true, null, + new[] { new ExportedCert("ABC123", new byte[] { 1, 2, 3 }) })); + + var write = certStoreProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("ABC123", write.Thumbprint); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), write.DerBase64); + } +}