From a6f53e5b22abb959740eb1d02f6be36dbc690823 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 14:00:56 -0400 Subject: [PATCH] =?UTF-8?q?FOCAS=20Tier-C=20PR=20B=20=E2=80=94=20Driver.FO?= =?UTF-8?q?CAS.Host=20net48=20x86=20skeleton=20+=20pipe=20server.=20Second?= =?UTF-8?q?=20PR=20of=20the=205-PR=20#220=20split.=20Stands=20up=20the=20W?= =?UTF-8?q?indows=20Service=20entry=20point=20+=20named-pipe=20scaffolding?= =?UTF-8?q?=20so=20PR=20C=20has=20a=20place=20to=20move=20the=20Fwlib32=20?= =?UTF-8?q?calls=20into.=20New=20net48=20x86=20project=20(Fwlib32.dll=20is?= =?UTF-8?q?=2032-bit-only,=20same=20bitness=20constraint=20as=20Galaxy.Hos?= =?UTF-8?q?t/MXAccess);=20references=20Driver.FOCAS.Shared=20for=20framing?= =?UTF-8?q?=20+=20DTOs=20so=20the=20wire=20format=20Proxy<->Host=20stays?= =?UTF-8?q?=20a=20single=20type=20system.=20Ships=20four=20files:=20PipeAc?= =?UTF-8?q?l=20creates=20a=20PipeSecurity=20where=20only=20the=20configure?= =?UTF-8?q?d=20server=20principal=20SID=20gets=20ReadWrite+Synchronize=20+?= =?UTF-8?q?=20LocalSystem/BuiltinAdministrators=20are=20explicitly=20denie?= =?UTF-8?q?d=20(so=20a=20compromised=20service=20account=20on=20the=20same?= =?UTF-8?q?=20host=20can't=20escalate=20via=20the=20pipe);=20IFrameHandler?= =?UTF-8?q?=20abstracts=20the=20dispatch=20surface=20with=20HandleAsync=20?= =?UTF-8?q?+=20AttachConnection=20for=20server-push=20event=20sinks;=20Pip?= =?UTF-8?q?eServer=20accepts=20one=20connection=20at=20a=20time,=20verifie?= =?UTF-8?q?s=20the=20peer=20SID=20via=20RunAsClient,=20reads=20the=20first?= =?UTF-8?q?=20Hello=20frame=20+=20matches=20the=20shared-secret=20and=20pr?= =?UTF-8?q?otocol=20major=20version,=20sends=20HelloAck,=20then=20hands=20?= =?UTF-8?q?off=20to=20the=20handler=20until=20EOF=20or=20cancel;=20StubFra?= =?UTF-8?q?meHandler=20fully=20handles=20Heartbeat/HeartbeatAck=20so=20a?= =?UTF-8?q?=20future=20supervisor's=20liveness=20detector=20stays=20happy,?= =?UTF-8?q?=20and=20returns=20ErrorResponse{Code=3Dnot-implemented,Message?= =?UTF-8?q?=3D"Kind=20X=20is=20stubbed=20-=20Fwlib32=20lift=20lands=20in?= =?UTF-8?q?=20PR=20C"}=20for=20every=20data-plane=20request.=20Program.cs?= =?UTF-8?q?=20mirrors=20the=20Galaxy.Host=20startup=20exactly:=20reads=20O?= =?UTF-8?q?TOPCUA=5FFOCAS=5FPIPE=20/=20OTOPCUA=5FALLOWED=5FSID=20/=20OTOPC?= =?UTF-8?q?UA=5FFOCAS=5FSECRET=20from=20the=20env=20(supervisor=20passes?= =?UTF-8?q?=20these=20at=20spawn=20time),=20builds=20Serilog=20rolling-fil?= =?UTF-8?q?e=20logger=20into=20%ProgramData%\OtOpcUa\focas-host-*.log,=20c?= =?UTF-8?q?onstructs=20the=20pipe=20server=20with=20StubFrameHandler,=20lo?= =?UTF-8?q?ops=20on=20RunAsync=20until=20SIGINT.=20Log=20messages=20mark?= =?UTF-8?q?=20the=20backend=20as=20"stub"=20so=20it's=20visible=20in=20log?= =?UTF-8?q?s=20that=20Fwlib32=20isn't=20actually=20connected=20yet.=20Driv?= =?UTF-8?q?er.FOCAS.Host.Tests=20(net48=20x86)=20ships=20three=20integrati?= =?UTF-8?q?on=20tests=20mirroring=20IpcHandshakeIntegrationTests=20from=20?= =?UTF-8?q?Galaxy.Host:=20correct-secret=20handshake=20+=20heartbeat=20rou?= =?UTF-8?q?nd-trip,=20wrong-secret=20rejection=20with=20UnauthorizedAccess?= =?UTF-8?q?Exception,=20and=20a=20new=20test=20that=20sends=20a=20ReadRequ?= =?UTF-8?q?est=20and=20asserts=20the=20StubFrameHandler=20returns=20ErrorR?= =?UTF-8?q?esponse{not-implemented}=20mentioning=20PR=20C=20in=20the=20mes?= =?UTF-8?q?sage=20so=20the=20wiring=20between=20frame=20dispatch=20+=20kin?= =?UTF-8?q?d=20=E2=86=92=20error=20mapping=20is=20locked.=20Tests=20follow?= =?UTF-8?q?=20the=20same=20is-Administrator=20skip=20guard=20as=20Galaxy?= =?UTF-8?q?=20because=20PipeAcl=20denies=20BuiltinAdministrators.=20No=20c?= =?UTF-8?q?hanges=20to=20existing=20driver=20code;=20FOCAS=20unit=20tests?= =?UTF-8?q?=20still=20at=20165/165=20+=20Shared=20tests=20at=2024/24.=20PR?= =?UTF-8?q?=20C=20wires=20the=20real=20Fwlib32=20backend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 2 + .../Ipc/IFrameHandler.cs | 31 ++++ .../Ipc/PipeAcl.cs | 39 +++++ .../Ipc/PipeServer.cs | 152 +++++++++++++++++ .../Ipc/StubFrameHandler.cs | 41 +++++ .../Program.cs | 62 +++++++ ...ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj | 40 +++++ .../IpcHandshakeIntegrationTests.cs | 157 ++++++++++++++++++ ....WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj | 33 ++++ 9 files changed, 557 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 13dbf2f..4217fd8 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -15,6 +15,7 @@ + @@ -43,6 +44,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs new file mode 100644 index 0000000..75cef97 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; + +/// +/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session +/// state and translate request DTOs into Fwlib32 calls. +/// +public interface IFrameHandler +{ + Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct); + + /// + /// Called once per accepted connection after the Hello handshake. Lets the handler + /// attach server-pushed event sinks (data-change notifications, runtime-status + /// changes) to the connection's . Returns an + /// the pipe server disposes when the connection closes — + /// backends use it to unsubscribe from their push sources. + /// + IDisposable AttachConnection(FrameWriter writer); + + public sealed class NoopAttachment : IDisposable + { + public static readonly NoopAttachment Instance = new(); + public void Dispose() { } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs new file mode 100644 index 0000000..aac29a1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs @@ -0,0 +1,39 @@ +using System; +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; + +/// +/// Builds the for the FOCAS Host pipe. Same pattern as +/// Galaxy.Host: only the configured OtOpcUa server principal SID gets +/// ReadWrite | Synchronize; LocalSystem + Administrators are explicitly denied +/// so a compromised service account on the same host can't escalate via the pipe. +/// +public static class PipeAcl +{ + public static PipeSecurity Create(SecurityIdentifier allowedSid) + { + if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid)); + + var security = new PipeSecurity(); + + security.AddAccessRule(new PipeAccessRule( + allowedSid, + PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize, + AccessControlType.Allow)); + + var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + + if (allowedSid != localSystem) + security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny)); + if (allowedSid != admins) + security.AddAccessRule(new PipeAccessRule(admins, PipeAccessRights.FullControl, AccessControlType.Deny)); + + security.SetOwner(allowedSid); + + return security; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs new file mode 100644 index 0000000..582870e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs @@ -0,0 +1,152 @@ +using System; +using System.IO.Pipes; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using Serilog; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; + +/// +/// Accepts one client connection at a time on the FOCAS Host's named pipe with the +/// strict ACL from . Verifies the peer SID + per-process shared +/// secret before any RPC frame is accepted. Mirrors the Galaxy.Host pipe server byte for +/// byte — different MessageKind enum, same negotiation semantics. +/// +public sealed class PipeServer : IDisposable +{ + private readonly string _pipeName; + private readonly SecurityIdentifier _allowedSid; + private readonly string _sharedSecret; + private readonly ILogger _logger; + private readonly CancellationTokenSource _cts = new(); + private NamedPipeServerStream? _current; + + public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger) + { + _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName)); + _allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid)); + _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct); + var acl = PipeAcl.Create(_allowedSid); + + _current = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + inBufferSize: 64 * 1024, + outBufferSize: 64 * 1024, + pipeSecurity: acl); + + try + { + await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false); + + if (!VerifyCaller(_current, out var reason)) + { + _logger.Warning("FOCAS IPC caller rejected: {Reason}", reason); + _current.Disconnect(); + return; + } + + using var reader = new FrameReader(_current, leaveOpen: true); + using var writer = new FrameWriter(_current, leaveOpen: true); + + var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); + if (first is null || first.Value.Kind != FocasMessageKind.Hello) + { + _logger.Warning("FOCAS IPC first frame was not Hello; dropping"); + return; + } + + var hello = MessagePackSerializer.Deserialize(first.Value.Body); + if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal)) + { + await writer.WriteAsync(FocasMessageKind.HelloAck, + new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, + linked.Token).ConfigureAwait(false); + _logger.Warning("FOCAS IPC Hello rejected: shared-secret-mismatch"); + return; + } + + if (hello.ProtocolMajor != Hello.CurrentMajor) + { + await writer.WriteAsync(FocasMessageKind.HelloAck, + new HelloAck + { + Accepted = false, + RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}", + }, + linked.Token).ConfigureAwait(false); + _logger.Warning("FOCAS IPC Hello rejected: major mismatch peer={Peer} server={Server}", + hello.ProtocolMajor, Hello.CurrentMajor); + return; + } + + await writer.WriteAsync(FocasMessageKind.HelloAck, + new HelloAck { Accepted = true, HostName = Environment.MachineName }, + linked.Token).ConfigureAwait(false); + + using var attachment = handler.AttachConnection(writer); + + while (!linked.Token.IsCancellationRequested) + { + var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); + if (frame is null) break; + + await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false); + } + } + finally + { + _current.Dispose(); + _current = null; + } + } + + public async Task RunAsync(IFrameHandler handler, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error(ex, "FOCAS IPC connection loop error — accepting next"); } + } + } + + private bool VerifyCaller(NamedPipeServerStream pipe, out string reason) + { + try + { + pipe.RunAsClient(() => + { + using var wi = WindowsIdentity.GetCurrent(); + if (wi.User is null) + throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller"); + if (wi.User != _allowedSid) + throw new UnauthorizedAccessException( + $"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}"); + }); + reason = string.Empty; + return true; + } + catch (Exception ex) { reason = ex.Message; return false; } + } + + public void Dispose() + { + _cts.Cancel(); + _current?.Dispose(); + _cts.Dispose(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs new file mode 100644 index 0000000..2c28dac --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; + +/// +/// Placeholder handler that returns ErrorResponse{Code=not-implemented} for every +/// FOCAS data-plane request. Exists so PR B can ship the pipe server + ACL + handshake +/// plumbing before PR C moves the Fwlib32 calls. Heartbeats are handled fully so the +/// supervisor's liveness detector stays happy. +/// +public sealed class StubFrameHandler : IFrameHandler +{ + public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) + { + if (kind == FocasMessageKind.Heartbeat) + { + var hb = MessagePackSerializer.Deserialize(body); + return writer.WriteAsync(FocasMessageKind.HeartbeatAck, + new HeartbeatAck + { + MonotonicTicks = hb.MonotonicTicks, + HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }, ct); + } + + return writer.WriteAsync(FocasMessageKind.ErrorResponse, + new ErrorResponse + { + Code = "not-implemented", + Message = $"Kind {kind} is stubbed — Fwlib32 lift lands in PR C", + }, + ct); + } + + public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs new file mode 100644 index 0000000..6450747 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs @@ -0,0 +1,62 @@ +using System; +using System.Security.Principal; +using System.Threading; +using Serilog; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host; + +/// +/// Entry point for the OtOpcUaFocasHost Windows service / console host. The +/// supervisor (Proxy-side) spawns this process per FOCAS driver instance and passes the +/// pipe name, allowed-SID, and per-process shared secret as environment variables. In +/// PR B the backend is — PR C swaps in the real +/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10 +/// driver. +/// +public static class Program +{ + public static int Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + @"%ProgramData%\OtOpcUa\focas-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)), + rollingInterval: RollingInterval.Day) + .CreateLogger(); + + try + { + var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_PIPE") ?? "OtOpcUaFocas"; + var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID") + ?? throw new InvalidOperationException( + "OTOPCUA_ALLOWED_SID not set — the FOCAS Proxy supervisor must pass the server principal SID"); + var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_SECRET") + ?? throw new InvalidOperationException( + "OTOPCUA_FOCAS_SECRET not set — the FOCAS Proxy supervisor must pass the per-process secret at spawn time"); + + var allowedSid = new SecurityIdentifier(allowedSidValue); + + using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger); + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}", + pipeName, allowedSidValue); + + var handler = new StubFrameHandler(); + Log.Warning("OtOpcUaFocasHost backend=stub — Fwlib32 lift lands in PR C"); + + server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); + + Log.Information("OtOpcUaFocasHost stopped cleanly"); + return 0; + } + catch (Exception ex) + { + Log.Fatal(ex, "OtOpcUaFocasHost fatal"); + return 2; + } + finally { Log.CloseAndFlush(); } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj new file mode 100644 index 0000000..b9682f6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj @@ -0,0 +1,40 @@ + + + + Exe + net48 + + x86 + true + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host + OtOpcUa.Driver.FOCAS.Host + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs new file mode 100644 index 0000000..b520c2a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using Serilog; +using Serilog.Core; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests +{ + /// + /// Direct FOCAS Host IPC handshake test. Drives through a + /// hand-rolled pipe client built on / + /// from FOCAS.Shared. Skipped on Administrator shells because PipeAcl denies + /// the BuiltinAdministrators group. + /// + [Trait("Category", "Integration")] + public sealed class IpcHandshakeIntegrationTests + { + private static bool IsAdministrator() + { + using var identity = WindowsIdentity.GetCurrent(); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + + private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)> + ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct) + { + var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + await stream.ConnectAsync(5_000, ct); + + var reader = new FrameReader(stream, leaveOpen: true); + var writer = new FrameWriter(stream, leaveOpen: true); + await writer.WriteAsync(FocasMessageKind.Hello, + new Hello { PeerName = "test-client", SharedSecret = secret }, ct); + + var ack = await reader.ReadFrameAsync(ct); + if (ack is null) throw new EndOfStreamException("no HelloAck"); + if (ack.Value.Kind != FocasMessageKind.HelloAck) + throw new InvalidOperationException("unexpected first frame kind " + ack.Value.Kind); + var ackMsg = MessagePackSerializer.Deserialize(ack.Value.Body); + if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason); + + return (stream, reader, writer); + } + + [Fact] + public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips() + { + if (IsAdministrator()) return; + + using var identity = WindowsIdentity.GetCurrent(); + var sid = identity.User!; + var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}"; + const string secret = "test-secret-2026"; + Logger log = new LoggerConfiguration().CreateLogger(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var server = new PipeServer(pipe, sid, secret, log); + var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token)); + + var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token); + using (stream) + using (reader) + using (writer) + { + await writer.WriteAsync(FocasMessageKind.Heartbeat, + new Heartbeat { MonotonicTicks = 42 }, cts.Token); + + var hbAck = await reader.ReadFrameAsync(cts.Token); + hbAck.HasValue.ShouldBeTrue(); + hbAck!.Value.Kind.ShouldBe(FocasMessageKind.HeartbeatAck); + MessagePackSerializer.Deserialize(hbAck.Value.Body).MonotonicTicks.ShouldBe(42L); + } + + cts.Cancel(); + try { await serverTask; } catch { } + server.Dispose(); + } + + [Fact] + public async Task Handshake_with_wrong_secret_is_rejected() + { + if (IsAdministrator()) return; + + using var identity = WindowsIdentity.GetCurrent(); + var sid = identity.User!; + var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}"; + Logger log = new LoggerConfiguration().CreateLogger(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var server = new PipeServer(pipe, sid, "real-secret", log); + var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token)); + + await Should.ThrowAsync(async () => + { + var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token); + s.Dispose(); + r.Dispose(); + w.Dispose(); + }); + + cts.Cancel(); + try { await serverTask; } catch { } + server.Dispose(); + } + + [Fact] + public async Task Stub_handler_returns_not_implemented_for_data_plane_request() + { + if (IsAdministrator()) return; + + using var identity = WindowsIdentity.GetCurrent(); + var sid = identity.User!; + var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}"; + const string secret = "stub-test"; + Logger log = new LoggerConfiguration().CreateLogger(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var server = new PipeServer(pipe, sid, secret, log); + var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token)); + + var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token); + using (stream) + using (reader) + using (writer) + { + await writer.WriteAsync(FocasMessageKind.ReadRequest, + new ReadRequest + { + SessionId = 1, + Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 }, + DataType = FocasDataTypeCode.Int32, + }, + cts.Token); + + var resp = await reader.ReadFrameAsync(cts.Token); + resp.HasValue.ShouldBeTrue(); + resp!.Value.Kind.ShouldBe(FocasMessageKind.ErrorResponse); + var err = MessagePackSerializer.Deserialize(resp.Value.Body); + err.Code.ShouldBe("not-implemented"); + err.Message.ShouldContain("PR C"); + } + + cts.Cancel(); + try { await serverTask; } catch { } + server.Dispose(); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj new file mode 100644 index 0000000..74b5ccf --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj @@ -0,0 +1,33 @@ + + + + net48 + x86 + true + enable + latest + false + true + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + -- 2.49.1