using System; using System.Net; using System.Security.Cryptography.X509Certificates; using System.Threading; using Serilog; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware; /// /// 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 driver-stability.md). Hosts a /// TCP server (optionally over TLS) dispatching the five sidecar contracts (PR 3.3) to /// the Wonderware Historian SDK. /// public static class Program { /// Entry point for the Wonderware Historian sidecar process. /// Command-line arguments (unused). /// 0 on success, 2 on fatal error. public static int Main(string[] args) { Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.File( @"%ProgramData%\OtOpcUa\historian-wonderware-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)), rollingInterval: RollingInterval.Day) .CreateLogger(); try { 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 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 "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( Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"), "true", StringComparison.OrdinalIgnoreCase); if (!historianEnabled) { 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; } using var historian = BuildHistorian(); var alarmWriter = BuildAlarmWriter(); var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter); using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger); 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 */ } Log.Information("Wonderware historian sidecar stopped cleanly"); return 0; } catch (Exception ex) { Log.Fatal(ex, "Wonderware historian sidecar fatal"); return 2; } finally { Log.CloseAndFlush(); } } /// /// Builds the Wonderware Historian data source from environment variables. Mirrors /// the env-var contract that Driver.Galaxy.Host used in v1; PR 3.W reaffirms /// this contract in install scripts. /// private static HistorianDataSource BuildHistorian() { var cfg = new HistorianConfiguration { Enabled = true, ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase), UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"), Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"), CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30), MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000), FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60), }; var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS"); if (!string.IsNullOrWhiteSpace(servers)) cfg.ServerNames = new System.Collections.Generic.List( servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); Log.Information("Sidecar Historian config — {NodeCount} node(s), port={Port}", cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port); return new HistorianDataSource(cfg); } private static int TryParseInt(string envName, int defaultValue) { var raw = Environment.GetEnvironmentVariable(envName); return int.TryParse(raw, out var parsed) ? parsed : defaultValue; } /// /// Loads the TLS server certificate when TLS is enabled. The reference is either a /// .pfx file path (decrypted with the optional password env var) or, if not a /// file, a thumbprint resolved from the LocalMachine\My store. /// 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]; } /// /// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise /// returns null so falls back to the /// "not configured" reply for any incoming WriteAlarmEvents frame. /// Default is true when OTOPCUA_HISTORIAN_ENABLED=true; explicitly /// set OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false to keep a read-only /// deployment that still loads the SDK for reads. /// internal static IAlarmEventWriter? BuildAlarmWriter() { var raw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED"); var enabled = string.IsNullOrWhiteSpace(raw) ? true : !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase); if (!enabled) { Log.Information("Alarm-event writer disabled (OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false); historian sidecar will reject WriteAlarmEvents frames."); return null; } var cfg = BuildAlarmWriterConfig(); var backend = new SdkAlarmHistorianWriteBackend(cfg); Log.Information("Alarm-event writer enabled — backend=SdkAlarmHistorianWriteBackend server={Server}", cfg.ServerName); return new AahClientManagedAlarmEventWriter(backend); } private static HistorianConfiguration BuildAlarmWriterConfig() { return new HistorianConfiguration { Enabled = true, ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase), UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"), Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"), CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30), FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60), }; } }