using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Serilog; using Serilog.Formatting.Compact; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.S7; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; using ZB.MOM.WW.OtOpcUa.Server; using ZB.MOM.WW.OtOpcUa.Server.Alarms; using ZB.MOM.WW.OtOpcUa.Server.History; using ZB.MOM.WW.OtOpcUa.Server.Hosting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Phase7; using ZB.MOM.WW.OtOpcUa.Server.Redundancy; using ZB.MOM.WW.OtOpcUa.Server.Security; var builder = Host.CreateApplicationBuilder(args); // Per Phase 6.1 Stream C.3: SIEMs (Splunk, Datadog) ingest the JSON file without a // regex parser. Plain-text rolling file stays on by default for human readability; // JSON file is opt-in via appsetting `Serilog:WriteJson = true`. var writeJson = builder.Configuration.GetValue("Serilog:WriteJson"); var loggerBuilder = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .WriteTo.Console() .WriteTo.File("logs/otopcua-.log", rollingInterval: RollingInterval.Day); if (writeJson) { loggerBuilder = loggerBuilder.WriteTo.File( new CompactJsonFormatter(), "logs/otopcua-.json.log", rollingInterval: RollingInterval.Day); } Log.Logger = loggerBuilder.CreateLogger(); builder.Services.AddSerilog(); builder.Services.AddWindowsService(o => o.ServiceName = "OtOpcUa"); var nodeSection = builder.Configuration.GetSection(NodeOptions.SectionName); var options = new NodeOptions { NodeId = nodeSection.GetValue("NodeId") ?? throw new InvalidOperationException("Node:NodeId not configured"), ClusterId = nodeSection.GetValue("ClusterId") ?? throw new InvalidOperationException("Node:ClusterId not configured"), ConfigDbConnectionString = nodeSection.GetValue("ConfigDbConnectionString") ?? throw new InvalidOperationException("Node:ConfigDbConnectionString not configured"), LocalCachePath = nodeSection.GetValue("LocalCachePath") ?? "config_cache.db", }; var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName); var ldapSection = opcUaSection.GetSection("Ldap"); var ldapOptions = new LdapOptions { Enabled = ldapSection.GetValue("Enabled") ?? false, Server = ldapSection.GetValue("Server") ?? "localhost", Port = ldapSection.GetValue("Port") ?? 3893, UseTls = ldapSection.GetValue("UseTls") ?? false, AllowInsecureLdap = ldapSection.GetValue("AllowInsecureLdap") ?? false, SearchBase = ldapSection.GetValue("SearchBase") ?? "dc=lmxopcua,dc=local", ServiceAccountDn = ldapSection.GetValue("ServiceAccountDn") ?? string.Empty, ServiceAccountPassword = ldapSection.GetValue("ServiceAccountPassword") ?? string.Empty, GroupToRole = ldapSection.GetSection("GroupToRole").Get>() ?? new(StringComparer.OrdinalIgnoreCase), }; // Security: an LDAP bind without TLS sends usernames + plaintext passwords in clear // text on the server→LDAP hop. AllowInsecureLdap is a dev-only escape hatch; warn // loudly when a deployment has enabled LDAP and opted into the insecure path. if (ldapOptions.Enabled && !ldapOptions.UseTls && ldapOptions.AllowInsecureLdap) { Log.Warning( "LDAP authentication is enabled with UseTls=false and AllowInsecureLdap=true — " + "credentials are sent in clear text on the server→LDAP hop. Set Ldap:UseTls=true " + "(LDAPS) for production deployments."); } var opcUaOptions = new OpcUaServerOptions { EndpointUrl = opcUaSection.GetValue("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa", ApplicationName = opcUaSection.GetValue("ApplicationName") ?? "OtOpcUa Server", ApplicationUri = opcUaSection.GetValue("ApplicationUri") ?? "urn:OtOpcUa:Server", PkiStoreRoot = opcUaSection.GetValue("PkiStoreRoot") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"), AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue("AutoAcceptUntrustedClientCertificates") ?? true, SecurityProfile = Enum.TryParse(opcUaSection.GetValue("SecurityProfile"), true, out var p) ? p : OpcUaSecurityProfile.None, Ldap = ldapOptions, AnonymousRoles = opcUaSection.GetSection("AnonymousRoles").Get() ?? [], }; builder.Services.AddSingleton(options); builder.Services.AddSingleton(opcUaOptions); builder.Services.AddSingleton(ldapOptions); builder.Services.AddSingleton(sp => ldapOptions.Enabled ? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService>()) : new DenyAllUserAuthenticator()); builder.Services.AddSingleton(_ => new LiteDbConfigCache(options.LocalCachePath)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Task #248 — driver-instance bootstrap pipeline. DriverFactoryRegistry is the // type-name → factory map; each driver project's static Register call pre-loads // its factory so the bootstrapper can materialise DriverInstance rows from the // central DB into live IDriver instances. builder.Services.AddSingleton(_ => { var registry = new DriverFactoryRegistry(); // Galaxy access flows through the in-process GalaxyDriver (DriverType = // "GalaxyMxGateway") talking gRPC to the mxaccessgw worker. The legacy // out-of-process GalaxyProxyDriver retired in PR 7.2 once the parity matrix // (docs/v2/Galaxy.ParityMatrix.md) verified equivalence. ZB.MOM.WW.OtOpcUa.Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry); FocasDriverFactoryExtensions.Register(registry); ModbusDriverFactoryExtensions.Register(registry); AbCipDriverFactoryExtensions.Register(registry); AbLegacyDriverFactoryExtensions.Register(registry); S7DriverFactoryExtensions.Register(registry); TwinCATDriverFactoryExtensions.Register(registry); return registry; }); builder.Services.AddSingleton(); // Phase 6.1 Stream B.4 (task #137) — ScheduledRecycleHostedService. Empty scheduler // list by default; DriverInstanceBootstrapper calls AddScheduler for any Tier C driver // whose ResilienceConfig carries a RecycleIntervalSeconds AND has an IDriverSupervisor // registered in DI. Registered as singleton so DriverInstanceBootstrapper can inject // the same instance that the BackgroundService loop drives. builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's // bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation. // DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155 // added to OpcUaApplicationHost's ctor seam. builder.Services.AddSingleton(); builder.Services.AddScoped(); // Phase 6.2 Stream C wiring — constructs AuthorizationGate + NodeScopeResolver from the // published generation's NodeAcl rows + per-driver EquipmentNamespaceContent. Gated by // NodeOptions.Authorization.Enabled (default false) so existing deployments don't flip // to ACL enforcement accidentally on upgrade. builder.Services.AddSingleton(); // PR 1+2.W — server-level history routing + alarm-condition state machine. Singletons // shared across every DriverNodeManager. The alarm service runs the Active / // Acknowledged / Inactive state machine for any driver that declares alarms via // AlarmConditionInfo's sub-attribute refs. builder.Services.AddSingleton(); builder.Services.AddSingleton(); // PR 3.W — Wonderware historian sidecar wiring. Reads Historian:Wonderware:* from // configuration; when Enabled=true, registers the .NET 10 client as both an // IHistorianDataSource (via IHistoryRouter under the configured driver instance // prefix; defaults to "galaxy") and an IAlarmHistorianWriter (consumed by the // SqliteStoreAndForwardSink drain worker once task #248 wires it). Disabled // deployments fall back to DriverNodeManager's legacy IHistoryProvider adapter // for the read path and NullAlarmHistorianSink for the write path — keeping the // sidecar fully optional until the legacy paths retire in PR 7.2. var wonderwareSection = builder.Configuration.GetSection("Historian:Wonderware"); var wonderwareEnabled = wonderwareSection.GetValue("Enabled", false); if (wonderwareEnabled) { var wonderwarePrefix = wonderwareSection.GetValue("DriverInstancePrefix", "galaxy") ?? throw new InvalidOperationException("Historian:Wonderware:DriverInstancePrefix must be a string when configured."); var wonderwareOptions = new WonderwareHistorianClientOptions( PipeName: wonderwareSection.GetValue("PipeName") ?? throw new InvalidOperationException("Historian:Wonderware:PipeName must be set when Enabled=true."), SharedSecret: wonderwareSection.GetValue("SharedSecret") ?? throw new InvalidOperationException("Historian:Wonderware:SharedSecret must be set when Enabled=true."), PeerName: wonderwareSection.GetValue("PeerName", $"OtOpcUa-{options.NodeId}") ?? "OtOpcUa", ConnectTimeout: TimeSpan.FromSeconds(wonderwareSection.GetValue("ConnectTimeoutSeconds", 10)), CallTimeout: TimeSpan.FromSeconds(wonderwareSection.GetValue("CallTimeoutSeconds", 30))); builder.Services.AddSingleton(wonderwareOptions); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => new WonderwareHistorianBootstrap( sp.GetRequiredService(), sp.GetRequiredService(), wonderwarePrefix, sp.GetRequiredService>())); } builder.Services.AddSingleton(sp => { var registry = sp.GetRequiredService(); return new OpcUaApplicationHost( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), equipmentContentLookup: registry.Get, historyRouter: sp.GetRequiredService(), alarmConditionService: sp.GetRequiredService()); }); builder.Services.AddHostedService(); // Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context // so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick. builder.Services.AddDbContext(opt => opt.UseSqlServer(options.ConfigDbConnectionString)); // Additional pooled factory so Phase 6.3 RedundancyCoordinator (singleton) can create its // own scoped DbContext for topology loading without fighting the scoped HostStatusPublisher. builder.Services.AddDbContextFactory(opt => opt.UseSqlServer(options.ConfigDbConnectionString)); builder.Services.AddHostedService(); // Phase 6.3 Stream C (task #147) — ServiceLevel + ServerUriArray + RedundancySupport node // wiring. Coordinator holds topology; publisher computes ServiceLevel byte + ServerUriArray; // hosted service ticks publisher + pushes values onto the Server object via the node writer. builder.Services.AddSingleton(sp => new RedundancyCoordinator( sp.GetRequiredService>(), sp.GetRequiredService>(), options.NodeId, options.ClusterId)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new RedundancyStatePublisher( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); builder.Services.AddHostedService(); // Phase 6.3 Stream B — two-layer peer-probe loops populating PeerReachabilityTracker. // Without these the publisher sees PeerReachability.Unknown for every peer and degrades // to the Isolated-Primary band (230) even when the peer is up. Safe default but not the // full non-transparent-redundancy UX. builder.Services.AddSingleton(); builder.Services.AddHttpClient(PeerHttpProbeLoop.HttpClientName); builder.Services.AddHostedService(); builder.Services.AddHostedService(); // Phase 6.3 A.2 + 6.1 Stream D — periodic generation refresh. Detects peer-published // generations, opens an ApplyLeaseRegistry lease during the refresh window (so the // publisher surfaces PrimaryMidApply=200 instead of sitting at PrimaryHealthy=255 // through the apply), and calls coordinator.RefreshAsync to pick up topology changes. builder.Services.AddHostedService(); // Phase 7 follow-up #246 — historian sink + engine composer. NullAlarmHistorianSink // is the default until the Galaxy.Host SqliteStoreAndForwardSink writer adapter // lands (task #248). The composer reads Script/VirtualTag/ScriptedAlarm rows on // generation bootstrap, builds the engines, and starts the driver-bridge feed. builder.Services.AddSingleton(NullAlarmHistorianSink.Instance); builder.Services.AddSingleton(Log.Logger); // Serilog root for ScriptLoggerFactory builder.Services.AddSingleton(); var host = builder.Build(); await host.RunAsync();