Compare commits

..

138 Commits

Author SHA1 Message Date
Joseph Doherty 410acc92eb feat(dashboard): distinct 'forced' subtag provider badge
Render Fallback:Mode=ForceSubtag as a cyan 'Subtag monitoring (forced)'
badge, distinct from the amber failover 'degraded' badge, so an intentional
configuration isn't shown as a fault. Distinguished by the shared
AlarmProviderReasons.ForcedSubtag reason carried on the provider-status feed.
2026-06-15 01:43:17 -04:00
Joseph Doherty b40aaeef05 docs: forced-subtag fix resolution (#1 proto artifact, #2 fixed) 2026-06-15 01:33:14 -04:00
Joseph Doherty 9208225f9c fix: gateway reflects configured forced provider mode into gauge/feed (#2) 2026-06-15 01:10:04 -04:00
Joseph Doherty c6f17557f6 docs: forced-subtag mode fix plan 2026-06-15 01:04:46 -04:00
Joseph Doherty bbbef4d098 D2: document no current duplicate / endpoint (no-op) 2026-06-14 23:49:47 -04:00
Joseph Doherty 4af24b9518 D1: surface AlarmProviderSwitchCount on dashboard metric list 2026-06-14 23:49:02 -04:00
Joseph Doherty 371ce53409 docs: add deferred follow-ups plan (D1-D5) 2026-06-14 23:46:21 -04:00
Joseph Doherty 597677025f Merge alarm-fallback cleanup: metrics snapshot/reason, SQL prune, teardown, doc drift
Implements the actionable deferred items from pending.md (B1-B5, C6-C7):
- B1/B2 metrics: provider-switch count in snapshot + bounded reason enum
- B5: drop dead primitive branch from AlarmAttributesSql
- B3/B4 worker: UnAdvise only advised handles (+Dispose tests); remove dead field
- C6/C7: doc clarifications and design-doc superseded notes

Verified: gateway tests on macOS, net48/x86 worker suite (318 passed) on windev.
2026-06-14 02:39:10 -04:00
Joseph Doherty 393e326275 docs(alarms): note operator/IDE toggle drives the live subtag smoke test
C6a: the rig's TestAlarm attributes are object-driven; a flip script OR a manual
operator/IDE toggle drives them (confirmed live 2026-06-14). Update the how-to-run
comments and Skip reason accordingly.
2026-06-14 02:35:59 -04:00
Joseph Doherty 986dcee14a worker(alarms): UnAdvise only advised handles in LmxSubtagAlarmSource teardown
B3: track advised handles separately from added handles so Dispose only UnAdvises
items that were actually advised — a write-only subtag (e.g. ack-comment added by
Write, never advised) is removed but not unadvised. Add Dispose tests covering the
advised/write-only split, full removal, single Unregister, and double-dispose
idempotency.
2026-06-14 02:35:59 -04:00
Joseph Doherty a3752799de worker(alarms): remove dead FailoverAlarmConsumer.subscriptionExpression
B4: the field was stored in Subscribe but never read — the primary is never
re-subscribed during probing. Drop it and keep the rationale as a comment.
2026-06-14 02:35:59 -04:00
Joseph Doherty 37aadf72b3 docs(alarms): clarify resolver cancellation contract; mark design doc superseded
C6b: IAlarmWatchListResolver.ResolveAsync doc now notes that while discovery being
unavailable never throws, a triggered cancellation token still propagates.
C7: annotate the original design doc where it drifted from the shipped code — metric
names / unimplemented watch-list gauges, and the proto-type location (gateway proto, not
worker proto).
2026-06-14 02:33:14 -04:00
Joseph Doherty 5573f2a229 galaxy(alarms): drop dead primitive branch from AlarmAttributesSql
B5: the candidate CTE's src_pri=1 (primitive-instance) UNION ALL branch was always
excluded by the final WHERE r.src_pri=0, so it added work with no output change. Remove
the branch and the now-constant src_pri column/filter. An alarm anchor is always a user
attribute, so output is identical.
2026-06-14 02:33:14 -04:00
Joseph Doherty 56abd64c6c metrics(alarms): expose provider-switch count in snapshot, bound the reason tag
B1: add AlarmProviderSwitchCount to GatewayMetricsSnapshot so the switch total is
readable without scraping the OTEL counter.
B2: replace the free-text reason tag on mxgateway.alarms.provider_switches with a
bounded AlarmProviderSwitchReason enum (failover/failback/unknown); the human-readable
reason stays in the structured log.
2026-06-14 02:33:02 -04:00
Joseph Doherty 5b31e99ab6 alarms: compose subtag reference from object's real Galaxy area for exact alarmmgr parity 2026-06-14 02:12:11 -04:00
Joseph Doherty 64db828d71 docs(alarms): record live confirmation of subtag path + ack; advise-before-write requirement 2026-06-13 11:26:08 -04:00
Joseph Doherty 1a9367b5de worker(alarms): advise ack-comment subtag so the ack write targets an active MXAccess item 2026-06-13 11:23:39 -04:00
Joseph Doherty 98e997b573 test(alarms): probe writes evidence log to PROBE_LOG file 2026-06-13 11:15:05 -04:00
Joseph Doherty 0e8d911fd8 test(alarms): live runtime-path resolution probe (LiveMxAccessFact) for alarm subtags 2026-06-13 11:14:12 -04:00
Joseph Doherty e72763d703 alarms: use confirmed AVEVA AlarmExtension subtag names (InAlarm/Acked/AckMsg/Priority) 2026-06-13 11:07:22 -04:00
Joseph Doherty 3c9becc8d6 docs(plan): mark all alarm-subtag-fallback tasks completed 2026-06-13 10:55:18 -04:00
Joseph Doherty ec88532fe4 alarms: propagate degraded/source_provider through snapshot + gateway cache paths (integration fix I1/I2) 2026-06-13 10:53:55 -04:00
Joseph Doherty 2f30f0c7c0 docs(alarms): document alarmmgr->subtag fallback (providers, failover, config, contract, parity) 2026-06-13 10:43:37 -04:00
Joseph Doherty 27f6c9e6b7 dashboard(alarms): provider-status badge (alarmmgr vs degraded subtag) 2026-06-13 10:37:37 -04:00
Joseph Doherty 29bd504a99 test(alarms): end-to-end provider failover/failback lifecycle through GatewayAlarmMonitor 2026-06-13 10:34:24 -04:00
Joseph Doherty e10b252e3a test(alarms): drop unsupported Assert.Equal message args in live subtag smoke test (xUnit) 2026-06-13 10:30:39 -04:00
Joseph Doherty bcc54ca56b server(alarms): provider-mode gauge startup baseline; reconcile-lock comment; de-flake monitor test 2026-06-13 10:29:13 -04:00
Joseph Doherty ee459f43e1 test(alarms): opt-in live subtag-fallback smoke test (Skip by default)
Adds AlarmSubtagLiveSmokeTests to validate the open design item from Task 17:
confirms that LmxSubtagAlarmSource (real MxAccessComObjectFactory) wired to
SubtagAlarmConsumer synthesizes degraded Raise transitions with stable synthetic
GUIDs from Galaxy alarm subtags, and that AcknowledgeByName writes the
ack-comment subtag (rc=0). PLACEHOLDER_* subtag addresses are best-guess and
must be verified against MXAccess-Public-API.md + live Galaxy before flipping Skip.
2026-06-13 10:26:28 -04:00
Joseph Doherty ebf1d95f72 server(alarms): monitor resolves watch-list, sends ForcedMode/failover, reflects provider mode into feed + metrics 2026-06-13 10:20:03 -04:00
Joseph Doherty 3ccf0b5f9e server(alarms): honor ExcludeAttributes GR-only contract; warn on empty config-only watch-list 2026-06-13 10:12:58 -04:00
Joseph Doherty f7ccfd678e server(alarms): watch-list resolver merging GR discovery + config override 2026-06-13 10:09:10 -04:00
Joseph Doherty 3f5e5fc0b3 worker(alarms): route ForcedMode/watch-list/failover via AlarmCommandHandler; emit provider-mode-changed event 2026-06-13 10:04:33 -04:00
Joseph Doherty 7241a4fb9c worker(alarms): net48 index fix; enforce ProbeIntervalSeconds; OOM-safe catch; reset-on-failure test 2026-06-13 09:55:07 -04:00
Joseph Doherty d6c0bb41ca worker(alarms): failback probe re-polls the still-subscribed primary (no re-Subscribe) 2026-06-13 09:49:38 -04:00
Joseph Doherty 0a54c0bc4b worker(alarms): FailoverAlarmConsumer auto-failover/failback state machine 2026-06-13 09:46:47 -04:00
Joseph Doherty fd64b9260c worker(alarms): exact-match ack resolution (no substring false-match) + ack-by-guid tests 2026-06-13 09:42:00 -04:00
Joseph Doherty 4bd757a136 worker(alarms): SubtagAlarmConsumer synthesizing degraded transitions; dispatcher propagates Degraded 2026-06-13 09:35:49 -04:00
Joseph Doherty 1e2ed6d1ea worker(alarms): WriteRecord as class not positional record (net48 has no IsExternalInit) 2026-06-13 09:30:52 -04:00
Joseph Doherty 5f6655de27 server(alarms): drop redundant null-coalesce; tidy validator tests (review fixes) 2026-06-13 09:27:37 -04:00
Joseph Doherty fbc9cf56df worker(alarms): SyntheticAlarmGuid internal + alarmmgr-parity assertion (review fixes) 2026-06-13 09:26:52 -04:00
Joseph Doherty 4c0e14fc5d worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags 2026-06-13 09:24:09 -04:00
Joseph Doherty c75920c620 docs(plan): correct alarm proto location to mxaccess_gateway.proto (Tasks 1-2) 2026-06-13 09:18:11 -04:00
Joseph Doherty a46ce90e6f server(metrics): alarm provider mode gauge + provider switch counter (Task 13) 2026-06-13 09:18:11 -04:00
Joseph Doherty f113ca53a1 server(galaxy): GetAlarmAttributesAsync discovery query + alarm-attribute row mapping (Task 11) 2026-06-13 09:18:11 -04:00
Joseph Doherty f3616cc7fa server(alarms): AlarmFallbackOptions + ForceSubtag/threshold validation (Task 10) 2026-06-13 09:18:11 -04:00
Joseph Doherty 57d5a8725f worker(alarms): synthetic GUID + degraded/source_provider on emitted transitions 2026-06-13 09:14:23 -04:00
Joseph Doherty 60d35a914f contracts: regenerate Generated/ for alarm provider mode + subtag types
Keeps committed generated C# in sync with the .proto change in 1d85db7
(AlarmProviderMode, AlarmSubtagTarget, AlarmFailoverConfig, AlarmProviderStatus,
OnAlarmProviderModeChangedEvent, degraded/source_provider fields).
2026-06-13 09:10:08 -04:00
Joseph Doherty b10e103bcf worker(alarms): fix net48 build (init->set, usings), token-boundary name parse, acked latch, dup-address guard, tests 2026-06-13 09:05:58 -04:00
Joseph Doherty 348ab16456 worker(alarms): subtag value-source seam + synthesis state machine 2026-06-13 08:57:28 -04:00
Joseph Doherty c16f016f0a test(contracts): round-trip provider status + degraded provenance 2026-06-13 08:56:13 -04:00
Joseph Doherty 1d85db7b4e contracts(gateway): AlarmProviderMode, subtag watch-list, provider status, degraded provenance, mode-changed event 2026-06-13 08:53:02 -04:00
Joseph Doherty 5ea5618315 docs: implementation plan for alarm subtag-monitoring fallback
18 TDD tasks across contracts, worker (SubtagAlarmConsumer + FailoverAlarmConsumer),
gateway (GR-SQL watch-list discovery, monitor mode reflection, metrics, dashboard),
and docs. Grounded in current signatures; parity-preserving (worker-side synthesis).
2026-06-13 08:44:42 -04:00
Joseph Doherty 38a0ad8ab4 docs: design for alarmmgr→subtag alarm-provider fallback
Auto-failover/failback between the wnwrap alarmmgr consumer and a new
worker-side SubtagAlarmConsumer that advises alarm subtags and synthesizes
transitions. GR-SQL+config watch-list discovery, ack via ack-comment write,
degraded state surfaced in the gRPC contract and dashboard/metrics.
2026-06-13 08:35:18 -04:00
Joseph Doherty 5df2ef0d1e chore(theme): bump ZB.MOM.WW.Theme 0.3.0 -> 0.3.1 (interactive-render nav fix) 2026-06-05 07:19:11 -04:00
Joseph Doherty e5785fd769 chore(theme): consume ZB.MOM.WW.Theme 0.3.0 (nav/login kit fixes) 2026-06-05 05:13:06 -04:00
Joseph Doherty 22370ca4da docs(glauth): repoint glauth.md at the shared GLAuth on 10.100.0.35
No more per-box C:\publish\glauth NSSM service — dev/test LDAP is the shared
zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local). Provisioning now via
scadaproj/infra/glauth/config.toml. Old localhost/NSSM procedures kept as
retired reference; test users multi-role/gw-viewer.
2026-06-04 16:38:24 -04:00
Joseph Doherty e0a3fbf35b fix(dashboard)!: move login POST to /auth/login to resolve AmbiguousMatchException
The themed Blazor <LoginCard> page (Components/Pages/Login.razor, @page "/login")
registers a Razor Components endpoint that matches ALL HTTP methods. The credential
form POSTed to /login, where MapPost("/login") also matched — so every POST /login
threw Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException (HTTP 500),
breaking dashboard login for every user. It was latent because the dashboard was only
ever reached via the AllowAnonymousLocalhost bypass on the host box.

Move the credential POST to a distinct /auth/login route (mirroring ScadaBridge, which
never collided because it posts to /auth/login). GET /login stays the Blazor page; the
cookie LoginPath stays /login. Adds a registration assertion pinning DashboardLoginPost
to /auth/login as the regression guard.

Files: Login.razor (LoginCard Action), DashboardEndpointRouteBuilderExtensions (MapPost
route), GatewayApplicationTests (route assertion).
2026-06-04 14:01:05 -04:00
Joseph Doherty 161ed6f80d chore(theme): bump ZB.MOM.WW.Theme 0.2.0 -> 0.2.1 (desktop app-shell render fix) 2026-06-04 10:23:44 -04:00
Joseph Doherty e57d864ab2 fix(dashboard): make dashboard auth cookie name configurable
The dashboard auth cookie name was hardcoded to the constant
DashboardAuthenticationDefaults.CookieName (MxGatewayDashboard). Browser
cookies are scoped by host+path but NOT by port, so two gateway instances
sharing a hostname would clobber each other's dashboard session under the
shared name.

Add DashboardOptions.CookieName (MxGateway:Dashboard:CookieName); null/blank
keeps the canonical default. Applied in the existing dashboard cookie
PostConfigure (runs after the inline AddCookie default, so it wins). Behaviour
is unchanged when unset. Adds a Tests case for the override.
2026-06-03 13:11:29 -04:00
Joseph Doherty 5539ec8542 chore(dashboard): prune dead sidebar + orphaned login CSS from site.css
Removed the dead .sidebar nav block (replaced by the kit's .side-rail shell) and
the orphaned .dashboard-login/.login-card rules (the /login page now uses the
kit's <LoginCard>). Kept .app-bar (still used by the /denied page header) and the
.chip white-space override (emitted by StatusPill); corrected the now-stale
app-bar comment. 106 lines removed; builds clean.
2026-06-03 04:37:23 -04:00
Joseph Doherty 73e54e252d feat(dashboard): Blazor LoginCard page reusing the hardened /login endpoint 2026-06-03 03:56:51 -04:00
Joseph Doherty 70d959bd9b refactor(dashboard): StatusBadge delegates to ZB.MOM.WW.Theme StatusPill 2026-06-03 03:51:45 -04:00
Joseph Doherty 0c5b796e2e feat(dashboard): split MainLayout into ZB.MOM.WW.Theme ThemeShell + kit nav 2026-06-03 03:49:34 -04:00
Joseph Doherty 47dc9d865f refactor(dashboard): drop vendored theme.css/fonts/nav-state.js; keep app-only CSS in site.css
Repoint the server-rendered sign-in/fallback HTML (DashboardEndpointRouteBuilderExtensions) from /css/theme.css to the kit's _content/ZB.MOM.WW.Theme/css/{theme,layout}.css, mirroring ThemeHead, since that static page cannot use the Razor component.
2026-06-03 03:46:37 -04:00
Joseph Doherty 4f757e3c0c feat(dashboard): use ZB.MOM.WW.Theme ThemeHead + ThemeScripts 2026-06-03 03:44:18 -04:00
Joseph Doherty 2f0ee4c961 build(server): reference ZB.MOM.WW.Theme 0.2.0 2026-06-03 03:43:17 -04:00
Joseph Doherty 0859d47f75 feat(audit): MxGateway IAuditActorAccessor + dashboard audit Actor = operator principal (keyId→Target) (Phase 3)
Introduce IAuditActorAccessor seam + HttpAuditActorAccessor impl (reads ZbClaimTypes.Username
from IHttpContextAccessor; falls back to Identity.Name / ZbClaimTypes.Name; null when
unauthenticated). Register in DI via DashboardServiceCollectionExtensions.

Wire DashboardApiKeyManagementService: WriteDashboardAuditAsync now accepts the ClaimsPrincipal
user already in scope at each call site; ResolveOperatorActor extracts ZbClaimTypes.Username
(preferred) or Identity.Name. All four dashboard-* events now emit Actor = LDAP operator
username and Target = managed keyId, fixing the semantic gap where both fields held the keyId.

ConstraintEnforcer (gRPC / API-key actor) and CanonicalForwardingApiKeyAuditStore (CLI /
"system"/"cli" fallback) are unchanged.

Tests: DashboardApiKeyManagementServiceTests updated — CreateAuthorizedUser adds ZbClaimTypes.Username
("alice"), all dashboard-* audit assertions updated to Actor = "alice" / Target = "operator01";
new CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget verifies the
canonical AuditEvent directly. New HttpAuditActorAccessorTests (4 cases: username claim, Identity.Name
fallback, unauthenticated → null, no context → null). ConstraintEnforcer tests still assert API-key/anonymous actor.
2026-06-02 15:25:39 -04:00
Joseph Doherty 7ea8358c06 feat(audit): MxGateway local producers (dashboard + constraint-denial) emit canonical AuditEvent with Target/CorrelationId (Task 2.3 #6) 2026-06-02 10:13:54 -04:00
Joseph Doherty a5944bbe5d feat(audit): MxGateway canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore->canonical adapter (Task 2.3) 2026-06-02 10:10:38 -04:00
Joseph Doherty 04bce3ff9f feat(auth)!: MxGateway canonical dashboard roles — Admin→Administrator (Task 1.7)
Standardize the dashboard role VALUE on the canonical six: Admin→Administrator
(Viewer unchanged). Pure value rename via DashboardRoles.Admin constant +
appsettings GroupToRole; the GatewayOptionsValidator allowed-set/message track
the constant so they now require 'Administrator' or 'Viewer'. Enforcement is
unchanged — Administrator authorizes exactly what Admin did.

Dashboard roles are derived at login from LDAP groups via GroupToRole and are
never persisted to the SQLite auth store, so no DB migration/seed change.

UNTOUCHED: the separate gRPC API-key scope GatewayScopes.Admin = "admin"
(lowercase) and every "admin" scope literal — a distinct data-plane system.
2026-06-02 07:22:42 -04:00
Joseph Doherty 9572045787 chore(auth): MxGateway unify dev LDAP base DN to dc=zb,dc=local (Task 1.6) 2026-06-02 06:44:38 -04:00
Joseph Doherty 7e1af37eb1 feat(auth): MxGateway dashboard adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
- DashboardAuthenticator.CreatePrincipal: emit ZbClaimTypes.Username ("zb:username") with
  the login username, ZbClaimTypes.DisplayName ("zb:displayname") with the display name,
  ZbClaimTypes.Name (== ClaimTypes.Name) for Identity.Name resolution, ZbClaimTypes.Role
  (== ClaimTypes.Role) for IsInRole/[Authorize]. Keep ClaimTypes.NameIdentifier for back-compat
  read-sites; keep mxgateway:ldap_group unchanged (MxGateway-specific, no ZbClaimType for groups).
  ClaimsIdentity built with nameType=ZbClaimTypes.Name, roleType=ZbClaimTypes.Role.
- DashboardServiceCollectionExtensions.AddGatewayDashboard: route cookie hardening through
  ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h); set cookie name/path/redirects
  after Apply; PostConfigure still overrides SecurePolicy per RequireHttpsCookie setting.
- DashboardAuthenticatorTests: add AuthenticateAsync_Success_EmitsCanonicalZbClaims asserting
  zb:username, zb:displayname, ZbClaimTypes.Role per role, Identity.Name, and ldap_group preserved.
2026-06-02 06:10:48 -04:00
Joseph Doherty 05009d7370 feat(auth): cut MxGateway API keys over to ZB.MOM.WW.Auth.ApiKeys 0.1.2; keep constraint enforcement+gRPC+CLI on top (Task 1.3) 2026-06-02 02:08:38 -04:00
Joseph Doherty f4dc11bae4 fix(auth): MxGateway 1.2 review fixes — group-claim doc, dedup LdapOptions, 0.1.1 pin 2026-06-02 01:28:57 -04:00
Joseph Doherty c3b466e13d feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4) 2026-06-02 00:51:10 -04:00
Joseph Doherty 792e3f9445 feat(auth): add IGroupRoleMapper<string> seam (Task 1.1) 2026-06-02 00:31:00 -04:00
Joseph Doherty ae281d06bb build: add ZB.MOM.WW.Auth/Audit feed mapping
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed.
PackageReferences (inline Version=) added during Phase 1/2 adoption.
2026-06-02 00:17:10 -04:00
Joseph Doherty 3ca2799c90 fix: tighten MxGateway Ldap:Port to 1-65535; catch IOException in path validation
Defect 1: ValidateLdap used AddIfNotPositive for Port, accepting any value
> 0 including 70000. Replaced with builder.Port() from the shared
ZB.MOM.WW.Configuration library, which enforces the 1-65535 TCP range and
emits "MxGateway:Ldap:Port must be between 1 and 65535 (was {value})".

Defect 2: AddIfInvalidPath only caught ArgumentException, NotSupportedException,
and PathTooLongException from Path.GetFullPath. On macOS/Linux a path containing
an embedded null throws IOException, which escaped the catch block and caused
Validate() to throw instead of returning a failure. Added catch (IOException).

Tests: added Validate_Fails_WhenLdapPortIsZero, Validate_Fails_WhenLdapPortExceedsMaximum,
and Validate_Succeeds_WhenLdapEnabledWithValidPort to cover the new range boundary.
2026-06-01 22:45:16 -04:00
Joseph Doherty 459a88b3e7 refactor: adopt ZB.MOM.WW.Configuration in MxGateway (behaviour-preserving) 2026-06-01 18:22:21 -04:00
Joseph Doherty 437ab65fc1 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:27 -04:00
Joseph Doherty 679562e5ed Merge feat/telemetry-followons: telemetry follow-ons for MxAccessGateway
Metric normalization: meter MxGateway.Server -> ZB.MOM.WW.MxGateway and the 3
duration histograms ms -> s (safe: never Prometheus-exported before). Config-driven
OTLP exporter opt-in (default Prometheus). Metrics.md synced; doc-review artifacts
gitignored.
2026-06-01 17:17:31 -04:00
Joseph Doherty dbf550da8b docs(mxgateway): sync Metrics.md to renamed meter + seconds histogram units 2026-06-01 16:48:46 -04:00
Joseph Doherty 3965a7741e feat(mxgateway): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 16:44:40 -04:00
Joseph Doherty abb2cfb84b feat(mxgateway): normalize metrics — meter ZB.MOM.WW.MxGateway + histograms in seconds 2026-06-01 16:39:56 -04:00
Joseph Doherty 4e0d8ccfed chore(mxgateway): gitignore CommentChecker doc-review artifacts 2026-06-01 16:34:46 -04:00
Joseph Doherty a935aa8b7c Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across MxAccessGateway
Full MEL->Serilog migration via AddZbSerilog; GatewayLogRedactor exposed through
the shared ILogRedactor seam; GatewayMetrics now exports via AddZbTelemetry + new
/metrics (meter name MxGateway.Server + ms histogram units unchanged; rename/unit
conversion deferred). Behaviour-preserving.
2026-06-01 16:05:41 -04:00
Joseph Doherty 9912389fa1 feat(mxgateway): export GatewayMetrics via AddZbTelemetry + /metrics (name/units unchanged) 2026-06-01 15:53:46 -04:00
Joseph Doherty f1129b969d feat(mxgateway): expose GatewayLogRedactor via shared ILogRedactor seam 2026-06-01 15:49:32 -04:00
Joseph Doherty c51b6f9ce4 feat(mxgateway): adopt AddZbSerilog — MEL→Serilog provider swap (behaviour-preserving) 2026-06-01 15:43:10 -04:00
Joseph Doherty e39972357b config(mxgateway): translate MEL Logging section to Serilog 2026-06-01 15:32:38 -04:00
Joseph Doherty 9ad17e2964 build(mxgateway): reference ZB.MOM.WW.Telemetry + Serilog packages 2026-06-01 15:29:43 -04:00
Joseph Doherty ef0a883a81 Merge feat/adopt-zb-health: ZB.MOM.WW.Health adoption + TLS auto-cert/lenient-client-trust feature 2026-06-01 14:09:24 -04:00
Joseph Doherty 62ba5e9487 feat: map canonical ZB health tiers; replace bypassing /health/live 2026-06-01 13:44:13 -04:00
Joseph Doherty 136614be94 feat: add AuthStoreHealthCheck readiness probe 2026-06-01 13:33:54 -04:00
Joseph Doherty a912bffad5 build: reference ZB.MOM.WW.Health from the Gitea feed 2026-06-01 13:29:39 -04:00
Joseph Doherty 9bdb899774 fix(clients): inline Go gosec directive and strip IPv6 brackets in Python authority split 2026-06-01 07:57:22 -04:00
Joseph Doherty e5c704de69 feat(gateway): add machine FQDN to self-signed cert SANs
Best-effort resolve the host FQDN via Dns.GetHostEntry and add it as a
DNS SAN when it differs (OrdinalIgnoreCase) from the short machine name
and "localhost". SocketException / ArgumentException are caught and
silently skipped so cert generation remains robust when DNS is absent.
2026-06-01 07:52:48 -04:00
Joseph Doherty 4e520f9c0c fix(gateway): delete temp cert file on persist failure
Wrap the WriteAllBytes/Move/HardenPermissions sequence in a try/catch so
that any failure best-effort deletes the hardened .tmp file (which may
already hold PFX/private-key bytes) before rethrowing.  Add a test that
induces a persist failure by pointing SelfSignedCertPath inside a
regular file and asserts no .tmp is left on disk.
2026-06-01 07:45:15 -04:00
Joseph Doherty 2eb81379e4 docs: TLS auto-cert and lenient client trust 2026-06-01 07:43:13 -04:00
Joseph Doherty ddd5721082 fix(gateway): harden self-signed cert persistence and config validation 2026-06-01 07:37:27 -04:00
Joseph Doherty 3775f6bf3b feat(gateway): supply generated cert as Kestrel HTTPS default 2026-06-01 07:30:26 -04:00
Joseph Doherty cdfad420bb fix(client-rust): apply TLS guard to GalaxyClient and add CLI strict flag
Extract the TLS-without-CA guard into a shared `build_tls_config` helper
in options.rs so both GatewayClient and GalaxyClient use identical logic.
GalaxyClient previously had no guard, so TLS-without-CA produced a cryptic
tonic handshake failure; it now returns the same actionable InvalidEndpoint
error. The guard message notes that a server-name override affects SNI but
does not pin trust. Add --require-certificate-validation to ConnectionArgs
in the CLI binary. Add a mirror test for GalaxyClient in tests/tls.rs.
2026-06-01 07:28:16 -04:00
Joseph Doherty 330e665f6b fix(gateway): correct ECDSA key usage and dispose CertificateRequest
Drop KeyEncipherment from the self-signed cert's key-usage extension — it
is semantically wrong for ECDSA (RSA key-transport only); DigitalSignature
alone is correct for TLS 1.3 / ECDHE server certs.  CertificateRequest is
unchanged (not IDisposable in .NET 10).  Test now also asserts MachineName,
127.0.0.1 and IPv6 loopback are present in the SAN extension.
2026-06-01 07:27:15 -04:00
Joseph Doherty 5e01ad9c22 fix(client-dotnet): apply lenient TLS to GalaxyRepositoryClient and enforce hostname on CA-pin
Mirror MxGatewayClient's three-branch handler structure in GalaxyRepositoryClient
(CA-pin / lenient accept-all / OS trust) so the Galaxy endpoint works against the
gateway's self-signed cert under the default lenient posture. Expose an internal
CreateHttpHandlerForTests seam for unit testing. Add RemoteCertificateNameMismatch
rejection at the top of both CA-pinned callbacks so a pinned-CA connection truly
verifies the host. Strengthen existing lenient test to invoke the callback and assert
it returns true; add mirrored Galaxy-client handler tests.
2026-06-01 07:24:07 -04:00
Joseph Doherty 77a9108673 feat(gateway): persist/reuse self-signed cert with hardened permissions 2026-06-01 07:23:33 -04:00
Joseph Doherty 192607ab8c fix(gateway): detect Certificate:Thumbprint and cover more KestrelTlsInspector cases 2026-06-01 07:22:24 -04:00
Joseph Doherty ba82afe669 fix(client-java): keep Temurin 21 toolchain, auto-provision instead of bumping to 26 2026-06-01 07:20:04 -04:00
Joseph Doherty fe7d1ce1ec feat(gateway): validate MxGateway:Tls options 2026-06-01 07:19:22 -04:00
Joseph Doherty b8a6695612 feat(gateway): generate self-signed ECDSA cert with SANs 2026-06-01 07:18:39 -04:00
Joseph Doherty 6f9188bc8d test(client-python): update TLS default-channel test for TOFU behavior 2026-06-01 07:17:36 -04:00
Joseph Doherty a276f46f81 feat(client-java): accept gateway cert by default over TLS 2026-06-01 07:13:45 -04:00
Joseph Doherty 572b268d81 feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback) 2026-06-01 07:11:09 -04:00
Joseph Doherty 4c093a64fa feat(client-python): accept gateway cert by default via TOFU pre-fetch 2026-06-01 07:10:55 -04:00
Joseph Doherty f47bbaea95 feat(client-dotnet): accept gateway cert by default over TLS 2026-06-01 07:08:55 -04:00
Joseph Doherty c463b49f46 feat(client-go): accept gateway cert by default over TLS 2026-06-01 07:08:47 -04:00
Joseph Doherty 87f86503ef feat(gateway): add MxGateway:Tls options block 2026-06-01 07:08:19 -04:00
Joseph Doherty e912ef960c feat(gateway): detect HTTPS endpoints missing a certificate 2026-06-01 07:08:12 -04:00
Joseph Doherty c4e7ddea70 docs: implementation plan for gateway TLS auto-cert and lenient client trust 2026-06-01 07:01:58 -04:00
Joseph Doherty 6bfa4fe884 docs: design for gateway TLS auto-cert and lenient client trust 2026-06-01 06:54:23 -04:00
Joseph Doherty b4a7bac4c0 scripts: add pack-clients.ps1 to pack/publish all 5 client packages 2026-05-28 17:12:08 -04:00
Joseph Doherty 6df373ae4c client/go: release docs and tag-go-module.ps1 helper 2026-05-28 17:07:25 -04:00
Joseph Doherty fe44e3c18a client/java: maven-publish wiring for Gitea Maven feed 2026-05-28 17:07:11 -04:00
Joseph Doherty 523f944f3e client/rust: Cargo metadata + Gitea alternative-registry config 2026-05-28 17:06:47 -04:00
Joseph Doherty c33f1e6047 client/python: PyPI metadata + Gitea feed install instructions 2026-05-28 17:06:01 -04:00
Joseph Doherty 92cc4688e6 client/go: avoid holding mutex across BrowseChildren RPC in Expand 2026-05-28 15:33:48 -04:00
Joseph Doherty a155554038 grpc: reuse GalaxyBrowseProjector.ResolveParentId from handler 2026-05-28 15:32:48 -04:00
Joseph Doherty 68f905a344 client/java: avoid holding monitor across BrowseChildren RPC in expand 2026-05-28 15:32:36 -04:00
Joseph Doherty 5abc222c72 galaxy: add by-name and by-path indexes to GalaxyHierarchyIndex 2026-05-28 15:31:56 -04:00
Joseph Doherty da3aa7b0b2 client/go: paginate DiscoverHierarchy across multi-page galaxies 2026-05-28 15:31:16 -04:00
Joseph Doherty f0ec068430 galaxy: add cycle guard to HasMatchingDescendant 2026-05-28 15:30:08 -04:00
Joseph Doherty 1a1d14a9fd client/python: add public browse_children_raw for API parity 2026-05-28 15:29:08 -04:00
Joseph Doherty b2448510ac client/java: add browseChildrenRejectsRepeatedPageToken test for parity 2026-05-28 15:17:52 -04:00
Joseph Doherty 75610e3f55 client/go: wrap browseChildren duplicate-page-token error in GatewayError 2026-05-28 15:17:10 -04:00
Joseph Doherty 5032166106 client/dotnet: assert failed expand leaves node unexpanded 2026-05-28 15:16:07 -04:00
Joseph Doherty 76a042d663 grpc: make page_token error strings RPC-name-agnostic 2026-05-28 15:15:40 -04:00
Joseph Doherty 4a19854eb9 docs: per-client High-level walker example using LazyBrowseNode
Add a "High-level walker" subsection under each client's "Browsing
lazily" section showing idiomatic use of LazyBrowseNode (browse +
expand, idempotency note, redeploy refresh pattern).
2026-05-28 14:34:19 -04:00
Joseph Doherty a4467e23ef client/python: make LazyBrowseNode.expand concurrency-safe 2026-05-28 14:32:35 -04:00
Joseph Doherty eacfeff9fb client/dotnet: make LazyBrowseNode.ExpandAsync thread-safe 2026-05-28 14:28:36 -04:00
230 changed files with 19224 additions and 3680 deletions
+5
View File
@@ -147,3 +147,8 @@ generated-scratch/
# Keep empty directories with .gitkeep files when needed # Keep empty directories with .gitkeep files when needed
!.gitkeep !.gitkeep
# Documentation review artifacts (CommentChecker output)
*-docs-issues.md
*-docs-fixed.md
*-docs-final.md
+1 -1
View File
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
## Design Sources To Consult Before Non-Trivial Changes ## Design Sources To Consult Before Non-Trivial Changes
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling. - `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
- `glauth.md`local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there. - `glauth.md`shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.). - `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs. - `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`. - `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
+21
View File
@@ -0,0 +1,21 @@
<Project>
<PropertyGroup>
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
<Authors>Joseph Doherty</Authors>
<Company>ZB MOM WW</Company>
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
<Product>MxAccessGateway Client</Product>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
<Version>0.1.0</Version>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Default: do NOT pack. Each project opts in. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
+19
View File
@@ -107,6 +107,7 @@ public sealed class MxGatewayClientOptions
public required string ApiKey { get; init; } public required string ApiKey { get; init; }
public bool UseTls { get; init; } public bool UseTls { get; init; }
public string? CaCertificatePath { get; init; } public string? CaCertificatePath { get; init; }
public bool RequireCertificateValidation { get; init; }
public string? ServerNameOverride { get; init; } public string? ServerNameOverride { get; init; }
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -124,6 +125,24 @@ or subscription changes because those calls can partially succeed in MXAccess.
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
library constructor unless a helper explicitly says it does that. library constructor unless a helper explicitly says it does that.
### TLS trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set
and `CaCertificatePath` is empty, `CreateHttpHandler` installs a
`RemoteCertificateValidationCallback` that returns `true`, so the gateway's
self-signed certificate is accepted without verification.
To verify the gateway instead:
- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust`
`X509Chain` against that root, and the callback additionally rejects a
hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or
- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust
verification on a connection with no pinned CA.
Pinning a CA always wins over the lenient default.
## Auth Interceptor ## Auth Interceptor
Use a gRPC call credentials/interceptor layer to attach: Use a gRPC call credentials/interceptor layer to attach:
+60
View File
@@ -218,6 +218,32 @@ for (int i = 0; i < roots.Children.Count; i++)
} }
``` ```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
foreach (LazyBrowseNode root in roots)
{
if (root.HasChildrenHint)
{
await root.ExpandAsync();
}
foreach (LazyBrowseNode child in root.Children)
{
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
}
}
```
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`BrowseAsync` again from the root.
### Watching deploy events ### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The `WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
@@ -261,6 +287,17 @@ Use TLS options for a secured gateway:
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
``` ```
### TLS trust
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
no pinned CA accepts whatever certificate the gateway presents. To verify
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
`--server-name` when the dialed host differs from the certificate SAN. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## Integration Checks ## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available: Run live checks only when a gateway and MXAccess-backed worker are available:
@@ -273,6 +310,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
``` ```
## Installing as a NuGet Package
The client publishes to the internal Gitea NuGet feed at
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
Add the feed once:
````bash
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
--name dohertj2-gitea \
--username <gitea-username> \
--password <gitea-token-or-password> \
--store-password-in-clear-text
````
Then add the package to your project:
````bash
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
````
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
## Related Documentation ## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md) - [Client Packaging](../../docs/ClientPackaging.md)
@@ -106,6 +106,8 @@ public sealed class LazyBrowseNodeTests
new RpcException(new Status(StatusCode.NotFound, "Parent not found")))); new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync()); await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
Assert.False(roots[0].IsExpanded);
Assert.Empty(roots[0].Children);
} }
/// <summary> /// <summary>
@@ -142,6 +144,37 @@ public sealed class LazyBrowseNodeTests
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken); Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
} }
/// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary>
[Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 7));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(2, "Mixer_001")],
childHasChildren: [false],
cacheSequence: 7));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Fire ten concurrent expands of the same node.
Task[] tasks = Enumerable.Range(0, 10)
.Select(_ => roots[0].ExpandAsync())
.ToArray();
await Task.WhenAll(tasks);
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
// 1 roots fetch + exactly 1 expand fetch = 2 total
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary> /// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request. /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary> /// </summary>
@@ -0,0 +1,85 @@
using System.Net.Http;
using System.Net.Security;
using ZB.MOM.WW.MxGateway.Client;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
public sealed class GalaxyRepositoryClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
@@ -490,7 +490,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
.ConfigureAwait(false); .ConfigureAwait(false);
} }
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{ {
SocketsHttpHandler handler = new() SocketsHttpHandler handler = new()
{ {
@@ -510,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath); X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) => handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{ {
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null) if (certificate is null)
{ {
return false; return false;
@@ -525,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return customChain.Build(certificateToValidate); return customChain.Build(certificateToValidate);
}; };
} }
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
} }
return handler; return handler;
@@ -13,6 +13,7 @@ public sealed class LazyBrowseNode
private readonly GalaxyRepositoryClient _client; private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options; private readonly BrowseChildrenOptions _options;
private readonly List<LazyBrowseNode> _children = []; private readonly List<LazyBrowseNode> _children = [];
private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded; private bool _isExpanded;
internal LazyBrowseNode( internal LazyBrowseNode(
@@ -43,6 +44,10 @@ public sealed class LazyBrowseNode
/// Fetches direct children from the gateway and populates <see cref="Children"/>. /// Fetches direct children from the gateway and populates <see cref="Children"/>.
/// Idempotent: subsequent calls are no-ops. /// Idempotent: subsequent calls are no-ops.
/// </summary> /// </summary>
/// <remarks>
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
/// (after the first completes) return immediately.
/// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task ExpandAsync(CancellationToken cancellationToken = default) public async Task ExpandAsync(CancellationToken cancellationToken = default)
{ {
@@ -51,33 +56,46 @@ public sealed class LazyBrowseNode
return; return;
} }
string pageToken = string.Empty; await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
HashSet<string> seenPageTokens = new(StringComparer.Ordinal); try
do
{ {
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options); if (_isExpanded)
request.ParentGobjectId = Object.GobjectId;
request.PageToken = pageToken;
BrowseChildrenReply reply = await _client
.BrowseChildrenRawAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{ {
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; return;
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
} }
pageToken = reply.NextPageToken; string pageToken = string.Empty;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{ {
throw new MxGatewayException( BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); request.ParentGobjectId = Object.GobjectId;
request.PageToken = pageToken;
BrowseChildrenReply reply = await _client
.BrowseChildrenRawAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
} }
while (!string.IsNullOrWhiteSpace(pageToken));
_isExpanded = true;
}
finally
{
_expandLock.Release();
} }
while (!string.IsNullOrWhiteSpace(pageToken));
_isExpanded = true;
} }
} }
@@ -315,7 +315,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
.ConfigureAwait(false); .ConfigureAwait(false);
} }
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{ {
SocketsHttpHandler handler = new() SocketsHttpHandler handler = new()
{ {
@@ -335,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath); X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) => handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{ {
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null) if (certificate is null)
{ {
return false; return false;
@@ -350,6 +358,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
return customChain.Build(certificateToValidate); return customChain.Build(certificateToValidate);
}; };
} }
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
} }
return handler; return handler;
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
/// </summary> /// </summary>
public static class MxGatewayClientContractInfo public static class MxGatewayClientContractInfo
{ {
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
public const uint GatewayProtocolVersion = public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion; GatewayContractInfo.GatewayProtocolVersion;
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
public const uint WorkerProtocolVersion = public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion; GatewayContractInfo.WorkerProtocolVersion;
} }
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
/// </summary> /// </summary>
public string? CaCertificatePath { get; init; } public string? CaCertificatePath { get; init; }
/// <summary>
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
/// use the OS trust store. When false (default), the gateway certificate is
/// accepted without verification — appropriate for this internal tool's
/// auto-generated self-signed certificate. Pinning a CA always verifies.
/// </summary>
public bool RequireCertificateValidation { get; init; }
/// <summary> /// <summary>
/// Gets the server name override for SNI during TLS handshake. /// Gets the server name override for SNI during TLS handshake.
/// </summary> /// </summary>
@@ -16,4 +16,21 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project> </Project>
+17
View File
@@ -104,6 +104,23 @@ Support:
- `credentials.NewClientTLSFromFile`, - `credentials.NewClientTLSFromFile`,
- custom `tls.Config` for advanced callers. - custom `tls.Config` for advanced callers.
### Trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is
`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied,
`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying
`ServerNameOverride` as the SNI when set), so the gateway's self-signed
certificate is accepted without verification.
To verify the gateway instead:
- set `CACertFile` to pin a CA (full verification against that root), or
- set `RequireCertificateValidation: true` to verify against the OS/system trust
roots without pinning.
Pinning a CA always wins over the lenient default.
## Streaming ## Streaming
`Events(ctx)` should return a receive channel of: `Events(ctx)` should return a receive channel of:
+80
View File
@@ -75,6 +75,14 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
}) })
``` ```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
true` to verify against the OS/system trust roots without pinning. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`, `Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer `AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the `SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
@@ -143,6 +151,46 @@ for i, child := range reply.GetChildren() {
} }
``` ```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
log.Fatal(err)
}
defer galaxy.Close()
roots, err := galaxy.Browse(ctx, nil)
if err != nil {
log.Fatal(err)
}
for _, root := range roots {
if root.HasChildrenHint() {
if err := root.Expand(ctx); err != nil {
log.Fatal(err)
}
}
for _, child := range root.Children() {
kind := "leaf"
if child.HasChildrenHint() {
kind = "has children"
}
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
}
}
```
`Expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`Browse` again from the root.
### Watching deploy events ### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a `WatchDeployEvents` opens a server-streaming subscription. The server emits a
@@ -235,6 +283,38 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
``` ```
## Installing the Go client
The module is resolved directly from the git repo — no package registry:
````bash
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
````
Then import:
````go
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
````
If your build environment cannot reach `gitea.dohertylan.com` directly,
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
for the internal module path.
## Releasing a new version
Go modules in monorepo subdirectories use prefixed tags. To tag a release
from this repo:
````bash
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
````
The script validates semver, refuses to tag with uncommitted tracked
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
pushes it to origin.
## Related Documentation ## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md) - [Client Packaging](../../docs/ClientPackaging.md)
+16 -4
View File
@@ -222,10 +222,22 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
return credentials.NewTLS(cfg), nil return credentials.NewTLS(cfg), nil
} }
return credentials.NewTLS(&tls.Config{ return credentials.NewTLS(tlsConfigForOptions(opts)), nil
MinVersion: tls.VersionTLS12, }
ServerName: opts.ServerNameOverride,
}), nil // tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path.
// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig).
// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture.
func tlsConfigForOptions(opts Options) *tls.Config {
// CA file and custom TLSConfig take their own paths in resolveTransportCredentials.
if opts.CACertFile != "" || opts.TLSConfig != nil {
return nil
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: opts.ServerNameOverride,
InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation
}
} }
// OpenSessionOptions describes fields used to create an OpenSessionRequest. // OpenSessionOptions describes fields used to create an OpenSessionRequest.
+59
View File
@@ -0,0 +1,59 @@
package mxgateway
import (
"crypto/tls"
"testing"
)
// tlsConfigFromOptions is the internal helper under test.
// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials.
// We exercise it directly to avoid needing a real dial target.
func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) {
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
})
if cfg == nil {
t.Fatal("expected non-nil tls.Config")
}
if !cfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true by default when no CA is pinned")
}
}
func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) {
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
RequireCertificateValidation: true,
})
if cfg == nil {
t.Fatal("expected non-nil tls.Config")
}
if cfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true")
}
}
func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) {
// When a CA file is pinned, the CA-verification path is taken instead.
// tlsConfigForOptions should return nil (the CA path does not use our helper).
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
CACertFile: "/some/ca.pem",
})
if cfg != nil {
t.Error("expected nil tls.Config when CACertFile is set (CA path taken)")
}
}
func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) {
// When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it.
custom := &tls.Config{MinVersion: tls.VersionTLS13}
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
TLSConfig: custom,
})
if cfg != nil {
t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)")
}
}
+113 -23
View File
@@ -18,6 +18,11 @@ import (
// browseChildrenPageSize is the per-request page size used by the lazy walker. // browseChildrenPageSize is the per-request page size used by the lazy walker.
const browseChildrenPageSize = 500 const browseChildrenPageSize = 500
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
// Mirrors the .NET client constant so large galaxies are not silently truncated
// by the server's default page cap.
const discoverHierarchyPageSize = 5000
// RawGalaxyRepositoryClient is the generated gRPC client interface for the // RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract // Galaxy Repository service exposed for callers that need direct contract
// access. // access.
@@ -155,16 +160,35 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each // DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
// object's dynamic attributes. The objects are returned in the order supplied // object's dynamic attributes. The objects are returned in the order supplied
// by the server. // by the server. The call pages over the server's NextPageToken until the
// server signals it has no more results, matching the .NET client.
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) { func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
callCtx, cancel := c.callContext(ctx) var objects []*GalaxyObject
defer cancel() pageToken := ""
seen := map[string]struct{}{}
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{}) for {
if err != nil { callCtx, cancel := c.callContext(ctx)
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err} reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
PageSize: discoverHierarchyPageSize,
PageToken: pageToken,
})
cancel()
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
objects = append(objects, reply.GetObjects()...)
pageToken = reply.GetNextPageToken()
if pageToken == "" {
return objects, nil
}
if _, dup := seen[pageToken]; dup {
return nil, &GatewayError{
Op: "galaxy discover hierarchy",
Err: fmt.Errorf("repeated page token %q", pageToken),
}
}
seen[pageToken] = struct{}{}
} }
return reply.GetObjects(), nil
} }
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers // WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
@@ -249,15 +273,25 @@ func (c *GalaxyClient) Close() error {
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by // LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
// (*GalaxyClient).Browse. Children are not fetched until Expand is called. // (*GalaxyClient).Browse. Children are not fetched until Expand is called.
// The node is safe for concurrent use; concurrent Expand calls collapse to a // The node is safe for concurrent use; concurrent Expand calls coalesce onto
// single RPC. // a single in-flight RPC and do not block snapshot accessors.
type LazyBrowseNode struct { type LazyBrowseNode struct {
client *GalaxyClient client *GalaxyClient
object *pb.GalaxyObject object *pb.GalaxyObject
hasChildrenHint bool hasChildrenHint bool
options BrowseChildrenOptions options BrowseChildrenOptions
mu sync.Mutex // expandLock gates inspection and mutation of expand-coordination state
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
expandLock sync.Mutex
expanding bool
expandDone chan struct{}
expandErr error
// mu protects the children snapshot and isExpanded flag for concurrent
// Children() / IsExpanded() readers.
mu sync.RWMutex
children []*LazyBrowseNode children []*LazyBrowseNode
isExpanded bool isExpanded bool
} }
@@ -272,8 +306,8 @@ func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
// Children returns a snapshot copy of the currently-loaded child nodes. Returns // Children returns a snapshot copy of the currently-loaded child nodes. Returns
// an empty slice when Expand has not yet been called. // an empty slice when Expand has not yet been called.
func (n *LazyBrowseNode) Children() []*LazyBrowseNode { func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
n.mu.Lock() n.mu.RLock()
defer n.mu.Unlock() defer n.mu.RUnlock()
out := make([]*LazyBrowseNode, len(n.children)) out := make([]*LazyBrowseNode, len(n.children))
copy(out, n.children) copy(out, n.children)
return out return out
@@ -281,28 +315,81 @@ func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
// IsExpanded reports whether Expand has completed successfully on this node. // IsExpanded reports whether Expand has completed successfully on this node.
func (n *LazyBrowseNode) IsExpanded() bool { func (n *LazyBrowseNode) IsExpanded() bool {
n.mu.Lock() n.mu.RLock()
defer n.mu.Unlock() defer n.mu.RUnlock()
return n.isExpanded return n.isExpanded
} }
// Expand fetches this node's direct children via BrowseChildren when they have // Expand fetches this node's direct children via BrowseChildren when they have
// not yet been loaded. Subsequent calls after a successful Expand are a no-op // not yet been loaded. Subsequent calls after a successful Expand are a no-op
// and do not issue another RPC. // and do not issue another RPC.
//
// Expand is safe to call concurrently from multiple goroutines: callers that
// arrive while an expansion is in flight wait on the active RPC and share its
// result instead of issuing a second RPC. The RPC itself runs without holding
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
// not blocked for the duration of the network round trip.
//
// Failure semantics: a failed expansion surfaces the same error to every
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
// false, no in-flight expansion). The next Expand call therefore retries with
// a fresh RPC; failures are not sticky.
func (n *LazyBrowseNode) Expand(ctx context.Context) error { func (n *LazyBrowseNode) Expand(ctx context.Context) error {
n.mu.Lock() // Fast path: already expanded.
defer n.mu.Unlock() n.mu.RLock()
if n.isExpanded { if n.isExpanded {
n.mu.RUnlock()
return nil return nil
} }
n.mu.RUnlock()
// Either start a new expansion or wait on an existing one.
n.expandLock.Lock()
n.mu.RLock()
alreadyExpanded := n.isExpanded
n.mu.RUnlock()
if alreadyExpanded {
n.expandLock.Unlock()
return nil
}
if n.expanding {
done := n.expandDone
n.expandLock.Unlock()
select {
case <-done:
n.expandLock.Lock()
err := n.expandErr
n.expandLock.Unlock()
return err
case <-ctx.Done():
return ctx.Err()
}
}
n.expanding = true
n.expandDone = make(chan struct{})
done := n.expandDone
n.expandLock.Unlock()
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
parentID := n.object.GetGobjectId() parentID := n.object.GetGobjectId()
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options) children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
if err != nil {
return err if err == nil {
n.mu.Lock()
n.children = children
n.isExpanded = true
n.mu.Unlock()
} }
n.children = children
n.isExpanded = true // Publish result to waiters and clear the in-flight marker so a failed
return nil // expansion can be retried by the next Expand call.
n.expandLock.Lock()
n.expandErr = err
n.expanding = false
close(done)
n.expandLock.Unlock()
return err
} }
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes // Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
@@ -375,7 +462,10 @@ func (c *GalaxyClient) browseChildrenInner(
return nodes, nil return nodes, nil
} }
if _, dup := seen[pageToken]; dup { if _, dup := seen[pageToken]; dup {
return nil, fmt.Errorf("mxgateway: galaxy browse children returned repeated page token %q", pageToken) return nil, &GatewayError{
Op: "galaxy browse children",
Err: fmt.Errorf("repeated page token %q", pageToken),
}
} }
seen[pageToken] = struct{}{} seen[pageToken] = struct{}{}
} }
+136 -12
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net" "net"
"sync"
"testing" "testing"
"time" "time"
@@ -146,6 +147,47 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
} }
} }
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
page1 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 1, TagName: "A"},
{GobjectId: 2, TagName: "B"},
},
NextPageToken: "page-2",
TotalObjectCount: 3,
}
page2 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 3, TagName: "C"},
},
TotalObjectCount: 3,
}
fake := &fakeGalaxyServer{
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objs, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy: %v", err)
}
if got, want := len(objs), 3; got != want {
t.Fatalf("len(objs) = %d, want %d", got, want)
}
if len(fake.discoverHierarchyCalls) != 2 {
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
}
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
t.Fatalf("first call PageSize = %d, want %d",
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
}
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
t.Fatalf("second call page token = %q, want %q",
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) { func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true} fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake) client, cleanup := newGalaxyBufconnClient(t, fake)
@@ -372,18 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
type fakeGalaxyServer struct { type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply testReply *pb.TestConnectionReply
testAuth string testAuth string
failTest bool failTest bool
deployReply *pb.GetLastDeployTimeReply deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
watchRequest *pb.WatchDeployEventsRequest discoverHierarchyReplies []*pb.DiscoverHierarchyReply
watchSendInterval time.Duration watchEvents []*pb.DeployEvent
watchHoldOpen bool watchRequest *pb.WatchDeployEventsRequest
browseChildrenCalls []*pb.BrowseChildrenRequest watchSendInterval time.Duration
browseChildrenReplies []*pb.BrowseChildrenReply watchHoldOpen bool
browseChildrenError error browseChildrenCalls []*pb.BrowseChildrenRequest
browseChildrenReplies []*pb.BrowseChildrenReply
browseChildrenError error
} }
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) { func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
@@ -405,6 +449,12 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
} }
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) { func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
if len(s.discoverHierarchyReplies) > 0 {
reply := s.discoverHierarchyReplies[0]
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
return reply, nil
}
if s.discoverReply != nil { if s.discoverReply != nil {
return s.discoverReply, nil return s.discoverReply, nil
} }
@@ -738,3 +788,77 @@ func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
t.Fatal("HistorizedOnly = false, want true") t.Fatal("HistorizedOnly = false, want true")
} }
} }
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
// roots
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
// one expand: one child
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx := context.Background()
roots, err := client.Browse(ctx, nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errs <- roots[0].Expand(ctx)
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent Expand: %v", err)
}
}
if !roots[0].IsExpanded() {
t.Fatal("IsExpanded() = false after 10 concurrent expands")
}
if got, want := len(roots[0].Children()), 1; got != want {
t.Fatalf("len(children) = %d, want %d", got, want)
}
// 1 roots fetch + exactly 1 expand fetch.
if got, want := len(fake.browseChildrenCalls), 2; got != want {
t.Fatalf("RPC count = %d, want %d", got, want)
}
}
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
// will request a second page. Queue the same reply twice so the second response
// returns the same page token, triggering the duplicate-token guard.
page := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
)
page.NextPageToken = "1:abc:1"
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.Browse(context.Background(), nil)
if err == nil {
t.Fatal("Browse: error = nil, want repeated-page-token error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
}
}
+4
View File
@@ -34,6 +34,10 @@ type Options struct {
TransportCredentials credentials.TransportCredentials TransportCredentials credentials.TransportCredentials
// DialOptions are appended to the gRPC dial options after the defaults. // DialOptions are appended to the gRPC dial options after the defaults.
DialOptions []grpc.DialOption DialOptions []grpc.DialOption
// RequireCertificateValidation forces TLS certificate verification even when
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
// accepted without verification (internal-tool posture).
RequireCertificateValidation bool
} }
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by // BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
+17
View File
@@ -112,6 +112,23 @@ Support:
- custom CA certificate file, - custom CA certificate file,
- server name override for test environments. - server name override for test environments.
### Trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when the channel is not
plaintext and no `caCertificatePath` is set, the client builds
`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`
(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without
verification.
To verify the gateway instead:
- set `caCertificatePath` to pin a CA (full verification against that root), or
- set `requireCertificateValidation` to `true` to verify against the JVM trust
store without pinning.
Pinning a CA always wins over the lenient default.
## Streaming ## Streaming
Support both: Support both:
+71
View File
@@ -57,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
} }
``` ```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
no `caCertificatePath` accepts whatever certificate the gateway presents (via
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
verify against the JVM trust store without pinning. Use `serverNameOverride` /
`--server-name-override` when the dialed host differs from the certificate SAN.
See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`, Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the `closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
underlying protobuf messages. `MxGatewayCommandException` and underlying protobuf messages. `MxGatewayCommandException` and
@@ -139,6 +149,36 @@ for (int i = 0; i < children.size(); i++) {
} }
``` ```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
List<LazyBrowseNode> roots = galaxy.browse();
for (LazyBrowseNode root : roots) {
if (root.hasChildrenHint()) {
root.expand();
}
for (LazyBrowseNode child : root.getChildren()) {
String kind = child.hasChildrenHint() ? "has children" : "leaf";
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
}
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events ### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway `GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
@@ -252,6 +292,37 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
``` ```
## Installing from the Gitea Maven repository
The client publishes to the internal Gitea Maven repository at
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
In your consumer project's `build.gradle`:
````groovy
repositories {
maven {
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME')
password = System.getenv('GITEA_TOKEN')
}
}
}
dependencies {
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
}
````
To publish a new version from this repo:
````bash
export GITEA_USERNAME=dohertj2
export GITEA_TOKEN=<your-gitea-token>
gradle :zb-mom-ww-mxgateway-client:publish
````
## Related Documentation ## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md) - [Client Packaging](../../docs/ClientPackaging.md)
+40
View File
@@ -37,4 +37,44 @@ subprojects {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
} }
} }
pluginManager.withPlugin('maven-publish') {
publishing {
publications {
maven(MavenPublication) {
from components.java
pom {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
description = 'MxAccessGateway Java client'
scm {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
}
developers {
developer {
id = 'dohertj2'
name = 'Joseph Doherty'
}
}
licenses {
license {
name = 'Proprietary'
distribution = 'repo'
}
}
}
}
}
repositories {
maven {
name = 'GiteaPackages'
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME') ?: ''
password = System.getenv('GITEA_TOKEN') ?: ''
}
}
}
}
}
} }
+4
View File
@@ -9,6 +9,10 @@ pluginManagement {
} }
} }
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
@@ -1,6 +1,7 @@
plugins { plugins {
id 'java-library' id 'java-library'
id 'com.google.protobuf' id 'com.google.protobuf'
id 'maven-publish'
} }
dependencies { dependencies {
@@ -30,6 +31,11 @@ sourceSets {
} }
} }
java {
withSourcesJar()
withJavadocJar()
}
protobuf { protobuf {
protoc { protoc {
artifact = "com.google.protobuf:protoc:${protobufVersion}" artifact = "com.google.protobuf:protoc:${protobufVersion}"
@@ -4,6 +4,9 @@ import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/** /**
* One node in a lazy-loaded Galaxy browse tree. Holds the underlying * One node in a lazy-loaded Galaxy browse tree. Holds the underlying
@@ -16,7 +19,14 @@ public final class LazyBrowseNode {
private final GalaxyObject object; private final GalaxyObject object;
private final boolean hasChildrenHint; private final boolean hasChildrenHint;
private final BrowseChildrenOptions options; private final BrowseChildrenOptions options;
private final Object lock = new Object();
// expandLock gates the start of a new expand AND the publish of the in-flight
// future. Readers (getChildren / isExpanded) use a separate read-write lock so
// they never block on the gRPC call.
private final Object expandLock = new Object();
private CompletableFuture<Void> inFlight;
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private List<LazyBrowseNode> children = Collections.emptyList(); private List<LazyBrowseNode> children = Collections.emptyList();
private boolean isExpanded; private boolean isExpanded;
@@ -43,15 +53,21 @@ public final class LazyBrowseNode {
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */ /** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
public List<LazyBrowseNode> getChildren() { public List<LazyBrowseNode> getChildren() {
synchronized (lock) { readWriteLock.readLock().lock();
try {
return List.copyOf(children); return List.copyOf(children);
} finally {
readWriteLock.readLock().unlock();
} }
} }
/** @return {@code true} after the first {@link #expand()} call completes. */ /** @return {@code true} after the first {@link #expand()} call completes. */
public boolean isExpanded() { public boolean isExpanded() {
synchronized (lock) { readWriteLock.readLock().lock();
try {
return isExpanded; return isExpanded;
} finally {
readWriteLock.readLock().unlock();
} }
} }
@@ -59,17 +75,76 @@ public final class LazyBrowseNode {
* Fetches direct children from the gateway and populates {@link #getChildren()}. * Fetches direct children from the gateway and populates {@link #getChildren()}.
* Idempotent: subsequent calls are no-ops and do not issue a second RPC. * Idempotent: subsequent calls are no-ops and do not issue a second RPC.
* *
* <p>Concurrent callers coalesce onto a single in-flight RPC: the first caller
* (the "leader") issues the gRPC call, while any other thread that calls
* {@code expand()} during that window blocks on the leader's future and sees
* the same result (or the same exception). On failure the in-flight slot is
* cleared so a subsequent call can retry.
*
* <p>Readers ({@link #getChildren()} / {@link #isExpanded()}) take a separate
* read lock and are never blocked for the duration of the RPC.
*
* @throws MxGatewayException on transport or protocol failure * @throws MxGatewayException on transport or protocol failure
*/ */
public void expand() { public void expand() {
synchronized (lock) { if (isExpanded()) {
if (isExpanded) { return;
}
CompletableFuture<Void> future;
boolean iAmTheLeader;
synchronized (expandLock) {
if (isExpanded()) {
return; return;
} }
List<LazyBrowseNode> loaded = if (inFlight != null) {
client.browseChildrenInner(Integer.valueOf(object.getGobjectId()), options); future = inFlight;
this.children = loaded; iAmTheLeader = false;
this.isExpanded = true; } else {
future = new CompletableFuture<>();
inFlight = future;
iAmTheLeader = true;
}
}
if (iAmTheLeader) {
try {
List<LazyBrowseNode> loaded =
client.browseChildrenInner(object.getGobjectId(), options);
readWriteLock.writeLock().lock();
try {
this.children = loaded;
this.isExpanded = true;
} finally {
readWriteLock.writeLock().unlock();
}
synchronized (expandLock) {
inFlight = null;
}
future.complete(null);
} catch (RuntimeException ex) {
synchronized (expandLock) {
inFlight = null;
}
future.completeExceptionally(ex);
throw ex;
}
} else {
try {
future.get();
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new MxGatewayException("Interrupted waiting for browse-children expand.", ie);
} catch (ExecutionException ee) {
Throwable cause = ee.getCause();
if (cause instanceof MxGatewayException me) {
throw me;
}
if (cause instanceof RuntimeException re) {
throw re;
}
throw new MxGatewayException("BrowseChildren expand failed.", cause);
}
} }
} }
} }
@@ -384,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable {
} catch (SSLException error) { } catch (SSLException error) {
throw new MxGatewayException("failed to configure gateway TLS", error); throw new MxGatewayException("failed to configure gateway TLS", error);
} }
} else if (!options.requireCertificateValidation()) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util
.InsecureTrustManagerFactory.INSTANCE)
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure lenient gateway TLS", error);
}
} else { } else {
builder.useTransportSecurity(); builder.useTransportSecurity();
} }
@@ -393,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable {
return builder.build(); return builder.build();
} }
/**
* Package-visible test seam creates a raw {@link ManagedChannel} from the
* given options without attaching auth interceptors. Used by TLS fixture
* tests to verify channel construction behaviour without a full
* {@link MxGatewayClient} wrapper.
*
* @param options the client options
* @return a new {@link ManagedChannel}
*/
static ManagedChannel createChannelForTests(MxGatewayClientOptions options) {
return createChannel(options);
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) { private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) { if (options.callTimeout().isNegative()) {
return stub; return stub;
@@ -20,6 +20,7 @@ public final class MxGatewayClientOptions {
private final String apiKey; private final String apiKey;
private final boolean plaintext; private final boolean plaintext;
private final Path caCertificatePath; private final Path caCertificatePath;
private final boolean requireCertificateValidation;
private final String serverNameOverride; private final String serverNameOverride;
private final Duration connectTimeout; private final Duration connectTimeout;
private final Duration callTimeout; private final Duration callTimeout;
@@ -31,6 +32,7 @@ public final class MxGatewayClientOptions {
apiKey = builder.apiKey == null ? "" : builder.apiKey; apiKey = builder.apiKey == null ? "" : builder.apiKey;
plaintext = builder.plaintext; plaintext = builder.plaintext;
caCertificatePath = builder.caCertificatePath; caCertificatePath = builder.caCertificatePath;
requireCertificateValidation = builder.requireCertificateValidation;
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride; serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout; connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout; callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
@@ -95,6 +97,18 @@ public final class MxGatewayClientOptions {
return caCertificatePath; return caCertificatePath;
} }
/**
* Returns whether TLS certificate verification is required even when no CA is pinned.
* When {@code false} (default), the gateway's self-signed certificate is accepted
* without verification. When {@code true}, the OS trust store is used.
* Pinning a CA via {@link #caCertificatePath()} always verifies regardless of this flag.
*
* @return {@code true} if strict certificate verification is required
*/
public boolean requireCertificateValidation() {
return requireCertificateValidation;
}
/** /**
* Returns the TLS server-name override, or an empty string when none was supplied. * Returns the TLS server-name override, or an empty string when none was supplied.
* *
@@ -148,6 +162,8 @@ public final class MxGatewayClientOptions {
+ plaintext + plaintext
+ ", caCertificatePath=" + ", caCertificatePath="
+ caCertificatePath + caCertificatePath
+ ", requireCertificateValidation="
+ requireCertificateValidation
+ ", serverNameOverride='" + ", serverNameOverride='"
+ serverNameOverride + serverNameOverride
+ '\'' + '\''
@@ -177,6 +193,7 @@ public final class MxGatewayClientOptions {
private String apiKey; private String apiKey;
private boolean plaintext; private boolean plaintext;
private Path caCertificatePath; private Path caCertificatePath;
private boolean requireCertificateValidation;
private String serverNameOverride; private String serverNameOverride;
private Duration connectTimeout; private Duration connectTimeout;
private Duration callTimeout; private Duration callTimeout;
@@ -230,6 +247,21 @@ public final class MxGatewayClientOptions {
return this; return this;
} }
/**
* When {@code true}, TLS connections without a pinned CA use the OS trust store
* and will reject the gateway's self-signed certificate. When {@code false}
* (default), the gateway certificate is accepted without verification
* appropriate for this internal tool's auto-generated self-signed certificate.
* Pinning a CA via {@link #caCertificatePath(Path)} always verifies.
*
* @param value {@code true} to require certificate validation, {@code false} to accept any cert
* @return this builder
*/
public Builder requireCertificateValidation(boolean value) {
requireCertificateValidation = value;
return this;
}
/** /**
* Overrides the TLS server name used during the handshake. * Overrides the TLS server name used during the handshake.
* *
@@ -40,9 +40,14 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Queue; import java.util.Queue;
import java.util.UUID; import java.util.UUID;
import java.util.ArrayList;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -203,6 +208,27 @@ final class GalaxyRepositoryClientTests {
} }
} }
@Test
void browseChildrenRejectsRepeatedPageToken() throws Exception {
// Queue the same BrowseChildrenReply twice with a non-empty NextPageToken.
// The client will request a second page and detect that the token repeats.
BrowseChildrenService service = new BrowseChildrenService();
BrowseChildrenReply repeatedReply = browseReply(
List.of(obj(1, "Plant", true)),
List.of(true),
1L,
"1:abc:1");
service.replies.add(repeatedReply);
service.replies.add(repeatedReply);
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
MxGatewayException error = assertThrows(MxGatewayException.class, client::browse);
assertTrue(error.getMessage().contains("repeated page token"));
}
}
@Test @Test
void watchDeployEventsReceivesEventsInOrder() throws Exception { void watchDeployEventsReceivesEventsInOrder() throws Exception {
DeployEvent first = DeployEvent.newBuilder() DeployEvent first = DeployEvent.newBuilder()
@@ -445,6 +471,91 @@ final class GalaxyRepositoryClientTests {
} }
} }
@Test
void browseExpandConcurrentCallersOnlyFireOneRpc() throws Exception {
// Verifies that concurrent expand() calls coalesce onto a single in-flight
// BrowseChildren RPC and that readers (isExpanded/getChildren) are not
// blocked for the full RPC duration.
BrowseChildrenReply rootsReply = browseReply(
List.of(obj(1, "Plant", true)),
List.of(true),
7L,
"");
BrowseChildrenReply childrenReply = browseReply(
List.of(obj(2, "Mixer_001", false)),
List.of(false),
7L,
"");
// Gate the child fetch behind a latch so multiple expanders can pile up.
CountDownLatch release = new CountDownLatch(1);
AtomicInteger childCalls = new AtomicInteger();
BrowseChildrenService service = new BrowseChildrenService() {
@Override
public void browseChildren(
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
calls.add(request);
BrowseChildrenReply reply;
if (!request.hasParentGobjectId()) {
reply = rootsReply;
} else {
// Block the leader until the followers have arrived.
try {
assertTrue(release.await(5, TimeUnit.SECONDS), "release latch never tripped");
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
responseObserver.onError(Status.CANCELLED.asRuntimeException());
return;
}
childCalls.incrementAndGet();
reply = childrenReply;
}
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<LazyBrowseNode> roots = client.browse();
LazyBrowseNode root = roots.get(0);
int parallelism = 10;
ExecutorService pool = Executors.newFixedThreadPool(parallelism);
try {
CountDownLatch ready = new CountDownLatch(parallelism);
List<Future<Void>> futures = new ArrayList<>();
for (int i = 0; i < parallelism; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
root.expand();
return null;
}));
}
// Wait for all callers to be in flight, then release the leader.
assertTrue(ready.await(5, TimeUnit.SECONDS), "expander threads did not start");
// Readers must not be blocked by an in-flight expand; this should not deadlock
// and should return the pre-expand state.
assertFalse(root.isExpanded());
assertEquals(0, root.getChildren().size());
release.countDown();
for (Future<Void> f : futures) {
f.get(10, TimeUnit.SECONDS);
}
} finally {
pool.shutdownNow();
}
assertTrue(root.isExpanded());
assertEquals(1, root.getChildren().size());
// Exactly one expand RPC was issued even though many callers raced.
assertEquals(1, childCalls.get());
// 1 roots fetch + exactly 1 expand fetch.
assertEquals(2, service.calls.size());
}
}
@Test @Test
void browseWithFilterForwardsToRequest() throws Exception { void browseWithFilterForwardsToRequest() throws Exception {
BrowseChildrenService service = new BrowseChildrenService(); BrowseChildrenService service = new BrowseChildrenService();
@@ -486,7 +597,7 @@ final class GalaxyRepositoryClientTests {
return b.build(); return b.build();
} }
private static final class BrowseChildrenService extends TestService { private static class BrowseChildrenService extends TestService {
final List<BrowseChildrenRequest> calls = final List<BrowseChildrenRequest> calls =
Collections.synchronizedList(new CopyOnWriteArrayList<>()); Collections.synchronizedList(new CopyOnWriteArrayList<>());
final Queue<BrowseChildrenReply> replies = new ArrayDeque<>(); final Queue<BrowseChildrenReply> replies = new ArrayDeque<>();
@@ -0,0 +1,198 @@
package com.zb.mom.ww.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.StatusRuntimeException;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.stub.StreamObserver;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Base64;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Verifies that the Java client connects to a Netty TLS server with a
* self-signed certificate when no CA is pinned (lenient default), and that
* setting {@code requireCertificateValidation(true)} causes a TLS failure.
*
* <p>A self-signed certificate is generated using {@code keytool} (always
* available in the JDK) to avoid dependencies on internal JDK APIs or
* BouncyCastle, and so the test works on all JDK versions used by the project.
*/
final class MxGatewayClientTlsTests {
private Server server;
private int port;
private File certPemFile;
private File keyPemFile;
private File keystoreFile;
@BeforeEach
void startTlsServer() throws Exception {
keystoreFile = File.createTempFile("gw-test-ks", ".p12");
certPemFile = File.createTempFile("gw-test-cert", ".pem");
keyPemFile = File.createTempFile("gw-test-key", ".pem");
// keytool refuses to write to a pre-existing (even empty) file; delete it first.
keystoreFile.delete();
// Use keytool to generate a self-signed PKCS12 keystore.
String keytool = ProcessHandle.current().info().command()
.map(cmd -> cmd.replace("java", "keytool"))
.orElse("keytool");
// Fall back to just "keytool" on PATH if the resolved path doesn't exist.
if (!new File(keytool).exists()) {
keytool = "keytool";
}
Process p = new ProcessBuilder(
keytool,
"-genkeypair",
"-alias", "server",
"-keyalg", "RSA",
"-keysize", "2048",
"-sigalg", "SHA256withRSA",
"-validity", "1",
"-dname", "CN=localhost",
"-storetype", "PKCS12",
"-storepass", "changeit",
"-keypass", "changeit",
"-keystore", keystoreFile.getAbsolutePath())
.redirectErrorStream(true)
.start();
int exit = p.waitFor();
if (exit != 0) {
String out = new String(p.getInputStream().readAllBytes());
throw new IllegalStateException("keytool failed (exit " + exit + "): " + out);
}
// Export cert and private key from the PKCS12 keystore to PEM files.
KeyStore ks = KeyStore.getInstance("PKCS12");
try (var is = Files.newInputStream(keystoreFile.toPath())) {
ks.load(is, "changeit".toCharArray());
}
X509Certificate cert = (X509Certificate) ks.getCertificate("server");
PrivateKey privateKey = (PrivateKey) ks.getKey("server", "changeit".toCharArray());
try (FileOutputStream out = new FileOutputStream(certPemFile)) {
out.write("-----BEGIN CERTIFICATE-----\n".getBytes());
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(cert.getEncoded()));
out.write("\n-----END CERTIFICATE-----\n".getBytes());
}
try (FileOutputStream out = new FileOutputStream(keyPemFile)) {
out.write("-----BEGIN PRIVATE KEY-----\n".getBytes());
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(privateKey.getEncoded()));
out.write("\n-----END PRIVATE KEY-----\n".getBytes());
}
server = NettyServerBuilder
.forAddress(new InetSocketAddress("127.0.0.1", 0))
.sslContext(GrpcSslContexts.forServer(certPemFile, keyPemFile).build())
.addService(new MinimalGatewayService())
.build()
.start();
port = server.getPort();
}
@AfterEach
void stopTlsServer() throws InterruptedException {
if (server != null) {
server.shutdown();
server.awaitTermination(5, TimeUnit.SECONDS);
}
if (certPemFile != null) {
certPemFile.delete();
}
if (keyPemFile != null) {
keyPemFile.delete();
}
if (keystoreFile != null) {
keystoreFile.delete();
}
}
@Test
void connectsToSelfSignedServer_WhenRequireCertificateValidationIsFalse() throws SSLException {
// Default options requireCertificateValidation defaults to false.
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("127.0.0.1:" + port)
.apiKey("test-key")
.connectTimeout(Duration.ofSeconds(5))
.callTimeout(Duration.ofSeconds(5))
.build();
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
try {
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
MxAccessGatewayGrpc.newBlockingStub(channel);
OpenSessionReply reply = stub.openSession(
OpenSessionRequest.newBuilder()
.setClientSessionName("tls-test")
.build());
assertTrue(reply.getProtocolStatus().getCode()
== ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK);
} finally {
channel.shutdownNow();
}
}
@Test
void failsToConnect_WhenRequireCertificateValidationIsTrue() throws SSLException {
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("127.0.0.1:" + port)
.apiKey("test-key")
.requireCertificateValidation(true)
.connectTimeout(Duration.ofSeconds(5))
.callTimeout(Duration.ofSeconds(5))
.build();
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
try {
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
MxAccessGatewayGrpc.newBlockingStub(channel);
assertThrows(StatusRuntimeException.class, () ->
stub.openSession(OpenSessionRequest.newBuilder()
.setClientSessionName("tls-strict-test")
.build()));
} finally {
channel.shutdownNow();
}
}
/** Minimal gateway stub that succeeds any OpenSession call. */
private static final class MinimalGatewayService
extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
@Override
public void openSession(
OpenSessionRequest request,
StreamObserver<OpenSessionReply> responseObserver) {
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("tls-test-session")
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build())
.build());
responseObserver.onCompleted();
}
}
}
+22
View File
@@ -112,6 +112,28 @@ Support:
- TLS channel with default roots, - TLS channel with default roots,
- custom root certificate file. - custom root certificate file.
### Trust posture (trust-on-first-use)
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). grpc-python exposes no per-channel skip-verify hook, so the client cannot
"accept any certificate" the way the other clients do. Instead, when the channel
is not plaintext and neither `ca_file` nor `require_certificate_validation` is
set, the TLS default is **trust-on-first-use**: the client fetches the server's
presented certificate once via `ssl.get_server_certificate` (an unverified
probe), pins it as the channel's only trust root, and — because the generated
certificate always carries a `localhost` SAN — defaults
`grpc.ssl_target_name_override` to `localhost` when no `server_name_override` was
supplied (tolerating dial-by-IP or a hostname mismatch). A failed probe is
surfaced as a transport error naming the endpoint.
To verify the gateway instead:
- set `ca_file` to verify against a specific CA, or
- set `require_certificate_validation=True` to verify against the system trust
roots.
Both bypass the TOFU path.
## Streaming ## Streaming
Expose `stream_events` as an async iterator. Canceling the task should cancel Expose `stream_events` as an async iterator. Canceling the task should cancel
+48
View File
@@ -157,6 +157,30 @@ for child, has_children in zip(reply.children, reply.child_has_children):
print(child.tag_name, "expand=" + str(has_children)) print(child.tag_name, "expand=" + str(has_children))
``` ```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```python
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
roots = await galaxy.browse()
for root in roots:
if root.has_children_hint:
await root.expand()
for child in root.children:
kind = "has children" if child.has_children_hint else "leaf"
print(f"{child.object.tag_name} ({kind})")
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events ### Watching deploy events
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming `GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
@@ -206,6 +230,17 @@ The client supports plaintext channels for local development, TLS with system
roots, TLS with a custom `ca_file`, and an optional test server name override. roots, TLS with a custom `ca_file`, and an optional test server name override.
API keys are redacted from option repr output and CLI error output. API keys are redacted from option repr output and CLI error output.
The gateway can auto-generate its own self-signed certificate (it has no PKI).
grpc-python has no per-channel skip-verify, so the lenient TLS default is
**trust-on-first-use**: with no `ca_file` and `require_certificate_validation`
left `False`, the client fetches the gateway's presented certificate once
(unverified) and pins it for the channel, defaulting the SNI/target-name override
to `localhost` (the generated certificate always carries a `localhost` SAN) when
none was supplied. To verify instead, pass `ca_file` to verify against a specific
CA, or set `require_certificate_validation=True` to verify against the system
trust roots. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## CLI ## CLI
The CLI emits deterministic JSON for automation: The CLI emits deterministic JSON for automation:
@@ -244,6 +279,19 @@ $env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
``` ```
## Installing from the Gitea PyPI Feed
The client publishes to the internal Gitea PyPI feed:
````bash
pip install \
--index-url https://gitea.dohertylan.com/api/packages/dohertj2/pypi/simple/ \
zb-mom-ww-mxaccess-gateway-client
````
If you need authentication (private feed), use `--extra-index-url` and either
a `~/.netrc` entry or `PIP_INDEX_URL=https://<user>:<token>@gitea.dohertylan.com/...`.
## Related Documentation ## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md) - [Client Packaging](../../docs/ClientPackaging.md)
+23
View File
@@ -13,12 +13,35 @@ dependencies = [
"grpcio>=1.80,<2", "grpcio>=1.80,<2",
"protobuf>=6.33,<7", "protobuf>=6.33,<7",
] ]
authors = [
{ name = "Joseph Doherty" },
]
license = { text = "Proprietary" }
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
classifiers = [
"Development Status :: 3 - Alpha",
"License :: Other/Proprietary License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: System :: Distributed Computing",
"Topic :: Software Development :: Libraries :: Python Modules",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
]
[project.urls]
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"grpcio-tools>=1.80,<2", "grpcio-tools>=1.80,<2",
"pytest>=9,<10", "pytest>=9,<10",
"pytest-asyncio>=1.3,<2", "pytest-asyncio>=1.3,<2",
"build>=1.2,<2",
"twine>=5,<6",
] ]
[project.scripts] [project.scripts]
@@ -140,6 +140,22 @@ class GalaxyRepositoryClient:
) )
seen_page_tokens.add(page_token) seen_page_tokens.add(page_token)
async def browse_children_raw(
self, request: galaxy_pb.BrowseChildrenRequest
) -> galaxy_pb.BrowseChildrenReply:
"""Issue one BrowseChildren RPC and return the raw reply.
Lower-level escape hatch for callers that need direct page-token control
or do not want LazyBrowseNode wrapping. Most callers should use
:py:meth:`browse` and :py:meth:`LazyBrowseNode.expand` instead.
"""
return await self._unary(
"browse children",
self.raw_stub.BrowseChildren,
request,
)
async def browse( async def browse(
self, self,
options: BrowseChildrenOptions | None = None, options: BrowseChildrenOptions | None = None,
@@ -292,6 +308,7 @@ class LazyBrowseNode:
self._options = options self._options = options
self._children: list[LazyBrowseNode] = [] self._children: list[LazyBrowseNode] = []
self._is_expanded = False self._is_expanded = False
self._expand_lock = asyncio.Lock()
@property @property
def object(self) -> galaxy_pb.GalaxyObject: def object(self) -> galaxy_pb.GalaxyObject:
@@ -317,14 +334,17 @@ class LazyBrowseNode:
"""Fetch direct children of this node; no-op on subsequent calls.""" """Fetch direct children of this node; no-op on subsequent calls."""
if self._is_expanded: if self._is_expanded:
return return
new_children: list[LazyBrowseNode] = [] async with self._expand_lock:
async for child in self._client._iter_browse_children( if self._is_expanded:
parent_gobject_id=self._object.gobject_id, return
options=self._options, new_children: list[LazyBrowseNode] = []
): async for child in self._client._iter_browse_children(
new_children.append(child) parent_gobject_id=self._object.gobject_id,
self._children.extend(new_children) options=self._options,
self._is_expanded = True ):
new_children.append(child)
self._children.extend(new_children)
self._is_expanded = True
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]: async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ssl
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -9,6 +10,7 @@ from pathlib import Path
import grpc import grpc
from .auth import REDACTED, ApiKey from .auth import REDACTED, ApiKey
from .errors import MxGatewayTransportError
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -19,6 +21,7 @@ class ClientOptions:
api_key: str | ApiKey | None = None api_key: str | ApiKey | None = None
plaintext: bool = False plaintext: bool = False
ca_file: str | None = None ca_file: str | None = None
require_certificate_validation: bool = False
server_name_override: str | None = None server_name_override: str | None = None
call_timeout: float | None = 30.0 call_timeout: float | None = 30.0
stream_timeout: float | None = None stream_timeout: float | None = None
@@ -45,6 +48,7 @@ class ClientOptions:
f"{type(self).__name__}(endpoint={self.endpoint!r}, " f"{type(self).__name__}(endpoint={self.endpoint!r}, "
f"api_key={api_key!r}, plaintext={self.plaintext!r}, " f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
f"ca_file={self.ca_file!r}, " f"ca_file={self.ca_file!r}, "
f"require_certificate_validation={self.require_certificate_validation!r}, "
f"server_name_override={self.server_name_override!r}, " f"server_name_override={self.server_name_override!r}, "
f"call_timeout={self.call_timeout!r}, " f"call_timeout={self.call_timeout!r}, "
f"stream_timeout={self.stream_timeout!r}, " f"stream_timeout={self.stream_timeout!r}, "
@@ -69,8 +73,34 @@ class BrowseChildrenOptions:
historized_only: bool = False historized_only: bool = False
def _split_authority(endpoint: str) -> tuple[str, int]:
"""Split a gRPC target (optionally scheme-prefixed) into (host, port).
Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``),
returning the host without brackets so it is safe to pass to
``ssl.get_server_certificate``.
"""
target = endpoint.split("://", 1)[-1]
if target.startswith("["):
# Bracketed IPv6: "[::1]:5120" or "[::1]"
bracket_end = target.find("]")
host = target[1:bracket_end] # strip surrounding brackets
remainder = target[bracket_end + 1 :] # ":5120" or ""
port_str = remainder.lstrip(":")
return (host, int(port_str) if port_str else 443)
host, _, port = target.rpartition(":")
return (host or "localhost", int(port) if port else 443)
def create_channel(options: ClientOptions) -> grpc.aio.Channel: def create_channel(options: ClientOptions) -> grpc.aio.Channel:
"""Create a plaintext or TLS `grpc.aio` channel from client options.""" """Create a plaintext or TLS `grpc.aio` channel from client options.
The TLS default is lenient: grpc-python has no per-channel skip-verify, so
the server's presented certificate is fetched once (unverified) and pinned
as the channel's only trust root (trust-on-first-use). Set
`require_certificate_validation=True` to force system-trust verification, or
pass `ca_file` to verify against a specific CA both bypass the TOFU path.
"""
channel_options: list[tuple[str, str | int]] = [ channel_options: list[tuple[str, str | int]] = [
("grpc.max_receive_message_length", options.max_grpc_message_bytes), ("grpc.max_receive_message_length", options.max_grpc_message_bytes),
@@ -82,11 +112,28 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
if options.plaintext: if options.plaintext:
return grpc.aio.insecure_channel(options.endpoint, options=channel_options) return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
root_certificates = None
if options.ca_file: if options.ca_file:
root_certificates = Path(options.ca_file).read_bytes() root_certificates = Path(options.ca_file).read_bytes()
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
elif options.require_certificate_validation:
credentials = grpc.ssl_channel_credentials()
else:
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
# server's certificate (unverified) and pin it for this channel (TOFU).
host, port = _split_authority(options.endpoint)
try:
presented = ssl.get_server_certificate((host, port))
except OSError as error:
raise MxGatewayTransportError(
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
) from error
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
# The gateway self-signed cert always carries a "localhost" SAN, so default
# the SNI/target-name override to it when none was supplied, tolerating
# dial-by-IP or hostname mismatch.
if not options.server_name_override:
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
return grpc.aio.secure_channel( return grpc.aio.secure_channel(
options.endpoint, options.endpoint,
credentials, credentials,
+186 -23
View File
@@ -72,27 +72,83 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
] ]
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None: def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, object, object]] = [] """Default TLS (no ca_file, no require_certificate_validation) uses TOFU:
fetches the server cert unverified, pins it as root_certificates, and adds
grpc.ssl_target_name_override = "localhost" automatically.
"""
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
get_cert_calls: list[tuple[str, int]] = []
def fake_credentials(*, root_certificates: object) -> str: def fake_get_server_certificate(addr: tuple[str, int]) -> str:
assert root_certificates is None get_cert_calls.append(addr)
return _DUMMY_PEM
cred_calls: list[object] = []
def fake_credentials(*, root_certificates: object = None) -> str:
cred_calls.append(root_certificates)
return "creds" return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str: def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
calls.append((endpoint, credentials, options)) channel_calls.append((endpoint, credentials, options))
return "tls-channel" return "tls-channel"
monkeypatch.setattr( monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
options_module.grpc, monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
"ssl_channel_credentials", monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
fake_credentials,
channel = create_channel(
ClientOptions(endpoint="gateway.example:5001"),
) )
assert channel == "tls-channel"
# TOFU: should have fetched the cert from the server (host, port)
assert get_cert_calls == [("gateway.example", 5001)]
# Pinned the fetched PEM bytes as root_certificates
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
# Auto-injected localhost override (no server_name_override supplied)
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
("grpc.ssl_target_name_override", "localhost"),
],
),
]
def test_create_channel_uses_tls_channel_tofu_respects_server_name_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When server_name_override is set, TOFU still runs but does NOT add the
auto-localhost override (the explicit override is already in channel_options).
"""
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
monkeypatch.setattr( monkeypatch.setattr(
options_module.grpc.aio, options_module.ssl,
"secure_channel", "get_server_certificate",
fake_secure_channel, lambda addr: _DUMMY_PEM,
) )
cred_calls: list[object] = []
def fake_credentials(*, root_certificates: object = None) -> str:
cred_calls.append(root_certificates)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel( channel = create_channel(
ClientOptions( ClientOptions(
@@ -102,14 +158,121 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
) )
assert channel == "tls-channel" assert channel == "tls-channel"
assert calls == [ assert cred_calls == [_DUMMY_PEM.encode("ascii")]
( assert channel_calls == [
"gateway.example:5001", (
"creds", "gateway.example:5001",
[ "creds",
("grpc.max_receive_message_length", 16 * 1024 * 1024), [
("grpc.max_send_message_length", 16 * 1024 * 1024), ("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.ssl_target_name_override", "gateway.test"), ("grpc.max_send_message_length", 16 * 1024 * 1024),
], # Explicit override from ClientOptions — not the auto-localhost one
), ("grpc.ssl_target_name_override", "gateway.test"),
] ],
),
]
def test_create_channel_uses_tls_channel_require_cert_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""require_certificate_validation=True uses system trust (no TOFU, no root_certificates)."""
get_cert_called = False
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
nonlocal get_cert_called
get_cert_called = True
return "SHOULD_NOT_BE_CALLED"
cred_calls: list[object] = []
def fake_credentials(**kwargs: object) -> str:
cred_calls.append(kwargs)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel(
ClientOptions(
endpoint="gateway.example:5001",
require_certificate_validation=True,
),
)
assert channel == "tls-channel"
# Must NOT call TOFU prefetch
assert not get_cert_called
# ssl_channel_credentials() called with NO keyword args (system trust)
assert cred_calls == [{}]
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
],
),
]
def test_create_channel_uses_tls_channel_ca_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: pytest.TempPathFactory,
) -> None:
"""ca_file path: reads the PEM file, passes bytes as root_certificates, skips TOFU."""
ca_pem = b"-----BEGIN CERTIFICATE-----\nY2FkYXRh\n-----END CERTIFICATE-----\n"
ca_file = tmp_path / "ca.pem"
ca_file.write_bytes(ca_pem)
get_cert_called = False
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
nonlocal get_cert_called
get_cert_called = True
return "SHOULD_NOT_BE_CALLED"
cred_calls: list[object] = []
def fake_credentials(*, root_certificates: object = None) -> str:
cred_calls.append(root_certificates)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel(
ClientOptions(
endpoint="gateway.example:5001",
ca_file=str(ca_file),
),
)
assert channel == "tls-channel"
assert not get_cert_called
assert cred_calls == [ca_pem]
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
],
),
]
+53 -1
View File
@@ -391,6 +391,28 @@ async def test_browse_expand_idempotent_no_second_rpc() -> None:
assert len(roots[0].children) == 1 assert len(roots[0].children) == 1
@pytest.mark.asyncio
async def test_browse_expand_concurrent_callers_only_fire_one_rpc() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7),
_build_browse_reply([_obj(2, "Mixer_001")], [False], 7),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
# Ten concurrent expand calls on the same node should issue exactly one RPC.
await asyncio.gather(*(roots[0].expand() for _ in range(10)))
assert roots[0].is_expanded
assert len(roots[0].children) == 1
# 1 roots fetch + exactly 1 expand fetch = 2 total
assert len(stub.browse_children.requests) == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None: async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
stub = FakeGalaxyStub() stub = FakeGalaxyStub()
@@ -485,6 +507,35 @@ async def test_browse_with_filter_forwards_to_request() -> None:
assert request.historized_only is True assert request.historized_only is True
@pytest.mark.asyncio
async def test_browse_children_raw_returns_reply_unwrapped() -> None:
"""browse_children_raw forwards the request to the stub and returns the raw reply."""
stub = FakeGalaxyStub()
expected = _build_browse_reply(
children=[_obj(1, "Plant", is_area=True)],
child_has_children=[True],
cache_sequence=42,
)
stub.browse_children.replies = [expected]
async with await GalaxyRepositoryClient.connect(
endpoint="fake",
plaintext=True,
stub=stub,
) as client:
request = galaxy_pb.BrowseChildrenRequest(
page_size=10,
tag_name_glob="Plant*",
)
reply = await client.browse_children_raw(request)
assert reply.cache_sequence == 42
assert len(reply.children) == 1
assert reply.children[0].tag_name == "Plant"
assert len(stub.browse_children.requests) == 1
assert stub.browse_children.requests[0].tag_name_glob == "Plant*"
class FakeGalaxyStub: class FakeGalaxyStub:
def __init__(self) -> None: def __init__(self) -> None:
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)]) self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
@@ -506,7 +557,8 @@ class FakeUnary:
def __init__(self, replies: list[Any]) -> None: def __init__(self, replies: list[Any]) -> None:
self.replies = replies self.replies = replies
self.requests: list[Any] = [] self.requests: list[Any] = []
self.exceptions: list[BaseException] = [] # None entries mean "no exception on this call"; aligns with the replies queue index-by-index.
self.exceptions: list[BaseException | None] = []
self.metadata: tuple[tuple[str, str], ...] | None = None self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__( async def __call__(
+165
View File
@@ -0,0 +1,165 @@
"""TLS behaviour tests for ``create_channel``.
These spin up a real loopback ``grpc.aio`` server with a freshly generated
self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's
auto-generated cert) and assert the lenient TOFU default lets a client connect
without any CA configured.
Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback
TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the
suite gates anything that depends on real sockets rather than fakes.
"""
from __future__ import annotations
import os
import shutil
import socket
import ssl
import subprocess
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
import grpc
import pytest
import pytest_asyncio
from zb_mom_ww_mxgateway import ClientOptions
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
from zb_mom_ww_mxgateway.options import create_channel
pytestmark = pytest.mark.tls
_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1"
_OPENSSL = shutil.which("openssl")
requires_tls = pytest.mark.skipif(
not _RUN_TLS_TESTS,
reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests",
)
requires_openssl = pytest.mark.skipif(
_OPENSSL is None,
reason="openssl CLI is required to generate a self-signed test certificate",
)
def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]:
"""Generate a self-signed cert/key pair with a ``localhost`` SAN."""
key_path = directory / "server.key"
cert_path = directory / "server.crt"
subprocess.run(
[
str(_OPENSSL),
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
str(key_path),
"-out",
str(cert_path),
"-days",
"1",
"-subj",
"/CN=mxgateway-test",
"-addext",
"subjectAltName=DNS:localhost,IP:127.0.0.1",
],
check=True,
capture_output=True,
)
return cert_path, key_path
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer):
"""Minimal servicer answering ``OpenSession`` with a fixed session id."""
async def OpenSession( # noqa: N802 - generated gRPC method name
self, request: pb.OpenSessionRequest, context: object
) -> pb.OpenSessionReply:
return pb.OpenSessionReply(session_id="tls-session-1")
@pytest_asyncio.fixture
async def tls_server() -> AsyncIterator[int]:
with tempfile.TemporaryDirectory() as tmp:
cert_path, key_path = _generate_self_signed_cert(Path(tmp))
credentials = grpc.ssl_server_credentials(
[(key_path.read_bytes(), cert_path.read_bytes())]
)
server = grpc.aio.server()
pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server)
port = _free_port()
server.add_secure_port(f"127.0.0.1:{port}", credentials)
await server.start()
try:
yield port
finally:
await server.stop(grace=None)
@requires_tls
@requires_openssl
@pytest.mark.asyncio
async def test_default_tls_connects_via_tofu(tls_server: int) -> None:
"""Default TLS options (no CA) connect by pinning the presented cert."""
options = ClientOptions(
endpoint=f"127.0.0.1:{tls_server}",
api_key="mxgw_test_secret",
)
channel = create_channel(options)
try:
stub = pb_grpc.MxAccessGatewayStub(channel)
reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10)
assert reply.session_id == "tls-session-1"
finally:
await channel.close()
def test_split_authority_parses_host_and_port() -> None:
from zb_mom_ww_mxgateway.options import _split_authority
assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120)
assert _split_authority("localhost:5120") == ("localhost", 5120)
assert _split_authority(":5120") == ("localhost", 5120)
def test_split_authority_strips_ipv6_brackets() -> None:
from zb_mom_ww_mxgateway.options import _split_authority
# Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate
assert _split_authority("[::1]:5120") == ("::1", 5120)
# Bare bracketed IPv6 (no port) — default port 443
assert _split_authority("[::1]") == ("::1", 443)
# Scheme-prefixed bracketed IPv6
assert _split_authority("grpc://[::1]:5120") == ("::1", 5120)
def test_tofu_connect_failure_raises_transport_error() -> None:
"""A failed cert pre-fetch surfaces the client's transport error type."""
options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}")
with pytest.raises(MxGatewayTransportError) as excinfo:
create_channel(options)
assert options.endpoint in str(excinfo.value)
def test_require_certificate_validation_uses_system_trust() -> None:
"""``require_certificate_validation`` must not attempt a TOFU pre-fetch."""
# Pointing at a closed port: with system-trust the channel is created lazily
# (no eager pre-fetch), so create_channel must succeed without connecting.
options = ClientOptions(
endpoint=f"127.0.0.1:{_free_port()}",
require_certificate_validation=True,
)
channel = create_channel(options)
assert isinstance(channel, grpc.aio.Channel)
+3
View File
@@ -17,3 +17,6 @@
# args through the GNU linker and reject `/STACK:`, are unaffected. # args through the GNU linker and reject `/STACK:`, are unaffected.
[target.'cfg(all(windows, target_env = "msvc"))'] [target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "link-arg=/STACK:8388608"] rustflags = ["-C", "link-arg=/STACK:8388608"]
[registries.dohertj2-gitea]
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
+14 -2
View File
@@ -2,7 +2,16 @@
name = "zb-mom-ww-mxgateway-client" name = "zb-mom-ww-mxgateway-client"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
publish = false authors = ["Joseph Doherty"]
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
license = "Proprietary"
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
documentation = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
readme = "README.md"
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
categories = ["api-bindings", "asynchronous"]
publish = ["dohertj2-gitea"]
build = "build.rs" build = "build.rs"
[workspace] [workspace]
@@ -12,7 +21,10 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "0.1.0" version = "0.1.0"
publish = false authors = ["Joseph Doherty"]
license = "Proprietary"
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
publish = ["dohertj2-gitea"]
[workspace.dependencies] [workspace.dependencies]
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
+62
View File
@@ -76,6 +76,19 @@ types.
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
``` ```
### TLS trust (pin-only)
The gateway can auto-generate its own self-signed certificate (it has no PKI).
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
is pin-only. A TLS connection requires either `--ca-file` /
`ClientOptions::with_ca_file(...)` to pin a CA (export the gateway's self-signed
certificate and pin it), or `--require-certificate-validation` /
`with_require_certificate_validation(true)` to verify against the system trust
roots. TLS with neither set fails `connect` with a clear, actionable error rather
than accepting the certificate. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## Library Surface ## Library Surface
`ClientOptions` configures endpoint, API key, plaintext or TLS transport, `ClientOptions` configures endpoint, API key, plaintext or TLS transport,
@@ -157,6 +170,31 @@ for (child, has_children) in reply.children.iter().zip(reply.child_has_children.
} }
``` ```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```rust
let mut client = GalaxyClient::connect(
ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
).await?;
let roots = client.browse(None).await?;
for root in &roots {
if root.has_children_hint() {
root.expand().await?;
}
for child in root.children().await {
let kind = if child.has_children_hint() { "has children" } else { "leaf" };
println!("{} ({kind})", child.object().tag_name);
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events ### Watching deploy events
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The `watch_deploy_events` opens the `WatchDeployEvents` server stream. The
@@ -211,3 +249,27 @@ cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --
- [Client Proto Generation](../../docs/ClientProtoGeneration.md) - [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Rust Client Detailed Design](./RustClientDesign.md) - [Rust Client Detailed Design](./RustClientDesign.md)
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md) - [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
## Installing from the Gitea Cargo registry
The crate publishes to the internal Gitea Cargo registry. Register the
registry once in your global `~/.cargo/config.toml`:
```toml
[registries.dohertj2-gitea]
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
```
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
```toml
[registries.dohertj2-gitea]
token = "Bearer <your-gitea-token>"
```
Then add the dependency:
```toml
[dependencies]
zb-mom-ww-mxgateway-client = { version = "0.1.0", registry = "dohertj2-gitea" }
```
+19
View File
@@ -189,6 +189,25 @@ Support:
- custom CA file, - custom CA file,
- domain override. - domain override.
### Trust posture (pin-only)
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). Rust is the **exception** to the lenient-by-default posture the other
clients use: tonic 0.13.1 exposes no public hook to inject a custom certificate
verifier, so the Rust client cannot accept an arbitrary certificate. TLS over the
Rust client is therefore **pin-only** — it requires either:
- `ClientOptions::with_ca_file(...)` to pin a CA (the supported path for the
gateway's self-signed certificate; export the certificate and pin it), or
- `ClientOptions::with_require_certificate_validation(true)` to verify against the
system trust roots.
With TLS enabled (`with_plaintext(false)`), no pinned CA, and certificate
validation not required, `GatewayClient::connect` rejects the connection with a
clear, actionable error pointing at `with_ca_file` /
`require_certificate_validation` rather than silently accepting the certificate.
The CLI exposes `--ca-file` and `--require-certificate-validation`.
## Streaming ## Streaming
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
+1 -1
View File
@@ -2,7 +2,7 @@
name = "mxgw-cli" name = "mxgw-cli"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish = false
[[bin]] [[bin]]
name = "mxgw" name = "mxgw"
+8
View File
@@ -426,6 +426,11 @@ struct ConnectionArgs {
ca_file: Option<PathBuf>, ca_file: Option<PathBuf>,
#[arg(long)] #[arg(long)]
server_name_override: Option<String>, server_name_override: Option<String>,
/// Verify the server certificate against the system trust roots even
/// without a pinned CA. The Rust client's default is to require a CA
/// file (see `--ca-file`); set this flag to use system roots instead.
#[arg(long)]
require_certificate_validation: bool,
#[arg(long, default_value_t = 10)] #[arg(long, default_value_t = 10)]
connect_timeout_seconds: u64, connect_timeout_seconds: u64,
#[arg(long, default_value_t = 30)] #[arg(long, default_value_t = 30)]
@@ -453,6 +458,9 @@ impl ConnectionArgs {
if let Some(server_name_override) = &self.server_name_override { if let Some(server_name_override) = &self.server_name_override {
options = options.with_server_name_override(server_name_override); options = options.with_server_name_override(server_name_override);
} }
if self.require_certificate_validation {
options = options.with_require_certificate_validation(true);
}
options options
} }
+3 -16
View File
@@ -6,10 +6,8 @@
//! code should prefer [`GatewayClient::open_session`] and the [`Session`] //! code should prefer [`GatewayClient::open_session`] and the [`Session`]
//! handle it returns, rather than the `*_raw` methods. //! handle it returns, rather than the `*_raw` methods.
use std::fs;
use tonic::codegen::InterceptedService; use tonic::codegen::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig}; use tonic::transport::Channel;
use tonic::Request; use tonic::Request;
use crate::auth::AuthInterceptor; use crate::auth::AuthInterceptor;
@@ -21,7 +19,7 @@ use crate::generated::mxaccess_gateway::v1::{
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest, OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
StreamEventsRequest, StreamEventsRequest,
}; };
use crate::options::ClientOptions; use crate::options::{build_tls_config, ClientOptions};
use crate::session::Session; use crate::session::Session;
/// Generated gateway client wrapped in the auth interceptor that /// Generated gateway client wrapped in the auth interceptor that
@@ -78,18 +76,7 @@ impl GatewayClient {
})?; })?;
endpoint = endpoint.connect_timeout(options.connect_timeout()); endpoint = endpoint.connect_timeout(options.connect_timeout());
if !options.plaintext() { if let Some(tls) = build_tls_config(&options)? {
let mut tls = ClientTlsConfig::new();
if let Some(server_name) = options.server_name_override() {
tls = tls.domain_name(server_name.to_owned());
}
if let Some(ca_file) = options.ca_file() {
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
tls = tls.ca_certificate(Certificate::from_pem(certificate));
}
endpoint = endpoint.tls_config(tls)?; endpoint = endpoint.tls_config(tls)?;
} }
+3 -15
View File
@@ -6,13 +6,12 @@
//! re-exported through [`crate::generated::galaxy_repository::v1`]. //! re-exported through [`crate::generated::galaxy_repository::v1`].
use std::collections::HashSet; use std::collections::HashSet;
use std::fs;
use std::sync::Arc; use std::sync::Arc;
use prost_types::Timestamp; use prost_types::Timestamp;
use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Mutex as AsyncMutex;
use tonic::codegen::InterceptedService; use tonic::codegen::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig}; use tonic::transport::Channel;
use tonic::Request; use tonic::Request;
use crate::auth::AuthInterceptor; use crate::auth::AuthInterceptor;
@@ -23,7 +22,7 @@ use crate::generated::galaxy_repository::v1::{
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
WatchDeployEventsRequest, WatchDeployEventsRequest,
}; };
use crate::options::ClientOptions; use crate::options::{build_tls_config, ClientOptions};
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000; const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500; const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
@@ -183,18 +182,7 @@ impl GalaxyClient {
})?; })?;
endpoint = endpoint.connect_timeout(options.connect_timeout()); endpoint = endpoint.connect_timeout(options.connect_timeout());
if !options.plaintext() { if let Some(tls) = build_tls_config(&options)? {
let mut tls = ClientTlsConfig::new();
if let Some(server_name) = options.server_name_override() {
tls = tls.domain_name(server_name.to_owned());
}
if let Some(ca_file) = options.ca_file() {
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
tls = tls.ca_certificate(Certificate::from_pem(certificate));
}
endpoint = endpoint.tls_config(tls)?; endpoint = endpoint.tls_config(tls)?;
} }
+94
View File
@@ -3,10 +3,14 @@
//! chain of `with_*` setters; the `Debug` impl redacts the API key. //! chain of `with_*` setters; the `Debug` impl redacts the API key.
use std::fmt; use std::fmt;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use tonic::transport::{Certificate, ClientTlsConfig};
use crate::auth::ApiKey; use crate::auth::ApiKey;
use crate::error::Error;
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024; const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
@@ -22,6 +26,7 @@ pub struct ClientOptions {
api_key: Option<ApiKey>, api_key: Option<ApiKey>,
plaintext: bool, plaintext: bool,
ca_file: Option<PathBuf>, ca_file: Option<PathBuf>,
require_certificate_validation: bool,
server_name_override: Option<String>, server_name_override: Option<String>,
connect_timeout: Duration, connect_timeout: Duration,
call_timeout: Duration, call_timeout: Duration,
@@ -38,6 +43,7 @@ impl ClientOptions {
api_key: None, api_key: None,
plaintext: true, plaintext: true,
ca_file: None, ca_file: None,
require_certificate_validation: false,
server_name_override: None, server_name_override: None,
connect_timeout: Duration::from_secs(10), connect_timeout: Duration::from_secs(10),
call_timeout: Duration::from_secs(30), call_timeout: Duration::from_secs(30),
@@ -67,6 +73,22 @@ impl ClientOptions {
self self
} }
/// Require TLS certificate verification even without a pinned CA. Default
/// false: the gateway's self-signed certificate is accepted (internal-tool
/// posture). Setting a CA file always verifies.
///
/// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a
/// custom rustls verifier, so the Rust client cannot accept an arbitrary
/// self-signed certificate the way the other clients do. With the default
/// (false) and no pinned CA, [`crate::client::GatewayClient::connect`]
/// rejects the TLS connection and asks for a CA file. Either pin a CA via
/// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or
/// set this `true` to verify against the system trust roots.
pub fn with_require_certificate_validation(mut self, require: bool) -> Self {
self.require_certificate_validation = require;
self
}
/// Override the SNI/server name used during the TLS handshake. Useful /// Override the SNI/server name used during the TLS handshake. Useful
/// when the dial-target host name does not match the certificate. /// when the dial-target host name does not match the certificate.
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self { pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
@@ -121,6 +143,12 @@ impl ClientOptions {
self.ca_file.as_ref() self.ca_file.as_ref()
} }
/// Whether TLS certificate verification is required even without a pinned
/// CA. See [`ClientOptions::with_require_certificate_validation`].
pub fn require_certificate_validation(&self) -> bool {
self.require_certificate_validation
}
/// Optional SNI / server-name override for TLS handshakes. /// Optional SNI / server-name override for TLS handshakes.
pub fn server_name_override(&self) -> Option<&str> { pub fn server_name_override(&self) -> Option<&str> {
self.server_name_override.as_deref() self.server_name_override.as_deref()
@@ -147,6 +175,68 @@ impl ClientOptions {
} }
} }
/// Build the [`ClientTlsConfig`] for a non-plaintext connection described by
/// `options`, applying the lenient-default guard that is the **Rust
/// pin-only exception**.
///
/// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed).
/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled.
/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned
/// CA was provided and `require_certificate_validation` is `false`.
///
/// # Why this guard exists
///
/// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a
/// crate-private connector and exposes no hook for a custom
/// `ServerCertVerifier`. The Rust client therefore cannot accept an arbitrary
/// self-signed certificate the way the other language clients do. Rather than
/// silently falling back to system-root verification (which always fails
/// against a self-signed gateway certificate), we reject the configuration
/// early with an actionable error.
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
if options.plaintext() {
return Ok(None);
}
let mut tls = ClientTlsConfig::new();
if let Some(server_name) = options.server_name_override() {
tls = tls.domain_name(server_name.to_owned());
}
if let Some(ca_file) = options.ca_file() {
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
tls = tls.ca_certificate(Certificate::from_pem(certificate));
} else if !options.require_certificate_validation() {
// Lenient-default fallback (Rust pin-only exception): tonic
// 0.13's `ClientTlsConfig` builds its rustls verifier inside a
// crate-private connector and exposes no hook for a custom
// `ServerCertVerifier`, so — unlike the other clients — the
// Rust client cannot accept an arbitrary self-signed cert. Pin
// the gateway's CA instead, or opt into strict verification
// against the system trust roots. We reject here rather than
// silently verifying against system roots (which would fail a
// self-signed gateway with a confusing handshake error).
//
// Note: a server-name override affects SNI (the hostname sent
// in the TLS ClientHello) but does NOT pin trust. Overriding
// the server name alone does not bypass certificate validation.
return Err(Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: "TLS requested without a pinned CA. The Rust client cannot accept an \
arbitrary self-signed certificate (tonic 0.13 exposes no custom \
rustls verifier). Pin the gateway certificate with \
ClientOptions::with_ca_file, or call \
ClientOptions::with_require_certificate_validation(true) to verify \
against the system trust roots. Note: a server-name override \
affects SNI but does not pin trust."
.to_owned(),
});
}
Ok(Some(tls))
}
impl Default for ClientOptions { impl Default for ClientOptions {
fn default() -> Self { fn default() -> Self {
Self::new("http://127.0.0.1:5000") Self::new("http://127.0.0.1:5000")
@@ -161,6 +251,10 @@ impl fmt::Debug for ClientOptions {
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>")) .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("plaintext", &self.plaintext) .field("plaintext", &self.plaintext)
.field("ca_file", &self.ca_file) .field("ca_file", &self.ca_file)
.field(
"require_certificate_validation",
&self.require_certificate_validation,
)
.field("server_name_override", &self.server_name_override) .field("server_name_override", &self.server_name_override)
.field("connect_timeout", &self.connect_timeout) .field("connect_timeout", &self.connect_timeout)
.field("call_timeout", &self.call_timeout) .field("call_timeout", &self.call_timeout)
+137
View File
@@ -0,0 +1,137 @@
//! TLS posture coverage for the Rust client.
//!
//! tonic 0.13.1's `ClientTlsConfig` exposes no hook for a custom rustls
//! `ServerCertVerifier` (the verifier is built internally inside the
//! crate-private `TlsConnector`), so the Rust client cannot implement the
//! "accept any server certificate" lenient default the other clients use.
//! Rust is therefore the documented **pin-only exception**: TLS without a
//! pinned CA is rejected up front with a clear, actionable error, and
//! supplying a CA file is the supported path. These tests pin that contract.
use std::time::Duration;
use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GalaxyClient, GatewayClient};
/// Drive `connect` to its error without requiring `GatewayClient: Debug`
/// (the success arm is dropped explicitly so `unwrap_err` is unnecessary).
async fn connect_err(options: ClientOptions) -> Error {
match GatewayClient::connect(options).await {
Ok(_client) => panic!("connect unexpectedly succeeded against a dead TLS address"),
Err(error) => error,
}
}
#[tokio::test]
async fn tls_without_ca_is_rejected_with_actionable_error_by_default() {
let options = ClientOptions::new("https://127.0.0.1:1")
.with_plaintext(false)
.with_connect_timeout(Duration::from_millis(200));
let error = connect_err(options).await;
let Error::InvalidEndpoint { detail, .. } = error else {
panic!("expected InvalidEndpoint, got {error:?}");
};
// The message must point the caller at the supported remedy (pin a CA)
// and name the opt-in escape hatch.
assert!(
detail.contains("ca_file") || detail.contains("CA"),
"error should instruct the user to pass a CA file: {detail}"
);
assert!(
detail.contains("require_certificate_validation"),
"error should mention the require_certificate_validation opt-in: {detail}"
);
}
#[tokio::test]
async fn tls_with_require_certificate_validation_does_not_short_circuit() {
// With strict verification opted in, the no-CA guard must not fire; the
// connect attempt instead proceeds to the transport (and fails to reach
// the dead address) rather than returning the "CA required" guard error.
let options = ClientOptions::new("https://127.0.0.1:1")
.with_plaintext(false)
.with_require_certificate_validation(true)
.with_connect_timeout(Duration::from_millis(200));
let error = connect_err(options).await;
assert!(
!matches!(&error, Error::InvalidEndpoint { detail, .. }
if detail.contains("require_certificate_validation")),
"strict verification must bypass the no-CA guard, got {error:?}"
);
}
#[tokio::test]
async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() {
// Pinning a CA is the supported TLS path: the no-CA guard must not fire.
// We hand it a readable PEM file; construction proceeds past the guard
// and only fails later at the transport (dead address / handshake).
let ca_path = std::env::temp_dir().join("mxgw-rust-tls-ca-fixture.pem");
std::fs::write(&ca_path, SELF_SIGNED_CA_PEM).unwrap();
let options = ClientOptions::new("https://127.0.0.1:1")
.with_plaintext(false)
.with_ca_file(&ca_path)
.with_connect_timeout(Duration::from_millis(200));
let error = connect_err(options).await;
let _ = std::fs::remove_file(&ca_path);
assert!(
!matches!(&error, Error::InvalidEndpoint { detail, .. }
if detail.contains("require_certificate_validation")),
"pinning a CA must bypass the no-CA guard, got {error:?}"
);
}
/// Drive `GalaxyClient::connect` to its error (mirrors `connect_err` above).
async fn galaxy_connect_err(options: ClientOptions) -> Error {
match GalaxyClient::connect(options).await {
Ok(_client) => {
panic!("GalaxyClient::connect unexpectedly succeeded against a dead TLS address")
}
Err(error) => error,
}
}
#[tokio::test]
async fn galaxy_tls_without_ca_is_rejected_with_actionable_error_by_default() {
// GalaxyClient::connect must apply the same TLS guard as GatewayClient —
// TLS without a pinned CA (and without require_certificate_validation)
// returns a clear, actionable InvalidEndpoint error.
let options = ClientOptions::new("https://127.0.0.1:1")
.with_plaintext(false)
.with_connect_timeout(Duration::from_millis(200));
let error = galaxy_connect_err(options).await;
let Error::InvalidEndpoint { detail, .. } = error else {
panic!("expected InvalidEndpoint, got {error:?}");
};
assert!(
detail.contains("ca_file") || detail.contains("CA"),
"error should instruct the user to pass a CA file: {detail}"
);
assert!(
detail.contains("require_certificate_validation"),
"error should mention the require_certificate_validation opt-in: {detail}"
);
}
/// A throwaway self-signed CA certificate (PEM). Only needs to parse as a
/// PEM trust root so the CA-pinning path is exercised past the guard.
const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----
";
+156
View File
@@ -790,3 +790,159 @@ Post-ack transition: kind=Clear …
10s cadence held throughout; full proto fields populated correctly; 10s cadence held throughout; full proto fields populated correctly;
ack registered server-side without errors. ack registered server-side without errors.
## Subtag-monitoring fallback provider
When the wnwrap alarm-manager source fails, the gateway worker switches to
`SubtagAlarmConsumer` — a synthetic alarm source that advises each alarm
attribute's subtags via the existing MXAccess `AddItem`/`Advise` pipeline and
derives alarm transitions from the resulting value-change stream. This is a
non-parity, degraded-mode source; every transition and snapshot it produces
carries `degraded = true`.
### Watch-list discovery
`GatewayAlarmMonitor` resolves the subtag watch-list at subscribe time by
calling `IAlarmWatchListResolver.GetAlarmAttributesAsync`. The resolver merges:
1. Galaxy Repository SQL (`GetAlarmAttributesAsync`) — objects that have alarm
extensions in the configured area.
2. Config overrides — `IncludeAttributes` adds explicit entries;
`ExcludeAttributes` removes Repository-derived ones. The config list takes
effect even when `UseGalaxyRepository` is `false`.
The resolved list is a set of `AlarmSubtagTarget` messages sent to the worker
inside `SubscribeAlarmsCommand.watch_list`. Each target carries the composed
MXAccess item addresses for the `InAlarm`, `Acked`, `AckMsg`, and `Priority`
subtags (confirmed AVEVA `AlarmExtension` field names, verified against the live
ZB Galaxy `attribute_definition` rows). The gateway re-runs discovery on its
reconcile cadence and pushes an updated watch-list when the model changes.
Each target's canonical `AlarmFullReference` is composed as
`Galaxy!{area}.{reference}` (literal `Galaxy` provider). The `{area}` is the
alarm object's **real Galaxy area** — discovered per object via
`gobject.area_gobject_id` (`GetAlarmAttributesAsync` projects it as `area_name`)
— so the synthesized reference's group matches exactly the area the native
alarmmgr (wnwrap) emits for the same alarm (e.g. `TestMachine_001` in `TestArea`
yields `Galaxy!TestArea.TestMachine_001.TestAlarm001`). The configured
`Discovery.Area` / `DefaultArea` is **only** the fallback for explicit
`IncludeAttributes` entries, which carry no discovered area.
### Subtag advise and `LmxSubtagAlarmSource`
`LmxSubtagAlarmSource` (implements `ISubtagAlarmSource`) owns a separate
`LMXProxyServerClass` instance on the worker STA — it does not share the
session's main MXAccess object. For each watch-list target it calls
`AddItem`/`Advise` on the configured subtag addresses. When a subtag value
changes, it raises `ValueChanged` on the STA and `SubtagAlarmConsumer`
forwards it to `SubtagAlarmStateMachine`.
`PollOnce()` on the subtag consumer is a no-op — the path is event-driven
through `Advise`, not poll-driven.
### Synthesis rules
`SubtagAlarmStateMachine` tracks `(active, acked)` per watch-list entry and
emits `MxAlarmTransitionEvent` records on change:
| Subtag change | Emitted transition | Notes |
|---|---|---|
| `InAlarm` false → true | Raise (`UNACK_ALM`) | `original_raise_timestamp` = first observed active time for this episode |
| `Acked` false → true, while `InAlarm` | Acknowledge (`ACK_ALM`) | `AckedDuringEpisode` latch set |
| `InAlarm` true → false | Clear | `AckRtn` if `AckedDuringEpisode` is set, else `UnackRtn` |
| `Acked` true → false, while `InAlarm` | (none) | Latch is NOT cleared; the episode retains its acknowledged status at clear |
The `AckedDuringEpisode` latch addresses out-of-order subtag delivery:
MXAccess does not guarantee the `Acked = false` update arrives before the
`InAlarm = false` update. The latch ensures a clear always emits `ACK_RTN`
when the alarm was acknowledged at any point during the active episode.
`SnapshotActive()` returns one `MxAlarmSnapshotRecord` per currently-active
alarm. State mapping:
- `InAlarm && !Acked``UNACK_ALM`
- `InAlarm && Acked``ACK_ALM`
- `!InAlarm` → not included in the snapshot
### Synthetic GUID
The alarmmgr provider supplies a native GUID per alarm record. The subtag
provider has no native GUID. `SubtagAlarmConsumer` derives a deterministic
GUID by hashing `alarm_full_reference` (via `SyntheticAlarmGuid.ForReference`).
The same reference always produces the same GUID within a session, so
GUID-based ack routing resolves correctly. The GUID is not stable across
different alarm references or gateway restarts in the sense of matching any
AVEVA-internal GUID.
### Acknowledge in subtag mode
`AlarmDispatcher` routes ack calls by active provider mode:
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
write target).
If the alarm has no writable ack-comment subtag (`AckComment` config key is
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
returns a failure code that the gateway surfaces as `FailedPrecondition`.
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
internal dictionary, then calls the same write path.
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
observed ones (active/acked/priority). This is required: MXAccess rejects a
write to an item that has been added but not advised with `E_INVALIDARG`
("Value does not fall within the expected range"). Advising it at subscribe
time makes it an active item so the later ack write succeeds — its value
changes carry no transition (the state machine ignores unmapped addresses).
### Live validation
The subtag path was validated against live MXAccess on the dev rig
(`DESKTOP-6JL3KKO`, Galaxy `DEV`, `TestMachine_001.TestAlarm001`):
- `…​.InAlarm``True` (Boolean), `…​.Acked``False` (Boolean),
`…​.Priority``500` (Int32), `…​.AckMsg` → string — confirming the field
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
with **no** intermediate alarm-condition segment.
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
was advised — confirming the ack-by-comment-write mechanism end to end.
### Fidelity limitations
The following fields are not available or have lower quality in subtag mode:
| Field | Subtag-mode behavior |
|-------|---------------------|
| `alarm_guid` | Synthetic deterministic GUID from `alarm_full_reference`; not an AVEVA-native GUID |
| `original_raise_timestamp` | First observed `active = true` time; no AVEVA-native raise time |
| `transition_timestamp` | `OnDataChange` source timestamp from MXAccess |
| `severity` | From priority subtag if advised; 0 otherwise |
| `category` / `description` | Not populated (no subtag for these) |
| `current_value` / `limit_value` | Not populated unless corresponding subtags are in the watch-list |
| `alarm_type_name` | Not populated |
| `operator_user` / `operator_comment` | Not populated on synthesized raise/clear transitions |
| `retrigger` transition | Not synthesized (no re-alarm counter subtag is observed) |
Every transition and snapshot record carries `degraded = true` and
`source_provider = ALARM_PROVIDER_MODE_SUBTAG`. Clients that require full
fidelity must wait for failback to the alarm manager.
### Provider mode reflection
When `FailoverAlarmConsumer` switches between providers, it raises
`ProviderModeChanged`. `AlarmDispatcher` enqueues an
`OnAlarmProviderModeChangedEvent` (carried as an `MxEvent`), which the
gateway receives and reflects into:
- `AlarmFeedMessage.provider_status` emitted to every `StreamAlarms`
subscriber.
- The `/hubs/alarms` SignalR hub for the dashboard.
- Metrics: `mxgateway.alarms.provider_mode` gauge and
`mxgateway.alarms.provider_switches` counter.
On every switch `GatewayAlarmMonitor` also forces a reconcile
(`QueryActiveAlarms`) against the now-active provider so the gateway cache
reflects the post-switch state without a spurious raise/clear storm.
+13
View File
@@ -51,6 +51,19 @@ The shared inputs are:
The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's
`api-key-env` flag. They must not embed bearer tokens or raw API keys. `api-key-env` flag. They must not embed bearer tokens or raw API keys.
### TLS variant
The matrix runs over plaintext (`h2c`) by default. A TLS variant exists but stays
a manual/opt-in run, consistent with the gate above, because it needs the gateway
started with an HTTPS endpoint (an `https://` `MXGATEWAY_ENDPOINT`) and each CLI
switched to its TLS flag (`--tls` / `-tls` / `--plaintext=false` /
`plaintext=False`). The clients are lenient by default and accept the gateway's
auto-generated self-signed certificate without extra trust setup, except the Rust
CLI, which is pin-only and needs `--ca-file` or `--require-certificate-validation`
(and Python uses trust-on-first-use). See
[Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
and each client README for the per-client TLS flags.
## JSON Comparison ## JSON Comparison
Every command in the matrix requests JSON output. A runner can compare the Every command in the matrix requests JSON output. A runner can compare the
+88
View File
@@ -375,6 +375,94 @@ deployment-heavy box, multiply per-session SQL connections, and complicate the
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
replies and a heavy DOM) without disturbing the materialization model. replies and a heavy DOM) without disturbing the materialization model.
## TLS Auto-Certificate and Lenient Client Trust
Decision: when a Kestrel `https://` endpoint is configured without a certificate
of its own (and no `Kestrel:Certificates:Default` is set), the gateway generates
and persists a self-signed certificate rather than failing to start. Clients
connecting over TLS without a pinned CA accept whatever certificate the server
presents by default; pinning a CA restores full verification.
Rationale: `mxaccessgw` is an internal tool with no PKI to issue or distribute
certificates. The prior behavior — an `https` endpoint with no certificate
fails at startup with Kestrel's opaque "no server certificate was specified"
error — pushed operators toward plaintext (`h2c`), exposing the API key and
request payloads on the wire. Auto-generating a long-lived, persisted, reused
certificate lets TLS "just work" with zero certificate management, while the
lenient client default means clients connect to that self-signed certificate
without a manual trust step. Both choices are deliberate, not oversights:
strict-by-default would force PKI work this tool does not warrant. Plaintext-only
deployments are untouched — no certificate or key material is written for them —
and an operator who supplies a real certificate transparently overrides the
generated one.
Two clients diverge from "accept any certificate" because their gRPC stacks lack
a per-channel skip-verify hook:
- Python uses trust-on-first-use: it fetches the server's presented certificate
over a separate unverified probe and pins it for the channel, and defaults the
SNI/target-name override to `localhost` (the generated certificate always
carries a `localhost` SAN).
- Rust is pin-only: tonic exposes no public hook to inject a custom certificate
verifier, so TLS over Rust requires either a pinned CA or an explicit opt-in to
system-trust verification; otherwise connecting returns a clear, actionable
error.
See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
and the per-client READMEs for the as-built behavior.
## Alarm-Manager to Subtag Fallback
Decision: add a second alarm provider (subtag monitoring) that the worker
activates automatically when the native wnwrap alarm manager fails, and fails
back to automatically when the manager recovers.
### Worker-side synthesis
Synthesis of alarm transitions from subtag value changes happens entirely in
the worker (`SubtagAlarmConsumer` / `SubtagAlarmStateMachine`). The gateway
still forwards only events the worker emits and synthesizes nothing itself.
This satisfies the parity rule even though the subtag path is inherently
non-parity: the parity rule governs where synthesis lives, not whether
synthesis is permitted when the native source is unavailable.
### Degraded is explicit
Every subtag-mode transition carries `degraded = true` on the
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto messages, and the
`AlarmFeedMessage` feed carries an `AlarmProviderStatus` payload on stream
open and on every switch. No client can mistake a subtag-mode alarm for an
authoritative alarmmgr record. Subtag mode has lower fidelity: synthetic
deterministic GUID (SHA-derived from the alarm reference), best-effort
original-raise timestamp, narrower field set. Clients that need full fidelity
must wait for failback.
### Failover trigger
The failover trigger is N consecutive wnwrap COM failures — a `COMException`
thrown by `Subscribe` or `PollOnce`, or a failure HRESULT from
`GetXmlCurrentAlarms2`. A single poll failure does not trigger a switch; the
threshold (default 3, floored at 1) guards against transient COM hiccups. The
counter resets on any clean poll so a flapping provider does not permanently
latch in subtag mode.
### Acknowledge via ack-comment write
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
performs the native ack in AVEVA. This differs from alarmmgr mode, where
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
subtag name is empty by default; configuring it is required for ack to work in
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
config block exists precisely so names are not guessed without validation
against the live MXAccess attribute set.
### Related documentation
- [Gateway Configuration — Alarm Fallback options](./GatewayConfiguration.md#alarm-fallback-options)
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)
## Later Revisit Items ## Later Revisit Items
These are explicit post-v1 revisit items, not open blockers: These are explicit post-v1 revisit items, not open blockers:
+249
View File
@@ -148,6 +148,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: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: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: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:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `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: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. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
@@ -229,6 +230,254 @@ behavior.
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor. `StreamAlarms` are session-less RPCs served by the monitor.
### Alarm fallback options
The `Fallback` sub-section controls how the alarm feed selects between the
native wnwrap alarm-manager provider and the subtag-monitoring fallback.
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover. `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
| `MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold` | `3` | Number of consecutive wnwrap COM failures (`COMException` or failure HRESULT from `Subscribe` / `GetXmlCurrentAlarms2`) before the monitor switches to subtag mode. Floored at 1. |
| `MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds` | `30` | While in subtag mode, how often (in seconds) the monitor probes the wnwrap provider to detect recovery. Floored at 1. |
| `MxGateway:Alarms:Fallback:FailbackStableProbes` | `3` | Number of consecutive clean wnwrap probes required before the monitor switches back to the alarm manager. Floored at 1. |
| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. |
| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. This area is **not** used to compose a Repository-derived alarm's canonical `Galaxy!{area}.{reference}`: each discovered alarm uses its object's real Galaxy area (discovered via `gobject.area_gobject_id`), so the reference's group matches what the native alarmmgr emits. `Discovery:Area` / `DefaultArea` is used as the composition area only for explicit `IncludeAttributes` entries, which carry no discovered area. |
| `MxGateway:Alarms:Fallback:Discovery:IncludeAttributes` | _(empty)_ | Explicit MXAccess attribute paths to add to the subtag watch-list, supplementing (or replacing, when `UseGalaxyRepository` is `false`) the Repository-derived list. |
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the Repository-derived watch-list. Ignored when `UseGalaxyRepository` is `false`. |
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
Validation rules:
- `Mode` must be `Auto`, `ForceAlarmManager`, or `ForceSubtag` (case-insensitive).
- `Mode = ForceSubtag` with both `UseGalaxyRepository = false` and an empty
`IncludeAttributes` list produces a startup validation warning: the subtag
provider has no attributes to advise.
- `ConsecutiveFailureThreshold`, `FailbackProbeIntervalSeconds`, and
`FailbackStableProbes` are floored at 1 by `GatewayOptionsValidator`.
Full example with non-default fallback settings:
```json
{
"MxGateway": {
"Alarms": {
"Enabled": true,
"SubscriptionExpression": "\\\\SCADA01\\Galaxy!PlantArea",
"DefaultArea": "PlantArea",
"ReconcileIntervalSeconds": 30,
"Fallback": {
"Mode": "Auto",
"ConsecutiveFailureThreshold": 3,
"FailbackProbeIntervalSeconds": 30,
"FailbackStableProbes": 3,
"Discovery": {
"UseGalaxyRepository": true,
"Area": "",
"IncludeAttributes": [],
"ExcludeAttributes": []
},
"Subtags": {
"Active": "InAlarm",
"Acked": "Acked",
"AckComment": "AckMsg",
"Priority": "Priority"
}
}
}
}
}
```
The defaults (`InAlarm`/`Acked`/`AckMsg`/`Priority`) are the confirmed AVEVA
`AlarmExtension` primitive field names, verified by querying the live ZB Galaxy
`attribute_definition` rows. The `Subtags` block exists so names can be
overridden without a code change if a site's alarm template uses different
attribute names. See `docs/AlarmClientDiscovery.md` for the synthesis rules that
depend on these names.
## Host Endpoints and Transport Security (Kestrel)
The listening endpoints are **not** part of the `MxGateway` section. The gateway
uses the stock ASP.NET Core host (`WebApplication.CreateBuilder`) with no
`ConfigureKestrel` call in code, so endpoints come entirely from the standard
`Kestrel` configuration section. On the deployed hosts these values are supplied
as NSSM environment variables (`Kestrel__Endpoints__...`), not from
`appsettings.json`.
Two named endpoints are bound:
| Endpoint name | Purpose | Protocol requirement |
|---|---|---|
| `Http` | Public gRPC API (sessions, invoke, events, Galaxy browse) | HTTP/2 |
| `Dashboard` | Blazor dashboard and SignalR hubs | HTTP/1.1 (HTTP/2 optional) |
Both endpoints share one routing pipeline; the names only select which TCP port
serves which traffic. The gRPC endpoint must negotiate **HTTP/2**, which drives
the protocol settings below.
### Plaintext (current deployments)
Both running hosts (`10.100.0.48` and `wonder-app-vd03`) serve the gRPC port in
**cleartext HTTP/2 (`h2c`)**. Because cleartext HTTP/2 has no ALPN to negotiate
the protocol, the gRPC endpoint must be pinned to `Http2` with prior knowledge:
```text
Kestrel__Endpoints__Http__Url=http://0.0.0.0:5120
Kestrel__Endpoints__Http__Protocols=Http2
Kestrel__Endpoints__Dashboard__Url=http://0.0.0.0:5130
```
In this mode all client↔gateway traffic — including the
`authorization: Bearer mxgw_...` API key and any `WriteSecured` / `AuthenticateUser`
payloads — crosses the network **unencrypted**. This is acceptable only on a
trusted/isolated network segment. Prefer TLS for anything else.
### TLS
To encrypt the gRPC channel, give the `Http` endpoint an `https://` URL and a
certificate. Over TLS, ALPN negotiates HTTP/2, so the explicit `Protocols=Http2`
pin is no longer required (the default `Http1AndHttp2` works for gRPC over TLS).
`appsettings.json` form:
```json
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "https://0.0.0.0:5120",
"Certificate": {
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
"Password": "<pfx-password>"
}
},
"Dashboard": {
"Url": "https://0.0.0.0:5130",
"Certificate": {
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
"Password": "<pfx-password>"
}
}
}
}
}
```
Equivalent NSSM environment-variable form (how config is delivered on the hosts —
see [server deploy mechanics in the project notes]):
```text
Kestrel__Endpoints__Http__Url=https://0.0.0.0:5120
Kestrel__Endpoints__Http__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
Kestrel__Endpoints__Http__Certificate__Password=<pfx-password>
Kestrel__Endpoints__Dashboard__Url=https://0.0.0.0:5130
Kestrel__Endpoints__Dashboard__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
Kestrel__Endpoints__Dashboard__Certificate__Password=<pfx-password>
```
Certificate sourcing options (any standard ASP.NET Core form is accepted):
| Form | Keys |
|---|---|
| PFX file | `Certificate:Path` (+ `Certificate:Password` if encrypted) |
| PEM pair | `Certificate:Path` (cert) + `Certificate:KeyPath` (private key) |
| Windows cert store | `Certificate:Subject`, `Certificate:Store` (e.g. `My`), `Certificate:Location` (`LocalMachine`), `Certificate:AllowInvalid` |
The certificate's CN/SAN must cover the host name clients dial (or clients must
set a server-name override — see below). The dashboard endpoint can keep its own
certificate independent of the gRPC endpoint; pair this with
`MxGateway:Dashboard:RequireHttpsCookie` (`true`) for production HTTPS.
### Automatic self-signed certificate
`mxaccessgw` is an internal tool with no PKI to issue certificates, so requiring
an operator to supply one before TLS works pushed deployments toward plaintext.
To avoid that, the gateway fills in a self-signed certificate when an HTTPS
endpoint is configured without one.
**Trigger.** At startup the gateway inspects `Kestrel:Endpoints:*`. If any
endpoint has an `https://` URL and no `Certificate` subsection of its own, and no
`Kestrel:Certificates:Default` is set, the gateway generates (or loads) a
persisted self-signed certificate and wires it in as the HTTPS *default* via
`ConfigureHttpsDefaults`. All-plaintext deployments are untouched: when no HTTPS
endpoint is configured, no certificate or key material is generated or written.
**Generated certificate.** ECDSA P-256, `serverAuth` EKU, validity ≈
`ValidityYears` (default 10 years, with one day of clock-skew slack before
`notBefore`). SANs cover `localhost`, the machine name (and its FQDN when
resolvable), each entry in `AdditionalDnsNames`, and the loopback addresses
`127.0.0.1` and `::1`.
**`MxGateway:Tls:*` options.** All optional; the zero-config path needs none of
them.
| Option | Default | Purpose |
|---|---|---|
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1100) |
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
`ValidityYears` is validated by `GatewayOptionsValidator` (range 1100); the
"HTTPS endpoint configured but no certificate available" fail-fast lives in the
bootstrap/provider, because the validator only sees the `MxGateway` section, not
`Kestrel:Endpoints`.
**Persistence.** The PFX is written with an **empty** export password — a random
in-memory password could not be reused across restarts, which the
persist-and-reuse model requires. The private key is instead protected at rest by
filesystem permissions: a restrictive ACL on Windows (SYSTEM + Administrators,
inherited ACEs stripped) on the `certs` directory and file, and mode `0600` on
non-Windows. The write is atomic (hardened temp file, then move). The persisted
certificate is reused across restarts (stable thumbprint, so CA-pinning clients
keep working) and regenerated only when it is missing, expired (and
`RegenerateIfExpired` is `true`), or unreadable/corrupt. If the directory is not
writable or the ACL cannot be applied, the gateway fails fast with a diagnostic
naming the path rather than falling back to an in-memory certificate.
**Logging.** On generate or load, the gateway logs the certificate thumbprint,
SAN list, and `notAfter` at Information. The PFX bytes, export password, and
private key are never logged.
**Operator override.** The generated certificate is only the HTTPS *default*. To
use a real certificate, configure one explicitly — either per endpoint via
`Kestrel:Endpoints:<name>:Certificate` (`Path`/`Subject`/`Thumbprint`, etc., as
in the table above) or globally via `Kestrel:Certificates:Default`. An
explicitly-configured certificate takes precedence, and the gateway then writes
no self-signed material.
### Client side
Each official client opts into TLS explicitly. For the .NET client
(`MxGatewayClientOptions`):
| Option | Effect |
|---|---|
| `UseTls` (default `false`) | Enables TLS. Requires an `https://` endpoint; an `https://` endpoint without `UseTls` fails validation, and vice versa. |
| `CaCertificatePath` | Pins a custom root (self-signed / private CA) using `CustomRootTrust` chain validation instead of the OS trust store; the .NET client also enforces the certificate hostname/SAN match on this path. |
| `RequireCertificateValidation` (default `false`) | Forces OS/system-trust verification on a TLS connection with no pinned CA. Leave `false` for the lenient default. |
| `ServerNameOverride` | SNI / certificate host name override when the dialed host differs from the certificate CN/SAN. |
To pair with the auto-generated self-signed certificate above, the clients are
**lenient by default**: a TLS connection with no pinned CA accepts whatever
certificate the gateway presents. Pin `CaCertificatePath` to verify, or set
`RequireCertificateValidation` to force system-trust verification without
pinning. The other language clients expose the equivalent options; the exact
behavior differs per stack — Python uses trust-on-first-use and Rust is pin-only.
See each client README for the as-built behavior.
### Gateway↔worker IPC
Transport security here applies only to the public gRPC channel. The
gateway↔worker link is a per-session **named pipe**
(`mxaccess-gateway-{gatewayPid}-{sessionId}`), not a network socket. It is not
TLS-encrypted and does not need to be: it never leaves the local Windows host and
is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md).
## Related Documentation ## Related Documentation
- [Gateway Process Detailed Design](./GatewayProcessDesign.md) - [Gateway Process Detailed Design](./GatewayProcessDesign.md)
+85
View File
@@ -94,6 +94,73 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree. `StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
#### Provider status on the alarm feed
`AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying
an `AlarmProviderStatus` message:
```protobuf
message AlarmProviderStatus {
AlarmProviderMode mode = 1;
bool degraded = 2; // true whenever mode == SUBTAG
string reason = 3; // human-readable switch reason
google.protobuf.Timestamp since = 4;
}
```
The gateway emits `provider_status` once when a client first subscribes
(immediately after the initial snapshot and before the first live transition)
and again on every failover or failback. A late-joining client therefore
always learns the current provider mode without waiting for the next switch.
`AlarmProviderMode` is an enum with three values:
| Value | Meaning |
|-------|---------|
| `ALARM_PROVIDER_MODE_UNSPECIFIED` (0) | Default / unset |
| `ALARM_PROVIDER_MODE_ALARMMGR` (1) | Native wnwrap alarm-manager source |
| `ALARM_PROVIDER_MODE_SUBTAG` (2) | Subtag-monitoring fallback (degraded) |
#### Degraded and source-provider fields on transitions and snapshots
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` both carry two new fields:
- `bool degraded` (field 14) — `true` when the record came from the subtag
fallback, not the native alarmmgr.
- `AlarmProviderMode source_provider` (field 15) — which provider produced
this record (`ALARMMGR` or `SUBTAG`).
Both fields are proto3 defaults (`false` / `UNSPECIFIED`) in alarmmgr mode,
so existing clients that do not read them continue to function without change.
Clients that care about provenance — for example, an OPC UA server that
applies different quality flags to degraded alarms — should inspect `degraded`
before consuming the transition.
Subtag-mode records are a non-parity source. They carry synthetic GUIDs,
best-effort timestamps, and reduced field coverage. See
`docs/AlarmClientDiscovery.md` for the full fidelity table.
#### Provider-mode-changed event
The worker emits `OnAlarmProviderModeChangedEvent` (family
`MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED`) on each switch between
providers:
```protobuf
message OnAlarmProviderModeChangedEvent {
AlarmProviderMode mode = 1;
string reason = 2;
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
google.protobuf.Timestamp at = 4;
}
```
This event arrives on the `StreamEvents` stream of the alarm monitor's
internal gateway session (not on client sessions). `GatewayAlarmMonitor`
consumes it and reflects the new mode into the `StreamAlarms` feed's
`provider_status`, the dashboard hub, and metrics. Client sessions do not
receive this event directly.
## Validation Rules ## Validation Rules
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free. `MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
@@ -243,9 +310,27 @@ services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInt
Because the interceptor runs before any handler, `MxAccessGatewayService` can safely assume the call has been authorized and that `IGatewayRequestIdentityAccessor.Current` is populated. The handler's only responsibility is to read the identity for `OpenSession` so the session is owned by the authenticated principal; it does not perform any authorization checks of its own. See [Authorization](./Authorization.md) for the policy and identity model. Because the interceptor runs before any handler, `MxAccessGatewayService` can safely assume the call has been authorized and that `IGatewayRequestIdentityAccessor.Current` is populated. The handler's only responsibility is to read the identity for `OpenSession` so the session is owned by the authenticated principal; it does not perform any authorization checks of its own. See [Authorization](./Authorization.md) for the policy and identity model.
## Transport Security
The gRPC endpoint runs over HTTP/2, in cleartext (`h2c`) or TLS depending on the
Kestrel endpoint configuration. The current deployments serve it in cleartext, so
the API key and request payloads cross the network unencrypted. The endpoint,
protocol pinning, and TLS certificate configuration — plus the corresponding
client `UseTls` / `CaCertificatePath` options — are documented in
[Host Endpoints and Transport Security](./GatewayConfiguration.md#host-endpoints-and-transport-security-kestrel).
To make TLS usable without PKI, the gateway can auto-generate and persist a
self-signed certificate when an HTTPS endpoint is configured without one, and the
language clients are lenient by default — a TLS connection with no pinned CA
accepts the presented certificate (with per-stack nuances: Python is
trust-on-first-use, Rust is pin-only). See
[Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
and each client README for the as-built behavior.
## Related Documentation ## Related Documentation
- [Contracts](./Contracts.md) - [Contracts](./Contracts.md)
- [Sessions](./Sessions.md) - [Sessions](./Sessions.md)
- [Authorization](./Authorization.md) - [Authorization](./Authorization.md)
- [Gateway Configuration](./GatewayConfiguration.md)
- [Gateway Process Design](./GatewayProcessDesign.md) - [Gateway Process Design](./GatewayProcessDesign.md)
+6 -6
View File
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
## Overview ## Overview
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock. `GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
## Meter and OpenTelemetry Compatibility ## Meter and OpenTelemetry Compatibility
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
```csharp ```csharp
public sealed class GatewayMetrics : IDisposable public sealed class GatewayMetrics : IDisposable
{ {
public const string MeterName = "ZB.MOM.WW.MxGateway.Server"; public const string MeterName = "ZB.MOM.WW.MxGateway";
public GatewayMetrics() public GatewayMetrics()
{ {
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
### Histograms ### Histograms
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`): Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
```csharp ```csharp
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms"); _workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms"); _commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms"); _eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
``` ```
| Instrument | Tags | What it measures | | Instrument | Tags | What it measures |
@@ -0,0 +1,156 @@
# Gateway TLS Auto-Certificate and Lenient Client Trust — Design
Date: 2026-06-01
Status: Approved (brainstorming), pending implementation plan
## Problem
The gateway can serve gRPC and the dashboard over TLS, but only if an operator
supplies a certificate via the Kestrel `https://` endpoint config. With no cert,
an `https` endpoint fails at startup with Kestrel's opaque "No server certificate
was specified" error. Both current deployments therefore run plaintext (`h2c`),
exposing the API key and request payloads on the wire.
`mxaccessgw` is an internal tool. The goal is for TLS to "just work" with zero PKI
management: the gateway fabricates its own long-lived certificate when an HTTPS
endpoint is configured without one, and clients accept whatever certificate is
presented unless an operator explicitly opts into pinning.
## Decisions
1. **Gateway = fill-missing-cert-only.** No new "enable TLS" switch. TLS is still
driven by configuring a Kestrel `https://` endpoint. New behavior: when an
HTTPS endpoint has no `Certificate` section, the gateway generates/loads a
persisted self-signed cert instead of failing. Plaintext-only hosts are
untouched — no certificate or key material is ever written for them.
2. **Persist & reuse.** The self-signed cert is saved as a PFX under
`C:\ProgramData\MxGateway\certs`, reused across restarts, regenerated only if
missing, expired, or unreadable. Stable thumbprint; survives restarts; any
CA-pinning client keeps working.
3. **Clients = lenient TLS, plaintext default.** When a client connects over TLS
without a pinned CA, it skips verification (accepts any cert). Pinning a CA file
restores full verification. The per-client connection default (mostly
plaintext/`http`) does not change — TLS is still opt-in via the endpoint scheme.
**Scope boundary:** the gateway↔worker named-pipe IPC is unchanged (local,
OS-secured by the pipe ACL). This work touches only the public gRPC/dashboard
transport and the five language clients.
## Gateway component
New type `SelfSignedCertificateProvider` in
`src/ZB.MOM.WW.MxGateway.Server/Security/Tls/`.
1. **Detect need.** Inspect `Kestrel:Endpoints:*` configuration at startup. If any
endpoint has an `https://` URL and no `Certificate` subsection, a default cert
is needed. If none do, the provider is a no-op (no file written).
2. **Load-or-create.** Look for the persisted PFX. If present, valid, and
unexpired, load it. Otherwise generate and persist.
3. **Generate.** `CertificateRequest` with **ECDSA P-256**, `notBefore = now - 1
day` (clock-skew slack), `notAfter = now + ValidityYears`. SANs: `DNS=localhost`,
`DNS=<MachineName>`, `DNS=<MachineName.FQDN>` when resolvable, plus
`IP=127.0.0.1` and `IP=::1`. Server-auth EKU.
4. **Persist securely.** Write the PFX with an **empty** export password (a random
in-memory password cannot be reused across restarts, which the persist-and-reuse
decision requires); protect the private key with a restrictive ACL (SYSTEM +
Administrators + service account) on the `certs` directory and file on Windows,
and `0600` on non-Windows; atomic write (temp + rename). After generating, the
cert is reloaded from the persisted PFX so Kestrel always serves the on-disk key.
5. **Wire into Kestrel.** In `GatewayApplication.CreateBuilder`, add
`builder.WebHost.ConfigureKestrel(o => o.ConfigureHttpsDefaults(h =>
h.ServerCertificate = cert))`. `ConfigureHttpsDefaults` supplies the cert only
for HTTPS endpoints that did not specify their own, so an operator-configured
`Kestrel:Endpoints:*:Certificate` transparently overrides it. One hook covers
both the gRPC and dashboard ports.
### New config block `MxGateway:Tls`
All optional; the zero-config path needs none of them.
| Option | Default | Purpose |
|---|---|---|
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated cert lives |
| `Tls:ValidityYears` | `10` | Lifetime of the generated cert |
| `Tls:AdditionalDnsNames` | `[]` | Extra SANs (e.g. a load-balancer name) |
| `Tls:RegenerateIfExpired` | `true` | Auto-replace an expired persisted cert |
Validated by `GatewayOptionsValidator`: `ValidityYears` in 1100,
`SelfSignedCertPath` is a valid path shape when non-blank, and
`AdditionalDnsNames` entries are non-blank. (The "https endpoint exists but cert
path is blank" fail-fast lives in the bootstrap/provider, not the validator,
because the validator only sees the `MxGateway` section, not `Kestrel:Endpoints`.)
**Logging:** on generate/load, log thumbprint + SAN list + `notAfter` at
Information. Never log the PFX password or private key.
## Client lenient-TLS behavior
Uniform rule: **TLS on + no CA pinned ⇒ skip verification; CA pinned ⇒ full
verification.** No transport default changes. Each client also exposes an explicit
switch to force-disable leniency (strict-without-pinning) for the future.
| Client | Mechanism | Effort |
|---|---|---|
| .NET | In `CreateHttpHandler`, when `UseTls` and `CaCertificatePath` empty, set `SslOptions.RemoteCertificateValidationCallback = (_,_,_,_) => true`. CA path keeps existing custom-root validation. | trivial |
| Go | In `buildCredentials`, when TLS and no `CACertFile`/`TLSConfig`, use `tls.Config{InsecureSkipVerify: true, ServerName: override}`. | trivial |
| Java | grpc-netty-shaded 1.76.0 ships `InsecureTrustManagerFactory`. When TLS and no CA, build `GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`. | easy |
| Python | grpc-python has no per-channel skip-verify. Fetch the server leaf cert at connect via `ssl.get_server_certificate((host, port))`, pass it as `root_certificates` to `ssl_channel_credentials`, plus `grpc.ssl_target_name_override`. Effectively trusts what is presented (TOFU). | moderate, special-cased |
| Rust | tonic 0.13.1 + rustls (`tls-ring`). Implement a custom `rustls::client::danger::ServerCertVerifier` that accepts everything, build a `rustls::ClientConfig` via `.dangerous().with_custom_certificate_verifier(...)`, feed it to the channel. May require a custom hyper-rustls connector if `ClientTlsConfig` will not take a raw rustls config. **Needs an API spike.** | highest |
### Honesty caveats
- **Python** is not literally "ignore the cert"; it pins whatever the server
presents on first contact via a separate unverified TLS probe. For a self-signed
internal cert this is the intended outcome. Documented as a difference.
- **Rust** leniency depends on the tonic 0.13 TLS surface. If a custom verifier is
disproportionately invasive, the fallback is to require a CA file for Rust TLS
(pin-only) and document Rust as the exception.
## Error handling
Gateway:
- Cert dir not writable / ACL fails ⇒ fail fast at startup with a diagnostic naming
the path and required permission. No silent in-memory fallback.
- Persisted PFX corrupt/unreadable ⇒ warn, regenerate, overwrite.
- Persisted cert expired ⇒ regenerate if `RegenerateIfExpired` (default), else fail
fast instructing the operator to delete it or enable regeneration.
- HTTPS endpoint configured but generation disabled / path empty ⇒ validator
rejects at startup rather than letting Kestrel throw its opaque error.
Clients: surface unchanged. Skip-verify cannot itself raise. Python's pre-fetch
wraps connect failure into the existing connect-error type with the endpoint in the
message. Rust pin-only fallback surfaces the existing CA-file error.
## Documentation (same commit as source, per CLAUDE.md)
- `docs/GatewayConfiguration.md` — extend the TLS section: auto-generation, the
`MxGateway:Tls:*` block, persistence location/ACL, thumbprint logging, operator
override via `Kestrel:Endpoints:*:Certificate`.
- Each client README + `*ClientDesign.md` — "TLS is lenient by default; pin a CA to
verify," with Python TOFU and any Rust caveat noted.
- `docs/DesignDecisions.md` — record both posture choices and the why (internal
tool, no PKI) so they are not mistaken for an oversight.
## Testing
Gateway (`MxGateway.Tests`, no MXAccess):
- `SelfSignedCertificateProvider`: SANs, server-auth EKU, `notAfter ≈ now +
ValidityYears`, ECDSA P-256.
- Load-or-create: valid persisted PFX reused (same thumbprint); expired regenerates
when enabled; corrupt regenerates with a warning.
- Detection: HTTPS-without-cert engages; all-plaintext no-ops and writes no file;
endpoint with its own cert is not overridden.
- `GatewayOptionsValidator`: new `Tls:*` rules.
- Host integration: `Kestrel:Endpoints:Http:Url=https://127.0.0.1:0` builds and
binds (today it throws "no certificate specified").
Clients: each test project gets a lenient-TLS test against a throwaway self-signed
cert — connect with no CA succeeds; pinning a wrong CA fails (proves pinning still
verifies). Python exercises the pre-fetch path; mark opt-in if loopback timing is
flaky. Standard (non-live) tests; no MXAccess or external services.
Cross-language: add a TLS variant note to `docs/CrossLanguageSmokeMatrix.md`;
running the matrix over TLS stays manual/opt-in, consistent with the existing gate.
Per-component verification follows CLAUDE.md's source-update table (build + test
each touched component independently).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-06-01-gateway-cert-autogen-implementation.md",
"tasks": [
{"id": 1, "subject": "Task 1: Add TlsOptions config + bind into GatewayOptions", "status": "pending"},
{"id": 2, "subject": "Task 2: Validate MxGateway:Tls in GatewayOptionsValidator", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: SelfSignedCertificateProvider.GenerateCertificate", "status": "pending", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: SelfSignedCertificateProvider.LoadOrCreate (persist/reuse/regenerate/ACL)", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: KestrelTlsInspector (detect HTTPS-without-cert)", "status": "pending"},
{"id": 6, "subject": "Task 6: Wire auto-cert into GatewayApplication.CreateBuilder", "status": "pending", "blockedBy": [1, 4, 5]},
{"id": 7, "subject": "Task 7: .NET client lenient TLS by default", "status": "pending"},
{"id": 8, "subject": "Task 8: Go client lenient TLS by default", "status": "pending"},
{"id": 9, "subject": "Task 9: Java client lenient TLS by default", "status": "pending"},
{"id": 10, "subject": "Task 10: Python client lenient TLS via TOFU pre-fetch", "status": "pending"},
{"id": 11, "subject": "Task 11: Rust client lenient TLS via rustls verifier (spike + fallback)", "status": "pending"},
{"id": 12, "subject": "Task 12: Documentation", "status": "pending", "blockedBy": [6, 7, 8, 9, 10, 11]}
],
"lastUpdated": "2026-06-01"
}
@@ -0,0 +1,316 @@
# Alarm Subtag-Monitoring Fallback — Design
**Date:** 2026-06-13
**Status:** Superseded by implementation (merged to `main`). This is the original
brainstorming design; a few details below were refined during implementation —
see the inline **Superseded** notes. The shipped behaviour is documented in
`docs/AlarmClientDiscovery.md`, the client READMEs, and the contracts.
**Branch:** `feat/alarm-subtag-fallback`
## Problem
The gateway's central alarm feed (`GatewayAlarmMonitor` → worker
`WnWrapAlarmConsumer`) depends on the AVEVA wnwrap COM consumer
(`WNWRAPCONSUMERLib.wwAlarmConsumerClass`), which polls `GetXmlCurrentAlarms2`
on the worker STA. That provider can fail at the COM boundary (the older
`aaAlarmManagedClient` crashed on FILETIME marshaling; wnwrap can still return
failure HRESULTs or throw `COMException`). When it does, the gateway loses all
alarm visibility.
This design adds a **second alarm source** — direct monitoring of each alarm
attribute's subtags (`.active`, `.acked`, …) via the existing MXAccess
`AddItem`/`Advise` pipeline — and **fails over to it automatically when the
wnwrap provider breaks, then fails back automatically when it recovers**. The
subtag source can also be forced on by config.
## Decisions (locked during brainstorming)
| Decision | Choice |
|---|---|
| Failover model | **Auto-failover + auto-failback** (both directions, runtime) |
| Watch-list source | **Galaxy Repository SQL discovery + config override** |
| Acknowledge in subtag mode | **Write the operator comment to the alarm's ack-comment subtag** (the write performs the ack) |
| Failure signal | **N consecutive wnwrap COM failures** (Subscribe / `GetXmlCurrentAlarms2` throws or returns a failure HRESULT) |
| Degraded-state visibility | **Both** — explicit field in the gRPC contract **and** dashboard + metrics |
| Synthesis location | **Worker-side** (`Approach A`) — keeps the parity rule "the gateway forwards only events the worker emits; it never synthesizes events" |
## Core principle
Subtag monitoring is, by definition, a **non-parity, lower-fidelity** alarm
source: it synthesizes alarm transitions from raw data changes, has no native
alarm GUID, no native original-raise timestamp, and a narrower field set. Per
`CLAUDE.md`, synthesizing events is allowed only as an explicit opt-in
non-parity mode. This design satisfies that by (a) doing the synthesis **inside
the worker** (so the gateway still only forwards worker-emitted events) and
(b) marking every degraded event and the whole feed as degraded so no client
mistakes it for the authoritative alarmmgr feed.
## Architecture
```
GATEWAY (.NET 10, x64)
┌─────────────────────────────────────────────────────────────────┐
│ GatewayAlarmMonitor (BackgroundService) │
│ • resolves watch-list: Galaxy Repository SQL + config override │
│ • arms the worker with the watch-list at subscribe time │
│ • consumes AlarmProviderModeChanged → reflects mode into feed, │
│ /hubs/alarms dashboard hub, and metrics │
│ • forces a cache reconcile (QueryActiveAlarms) on every switch │
└───────────────────────────────┬───────────────────────────────────┘
│ IPC (WorkerEnvelope frames)
│ · SubscribeAlarms{ watch_list, failover cfg }
│ · AlarmProviderModeChanged{ mode, reason, hresult }
│ · OnAlarmTransitionEvent (degraded flag set in subtag mode)
WORKER (.NET FW 4.8, x86, STA)
┌─────────────────────────────────────────────────────────────────┐
│ AlarmDispatcher → FailoverAlarmConsumer : IMxAccessAlarmConsumer │
│ ├─ primary : WnWrapAlarmConsumer (wnwrap COM poll, unchanged) │
│ └─ standby : SubtagAlarmConsumer (AddItem/Advise on subtags) │
│ │
│ FailoverAlarmConsumer owns the state machine: │
│ PrimaryActive ──(N consecutive wnwrap COM failures)──▶ Degraded │
│ Degraded ──(M consecutive clean wnwrap probe polls)──▶ Primary │
│ on each switch: snapshot the now-active provider, hand off │
└─────────────────────────────────────────────────────────────────┘
```
The failover state machine lives **worker-local** so the switch is instant — no
IPC round-trip at the moment alarmmgr dies. The gateway *arms* the standby
consumer up front (passes the watch-list at subscribe time) so it is ready
before it is ever needed.
## Components
### Worker (`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/`)
**`SubtagAlarmConsumer : IMxAccessAlarmConsumer` (new)** — the standby provider.
- On `Subscribe`, instead of wnwrap registration it `AddItem`/`Advise`s the
configured subtags for each watch-list entry on the existing STA (reuses the
worker's item-subscription machinery). Per attribute it advises at minimum
`.active` and `.acked`; optionally `.priority`/severity, `.descr`, value/limit
if present.
- Converts each `OnDataChange` into the same `MxAlarmTransitionEvent` the wnwrap
consumer emits, via the synthesis rules below, and raises
`AlarmTransitionEmitted`. Marks each as **degraded**.
- `SnapshotActiveAlarms()` returns the currently-active set computed from
last-known subtag values.
- `AcknowledgeByName(...)` resolves the watch-list entry's ack-comment subtag and
issues a `Write(comment)` on the STA. `AcknowledgeByGuid(...)` maps the
synthetic GUID (see below) back to a reference, then does the same. If the
attribute exposes no writable ack-comment subtag, returns a failure code that
the gateway surfaces as `FailedPrecondition`.
- `PollOnce()` is a no-op (subtag mode is event-driven via Advise).
**`FailoverAlarmConsumer : IMxAccessAlarmConsumer` (new)** — composite + state
machine. Owns the wnwrap consumer (primary) and the subtag consumer (standby),
forwards `AlarmTransitionEmitted` from whichever child is active, and raises a
new `ProviderModeChanged` event on every switch.
- **Failure counting:** wraps `Subscribe`/`PollOnce` on the primary; a thrown
`COMException` or a failure HRESULT increments a consecutive-failure counter,
reset to zero on any clean poll.
- **Failover** (`PrimaryActive → Degraded`): at `ConsecutiveFailureThreshold`
(default 3), ensures the standby is subscribed (it was armed at startup), sets
active = standby, snapshots the standby's active set for hand-off, and emits
`ProviderModeChanged(SUBTAG, reason, hresult)`.
- **Failback probe** (`Degraded → PrimaryActive`): while degraded, every
`FailbackProbeIntervalSeconds` (default 30) it re-attempts wnwrap
`Subscribe`+`PollOnce` on the STA. After `FailbackStableProbes` (default 3)
consecutive clean polls it switches active = primary, returns the standby to
standby, and emits `ProviderModeChanged(ALARMMGR, "recovered")`.
- **Hand-off:** on every switch it takes `SnapshotActiveAlarms()` from the
now-active provider so the gateway can reconcile and avoid spurious
raise/clear storms.
**`AlarmDispatcher` / `MxAccessAlarmEventSink` / `AlarmCommandHandler`
(changed, minimal)** — `AlarmDispatcher` holds a `FailoverAlarmConsumer` instead
of a bare `WnWrapAlarmConsumer`; it subscribes to `ProviderModeChanged` and
enqueues a mode-changed worker event. The ack path routes by active mode (native
wnwrap ack in alarmmgr mode; ack-comment write in subtag mode), but that routing
is entirely inside the consumer — the dispatcher just calls
`AcknowledgeByName`/`AcknowledgeByGuid`.
### Gateway (`src/ZB.MOM.WW.MxGateway.Server/`)
**Galaxy Repository discovery (new query)** — alongside the existing GR SQL
browse RPCs, a query "attributes that have alarms configured, with their
ack-comment subtag and area", scoped to the configured area. Merged with the
config override (explicit includes/excludes). Produces the watch-list of
`AlarmSubtagTarget`s.
**`GatewayAlarmMonitor` (changed)** — resolves the watch-list at subscribe time
and passes it to the worker; consumes `AlarmProviderModeChanged` and reflects
the current provider mode into (a) the `AlarmFeedMessage` provider-status,
(b) the `/hubs/alarms` dashboard hub, and (c) metrics; forces a reconcile
(`QueryActiveAlarms`) on every switch. Re-runs discovery on its existing
reconcile cadence and pushes an updated watch-list when the model changes.
**`AlarmsOptions` (extended)** — new `Fallback` sub-section (below).
### Contract (`src/ZB.MOM.WW.MxGateway.Contracts/Protos/`)
**`mxaccess_gateway.proto`:**
- `enum AlarmProviderMode { ALARM_PROVIDER_MODE_UNSPECIFIED = 0; ALARMMGR = 1; SUBTAG = 2; }`
- New `AlarmFeedMessage` oneof case `AlarmProviderStatus provider_status`,
carrying `{ AlarmProviderMode mode; bool degraded; string reason;
google.protobuf.Timestamp since; }`. Emitted on stream open and on every
change so a late-joining client immediately learns the mode.
- Add `bool degraded` + `AlarmProviderMode source_provider` to
`OnAlarmTransitionEvent` **and** `ActiveAlarmSnapshot`, so per-item provenance
is visible even mid-stream. All additions are new field numbers — backward
compatible; existing clients ignore them and keep seeing alarms.
**`mxaccess_worker.proto`:**
> **Superseded:** these additions shipped in `mxaccess_gateway.proto`, not
> `mxaccess_worker.proto` — the worker imports the gateway proto and the alarm
> commands/events live there (`AlarmSubtagTarget`,
> `OnAlarmProviderModeChangedEvent`, the extended subscribe command).
- Extend the alarm-subscribe command with: `AlarmProviderMode forced_mode`
(`UNSPECIFIED` = auto), `int32 consecutive_failure_threshold`,
`int32 failback_probe_interval_seconds`, `int32 failback_stable_probes`, and
`repeated AlarmSubtagTarget watch_list`, where `AlarmSubtagTarget =
{ string alarm_full_reference; string source_object_reference;
string active_subtag; string acked_subtag; string ack_comment_subtag;
string priority_subtag; }`.
- New worker→gateway event `AlarmProviderModeChanged { AlarmProviderMode mode;
string reason; int32 hresult; google.protobuf.Timestamp at; }`.
> Generated code under `Generated/` and `clients/*/generated*/` is rebuilt from
> these `.proto` files — never hand-edited. Every generated client touched by
> the contract is rebuilt per the source-update workflow.
## Data flow
### Subtag synthesis rules
`SubtagAlarmConsumer` keeps last-known `(active, acked)` per watch-list entry and
emits transitions on change:
| Subtag change | Emitted transition | Notes |
|---|---|---|
| `active` false → true | `RAISE` (state `UNACK_ALM`) | `original_raise_timestamp` = first-observed active time |
| `acked` false → true while `active` | `ACKNOWLEDGE` | `operator_user`/`operator_comment` from ack-comment subtag if advised |
| `active` true → false | `CLEAR` | maps to `AckRtn` if acked at clear, else `UnackRtn` |
| `active` stays true, re-alarm | `RETRIGGER` | **only** if a re-alarm counter subtag exists; otherwise not synthesized (documented limitation) |
Snapshot state mapping for `ActiveAlarmSnapshot.current_state`:
`active && !acked → ACTIVE`, `active && acked → ACTIVE_ACKED`,
`!active → INACTIVE`.
Field degradation in subtag mode:
- `alarm_full_reference` — from the watch-list entry (stable, drives ack-by-ref).
- Synthetic, deterministic GUID derived by hashing `alarm_full_reference` so
GUID-based ack still resolves; flagged `degraded = true`.
- `severity` — from the priority subtag if advised, else 0.
- `original_raise_timestamp` — first-observed active time (best effort).
- `transition_timestamp` — the `OnDataChange` timestamp.
- `category`/`description`/`current_value`/`limit_value` — populated only if the
corresponding subtag is advised; otherwise empty.
### Acknowledge
`AcknowledgeAlarm`/`AcknowledgeAlarmByName` are unchanged at the RPC surface.
`AlarmDispatcher` routes by active provider mode:
- **alarmmgr mode:** native wnwrap `AlarmAckByName`/`AlarmAckByGUID` (unchanged).
- **subtag mode:** resolve the target's `ack_comment_subtag`, `Write` the
operator comment via the existing worker write path on the STA. No writable
ack-comment subtag → `FailedPrecondition`.
### Provider-mode reflection
Worker `AlarmProviderModeChanged``GatewayAlarmMonitor` → (a) emit/refresh
`AlarmFeedMessage.provider_status` to every `StreamAlarms` subscriber, (b) push
to `/hubs/alarms`, (c) update metrics, (d) force a reconcile.
## Error handling
- **Both providers down** (subtag advise also failing): the monitor stays
faulted and keeps retrying both; acknowledge returns `Unavailable`. No silent
data loss — the feed reports degraded with reason.
- **Empty watch-list in subtag mode** (GR SQL unavailable, no config override):
log + metric `alarm_fallback_watchlist_empty`; the feed reports degraded +
empty; the gateway keeps re-running discovery on its reconcile cadence and
pushes an updated watch-list when one becomes available.
- **Switch hand-off:** every switch snapshots the now-active provider and
reconciles against the gateway cache to avoid a raise/clear storm.
- **STA affinity:** all subtag advise/write and wnwrap probe calls run on the
worker STA (reuse the existing affinity guard) to satisfy
`ThreadingModel=Apartment`.
### Metrics
- `mxgateway_alarm_provider_mode` (gauge: 1 = alarmmgr, 2 = subtag)
- `mxgateway_alarm_provider_switch_total{from,to,reason}` (counter)
- `mxgateway_alarm_fallback_watchlist_size` (gauge)
> **Superseded:** the shipped meter names are `mxgateway.alarms.provider_mode`
> (gauge) and `mxgateway.alarms.provider_switches{from,to,reason}` (counter,
> `reason` bounded to `failover`/`failback`/`unknown`). The watch-list-size /
> watch-list-empty gauges were not implemented; an empty watch-list is surfaced
> via a warning log and the feed's degraded `ProviderStatus` instead.
## Configuration
```jsonc
"MxGateway": {
"Alarms": {
"Enabled": true,
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
"DefaultArea": "DEV",
"ReconcileIntervalSeconds": 30,
"Fallback": {
"Mode": "Auto", // Auto | ForceAlarmManager | ForceSubtag
"ConsecutiveFailureThreshold": 3,
"FailbackProbeIntervalSeconds": 30,
"FailbackStableProbes": 3,
"Discovery": {
"UseGalaxyRepository": true,
"Area": "", // defaults to Alarms.DefaultArea
"IncludeAttributes": [], // explicit additions
"ExcludeAttributes": []
},
"Subtags": {
"Active": "active",
"Acked": "acked",
"AckComment": "", // verified against MXAccess analysis
"Priority": "priority"
}
}
}
}
```
`GatewayOptionsValidator` additions: `Mode = ForceSubtag` with empty discovery
result and no explicit `IncludeAttributes` → startup validation warning;
threshold/interval/probe values floored at sane minimums.
## Open item to confirm during implementation
The exact AVEVA subtag names (`.active`, `.acked`, the ack-comment attribute,
priority) must be confirmed against the MXAccess analysis project
(`C:\Users\dohertj2\Desktop\mxaccess`, `docs/MXAccess-Public-API.md`) and the
live Galaxy before wiring `SubtagAlarmConsumer`. The config `Subtags` block
exists precisely so the resolved names are not hard-coded.
## Testing
| Layer | Tests |
|---|---|
| Worker unit (`MxGateway.Worker.Tests`, x86) | `SubtagAlarmConsumer` synthesis — feed `OnDataChange` sequences, assert raise/ack/clear transitions, snapshot states, degraded flag, synthetic-GUID stability, ack-comment write routing |
| Worker unit | `FailoverAlarmConsumer` state machine — fake wnwrap throwing after K polls: assert switch at threshold, failback after stable probes, `ProviderModeChanged` emitted, no duplicate transitions across switch (hand-off reconcile) |
| Gateway unit (`MxGateway.Tests`, fake worker) | discovery + config-override merge; `GatewayAlarmMonitor` reflects mode into feed + hub; metrics increment on switch |
| Contract | proto round-trip for new fields; existing alarm tests unchanged (alarmmgr-mode regression — parity preserved) |
| Live (opt-in, `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) | real subtag advise + ack-comment write against a live alarm; GR SQL discovery query against the `ZB` DB (gated like existing GR tests) |
## Docs to update in the same change
`gateway.md` (alarm provider section), `docs/DesignDecisions.md` (record the
fallback decision), `docs/GatewayConfiguration.md` (the `Fallback` block),
`docs/AlarmClientDiscovery.md` (subtag provider + synthesis rules),
`docs/Grpc.md` (the new `provider_status` / `degraded` fields), and any client
READMEs whose generated alarm types gain fields.
@@ -0,0 +1,860 @@
# Alarm Subtag-Monitoring Fallback — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Add a second alarm source — direct MXAccess subtag monitoring — that the gateway auto-fails-over to when the wnwrap alarmmgr provider breaks, auto-fails-back to when it recovers, and can be forced on by config.
**Architecture:** Worker-side synthesis (parity rule preserved). A new `SubtagAlarmConsumer` (own `LMXProxyServerClass`, `AddItem`/`Advise` on alarm subtags) and a `FailoverAlarmConsumer` composite (state machine over the wnwrap primary + subtag standby) both implement the existing `IMxAccessAlarmConsumer` seam. The gateway resolves the subtag watch-list (Galaxy Repository SQL + config override), arms the worker at subscribe time, and reflects the live provider mode into the gRPC alarm feed, the dashboard hub, and metrics.
**Tech Stack:** .NET 10 (gateway, x64) + .NET Framework 4.8 (worker, x86, STA), protobuf/gRPC, `Microsoft.Data.SqlClient` (Galaxy Repository), SignalR (dashboard), `System.Diagnostics.Metrics`, xUnit (plain `Assert`, no FluentAssertions).
**Design source:** `docs/plans/2026-06-13-alarm-subtag-fallback-design.md`
**Branch:** `feat/alarm-subtag-fallback` (already created)
---
## Conventions for every task
- **TDD:** write the failing test, run it red, implement, run it green, commit.
- **xUnit, plain `Assert.*`**, naming `Subject_Condition_Expected`. Worker fakes are sealed private nested classes that raise events.
- **Build/test commands:**
- Contracts regen: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
- Gateway: `dotnet build src/ZB.MOM.WW.MxGateway.Server` ; `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
- Worker (x86): `dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86` ; `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86`
- Single test: append `--filter FullyQualifiedName~<ClassOrMethod>`
- **Build is strict:** `TreatWarningsAsErrors=true`, nullable enabled. Add XML doc comments on public members (the repo runs a doc checker).
- **Generated code** under `Generated/` is never hand-edited — rebuild the contracts project to regenerate.
- **Namespaces:** worker MxAccess types live in `ZB.MOM.WW.MxGateway.Worker.MxAccess`; proto C# types in `ZB.MOM.WW.MxGateway.Contracts.Proto`.
---
## Phase 0 — Contracts
### Task 1: Worker proto — subtag watch-list, failover config, provider-mode enum
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (Task 2 imports these types)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (real `SubscribeAlarmsCommand` at ~line 324; `MxCommand` references it at 123-125)
> **CORRECTION (execution):** The alarm command messages and `MxCommand` live in **`mxaccess_gateway.proto`**, not the worker proto. `mxaccess_worker.proto` *imports* the gateway proto (`WorkerCommand.command` is `mxaccess_gateway.v1.MxCommand`), so the gateway proto is the base and the worker proto needs **no** change. `AlarmProviderMode` and the new types are added to the gateway proto and are visible to worker code as `mxaccess_gateway.v1` types. Tasks 1 and 2 are executed as a single combined edit on this one file.
**Step 1: Add the enum and messages.** In `mxaccess_gateway.proto`, extend the existing `SubscribeAlarmsCommand` message (line 324) and add the new types after it:
```protobuf
// Provider selection / current provider for the alarm feed. Defined here in
// the worker contract because the worker SubscribeAlarmsCommand references it;
// mxaccess_gateway.proto imports this file and reuses the same enum.
enum AlarmProviderMode {
ALARM_PROVIDER_MODE_UNSPECIFIED = 0; // auto: alarmmgr primary, subtag fallback
ALARM_PROVIDER_MODE_ALARMMGR = 1;
ALARM_PROVIDER_MODE_SUBTAG = 2;
}
message SubscribeAlarmsCommand {
string subscription_expression = 1; // existing field — keep
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
AlarmProviderMode forced_mode = 2;
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
repeated AlarmSubtagTarget watch_list = 3;
AlarmFailoverConfig failover = 4;
}
// One alarm attribute the subtag consumer advises. Addresses are full MXAccess
// item references the worker passes straight to AddItem.
message AlarmSubtagTarget {
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
string source_object_reference = 2; // e.g. "Tank01"
string active_subtag = 3; // item address of the in-alarm boolean
string acked_subtag = 4; // item address of the acknowledged boolean
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
string priority_subtag = 6; // optional severity source; empty if absent
}
message AlarmFailoverConfig {
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
}
```
`UnsubscribeAlarmsCommand` and `AcknowledgeAlarmCommand` are unchanged.
**Step 2: Regenerate & verify it compiles.**
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
Expected: build succeeds; generated `AlarmProviderMode`, `AlarmSubtagTarget`, `AlarmFailoverConfig` types appear.
**Step 3: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto
git commit -m "contracts(worker): subtag watch-list + failover config + AlarmProviderMode"
```
---
### Task 2: Gateway proto — provider status on the feed, degraded provenance, mode-changed event
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 1; Task 3 tests both)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`OnAlarmTransitionEvent` ~719-771, `ActiveAlarmSnapshot` ~783-803, `AlarmFeedMessage` ~860-870, `MxEvent` family enum + body oneof, `MxEventFamily` enum)
**Step 1: Add degraded provenance to the two alarm payloads.** Append to `OnAlarmTransitionEvent` (next free field 14):
```protobuf
// True when this transition came from the subtag-monitoring fallback rather
// than the native alarmmgr provider — i.e. it was synthesized from data
// changes and carries reduced fidelity (synthetic GUID, no native raise time).
bool degraded = 14;
// Which provider produced this transition.
AlarmProviderMode source_provider = 15;
```
Append the identical two fields to `ActiveAlarmSnapshot` (next free field 14):
```protobuf
bool degraded = 14;
AlarmProviderMode source_provider = 15;
```
**Step 2: Add provider status to the feed oneof.** Add a new oneof case to `AlarmFeedMessage` (next free field 4) and a new message:
```protobuf
message AlarmFeedMessage {
oneof payload {
ActiveAlarmSnapshot active_alarm = 1;
bool snapshot_complete = 2;
OnAlarmTransitionEvent transition = 3;
// Provider-mode status. Emitted once on stream open and again on every
// failover/failback so late joiners learn the current mode immediately.
AlarmProviderStatus provider_status = 4;
}
}
message AlarmProviderStatus {
AlarmProviderMode mode = 1;
bool degraded = 2; // true whenever mode == SUBTAG
string reason = 3; // human-readable switch reason
google.protobuf.Timestamp since = 4;
}
```
**Step 3: Add the worker→gateway mode-changed event to `MxEvent`.** Find the `MxEventFamily` enum and the `MxEvent` body oneof. Add a family member and a body message + oneof case (use the next free family value and the next free `MxEvent` body field number — check the file):
```protobuf
// in MxEventFamily enum:
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = <next>;
// new message near OnAlarmTransitionEvent:
message OnAlarmProviderModeChangedEvent {
AlarmProviderMode mode = 1;
string reason = 2;
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
google.protobuf.Timestamp at = 4;
}
// in MxEvent body oneof:
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = <next>;
```
`AlarmProviderMode` is defined in `mxaccess_worker.proto`; confirm `mxaccess_gateway.proto` already has `import "mxaccess_worker.proto";` (it references `SubscribeAlarmsCommand`, so it does) and reference the enum unqualified or via its package as the existing references do.
**Step 4: Regenerate & verify.**
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
Expected: build succeeds.
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
git commit -m "contracts(gateway): AlarmProviderStatus feed case, degraded provenance, mode-changed event"
```
---
### Task 3: Proto round-trip tests for the new alarm fields
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Tasks 1-2)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`
**Step 1: Add tests** mirroring the existing `Event_RoundTripsOnAlarmTransitionWithFullPayload` style:
```csharp
[Fact]
public void Feed_RoundTripsProviderStatus()
{
var since = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
var original = new AlarmFeedMessage
{
ProviderStatus = new AlarmProviderStatus
{
Mode = AlarmProviderMode.Subtag,
Degraded = true,
Reason = "wnwrap poll failed 3x (HRESULT 0x80004005)",
Since = since,
},
};
var parsed = AlarmFeedMessage.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(AlarmFeedMessage.PayloadOneofCase.ProviderStatus, parsed.PayloadCase);
Assert.True(parsed.ProviderStatus.Degraded);
Assert.Equal(AlarmProviderMode.Subtag, parsed.ProviderStatus.Mode);
}
[Fact]
public void Transition_RoundTripsDegradedProvenance()
{
var t = new OnAlarmTransitionEvent
{
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Degraded = true,
SourceProvider = AlarmProviderMode.Subtag,
};
var parsed = OnAlarmTransitionEvent.Parser.ParseFrom(t.ToByteArray());
Assert.True(parsed.Degraded);
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
}
```
**Step 2: Run red→green.**
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests`
Expected: PASS.
**Step 3: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
git commit -m "test(contracts): round-trip provider status + degraded provenance"
```
---
## Phase 1 — Worker: subtag consumer + failover
### Task 4: Subtag value-source abstraction + synthesis state holder
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 5 builds on it)
A testable seam so synthesis logic is unit-tested without COM. The COM wiring lands in Task 6.
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs`
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs`
**Step 1: Define the source abstraction.** `ISubtagAlarmSource` advises subtag addresses and raises a normalized value-change callback on the STA:
```csharp
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
public sealed class SubtagValueChange
{
/// <summary>The full item address that changed (matches an AlarmSubtagTarget subtag).</summary>
public string ItemAddress { get; init; } = string.Empty;
/// <summary>The new value (boolean for .active/.acked, numeric for priority).</summary>
public object? Value { get; init; }
/// <summary>The change timestamp in UTC.</summary>
public DateTime TimestampUtc { get; init; }
}
/// <summary>
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
/// The production implementation (Task 6) owns its own LMXProxyServerClass;
/// tests substitute a fake that pushes <see cref="SubtagValueChange"/>s.
/// </summary>
public interface ISubtagAlarmSource : IDisposable
{
/// <summary>Raised on the STA when an advised subtag's value changes.</summary>
event EventHandler<SubtagValueChange>? ValueChanged;
/// <summary>Advises every subtag in the supplied addresses; idempotent per address.</summary>
void Advise(IReadOnlyCollection<string> itemAddresses);
/// <summary>Writes a value to an item address (used for the ack-comment write).</summary>
void Write(string itemAddress, object? value);
}
```
**Step 2: Write the state-machine tests first.** `SubtagAlarmStateMachine` maps `(active, acked)` changes per target to `MxAlarmTransitionEvent`s. Test the four core transitions:
```csharp
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class SubtagAlarmStateMachineTests
{
private static AlarmSubtagTarget Target() => new()
{
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
ActiveSubtag = "Tank01.Level.HiHi.active",
AckedSubtag = "Tank01.Level.HiHi.acked",
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
};
[Fact]
public void ActiveFalseToTrue_EmitsRaise_FlaggedDegraded()
{
var sm = new SubtagAlarmStateMachine(new[] { Target() });
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
var e = Assert.Single(events);
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName); // reference minus provider/area
}
[Fact]
public void AckedTrueWhileActive_EmitsAckTransition()
{
var sm = new SubtagAlarmStateMachine(new[] { Target() });
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
sm.Apply("Tank01.Level.HiHi.active", true, ts);
var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
var e = Assert.Single(events);
Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
}
[Fact]
public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
{
var sm = new SubtagAlarmStateMachine(new[] { Target() });
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
sm.Apply("Tank01.Level.HiHi.active", true, ts);
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
var e = Assert.Single(events);
Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
}
[Fact]
public void Snapshot_ReflectsActiveAndAckedState()
{
var sm = new SubtagAlarmStateMachine(new[] { Target() });
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
sm.Apply("Tank01.Level.HiHi.active", true, ts);
sm.Apply("Tank01.Level.HiHi.acked", true, ts);
var snap = Assert.Single(sm.SnapshotActive());
Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
}
}
```
Run: `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmStateMachineTests` → FAIL (type missing).
**Step 3: Implement `SubtagAlarmStateMachine`.** Build an address→target index (active/acked/priority/comment addresses), hold per-reference `(bool active, bool acked, DateTime firstRaiseUtc, int priority)`, and emit on change:
- active `false→true``UnackAlm`, set `firstRaiseUtc`, `PreviousState` from prior state.
- acked `false→true` while active ⇒ `AckAlm`.
- active `true→false``AckRtn` if currently acked else `UnackRtn`; then reset acked.
- priority change ⇒ update stored priority, no transition.
- `TagName` = `alarm_full_reference` with any `Provider!Area.` prefix stripped (match `WnWrapAlarmConsumer`'s reference shape so `GatewayAlarmMonitor` keys align). Set `ProviderName`, `Group`, `Priority`, `AlarmComment` from the target/last values. Mark a `Degraded`/source flag (carried via a new field — see Task 5 wiring).
- `SnapshotActive()` returns `MxAlarmSnapshotRecord` for references whose active is true.
**Step 4: Run green.** Expected: PASS.
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs \
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs \
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs
git commit -m "worker(alarms): subtag value-source seam + synthesis state machine"
```
---
### Task 5: `SubtagAlarmConsumer` over the source seam (no COM yet)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 4)
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs`
**Step 1: Test with a fake `ISubtagAlarmSource`.** Drive value changes through the source, assert `AlarmTransitionEmitted` fires with synthesized records and that ack writes the comment to the ack-comment subtag:
```csharp
public sealed class SubtagAlarmConsumerTests
{
private sealed class FakeSource : ISubtagAlarmSource
{
public event EventHandler<SubtagValueChange>? ValueChanged;
public List<string> Advised { get; } = new();
public (string Address, object? Value)? LastWrite { get; private set; }
public void Advise(IReadOnlyCollection<string> a) => Advised.AddRange(a);
public void Write(string a, object? v) => LastWrite = (a, v);
public void Raise(string addr, object? val, DateTime ts) =>
ValueChanged?.Invoke(this, new SubtagValueChange { ItemAddress = addr, Value = val, TimestampUtc = ts });
public void Dispose() { }
}
private static AlarmSubtagTarget Target() => new()
{
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
ActiveSubtag = "Tank01.Level.HiHi.active",
AckedSubtag = "Tank01.Level.HiHi.acked",
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
};
[Fact]
public void Subscribe_AdvisesAllSubtags()
{
var src = new FakeSource();
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
c.Subscribe("ignored-in-subtag-mode");
Assert.Contains("Tank01.Level.HiHi.active", src.Advised);
Assert.Contains("Tank01.Level.HiHi.acked", src.Advised);
}
[Fact]
public void ValueChange_RaisesSynthesizedTransition()
{
var src = new FakeSource();
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
c.Subscribe("x");
MxAlarmTransitionEvent? seen = null;
c.AlarmTransitionEmitted += (_, e) => seen = e;
src.Raise("Tank01.Level.HiHi.active", true, new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
Assert.NotNull(seen);
Assert.Equal(MxAlarmStateKind.UnackAlm, seen!.Record.State);
}
[Fact]
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
{
var src = new FakeSource();
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
c.Subscribe("x");
int rc = c.AcknowledgeByName("Tank01.Level.HiHi", "Galaxy", "Area",
"ack from HMI", "op1", "node", "dom", "Op One");
Assert.Equal(0, rc);
Assert.Equal(("Tank01.Level.HiHi.ackmsg", (object?)"ack from HMI"), src.LastWrite);
}
}
```
**Step 2: Implement `SubtagAlarmConsumer : IMxAccessAlarmConsumer`.**
- Constructor `(ISubtagAlarmSource source, IReadOnlyList<AlarmSubtagTarget> watchList)`; build a `SubtagAlarmStateMachine`; index `alarm_full_reference`→target for ack routing.
- `Subscribe(_)`: call `source.Advise(<all subtag addresses>)`; subscribe to `source.ValueChanged`, feed each into the state machine, and re-raise each produced `MxAlarmTransitionEvent` via `AlarmTransitionEmitted` (mark degraded).
- `AcknowledgeByName(alarmName, …, comment, …)`: resolve the target by reference; if no `AckCommentSubtag`, return a non-zero failure code; else `source.Write(target.AckCommentSubtag, comment)` and return 0.
- `AcknowledgeByGuid(guid, …)`: map the synthetic GUID (deterministic hash of reference — see Task 8 helper, or a local copy) back to a reference, then delegate to the name path; unknown GUID ⇒ non-zero.
- `SnapshotActiveAlarms()`: from the state machine.
- `PollOnce()`: no-op.
- `Dispose()`: unsubscribe + dispose source.
**Step 3: Run green.** `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmConsumerTests`.
**Step 4: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs \
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
git commit -m "worker(alarms): SubtagAlarmConsumer synthesizing transitions over the source seam"
```
---
### Task 6: COM-backed `LmxSubtagAlarmSource` (own LMXProxyServerClass)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
The only piece that touches live COM. Like `WnWrapAlarmConsumer`, it owns its own MXAccess server object so the subtag source is self-contained and isolated from the session's item pipeline. Logic stays thin (advise/write/marshal); real verification is the live smoke test in Task 17.
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs` (constructor/guard tests only; COM path is live-gated)
**Step 1: Implement `LmxSubtagAlarmSource : ISubtagAlarmSource`.**
- Own an `LMXProxyServerClass` (reuse the worker's `IMxAccessServer`/`MxAccessComServer` wrapper + `IMxAccessComObjectFactory` so it is fakeable; constructor takes the factory).
- `Advise(addresses)`: `RegisterServer` (topic) once; per address `AddItem``itemHandle`, `Advise`, and record `itemHandle→address`. Subscribe to the proxy's `OnDataChange`; in the handler, look up the address by `phItemHandle`, normalize `pvItemValue` (VARIANT→bool/double) and `pftItemTimeStamp`→UTC, and raise `ValueChanged`. All calls run on the STA (the worker STA pumps messages, so `OnDataChange` delivers).
- `Write(address, value)`: resolve/create the item handle, `server.Write(serverHandle, itemHandle, value, userId: 0)`.
- `Dispose()`: `UnAdvise`/`RemoveItem`/`Unregister`/release COM.
**Step 2: Tests** — only the non-COM guards (null factory throws; `Write` before `Advise` resolves a handle or throws a clear error). Mark the COM round-trip `[LiveMxAccessFact]` and `Skip` per the `AlarmsLiveSmokeTests` precedent.
**Step 3: Build x86 + run unit tests.**
`dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86`
`dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~LmxSubtagAlarmSourceTests`
**Step 4: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs \
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs
git commit -m "worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags"
```
---
### Task 7: `FailoverAlarmConsumer` state machine
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 5)
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs`
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs` (small EventArgs)
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs`
**Step 1: Test the switch/failback with a fake primary that throws.**
```csharp
public sealed class FailoverAlarmConsumerTests
{
private sealed class FlakyPrimary : IMxAccessAlarmConsumer
{
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
public int PollsUntilHeal = int.MaxValue; // becomes healthy after N polls while degraded
public bool ThrowOnPoll = true;
private int _polls;
public void Subscribe(string s) { if (ThrowOnPoll) throw new COMException("boom", unchecked((int)0x80004005)); }
public void PollOnce()
{
_polls++;
if (ThrowOnPoll && _polls < PollsUntilHeal) throw new COMException("boom", unchecked((int)0x80004005));
}
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 0;
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 0;
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>();
public void Dispose() { }
}
private sealed class StubStandby : IMxAccessAlarmConsumer { /* records Subscribe, no-op rest */ }
[Fact]
public void Primary_FailsThresholdTimes_SwitchesToSubtagAndEmitsModeChange()
{
var primary = new FlakyPrimary();
var standby = new StubStandby();
using var c = new FailoverAlarmConsumer(primary, standby,
new FailoverSettings(threshold: 3, probeIntervalSeconds: 30, stableProbes: 3));
AlarmProviderModeChange? change = null;
c.ProviderModeChanged += (_, e) => change = e;
c.Subscribe("\\\\host\\Galaxy!Area"); // primary.Subscribe throws -> counts as failure 1
c.PollOnce(); // failure 2
c.PollOnce(); // failure 3 -> switch
Assert.NotNull(change);
Assert.Equal(AlarmProviderMode.Subtag, change!.Mode);
}
[Fact]
public void WhileDegraded_PrimaryHeals_FailsBackAfterStableProbes()
{
var primary = new FlakyPrimary { PollsUntilHeal = 0 }; // will heal once we stop throwing
var standby = new StubStandby();
using var c = new FailoverAlarmConsumer(primary, standby,
new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 2));
var modes = new List<AlarmProviderMode>();
c.ProviderModeChanged += (_, e) => modes.Add(e.Mode);
c.Subscribe("x"); // failure -> switch to subtag
primary.ThrowOnPoll = false;
c.ProbeOnce(); // clean probe 1
c.ProbeOnce(); // clean probe 2 -> failback
Assert.Equal(AlarmProviderMode.Subtag, modes[0]);
Assert.Equal(AlarmProviderMode.Alarmmgr, modes[^1]);
}
}
```
**Step 2: Implement.**
- `record FailoverSettings(int threshold, int probeIntervalSeconds, int stableProbes)`; `AlarmProviderModeChange : EventArgs { AlarmProviderMode Mode; string Reason; int HResult; DateTime AtUtc; }`.
- Constructor `(IMxAccessAlarmConsumer primary, IMxAccessAlarmConsumer standby, FailoverSettings settings)`; forced-mode variants handled in Task 9 wiring (forced ⇒ skip the other consumer).
- Forward `AlarmTransitionEmitted` from the **active** child only (swap the subscription on switch).
- Wrap `Subscribe`/`PollOnce` on the primary: on `COMException` (or a failure HRESULT) while `PrimaryActive`, increment a counter; at `threshold`, ensure standby `Subscribe`d, set active=standby, snapshot standby for hand-off, raise `ProviderModeChanged(Subtag, reason, hresult, now)`. Reset counter on any clean primary poll.
- `ProbeOnce()` (driven by the poll loop while degraded, gated by `probeIntervalSeconds`): try primary `Subscribe`+`PollOnce`; count consecutive clean probes; at `stableProbes`, set active=primary, return standby to standby, raise `ProviderModeChanged(Alarmmgr, "recovered", 0, now)`.
- `Acknowledge*` / `SnapshotActiveAlarms` delegate to the **active** child.
- `PollOnce()` drives the active child's poll, and—while degraded—also drives the failback probe cadence.
**Step 3: Run green** (x86 filter `FailoverAlarmConsumerTests`).
**Step 4: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs \
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs \
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
git commit -m "worker(alarms): FailoverAlarmConsumer auto-failover/failback state machine"
```
---
### Task 8: Synthetic-GUID helper + degraded flag on the event sink path
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 9
Carry `degraded` + `source_provider` from the worker synthesis into the emitted `OnAlarmTransitionEvent`.
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` (add `bool Degraded`)
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` (`EnqueueTransition` carries degraded)
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs` (`CreateOnAlarmTransition` sets `Degraded`/`SourceProvider`)
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs`
- Test: add cases to `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs` and a new `SyntheticAlarmGuidTests.cs`
**Step 1: `SyntheticAlarmGuid.ForReference(string reference)`** — deterministic GUID from a stable hash (e.g. MD5 of the UTF-8 reference → `new Guid(bytes)`), so subtag-mode acks resolve by GUID. Test determinism + difference:
```csharp
[Fact] public void SameReference_SameGuid() =>
Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C"));
[Fact] public void DifferentReference_DifferentGuid() =>
Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D"));
```
**Step 2: Thread `degraded`** through `MxAlarmSnapshotRecord.Degraded`, `EnqueueTransition(... bool degraded)`, and `CreateOnAlarmTransition(... bool degraded, AlarmProviderMode sourceProvider)`. Default `degraded=false`, `sourceProvider=Alarmmgr` so the wnwrap path is unchanged (regression: existing `AlarmDispatcherTests` still pass with `Degraded=false`).
**Step 3: Tests** — extend `AlarmDispatcherTests` with a subtag-style transition asserting `body.Degraded == true` and `SourceProvider == Subtag`.
**Step 4: Build x86 + run** worker tests for `AlarmDispatcherTests`, `SyntheticAlarmGuidTests`.
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs \
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs \
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs \
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs \
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
git commit -m "worker(alarms): synthetic GUID + degraded provenance on emitted transitions"
```
---
### Task 9: Wire watch-list + failover config through `AlarmCommandHandler`; emit mode-changed event
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 5, 7, 8)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (`ExecuteSubscribeAlarms`, ~lines 588-616)
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (consumer factory wiring; mode-change → event queue)
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs` (extend or create)
**Step 1: Carry the subscribe payload.** Change the alarm subscribe entry point from `Subscribe(string subscription)` to `Subscribe(SubscribeAlarmsCommand command)` (the command now has `ForcedMode`, `WatchList`, `Failover`). In `AlarmCommandHandler.Subscribe`:
- Build the active provider per `ForcedMode`:
- `ALARMMGR``WnWrapAlarmConsumer` only.
- `SUBTAG``SubtagAlarmConsumer(new LmxSubtagAlarmSource(factory), watchList)` only.
- `UNSPECIFIED``FailoverAlarmConsumer(primary: wnwrap, standby: subtag, settings-from-Failover)`.
- Use the existing `consumerFactory` seam but widen it to `Func<SubscribeAlarmsCommand, IMxAccessAlarmConsumer>` so tests inject fakes and production builds the failover composite. Subscribe to `FailoverAlarmConsumer.ProviderModeChanged` and enqueue an `OnAlarmProviderModeChangedEvent` MxEvent via the event queue (new mapper method `CreateOnAlarmProviderModeChanged`).
**Step 2: Executor + STA wiring.** `ExecuteSubscribeAlarms` passes the full `SubscribeAlarmsCommand` (not just the expression). In `MxAccessStaSession`, the `alarmCommandHandlerFactory` must give the handler access to the `IMxAccessComObjectFactory` so the subtag source can create its own proxy server on the STA; keep the `EnsureOnAlarmConsumerThread` affinity guard on every path.
**Step 3: Test** — fake consumer factory; assert that a `SUBTAG` forced command builds the subtag consumer and advises; that an auto command building a fake failover composite, when it raises `ProviderModeChanged`, enqueues an `OnAlarmProviderModeChangedEvent` on the queue.
**Step 4: Build x86 + worker tests.**
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
git commit -m "worker(alarms): route watch-list/failover config; emit provider-mode-changed event"
```
---
## Phase 2 — Gateway: discovery, options, monitor, metrics, dashboard
### Task 10: `AlarmsOptions.Fallback` + validation
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 11, Task 13
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`
- Create: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (`ValidateAlarms`, ~lines 234-258)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs` (extend)
**Step 1:** Add `AlarmFallbackOptions Fallback { get; init; } = new();` to `AlarmsOptions`. `AlarmFallbackOptions`: `string Mode = "Auto"` (`Auto|ForceAlarmManager|ForceSubtag`), `int ConsecutiveFailureThreshold = 3`, `int FailbackProbeIntervalSeconds = 30`, `int FailbackStableProbes = 3`, a `Discovery` sub-object (`bool UseGalaxyRepository = true`, `string Area = ""`, `string[] IncludeAttributes = []`, `string[] ExcludeAttributes = []`), and a `Subtags` sub-object (`Active="active"`, `Acked="acked"`, `AckComment=""`, `Priority="priority"`).
**Step 2:** In `ValidateAlarms`, when `Enabled` and `Mode == "ForceSubtag"` and `Discovery.UseGalaxyRepository == false` and `IncludeAttributes` empty ⇒ add a validation error ("ForceSubtag requires Galaxy Repository discovery or an explicit IncludeAttributes list"). Floor the three numeric values at 1. Validate `Mode` is one of the three literals.
**Step 3-5:** Test the new validation cases (red→green), build the server, commit.
---
### Task 11: Galaxy Repository "alarm attributes" discovery query
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 13
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs` (add `GetAlarmAttributesAsync` + SQL constant, following `GetAttributesAsync` ~lines 86-115 and `AttributesSql` ~line 176)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs`
- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/` (projection unit test; live SQL gated)
**Step 1:** `GalaxyAlarmAttributeRow { string FullTagReference; string SourceObjectReference; string AckCommentSubtag; }` (and any priority subtag). `GetAlarmAttributesAsync` reuses the existing `is_alarm` detection (the `AlarmExtension` primitive join already in `AttributesSql`) filtered to `is_alarm = 1`, projecting the alarm reference + its ack-comment attribute. Follow the exact `SqlConnection`/`SqlCommand`/`SqlDataReader` pattern from `GetAttributesAsync`.
**Step 2:** Unit-test the row→`AlarmSubtagTarget` mapping (a pure mapper function); gate any live-DB test like the existing Galaxy live tests (or `Skip` with a note, matching `AlarmsLiveSmokeTests`).
**Step 3-5:** red→green, build server, commit.
---
### Task 12: Watch-list resolver (GR SQL + config override → `AlarmSubtagTarget[]`)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Tasks 10, 11)
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs`
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs`
**Step 1: Test the merge** with a fake `IGalaxyRepository`:
- discovery rows + `IncludeAttributes` are unioned; `ExcludeAttributes` removed; each becomes an `AlarmSubtagTarget` with `.active`/`.acked`/`.ackmsg` addresses composed from the configured `Subtags` names (`<reference>.<Active>`, etc.); empty config subtag names fall back to defaults; GR unavailable + no includes ⇒ empty list + a logged warning flag.
**Step 2: Implement** `ResolveAsync(AlarmsOptions, CancellationToken) → IReadOnlyList<AlarmSubtagTarget>`.
**Step 3-5:** red→green, build, commit.
---
### Task 13: Gateway metrics — provider-mode gauge + switch counter
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs` (ctor ~lines 55-79; add counter + observable gauge following the existing pattern)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs` (if present; else assert via a `MeterListener`)
**Step 1:** Add `mxgateway.alarms.provider_switches` counter (tagged `from`,`to`,`reason`) and `mxgateway.alarms.provider_mode` observable gauge (1=alarmmgr, 2=subtag), plus `AlarmProviderSwitched(int from, int to, string reason)` and a private `GetAlarmProviderMode()` (lock on `_syncRoot` like the others).
**Step 2-4:** test, build, commit.
---
### Task 14: `GatewayAlarmMonitor` — arm watch-list, reflect provider mode, reconcile on switch
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 9, 12, 13)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (ctor ~41-49; `SubscribeAlarmsAsync` ~210-233; event-drain loop; `StreamAsync` ~386-434)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs` (new, using `FakeWorkerHarness`)
**Step 1:** Inject `IAlarmWatchListResolver` and `GatewayMetrics`. In `SubscribeAlarmsAsync`, resolve the watch-list and build the `SubscribeAlarmsCommand` with `ForcedMode` (from `Fallback.Mode`), `WatchList`, and `Failover` populated from options — instead of the bare `{ SubscriptionExpression }`.
**Step 2:** In the worker-event drain path, handle `OnAlarmProviderModeChangedEvent`: update a `_providerStatus` field (mode/degraded/reason/since), `Broadcast(new AlarmFeedMessage { ProviderStatus = … })` to every subscriber, call `metrics.AlarmProviderSwitched(...)`, and force a `ReconcileAsync` so the cache re-seeds from the now-active provider (avoids raise/clear storms).
**Step 3:** In `StreamAsync`, emit the current `provider_status` as the **first** message (before the snapshot) so a late joiner immediately knows the mode.
**Step 4: Test** — stand up the monitor with `FakeWorkerHarness`; emit an `OnAlarmProviderModeChangedEvent(Subtag)`; assert a `StreamAsync` subscriber receives a `ProviderStatus{ Mode=Subtag, Degraded=true }` and that the switch counter incremented. Also assert a transition emitted in subtag mode flows through with `Degraded=true`.
**Step 5:** build server, run the new test, commit.
---
### Task 15: Dashboard — push provider status to `/hubs/alarms` + UI indicator
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 14)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs` (forward `ProviderStatus` messages — they already flow through `StreamAsync`, so confirm the existing `SendAsync(AlarmMessage, message)` carries them; add a dedicated `"ProviderModeChanged"` client method if the dashboard needs a distinct channel)
- Modify: the alarms dashboard page/component (Bootstrap-only badge: green "alarmmgr" / amber "degraded — subtag") — find under `src/ZB.MOM.WW.MxGateway.Server/Dashboard/`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` dashboard model test (e.g. a `DashboardAlarmProviderStatus.FromFeed` mapper, mirroring `DashboardActiveAlarm.FromSnapshot`)
**Constraint:** Bootstrap CSS/JS only — no MudBlazor/Radzen/FluentUI.
**Steps:** TDD the model mapper, wire the publisher + badge, build, commit.
---
## Phase 3 — Integration, docs, live smoke
### Task 16: End-to-end fake-worker failover test
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 18
**Files:**
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs`
Drive the full gateway path with `FakeWorkerHarness`: subscribe (assert the `SubscribeAlarmsCommand` carries a watch-list), emit a wnwrap-style transition (assert `Degraded=false`), emit `OnAlarmProviderModeChangedEvent(Subtag)`, emit a synthesized transition (assert `Degraded=true`, `SourceProvider=Subtag`), then `OnAlarmProviderModeChangedEvent(Alarmmgr)` and assert the feed reports recovery. Build, run, commit.
---
### Task 17: Live subtag smoke test (opt-in)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 18
**Files:**
- Test: `src/ZB.MOM.WW.MxGateway.IntegrationTests/...AlarmSubtagLiveSmokeTests.cs` (or the worker live suite)
A `[LiveMxAccessFact]`, `Skip`-by-default test (per `AlarmsLiveSmokeTests` precedent) that, against a live Galaxy + alarm flip script: advises the real `.active`/`.acked` subtags via `LmxSubtagAlarmSource`, asserts a synthesized raise/clear, and performs an ack via the ack-comment write. Document the exact subtag names discovered (resolves the design's open item). Commit.
---
### Task 18: Documentation
**Classification:** trivial
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Modify: `gateway.md` (alarm provider section: dual provider + auto-failover/failback)
- Modify: `docs/DesignDecisions.md` (record the fallback decision + parity rationale)
- Modify: `docs/GatewayConfiguration.md` (the `MxGateway:Alarms:Fallback` block)
- Modify: `docs/AlarmClientDiscovery.md` (subtag provider, synthesis rules, ack-comment write)
- Modify: `docs/Grpc.md` (new `provider_status` feed case + `degraded`/`source_provider` fields)
Follow `StyleGuide.md` (PascalCase filenames, present tense, explain *why*). No code; commit.
---
## Execution order & parallelism summary
- **Serial spine:** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8/9 → 10/11 → 12 → 13 → 14 → 15 → 16 → 17/18.
- **Parallelizable clusters:** {8, 9 partially}, {10, 11, 13}, {16, 17, 18}.
- **High-risk tasks** (full review chain): 1, 2, 6, 7, 9, 14. **Standard:** 4, 5, 8, 10, 11, 12, 15, 16. **Small/trivial:** 3, 13, 17, 18.
## Risk notes for the executor
- **Field-number collisions:** Task 2 must read the live `MxEvent`/`MxEventFamily` numbers before adding — the agent map gave alarm-payload maxima but not `MxEvent`'s. Verify before editing.
- **STA discipline:** every COM call in `LmxSubtagAlarmSource` and every consumer swap runs on the worker STA; keep the `EnsureOnAlarmConsumerThread` guard. The worker STA already pumps Windows messages, which is required for the subtag `OnDataChange` to deliver.
- **Parity regression:** alarmmgr-mode output must be byte-for-byte unchanged. Existing `AlarmDispatcherTests` and `ProtobufContractRoundTripTests` are the guardrail — they must stay green with `Degraded=false` defaults.
- **Subtag names unverified:** the design leaves exact AVEVA subtag names (`.active`, `.acked`, ack-comment) to confirm against `C:\Users\dohertj2\Desktop\mxaccess` + a live Galaxy (Task 17). The config `Subtags` block exists so names are not hard-coded.
@@ -0,0 +1,147 @@
{
"planPath": "docs/plans/2026-06-13-alarm-subtag-fallback.md",
"tasks": [
{
"id": 54,
"subject": "Task 1: Worker proto \u2014 watch-list, failover config, AlarmProviderMode",
"status": "completed"
},
{
"id": 55,
"subject": "Task 2: Gateway proto \u2014 provider status, degraded provenance, mode-changed event",
"status": "completed",
"blockedBy": [
54
]
},
{
"id": 56,
"subject": "Task 3: Proto round-trip tests for new alarm fields",
"status": "completed",
"blockedBy": [
54,
55
]
},
{
"id": 57,
"subject": "Task 4: Subtag value-source abstraction + synthesis state machine",
"status": "completed",
"blockedBy": [
54
]
},
{
"id": 58,
"subject": "Task 5: SubtagAlarmConsumer over the source seam",
"status": "completed",
"blockedBy": [
57
]
},
{
"id": 59,
"subject": "Task 6: COM-backed LmxSubtagAlarmSource",
"status": "completed",
"blockedBy": [
57
]
},
{
"id": 60,
"subject": "Task 7: FailoverAlarmConsumer state machine",
"status": "completed",
"blockedBy": [
58
]
},
{
"id": 61,
"subject": "Task 8: Synthetic GUID + degraded flag on event sink path",
"status": "completed",
"blockedBy": [
55
]
},
{
"id": 62,
"subject": "Task 9: Wire watch-list/failover through AlarmCommandHandler; emit mode-changed",
"status": "completed",
"blockedBy": [
58,
60,
61
]
},
{
"id": 63,
"subject": "Task 10: AlarmsOptions.Fallback + validation",
"status": "completed"
},
{
"id": 64,
"subject": "Task 11: Galaxy Repository alarm-attributes discovery query",
"status": "completed"
},
{
"id": 65,
"subject": "Task 12: Watch-list resolver (GR SQL + config override)",
"status": "completed",
"blockedBy": [
54,
63,
64
]
},
{
"id": 66,
"subject": "Task 13: Metrics \u2014 provider-mode gauge + switch counter",
"status": "completed"
},
{
"id": 67,
"subject": "Task 14: GatewayAlarmMonitor \u2014 arm watch-list, reflect mode, reconcile on switch",
"status": "completed",
"blockedBy": [
55,
62,
65,
66
]
},
{
"id": 68,
"subject": "Task 15: Dashboard \u2014 push provider status + UI badge",
"status": "completed",
"blockedBy": [
67
]
},
{
"id": 69,
"subject": "Task 16: End-to-end fake-worker failover test",
"status": "completed",
"blockedBy": [
67
]
},
{
"id": 70,
"subject": "Task 17: Live subtag smoke test (opt-in)",
"status": "completed",
"blockedBy": [
59,
62
]
},
{
"id": 71,
"subject": "Task 18: Documentation",
"status": "completed",
"blockedBy": [
67
]
}
],
"lastUpdated": "2026-06-13T13:30:00Z"
}
+270
View File
@@ -0,0 +1,270 @@
# Deferred Follow-ups Implementation Plan
**Date:** 2026-06-14
**Status:** Plan only — NOT yet executed. Saved for review.
**Context:** After the alarm-subtag-fallback cleanup (merged `5976770`) and its redeploy to
windev (10.100.0.48), five items remain deferred. This plan handles all five. They are
independent — execute in any order, or cherry-pick. Items D1D2 are code (branch off `main`);
D3 is a dev-rig validation; D4D5 are ops on the deployed hosts (no code).
Source of the deferred list: the post-deploy review on 2026-06-14. See also memory
`project_deploy_mechanics`, `project_wonder_deployment`, `project_alarm_subtag_fallback`,
`project_rig_alarms_object_driven`.
---
## D1 — Surface `AlarmProviderSwitchCount` on the dashboard metric list
**Classification:** small · **Est:** ~10 min · **Where:** Server (net10), build+test on macOS
**Why deferred:** the dashboard reads provider *state* from the feed's `ProviderStatus` badge,
not the metrics snapshot, so the snapshot field (added in B1) and the OTEL counter were enough.
This makes the cumulative switch count visible in the dashboard's metric table too.
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs` (`CreateMetricSummaries`, ~line 178198)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardSnapshotServiceTests.cs` (or the existing snapshot test file)
**Steps:**
1. In `CreateMetricSummaries`, add to the `metrics` list (near the other counters):
`new("mxgateway.alarms.provider_switches", snapshot.AlarmProviderSwitchCount),`
Also consider adding the current mode as a gauge row if useful:
the snapshot does not carry the mode int (only the feed does), so DO NOT invent one —
only add the switch count, which the snapshot already exposes.
2. Add/extend a unit test asserting the summary list contains a
`mxgateway.alarms.provider_switches` row equal to the snapshot's `AlarmProviderSwitchCount`
after calling `metrics.AlarmProviderSwitched(...)`.
3. Verify: `dotnet build src/ZB.MOM.WW.MxGateway.Server` (macOS) and
`dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter FullyQualifiedName~DashboardSnapshot`.
4. Commit.
**Rollback:** revert the one-line list addition.
---
## D2 — Reproduce and fix the `AmbiguousMatchException` on `GET /`
**Classification:** standard · **Est:** ~3045 min (repro + fix) · **Where:** Server (net10)
**Why deferred:** observed only in *old* (06/05, Development) logs:
`AmbiguousMatchException: The request matched multiple endpoints. Matches: / (/) / (/)`.
Unauthenticated `GET /` safely 302s to `/login`; the ambiguity would only throw for an
*authenticated* request that reaches routing. Pre-existing and unrelated to the alarm work,
but it would surface as a dashboard-home 500. Confirm it still reproduces before fixing.
**Root-cause candidates (the two endpoints both matching `/`):**
- `DashboardHome.razor` (`@page "/"`) mapped by `MapRazorComponents<App>()`
(`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:92`).
- `MapStaticAssets(...)` (`GatewayApplication.cs:190`) — in .NET 810 the static-assets
endpoint can register a fingerprinted root that collides with the home page.
**Step 0 — Reproduce (do this first; if it does NOT repro, downgrade to a documentation note):**
1. Log into the dashboard as `multi-role`/`password` (Administrator) at
`http://10.100.0.48:5130/login`.
2. Navigate to `http://10.100.0.48:5130/` (the "Dashboard" home).
3. If it renders the Overview page → not a live bug on this build; record that and stop
(the dev error-page throw was a Development-only artifact). If it 500s with
`AmbiguousMatchException` → proceed to fix.
**Fix (only if it reproduces):**
- Files: `src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` (endpoint mapping order/region),
`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs`,
`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor`.
- Candidate fixes, in order of preference:
1. Inspect the actual two matched endpoint display names at runtime (the exception lists
them) — temporarily enable detailed errors or read the stderr `Matches:` block — to learn
exactly which two collide.
2. If `MapStaticAssets` owns the second `/`: it must run, but confirm it isn't double-mapping
the home route; if it is, move `MapStaticAssets` ordering or scope it so it doesn't claim
the literal `/` content path. (Do NOT remove `MapStaticAssets` — Blazor needs it.)
3. If two component pages both declare `@page "/"`: give the non-home one a distinct route.
4. Last resort: give `DashboardHome` an explicit route (e.g. `@page "/overview"`) and add a
trivial `MapGet("/", () => Results.Redirect("/overview"))` with `.RequireAuthorization(...)`.
- Add a test that exercises the root route once resolved (a WebApplicationFactory-based
integration test asserting authenticated `GET /` returns 200, if the dashboard test harness
supports auth; otherwise a routing-uniqueness assertion).
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.Server`; re-run the authenticated `GET /` repro.
**Rollback:** revert the routing change; the home page returns to its prior (ambiguous) state.
**Note:** this fix ships only via a Server redeploy (D-deploy applies, see D5/windev redeploy
procedure in `project_deploy_mechanics`). Decide whether to bundle it with the next redeploy.
---
## D3 — Validate the failover path end-to-end on the dev rig (ForceSubtag)
**Classification:** standard (validation, no production code) · **Est:** ~3045 min · **Where:** dev rig / windev
**Why deferred:** `provider_mode 1` (healthy alarmmgr) is verified in production, but the actual
alarmmgr→subtag **failover**, the **degraded badge**, the synthesized subtag alarms, and the
`provider_switches{from,to,reason}` counter (with the new bounded `failover`/`failback`/`unknown`
tag) have never fired live — a healthy system never switches, and the rig's alarms can't be made
to fail COM on demand. Use the explicit `ForceSubtag` config mode to exercise the degraded path
deterministically without an alarmmgr fault.
**Approach:** run a *temporary* gateway instance (NOT the production service) in `ForceSubtag`
mode against the dev Galaxy, drive it with the operator/IDE alarm toggle, and confirm the
degraded surface. Do this on windev in a throwaway run so production stays on `Auto`.
**Steps:**
1. On windev, from a build worktree at `main`, run the Server locally (or a second instance on
alt ports) with `MxGateway:Alarms:Fallback:Mode=ForceSubtag` (env
`MxGateway__Alarms__Fallback__Mode=ForceSubtag`), pointed at the dev Galaxy
(`\\DESKTOP-6JL3KKO\Galaxy!DEV`). Use distinct Kestrel ports to avoid clashing with the
production service on 5120/5130.
2. Subscribe an alarm client (or open the dashboard alarms page for that instance) and confirm:
- The provider badge shows **"Subtag monitoring (degraded)"** (amber `bg-warning`).
- `curl .../metrics` shows `mxgateway_alarms_provider_mode 2`.
- Active alarms appear with `degraded=true` and a synthetic (MD5-derived) GUID, with the
reference shape `Galaxy!<realArea>.<object>.<attr>` (e.g. `Galaxy!TestArea.TestMachine_001.TestAlarm001`).
3. Drive a transition: have the operator/IDE toggle a `TestMachine_NNN.TestAlarmNNN` true→false
(external MXAccess writes are ignored — see `project_rig_alarms_object_driven`); confirm a
synthesized Raise then Clear, and an `AckMsg` write via AcknowledgeByName returns 0.
4. (Optional, to exercise the switch counter) run in `Auto` and induce a primary fault if a safe
way exists; otherwise document that the counter is unit-tested only and `ForceSubtag` covers
the degraded surface. Note: `ForceSubtag` may not increment `provider_switches` (no runtime
switch) — that counter's live exercise remains the one gap; record it explicitly rather than
claiming coverage.
5. Tear down the temporary instance. Production service is untouched (stays `Auto`).
6. Record results in `project_alarm_subtag_fallback` memory (degraded badge + synthesized
subtag alarms now live-validated; switch-counter still unit-test-only if not exercised).
**Rollback:** none — temporary instance only; nothing in production changes.
---
## D4 — Prune stale deploy backups on windev
**Classification:** trivial (ops) · **Est:** ~10 min · **Where:** windev (10.100.0.48), no code
**Why deferred:** backups accumulate on every deploy; harmless but cluttering
`C:\publish\mxaccessgw\`.
**Current backups observed (2026-06-14):**
- `Server.bak-20260526T143341`, `Server.bak-20260529T090320`, `Server.bak529.20260604T122616`,
`Server.bak-theme030-20260605-053056`, `Server.bak-theme031-20260605-083410`,
`Server.bak-20260614-prefallback` (today's), `Worker.bak-20260614-prefallback` (today's).
- Inside `Server\`: `ZB.MOM.WW.MxGateway.Server.dll.bak-20260604-loginfix`,
`appsettings.json.bak-20260604-{glauth35,ldapkeys}`.
**Steps:**
1. **Keep** `Server.bak-20260614-prefallback` and `Worker.bak-20260614-prefallback` — the
immediate rollback for the current deploy. Keep until the new build has soaked (e.g. a few
days / one business cycle).
2. Delete the clearly-superseded older backups: the `Server.bak-2026052*`, `Server.bak529.*`,
and `Server.bak-theme03*` dirs. Confirm each is a Server dir (not something live) before
`rmdir /s /q`.
3. Optionally remove the in-`Server` `.bak-*` sidecar files (`*.dll.bak-*`,
`appsettings.json.bak-*`) — but FIRST confirm the live `appsettings.json` is correct (it is,
post-deploy) so the `.bak` ldap/glauth copies aren't the only record of a needed value.
4. Verify the service is unaffected: `Get-Service MxAccessGw` Running, `/health/ready` 200.
**Rollback:** none needed (deletions only; the current-deploy backup is retained). If unsure
about any single dir, skip it.
---
## D5 — Redeploy the wonder host to bring it to parity
**Classification:** high-risk (separate production-ish box, fiddly access, divergent build) · **Est:** ~12 h · **Where:** wonder-app-vd03.zmr.zimmer.com (10.220.157.247)
**Why deferred:** wonder still runs the pre-feature build — it lacks the entire alarm-fallback
feature and the cleanup. Bring it to `5976770` if parity is wanted. This box is materially
different from windev (read `project_wonder_deployment` fully before starting).
**Key differences from windev (do NOT reuse the windev recipe blindly):**
- Access: no usable `ssh host "cmd"` exec. Port 22 OpenSSH (PowerShell) was observed CLOSED
2026-05-26; the reliable path is **servecli on port 2222** (cmd.exe PTY + SFTP only) using the
base64-`-EncodedCommand` pattern in `project_wonder_deployment` / `~/Desktop/servecli/instructions.md`.
Re-test port 22 first; if up, prefer it.
- Build shape: wonder runs a **self-contained single-file win-x64** Server (~118 MB), renamed to
`MxGateway.Server.exe` for the NSSM path `E:\ApiInstall\MxGateway\Server\MxGateway.Server.exe`.
So publish must be `-r win-x64 --self-contained -p:PublishSingleFile=true` (NOT the
framework-dependent windev recipe), then rename the exe to `MxGateway.Server.exe`.
- Static assets quirk: ship BOTH `ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json` and
`MxGateway.Server.staticwebassets.endpoints.json` next to the exe (the ZB-named one is used).
- Config: wonder's `appsettings.json` carries HTTP-switched Kestrel (`http://0.0.0.0:5130`),
`Dashboard.GroupToRole` = `{ SCADA-Admins/Designers/Deploy-All → Admin }`,
`Dashboard.RequireHttpsCookie:false`, LDAP base `dc=scadalink,dc=local`. **Preserve wonder's
appsettings.json** exactly like windev (do not ship the repo default). Note: wonder's
GroupToRole values are `"Admin"` — VERIFY the current build's validator accepts `"Admin"` (the
windev crash showed it requires `'Administrator'`/`'Viewer'`). **If the validator rejects
`"Admin"`, wonder's appsettings GroupToRole values must be updated to `Administrator` BEFORE/with
the deploy or the new build will crash-loop.** This is the single biggest risk — resolve it first.
- Worker: wonder's x86 worker is the original; the alarm-fallback adds new subscribe fields. To
use subtag fallback on wonder, the **x86 worker must also be redeployed** (publish x86, rename
if needed). If only the Server is updated, confirm worker IPC protobuf compat (contracts changed
for alarms — a stale worker may not understand the extended SubscribeAlarms). Safer to deploy
both Server and Worker together.
**Steps (high level — expand against the runbook before executing):**
1. Confirm parity is actually wanted on wonder, and whether the dashboard (disabled there) and
the alarm monitor are even in scope for that box. If alarms/dashboard are off on wonder, this
may reduce to "Server binary parity only."
2. Resolve the **GroupToRole value compatibility** question (Admin vs Administrator) — inspect
the current build's `GatewayOptionsValidator` and decide whether to patch wonder's appsettings.
3. Build self-contained Server (`-r win-x64 --self-contained -p:PublishSingleFile=true`) and x86
Worker from `main` (`5976770`).
4. Transfer via SFTP (servecli) to a staging dir on `E:\ApiInstall\MxGateway\`.
5. Stop `MxAccessGw`; back up `Server``Server.bak.<ts>` and `Worker``Worker.bak.<ts>`;
swap in the new build; **restore wonder's `appsettings.json`** (+ patched GroupToRole if
needed); ensure both staticwebassets manifests are present; rename Server exe to
`MxGateway.Server.exe`.
6. Start `MxAccessGw` (no dependents). Verify stderr line count does not grow (no crash loop),
`curl -k https://...:5130/health/live` (or HTTP per current config) Healthy, gRPC 5120 up.
7. Verify the new metric is present if the metrics endpoint is exposed there.
**Rollback (documented in memory):** stop service,
`Move-Item Server Server.failed; Move-Item Server.bak.<ts> Server`, restore appsettings from the
backup, start.
---
## Suggested order
1. **D2 repro** (5 min — just log in and hit `/`): decides whether D2 is a real fix or a no-op.
2. **D1** (small code) + **D2 fix if needed** — bundle into one Server branch; they ship together
on the next Server redeploy.
3. **D4** (prune windev backups) — quick ops, independent.
4. **D3** (ForceSubtag validation) — exercises the degraded surface; do before/after the D1+D2
redeploy so you also confirm the new Server build is healthy.
5. **D5** (wonder) — largest and riskiest; do last, only if parity is wanted, and resolve the
GroupToRole-value compatibility question first.
## Execution note
This plan is intentionally NOT executed. When ready, execute on a branch off `main`
(`feat/deferred-followups` or per-item branches) — do not commit to `main` directly. D1/D2 need a
Server redeploy to take effect; D4/D5 are host operations; D3 touches nothing permanent.
---
## D2 — Resolution (2026-06-14)
Static source determination on build `5976770` (runtime repro out of scope). What was checked:
- **Razor `@page "/"` count:** exactly ONE — `Dashboard/Components/Pages/DashboardHome.razor`.
All other pages declare distinct routes (`/login`, `/sessions`, `/galaxy`, `/browse`,
`/apikeys`, `/workers`, `/events`, `/alarms`, `/settings`, `/sessions/{SessionId}`).
- **No root index.html:** `src/ZB.MOM.WW.MxGateway.Server/wwwroot/` contains only `css/` and
`lib/` subdirectories; no `index.html` anywhere under `wwwroot/`.
- **No `UseDefaultFiles` / no `MapFallback`:** neither appears anywhere in the Server project.
- **`MapStaticAssets` mapped once** (`GatewayApplication.cs:190`) and `UseStaticFiles()` once
(`:41`); the static-assets manifest serves fingerprinted CSS/JS assets, not a literal `/`.
- **No `MapGet("/")`:** the dashboard endpoint builder
(`Dashboard/DashboardEndpointRouteBuilderExtensions.cs`) maps only `/auth/login`, `/logout`,
`/denied`, `/hubs/{snapshot,alarms,events}`, `/hubs/token`, then `MapRazorComponents<App>()`.
None use pattern `/`.
- **`MapZbHealth` / `MapZbMetrics`** come from the external `ZB.MOM.WW.Health` shared library
(not in this repo) and map health/metrics paths, not `/`.
**Root cause of the 2026-06-05 log:** `code-reviews/Server/findings.md` (re-review at `42b0037`,
2026-05-24) records that commit `de7639a` **removed the legacy `MapGet("/", ...)` redirect that
was colliding with the Blazor `@page "/"` (a real 500)**. That legacy registration was the source
of the `AmbiguousMatchException`. It is gone on the current build, so the second `/` endpoint no
longer exists.
**Conclusion:** No duplicate `/` endpoint on build `5976770`. The AmbiguousMatchException is not
reproducible from source — it was a stale Development-only artifact from before `de7639a` reached
the deployed instance. **No source change made** (no-op).
**Residual:** A 100% confirmation still requires an authenticated runtime `GET /` against a
deployed instance (the only path that exercises routing past the unauthenticated 302-to-`/login`).
Recommend a spot-check of authenticated `GET /` after the next Server redeploy; if it returns 200
(not 500), this item can be fully closed.
@@ -0,0 +1,116 @@
# ForceSubtag Mode Fix Implementation Plan
> Fixes the two defects surfaced by the D3 live validation (2026-06-15): forced-subtag
> doesn't actually run subtag (#1), and the gateway never reflects a forced provider mode
> into the gauge/feed (#2).
**Goal:** Make `MxGateway:Alarms:Fallback:Mode=ForceSubtag` actually serve degraded subtag
alarms AND have the gateway advertise `provider_mode=2` / degraded badge.
**Evidence:** Live ForceSubtag run returned alarmmgr-sourced active alarms (May raise
timestamps, `degraded=false`) and `provider_mode` stuck at 1, despite ForceSubtag binding
(proven by invalid-value crash) and the deployed worker containing the ForcedMode routing
(`3f5e5fc``5976770`, worker dated 2026-06-14).
---
## Defect #2 (CONFIRMED code defect) — gateway never reflects forced mode
**Root cause:** `GatewayAlarmMonitor.RunMonitorAsync` hard-baselines `_providerMode=Alarmmgr`
and sets the gauge to 1, ignoring `_options.Fallback.Mode`. `_providerMode` only advances on a
worker `OnAlarmProviderModeChanged` event, which is raised ONLY by `FailoverAlarmConsumer`
(Auto mode). Forced-subtag builds `SubtagAlarmConsumer` directly → no event → gauge/feed stay
Alarmmgr forever.
### Task 2: Seed provider mode from configured forced mode (gateway, net10)
**Classification:** small · **Parallelizable with:** none (precedes the diagnostic build)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`RunMonitorAsync`, ~lines 160-172; add a permanent observability log in `SubscribeAlarmsAsync` ~line 257)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs`
**Change:** in `RunMonitorAsync`, compute `initialMode = MapForcedMode(_options.Fallback.Mode)`
mapped as `Subtag→Subtag`, `Alarmmgr→Alarmmgr`, `Unspecified→Alarmmgr` (Auto starts on the
alarmmgr primary). Set `_providerMode/_providerDegraded(=Subtag)/_providerReason/_providerSince`
and `_metrics.SetAlarmProviderMode(ModeToInt(initialMode))` — using the existing no-switch gauge
seam so `provider_switches` does NOT increment. Add a log in `SubscribeAlarmsAsync`:
`"Alarm subscribe forcedMode={ForcedMode} (configMode={ConfigMode}) watchList={Count}"`.
**Tests (fake-worker, no MXAccess):** with `Fallback:Mode=ForceSubtag` assert (a) first
`StreamAlarms` message is `ProviderStatus{Mode=Subtag,Degraded=true}`; (b) gauge==2; (c)
`provider_switches`==0. Add `ForceAlarmManager`→gauge 1 and `Auto`→gauge 1 baseline cases.
**Verify:** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; `dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter FullyQualifiedName~GatewayAlarmMonitorProviderMode`.
---
## Defect #1 (runtime bug — needs diagnosis) — ForceSubtag runs alarmmgr
The HEAD source path (gateway `MapForcedMode``SubscribeAlarmsCommand.ForcedMode=Subtag`
IPC → worker `AlarmCommandHandler.BuildConsumer``SubtagAlarmConsumer`) is statically correct,
yet the runtime ran alarmmgr. A runtime diagnostic must locate where `forcedMode` becomes
`Unspecified`.
### Task 1: Add worker BuildConsumer observability log (worker, net48 x86)
**Classification:** small (net48 — no init-only props) · **Parallelizable with:** Task 2
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs` (`BuildConsumer`, ~line 206)
**Change:** log at entry of `BuildConsumer`:
`"BuildConsumer forcedMode={ForcedMode} watchList={Count}"`. This is permanent observability.
**Verify (windev only):** `dotnet build src/ZB.MOM.WW.MxGateway.Worker -p:Platform=x86`.
### Task 3: Diagnostic run — capture the real forcedMode
**Classification:** standard (ops/diagnostic on windev) · **Parallelizable with:** none
**Steps:** rebuild worker (x86) + server from the branch, stand up the D3-style temp ForceSubtag
instance (alt ports 5122/5132, isolated `.D3` worker name, WMI-detached, Http2, Development env
for file logs), trigger the always-on monitor, and read the two new log lines:
- Gateway `Alarm subscribe forcedMode=...` — what the gateway SENDS.
- Worker `BuildConsumer forcedMode=...` — what the worker RECEIVES.
Decision matrix:
- Gateway logs `Subtag`, worker logs `Unspecified` → IPC/serialization drops the enum → fix the
send/translation path (likely a worker-proto vs gateway-proto `SubscribeAlarmsCommand` mismatch
in the named-pipe envelope).
- Worker logs `Subtag` but alarmmgr data appears → bug in `BuildStandby`/`SubtagAlarmConsumer`/
`AlarmDispatcher` snapshot path.
- Gateway logs `Unspecified` despite config ForceSubtag → gateway config/options read.
### Task 4: Fix #1 per Task 3 diagnosis
**Classification:** standard/high-risk (depends on where the defect is) · the exact change is
determined by Task 3. Add a regression test at the identified layer (worker unit test for
BuildConsumer→SubtagAlarmConsumer, or a contract/IPC round-trip test if the enum is dropped).
---
## Final: build, test, redeploy, re-validate
- Build gateway (macOS) + worker (windev x86); run gateway + worker test suites.
- Redeploy windev Server (and Worker if Task 4 changed it) per `project_deploy_mechanics`,
preserving appsettings.
- Re-validate live with the temp ForceSubtag instance: active alarms `degraded=true` /
`source_provider=SUBTAG` with recent timestamps, `provider_mode 2`. Tear down temp instance;
production untouched.
## Execution note
Branch off `main`. #2 is the clean confirmed fix; #1 is diagnose-then-fix. Net48 worker
constraints apply (no init-only props/positional records). Do NOT increment `provider_switches`
on an initial forced-mode set.
---
## Resolution (2026-06-15)
**#1 was NOT a bug — it was a grpcurl proto-mismatch artifact.** End-to-end instrumentation
proved: the gateway sends `forcedMode=Subtag`, the worker's `BuildConsumer` builds the
`SubtagAlarmConsumer`, and the worker `QueryActive` + gateway `ApplyReconcile`/`StreamAlarms`
all carry `degraded=true` / `source_provider=SUBTAG`. The original "degraded=false" observation
came from running grpcurl against the host checkout's proto (branch `feat/lazy-browse-children`),
which predates the feature and lacks the `degraded`/`source_provider` fields — grpcurl silently
dropped them. With the matching proto, every active alarm shows `degraded=true`. No code change.
Tasks 1/3/4 (worker diagnostics + #1 fix) were dropped; the temporary diagnostics were reverted.
**#2 was a real defect — fixed.** Gateway now seeds `_providerMode`/gauge/feed from the configured
forced mode (`fix: gateway reflects configured forced provider mode`). Verified live:
`provider_mode=2`, first `ProviderStatus` = `Mode=Subtag, degraded=true,
reason="Forced subtag mode (configuration)"`. Auto mode unchanged → windev production (Auto)
unaffected; no redeploy required. Gateway tests: 163 passed.
+61
View File
@@ -143,6 +143,67 @@ session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule `docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
for the alarm subsystem. for the alarm subsystem.
### Alarm providers and failover
The alarm feed has two providers, both implemented worker-side:
- **Alarm manager (primary):** `WnWrapAlarmConsumer` polls
`wwAlarmConsumerClass.GetXmlCurrentAlarms2` on the worker STA. This is the
authoritative native source.
- **Subtag monitoring (standby):** `SubtagAlarmConsumer` advises each alarm
attribute's subtags (`.active`, `.acked`, optionally `.priority`) via the
existing `AddItem`/`Advise` pipeline through `LmxSubtagAlarmSource` and
synthesizes alarm transitions with `SubtagAlarmStateMachine`. This is a
non-parity, lower-fidelity source — synthetic GUIDs, no native raise
timestamps, narrower fields.
`FailoverAlarmConsumer` wraps both and owns the state machine:
- **Auto-failover:** after `ConsecutiveFailureThreshold` (default 3)
consecutive wnwrap COM failures — `Subscribe` or `PollOnce` throws or
returns a failure HRESULT — it activates the standby. The standby is armed
(subscribed and adviseing) from the start so its state is warm at the moment
of switch.
- **Auto-failback:** while degraded, every `FailbackProbeIntervalSeconds`
(default 30) it re-probes the still-subscribed primary. After
`FailbackStableProbes` (default 3) consecutive clean polls it switches back
to the alarm manager.
- **On every switch:** the consumer snapshots the now-active provider and
emits `OnAlarmProviderModeChangedEvent` so the gateway can reconcile its
cache without a raise/clear storm.
Synthesis is worker-side. This preserves the parity rule — the gateway
forwards only events the worker emits and never synthesizes transitions
itself. The synthesis rules are documented in
`docs/AlarmClientDiscovery.md`.
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
comment to the alarm attribute's ack-comment subtag. The write performs the
ack. If the attribute has no writable ack-comment subtag configured, the RPC
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
used as before.
**Degraded state visibility:** every subtag-mode transition carries
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto fields. The
`AlarmFeedMessage` feed emits an `AlarmProviderStatus` message (the
`provider_status` oneof case) on stream open and on every switch. The
dashboard shows a Bootstrap badge: green ("Alarm Manager") when healthy, amber
("Subtag monitoring (degraded)") on an unexpected failover, and cyan ("Subtag
monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSubtag`
— the latter distinguished by the well-known `AlarmProviderStatus.reason`
(`AlarmProviderReasons.ForcedSubtag`) so an intentional configuration is not shown
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
on from startup; `Auto` (default) enables failover and failback. Watch-list
discovery for the subtag provider uses Galaxy Repository SQL with config
overrides. See `docs/GatewayConfiguration.md` for the full `Fallback` option
block and `docs/AlarmClientDiscovery.md` for synthesis rules and fidelity
limitations.
Dashboard authentication is LDAP-backed (distinct from the API-key model on Dashboard authentication is LDAP-backed (distinct from the API-key model on
the gRPC API). `/login` accepts username and password in a form body, binds the gRPC API). `/login` accepts username and password in a form body, binds
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer` against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
+104 -57
View File
@@ -1,28 +1,37 @@
# GLAuth — LDAP authn reference for mxaccessgw # GLAuth — LDAP authn reference for mxaccessgw
GLAuth is a lightweight LDAP server installed on this dev box at > **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
`C:\publish\glauth\` and run as a Windows service via NSSM. It already > Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa > the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
Admin UI's cookie login; this doc captures everything mxaccessgw needs > The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
to consume the same directory so a single set of dev credentials covers
both stacks.
The authoritative copy of LmxOpcUa's reference lives at GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
mxaccessgw — what users + groups are already provisioned, how to bind docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
against them, and what's needed to add a gw-specific role. that directory so a single set of dev credentials covers all stacks.
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
kept only as a rollback option. Do not edit or restart it for new work.)*
The single source of truth for the shared GLAuth is
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
what users + groups are provisioned, how to bind against them, and what's needed to add a
gw-specific role.
## Connection details ## Connection details
| Setting | Value | | Setting | Value |
|---|---| |---|---|
| Protocol | LDAP (unencrypted) | | Protocol | LDAP (unencrypted) |
| Host | `localhost` | | Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
| Port | `3893` | | Port | `3893` |
| LDAPS | disabled in dev (set `[ldaps]` block to enable) | | LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
| Base DN | `dc=lmxopcua,dc=local` | | Base DN | `dc=zb,dc=local` |
| Bind DN format | `cn={username},dc=lmxopcua,dc=local` | | Bind DN format | `cn={username},dc=zb,dc=local` |
| Group OU | `ou=<groupname>,ou=groups,dc=lmxopcua,dc=local` | | Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) | | Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
## Pre-existing groups (LmxOpcUa role taxonomy) ## Pre-existing groups (LmxOpcUa role taxonomy)
@@ -33,11 +42,11 @@ LmxOpcUa write rights doesn't need a second account for the gw.
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping | | Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|---|---|---|---|---| |---|---|---|---|---|
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) | | ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=zb,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) | | WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=lmxopcua,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) | | WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=lmxopcua,dc=local` | Write Configure attrs | `WriteSecured` (Configure) | | WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=zb,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=lmxopcua,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added | | AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
**A user can be in multiple groups** — `othergroups = [...]` in the **A user can be in multiple groups** — `othergroups = [...]` in the
config is a list. `admin` is the canonical example (in every role config is a list. `admin` is the canonical example (in every role
@@ -59,20 +68,26 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
`readonly` is the right "negative" case for proving Browse-OK / `readonly` is the right "negative" case for proving Browse-OK /
Write-denied. Write-denied.
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy: The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the `GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
dashboard login and `DashboardLdapLiveTests` require `admin` to be a These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline The dashboard test users are **`multi-role`/`password`** (Administrator) and
GLAuth config — it must be provisioned before dashboard authn or the **`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
LDAP live tests work. See [Provisioning the GwAdmin See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
group](#provisioning-the-gwadmin-group) below. (now-retired) per-box procedure and for the shared-config equivalent.
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
> maps to `Viewer`. This is a pure value rename via
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
## Two bind patterns ## Two bind patterns
### 1. Direct bind (simplest) ### 1. Direct bind (simplest)
``` ```
DN: cn=admin,dc=lmxopcua,dc=local DN: cn=admin,dc=zb,dc=local
Password: admin123 Password: admin123
``` ```
@@ -84,9 +99,9 @@ by `sAMAccountName`, not `cn`. Use this only for dev convenience.
### 2. Bind-then-search (production-grade) ### 2. Bind-then-search (production-grade)
``` ```
1. Bind as the service account (cn=serviceaccount,dc=lmxopcua,dc=local 1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
/ serviceaccount123). / serviceaccount123).
2. Search under dc=lmxopcua,dc=local with filter 2. Search under dc=zb,dc=local with filter
(uid=<entered-username>) — or any attribute the deployment (uid=<entered-username>) — or any attribute the deployment
identifies users by. GLAuth populates uid + cn. identifies users by. GLAuth populates uid + cn.
3. Read the returned entry's DN + memberOf list (groups). 3. Read the returned entry's DN + memberOf list (groups).
@@ -112,12 +127,12 @@ record:
```yaml ```yaml
ldap: ldap:
enabled: true enabled: true
server: localhost server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
port: 3893 port: 3893
useTls: false useTls: false
allowInsecureLdap: true # dev only allowInsecureLdap: true # dev only
searchBase: "dc=lmxopcua,dc=local" searchBase: "dc=zb,dc=local"
serviceAccountDn: "cn=serviceaccount,dc=lmxopcua,dc=local" serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
serviceAccountPassword: "serviceaccount123" serviceAccountPassword: "serviceaccount123"
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
displayNameAttribute: "cn" displayNameAttribute: "cn"
@@ -131,19 +146,35 @@ ldap:
``` ```
`groupAttribute` returns full DNs like `groupAttribute` returns full DNs like
`ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` — the authenticator `ou=ReadOnly,ou=groups,dc=zb,dc=local` — the authenticator
should strip the leading `ou=` (or `cn=` against AD) RDN value and should strip the leading `ou=` (or `cn=` against AD) RDN value and
look that up in `groupToRole`. look that up in `groupToRole`.
## Provisioning the GwAdmin group ## Provisioning the GwAdmin group
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
>
> ```bash
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose up -d --force-recreate
> ```
>
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
> rollback reference only — do not use it for new provisioning.
`GwAdmin` is the gateway-specific dashboard-admin role. It is the `GwAdmin` is the gateway-specific dashboard-admin role. It is the
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject `DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
`admin` until a `GwAdmin` group exists and `admin` is a member. logins unless the user is a member of `GwAdmin`.
GLAuth's baseline config ships only the five LmxOpcUa role groups, so The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
server: `multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
---
**RETIRED — per-box provisioning (reference/rollback only):**
1. Edit `C:\publish\glauth\glauth.cfg` 1. Edit `C:\publish\glauth\glauth.cfg`
2. Append the group: 2. Append the group:
@@ -172,7 +203,7 @@ server:
4. `nssm restart GLAuth` 4. `nssm restart GLAuth`
After the restart, `admin`'s `memberOf` includes After the restart, `admin`'s `memberOf` includes
`ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local`, which the authenticator `ou=GwAdmin,ou=groups,dc=zb,dc=local`, which the authenticator
strips to `GwAdmin` and matches against `RequiredGroup`. The same strips to `GwAdmin` and matches against `RequiredGroup`. The same
pattern applies to any future permission that doesn't fit the existing pattern applies to any future permission that doesn't fit the existing
five roles. five roles.
@@ -193,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
## Quick verification ## Quick verification
From mxaccessgw's dev box, prove the directory is reachable: From mxaccessgw's dev box, prove the shared directory is reachable:
```powershell ```powershell
# Plain bind via PowerShell + System.DirectoryServices.Protocols # Plain bind via PowerShell + System.DirectoryServices.Protocols
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893") # (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic $ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
$ldap.SessionOptions.ProtocolVersion = 3 $ldap.SessionOptions.ProtocolVersion = 3
$ldap.SessionOptions.SecureSocketLayer = $false $ldap.SessionOptions.SecureSocketLayer = $false
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=lmxopcua,dc=local","admin123") $cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
$ldap.Bind($cred) $ldap.Bind($cred)
"Bind OK" "Bind OK"
``` ```
@@ -209,17 +241,32 @@ $ldap.Bind($cred)
Or via `ldapsearch` if you have OpenLDAP CLI tools: Or via `ldapsearch` if you have OpenLDAP CLI tools:
```bash ```bash
ldapsearch -x -H ldap://localhost:3893 \ ldapsearch -x -H ldap://10.100.0.35:3893 \
-D "cn=admin,dc=lmxopcua,dc=local" -w admin123 \ -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
-b "dc=lmxopcua,dc=local" "(uid=admin)" -b "dc=zb,dc=local" "(uid=multi-role)"
``` ```
The response should list `admin`'s entry with `memberOf` populated for The response should list `multi-role`'s entry with `memberOf` including
all five role groups — plus `GwAdmin` once the gateway-specific group `ou=GwAdmin,ou=groups,dc=zb,dc=local`.
is provisioned.
## Service management ## Service management
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
>
> **Active (shared) management:**
> ```bash
> ssh 10.100.0.35
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose ps # check container status
> docker compose up -d --force-recreate # apply config.toml changes
> docker compose logs -f # tail logs
> ```
**RETIRED — per-box NSSM commands (rollback reference):**
```powershell ```powershell
# Status / start / stop / restart # Status / start / stop / restart
nssm status GLAuth nssm status GLAuth
@@ -253,12 +300,12 @@ applies to mxaccessgw verbatim. Keys that change:
| Field | GLAuth dev value | AD production value | | Field | GLAuth dev value | AD production value |
|---|---|---| |---|---|---|
| `Server` | `localhost` | a domain controller FQDN, or the domain itself | | `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement | | `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
| `UseTls` | `false` | `true` | | `UseTls` | `false` | `true` |
| `AllowInsecureLdap` | `true` | `false` | | `AllowInsecureLdap` | `true` | `false` |
| `SearchBase` | `dc=lmxopcua,dc=local` | `DC=corp,DC=example,DC=com` | | `SearchBase` | `dc=zb,dc=local` | `DC=corp,DC=example,DC=com` |
| `ServiceAccountDn` | `cn=serviceaccount,dc=lmxopcua,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` | | `ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) | | `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) | | `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
@@ -269,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
## Security notes for production ## Security notes for production
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is - **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
unencrypted on disk; anyone with read access to `C:\publish\glauth\` `scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
can SHA256-rainbow-table the entries. Treat the dev creds as `10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
throwaway. Production LDAP is Active Directory. Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
- The 3-fail / 10-minute lockout is per source IP, not per user — a - The 3-fail / 10-minute lockout is per source IP, not per user — a
shared NAT can lock out a whole office. Tunable in `[behaviors]`. shared NAT can lock out a whole office. Tunable in `[behaviors]`.
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the - LDAPS isn't enabled in dev; binding sends passwords cleartext on the
wire. Fine for `localhost`, never expose port 3893 off-box without wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
enabling TLS first. expose port 3893 externally without enabling TLS first.
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
</packageSources>
<!-- nuget.org serves everything; the Gitea feed serves only the ZB.MOM.WW.* shared libs.
Credentials are NOT committed: they are provided per-developer at the user level. -->
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="dohertj2-gitea">
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
<package pattern="ZB.MOM.WW.Telemetry" />
<package pattern="ZB.MOM.WW.Telemetry.*" />
<package pattern="ZB.MOM.WW.Configuration" />
<package pattern="ZB.MOM.WW.Auth" />
<package pattern="ZB.MOM.WW.Auth.*" />
<package pattern="ZB.MOM.WW.Audit" />
<package pattern="ZB.MOM.WW.Theme" />
</packageSource>
</packageSourceMapping>
</configuration>
+312
View File
@@ -0,0 +1,312 @@
#Requires -Version 7
<#
.SYNOPSIS
Packs all MxAccessGateway clients into a single dist/ directory.
.DESCRIPTION
Runs each language client's native packaging command:
.NET -> dotnet pack (NuGet)
Python -> python -m build (sdist + wheel)
Rust -> cargo package (.crate)
Java -> gradle assemble + jars (jar + sources + javadoc + pom)
Go -> skipped; use scripts/tag-go-module.ps1
All artifacts land in -OutputDir (default: dist/).
With -Publish, each language pushes its package to the internal Gitea
feed. Requires GITEA_USERNAME and GITEA_TOKEN env vars.
.PARAMETER OutputDir
Where to drop the packed artifacts. Default: ./dist
.PARAMETER Languages
Subset of languages to pack. Default: all five.
Values: dotnet, python, rust, java, go
.PARAMETER Publish
After packing, upload to Gitea feeds. Requires:
GITEA_USERNAME
GITEA_TOKEN
Will refuse to publish if either is missing.
.PARAMETER SkipTests
Skip per-language regression tests before packing. Default: false.
.EXAMPLE
pwsh scripts/pack-clients.ps1
pwsh scripts/pack-clients.ps1 -Languages dotnet,python
pwsh scripts/pack-clients.ps1 -Publish
#>
[CmdletBinding()]
param(
[string]$OutputDir = (Join-Path $PSScriptRoot '..' 'dist'),
[string[]]$Languages = @('dotnet', 'python', 'rust', 'java', 'go'),
[switch]$Publish,
[switch]$SkipTests
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Normalize comma-separated strings that shells may pass as a single element.
$validLanguages = @('dotnet', 'python', 'rust', 'java', 'go')
$Languages = @($Languages | ForEach-Object { $_ -split ',' } | ForEach-Object {
$_.Trim().ToLowerInvariant()
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
foreach ($lang in $Languages) {
if ($validLanguages -notcontains $lang) {
throw "Unsupported language '$lang'. Supported values: $($validLanguages -join ', ')."
}
}
if ($Languages.Count -eq 0) {
throw "At least one language is required. Supported values: $($validLanguages -join ', ')."
}
# Resolve absolute output dir
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir | Out-Null
}
if ($Publish) {
if ([string]::IsNullOrEmpty($env:GITEA_USERNAME)) {
throw 'Publish requires GITEA_USERNAME env var.'
}
if ([string]::IsNullOrEmpty($env:GITEA_TOKEN)) {
throw 'Publish requires GITEA_TOKEN env var.'
}
}
$GiteaNugetFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json'
$GiteaPypiFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/pypi'
$JavaHome = '/Users/dohertj2/.local/jdks/jdk-21.0.11+10/Contents/Home'
function Write-Header {
param([string]$Text)
Write-Host ''
Write-Host '=== ' -NoNewline -ForegroundColor Cyan
Write-Host $Text -ForegroundColor Cyan
}
# -------- .NET --------
function Invoke-PackDotnet {
Write-Header '.NET'
if (-not $SkipTests) {
Write-Host 'Running .NET client tests...'
$testProject = Join-Path $RepoRoot 'clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj'
& dotnet test $testProject --no-restore
if ($LASTEXITCODE -ne 0) { throw '.NET tests failed.' }
}
Write-Host 'Packing ZB.MOM.WW.MxGateway.Contracts...'
& dotnet pack (Join-Path $RepoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj') `
-c Release -o $OutputDir
if ($LASTEXITCODE -ne 0) { throw '.NET Contracts pack failed.' }
Write-Host 'Packing ZB.MOM.WW.MxGateway.Client...'
& dotnet pack (Join-Path $RepoRoot 'clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj') `
-c Release -o $OutputDir
if ($LASTEXITCODE -ne 0) { throw '.NET Client pack failed.' }
Write-Host "Packed .NET artifacts -> $OutputDir" -ForegroundColor Green
if ($Publish) {
Write-Host 'Publishing .NET packages to Gitea...' -ForegroundColor Yellow
Get-ChildItem $OutputDir -Filter 'ZB.MOM.WW.MxGateway.*.nupkg' | ForEach-Object {
& dotnet nuget push $_.FullName --source $GiteaNugetFeed --api-key $env:GITEA_TOKEN
if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for '$($_.Name)'." }
}
}
}
# -------- Python --------
function Invoke-PackPython {
Write-Header 'Python'
# Use a persistent venv in /tmp so repeated runs skip reinstall.
$Venv = '/tmp/mxgw-py'
if (-not (Test-Path "$Venv/bin/python")) {
Write-Host "Creating Python venv at $Venv..."
& python3 -m venv $Venv
if ($LASTEXITCODE -ne 0) { throw 'python3 -m venv failed.' }
& "$Venv/bin/pip" install --quiet --upgrade pip
& "$Venv/bin/pip" install --quiet build twine
& "$Venv/bin/pip" install --quiet -e (Join-Path $RepoRoot 'clients/python[dev]')
}
if (-not $SkipTests) {
Write-Host 'Running Python tests...'
Push-Location (Join-Path $RepoRoot 'clients/python')
try {
& "$Venv/bin/python" -m pytest -q
if ($LASTEXITCODE -ne 0) { throw 'Python tests failed.' }
} finally { Pop-Location }
}
Write-Host 'Building Python sdist + wheel...'
& "$Venv/bin/python" -m build (Join-Path $RepoRoot 'clients/python') --outdir $OutputDir
if ($LASTEXITCODE -ne 0) { throw 'Python build failed.' }
Write-Host "Packed Python artifacts -> $OutputDir" -ForegroundColor Green
if ($Publish) {
Write-Host 'Publishing Python distribution to Gitea...' -ForegroundColor Yellow
$wheels = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.whl')
$sdists = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.tar.gz')
$files = ($wheels + $sdists) | ForEach-Object { $_.FullName }
& "$Venv/bin/python" -m twine upload `
--repository-url $GiteaPypiFeed `
-u $env:GITEA_USERNAME `
-p $env:GITEA_TOKEN `
@files
if ($LASTEXITCODE -ne 0) { throw 'twine upload failed.' }
}
}
# -------- Rust --------
function Invoke-PackRust {
Write-Header 'Rust'
$rustDir = Join-Path $RepoRoot 'clients/rust'
Push-Location $rustDir
try {
if (-not $SkipTests) {
Write-Host 'Running Rust tests...'
& cargo test --workspace
if ($LASTEXITCODE -ne 0) { throw 'Rust tests failed.' }
}
Write-Host 'Running cargo package...'
& cargo package --no-verify
if ($LASTEXITCODE -ne 0) { throw 'cargo package failed.' }
$packageDir = Join-Path $rustDir 'target/package'
$crates = @(Get-ChildItem $packageDir -Filter '*.crate')
if ($crates.Count -eq 0) {
throw 'cargo package produced no .crate files.'
}
foreach ($crate in $crates) {
Copy-Item $crate.FullName -Destination $OutputDir -Force
Write-Host " Copied $($crate.Name)"
}
} finally { Pop-Location }
Write-Host "Packed Rust artifacts -> $OutputDir" -ForegroundColor Green
if ($Publish) {
Write-Host 'Publishing Rust crate to Gitea...' -ForegroundColor Yellow
Push-Location (Join-Path $RepoRoot 'clients/rust')
try {
& cargo publish --no-verify --registry dohertj2-gitea
if ($LASTEXITCODE -ne 0) { throw 'cargo publish failed.' }
} finally { Pop-Location }
}
}
# -------- Java --------
function Invoke-PackJava {
Write-Header 'Java'
$env:JAVA_HOME = $JavaHome
$javaDir = Join-Path $RepoRoot 'clients/java'
Push-Location $javaDir
try {
if (-not $SkipTests) {
Write-Host 'Running Java tests...'
& gradle ':zb-mom-ww-mxgateway-client:test' --no-daemon
if ($LASTEXITCODE -ne 0) { throw 'Java tests failed.' }
}
Write-Host 'Assembling Java jars + pom...'
& gradle `
':zb-mom-ww-mxgateway-client:assemble' `
':zb-mom-ww-mxgateway-client:sourcesJar' `
':zb-mom-ww-mxgateway-client:javadocJar' `
':zb-mom-ww-mxgateway-client:generatePomFileForMavenPublication' `
--no-daemon
if ($LASTEXITCODE -ne 0) { throw 'Java assemble failed.' }
$libsDir = Join-Path $javaDir 'zb-mom-ww-mxgateway-client/build/libs'
$jars = @(Get-ChildItem $libsDir -Filter 'zb-mom-ww-mxgateway-client-*.jar')
if ($jars.Count -eq 0) {
throw "No jars found under '$libsDir'."
}
foreach ($jar in $jars) {
Copy-Item $jar.FullName -Destination $OutputDir -Force
Write-Host " Copied $($jar.Name)"
}
$pomSrc = Join-Path $javaDir 'zb-mom-ww-mxgateway-client/build/publications/maven/pom-default.xml'
if (Test-Path $pomSrc) {
# Derive the version from the jar filename (e.g. zb-mom-ww-mxgateway-client-0.1.0.jar).
$versionJar = $jars | Where-Object { $_.Name -notmatch '-(sources|javadoc)\.jar$' } | Select-Object -First 1
$version = if ($versionJar) {
[System.IO.Path]::GetFileNameWithoutExtension($versionJar.Name) -replace '^zb-mom-ww-mxgateway-client-', ''
} else {
'0.1.0'
}
$pomDest = Join-Path $OutputDir "zb-mom-ww-mxgateway-client-$version.pom"
Copy-Item $pomSrc -Destination $pomDest -Force
Write-Host " Copied pom -> $([System.IO.Path]::GetFileName($pomDest))"
} else {
Write-Warning "POM not found at '$pomSrc'; skipping."
}
} finally { Pop-Location }
Write-Host "Packed Java artifacts -> $OutputDir" -ForegroundColor Green
if ($Publish) {
Write-Host 'Publishing Java artifacts to Gitea Maven feed...' -ForegroundColor Yellow
Push-Location $javaDir
try {
& gradle ':zb-mom-ww-mxgateway-client:publish' --no-daemon
if ($LASTEXITCODE -ne 0) { throw 'gradle publish failed.' }
} finally { Pop-Location }
}
}
# -------- Go --------
function Invoke-PackGo {
Write-Header 'Go'
Write-Host 'Go modules are released by git-tagging — no artifact to pack.' -ForegroundColor Yellow
Write-Host 'To publish a Go release, run:' -ForegroundColor Yellow
Write-Host ' pwsh scripts/tag-go-module.ps1 -Version v0.1.0 -Push' -ForegroundColor Yellow
Write-Host '(skipping)' -ForegroundColor DarkGray
}
# -------- Dispatch --------
$wanted = @{}
foreach ($lang in $Languages) { $wanted[$lang.ToLower()] = $true }
if ($wanted.ContainsKey('dotnet')) { Invoke-PackDotnet }
if ($wanted.ContainsKey('python')) { Invoke-PackPython }
if ($wanted.ContainsKey('rust')) { Invoke-PackRust }
if ($wanted.ContainsKey('java')) { Invoke-PackJava }
if ($wanted.ContainsKey('go')) { Invoke-PackGo }
# -------- Summary --------
Write-Header 'Summary'
$artifacts = @(Get-ChildItem $OutputDir)
if ($artifacts.Count -eq 0) {
Write-Host ' (no artifacts)' -ForegroundColor DarkGray
} else {
foreach ($a in $artifacts) {
Write-Host (' {0,10} {1}' -f $a.Length, $a.Name)
}
}
Write-Host ''
Write-Host "All artifacts in: $OutputDir" -ForegroundColor Green
+62
View File
@@ -0,0 +1,62 @@
#Requires -Version 7
<#
.SYNOPSIS
Tags a release of the Go MxAccessGateway client module.
.DESCRIPTION
Go modules in monorepo subdirectories use prefixed tags
("clients/go/v0.1.0") so `go get <module>@v0.1.0` resolves correctly.
This script validates the version, creates the prefixed tag at HEAD,
and (optionally) pushes it.
.PARAMETER Version
Semver tag without the prefix, e.g. "v0.1.0".
.PARAMETER Push
When set, pushes the tag to origin after creation.
.EXAMPLE
pwsh scripts/tag-go-module.ps1 -Version v0.1.0
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Version,
[switch]$Push
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($Version -notmatch '^v\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$') {
throw "Version '$Version' must match semver vX.Y.Z (optionally with -prerelease suffix)."
}
$tag = "clients/go/$Version"
Write-Host "Creating Go-module tag: $tag" -ForegroundColor Cyan
# Verify we're on a clean checkout — refuse to tag with uncommitted changes.
$status = (git status --porcelain) -join "`n"
if ($status -and -not ($status -match '^\?\?')) {
throw "Working tree has tracked changes. Commit or stash before tagging."
}
# Verify the tag doesn't already exist.
$existing = git tag --list $tag
if ($existing) {
throw "Tag '$tag' already exists. Use a new version."
}
git tag -a $tag -m "Go client release $Version"
Write-Host "Created tag: $tag" -ForegroundColor Green
if ($Push) {
git push origin $tag
Write-Host "Pushed tag to origin." -ForegroundColor Green
} else {
Write-Host "Tag not pushed. To publish, run: git push origin $tag" -ForegroundColor Yellow
}
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.MxGateway.Contracts;
/// </summary> /// </summary>
public static class GatewayContractInfo public static class GatewayContractInfo
{ {
/// <summary>Protocol version advertised to clients in <c>OpenSessionReply</c>.</summary>
public const uint GatewayProtocolVersion = 3; public const uint GatewayProtocolVersion = 3;
/// <summary>Protocol version used to validate <c>WorkerEnvelope</c> framing on the gateway-worker pipe.</summary>
public const uint WorkerProtocolVersion = 1; public const uint WorkerProtocolVersion = 1;
/// <summary>Default backend name identifying the MXAccess worker process type.</summary>
public const string DefaultBackendName = "mxaccess-worker"; public const string DefaultBackendName = "mxaccess-worker";
/// <summary> /// <summary>
File diff suppressed because it is too large Load Diff
@@ -315,6 +315,14 @@ message SubscribeBulkCommand {
repeated string tag_addresses = 2; repeated string tag_addresses = 2;
} }
// Provider selection / current provider for the alarm feed. UNSPECIFIED on a
// SubscribeAlarmsCommand means auto: alarmmgr primary with subtag fallback.
enum AlarmProviderMode {
ALARM_PROVIDER_MODE_UNSPECIFIED = 0;
ALARM_PROVIDER_MODE_ALARMMGR = 1;
ALARM_PROVIDER_MODE_SUBTAG = 2;
}
// Subscribe the worker's alarm consumer to an AVEVA alarm provider. // Subscribe the worker's alarm consumer to an AVEVA alarm provider.
// Subscription expression follows the canonical // Subscription expression follows the canonical
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The // `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
@@ -323,6 +331,12 @@ message SubscribeBulkCommand {
// SubscribeAlarms to reconfigure). // SubscribeAlarms to reconfigure).
message SubscribeAlarmsCommand { message SubscribeAlarmsCommand {
string subscription_expression = 1; string subscription_expression = 1;
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
AlarmProviderMode forced_mode = 2;
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
repeated AlarmSubtagTarget watch_list = 3;
AlarmFailoverConfig failover = 4;
} }
// Tear down the worker's alarm consumer. No-op if no subscription is // Tear down the worker's alarm consumer. No-op if no subscription is
@@ -330,6 +344,23 @@ message SubscribeAlarmsCommand {
message UnsubscribeAlarmsCommand { message UnsubscribeAlarmsCommand {
} }
// One alarm attribute the subtag fallback consumer advises. Addresses are full
// MXAccess item references the worker passes straight to AddItem.
message AlarmSubtagTarget {
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
string source_object_reference = 2; // e.g. "Tank01"
string active_subtag = 3; // item address of the in-alarm boolean
string acked_subtag = 4; // item address of the acknowledged boolean
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
string priority_subtag = 6; // optional severity source; empty if absent
}
message AlarmFailoverConfig {
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
}
// Acknowledge a single alarm by its GUID. Operator identity fields are // Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log. // recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's // The reply's hresult / native_status surfaces AVEVA's
@@ -684,6 +715,7 @@ message MxEvent {
OperationCompleteEvent operation_complete = 22; OperationCompleteEvent operation_complete = 22;
OnBufferedDataChangeEvent on_buffered_data_change = 23; OnBufferedDataChangeEvent on_buffered_data_change = 23;
OnAlarmTransitionEvent on_alarm_transition = 24; OnAlarmTransitionEvent on_alarm_transition = 24;
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = 25;
} }
} }
@@ -694,6 +726,7 @@ enum MxEventFamily {
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3; MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4; MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5; MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = 6;
} }
message OnDataChangeEvent { message OnDataChangeEvent {
@@ -768,6 +801,20 @@ message OnAlarmTransitionEvent {
// Limit/threshold value that triggered the transition for limit alarms. // Limit/threshold value that triggered the transition for limit alarms.
// Optional; populated for AnalogLimitAlarm-family transitions. // Optional; populated for AnalogLimitAlarm-family transitions.
MxValue limit_value = 13; MxValue limit_value = 13;
// True when this transition came from the subtag-monitoring fallback rather
// than the native alarmmgr provider synthesized from data changes, reduced
// fidelity (synthetic GUID, no native raise time).
bool degraded = 14;
// Which provider produced this transition.
AlarmProviderMode source_provider = 15;
}
message OnAlarmProviderModeChangedEvent {
AlarmProviderMode mode = 1;
string reason = 2;
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
google.protobuf.Timestamp at = 4;
} }
enum AlarmTransitionKind { enum AlarmTransitionKind {
@@ -800,6 +847,8 @@ message ActiveAlarmSnapshot {
string operator_comment = 11; string operator_comment = 11;
MxValue current_value = 12; MxValue current_value = 12;
MxValue limit_value = 13; MxValue limit_value = 13;
bool degraded = 14;
AlarmProviderMode source_provider = 15;
} }
enum AlarmConditionState { enum AlarmConditionState {
@@ -866,9 +915,19 @@ message AlarmFeedMessage {
bool snapshot_complete = 2; bool snapshot_complete = 2;
// A live alarm state change (raise / acknowledge / clear). // A live alarm state change (raise / acknowledge / clear).
OnAlarmTransitionEvent transition = 3; OnAlarmTransitionEvent transition = 3;
// Provider-mode status. Emitted once on stream open and again on every
// failover/failback so late joiners learn the current mode immediately.
AlarmProviderStatus provider_status = 4;
} }
} }
message AlarmProviderStatus {
AlarmProviderMode mode = 1;
bool degraded = 2; // true whenever mode == SUBTAG
string reason = 3; // human-readable switch reason
google.protobuf.Timestamp since = 4;
}
message MxStatusProxy { message MxStatusProxy {
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct // Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
// (a 16-bit signed value in the COM struct, widened to int32 on the // (a 16-bit signed value in the COM struct, widened to int32 on the
@@ -4,6 +4,24 @@
<TargetFrameworks>net10.0;net48</TargetFrameworks> <TargetFrameworks>net10.0;net48</TargetFrameworks>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Contracts</PackageId>
<Version>0.1.0</Version>
<Authors>Joseph Doherty</Authors>
<Company>ZB MOM WW</Company>
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
<Description>Protobuf contracts and gRPC stubs for the MxAccessGateway service. Multi-targets net10.0 and net48.</Description>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
<PackageTags>mxaccess;mxgateway;grpc;contracts;protobuf</PackageTags>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Remove="Generated\**\*.cs" /> <Compile Remove="Generated\**\*.cs" />
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" /> <Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
@@ -1,8 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
namespace ZB.MOM.WW.MxGateway.IntegrationTests; namespace ZB.MOM.WW.MxGateway.IntegrationTests;
@@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a // IntegrationTests-023: DashboardAuthenticator builds the principal with a
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded // ClaimTypes.Role claim resolved from the LDAP groups via the
// GroupToRole map (GwAdmin -> Admin) means the admin principal must // DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
// carry Role=Admin alongside the raw LDAP-group claim. A regression in // means the admin principal must carry Role=Admin alongside the raw LDAP-group
// MapGroupsToRoles (returning an empty list, missing the RDN fallback) // claim. A regression in the group→role mapping would fail this assertion.
// would silently pass without this assertion.
Assert.Contains(result.Principal.Claims, claim => Assert.Contains(result.Principal.Claims, claim =>
claim.Type == ClaimTypes.Role claim.Type == ClaimTypes.Role
&& claim.Value == DashboardRoles.Admin); && claim.Value == DashboardRoles.Admin);
@@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{ {
// Exercises the LdapException branch: the user exists and the service // Exercises the user-bind-failure branch: the user exists and the service
// account search succeeds, but the candidate bind is rejected. // account search succeeds, but the candidate bind is rejected.
const string wrongPassword = "definitely-not-the-admin-password"; const string wrongPassword = "definitely-not-the-admin-password";
DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticator authenticator = CreateAuthenticator();
@@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails() public async Task AuthenticateAsync_UnknownUsername_Fails()
{ {
// Exercises the `candidate is null` branch: the service-account search // Exercises the user-not-found branch: the service-account search returns no
// returns no entry, so no candidate bind is attempted. // entry, so no candidate bind is attempted.
DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticator authenticator = CreateAuthenticator();
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
@@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{ {
// Exercises the connect-failure path: a closed loopback port produces a // Exercises the connect-failure path: a closed loopback port produces a
// connection error that DashboardAuthenticator must absorb into a Fail // connection error that the shared LdapAuthService must absorb into a Fail
// result rather than propagating an exception to the dashboard. // result rather than propagating an exception to the dashboard.
DashboardAuthenticator authenticator = new( DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
Options.Create(new GatewayOptions {
{ // 1 is a reserved port number that no LDAP server listens on.
Ldap = new LdapOptions Port = 1,
{ });
// 1 is a reserved port number that no LDAP server listens on.
Port = 1,
},
}),
NullLogger<DashboardAuthenticator>.Instance);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin", "admin",
@@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests
Assert.Null(result.Principal); Assert.Null(result.Principal);
} }
private static DashboardAuthenticator CreateAuthenticator() private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
{ {
return new DashboardAuthenticator( GatewayOptions gatewayOptions = new()
Options.Create(new GatewayOptions {
Dashboard = new DashboardOptions
{ {
Dashboard = new DashboardOptions GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ["GwAdmin"] = DashboardRoles.Admin,
{
["GwAdmin"] = DashboardRoles.Admin,
},
}, },
}), },
};
return new DashboardAuthenticator(
new LdapAuthService(ldapOptions),
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
NullLogger<DashboardAuthenticator>.Instance); NullLogger<DashboardAuthenticator>.Instance);
} }
/// <summary>
/// Builds the shared library <see cref="LibraryLdapOptions"/> from the gateway's
/// default LDAP settings so the live tests exercise the same seeded directory the
/// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev).
/// </summary>
private static LibraryLdapOptions LibraryOptions()
{
ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new();
return new LibraryLdapOptions
{
Enabled = gateway.Enabled,
Server = gateway.Server,
Port = gateway.Port,
Transport = gateway.Transport,
AllowInsecure = gateway.AllowInsecure,
SearchBase = gateway.SearchBase,
ServiceAccountDn = gateway.ServiceAccountDn,
ServiceAccountPassword = gateway.ServiceAccountPassword,
UserNameAttribute = gateway.UserNameAttribute,
DisplayNameAttribute = gateway.DisplayNameAttribute,
GroupAttribute = gateway.GroupAttribute,
};
}
} }
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// <summary>
/// Well-known <c>reason</c> strings carried on the alarm feed's
/// <c>AlarmProviderStatus</c> message. Shared between the producer
/// (<see cref="GatewayAlarmMonitor" />) and consumers (e.g. the dashboard
/// provider badge) so the two cannot drift on a magic string.
/// </summary>
public static class AlarmProviderReasons
{
/// <summary>
/// Reason set when the monitor starts in subtag mode because
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c> — a
/// deliberate configuration, not a runtime failover. Lets the dashboard
/// distinguish a forced subtag mode from an unexpected degraded failover.
/// </summary>
public const string ForcedSubtag = "Forced subtag mode (configuration)";
}
@@ -0,0 +1,178 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// <summary>
/// Default <see cref="IAlarmWatchListResolver"/>. Merges Galaxy Repository
/// alarm-attribute discovery with the configured include/exclude overrides
/// and composes the per-attribute subtag item addresses from the configured
/// subtag names.
/// </summary>
// NOTE: The exact subtag names and the canonical AlarmFullReference shape
// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the
// Task 17 live smoke test. The config Subtags block exists precisely so these
// names are not hard-coded here. The {area} is the alarm object's REAL Galaxy
// area discovered via gobject.area_gobject_id (the alarm group the native
// alarmmgr emits), giving exact reference parity with wnwrap. The configured
// Discovery.Area/DefaultArea is only the fallback for explicit IncludeAttributes
// entries, which carry no discovered area.
public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
{
private const string ProviderLiteral = "Galaxy";
private const string DefaultActiveSubtag = "InAlarm";
private const string DefaultAckedSubtag = "Acked";
private readonly IGalaxyRepository _repository;
private readonly ILogger<AlarmWatchListResolver> _logger;
/// <summary>Initializes the watch-list resolver.</summary>
/// <param name="repository">Galaxy Repository used for alarm-attribute discovery.</param>
/// <param name="logger">Diagnostic logger.</param>
public AlarmWatchListResolver(
IGalaxyRepository repository,
ILogger<AlarmWatchListResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
// Config fallback area used only for explicit IncludeAttributes entries (which
// carry no discovered area): discovery area, else the default area (may be empty).
string configFallbackArea = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area;
// 1. Build the ordered, de-duplicated attribute reference set.
// Each entry carries the reference, the source-object reference, and the
// per-entry area used to compose the canonical reference. GR rows contribute
// the object's real Galaxy area; config includes contribute the config
// fallback area (Discovery.Area else DefaultArea).
List<(string Reference, string SourceObject, string Area)> ordered = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
if (discovery.UseGalaxyRepository)
{
List<GalaxyAlarmAttributeRow> rows;
try
{
rows = await _repository.GetAlarmAttributesAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Discovery being unavailable must not crash the resolver: log and
// continue with an empty discovery set. The caller decides what to
// do with the (possibly config-only) result.
_logger.LogWarning(
ex,
"Galaxy Repository alarm-attribute discovery failed; continuing with configuration-only watch-list.");
rows = [];
}
foreach (GalaxyAlarmAttributeRow row in rows)
{
if (string.IsNullOrEmpty(row.FullTagReference) || !seen.Add(row.FullTagReference))
{
continue;
}
ordered.Add((row.FullTagReference, row.SourceObjectReference, row.Area));
}
}
foreach (string include in discovery.IncludeAttributes)
{
if (string.IsNullOrEmpty(include) || !seen.Add(include))
{
continue;
}
ordered.Add((include, DeriveSourceObject(include), configFallbackArea));
}
// Remove excluded references (case-insensitive), but only when GR discovery
// is active. ExcludeAttributes is documented as "Ignored when
// UseGalaxyRepository is false" (AlarmDiscoveryOptions.ExcludeAttributes).
// Whitespace-only entries are skipped, consistent with the include guard above.
if (discovery.UseGalaxyRepository)
{
HashSet<string> excluded = new(
discovery.ExcludeAttributes.Where(e => !string.IsNullOrWhiteSpace(e)),
StringComparer.OrdinalIgnoreCase);
if (excluded.Count > 0)
{
ordered.RemoveAll(e => excluded.Contains(e.Reference));
}
}
// 2. Resolve subtag names with safe fallbacks.
string active = string.IsNullOrEmpty(options.Fallback.Subtags.Active)
? DefaultActiveSubtag
: options.Fallback.Subtags.Active;
string acked = string.IsNullOrEmpty(options.Fallback.Subtags.Acked)
? DefaultAckedSubtag
: options.Fallback.Subtags.Acked;
string priority = options.Fallback.Subtags.Priority;
string ackComment = options.Fallback.Subtags.AckComment;
// 3. Compose one target per reference, using the PER-ENTRY area: the GR row's
// real Galaxy area (matching the alarmmgr group), or the config fallback for
// explicit includes.
List<AlarmSubtagTarget> targets = new(ordered.Count);
foreach ((string reference, string sourceObject, string area) in ordered)
{
targets.Add(new AlarmSubtagTarget
{
AlarmFullReference = ComposeFullReference(area, reference),
SourceObjectReference = sourceObject,
ActiveSubtag = $"{reference}.{active}",
AckedSubtag = $"{reference}.{acked}",
PrioritySubtag = string.IsNullOrEmpty(priority) ? string.Empty : $"{reference}.{priority}",
AckCommentSubtag = string.IsNullOrEmpty(ackComment) ? string.Empty : $"{reference}.{ackComment}",
});
}
// 4. Report the resolved count; warn when subtag mode was expected to cover
// something (GR enabled, or explicit includes were configured) but resolved
// to nothing. Only emit the Debug line when there is at least one target,
// to avoid a confusing "0 target(s)" noise line.
if (targets.Count == 0 && (discovery.UseGalaxyRepository || discovery.IncludeAttributes.Length > 0))
{
_logger.LogWarning(
"Alarm subtag watch-list resolved to zero targets; subtag-polling fallback will cover no alarms.");
}
else if (targets.Count > 0)
{
_logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count);
}
return targets;
}
/// <summary>
/// Derives the source-object reference for a configuration entry: the
/// substring before the first '.', or the whole string when there is no dot.
/// </summary>
private static string DeriveSourceObject(string reference)
{
int dot = reference.IndexOf('.', StringComparison.Ordinal);
return dot < 0 ? reference : reference[..dot];
}
/// <summary>
/// Composes the canonical alarm full reference: <c>Galaxy!{area}.{reference}</c>
/// when an area is set, otherwise <c>Galaxy!{reference}</c>.
/// </summary>
private static string ComposeFullReference(string area, string reference) =>
string.IsNullOrEmpty(area)
? $"{ProviderLiteral}!{reference}"
: $"{ProviderLiteral}!{area}.{reference}";
}
@@ -13,6 +13,7 @@ public static class AlarmsServiceCollectionExtensions
/// <returns>The service collection for chaining.</returns> /// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services) public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
{ {
services.AddSingleton<IAlarmWatchListResolver, AlarmWatchListResolver>();
services.AddSingleton<GatewayAlarmMonitor>(); services.AddSingleton<GatewayAlarmMonitor>();
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>()); services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>()); services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
@@ -1,7 +1,9 @@
using System.Threading.Channels; using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Alarms; namespace ZB.MOM.WW.MxGateway.Server.Alarms;
@@ -23,6 +25,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2); private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly IAlarmWatchListResolver _watchListResolver;
private readonly GatewayMetrics _metrics;
private readonly AlarmsOptions _options; private readonly AlarmsOptions _options;
private readonly ILogger<GatewayAlarmMonitor> _logger; private readonly ILogger<GatewayAlarmMonitor> _logger;
@@ -30,20 +34,34 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal); private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
private readonly List<Subscriber> _subscribers = []; private readonly List<Subscriber> _subscribers = [];
// Current provider status (mode + degraded + reason + since), guarded by _sync.
// Initialized to the alarm-manager, not-degraded baseline so a late joiner sees
// a sensible status even before any OnAlarmProviderModeChanged event arrives.
private AlarmProviderMode _providerMode = AlarmProviderMode.Alarmmgr;
private bool _providerDegraded;
private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled; private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
private volatile string? _lastError; private volatile string? _lastError;
private GatewaySession? _session; private GatewaySession? _session;
/// <summary>Initializes the gateway alarm monitor.</summary> /// <summary>Initializes the gateway alarm monitor.</summary>
/// <param name="sessionManager">Gateway session manager.</param> /// <param name="sessionManager">Gateway session manager.</param>
/// <param name="watchListResolver">Resolver for the subtag-fallback watch-list.</param>
/// <param name="metrics">Gateway metrics sink.</param>
/// <param name="options">Gateway options carrying the alarm configuration.</param> /// <param name="options">Gateway options carrying the alarm configuration.</param>
/// <param name="logger">Diagnostic logger.</param> /// <param name="logger">Diagnostic logger.</param>
public GatewayAlarmMonitor( public GatewayAlarmMonitor(
ISessionManager sessionManager, ISessionManager sessionManager,
IAlarmWatchListResolver watchListResolver,
GatewayMetrics metrics,
IOptions<GatewayOptions> options, IOptions<GatewayOptions> options,
ILogger<GatewayAlarmMonitor> logger) ILogger<GatewayAlarmMonitor> logger)
{ {
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
_watchListResolver = watchListResolver ?? throw new ArgumentNullException(nameof(watchListResolver));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms; _options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
@@ -139,6 +157,49 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken) private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
{ {
_state = GatewayAlarmMonitorState.Starting; _state = GatewayAlarmMonitorState.Starting;
// Derive the lifecycle baseline from the configured forced mode so a
// ForceSubtag / ForceAlarmManager start advertises the correct mode even
// though no OnAlarmProviderModeChanged event is raised in those modes
// (only Auto/failover produces that event). ForceSubtag starts degraded.
AlarmProviderMode initialMode;
bool initialDegraded;
string initialReason;
switch (MapForcedMode(_options.Fallback.Mode))
{
case AlarmProviderMode.Subtag:
initialMode = AlarmProviderMode.Subtag;
initialDegraded = true;
initialReason = AlarmProviderReasons.ForcedSubtag;
break;
case AlarmProviderMode.Alarmmgr:
initialMode = AlarmProviderMode.Alarmmgr;
initialDegraded = false;
initialReason = string.Empty;
break;
default:
// Unspecified (Auto): the failover consumer starts on the
// alarm-manager primary and only degrades to subtag on failure.
initialMode = AlarmProviderMode.Alarmmgr;
initialDegraded = false;
initialReason = string.Empty;
break;
}
lock (_sync)
{
// Re-baseline the provider status for this lifecycle so a restarted
// monitor advertises the configured mode until told otherwise.
_providerMode = initialMode;
_providerDegraded = initialDegraded;
_providerReason = initialReason;
_providerSince = DateTimeOffset.UtcNow;
}
// Align the observable gauge with the lifecycle baseline without recording
// a switch — the gauge was 0 (unknown) from construction until now.
_metrics.SetAlarmProviderMode(ModeToInt(initialMode));
GatewaySession session = await _sessionManager.OpenSessionAsync( GatewaySession session = await _sessionManager.OpenSessionAsync(
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null), new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
MonitorClientName, MonitorClientName,
@@ -173,6 +234,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
{ {
ApplyTransition(mxEvent.OnAlarmTransition); ApplyTransition(mxEvent.OnAlarmTransition);
} }
else if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmProviderModeChanged }
&& mxEvent.OnAlarmProviderModeChanged is not null)
{
await ApplyProviderModeChangeAsync(
session.SessionId,
mxEvent.OnAlarmProviderModeChanged,
linked.Token)
.ConfigureAwait(false);
}
} }
} }
finally finally
@@ -209,6 +279,33 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken) private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
{ {
IReadOnlyList<AlarmSubtagTarget> watchList = await _watchListResolver
.ResolveAsync(_options, cancellationToken)
.ConfigureAwait(false);
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
_logger.LogInformation(
"Alarm subscribe: forcedMode={ForcedMode} configMode={ConfigMode} watchList={WatchListCount}.",
forcedMode, _options.Fallback.Mode, watchList.Count);
// When the forced mode is Unspecified (the "Auto" case) and the resolved
// watch-list is empty — the common alarmmgr-only deployment — the command
// is identical-in-effect to the historical SubscribeAlarms (wnwrap only):
// the worker builds the wnwrap consumer and no subtag watch-list.
SubscribeAlarmsCommand command = new()
{
SubscriptionExpression = subscription,
ForcedMode = forcedMode,
Failover = new AlarmFailoverConfig
{
ConsecutiveFailureThreshold = _options.Fallback.ConsecutiveFailureThreshold,
FailbackProbeIntervalSeconds = _options.Fallback.FailbackProbeIntervalSeconds,
FailbackStableProbes = _options.Fallback.FailbackStableProbes,
},
};
command.WatchList.AddRange(watchList);
WorkerCommandReply reply = await _sessionManager.InvokeAsync( WorkerCommandReply reply = await _sessionManager.InvokeAsync(
sessionId, sessionId,
new WorkerCommand new WorkerCommand
@@ -216,7 +313,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Command = new MxCommand Command = new MxCommand
{ {
Kind = MxCommandKind.SubscribeAlarms, Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription }, SubscribeAlarms = command,
}, },
}, },
cancellationToken) cancellationToken)
@@ -310,6 +407,104 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
} }
} }
// Handles the worker's provider-mode-change event: updates the stored provider
// status, broadcasts it to every subscriber (provider status is global, not
// alarm-scoped), records the switch metric, and forces a cache reconcile so the
// active-alarm set reflects whatever the new mode reports.
private async Task ApplyProviderModeChangeAsync(
string sessionId,
OnAlarmProviderModeChangedEvent change,
CancellationToken cancellationToken)
{
AlarmProviderMode toMode = change.Mode;
string reason = change.Reason ?? string.Empty;
AlarmProviderStatus status;
int fromModeInt;
lock (_sync)
{
fromModeInt = ModeToInt(_providerMode);
_providerMode = toMode;
_providerDegraded = toMode == AlarmProviderMode.Subtag;
_providerReason = reason;
_providerSince = DateTimeOffset.UtcNow;
status = BuildProviderStatus();
BroadcastToAll(new AlarmFeedMessage { ProviderStatus = status });
}
AlarmProviderSwitchReason switchReason = toMode switch
{
AlarmProviderMode.Subtag => AlarmProviderSwitchReason.Failover,
AlarmProviderMode.Alarmmgr => AlarmProviderSwitchReason.Failback,
_ => AlarmProviderSwitchReason.Unknown,
};
_metrics.AlarmProviderSwitched(fromModeInt, ModeToInt(toMode), switchReason);
_logger.LogInformation(
"Alarm provider mode changed to {Mode} (degraded={Degraded}): {Reason}",
toMode,
status.Degraded,
reason);
try
{
// Intentionally awaited OUTSIDE _sync: ReconcileAsync acquires _sync itself,
// so holding it across the await here would deadlock. Subscribers therefore
// see the ProviderStatus push (above) slightly before the cache is re-seeded
// by the reconcile — an accepted brief inconsistency.
await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogDebug(
exception,
"Reconcile after alarm provider mode change failed; keeping the current cache.");
}
}
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
private AlarmProviderStatus BuildProviderStatus()
{
return new AlarmProviderStatus
{
Mode = _providerMode,
Degraded = _providerDegraded,
Reason = _providerReason,
Since = Timestamp.FromDateTimeOffset(_providerSince),
};
}
// Maps the configured fallback mode string to the forced provider mode the
// worker honours. Case-insensitive; anything other than the two force values
// (including the default "Auto") yields Unspecified ("let the worker decide").
private static AlarmProviderMode MapForcedMode(string? mode)
{
if (string.Equals(mode, "ForceAlarmManager", StringComparison.OrdinalIgnoreCase))
{
return AlarmProviderMode.Alarmmgr;
}
if (string.Equals(mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase))
{
return AlarmProviderMode.Subtag;
}
return AlarmProviderMode.Unspecified;
}
// Maps the provider-mode enum to the integer the metric expects
// (alarmmgr=1, subtag=2, unknown/unspecified=0).
private static int ModeToInt(AlarmProviderMode mode) => mode switch
{
AlarmProviderMode.Alarmmgr => 1,
AlarmProviderMode.Subtag => 2,
_ => 0,
};
// Replaces the cache with the worker's authoritative snapshot, broadcasting // Replaces the cache with the worker's authoritative snapshot, broadcasting
// a synthetic transition for any alarm the live stream missed. // a synthetic transition for any alarm the live stream missed.
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots) private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
@@ -374,6 +569,23 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
} }
} }
// Caller holds _sync. Pushes a feed message to every subscriber regardless of
// its alarm-filter prefix. Used for provider-status messages, which are global
// rather than scoped to a single alarm reference.
private void BroadcastToAll(AlarmFeedMessage message)
{
for (int index = _subscribers.Count - 1; index >= 0; index--)
{
Subscriber subscriber = _subscribers[index];
if (!subscriber.Channel.Writer.TryWrite(message))
{
subscriber.Channel.Writer.TryComplete(new InvalidOperationException(
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot."));
_subscribers.RemoveAt(index);
}
}
}
private void ClearCache() private void ClearCache()
{ {
lock (_sync) lock (_sync)
@@ -398,11 +610,14 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Subscriber subscriber = new(channel, prefix); Subscriber subscriber = new(channel, prefix);
ActiveAlarmSnapshot[] snapshot; ActiveAlarmSnapshot[] snapshot;
AlarmProviderStatus providerStatus;
lock (_sync) lock (_sync)
{ {
// Register before snapshotting under the same lock so no transition // Register before snapshotting under the same lock so neither a
// can slip between the snapshot and the live stream. // transition nor a provider-mode change can slip between the snapshot
// and the live stream.
_subscribers.Add(subscriber); _subscribers.Add(subscriber);
providerStatus = BuildProviderStatus();
snapshot = _alarms.Values snapshot = _alarms.Values
.Where(alarm => prefix.Length == 0 .Where(alarm => prefix.Length == 0
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal)) || alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
@@ -412,6 +627,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
try try
{ {
// Emit the current provider status first so a late joiner immediately
// learns the mode (and whether the feed is degraded) before any alarms.
yield return new AlarmFeedMessage { ProviderStatus = providerStatus };
foreach (ActiveAlarmSnapshot alarm in snapshot) foreach (ActiveAlarmSnapshot alarm in snapshot)
{ {
yield return new AlarmFeedMessage { ActiveAlarm = alarm }; yield return new AlarmFeedMessage { ActiveAlarm = alarm };
@@ -624,6 +843,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Description = transition.Description, Description = transition.Description,
OperatorUser = transition.OperatorUser, OperatorUser = transition.OperatorUser,
OperatorComment = transition.OperatorComment, OperatorComment = transition.OperatorComment,
Degraded = transition.Degraded,
SourceProvider = transition.SourceProvider,
}; };
if (transition.OriginalRaiseTimestamp is not null) if (transition.OriginalRaiseTimestamp is not null)
{ {
@@ -660,6 +881,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Description = snapshot.Description, Description = snapshot.Description,
OperatorUser = snapshot.OperatorUser, OperatorUser = snapshot.OperatorUser,
OperatorComment = snapshot.OperatorComment, OperatorComment = snapshot.OperatorComment,
Degraded = snapshot.Degraded,
SourceProvider = snapshot.SourceProvider,
}; };
if (snapshot.OriginalRaiseTimestamp is not null) if (snapshot.OriginalRaiseTimestamp is not null)
{ {
@@ -0,0 +1,30 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// <summary>
/// Resolves the subtag watch-list the gateway sends to the worker when the
/// central alarm monitor operates in subtag-polling fallback mode. Merges
/// Galaxy Repository alarm-attribute discovery with the configured
/// include/exclude overrides and composes the per-attribute subtag item
/// addresses from the configured subtag names.
/// </summary>
public interface IAlarmWatchListResolver
{
/// <summary>
/// Builds the subtag watch-list for the supplied alarm configuration.
/// </summary>
/// <param name="options">Alarm configuration carrying discovery and subtag-name settings.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>
/// The resolved <see cref="AlarmSubtagTarget"/> watch-list, possibly empty.
/// Discovery being unavailable never throws — it yields an empty (or
/// config-only) list and the caller decides what to do with it. Cancellation
/// is the one exception: a triggered <paramref name="cancellationToken"/>
/// still propagates an <see cref="OperationCanceledException"/>.
/// </returns>
Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,131 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Controls how the central alarm monitor selects between the MXAccess
/// alarm-manager subscription and the subtag-polling fallback, and
/// governs the failure-detection thresholds used when switching.
/// </summary>
public sealed class AlarmFallbackOptions
{
/// <summary>
/// Selects the operating mode for the alarm-manager ↔ subtag fallback
/// mechanism. Accepted values (case-insensitive):
/// <list type="bullet">
/// <item><c>Auto</c> — use the alarm manager; switch to subtag polling
/// automatically when <see cref="ConsecutiveFailureThreshold"/> failures
/// are detected, and probe for failback.</item>
/// <item><c>ForceAlarmManager</c> — always use the alarm manager;
/// never fall back.</item>
/// <item><c>ForceSubtag</c> — always use subtag polling;
/// never try the alarm manager.</item>
/// </list>
/// Default is <c>Auto</c>.
/// </summary>
public string Mode { get; init; } = "Auto";
/// <summary>
/// Number of consecutive alarm-manager failures before the monitor
/// switches to subtag-polling fallback. Must be at least 1. Default 3.
/// </summary>
public int ConsecutiveFailureThreshold { get; init; } = 3;
/// <summary>
/// How often (in seconds) the monitor sends a probe to the alarm manager
/// while operating in subtag-polling fallback mode, to detect recovery.
/// Must be at least 1. Default 30.
/// </summary>
public int FailbackProbeIntervalSeconds { get; init; } = 30;
/// <summary>
/// Number of consecutive successful probes required before the monitor
/// considers the alarm manager recovered and switches back. Must be at
/// least 1. Default 3.
/// </summary>
public int FailbackStableProbes { get; init; } = 3;
/// <summary>
/// Controls how the monitor discovers the set of objects to poll when
/// operating in subtag-polling fallback mode.
/// </summary>
public AlarmDiscoveryOptions Discovery { get; init; } = new();
/// <summary>
/// Configures the subtag names the monitor reads when polling alarm state
/// in subtag-fallback mode.
/// </summary>
public AlarmSubtagNameOptions Subtags { get; init; } = new();
}
/// <summary>
/// Governs how the alarm monitor discovers objects to include in subtag-polling
/// fallback mode. Either the Galaxy Repository query (when
/// <see cref="UseGalaxyRepository"/> is <c>true</c>) or an explicit
/// <see cref="IncludeAttributes"/> list must be supplied when
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c>.
/// </summary>
public sealed class AlarmDiscoveryOptions
{
/// <summary>
/// When <c>true</c> the monitor queries the Galaxy Repository SQL database
/// to enumerate alarm objects for the configured area. Default <c>true</c>.
/// </summary>
public bool UseGalaxyRepository { get; init; } = true;
/// <summary>
/// Galaxy area to scope the Repository query to. When empty the monitor
/// falls back to <see cref="AlarmsOptions.DefaultArea"/>. Ignored when
/// <see cref="UseGalaxyRepository"/> is <c>false</c>.
/// </summary>
public string Area { get; init; } = string.Empty;
/// <summary>
/// Explicit list of MXAccess attribute paths to include in subtag polling,
/// supplementing (or replacing, when <see cref="UseGalaxyRepository"/> is
/// <c>false</c>) the Repository-derived list. Default empty.
/// </summary>
public string[] IncludeAttributes { get; init; } = Array.Empty<string>();
/// <summary>
/// Attribute paths to exclude from the Repository-derived poll list.
/// Ignored when <see cref="UseGalaxyRepository"/> is <c>false</c>.
/// Default empty.
/// </summary>
public string[] ExcludeAttributes { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Configures the subtag names read by the alarm monitor when it is operating
/// in subtag-polling fallback mode. Names are matched against MXAccess item
/// handles; validation against the live MXAccess attribute list occurs at
/// runtime, not at startup.
/// Defaults are the confirmed AVEVA <c>AlarmExtension</c> primitive field names,
/// verified against the live ZB Galaxy <c>attribute_definition</c> rows.
/// </summary>
public sealed class AlarmSubtagNameOptions
{
/// <summary>
/// Subtag name for the in-alarm boolean. Confirmed AVEVA <c>AlarmExtension</c>
/// field name. Default <c>InAlarm</c>.
/// </summary>
public string Active { get; init; } = "InAlarm";
/// <summary>
/// Subtag name for the acknowledged boolean. Confirmed AVEVA <c>AlarmExtension</c>
/// field name. Default <c>Acked</c>.
/// </summary>
public string Acked { get; init; } = "Acked";
/// <summary>
/// Subtag name for the acknowledgement comment write target. Writing this subtag
/// performs the acknowledge in AVEVA. Confirmed AVEVA <c>AlarmExtension</c>
/// field name. When empty the ack-comment write path is disabled.
/// Default <c>AckMsg</c>.
/// </summary>
public string AckComment { get; init; } = "AckMsg";
/// <summary>
/// Subtag name for the alarm priority / severity. Confirmed AVEVA
/// <c>AlarmExtension</c> field name. Default <c>Priority</c>.
/// </summary>
public string Priority { get; init; } = "Priority";
}
@@ -45,4 +45,12 @@ public sealed class AlarmsOptions
/// the monitor floors it at 5 seconds. /// the monitor floors it at 5 seconds.
/// </summary> /// </summary>
public int ReconcileIntervalSeconds { get; init; } = 30; public int ReconcileIntervalSeconds { get; init; } = 30;
/// <summary>
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag
/// names. Defaults (Mode = "Auto") preserve behaviour when the section is
/// omitted from configuration.
/// </summary>
public AlarmFallbackOptions Fallback { get; init; } = new();
} }
@@ -21,6 +21,17 @@ public sealed class DashboardOptions
/// </summary> /// </summary>
public bool RequireHttpsCookie { get; init; } = true; public bool RequireHttpsCookie { get; init; } = true;
/// <summary>
/// Dashboard auth cookie name. When null/blank (the default) the canonical
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) to give a distinct name to a
/// gateway that shares a hostname with another gateway instance — browser cookies are scoped
/// by host+path but NOT by port, so two instances on the same host would otherwise clobber
/// each other's dashboard session under a shared cookie name. Changing this signs out
/// existing dashboard sessions on next deploy.
/// </summary>
public string? CookieName { get; init; }
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary> /// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000; public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
bool Enabled, bool Enabled,
string Server, string Server,
int Port, int Port,
bool UseTls, string Transport,
bool AllowInsecureLdap, bool AllowInsecure,
string SearchBase, string SearchBase,
string ServiceAccountDn, string ServiceAccountDn,
string ServiceAccountPassword, string ServiceAccountPassword,
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
Enabled: value.Ldap.Enabled, Enabled: value.Ldap.Enabled,
Server: value.Ldap.Server, Server: value.Ldap.Server,
Port: value.Ldap.Port, Port: value.Ldap.Port,
UseTls: value.Ldap.UseTls, Transport: value.Ldap.Transport.ToString(),
AllowInsecureLdap: value.Ldap.AllowInsecureLdap, AllowInsecure: value.Ldap.AllowInsecure,
SearchBase: value.Ldap.SearchBase, SearchBase: value.Ldap.SearchBase,
ServiceAccountDn: value.Ldap.ServiceAccountDn, ServiceAccountDn: value.Ldap.ServiceAccountDn,
ServiceAccountPassword: RedactedValue, ServiceAccountPassword: RedactedValue,
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
{ {
/// <summary>Registers gateway configuration services in the dependency injection container.</summary> /// <summary>Registers gateway configuration services in the dependency injection container.</summary>
/// <param name="services">The service collection.</param> /// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration to bind gateway options from.</param>
/// <returns>The service collection for chaining.</returns> /// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services) public static IServiceCollection AddGatewayConfiguration(
this IServiceCollection services, IConfiguration configuration)
{ {
services services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
.AddOptions<GatewayOptions>() configuration, GatewayOptions.SectionName);
.BindConfiguration(GatewayOptions.SectionName)
.ValidateOnStart();
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>(); services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
return services; return services;
@@ -43,4 +43,7 @@ public sealed class GatewayOptions
/// behaviour (alarms disabled). /// behaviour (alarms disabled).
/// </summary> /// </summary>
public AlarmsOptions Alarms { get; init; } = new(); public AlarmsOptions Alarms { get; init; } = new();
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
public TlsOptions Tls { get; init; } = new();
} }
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions> public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
{ {
private const int MinimumMaxMessageBytes = 1024; private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
@@ -11,32 +12,26 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
/// <summary> /// <summary>
/// Validates gateway configuration options. /// Validates gateway configuration options.
/// </summary> /// </summary>
/// <param name="name">Options name.</param> /// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">Gateway options to validate.</param> /// <param name="options">Gateway options to validate.</param>
/// <returns>Validation result.</returns> protected override void Validate(ValidationBuilder builder, GatewayOptions options)
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
{ {
List<string> failures = []; ValidateAuthentication(options.Authentication, builder);
ValidateLdap(options.Ldap, builder);
ValidateAuthentication(options.Authentication, failures); ValidateWorker(options.Worker, builder);
ValidateLdap(options.Ldap, failures); ValidateSessions(options.Sessions, builder);
ValidateWorker(options.Worker, failures); ValidateEvents(options.Events, builder);
ValidateSessions(options.Sessions, failures); ValidateDashboard(options.Dashboard, builder);
ValidateEvents(options.Events, failures); ValidateProtocol(options.Protocol, builder);
ValidateDashboard(options.Dashboard, failures); ValidateAlarms(options.Alarms, builder);
ValidateProtocol(options.Protocol, failures); ValidateTls(options.Tls, builder);
ValidateAlarms(options.Alarms, failures);
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
} }
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures) private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
{ {
if (!Enum.IsDefined(options.Mode)) if (!Enum.IsDefined(options.Mode))
{ {
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode."); builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
return; return;
} }
@@ -45,67 +40,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfBlank( AddIfBlank(
options.SqlitePath, options.SqlitePath,
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.", "MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
failures); builder);
AddIfInvalidPath( AddIfInvalidPath(
options.SqlitePath, options.SqlitePath,
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.", "MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
failures); builder);
AddIfBlank( AddIfBlank(
options.PepperSecretName, options.PepperSecretName,
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.", "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
failures); builder);
} }
} }
private static void ValidateLdap(LdapOptions options, List<string> failures) private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
{ {
if (!options.Enabled) if (!options.Enabled)
{ {
return; return;
} }
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures); AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures); AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
AddIfBlank( AddIfBlank(
options.ServiceAccountDn, options.ServiceAccountDn,
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.", "MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.ServiceAccountPassword, options.ServiceAccountPassword,
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.", "MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.UserNameAttribute, options.UserNameAttribute,
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.DisplayNameAttribute, options.DisplayNameAttribute,
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.GroupAttribute, options.GroupAttribute,
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
failures); builder);
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures); builder.Port(options.Port, "MxGateway:Ldap:Port");
if (!options.UseTls && !options.AllowInsecureLdap) if (options.Transport == LdapTransport.None && !options.AllowInsecure)
{ {
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false."); builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
} }
} }
private static void ValidateWorker(WorkerOptions options, List<string> failures) private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
{ {
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures); AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
AddIfInvalidPath( AddIfInvalidPath(
options.ExecutablePath, options.ExecutablePath,
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.", "MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
failures); builder);
if (!string.IsNullOrWhiteSpace(options.ExecutablePath) if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase)) && !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
{ {
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file."); builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
} }
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory)) if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
@@ -113,94 +108,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfInvalidPath( AddIfInvalidPath(
options.WorkingDirectory, options.WorkingDirectory,
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.", "MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
failures); builder);
} }
if (!Enum.IsDefined(options.RequiredArchitecture)) if (!Enum.IsDefined(options.RequiredArchitecture))
{ {
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture."); builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
} }
AddIfNotPositive( AddIfNotPositive(
options.StartupTimeoutSeconds, options.StartupTimeoutSeconds,
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.", "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.StartupProbeRetryAttempts, options.StartupProbeRetryAttempts,
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.StartupProbeRetryDelayMilliseconds, options.StartupProbeRetryDelayMilliseconds,
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.", "MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.PipeConnectAttemptTimeoutMilliseconds, options.PipeConnectAttemptTimeoutMilliseconds,
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.ShutdownTimeoutSeconds, options.ShutdownTimeoutSeconds,
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.", "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.HeartbeatIntervalSeconds, options.HeartbeatIntervalSeconds,
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.", "MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.HeartbeatGraceSeconds, options.HeartbeatGraceSeconds,
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.", "MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
failures); builder);
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds) if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
{ {
failures.Add( builder.Add(
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds."); "MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
} }
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
{ {
failures.Add( builder.Add(
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); $"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
} }
} }
private static void ValidateSessions(SessionOptions options, List<string> failures) private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
{ {
AddIfNotPositive( AddIfNotPositive(
options.DefaultCommandTimeoutSeconds, options.DefaultCommandTimeoutSeconds,
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.", "MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures); AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
AddIfNotPositive( AddIfNotPositive(
options.MaxPendingCommandsPerSession, options.MaxPendingCommandsPerSession,
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.", "MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.DefaultLeaseSeconds, options.DefaultLeaseSeconds,
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.LeaseSweepIntervalSeconds, options.LeaseSweepIntervalSeconds,
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
failures); builder);
if (options.AllowMultipleEventSubscribers) if (options.AllowMultipleEventSubscribers)
{ {
failures.Add( builder.Add(
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented."); "MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
} }
} }
private static void ValidateEvents(EventOptions options, List<string> failures) private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
{ {
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures); AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
if (!Enum.IsDefined(options.BackpressurePolicy)) if (!Enum.IsDefined(options.BackpressurePolicy))
{ {
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy."); builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
} }
} }
private static void ValidateDashboard(DashboardOptions options, List<string> failures) private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
{ {
// GroupToRole shape is validated even when the dashboard is disabled so // GroupToRole shape is validated even when the dashboard is disabled so
// misconfiguration surfaces at startup; emptiness is allowed, with the // misconfiguration surfaces at startup; emptiness is allowed, with the
@@ -211,13 +206,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
{ {
if (string.IsNullOrWhiteSpace(entry.Key)) if (string.IsNullOrWhiteSpace(entry.Key))
{ {
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank."); builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
} }
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal) if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal)) && !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
{ {
failures.Add( builder.Add(
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'."); $"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
} }
} }
@@ -225,18 +220,20 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfNotPositive( AddIfNotPositive(
options.SnapshotIntervalMilliseconds, options.SnapshotIntervalMilliseconds,
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
failures); builder);
AddIfNegative( AddIfNegative(
options.RecentFaultLimit, options.RecentFaultLimit,
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.", "MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
failures); builder);
AddIfNegative( AddIfNegative(
options.RecentSessionLimit, options.RecentSessionLimit,
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.", "MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
failures); builder);
} }
private static void ValidateAlarms(AlarmsOptions options, List<string> failures) private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
{ {
if (!options.Enabled) if (!options.Enabled)
{ {
@@ -250,58 +247,119 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression) if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
&& string.IsNullOrWhiteSpace(options.DefaultArea)) && string.IsNullOrWhiteSpace(options.DefaultArea))
{ {
failures.Add( builder.Add(
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true."); "MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
} }
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression) if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal)) && !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
{ {
failures.Add( builder.Add(
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape)."); @"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
} }
ValidateAlarmFallback(options.Fallback, builder);
}
private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
{
// Validate Mode is one of the recognised values (case-insensitive).
bool modeValid = Array.Exists(
ValidAlarmFallbackModes,
m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));
if (!modeValid)
{
builder.Add(
$"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
}
// ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
if (modeValid
&& string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
&& !fallback.Discovery.UseGalaxyRepository
&& fallback.Discovery.IncludeAttributes.Length == 0)
{
builder.Add(
"MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
}
// Floor validation: numeric thresholds must be at least 1.
AddIfNotPositive(
fallback.ConsecutiveFailureThreshold,
"MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
builder);
AddIfNotPositive(
fallback.FailbackProbeIntervalSeconds,
"MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
builder);
AddIfNotPositive(
fallback.FailbackStableProbes,
"MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
builder);
}
private const int MinimumCertValidityYears = 1;
private const int MaximumCertValidityYears = 100;
private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
{
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
{
builder.Add(
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
}
// The default is non-blank, so this only catches an explicitly-blanked path.
AddIfBlank(
options.SelfSignedCertPath,
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
builder);
AddIfInvalidPath(
options.SelfSignedCertPath,
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
builder);
foreach (string dns in options.AdditionalDnsNames)
{
if (string.IsNullOrWhiteSpace(dns))
{
builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
}
}
} }
private static void ValidateProtocol(ProtocolOptions options, List<string> failures) private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
{ {
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
{ {
failures.Add( builder.Add(
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}."); $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
} }
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
{ {
failures.Add( builder.Add(
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); $"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
} }
} }
private static void AddIfBlank(string? value, string message, List<string> failures) private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
{ {
if (string.IsNullOrWhiteSpace(value)) builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
{
failures.Add(message);
}
} }
private static void AddIfNotPositive(int value, string message, List<string> failures) private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
{ {
if (value <= 0) builder.RequireThat(value > 0, message);
{
failures.Add(message);
}
} }
private static void AddIfNegative(int value, string message, List<string> failures) private static void AddIfNegative(int value, string message, ValidationBuilder builder)
{ {
if (value < 0) builder.RequireThat(value >= 0, message);
{
failures.Add(message);
}
} }
private static void AddIfInvalidPath(string? value, string message, List<string> failures) private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
{ {
@@ -314,15 +372,19 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
} }
catch (ArgumentException) catch (ArgumentException)
{ {
failures.Add(message); builder.Add(message);
} }
catch (NotSupportedException) catch (NotSupportedException)
{ {
failures.Add(message); builder.Add(message);
} }
catch (PathTooLongException) catch (PathTooLongException)
{ {
failures.Add(message); builder.Add(message);
}
catch (IOException)
{
builder.Add(message);
} }
} }
} }
@@ -1,5 +1,32 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Gateway-side view of the <c>MxGateway:Ldap</c> section. This is a SHADOW of the
/// shared <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> type and is NOT
/// used to perform LDAP authentication at runtime — runtime bind/search is done by the
/// shared <c>ZB.MOM.WW.Auth.Ldap</c> provider, whose options are bound directly from the
/// same <c>MxGateway:Ldap</c> section by <c>AddZbLdapAuth</c> (see
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardServiceCollectionExtensions"/>).
/// <para>
/// This shadow exists for three things only: (1) startup validation via
/// <see cref="GatewayOptionsValidator"/>; (2) the redacted effective-config display
/// (<see cref="EffectiveLdapConfiguration"/> / <see cref="GatewayConfigurationProvider"/>);
/// and (3) it is the single home of the gateway's dev/default LDAP values, which the
/// integration live-test helper copies onto the shared options.
/// </para>
/// <para>
/// Review C2 — DRIFT WARNING: this class MUST stay field-compatible with the shared
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> so the one config section
/// binds cleanly onto both. The two are intentionally NOT merged because their defaults
/// differ on purpose: this shadow ships dev-friendly defaults (plaintext localhost,
/// <c>AllowInsecure=true</c>, populated <c>SearchBase</c>/<c>ServiceAccount*</c>), whereas
/// the shared type is secure-by-default (<c>Transport=Ldaps</c>, <c>AllowInsecure=false</c>,
/// empty DN fields). If you add/rename/remove a field on the shared type, mirror it here
/// (and in the validator + effective-config) so the section keeps binding to both.
/// </para>
/// </summary>
public sealed class LdapOptions public sealed class LdapOptions
{ {
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary> /// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
@@ -11,17 +38,24 @@ public sealed class LdapOptions
/// <summary>Gets the LDAP server port.</summary> /// <summary>Gets the LDAP server port.</summary>
public int Port { get; init; } = 3893; public int Port { get; init; } = 3893;
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary> /// <summary>
public bool UseTls { get; init; } /// Gets the transport/TLS mode for the LDAP connection. Replaces the former
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
/// a plaintext connection to TLS. Matches the shared
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
/// </summary>
public LdapTransport Transport { get; init; } = LdapTransport.None;
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary> /// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
public bool AllowInsecureLdap { get; init; } = true; public bool AllowInsecure { get; init; } = true;
/// <summary>Gets the LDAP search base distinguished name.</summary> /// <summary>Gets the LDAP search base distinguished name.</summary>
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; public string SearchBase { get; init; } = "dc=zb,dc=local";
/// <summary>Gets the service account distinguished name.</summary> /// <summary>Gets the service account distinguished name.</summary>
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local"; public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
/// <summary>Gets the service account password.</summary> /// <summary>Gets the service account password.</summary>
public string ServiceAccountPassword { get; init; } = "serviceaccount123"; public string ServiceAccountPassword { get; init; } = "serviceaccount123";
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Options controlling the gateway's self-signed certificate auto-generation.
/// Only consulted when a Kestrel HTTPS endpoint is configured without its own
/// certificate; plaintext deployments never trigger generation.
/// </summary>
public sealed class TlsOptions
{
/// <summary>Path to the persisted self-signed PFX. Reused across restarts.</summary>
public string SelfSignedCertPath { get; init; } =
@"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";
/// <summary>Lifetime in years of a freshly generated certificate.</summary>
public int ValidityYears { get; init; } = 10;
/// <summary>Extra DNS SANs to embed (e.g. a load-balancer name).</summary>
public IReadOnlyList<string> AdditionalDnsNames { get; init; } = [];
/// <summary>Regenerate the persisted certificate when it has expired.</summary>
public bool RegenerateIfExpired { get; init; } = true;
}
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" /> <base href="/" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" /> <ThemeHead />
<link rel="stylesheet" href="/css/site.css" /> <link rel="stylesheet" href="/css/site.css" />
<HeadOutlet @rendermode="InteractiveServer" /> <HeadOutlet @rendermode="InteractiveServer" />
</head> </head>
<body class="dashboard-body"> <body class="dashboard-body">
<Routes @rendermode="InteractiveServer" /> <Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/nav-state.js"></script> <ThemeScripts />
<script src="/_framework/blazor.web.js"></script> <script src="/_framework/blazor.web.js"></script>
</body> </body>
</html> </html>
@@ -0,0 +1,6 @@
@inherits LayoutComponentBase
@* Minimal layout for the login page: no side rail, no brand block. The page
renders its own centred card via the shared kit's <LoginCard>. Mirrors
OtOpcUa AdminUI's LoginLayout. *@
@Body
@@ -1,210 +1,40 @@
@using System.Linq
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject IJSRuntime JS
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;"> @* Thin layout: delegates the side-rail chassis (hamburger, brand, responsive
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS collapse) to the shared ZB.MOM.WW.Theme <ThemeShell>. The nav is reproduced
lives in bootstrap.bundle.min.js (loaded in App.razor). *@ with the kit's NavRailSection / NavRailItem; section expand-state persistence
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start" is owned by the kit's <details> + ThemeScripts (no JS interop here). *@
type="button" <ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
data-bs-toggle="collapse" <Nav>
data-bs-target="#sidebar-collapse" <NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
aria-controls="sidebar-collapse" <NavRailSection Title="Runtime" Key="runtime">
aria-expanded="false" <NavRailItem Href="/sessions" Text="Sessions" />
aria-label="Toggle navigation"> <NavRailItem Href="/workers" Text="Workers" />
&#9776; <NavRailItem Href="/events" Text="Events" />
</button> <NavRailItem Href="/alarms" Text="Alarms" />
</NavRailSection>
<div class="collapse d-lg-block" id="sidebar-collapse"> <NavRailSection Title="Galaxy" Key="galaxy">
<nav class="sidebar d-flex flex-column"> <NavRailItem Href="/galaxy" Text="Repository" />
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a> <NavRailItem Href="/browse" Text="Browse" />
</NavRailSection>
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;"> <NavRailSection Title="Admin" Key="admin">
<ul class="nav flex-column"> <NavRailItem Href="/apikeys" Text="API Keys" />
<li class="nav-item"> <NavRailItem Href="/settings" Text="Settings" />
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink> </NavRailSection>
</li> </Nav>
<RailFooter>
<NavSection Title="Runtime" <AuthorizeView>
Expanded="@_expanded.Contains("runtime")" <Authorized Context="authState">
OnToggle="@(() => ToggleAsync("runtime"))"> <span class="rail-user">@authState.User.Identity?.Name</span>
<li class="nav-item"> <form method="post" action="/logout" data-enhance="false">
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink> <AntiforgeryToken />
</li> <button class="rail-btn" type="submit">Sign Out</button>
<li class="nav-item"> </form>
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink> </Authorized>
</li> <NotAuthorized>
<li class="nav-item"> <a class="rail-btn" href="/login">Sign In</a>
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink> </NotAuthorized>
</li> </AuthorizeView>
<li class="nav-item"> </RailFooter>
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink> <ChildContent>@Body</ChildContent>
</li> </ThemeShell>
</NavSection>
<NavSection Title="Galaxy"
Expanded="@_expanded.Contains("galaxy")"
OnToggle="@(() => ToggleAsync("galaxy"))">
<li class="nav-item">
<NavLink class="nav-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
</li>
</NavSection>
<NavSection Title="Admin"
Expanded="@_expanded.Contains("admin")"
OnToggle="@(() => ToggleAsync("admin"))">
<li class="nav-item">
<NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
</li>
</NavSection>
</ul>
</div>
<AuthorizeView>
<Authorized Context="authState">
<div class="border-top px-3 py-2">
<div class="d-flex justify-content-between align-items-center">
<span class="text-body-secondary small">@authState.User.Identity?.Name</span>
<form method="post" action="/logout" data-enhance="false">
<AntiforgeryToken />
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
</form>
</div>
</div>
</Authorized>
<NotAuthorized>
<div class="border-top px-3 py-2">
<a href="/login" class="btn btn-outline-secondary btn-sm py-0 px-2 w-100">Sign In</a>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
<main class="page flex-grow-1">
@Body
</main>
</div>
@code {
// Sections whose collapsed/expanded state we persist. Acts as the allow-list
// when parsing the cookie so stale or attacker-supplied ids are ignored.
private static readonly string[] SectionIds = { "runtime", "galaxy", "admin" };
// The currently-expanded sections. Populated from the cookie on first
// render; mutated by ToggleAsync and by navigating into a section.
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
// Hydrate from the cookie. Until this completes the sidebar paints
// collapsed, matching the CentralUI behaviour.
string saved;
try
{
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
}
catch (JSDisconnectedException)
{
return;
}
foreach (var id in saved.Split(
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Array.IndexOf(SectionIds, id) >= 0)
{
_expanded.Add(id);
}
}
// The section of the page we loaded on is always expanded.
if (EnsureCurrentSectionExpanded())
{
await PersistAsync();
}
StateHasChanged();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
if (EnsureCurrentSectionExpanded())
{
_ = PersistAsync();
_ = InvokeAsync(StateHasChanged);
}
}
private async Task ToggleAsync(string id)
{
if (!_expanded.Remove(id))
{
_expanded.Add(id);
}
await PersistAsync();
}
// Adds the current page's section to _expanded; returns true if it changed.
private bool EnsureCurrentSectionExpanded()
{
var section = CurrentSection();
return section is not null && _expanded.Add(section);
}
// Maps the current URL's first path segment to a section id, or null for
// sectionless pages (Dashboard, Login).
private string? CurrentSection()
{
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
var firstSegment = relative.Split('?', '#')[0]
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return firstSegment switch
{
"sessions" or "workers" or "events" or "alarms" => "runtime",
"galaxy" or "browse" => "galaxy",
"apikeys" or "settings" => "admin",
_ => null,
};
}
private async Task PersistAsync()
{
try
{
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
}
catch (JSDisconnectedException)
{
// The circuit is gone — nothing to persist to.
}
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}
@@ -1,35 +0,0 @@
@* A collapsible sidebar nav section. The header is a full-width button that
toggles ChildContent visibility. Pattern lifted from ScadaLink CentralUI
(Components/Layout/NavSection.razor) — see [[project-deployed-service]]. *@
<li class="nav-item">
<button type="button"
class="nav-section-toggle"
@onclick="OnToggle"
aria-expanded="@(Expanded ? "true" : "false")">
<span class="chevron" aria-hidden="true">@(Expanded ? "▾" : "▸")</span>
<span>@Title</span>
</button>
</li>
@if (Expanded)
{
@ChildContent
}
@code {
/// <summary>Section label shown in the header (e.g. "Runtime").</summary>
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
/// <summary>Whether the section is expanded — its items rendered.</summary>
[Parameter]
public bool Expanded { get; set; }
/// <summary>Raised when the header button is clicked.</summary>
[Parameter]
public EventCallback OnToggle { get; set; }
/// <summary>The section's nav items, rendered only while expanded.</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,7 +1,10 @@
@page "/alarms" @page "/alarms"
@implements IAsyncDisposable @implements IAsyncDisposable
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
@inject IDashboardLiveDataService LiveData @inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions @inject IOptions<GatewayOptions> GatewayOptions
@inject DashboardHubConnectionFactory HubFactory
<PageTitle>Dashboard Alarms</PageTitle> <PageTitle>Dashboard Alarms</PageTitle>
@@ -10,6 +13,12 @@
<h1>Alarms</h1> <h1>Alarms</h1>
<div class="text-secondary">@HeaderLine()</div> <div class="text-secondary">@HeaderLine()</div>
</div> </div>
<div class="d-flex align-items-center gap-2">
<span class="badge @_providerStatus.BadgeCssClass"
title="@ProviderStatusTitle()">
@_providerStatus.Label
</span>
</div>
</div> </div>
@if (!GatewayOptions.Value.Alarms.Enabled) @if (!GatewayOptions.Value.Alarms.Enabled)
@@ -163,10 +172,44 @@
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private Task? _pollTask; private Task? _pollTask;
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
private HubConnection? _alarmsHub;
/// <inheritdoc /> /// <inheritdoc />
protected override void OnInitialized() protected override void OnInitialized()
{ {
_pollTask = PollLoopAsync(); _pollTask = PollLoopAsync();
_ = AttachAlarmsHubAsync();
}
private string? ProviderStatusTitle()
{
return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason)
? _providerStatus.Reason
: null;
}
private async Task AttachAlarmsHubAsync()
{
_alarmsHub = HubFactory.Create("/hubs/alarms");
_alarmsHub.On<AlarmFeedMessage>(AlarmsHub.AlarmMessage, async message =>
{
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
});
try
{
await _alarmsHub.StartAsync(_cts.Token).ConfigureAwait(false);
}
catch
{
// The badge is best-effort; it stays at the healthy default until
// the hub reconnects and delivers a fresh provider-status message.
}
} }
private string HeaderLine() private string HeaderLine()
@@ -268,6 +311,19 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _cts.CancelAsync(); await _cts.CancelAsync();
if (_alarmsHub is not null)
{
try
{
await _alarmsHub.DisposeAsync();
}
catch
{
// Disposal-time errors are best-effort.
}
}
if (_pollTask is not null) if (_pollTask is not null)
{ {
try try
@@ -0,0 +1,33 @@
@page "/login"
@layout LoginLayout
@using Microsoft.AspNetCore.Authorization
@* Login MUST stay anonymously reachable — [AllowAnonymous] overrides the
RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, so the
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
The card is the shared kit's <LoginCard>: it renders a NATIVE static
<form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
sharing a route. *@
@attribute [AllowAnonymous]
<LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
<AntiforgeryToken />
</LoginCard>
@code {
/// <summary>Original protected URL the operator was bounced from; round-tripped to POST /login.</summary>
[SupplyParameterFromQuery(Name = "returnUrl")]
private string? ReturnUrl { get; set; }
/// <summary>Failure message surfaced by POST /login after a failed authentication.</summary>
[SupplyParameterFromQuery(Name = "error")]
private string? Error { get; set; }
}
@@ -26,7 +26,7 @@ else
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr> <tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr> <tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr> <tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr> <tr><th scope="row">LDAP transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr> <tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr> <tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr> <tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
@@ -1,16 +1,16 @@
<span class="chip @CssClass">@Text</span> @* Thin adapter: maps MxGateway runtime state text → kit StatusPill state.
The bespoke .chip rendering now lives in the kit; only the app's domain
text→state vocabulary remains here. Call sites (<StatusBadge Text="..."/>) unchanged. *@
<StatusPill State="MapState(Text)">@Text</StatusPill>
@code { @code {
[Parameter] [Parameter] public string? Text { get; set; }
public string? Text { get; set; }
private string CssClass => Text switch private static StatusState MapState(string? text) => text switch
{ {
"Ready" or "Healthy" or "Active" => "chip-ok", "Ready" or "Healthy" or "Active" => StatusState.Ok,
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn", "Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
"Stale" or "Degraded" => "chip-warn", or "Stale" or "Degraded" => StatusState.Warn,
"Faulted" or "Unavailable" => "chip-bad", "Faulted" or "Unavailable" => StatusState.Bad,
"Closed" or "Revoked" or "Unknown" => "chip-idle", _ => StatusState.Idle,
_ => "chip-idle"
}; };
} }
@@ -10,4 +10,5 @@
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared @using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization @using ZB.MOM.WW.MxGateway.Server.Security.Authorization
@using ZB.MOM.WW.MxGateway.Server.Workers @using ZB.MOM.WW.MxGateway.Server.Workers
@using ZB.MOM.WW.Theme
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode
@@ -0,0 +1,101 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Dashboard projection of an <see cref="AlarmProviderStatus" /> message
/// carried on the alarm feed. Maps the protobuf provider mode / degraded
/// flag into Bootstrap-only display fields so the Alarms page can render a
/// status badge without touching protobuf types.
/// </summary>
public sealed record DashboardAlarmProviderStatus(
AlarmProviderMode Mode,
bool IsDegraded,
string Label,
string BadgeCssClass,
string Reason,
DateTimeOffset? SinceUtc)
{
/// <summary>Badge label shown when the alarm-manager provider is healthy.</summary>
public const string AlarmManagerLabel = "Alarm Manager";
/// <summary>Badge label shown when the feed has fallen back to subtag monitoring.</summary>
public const string DegradedLabel = "Subtag monitoring (degraded)";
/// <summary>
/// Badge label shown when the feed is in subtag monitoring because it was
/// deliberately configured (<c>Fallback:Mode=ForceSubtag</c>), as opposed
/// to an unexpected failover. A stable, intended state rather than a fault.
/// </summary>
public const string ForcedSubtagLabel = "Subtag monitoring (forced)";
private const string HealthyBadge = "bg-success";
private const string DegradedBadge = "bg-warning text-dark";
// Cyan/info badge: visually distinct from the amber failover-degraded badge —
// forced subtag is an intentional configuration, not an alarm-manager fault.
private const string ForcedSubtagBadge = "bg-info text-dark";
/// <summary>
/// The default status assumed before the first provider-status message
/// arrives: healthy alarm-manager mode.
/// </summary>
public static DashboardAlarmProviderStatus Healthy { get; } = new(
Mode: AlarmProviderMode.Alarmmgr,
IsDegraded: false,
Label: AlarmManagerLabel,
BadgeCssClass: HealthyBadge,
Reason: string.Empty,
SinceUtc: null);
/// <summary>Projects an alarm-feed provider-status payload into a dashboard badge model.</summary>
/// <param name="status">The provider-status payload from an <see cref="AlarmFeedMessage" />.</param>
/// <returns>The projected dashboard status.</returns>
public static DashboardAlarmProviderStatus FromProviderStatus(AlarmProviderStatus status)
{
ArgumentNullException.ThrowIfNull(status);
// Treat the explicit degraded flag and the SUBTAG mode as equivalent;
// the contract sets degraded=true whenever mode == SUBTAG, but guard
// against either being set independently.
bool degraded = status.Degraded || status.Mode == AlarmProviderMode.Subtag;
string reason = status.Reason ?? string.Empty;
// A configured ForceSubtag start carries the well-known forced reason and
// is a deliberate mode, not a failover — render it distinctly so an
// operator isn't alarmed by a "(degraded)" badge for an intended config.
bool forced = degraded
&& status.Mode == AlarmProviderMode.Subtag
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
string label = !degraded ? AlarmManagerLabel : forced ? ForcedSubtagLabel : DegradedLabel;
string badge = !degraded ? HealthyBadge : forced ? ForcedSubtagBadge : DegradedBadge;
return new DashboardAlarmProviderStatus(
Mode: status.Mode,
IsDegraded: degraded,
Label: label,
BadgeCssClass: badge,
Reason: reason,
SinceUtc: status.Since?.ToDateTimeOffset());
}
/// <summary>Projects an alarm-feed message into a dashboard badge model.</summary>
/// <param name="message">An alarm-feed message whose payload is a provider status.</param>
/// <returns>The projected dashboard status.</returns>
/// <exception cref="ArgumentException">The message does not carry a provider-status payload.</exception>
public static DashboardAlarmProviderStatus FromFeed(AlarmFeedMessage message)
{
ArgumentNullException.ThrowIfNull(message);
if (message.PayloadCase != AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
{
throw new ArgumentException(
"Alarm-feed message does not carry a provider-status payload.",
nameof(message));
}
return FromProviderStatus(message.ProviderStatus);
}
}
@@ -1,5 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
@@ -7,12 +12,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyManagementService( public sealed class DashboardApiKeyManagementService(
DashboardApiKeyAuthorization authorization, DashboardApiKeyAuthorization authorization,
ApiKeyAdminCommands adminCommands,
IApiKeyAdminStore adminStore, IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore, IAuditWriter auditWriter,
IApiKeySecretHasher hasher,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
{ {
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable";
/// <summary>Determines whether the user can manage API keys.</summary> /// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param> /// <param name="user">The authenticated user principal.</param>
@@ -42,28 +48,31 @@ public sealed class DashboardApiKeyManagementService(
} }
string keyId = request.KeyId.Trim(); string keyId = request.KeyId.Trim();
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
try try
{ {
await adminStore.CreateAsync( // The shared command set generates the secret, hashes it with the pepper, persists the
new ApiKeyCreateRequest( // record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
KeyId: keyId, // "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter
KeyPrefix: $"mxgw_{keyId}", // adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent
SecretHash: hasher.HashSecret(secret), // (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the
DisplayName: request.DisplayName.Trim(), // dashboard audit vocabulary — both rows land in the canonical audit_event store.
Scopes: request.Scopes, CreateKeyResult created = await adminCommands.CreateKeyAsync(
Constraints: request.Constraints, keyId,
CreatedUtc: DateTimeOffset.UtcNow), request.DisplayName.Trim(),
request.Scopes,
ApiKeyConstraintSerializer.Serialize(request.Constraints),
RemoteAddress(),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false); await WriteDashboardAuditAsync(user, keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey); return DashboardApiKeyManagementResult.Success(
"API key created. Copy the key now; it will not be shown again.",
created.Token);
} }
catch (ApiKeyPepperUnavailableException) catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
{ {
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
} }
@@ -94,18 +103,19 @@ public sealed class DashboardApiKeyManagementService(
} }
string normalizedKeyId = keyId.Trim(); string normalizedKeyId = keyId.Trim();
bool revoked = await adminStore KeyActionResult result = await adminCommands
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken) .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync( await WriteDashboardAuditAsync(
user,
normalizedKeyId, normalizedKeyId,
"dashboard-revoke-key", "dashboard-revoke-key",
revoked ? "revoked" : "not-found-or-already-revoked", result.Succeeded ? "revoked" : "not-found-or-already-revoked",
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return revoked return result.Succeeded
? DashboardApiKeyManagementResult.Success("API key revoked.") ? DashboardApiKeyManagementResult.Success("API key revoked.")
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
} }
@@ -131,27 +141,30 @@ public sealed class DashboardApiKeyManagementService(
} }
string normalizedKeyId = keyId.Trim(); string normalizedKeyId = keyId.Trim();
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(normalizedKeyId, secret);
try try
{ {
bool rotated = await adminStore CreateKeyResult rotated = await adminCommands
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken) .RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync( bool succeeded = rotated.Token is not null;
await WriteDashboardAuditAsync(
user,
normalizedKeyId, normalizedKeyId,
"dashboard-rotate-key", "dashboard-rotate-key",
rotated ? "rotated" : "not-found", succeeded ? "rotated" : "not-found",
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return rotated return succeeded
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey) ? DashboardApiKeyManagementResult.Success(
"API key rotated. Copy the key now; it will not be shown again.",
rotated.Token)
: DashboardApiKeyManagementResult.Fail("API key was not found."); : DashboardApiKeyManagementResult.Fail("API key was not found.");
} }
catch (ApiKeyPepperUnavailableException) catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
{ {
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
} }
@@ -182,7 +195,8 @@ public sealed class DashboardApiKeyManagementService(
.DeleteAsync(normalizedKeyId, cancellationToken) .DeleteAsync(normalizedKeyId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await AppendAuditAsync( await WriteDashboardAuditAsync(
user,
normalizedKeyId, normalizedKeyId,
"dashboard-delete-key", "dashboard-delete-key",
deleted ? "deleted" : "not-found-or-active", deleted ? "deleted" : "not-found-or-active",
@@ -194,22 +208,92 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting."); : DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
} }
private async Task AppendAuditAsync( private string? RemoteAddress() =>
string? keyId, httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
string eventType,
string? details, /// <summary>
/// Resolves the operator's username from the authenticated dashboard principal.
/// </summary>
/// <remarks>
/// The passed <paramref name="user"/> is preferred over the ambient HTTP context because it
/// is already in scope at every call site (the callers gate on <see cref="CanManage"/> using
/// it) and is unambiguous. Falls back to <see cref="IAuditActorAccessor.CurrentActor"/> for
/// defensive coverage, then to <c>"unknown"</c> when neither is available.
/// </remarks>
private static string ResolveOperatorActor(ClaimsPrincipal user)
{
// ZbClaimTypes.Username = "zb:username" — the canonical LDAP login name.
string? username = user.FindFirstValue(ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes.Username);
if (!string.IsNullOrWhiteSpace(username))
{
return username;
}
// Framework fallback: Identity.Name is driven by the nameClaimType on the ClaimsIdentity
// (set to ZbClaimTypes.Name = ClaimTypes.Name by DashboardAuthenticator → display name).
string? identityName = user.Identity?.Name;
if (!string.IsNullOrWhiteSpace(identityName))
{
return identityName;
}
return "unknown";
}
/// <summary>
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
/// </summary>
/// <remarks>
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
/// </remarks>
private async Task WriteDashboardAuditAsync(
ClaimsPrincipal user,
string keyId,
string action,
string? detail,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await auditStore.AppendAsync( AuditEvent auditEvent = new()
new ApiKeyAuditEntry( {
KeyId: keyId, EventId = Guid.NewGuid(),
EventType: eventType, OccurredAtUtc = DateTimeOffset.UtcNow,
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(), Actor = ResolveOperatorActor(user),
Details: details), Action = action,
cancellationToken) Outcome = AuditOutcome.Success,
.ConfigureAwait(false); Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
Target = keyId,
SourceNode = RemoteAddress(),
CorrelationId = ParseCorrelationId(),
DetailsJson = WrapDetail(detail),
};
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
/// </summary>
private Guid? ParseCorrelationId() =>
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
? correlationId
: null;
private static string? WrapDetail(string? detail) =>
detail is null
? null
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request) private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
{ {
string? keyIdValidation = ValidateKeyId(request.KeyId); string? keyIdValidation = ValidateKeyId(request.KeyId);
@@ -248,9 +332,4 @@ public sealed class DashboardApiKeyManagementService(
? null ? null
: "API key id may contain only letters, numbers, periods, and hyphens."; : "API key id may contain only letters, numbers, periods, and hyphens.";
} }
private static string FormatApiKey(string keyId, string secret)
{
return $"mxgw_{keyId}_{secret}";
}
} }
@@ -1,14 +1,26 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text; using ZB.MOM.WW.Auth.Abstractions.Ldap;
using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Authenticates interactive dashboard logins against LDAP. The bind/search
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
/// and never throws — returning the user's display name and LDAP groups on
/// success. This class keeps the dashboard-specific policy: groups are resolved
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator( public sealed class DashboardAuthenticator(
IOptions<GatewayOptions> options, ILdapAuthService ldapAuthService,
IGroupRoleMapper<string> roleMapper,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{ {
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized."; private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
@@ -19,240 +31,72 @@ public sealed class DashboardAuthenticator(
string? password, string? password,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
LdapOptions ldapOptions = options.Value.Ldap; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
DashboardOptions dashboardOptions = options.Value.Dashboard;
if (!ldapOptions.Enabled
|| string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
{ {
return DashboardAuthenticationResult.Fail(GenericFailureMessage); return DashboardAuthenticationResult.Fail(GenericFailureMessage);
} }
string normalizedUsername = username.Trim(); string normalizedUsername = username.Trim();
try // The shared service owns connect/bind/search and the fail-closed contract:
// it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
// its startup validator, and never throws. We only translate its outcome into a
// dashboard principal here.
LdapAuthResult ldapResult = await ldapAuthService
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
.ConfigureAwait(false);
if (!ldapResult.Succeeded)
{ {
using LdapConnection connection = new(); return DashboardAuthenticationResult.Fail(GenericFailureMessage);
connection.SecureSocketLayer = ldapOptions.UseTls;
await Task.Run(
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? candidate = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (candidate is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
await Task.Run(
() => connection.Bind(candidate.Dn, password),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? authenticatedEntry = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (authenticatedEntry is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
?? normalizedUsername;
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
if (roles.Count == 0)
{
logger.LogInformation(
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(
normalizedUsername,
displayName,
groups,
roles));
} }
catch (OperationCanceledException)
{ GroupRoleMapping<string> mapping = await roleMapper
throw; .MapAsync(ldapResult.Groups, cancellationToken)
} .ConfigureAwait(false);
catch (LdapException ex)
IReadOnlyList<string> roles = mapping.Roles;
if (roles.Count == 0)
{ {
// Preserve the long-standing "no roles matched -> login denied" rule.
logger.LogInformation( logger.LogInformation(
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.", "LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
normalizedUsername, ldapResult.Username);
ex.ResultCode);
return DashboardAuthenticationResult.Fail(GenericFailureMessage); return DashboardAuthenticationResult.Fail(GenericFailureMessage);
} }
catch (Exception ex)
{
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage); return DashboardAuthenticationResult.Success(CreatePrincipal(
} ldapResult.Username,
} ldapResult.DisplayName,
ldapResult.Groups,
/// <summary>Escapes special characters in LDAP filter strings.</summary> roles));
/// <param name="value">The string value to escape.</param>
internal static string EscapeLdapFilter(string value)
{
StringBuilder builder = new(value.Length);
foreach (char character in value)
{
builder.Append(character switch
{
'\\' => @"\5c",
'*' => @"\2a",
'(' => @"\28",
')' => @"\29",
'\0' => @"\00",
_ => character.ToString()
});
}
return builder.ToString();
} }
/// <summary> /// <summary>
/// Maps the user's LDAP groups to dashboard roles. A user can pick up /// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
/// multiple roles; Admin and Viewer are the only legal values. Returns
/// an empty list when no group matches (caller rejects the login).
/// </summary> /// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param> /// <param name="username">
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param> /// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
internal static IReadOnlyList<string> MapGroupsToRoles( /// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
IEnumerable<string> groups, /// </param>
IReadOnlyDictionary<string, string> groupToRole) /// <param name="displayName">
{ /// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
if (groupToRole.Count == 0) /// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
{ /// for cross-app consistency.
return []; /// </param>
} /// <param name="groups">
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
HashSet<string> roles = new(StringComparer.Ordinal); /// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
foreach (string group in groups) /// <c>GwAdmin</c>), not raw distinguished names. The shared
{ /// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
string normalizedGroup = group.Trim(); /// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
/// claim carries the short name. This differs from the pre-cutover behaviour,
// Lookup precedence (Server-040): the full literal group string is /// which surfaced the raw <c>memberOf</c> values (full DNs) on the claim; the
// tried first; only if that misses do we fall back to the leading /// claim is informational only (no policy or UI reads its value — authorization
// RDN value (e.g. "GwAdmin" extracted from /// is role-based), so the shape change is non-breaking for dashboard consumers.
// "ou=GwAdmin,ou=groups,..."). The map's comparer is /// </param>
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g. /// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
// "GwAdmin" and "gwadmin" both match.
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
{
roles.Add(mapped);
}
}
return [.. roles];
}
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param>
internal static string ExtractFirstRdnValue(string distinguishedName)
{
int equalsIndex = distinguishedName.IndexOf('=');
if (equalsIndex < 0)
{
return distinguishedName;
}
int valueStart = equalsIndex + 1;
int commaIndex = distinguishedName.IndexOf(',', valueStart);
return commaIndex > valueStart
? distinguishedName[valueStart..commaIndex]
: distinguishedName[valueStart..];
}
private static Task BindServiceAccountAsync(
LdapConnection connection,
LdapOptions ldapOptions,
CancellationToken cancellationToken)
{
return Task.Run(
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
cancellationToken);
}
private static async Task<LdapEntry?> SearchUserAsync(
LdapConnection connection,
LdapOptions ldapOptions,
string username,
CancellationToken cancellationToken)
{
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
ILdapSearchResults results = await Task.Run(
() => connection.Search(
ldapOptions.SearchBase,
LdapConnection.ScopeSub,
filter,
attrs: null,
typesOnly: false),
cancellationToken)
.ConfigureAwait(false);
LdapEntry? entry = null;
while (results.HasMore())
{
LdapEntry next = results.Next();
if (entry is not null)
{
return null;
}
entry = next;
}
return entry;
}
private static string? ReadAttribute(LdapEntry entry, string attributeName)
{
return ReadLdapAttribute(entry, attributeName)?.StringValue;
}
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
{
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
return attribute?.StringValueArray ?? [];
}
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
{
return entry.GetAttribute(attributeName)
?? entry.GetAttribute(attributeName.ToLowerInvariant())
?? entry.GetAttribute(attributeName.ToUpperInvariant());
}
private static ClaimsPrincipal CreatePrincipal( private static ClaimsPrincipal CreatePrincipal(
string username, string username,
string displayName, string displayName,
@@ -261,11 +105,21 @@ public sealed class DashboardAuthenticator(
{ {
List<Claim> claims = List<Claim> claims =
[ [
// Keep NameIdentifier so any existing read-site that uses it continues to work.
new Claim(ClaimTypes.NameIdentifier, username), new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, displayName), // Canonical login-username claim (Task 1.5).
new Claim(ZbClaimTypes.Username, username),
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
new Claim(ZbClaimTypes.Name, displayName),
// Canonical display-name claim for cross-app consistency (Task 1.5).
new Claim(ZbClaimTypes.DisplayName, displayName),
]; ];
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); // ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)].
claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role)));
// Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN.
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
claims.AddRange(groups.Select(group => new Claim( claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType, DashboardAuthenticationDefaults.LdapGroupClaimType,
group))); group)));
@@ -273,8 +127,8 @@ public sealed class DashboardAuthenticator(
ClaimsIdentity claimsIdentity = new( ClaimsIdentity claimsIdentity = new(
claims, claims,
DashboardAuthenticationDefaults.AuthenticationScheme, DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name, ZbClaimTypes.Name,
ClaimTypes.Role); ZbClaimTypes.Role);
return new ClaimsPrincipal(claimsIdentity); return new ClaimsPrincipal(claimsIdentity);
} }
@@ -1,7 +1,6 @@
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.HttpResults;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components; using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
@@ -25,14 +24,19 @@ public static class DashboardEndpointRouteBuilderExtensions
return endpoints; return endpoints;
} }
endpoints.MapGet( // GET /login is served by the [AllowAnonymous] Blazor <Login> component
"/login", // (Components/Pages/Login.razor → @page "/login"), which renders the shared
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery)) // kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
.AllowAnonymous() // RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
.WithName("DashboardLogin"); // so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
//
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
endpoints.MapPost( endpoints.MapPost(
"/login", "/auth/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator)) PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous() .AllowAnonymous()
@@ -92,17 +96,6 @@ public static class DashboardEndpointRouteBuilderExtensions
return endpoints; return endpoints;
} }
private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery)
{
string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
return Task.FromResult(TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
"text/html"));
}
private static async Task<IResult> PostLoginAsync( private static async Task<IResult> PostLoginAsync(
HttpContext httpContext, HttpContext httpContext,
IAntiforgery antiforgery, IAntiforgery antiforgery,
@@ -124,10 +117,13 @@ public static class DashboardEndpointRouteBuilderExtensions
if (!result.Succeeded || result.Principal is null) if (!result.Succeeded || result.Principal is null)
{ {
return TypedResults.Content( // Round-trip the failure back to the anonymous Blazor /login page, carrying
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage), // the (sanitized) returnUrl so a successful retry still lands on the target.
"text/html", string failureMessage = result.FailureMessage
statusCode: StatusCodes.Status401Unauthorized); ?? "The username or password is invalid, or the user is not authorized.";
return Results.Redirect(
$"/login?error={Uri.EscapeDataString(failureMessage)}"
+ $"&returnUrl={Uri.EscapeDataString(returnUrl)}");
} }
await httpContext await httpContext
@@ -158,42 +154,6 @@ public static class DashboardEndpointRouteBuilderExtensions
return Results.LocalRedirect("/login"); return Results.LocalRedirect("/login");
} }
private static string RenderLoginPage(
HttpContext httpContext,
IAntiforgery antiforgery,
string returnUrl,
string? failureMessage)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
string requestToken = tokens.RequestToken ?? string.Empty;
string alert = string.IsNullOrWhiteSpace(failureMessage)
? string.Empty
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
string body = $"""
<section class="dashboard-login">
{alert}
<form method="post" action="/login" class="card login-card">
<div class="card-body">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</div>
</form>
</section>
""";
return RenderPage("Dashboard Sign In", heading: null, body);
}
private static string RenderPage(string title, string body) private static string RenderPage(string title, string body)
=> RenderPage(title, heading: title, body); => RenderPage(title, heading: title, body);
@@ -215,7 +175,8 @@ public static class DashboardEndpointRouteBuilderExtensions
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title> <title>{HtmlEncoder.Default.Encode(title)}</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/theme.css" />
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/layout.css" />
<link rel="stylesheet" href="/css/site.css" /> <link rel="stylesheet" href="/css/site.css" />
</head> </head>
<body class="dashboard-body"> <body class="dashboard-body">

Some files were not shown because too many files have changed in this diff Show More