diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md
index e5c7799..d6d000c 100644
--- a/docs/GatewayConfiguration.md
+++ b/docs/GatewayConfiguration.md
@@ -46,6 +46,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"Dashboard": {
"Enabled": true,
"AllowAnonymousLocalhost": true,
+ "RequireHttpsCookie": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
@@ -146,6 +147,7 @@ the affected stream while the MXAccess session remains active.
|--------|---------|-------------|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
+| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
index 166390e..f802660 100644
--- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
[Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests
{
+ /// Verifies that an admin user in the GwAdmin group authenticates successfully.
[LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{
@@ -38,6 +39,7 @@ public sealed class DashboardLdapLiveTests
&& claim.Value == DashboardRoles.Admin);
}
+ /// Verifies that a readonly user without GwAdmin group fails to authenticate.
[LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
{
@@ -53,6 +55,7 @@ public sealed class DashboardLdapLiveTests
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
}
+ /// Verifies that authentication with wrong password fails without leaking the password.
[LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{
@@ -71,6 +74,7 @@ public sealed class DashboardLdapLiveTests
Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal);
}
+ /// Verifies that authentication with unknown username fails.
[LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails()
{
@@ -87,6 +91,7 @@ public sealed class DashboardLdapLiveTests
Assert.Null(result.Principal);
}
+ /// Verifies that authentication fails gracefully when the server is unreachable.
[LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{
diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs
index f5ec851..e97afa5 100644
--- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs
+++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs
@@ -4,6 +4,7 @@ public sealed class LiveLdapFactAttribute : FactAttribute
{
public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_LDAP_TESTS";
+ /// Initializes a live LDAP test fact that skips if LDAP tests are not enabled.
public LiveLdapFactAttribute()
{
if (!Enabled)
@@ -12,5 +13,6 @@ public sealed class LiveLdapFactAttribute : FactAttribute
}
}
+ /// Gets a value indicating whether live LDAP tests are enabled.
public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName);
}
diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs
index 01cbc82..4464c52 100644
--- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs
@@ -1112,6 +1112,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// transitions it to Faulted, which the public gRPC API only exposes indirectly via
/// CloseSession's reply (and not before a graceful close completes).
///
+ /// The session identifier.
+ /// The session if found; otherwise null.
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
return _registry.TryGet(sessionId, out session);
@@ -1534,6 +1536,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private readonly StringBuilder buffer = new();
private readonly object syncRoot = new();
+ /// Gets the accumulated output buffer contents.
public string Captured
{
get
@@ -1545,6 +1548,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
+ /// Writes a line of text to the buffer and inner helper.
+ /// The message to write.
public void WriteLine(string message)
{
lock (syncRoot)
@@ -1555,6 +1560,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
inner.WriteLine(message);
}
+ /// Writes a formatted line of text to the buffer and inner helper.
+ /// The message format string.
+ /// The format arguments.
public void WriteLine(string format, params object[] args)
{
string formatted = string.Format(System.Globalization.CultureInfo.InvariantCulture, format, args);
@@ -1569,11 +1577,13 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
{
+ ///
public Task CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken) => Task.FromResult(null);
+ ///
public Task CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -1581,6 +1591,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
int itemHandle,
CancellationToken cancellationToken) => Task.FromResult(null);
+ ///
public Task CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -1588,6 +1599,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
int itemHandle,
CancellationToken cancellationToken) => Task.FromResult(null);
+ ///
public Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
index ada7dfd..30461c9 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
@@ -683,8 +683,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private sealed class Subscriber(Channel channel, string prefix)
{
+ /// Gets the channel for publishing alarm messages to this subscriber.
public Channel Channel { get; } = channel;
+ /// Determines whether the alarm reference matches this subscriber's filter.
+ /// The alarm reference to match.
public bool Matches(string reference)
{
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs
index 7d8e915..7a56ef6 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs
@@ -8,6 +8,19 @@ public sealed class DashboardOptions
/// Gets whether anonymous localhost access to dashboard is allowed.
public bool AllowAnonymousLocalhost { get; init; } = true;
+ ///
+ /// When true (default), the dashboard auth cookie is restricted to HTTPS
+ /// requests via .
+ /// Set to false for plain-HTTP dev deployments — the cookie then uses
+ /// ,
+ /// which still marks it Secure on any HTTPS request but allows it to
+ /// round-trip over HTTP. Browsers silently drop Secure cookies set over
+ /// plain HTTP from non-localhost hosts, so leaving this true breaks
+ /// dashboard login from a remote browser unless the dashboard is served
+ /// over HTTPS.
+ ///
+ public bool RequireHttpsCookie { get; init; } = true;
+
/// Gets the dashboard snapshot update interval in milliseconds.
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs
index 0ac52fd..f5caf1f 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs
@@ -9,6 +9,7 @@ public sealed class GatewayOptions
///
public AuthenticationOptions Authentication { get; init; } = new();
+ /// Gets LDAP configuration options.
public LdapOptions Ldap { get; init; } = new();
///
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs
index d580569..d63ee4b 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs
@@ -2,25 +2,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class LdapOptions
{
+ /// Gets a value indicating whether LDAP authentication is enabled.
public bool Enabled { get; init; } = true;
+ /// Gets the LDAP server address.
public string Server { get; init; } = "localhost";
+ /// Gets the LDAP server port.
public int Port { get; init; } = 3893;
+ /// Gets a value indicating whether TLS is required for the connection.
public bool UseTls { get; init; }
+ /// Gets a value indicating whether insecure LDAP connections are allowed.
public bool AllowInsecureLdap { get; init; } = true;
+ /// Gets the LDAP search base distinguished name.
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
+ /// Gets the service account distinguished name.
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
+ /// Gets the service account password.
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
+ /// Gets the LDAP attribute name for user names.
public string UserNameAttribute { get; init; } = "cn";
+ /// Gets the LDAP attribute name for display names.
public string DisplayNameAttribute { get; init; } = "cn";
+ /// Gets the LDAP attribute name for group membership.
public string GroupAttribute { get; init; } = "memberOf";
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs
index d526dce..5a983f5 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs
@@ -12,5 +12,6 @@ public sealed class ProtocolOptions
///
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
+ /// Gets or sets the maximum gRPC message size in bytes.
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs
index 02136b1..9381775 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs
@@ -17,8 +17,10 @@ public sealed class SessionOptions
///
public int MaxPendingCommandsPerSession { get; init; } = 128;
+ /// Gets the default session lease duration in seconds.
public int DefaultLeaseSeconds { get; init; } = 1800;
+ /// Gets the interval for sweeping expired session leases in seconds.
public int LeaseSweepIntervalSeconds { get; init; } = 30;
///
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor
index a01fb1b..673e20a 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor
@@ -12,6 +12,7 @@
+