diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index 572ce05e..2ffedff4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -5,13 +5,17 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; /// /// Binds the ServerHistorian configuration section that gates the server-side /// HistoryRead backend. When is true, AddServerHistorian -/// registers a read-only WonderwareHistorianClient (supplied by the Host) as the -/// IHistorianDataSource in place of the NullHistorianDataSource default; -/// otherwise the Null default survives and HistoryRead returns GoodNoData-empty. +/// registers a read-only HistorianGateway-backed IHistorianDataSource (supplied by +/// the Host) in place of the NullHistorianDataSource default; otherwise the Null default +/// survives and HistoryRead returns GoodNoData-empty. /// -/// This is the READ path only — there are no DatabasePath / drain / capacity / retention -/// knobs (those belong to the write-side AlarmHistorian store-and-forward sink). The -/// client's own CallTimeout bounds each read; the node manager adds no extra timeout. +/// The read client talks gRPC to the ZB.MOM.WW.HistorianGateway sidecar +/// (historian_gateway.v1) over , authenticating with the +/// peppered-HMAC (histgw_<id>_<secret>) in the +/// Authorization: Bearer header. This is the READ path only — there are no +/// DatabasePath / drain / capacity / retention knobs (those belong to the write-side +/// AlarmHistorian store-and-forward sink). The client's own +/// bounds each read; the node manager adds no extra timeout. /// /// public sealed class ServerHistorianOptions @@ -20,26 +24,38 @@ public sealed class ServerHistorianOptions public const string SectionName = "ServerHistorian"; /// - /// When true, the Wonderware read client is registered as the + /// When true, the HistorianGateway read client is registered as the /// IHistorianDataSource; when false (the default) the no-op /// NullHistorianDataSource stays in place and HistoryRead returns empty. /// public bool Enabled { get; init; } - /// TCP hostname or IP address the Wonderware historian sidecar listens on. - public string Host { get; init; } = "localhost"; + /// + /// Absolute gateway endpoint URI the read client dials (e.g. https://host:5222). + /// The scheme selects the transport: https:// = TLS, http:// = h2c plaintext. + /// Required when is true. + /// + public string Endpoint { get; init; } = ""; - /// TCP port the Wonderware historian sidecar listens on. - public int Port { get; init; } = 32569; + /// + /// The peppered-HMAC API key (histgw_<id>_<secret>) the gateway validates + /// in the Authorization: Bearer header. Supply via the environment variable + /// ServerHistorian__ApiKey — never commit it to config. Required when + /// is true. + /// + public string ApiKey { get; init; } = ""; - /// When true, the client connects over TLS. - public bool UseTls { get; init; } + /// When true (the default), the client connects over TLS; must match the scheme. + public bool UseTls { get; init; } = true; - /// Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning. - public string? ServerCertThumbprint { get; init; } + /// When true, the client accepts a self-signed / untrusted server certificate (dev / on-prem only). + public bool AllowUntrustedServerCertificate { get; init; } - /// Per-process shared secret the sidecar verifies in the Hello frame. - public string SharedSecret { get; init; } = ""; + /// Path to a PEM CA certificate that pins the gateway's TLS trust chain. Null or empty uses the OS trust store. + public string? CaCertificatePath { get; init; } + + /// Per-call deadline applied to each unary gateway read. Defaults to 30 seconds. + public TimeSpan CallTimeout { get; init; } = TimeSpan.FromSeconds(30); /// /// The upper bound on the bounded over-fetch the HistoryRead-Raw paging uses to page WITHIN an @@ -54,15 +70,15 @@ public sealed class ServerHistorianOptions /// Returns operator-facing misconfiguration warnings for an Enabled historian /// (empty when disabled or correctly configured). Pure — the registration logs each entry. - /// Zero or more human-readable warning messages. + /// Zero or more human-readable warning messages (never carrying secret values). public IReadOnlyList Validate() { var warnings = new List(); if (!Enabled) return warnings; - if (string.IsNullOrWhiteSpace(SharedSecret)) - warnings.Add("ServerHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret."); - if (Port <= 0) - warnings.Add($"ServerHistorian:Port is {Port} — must be > 0; the read client cannot dial the sidecar."); + if (string.IsNullOrWhiteSpace(Endpoint)) + warnings.Add("ServerHistorian:Endpoint is empty while the historian is enabled — the read client has no gateway address to dial."); + if (string.IsNullOrWhiteSpace(ApiKey)) + warnings.Add("ServerHistorian:ApiKey is empty while the historian is enabled — the gateway gRPC surface will reject unauthenticated calls."); // MaxTieClusterOverfetch is intentionally checked AFTER the Enabled early-return above: // the over-fetch code path only runs when a real IHistorianDataSource is wired in, // so a zero/negative value is harmless (and noise-free) when the historian is disabled. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs index 5ae9b0ba..bb0b3df6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs @@ -104,9 +104,8 @@ public sealed class AddServerHistorianTests var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "true", - ["ServerHistorian:Host"] = "historian.example.com", - ["ServerHistorian:Port"] = "32569", - ["ServerHistorian:SharedSecret"] = "s", + ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", + ["ServerHistorian:ApiKey"] = "histgw_x_y", }); services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource()); @@ -126,11 +125,10 @@ public sealed class AddServerHistorianTests var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "true", - ["ServerHistorian:Host"] = "historian.example.com", - ["ServerHistorian:Port"] = "12345", + ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", + ["ServerHistorian:ApiKey"] = "histgw_x_y", ["ServerHistorian:UseTls"] = "true", - ["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", - ["ServerHistorian:SharedSecret"] = "s", + ["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem", }); services.AddServerHistorian(config, (opts, _) => @@ -143,55 +141,29 @@ public sealed class AddServerHistorianTests _ = provider.GetRequiredService(); seen.ShouldNotBeNull(); - seen.Host.ShouldBe("historian.example.com"); - seen.Port.ShouldBe(12345); + seen.Endpoint.ShouldBe("https://historian.example.com:5222"); + seen.ApiKey.ShouldBe("histgw_x_y"); seen.UseTls.ShouldBeTrue(); - seen.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); + seen.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem"); } [Fact] - public void Validate_warns_on_empty_shared_secret_when_enabled() - { - var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "", Port = 32569 }; - opts.Validate().ShouldContain(w => w.Contains("SharedSecret")); - } - - [Fact] - public void Validate_warns_on_non_positive_port_when_enabled() - { - var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 0 }; - opts.Validate().ShouldContain(w => w.Contains("Port")); - } - - [Fact] - public void Validate_is_silent_when_correctly_configured() - { - new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 32569 }.Validate().ShouldBeEmpty(); - } - - [Fact] - public void Validate_is_silent_when_disabled() - { - new ServerHistorianOptions { Enabled = false, SharedSecret = "", Port = 0 }.Validate().ShouldBeEmpty(); - } - - [Fact] - public void Section_binds_host_port_tls_fields() + public void Section_binds_endpoint_apikey_tls_fields() { var config = ConfigFrom(new Dictionary { - ["ServerHistorian:Host"] = "historian.example.com", - ["ServerHistorian:Port"] = "12345", + ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", + ["ServerHistorian:ApiKey"] = "histgw_x_y", ["ServerHistorian:UseTls"] = "true", - ["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", + ["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem", }); var opts = config.GetSection(ServerHistorianOptions.SectionName).Get(); opts.ShouldNotBeNull(); - opts.Host.ShouldBe("historian.example.com"); - opts.Port.ShouldBe(12345); + opts.Endpoint.ShouldBe("https://historian.example.com:5222"); + opts.ApiKey.ShouldBe("histgw_x_y"); opts.UseTls.ShouldBeTrue(); - opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); + opts.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs new file mode 100644 index 00000000..f653931e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs @@ -0,0 +1,34 @@ +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; + +/// +/// Covers the gateway-shaped warnings: a disabled or +/// correctly-configured historian is silent; an enabled one with a blank Endpoint or blank +/// ApiKey surfaces an operator-facing warning (carrying no secret value text). +/// +public sealed class ServerHistorianOptionsTests +{ + [Fact] + public void Disabled_yields_no_warnings() + => Assert.Empty(new ServerHistorianOptions { Enabled = false }.Validate()); + + [Fact] + public void Enabled_without_endpoint_warns() + { + var w = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" }.Validate(); + Assert.Contains(w, m => m.Contains("Endpoint")); + } + + [Fact] + public void Enabled_without_apikey_warns() + { + var w = new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "" }.Validate(); + Assert.Contains(w, m => m.Contains("ApiKey")); + } + + [Fact] + public void Valid_config_is_clean() + => Assert.Empty(new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y" }.Validate()); +}