using System; using System.Collections.Generic; using System.Data.SqlClient; using System.Linq; using Opc.Ua; using Serilog; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration { /// /// Validates and logs effective configuration at startup. (SVC-003, SVC-005) /// public static class ConfigurationValidator { private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator)); /// /// Validates the effective host configuration and writes the resolved values to the startup log before service /// initialization continues. /// /// /// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, /// and dashboard behavior. /// /// /// when the required settings are present and within supported bounds; otherwise, /// . /// public static bool ValidateAndLog(AppConfiguration config) { var valid = true; Log.Information("=== Effective Configuration ==="); // OPC UA Log.Information( "OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}", config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName); Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}", config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes); if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535) { Log.Error("OpcUa.Port must be between 1 and 65535"); valid = false; } if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName)) { Log.Error("OpcUa.GalaxyName must not be empty"); valid = false; } // Alarm filter var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0; Log.Information( "OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]", config.OpcUa.AlarmTrackingEnabled, alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters)); if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled) Log.Warning( "OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect", alarmFilterCount); // MxAccess Log.Information( "MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}", config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds, config.MxAccess.MaxConcurrentOperations); Log.Information( "MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s", config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect, config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds); Log.Information( "MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s", config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds, config.MxAccess.RequestTimeoutSeconds); if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName)) { Log.Error("MxAccess.ClientName must not be empty"); valid = false; } if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5) Log.Warning( "MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback", config.MxAccess.RuntimeStatusUnknownTimeoutSeconds); if (config.MxAccess.RequestTimeoutSeconds < 1) { Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1"); valid = false; } else if (config.MxAccess.RequestTimeoutSeconds < Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds)) { Log.Warning( "MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path", config.MxAccess.RequestTimeoutSeconds, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds); } // Galaxy Repository Log.Information( "GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}", SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds, config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes); var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName) ? Environment.MachineName : config.GalaxyRepository.PlatformName; Log.Information( "GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}", config.GalaxyRepository.Scope, config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform ? effectivePlatformName : "(n/a)"); if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform && string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)) Log.Information( "GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'", Environment.MachineName); if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString)) { Log.Error("GalaxyRepository.ConnectionString must not be empty"); valid = false; } // Dashboard Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s", config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds); // Security Log.Information( "Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}", string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates, config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize); Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)"); Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)"); Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths); var unknownProfiles = config.Security.Profiles .Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase)) .ToList(); if (unknownProfiles.Count > 0) Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}", string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames)); if (config.Security.MinimumCertificateKeySize < 2048) { Log.Error("Security.MinimumCertificateKeySize must be at least 2048"); valid = false; } if (config.Security.AutoAcceptClientCertificates) Log.Warning( "Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production"); if (config.Security.Profiles.Count == 1 && config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase)) Log.Warning("Only the 'None' security profile is configured — transport security is disabled"); // Historian var clusterNodes = config.Historian.ServerNames ?? new List(); var effectiveNodes = clusterNodes.Count > 0 ? string.Join(",", clusterNodes) : config.Historian.ServerName; Log.Information( "Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}", config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity, config.Historian.Port); Log.Information( "Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}", config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead, config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds); if (config.Historian.Enabled) { if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName)) { Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled"); valid = false; } if (config.Historian.FailureCooldownSeconds < 0) { Log.Error("Historian.FailureCooldownSeconds must be zero or positive"); valid = false; } if (config.Historian.RequestTimeoutSeconds < 1) { Log.Error("Historian.RequestTimeoutSeconds must be at least 1"); valid = false; } else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds) { Log.Warning( "Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path", config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds); } if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName) && config.Historian.ServerName != "localhost") Log.Warning( "Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries", config.Historian.ServerName, clusterNodes.Count); if (config.Historian.Port < 1 || config.Historian.Port > 65535) { Log.Error("Historian.Port must be between 1 and 65535"); valid = false; } if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName)) { Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled"); valid = false; } if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password)) Log.Warning("Historian.Password is empty — authentication may fail"); } // Authentication Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}", config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite); if (config.Authentication.Ldap.Enabled) { Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}", config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN); Log.Information( "Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}", config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup, config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup, config.Authentication.Ldap.AlarmAckGroup); if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn)) Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail"); } // Redundancy if (config.OpcUa.ApplicationUri != null) Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri); Log.Information( "Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}", config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, config.Redundancy.ServiceLevelBase); if (config.Redundancy.ServerUris.Count > 0) Log.Information("Redundancy.ServerUris=[{ServerUris}]", string.Join(", ", config.Redundancy.ServerUris)); if (config.Redundancy.Enabled) { if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri)) { Log.Error( "OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity"); valid = false; } if (config.Redundancy.ServerUris.Count < 2) Log.Warning( "Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers"); if (config.OpcUa.ApplicationUri != null && !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri)) Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", config.OpcUa.ApplicationUri); var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true); if (mode == RedundancySupport.None) Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", config.Redundancy.Mode); } if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255) { Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255"); valid = false; } Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID"); return valid; } private static string SanitizeConnectionString(string connectionString) { if (string.IsNullOrWhiteSpace(connectionString)) return "(empty)"; try { var builder = new SqlConnectionStringBuilder(connectionString); if (!string.IsNullOrEmpty(builder.Password)) builder.Password = "********"; return builder.ConnectionString; } catch { return "(unparseable)"; } } } }