Compare commits
2 Commits
focas-tier
...
focas-tier
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6f53e5b22 | ||
| b968496471 |
@@ -15,6 +15,7 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
||||||
|
|||||||
31
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session
|
||||||
|
/// state and translate request DTOs into Fwlib32 calls.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFrameHandler
|
||||||
|
{
|
||||||
|
Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <paramref name="writer"/>. Returns an
|
||||||
|
/// <see cref="IDisposable"/> the pipe server disposes when the connection closes —
|
||||||
|
/// backends use it to unsubscribe from their push sources.
|
||||||
|
/// </summary>
|
||||||
|
IDisposable AttachConnection(FrameWriter writer);
|
||||||
|
|
||||||
|
public sealed class NoopAttachment : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NoopAttachment Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="PipeSecurity"/> for the FOCAS Host pipe. Same pattern as
|
||||||
|
/// Galaxy.Host: only the configured OtOpcUa server principal SID gets
|
||||||
|
/// <c>ReadWrite | Synchronize</c>; LocalSystem + Administrators are explicitly denied
|
||||||
|
/// so a compromised service account on the same host can't escalate via the pipe.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accepts one client connection at a time on the FOCAS Host's named pipe with the
|
||||||
|
/// strict ACL from <see cref="PipeAcl"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
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<Hello>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Placeholder handler that returns <c>ErrorResponse{Code=not-implemented}</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StubFrameHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (kind == FocasMessageKind.Heartbeat)
|
||||||
|
{
|
||||||
|
var hb = MessagePackSerializer.Deserialize<Heartbeat>(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;
|
||||||
|
}
|
||||||
62
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
Normal file
62
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point for the <c>OtOpcUaFocasHost</c> 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 <see cref="StubFrameHandler"/> — PR C swaps in the real
|
||||||
|
/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10
|
||||||
|
/// driver.
|
||||||
|
/// </summary>
|
||||||
|
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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<!-- Fwlib32.dll is 32-bit only — x86 target is mandatory. Matches the Galaxy.Host
|
||||||
|
bitness constraint but for a different native library. -->
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
<Prefer32Bit>true</Prefer32Bit>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host</RootNamespace>
|
||||||
|
<AssemblyName>OtOpcUa.Driver.FOCAS.Host</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
|
||||||
|
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
||||||
|
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Direct FOCAS Host IPC handshake test. Drives <see cref="PipeServer"/> through a
|
||||||
|
/// hand-rolled pipe client built on <see cref="FrameReader"/> / <see cref="FrameWriter"/>
|
||||||
|
/// from FOCAS.Shared. Skipped on Administrator shells because <c>PipeAcl</c> denies
|
||||||
|
/// the BuiltinAdministrators group.
|
||||||
|
/// </summary>
|
||||||
|
[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<HelloAck>(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<HeartbeatAck>(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<UnauthorizedAccessException>(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<ErrorResponse>(resp.Value.Body);
|
||||||
|
err.Code.ShouldBe("not-implemented");
|
||||||
|
err.Message.ShouldContain("PR C");
|
||||||
|
}
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await serverTask; } catch { }
|
||||||
|
server.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
<Prefer32Bit>true</Prefer32Bit>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user