feat(historian-sidecar): TCP bootstrap + env, drop allowed-SID

This commit is contained in:
Joseph Doherty
2026-06-12 11:34:06 -04:00
parent 999e58c605
commit 706077f02f
2 changed files with 38 additions and 16 deletions
@@ -1,5 +1,6 @@
using System;
using System.Security.Principal;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
@@ -8,10 +9,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
/// <summary>
/// Entry point for the Wonderware Historian sidecar. Reads pipe name, allowed-SID,
/// shared secret, and historian connection config from environment (the supervisor
/// passes them at spawn time per <c>driver-stability.md</c>). Hosts a named-pipe server
/// dispatching the five sidecar contracts (PR 3.3) to the Wonderware Historian SDK.
/// Entry point for the Wonderware Historian sidecar. Reads the shared secret, TCP
/// bind/port, optional TLS settings, and historian connection config from environment
/// (the supervisor passes them at spawn time per <c>driver-stability.md</c>). Hosts a
/// TCP server (optionally over TLS) dispatching the five sidecar contracts (PR 3.3) to
/// the Wonderware Historian SDK.
/// </summary>
public static class Program
{
@@ -29,19 +31,19 @@ public static class Program
try
{
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PIPE")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_PIPE not set — supervisor must pass the sidecar pipe name");
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
?? throw new InvalidOperationException("OTOPCUA_ALLOWED_SID not set — supervisor must pass the server principal SID");
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SECRET")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_SECRET not set — supervisor must pass the per-process secret at spawn time");
var allowedSid = new SecurityIdentifier(allowedSidValue);
var tcpPort = TryParseInt("OTOPCUA_HISTORIAN_TCP_PORT", 32569);
var bindRaw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_BIND");
var bind = string.IsNullOrWhiteSpace(bindRaw) ? IPAddress.Any : IPAddress.Parse(bindRaw);
var tlsEnabled = string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_ENABLED"), "true", StringComparison.OrdinalIgnoreCase);
X509Certificate2? tlsCert = tlsEnabled ? LoadTlsCert() : null;
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
// Sidecar can boot in "pipe-only" mode (no real Wonderware Historian SDK
// Sidecar can boot in "tcp idle" mode (no real Wonderware Historian SDK
// initialization) for smoke + IPC tests. Production sets ENABLED=true so the
// SDK opens its connection up front.
var historianEnabled = string.Equals(
@@ -50,7 +52,7 @@ public static class Program
if (!historianEnabled)
{
Log.Information("Wonderware historian sidecar starting in pipe-only mode (OTOPCUA_HISTORIAN_ENABLED!=true) — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
Log.Information("Wonderware historian sidecar starting in tcp idle mode (SDK disabled) (OTOPCUA_HISTORIAN_ENABLED!=true) — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
cts.Token.WaitHandle.WaitOne();
Log.Information("Wonderware historian sidecar stopping cleanly");
return 0;
@@ -59,9 +61,9 @@ public static class Program
using var historian = BuildHistorian();
var alarmWriter = BuildAlarmWriter();
var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter);
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger);
Log.Information("Wonderware historian sidecar serving — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { /* clean shutdown via Ctrl-C */ }
@@ -112,6 +114,26 @@ public static class Program
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
}
/// <summary>
/// Loads the TLS server certificate when TLS is enabled. The reference is either a
/// <c>.pfx</c> file path (decrypted with the optional password env var) or, if not a
/// file, a thumbprint resolved from the <c>LocalMachine\My</c> store.
/// </summary>
private static X509Certificate2 LoadTlsCert()
{
var certRef = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_TLS_CERT not set but TLS enabled — supply a .pfx path or a LocalMachine\\My store thumbprint");
var pwd = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD");
if (System.IO.File.Exists(certRef))
return new X509Certificate2(certRef, pwd, X509KeyStorageFlags.MachineKeySet);
// else treat as a thumbprint in LocalMachine\My
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var found = store.Certificates.Find(X509FindType.FindByThumbprint, certRef.Replace(" ", ""), validOnly: false);
if (found.Count == 0) throw new InvalidOperationException($"OTOPCUA_HISTORIAN_TLS_CERT thumbprint '{certRef}' not found in LocalMachine\\My and is not a file path");
return found[0];
}
/// <summary>
/// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise
/// returns <c>null</c> so <see cref="HistorianFrameHandler"/> falls back to the
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests;
/// <summary>
/// Smoke test confirming the sidecar project links and the test project resolves a
/// ProjectReference to it. Real behavioural tests arrive in PR 3.2 (backend lift) and
/// PR 3.3 (pipe server). For PR 3.1 we just verify the assembly identity is what the
/// ProjectReference to it. Real behavioural tests live with the TCP frame server
/// (<c>TcpFrameServer</c>); here we just verify the assembly identity is what the
/// csproj declares.
/// </summary>
public class ProgramSmokeTests