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());
+}