Compare commits

..

38 Commits

Author SHA1 Message Date
Joseph Doherty
8adc8f5ab8 Phase 3 PR 37 — End-to-end live-stack Galaxy smoke test. Closes the code side of LMX follow-up #5; once OtOpcUaGalaxyHost is installed + started on the dev box, the suite exercises the full topology GalaxyProxyDriver in-process → named-pipe IPC → running OtOpcUaGalaxyHost Windows service → MxAccessGalaxyBackend → live MXAccess runtime → real deployed Galaxy objects. Never spawns the Host process itself — connects to the already-running service per project_galaxy_host_service.md, which is the only way to exercise the production COM-apartment + service-account + pipe-ACL configuration.
LiveStackConfig resolves the pipe name + per-install shared secret from two sources in order: OTOPCUA_GALAXY_PIPE + OTOPCUA_GALAXY_SECRET env vars first (for CI / benchwork overrides), then the service's per-process Environment registry values under HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost (what Install-Services.ps1 writes at install time). Registry read requires the test host to run elevated on most boxes — the skip message says so explicitly so operators see the right remediation. Hard-coded secrets are deliberately avoided: the installer generates 32 fresh random bytes per install, a committed secret would diverge from production the moment the service is re-installed.
LiveStackFixture is an IAsyncLifetime that (1) runs AvevaPrerequisites.CheckAllAsync with CheckGalaxyHostPipe=true + CheckHistorian=false — produces a structured PrerequisiteReport whose SkipReason is the exact operator-facing 'here's what you need to fix' text, (2) resolves LiveStackConfig and surfaces a clear skip when the secret isn't discoverable, (3) instantiates GalaxyProxyDriver + calls InitializeAsync (the IPC handshake), capturing a skip with the exception detail + common-cause hints (secret mismatch, SID not in pipe ACL, Host's backend couldn't connect to ZB) rather than letting a NullRef cascade through every subsequent test. SkipIfUnavailable() translates the captured SkipReason into Assert.Skip at the top of every fact so tests read as cleanly-skipped with a visible reason, not silently-passed or crashed.
LiveStackSmokeTests (5 facts, Collection=LiveStack, Category=LiveGalaxy): Fixture_initialized_successfully (cheapest possible end-to-end assertion — if this passes, the IPC handshake worked); Driver_reports_Healthy_after_IPC_handshake (DriverHealth.State post-connect); DiscoverAsync_returns_at_least_one_variable_from_live_galaxy (captures every Variable() call from DiscoverAsync via CapturingAddressSpaceBuilder and asserts > 0 — zero here usually means the Host couldn't read ZB, the skip message names OTOPCUA_GALAXY_ZB_CONN to check); GetHostStatuses_reports_at_least_one_platform (IHostConnectivityProbe surface — zero means the probe loop hasn't fired or no Platform is deployed locally); Can_read_a_discovered_variable_from_live_galaxy (reads the first discovered attribute's full reference, asserts status != BadInternalError — Galaxy's Uncertain-quality-until-first-Engine-scan is intentionally NOT treated as failure since it depends on runtime state that varies across test runs). Read-only by design; writes need an agreed scratch tag to avoid mutating a process-critical attribute — deferred to a follow-up PR that reuses this fixture.
CapturingAddressSpaceBuilder is a minimal IAddressSpaceBuilder that flattens every Variable() call into a list so tests can inspect what discovery produced without booting the full OPC UA node-manager stack; alarm annotation + property calls are no-ops. Scoped private to the test class.
Galaxy.Proxy.Tests csproj gains a ProjectReference to Driver.Galaxy.TestSupport (PR 36) for AvevaPrerequisites. The NU1702 warning about the Host project being net48-referenced-by-net10 is pre-existing from the HostSubprocessParityTests — Proxy.Tests only needs the Host EXE path for that parity scenario, not type surface.
Test run on THIS machine (OtOpcUaGalaxyHost not yet installed): Skipped! Failed 0, Passed 0, Skipped 5 — each skip message includes the full prerequisites report pointing at the missing service. Once the service is installed + started (scripts\install\Install-Services.ps1), the 5 facts will execute against live Galaxy. Proxy.Tests Unit: 17 pass / 0 fail (unchanged — new tests are Category=LiveGalaxy, separate suite). Full Proxy build clean. Memory already captures the 'live tests run via already-running service, don't spawn' convention (project_galaxy_host_service.md).
lmx-followups.md #5 updated: status is 'IN PROGRESS' across PRs 36 + 37 with the explicit remaining work (install + start services, subscribe-and-receive, write round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:49:51 -04:00
261869d84e Merge pull request 'Phase 3 PR 36 — AVEVA prerequisites test-support library' (#35) from phase-3-pr36-aveva-prerequisites into v2 2026-04-18 16:44:41 -04:00
Joseph Doherty
08c90d19fd Phase 3 PR 36 — AVEVA prerequisites test-support library. New tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport multi-targeted class library (net10.0 + net48 so both the modern and the MXAccess-COM x86 test projects can consume it) that probes every piece of the AVEVA System Platform + OtOpcUa stack a live-Galaxy test depends on and returns a structured PrerequisiteReport. Closes the gap where live-smoke tests silently returned 'unreachable' without telling operators which specific piece failed.
AvevaPrerequisites.CheckAllAsync walks eight probe categories producing PrerequisiteCheck rows each with Name (e.g. 'service:aaBootstrap', 'sql:ZB', 'com:LMXProxy', 'registry:ArchestrA.Framework'), Category (AvevaCoreService / AvevaSoftService / AvevaInstall / MxAccessCom / GalaxyRepository / AvevaHistorian / OtOpcUaService / Environment), Status (Pass / Warn / Fail / Skip), and operator-facing Detail message. Report aggregates them: IsLivetestReady (no Fails anywhere) and IsAvevaSideReady (AVEVA-side categories pass, our v2 services can be absent while still considering the environment AVEVA-ready) so different test tiers can use the right threshold.
Individual probes: ServiceProbe.Check queries the Windows Service Control Manager via System.ServiceProcess.ServiceController — treats DemandStart+Stopped as Warn (NmxSvc is DemandStart by design; master pulls it up) but AutoStart+Stopped as Fail; not-installed is Fail for hard-required services, Warn for soft ones; non-Windows hosts get Skip; transitional states like StartPending get Warn with a 'try again' hint. RegistryProbe reads HKLM\SOFTWARE\WOW6432Node\ArchestrA\{Framework,Framework\Platform,MSIInstall} — Framework key presence + populated InstallPath/RootPath values mean System Platform installed; PfeConfigOptions in the Platform subkey (format 'PlatformId=N,EngineId=N,...') indicates a Platform has been deployed from the IDE (PlatformId=0 means never deployed — MXAccess will connect but every subscription will be Bad quality); RebootRequired='True' under MSIInstall surfaces as a loud warn since post-patch behavior is undefined. MxAccessComProbe resolves the LMXProxy.LMXProxyServer ProgID → CLSID → HKLM\SOFTWARE\Classes\WOW6432Node\CLSID\{guid}\InprocServer32, verifying the registered file exists on disk (catches the orphan-registry case where a previous uninstall left the ProgID registered but the DLL is gone — distinguishes it from the 'totally not installed' case by message); also emits a Warn when the test process is 64-bit (MXAccess COM activation fails with REGDB_E_CLASSNOTREG 0x80040154 regardless of registration, so seeing this warning tells operators why the activation would fail even on a fully-installed machine). SqlProbe tests Galaxy Repository via Microsoft.Data.SqlClient using the Windows-auth localhost connection string the repo code defaults to — distinguishes 'SQL Server unreachable' (connection fails) from 'ZB database does not exist' (SELECT DB_ID('ZB') returns null) because they have different remediation paths (sc.exe start MSSQLSERVER vs. restore from .cab backup); a secondary CheckDeployedObjectCountAsync query on 'gobject WHERE deployed_version > 0' warns when the count is zero because discovery smoke tests will return empty hierarchies. NamedPipeProbe opens a 2s NamedPipeClientStream against OtOpcUaGalaxyHost's pipe ('OtOpcUaGalaxy' per the installer default) — pipe accepting a connection proves the Host service is listening; disconnects immediately so we don't consume a session slot.
Service lists kept as internal static data so tests can inspect + override: CoreServices (aaBootstrap + aaGR + NmxSvc + MSSQLSERVER — hard fail if missing), SoftServices (aaLogger + aaUserValidator + aaGlobalDataCacheMonitorSvr — warn only; stack runs without them but diagnostics/auth are degraded), HistorianServices (aahClientAccessPoint + aahGateway — opt-in via Options.CheckHistorian, only matters for HistoryRead IPC paths), OtOpcUaServices (our OtOpcUaGalaxyHost hard-required for end-to-end live tests + OtOpcUa warn + GLAuth warn). Narrower entry points CheckRepositoryOnlyAsync and CheckGalaxyHostPipeOnlyAsync for tests that only care about specific subsystems — avoid paying the full probe cost on every GalaxyRepositoryLiveSmokeTests fact.
Multi-targeting mechanics: System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are NuGet packages on net10 but in-box BCL references on net48; csproj conditions Package vs Reference by TargetFramework. Microsoft.Data.SqlClient v6 supports both frameworks so single PackageReference. Net48Polyfills.cs provides IsExternalInit shim (records/init-only setters) and SupportedOSPlatformAttribute stub so the same Probe sources compile on both frameworks without per-callsite preprocessor guards — lets Roslyn's platform-compatibility analyzer stay useful on net10 without breaking net48 builds.
Existing GalaxyRepositoryLiveSmokeTests updated to delegate its skip decision to AvevaPrerequisites.CheckRepositoryOnlyAsync (legacy ZbReachableAsync kept as a compatibility adapter so the in-test 'if (!await ZbReachableAsync()) return;' pattern keeps working while the surrounding fixtures gradually migrate to Assert.Skip-with-reason). Slnx file registers the new project.
Tests — AvevaPrerequisitesLiveTests (8 new Integration cases, Category=LiveGalaxy): the helper correctly reports Framework install (registry pass), aaBootstrap Running (service pass), aaGR Running (service pass), MxAccess COM registered (com pass), ZB database reachable (sql pass), deployed-object count > 0 (warn-upgraded-to-pass because this box has 49 objects deployed), the AVEVA side is ready even when our own services (OtOpcUaGalaxyHost) aren't installed yet (IsAvevaSideReady=true), and the helper emits rows for OtOpcUaGalaxyHost + OtOpcUa + GLAuth even when not installed (regression guard — nobody can accidentally ship a check that omits our own services). Full Galaxy.Host.Tests Category=LiveGalaxy suite: 13 pass (5 prior smoke + 8 new prerequisites). Full solution build clean, 0 errors.
What's NOT in this PR: end-to-end Galaxy stack smoke (Proxy → Host pipe → MXAccess → real Galaxy tag). That's the next PR — this one is the gate the end-to-end smoke will call first to produce actionable skip messages instead of silent returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:36:13 -04:00
5cc120d836 Merge pull request 'Phase 3 PR 35 — IHistoryProvider gains ReadAtTime + ReadEvents; Proxy implements both' (#34) from phase-3-pr35-history-readtime-readevents into v2 2026-04-18 16:12:43 -04:00
Joseph Doherty
bf329b05d8 Phase 3 PR 35 — IHistoryProvider gains ReadAtTimeAsync + ReadEventsAsync; GalaxyProxyDriver implements both. Extends Core.Abstractions.IHistoryProvider with two new methods that round out the OPC UA Part 11 HistoryRead surface (HistoryReadAtTime + HistoryReadEvents are the last two modes not covered by the PR 19-era ReadRawAsync + ReadProcessedAsync) and wires GalaxyProxyDriver to call the existing PR-10/PR-11 IPC contracts the Host already implements.
Interface additions use C# default interface implementations that throw NotSupportedException — existing IHistoryProvider implementations keep compiling, only drivers whose backend carries the relevant capability override. This matches the 'capabilities are optional per driver' design already used by IHistoryProvider.ReadProcessedAsync's docs (Modbus / OPC UA Client drivers never had an event historian and the default-throw path lets callers see BadHistoryOperationUnsupported naturally). New HistoricalEvent record models one historian row (EventId, SourceName, EventTimeUtc + ReceivedTimeUtc — process vs historian-persist timestamps, Message, Severity mapped to OPC UA's 1-1000 range); HistoricalEventsResult pairs the event list with a continuation-point token for future batching. Both live in Core.Abstractions so downstream (Proxy, Host, Server) reference a single domain shape — no Shared-contract leak into the driver-facing interface.
GalaxyProxyDriver.ReadAtTimeAsync maps the domain DateTime[] to Unix-ms longs, calls CallAsync on the existing MessageKind.HistoryReadAtTimeRequest, and trusts the Host's one-sample-per-requested-timestamp contract (the Host pads with bad-quality snapshots for timestamps it can't interpolate; re-aligning on the Proxy side would duplicate the Host's interpolation policy logic). ReadEventsAsync does the same for HistoryReadEventsRequest; ToHistoricalEvent translates GalaxyHistoricalEvent (MessagePack-annotated, Unix-ms) to the domain record, explicitly tagging DateTimeKind.Utc on both timestamp fields so downstream serializers (JSON, OPC UA types) don't apply an unexpected local-time offset.
Tests — HistoricalEventMappingTests (3 new Proxy.Tests unit cases): every field maps correctly from wire to domain; null SourceName and null DisplayText preserve through the mapping (system events without a source come out with null so callers can distinguish them from alarm events); both timestamps come out as DateTimeKind.Utc (regression guard against a future refactor using DateTime.FromFileTimeUtc or similar that defaults to Unspecified). Driver.Galaxy.Proxy.Tests Unit suite: 17 pass / 0 fail (14 prior + 3 new). Full solution build clean, 0 errors.
Scope exclusions — DriverNodeManager HistoryRead service-handler wiring (on the OPC UA Server side, where HistoryReadAtTime and HistoryReadEvents service requests land) and the full-loop integration test (OPC UA client → server → IPC → Host → HistorianDataSource → back) are deferred to a focused follow-up PR. The capability surface is the load-bearing change; wiring the service handlers is mechanical in comparison and worth its own PR for reviewability. docs/v2/lmx-followups.md #1 updated with the split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:08:27 -04:00
2584379e75 Merge pull request 'Phase 3 PR 34 — Host-status publisher (Server) + /hosts drill-down page (Admin)' (#33) from phase-3-pr34-host-status-publisher-page into v2 2026-04-18 16:04:20 -04:00
Joseph Doherty
ef2a810b2d Phase 3 PR 34 — Host-status publisher (Server) + /hosts drill-down page (Admin). Closes LMX follow-up #7 by wiring together the data layer from PR 33. Server.HostStatusPublisher is a BackgroundService that walks every driver registered in DriverHost every 10 seconds, skips drivers that don't implement IHostConnectivityProbe, calls GetHostStatuses() on each probe-capable driver, and upserts one DriverHostStatus row per (NodeId, DriverInstanceId, HostName) into the central config DB. Upsert path: SingleOrDefaultAsync on the composite PK; if no row exists, Add a new one; if a row exists, LastSeenUtc advances unconditionally (heartbeat) and State + StateChangedUtc update only on transitions so Admin UI can distinguish 'still reporting, still Running' from 'freshly transitioned to Running'. MapState translates Core.Abstractions.HostState to Configuration.Enums.DriverHostState (intentional duplicate enum — Configuration project stays free of driver-runtime deps per PR 33's choice). If a driver's GetHostStatuses throws, log warning and skip that driver this tick — never take down the Server on a publisher failure. If the DB is unreachable, log warning + retry next heartbeat (no buffering — next tick's current-state snapshot is more useful than replaying stale transitions after a long outage). 2-second startup delay so NodeBootstrap's RegisterAsync calls land before the first publish tick, then tick runs immediately so a freshly-started Server surfaces its host topology in the Admin UI without waiting a full interval.
Polling chosen over event-driven for initial scope: simpler, matches Admin UI consumer cadence, avoids DriverHost lifecycle-event plumbing that doesn't exist today. Event-driven push for sub-heartbeat latency is a straightforward follow-up.
Admin.Services.HostStatusService left-joins DriverHostStatus against ClusterNode on NodeId so rows persist even when the ClusterNode entry doesn't exist yet (first-boot bootstrap case). StaleThreshold = 30s — covers one missed publisher heartbeat plus a generous buffer for clock skew and GC pauses. Admin Components/Pages/Hosts.razor — FleetAdmin-visible page grouped by cluster (handles the '(unassigned)' case for rows without a matching ClusterNode). Four summary cards (Hosts / Running / Stale / Faulted); per-cluster table with Node / Driver / Host / State + Stale-badge / Last-transition / Last-seen / Detail columns; 10s auto-refresh via IServiceScopeFactory timer pattern matching FleetStatusPoller + Fleet dashboard (PR 27). Row-class highlighting: Faulted → table-danger, Stale → table-warning, else default. State badge maps DriverHostState enum to bootstrap color classes. Sidebar link added between 'Fleet status' and 'Clusters'.
Server csproj adds Microsoft.EntityFrameworkCore.SqlServer 10.0.0 + registers OtOpcUaConfigDbContext in Program.cs scoped via NodeOptions.ConfigDbConnectionString (no Admin-style manual SQL raw — the DbContext is the only access path, keeps migrations owner-of-record).
Tests — HostStatusPublisherTests (4 new Integration cases, uses per-run throwaway DB matching the FleetStatusPollerTests pattern): publisher upserts one row per host from each probe-capable driver and skips non-probe drivers; second tick advances LastSeenUtc without creating duplicate rows (upsert pattern verified end-to-end); state change between ticks updates State AND StateChangedUtc (datetime2(3) rounds to millisecond precision so comparison uses 1ms tolerance — documented inline); MapState translates every HostState enum member. Server.Tests Integration: 4 new tests pass. Admin build clean, Admin.Tests Unit still 23 / 0. docs/v2/lmx-followups.md item #7 marked DONE with three explicit deferred items (event-driven push, failure-count column, SignalR fan-out).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:51:55 -04:00
a7764e50f3 Merge pull request 'Phase 3 PR 33 — DriverHostStatus entity + migration (LMX #7 data layer)' (#32) from phase-3-pr33-driverhoststatus-entity into v2 2026-04-18 15:43:37 -04:00
Joseph Doherty
8464e3f376 Phase 3 PR 33 — DriverHostStatus entity + EF migration (data-layer for LMX #7). New DriverHostStatus entity with composite key (NodeId, DriverInstanceId, HostName) persists each server node's per-host connectivity view — one row per (server node, driver instance, probe-reported host), which means a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6 rows because each server node owns its own runtime view of the shared host topology, not 3. Fields: NodeId (64), DriverInstanceId (64), HostName (256 — fits Galaxy FQDNs and Modbus host:port strings), State (DriverHostState enum — Unknown/Running/Stopped/Faulted, persisted as nvarchar(16) via HasConversion<string> so DBAs inspecting the table see readable state names not ordinals), StateChangedUtc + LastSeenUtc (datetime2(3) — StateChangedUtc tracks actual transitions while LastSeenUtc advances on every publisher heartbeat so the Admin UI can flag stale rows from a crashed Server independent of State), Detail (nullable 1024 — exception message from the driver's probe when Faulted, null otherwise).
DriverHostState enum lives in Configuration.Enums/ rather than reusing Core.Abstractions.HostState so the Configuration project stays free of driver-runtime dependencies (it's referenced by both the Admin process and the Server process, so pulling in the driver-abstractions assembly to every Admin build would be unnecessary weight). The server-side publisher hosted service (follow-up PR 34) will translate HostStatusChangedEventArgs.NewState to this enum on every transition.
No foreign key to ClusterNode — a Server may start reporting host status before its ClusterNode row exists (first-boot bootstrap), and we'd rather keep the status row than drop it. The Admin-side service that renders the dashboard will left-join on NodeId when presenting. Two indexes declared: IX_DriverHostStatus_Node drives the per-cluster drill-down (Admin UI joins ClusterNode on ClusterId to pick which NodeIds to fetch), IX_DriverHostStatus_LastSeen drives the stale-row query (now - LastSeen > threshold).
EF migration AddDriverHostStatus creates the table + PK + both indexes. Model snapshot updated. SchemaComplianceTests expected-tables list extended. DriverHostStatusTests (3 new cases, category SchemaCompliance, uses the shared fixture DB): composite key allows same (host, driver) across different nodes AND same (node, host) across different drivers — both real-world cases the publisher needs to support; upsert-in-place pattern (fetch-by-composite-PK, mutate, save) produces one row not two — the pattern the publisher will use; State enum persists as string not int — reading the DB via ADO.NET returns 'Faulted' not '3'.
Configuration.Tests SchemaCompliance suite: 10 pass / 0 fail (7 prior + 3 new). Configuration build clean. No Server or Admin code changes yet — publisher + /hosts page are PR 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:41 -04:00
a9357600e7 Merge pull request 'Phase 3 PR 32 — Multi-driver integration test' (#31) from phase-3-pr32-multi-driver-integration into v2 2026-04-18 15:34:16 -04:00
Joseph Doherty
2f00c74bbb Phase 3 PR 32 — Multi-driver integration test. Closes LMX follow-up #6 with Server.Tests/MultipleDriverInstancesIntegrationTests.cs: registers two StubDriver instances (alpha + beta) with distinct DriverInstanceIds on one DriverHost, boots the full OpcUaApplicationHost, and exercises three behaviors end-to-end via a real OPC UA client session. (1) Each driver's namespace URI resolves to a distinct index in the client's NamespaceUris (alpha → urn:OtOpcUa:alpha, beta → urn:OtOpcUa:beta) — proves DriverNodeManager's namespaceUris-per-driver base-ctor wiring actually lands two separate INodeManager registrations. (2) Browsing one subtree returns only that driver's folder; the other driver's folder does NOT leak into the wrong subtree. This is the test that catches a cross-driver routing regression the v1 single-driver code path couldn't surface — if a future refactor flattens both drivers into a shared namespace, the 'shouldNotContain' assertion fails cleanly. (3) Reads route to the owning driver by namespace — alpha's ReadAsync returns 42 while beta's returns 99; a misroute would surface as 99 showing up on an alpha node id or vice versa. StubDriver is parameterized on (DriverInstanceId, folderName, readValue) so the same class constructs both instances without copy-paste.
No production code changes — pure additive test. Server.Tests Integration: 3 new tests pass; existing OpcUaServerIntegrationTests stays green (single-driver case still exercised there). Full Server.Tests Unit still 43 / 0. Deferred: multi-driver alarm-event case (two drivers each raising a GalaxyAlarmEvent, assert each condition lands on its owning instance's condition node) — needs a stub IAlarmSource and is worth its own focused PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:29:49 -04:00
5d5e1f9650 Merge pull request 'Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility' (#30) from phase-3-pr31-live-ldap-ad-compat into v2 2026-04-18 15:27:54 -04:00
Joseph Doherty
4886a5783f Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility. Closes LMX follow-up #4 with 6 live-bind tests in Server.Tests/LdapUserAuthenticatorLiveTests.cs against the dev GLAuth instance at localhost:3893 (skipped cleanly when unreachable via Assert.Skip + a clear SkipReason — matches the GalaxyRepositoryLiveSmokeTests pattern). Coverage: valid credentials bind + surface DisplayName; wrong password fails; unknown user fails; empty credentials fail pre-flight without touching the directory; writeop user's memberOf maps through GroupToRole to WriteOperate (the exact string WriteAuthzPolicy.IsAllowed expects); admin user surfaces all four mapped roles (WriteOperate + WriteTune + WriteConfigure + AlarmAck) proving memberOf parsing doesn't stop after the first match. While wiring this up, the authenticator's hard-coded user-lookup filter 'uid=<name>' didn't match GLAuth (which keys users by cn and doesn't populate uid) — AND it doesn't match Active Directory either, which uses sAMAccountName. Added UserNameAttribute to LdapOptions (default 'uid' for RFC 2307 backcompat) so deployments override to 'cn' / 'sAMAccountName' / 'userPrincipalName' as the directory requires; authenticator filter now interpolates the configured attribute. The default stays 'uid' so existing test fixtures and OpenLDAP installs keep working without a config change — a regression guard in LdapUserAuthenticatorAdCompatTests.LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat pins this so a future 'helpful' default change can't silently break anyone.
Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:23:22 -04:00
d70a2e0077 Merge pull request 'Phase 3 PR 30 — Modbus integration-test project scaffold + DL205 smoke test' (#29) from phase-3-pr30-modbus-integration-scaffold into v2 2026-04-18 15:08:45 -04:00
Joseph Doherty
cb7b81a87a Phase 3 PR 30 — Modbus integration-test project scaffold. New tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests project is the harness modbus-test-plan.md called for: a skip-when-unreachable fixture that TCP-probes a Modbus simulator endpoint (MODBUS_SIM_ENDPOINT, default localhost:502) once per test session, a DL205 device profile stub (single writable holding register at address 100, probe disabled to avoid racing with assertions), and one happy-path smoke test that initializes the real ModbusDriver + real ModbusTcpTransport, writes a known Int16 value, reads it back, and asserts status=0 + value round-trip. No DL205 quirk assertions yet — those land one-per-PR as the user validates each behavior in ModbusPal (word order for 32-bit, register-zero access, coil addressing base, max registers per FC03, response framing under load, exception code on protected-bit coil write).
ModbusSimulatorFixture is a collection fixture so the 2s TCP probe runs once per run, not per test; SkipReason gets a clear operator-facing message ('start ModbusPal or override MODBUS_SIM_ENDPOINT'). Tests call Assert.Skip(sim.SkipReason) rather than silently returning — matches the test-plan convention and reads cleanly in CI logs. DL205Profile.BuildOptions deliberately disables the background probe loop since integration tests drive reads explicitly and the probe would race with assertions. Tag naming uses the DL205_ prefix so filter 'DisplayName~DL205' surfaces device-specific failures at a glance.
Project references: xunit.v3 + Shouldly + Microsoft.NET.Test.Sdk + xunit.runner.visualstudio (matches the existing Driver.Modbus.Tests unit project), project ref to src/Driver.Modbus. Registered in ZB.MOM.WW.OtOpcUa.slnx under tests/. ModbusPal/README.md documents the dev loop (install ModbusPal jar, load profile, start simulator, dotnet test), explains MODBUS_SIM_ENDPOINT override for real-PLC benchwork, and flags DL205.xmpp as the first profile to add in a follow-up PR.
dotnet test run against the scaffold (no simulator running) skips cleanly: 0 failed, 0 passed, 1 skipped, with the SkipReason surfaced. dotnet build clean (0 warnings, 0 errors). Updated docs/v2/modbus-test-plan.md to mark the scaffold PR done and renumbered future PRs from 'PR 27+' to 'PR 31+' to stay in sync with the actual PR chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:02:39 -04:00
901d2b8019 Merge pull request 'Phase 3 PR 29 — Account/session page with roles + capabilities' (#28) from phase-3-pr29-account-page into v2 2026-04-18 14:46:45 -04:00
Joseph Doherty
d5fa1f450e Phase 3 PR 29 — Account/session page expanding the minimal sidebar role display into a dedicated /account route. Shows the authenticated operator's identity (username from ClaimTypes.NameIdentifier, display name from ClaimTypes.Name), their Admin roles as badges (from ClaimTypes.Role), the raw LDAP groups that mapped to those roles (from the 'ldap_group' claim added by Login.razor at sign-in), and a capability table listing each Admin capability with its required role and a Yes/No badge showing whether this session has it. Capability list mirrors the Program.cs authorization policies + each page's [Authorize] attribute so operators can self-service check whether their session has access without trial-and-error navigation — capabilities covered: view clusters + fleet status (all roles), edit configuration drafts (ConfigEditor or FleetAdmin per CanEdit policy), publish generations (FleetAdmin per CanPublish policy), manage certificate trust (FleetAdmin per PR 28 Certificates page attribute), manage external-ID reservations (ConfigEditor or FleetAdmin per Reservations page attribute).
Sidebar's 'Signed in as' line now wraps the display name in a link to /account so the existing sidebar-compact view becomes the entry point for the fuller page — keeps the sign-out button where it was for muscle memory, just adds the detail page one click away. Page is gated with [Authorize] (any authenticated admin) rather than a specific role — the capability table deliberately works for every signed-in user so they can see what they DON'T have access to, which helps them file the right ticket with their LDAP admin instead of getting a plain Access Denied when navigating blindly.
Capability → required-role table is defined as a private readonly record list in the page rather than pulled from a service because it's a UI-presentation concern, not runtime policy state — the runtime policy IS Program.cs's AddAuthorizationBuilder + each page's [Authorize] attribute, and this table just mirrors it for operator readability. Comment on the list reminds future-me to extend it when a new policy or [Authorize] page lands. No behavior change if roles are empty, but the page surfaces a hint ('Sign-in would have been blocked, so if you're seeing this, the session claim is likely stale') that nudges the operator toward signing out + back in.
No new tests added — the page is pure display over claims; its only logic is the 'has-capability' Any-overlap check which is exactly what ASP.NET's [Authorize(Roles=...)] does in-framework, and duplicating that in a unit test would test the framework rather than our code. Admin.Tests Unit stays 23 pass / 0 fail. Admin build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:43:35 -04:00
6fdaee3a71 Merge pull request 'Phase 3 PR 28 — Admin UI cert-trust management page' (#27) from phase-3-pr28-cert-trust into v2 2026-04-18 14:42:52 -04:00
Joseph Doherty
ed88835d34 Phase 3 PR 28 — Admin UI cert-trust management page. New /certificates route (FleetAdmin-only) surfaces the OPC UA server's PKI store rejected + trusted certs and gives operators Trust / Delete / Revoke actions so rejected client certs can be promoted without touching disk. CertTrustService reads $PkiStoreRoot/{rejected,trusted}/certs/*.der files directly via X509CertificateLoader — no Opc.Ua dependency in the Admin project, which keeps the Admin host runnable on a machine that doesn't have the full Server install locally (only needs the shared PKI directory reachable; typical deployment has Admin + Server side-by-side on the same box and PkiStoreRoot defaults match so a plain-vanilla install needs no override). CertTrustOptions bound from the Admin's 'CertTrust:PkiStoreRoot' section, default %ProgramData%\OtOpcUa\pki (matches OpcUaServerOptions.PkiStoreRoot default). Trust action moves the .der from rejected/certs/ to trusted/certs/ via File.Move(overwrite:true) — idempotent, tolerates a concurrent operator doing the same move. Delete wipes the file. Revoke removes from trusted/certs/ (Opc.Ua re-reads the Directory store on each new client handshake, so no explicit reload signal is needed; operators retry the rejected connection after trusting). Thumbprint matching is case-insensitive because X509Certificate2.Thumbprint is upper-case hex but operators copy-paste from logs that sometimes lowercase it. Malformed files in the store are logged + skipped — a single bad .der can't take the whole management page offline. Missing store directories produce empty lists rather than exceptions so a pristine install (Server never run yet, no rejected/trusted dirs yet) doesn't crash the page.
Razor page layout: two tables (Rejected / Trusted) with Subject / Issuer / Thumbprint / Valid-window / Actions columns, status banner after each action with success or warning kind ('file missing' = another admin handled it), FleetAdmin-only via [Authorize(Roles=AdminRoles.FleetAdmin)]. Each action invokes LogActionAsync which Serilog-logs the authenticated admin user + thumbprint + action for an audit trail — DB-level ConfigAuditLog persistence is deferred because its schema is cluster-scoped and cert actions are cluster-agnostic; Serilog + CertTrustService's filesystem-op info logs give the forensic trail in the meantime. Sidebar link added to MainLayout between Reservations and the future Account page.
Tests — CertTrustServiceTests (9 new unit cases): ListRejected parses Subject + Thumbprint + store kind from a self-signed test cert written into rejected/certs/; rejected and trusted stores are kept separate; TrustRejected moves the file and the Rejected list is empty afterwards; TrustRejected with a thumbprint not in rejected returns false without touching trusted; DeleteRejected removes the file; UntrustCert removes from trusted only; thumbprint match is case-insensitive (operator UX); missing store directories produce empty lists instead of throwing DirectoryNotFoundException (pristine-install tolerance); a junk .der in the store is logged + skipped and the valid certs still surface (one bad file doesn't break the page). Full Admin.Tests Unit suite: 23 pass / 0 fail (14 prior + 9 new). Full Admin build clean — 0 errors, 0 warnings.
lmx-followups.md #3 marked DONE with a cross-reference to this PR and a note that flipping AutoAcceptUntrustedClientCertificates to false as the production default is a deployment-config follow-up, not a code gap — the Admin UI is now ready to be the trust gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:37:55 -04:00
5389d4d22d Phase 3 PR 27 � Fleet status dashboard page (#26) 2026-04-18 14:07:16 -04:00
Joseph Doherty
b5f8661e98 Phase 3 PR 27 — Fleet status dashboard page. New /fleet route shows per-node apply state (ClusterNodeGenerationState joined with ClusterNode for the ClusterId) in a sortable table with summary cards for Total / Applied / Stale / Failed node counts. Stale detection: LastSeenAt older than 30s triggers a table-warning row class + yellow count card. Failed rows get table-danger + red card. Badge classes per LastAppliedStatus: Applied=bg-success, Failed=bg-danger, Applying=bg-info, unknown=bg-secondary. Timestamps rendered as relative-age strings ('42s ago', '15m ago', '3h ago', then absolute date for >24h). Error column is truncated to 320px with the full message in a tooltip so the table stays readable on wide fleets. Initial data load on OnInitializedAsync; auto-refresh every 5s via a Timer that calls InvokeAsync(RefreshAsync) — matches the FleetStatusPoller's 5s cadence so the dashboard sees the most recent state without polling ahead of the broadcaster. A Refresh button also kicks a manual reload; _refreshing gate prevents double-runs when the timer fires during an in-flight query. IServiceScopeFactory (matches FleetStatusPoller's pattern) creates a fresh DI scope per refresh so the per-page DbContext can't race the timer with the render thread; no new DI registrations needed. Live SignalR hub push is deliberately deferred to a follow-up PR — the existing FleetStatusHub + NodeStateChangedMessage already works for external JavaScript clients; wiring an in-process Blazor Server consumer adds HubConnectionBuilder plumbing that's worth its own focused change. Sidebar link added to MainLayout between Overview and Clusters. Full Admin.Tests Unit suite 14 pass / 0 fail — unchanged, no tests regressed. Full Admin build clean (0 errors, 0 warnings). Closes the 'no per-driver dashboard' gap from lmx-followups item #7 at the fleet level; per-host (platform/engine/Modbus PLC) granularity still needs a dedicated page that consumes IHostConnectivityProbe.GetHostStatuses from the Server process — that's the live-SignalR follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:12:31 -04:00
4058b88784 Merge pull request 'Phase 3 PR 26 — server-layer write authorization by role' (#25) from phase-3-pr26-server-write-authz into v2 2026-04-18 13:04:35 -04:00
Joseph Doherty
6b04a85f86 Phase 3 PR 26 — server-layer write authorization gating by role. Per the user's ACL-at-server-layer directive (saved as feedback_acl_at_server_layer.md in memory), write authorization is enforced in DriverNodeManager.OnWriteValue and never delegated to the driver or to driver-specific auth (the v1 Galaxy-provided security path is explicitly not part of v2 — drivers report SecurityClassification as discovery metadata only). New WriteAuthzPolicy static class in Server/Security/ maps SecurityClassification → required role per the table documented in docs/Configuration.md: FreeAccess = no role required (anonymous sessions can write), Operate + SecuredWrite = WriteOperate, Tune = WriteTune, VerifiedWrite + Configure = WriteConfigure, ViewOnly = deny regardless of roles. Role matching is case-insensitive and role requirements do NOT cascade — a session with WriteConfigure can write Configure attributes but needs WriteOperate separately to write Operate attributes; this is deliberate so escalation is an explicit LDAP group assignment, not a hierarchy the policy silently grants. DriverNodeManager gains a _securityByFullRef Dictionary populated during Variable() registration (parallel to the existing _variablesByFullRef) so OnWriteValue can look up the classification in O(1) on the hot path. OnWriteValue casts the session's context.UserIdentity to the new IRoleBearer interface (implemented by OtOpcUaServer.RoleBasedIdentity from PR 19) — empty Roles collection when the session is anonymous; the same WriteAuthzPolicy.IsAllowed check then either short-circuits true (FreeAccess), false (ViewOnly), or walks the roles list looking for the required one. On deny, OnWriteValue logs 'Write denied for {FullRef}: classification=X userRoles=[...]' at Information level (readable trail for operator complaints) and returns BadUserAccessDenied without touching IWritable.WriteAsync — drivers never see a request we'd have refused. IRoleBearer kept as a minimal server-side interface rather than reusing some abstraction from Core.Abstractions because the concept is OPC-UA-session-scoped and doesn't generalize (the driver side has no notion of a user session). Tests — WriteAuthzPolicyTests (17 new cases): FreeAccess allows write with empty role set + arbitrary roles; ViewOnly denies write even with every role; Operate requires WriteOperate; role match is case-insensitive; Operate denies empty role set + wrong role; SecuredWrite shares Operate's requirement; Tune requires WriteTune; Tune denies WriteOperate-only (asserts roles don't cascade — this is the test that catches a future regression where someone 'helpfully' adds a role-escalation table); Configure requires WriteConfigure; VerifiedWrite shares Configure's requirement; multi-role session allowed when any role matches; unrelated roles denied; RequiredRole theory covering all 5 classified-and-mapped rows + null for FreeAccess/ViewOnly special cases. lmx-followups.md follow-up #2 marked DONE with a back-reference to this PR and the memory note. Full Server.Tests Unit suite: 38 pass / 0 fail (17 new WriteAuthz + 14 SecurityConfiguration from PR 19 + 2 NodeBootstrap + 5 others). Server.Tests Integration (Category=Integration) 2 pass — existing PR 17 anonymous-endpoint smoke tests stay green since the read path doesn't hit OnWriteValue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:01:01 -04:00
cd8691280a Merge pull request 'Phase 3 PR 25 — Modbus test plan + DL205 quirk catalog' (#24) from phase-3-pr25-modbus-test-plan into v2 2026-04-18 12:49:19 -04:00
Joseph Doherty
77d09bf64e Phase 3 PR 25 — modbus-test-plan.md: integration-test playbook with per-device quirk catalog. ModbusPal is the chosen simulator; AutomationDirect DL205 is the first target device class with 6 pending quirks to document and cover with named tests (word order for 32-bit values, register-zero access policy, coil addressing base, maximum registers per FC03, response framing under sustained load, exception code on protected-bit coil write). Each quirk placeholder has a proposed test name so the user's validation work translates directly into integration tests. Test conventions section codifies the named-per-quirk pattern, skip-when-unreachable guard, real ModbusTcpTransport usage, and inter-test isolation. Sets up the harness-and-catalog structure future device families (Allen-Bradley Micrologix, Siemens S7-1200 Modbus gateway, Schneider M340, whatever the user hits) will slot into — same per-device catalog shape, cross-device patterns section for recurring quirks that can get promoted into driver defaults. Next concrete PRs proposed: PR 26 for the integration test project scaffold + DL205 profile + fixture with skip-guard + one smoke test, PR 27+ for the individual confirmed quirks one-per-PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:45:21 -04:00
163c821e74 Merge pull request 'Phase 3 PR 24 — Modbus PLC data type extensions' (#23) from phase-3-pr24-modbus-types into v2 2026-04-18 12:32:55 -04:00
Joseph Doherty
eea31dcc4e Phase 3 PR 24 — Modbus PLC data type extensions. Extends ModbusDataType beyond the textbook Int16/UInt16/Int32/UInt32/Float32 set with Int64/UInt64/Float64 (4-register types), BitInRegister (single bit within a holding register, BitIndex 0-15 LSB-first), and String (ASCII packed 2 chars per register with StringLength-driven sizing). Adds ModbusByteOrder enum on ModbusTagDefinition covering the two word-orderings that matter in the real PLC population: BigEndian (ABCD — Modbus TCP standard, Schneider PLCs that follow it strictly) and WordSwap (CDAB — Siemens S7 family, several Allen-Bradley series, some Modicon families). NormalizeWordOrder helper reverses word pairs in-place for 32-bit values and reverses all four words for 64-bit values (keeps bytes big-endian within each register, which is universal; swaps only the word positions). Internal codec surface switched from (bytes, ModbusDataType) pairs to (bytes, ModbusTagDefinition) because the tag carries the ByteOrder + BitIndex + StringLength context the codec needs; RegisterCount similarly takes the tag so strings can compute ceil(StringLength/2). DriverDataType mapping in MapDataType extended to cover the new logical types — Int64/UInt64 widen to Int32 (PR 25 follow-up: extend DriverDataType enum with Int64 to avoid precision loss), Float64 maps to DriverDataType.Float64, String maps to DriverDataType.String, BitInRegister surfaces as Boolean, all other mappings preserved. BitInRegister writes throw a deliberate InvalidOperationException with a 'read-modify-write' hint — to atomically flip a single bit the driver needs to FC03 the register, OR/AND in the bit, then FC06 it back; that's a separate PR because the bit-modify atomicity story needs a per-register mutex and optional compare-and-write semantics. Everything else (decoder paths for both byte orders, Int64/UInt64/Float64 encode + decode, bit-index extraction across both register halves, String nul-truncation on decode, String nul-padding on encode) ships here. Tests (21 new ModbusDataTypeTests): RegisterCount_returns_correct_register_count_per_type theory (10 rows covering every numeric type); RegisterCount_for_String_rounds_up_to_register_pair theory (5 rows including the 0-char edge case that returns 0 registers); Int32_BigEndian_decodes_ABCD_layout + Int32_WordSwap_decodes_CDAB_layout + Float32_WordSwap_encode_decode_roundtrips (covers the two most-common 32-bit orderings); Int64_BigEndian_roundtrips + UInt64_WordSwap_reverses_four_words (word-swap on 64-bit reverses the four-word layout explicitly, with the test computing the expected wire shape by hand rather than trusting the implementation) + Float64_roundtrips_under_word_swap (3.14159265358979 survives the round-trip with 1e-12 tolerance); BitInRegister_extracts_bit_at_index theory (6 rows including LSB, MSB, and arbitrary bits in a multi-bit mask); BitInRegister_write_is_not_supported_in_PR24 (asserts the exception message steers the reader to the 'read-modify-write' follow-up); String_decodes_ASCII_packed_two_chars_per_register (decodes 'HELLO!' from 3 packed registers with the 'HELLO!'u8 test-only UTF-8 literal which happens to equal the ASCII bytes for this ASCII input); String_decode_truncates_at_first_nul ('Hi' padded with nuls reads back as 'Hi'); String_encode_nul_pads_remaining_bytes (short input writes remaining bytes as 0). Full solution: 0 errors, 217 unit + integration tests pass (22 + 30 new Modbus = 52 Modbus total, 165 pre-existing). ModbusDriver capability footprint now matches the most common industrial PLC workloads — Siemens S7 + Allen-Bradley + Modicon all supported via ByteOrder config without driver forks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:27:12 -04:00
8a692d4ba8 Merge pull request 'Phase 3 PR 23 — Modbus IHostConnectivityProbe' (#22) from phase-3-pr23-modbus-probe into v2 2026-04-18 12:23:04 -04:00
Joseph Doherty
268b12edec Phase 3 PR 23 — Modbus IHostConnectivityProbe. ModbusDriver now implements 6 of 8 capability interfaces (adds IHostConnectivityProbe alongside IDriver + ITagDiscovery + IReadable + IWritable + ISubscribable from the earlier PRs). Background probe loop kicks off in InitializeAsync when ModbusProbeOptions.Enabled is true, sends a cheap FC03 read-1-register at ProbeAddress (default 0) every Interval (default 5s) with a per-tick Timeout (default 2s), and tracks the single Modbus endpoint's state in the HostState machine. Initial state = Unknown; first successful probe transitions to Running; any transport/timeout failure transitions to Stopped; recovery transitions back to Running. OnHostStatusChanged fires exactly on transitions (not on repeat successes — prevents event-spam on a healthy connection). HostName format is 'host:port' so the Admin UI can display the endpoint uniformly with Galaxy platforms/engines in the fleet status dashboard. GetHostStatuses returns a single-item list with the current state + last-change timestamp (Modbus driver talks to exactly one endpoint per instance — operators spin up multiple driver instances for multi-PLC deployments). ShutdownAsync cancels the probe CTS before tearing down the transport so the loop can't log a spurious Stopped after intentional shutdown (OperationCanceledException caught separately from the 'real' transport errors). ModbusDriverOptions extended with ModbusProbeOptions sub-record (Enabled default true, Interval 5s, Timeout 2s, ProbeAddress ushort for PLCs that have register-0 policies; most PLCs tolerate an FC03 at 0 but some industrial gateways lock it). Tests (7 new ModbusProbeTests): Initial_state_is_Unknown_before_first_probe_tick (probe disabled, state stays Unknown, HostName formatted); First_successful_probe_transitions_to_Running (enabled, waits for probe count + event queue, asserts Unknown → Running with correct OldState/NewState); Transport_failure_transitions_to_Stopped (flip fake.Reachable = false mid-run, wait for state diff); Recovery_transitions_Stopped_back_to_Running (up → down → up, asserts ≥ 3 transitions); Repeated_successful_probes_do_not_generate_duplicate_Running_events (several hundred ms of stable probes, count stays at 1); Disabled_probe_stays_Unknown_and_fires_no_events (safety guard when operator wants to disable probing); Shutdown_stops_the_probe_loop (probe count captured at shutdown, delay 400ms, assert ≤ 1 extra to tolerate the narrow race where an in-flight tick completes after shutdown — the contract is 'no new ticks scheduled' not 'instantaneous freeze'). FlappyTransport fake exposes a volatile Reachable flag so tests can toggle the PLC availability mid-run, + ProbeCount counter so tests can assert the loop actually issued requests. WaitForStateAsync helper polls GetHostStatuses up to a deadline; tolerates scheduler jitter on slow CI runners. Full solution: 0 errors, 202 unit + integration tests pass (22 Modbus + 180 pre-existing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:12:00 -04:00
edce1be742 Merge pull request 'Phase 3 PR 22 — Modbus ISubscribable via polling overlay' (#21) from phase-3-pr22-modbus-subscribe into v2 2026-04-18 12:07:51 -04:00
Joseph Doherty
18b3e24710 Phase 3 PR 22 — Modbus ISubscribable via polling overlay. Modbus has no push model at the wire protocol (unlike MXAccess's OnDataChange callback or OPC UA's own Subscription service), so the driver layers a per-subscription polling loop on top of the existing IReadable path: SubscribeAsync returns an opaque ModbusSubscriptionHandle, starts a background Task.Run that sleeps for the requested publishing interval (floored to 100ms so a misconfigured sub-10ms request doesn't hammer the PLC), reads every subscribed tag through the same FC01/03/04 path the one-shot ReadAsync uses, diffs the returned DataValueSnapshot against the last known value per tag, and raises OnDataChange exactly when (a) it's the first poll (initial-data push per OPC UA Part 4 convention) or (b) boxed value changed or (c) StatusCode changed — stable values don't generate event traffic after the initial push, matching the v1 MXAccess OnDataChange shape. SubscriptionState record holds the handle + tag list + interval + per-subscription CancellationTokenSource + ConcurrentDictionary<string,DataValueSnapshot> LastValues; UnsubscribeAsync removes the state from _subscriptions and cancels the CTS, stopping the polling loop cleanly. Multiple overlapping subscriptions each get their own polling Task so a slow PLC on one subscription can't stall the others. ShutdownAsync cancels every active subscription CTS before tearing down the transport so the driver doesn't leave orphaned polling tasks pumping requests against a disposed socket. Transient poll errors are swallowed inside the loop (the loop continues to the next tick) — the driver's health surface reflects the last-known Degraded state from the underlying ReadAsync path. OperationCanceledException is caught separately to exit the loop silently on unsubscribe/shutdown. Tests (6 new ModbusSubscriptionTests): Initial_poll_raises_OnDataChange_for_every_subscribed_tag asserts the initial-data push fires once per tag in the subscribe call (2 tags → 2 events with FullReference='Level' and FullReference='Temp'); Unchanged_values_do_not_raise_after_initial_poll lets the loop run ~5 cycles at 100ms with a stable register value, asserts only the initial push fires (no event spam on stable tags); Value_change_between_polls_raises_OnDataChange mutates the fake register bank between poll ticks and asserts a second event fires with the new value (verified via e.Snapshot.Value.ShouldBe((short)200)); Unsubscribe_stops_the_polling_loop captures the event count right after UnsubscribeAsync, mutates a register that would have triggered a change if polling continued, asserts the count stays the same after 400ms; SubscribeAsync_floors_intervals_below_100ms passes a 10ms interval + asserts only 1 event fires across 300ms (if the floor weren't enforced we'd see 30 — the test asserts the floor semantically by counting events on stable data); Multiple_subscriptions_fire_independently creates two subs on different tags, unsubscribes only one, mutates the other's tag, asserts only the surviving sub emits while the unsubscribed one stays at its pre-unsubscribe count. FakeTransport in this test file is scoped to FC03 only since that's all the subscription path exercises — keeps the test doubles minimal and the failure modes obvious. WaitForCountAsync helper polls a ConcurrentQueue up to a deadline, makes the tests tolerant of scheduler jitter on slow CI runners. Full solution: 0 errors, 195 tests pass (6 new subscription + 9 existing Modbus + 180 pre-existing). ModbusDriver now implements IDriver + ITagDiscovery + IReadable + IWritable + ISubscribable — five of the eight capability interfaces. IAlarmSource + IHistoryProvider remain unimplemented because Modbus has no wire-level alarm or history semantics; IHostConnectivityProbe is a plausible future addition (treat transport disconnect as a Stopped signal) but needs the socket-level connection-state tracking plumbed through IModbusTransport which is its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:03:39 -04:00
f6a12dafe9 Merge pull request 'Phase 3 PR 21 — Modbus TCP driver (first native-protocol greenfield)' (#20) from phase-3-pr21-modbus-driver into v2 2026-04-18 11:58:20 -04:00
Joseph Doherty
058c3dddd3 Phase 3 PR 21 — Modbus TCP driver: first native-protocol greenfield for v2. New src/Driver.Modbus project with ModbusDriver implementing IDriver + ITagDiscovery + IReadable + IWritable. Validates the driver-agnostic abstractions (IAddressSpaceBuilder, DriverAttributeInfo, DataValueSnapshot, WriteRequest/WriteResult) generalize beyond Galaxy — nothing Galaxy-specific is used here. ModbusDriverOptions carries Host/Port/UnitId/Timeout + a pre-declared tag list (Modbus has no discovery protocol — tags are configuration). IModbusTransport abstracts the socket layer so tests swap in-memory fakes; concrete ModbusTcpTransport speaks the MBAP ADU (TxId + Protocol=0 + Length + UnitId + PDU) over TcpClient, serializes requests through a semaphore for single-flight in-order responses, validates the response TxId matches, surfaces server exception PDUs as ModbusException with function code + exception code. DiscoverAsync streams one folder per driver with a BaseDataVariable per tag + DriverAttributeInfo that flags writable tags as SecurityClassification.Operate vs ViewOnly for read-only regions. ReadAsync routes per-tag by ModbusRegion: FC01 for Coils, FC02 for DiscreteInputs, FC03 for HoldingRegisters, FC04 for InputRegisters; register values decoded through System.Buffers.Binary.BinaryPrimitives (BigEndian for single-register Int16/UInt16 + two-register Int32/UInt32/Float32 per standard modbus word-swap conventions). WriteAsync uses FC05 (Write Single Coil with 0xFF00/0x0000 encoding) for booleans, FC06 (Write Single Register) for 16-bit types, FC16 (Write Multiple Registers) for 32-bit types. Unknown tag → BadNodeIdUnknown; write to InputRegister or DiscreteInput or Writable=false tag → BadNotWritable; exception during transport → BadInternalError + driver health Degraded. Subscriptions + Historian + Alarms deliberately out of scope — Modbus has no push model (subscribe would be a polling overlay, additive PR) and no history/alarm semantics at the protocol level. Tests (9 new ModbusDriverTests): InitializeAsync connects + populates the tag map + sets health=Healthy; Read Int16 from HoldingRegister returns BigEndian value; Read Float32 spans two registers BigEndian (IEEE 754 single for 25.5f round-trips exactly); Read Coil returns boolean from the bit-packed response; unknown tag name returns BadNodeIdUnknown without an exception; Write UInt16 round-trips via FC06; Write Float32 uses FC16 (two-register write verified by decoding back through the fake register bank); Write to InputRegister returns BadNotWritable; Discover streams one folder + one variable per tag with correct DriverDataType mapping (Int16/Int32→Int32, UInt16/UInt32→Int32, Float32→Float32, Bool→Boolean). FakeTransport simulates a 256-register/256-coil bank + implements the 7 function codes the driver uses. slnx updated with the new src + tests entries. Full solution post-add: 0 errors, 189 tests pass (9 Modbus + 180 pre-existing). IDriver abstraction validated against a fundamentally different protocol — Modbus TCP has no AlarmExtension, no ScanState, no IPC boundary, no historian, no LDAP — and the same builder/reader/writer contract plugged straight in. Future PRs on this driver: ISubscribable via a polling loop, IHostConnectivityProbe for dead-device detection, PLC-specific data-type extensions (Int64/BCD/string-in-registers).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:55:21 -04:00
52791952dd Merge pull request 'Phase 3 PR 20 — lmx-followups.md' (#19) from phase-3-pr20-lmx-followups into v2 2026-04-18 11:50:38 -04:00
Joseph Doherty
860deb8e0d Phase 3 PR 20 — lmx-followups.md: track remaining Galaxy-bridge tasks after PR 19 (HistoryReadAtTime/Events Proxy wiring, write-gating by role, Admin UI cert trust, live-LDAP integration test, full-stack Galaxy smoke, multi-driver test, per-host dashboard). Documents what each item depends on, the shipped surface it builds on, and the minimal to-do so a future session can pick any one off in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:43:15 -04:00
f5e7173de3 Merge pull request 'Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile' (#18) from phase-3-pr19-ldap-security into v2 2026-04-18 11:36:18 -04:00
Joseph Doherty
22d3b0d23c Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile. Replaces the anonymous-only endpoint with a configurable security profile and an LDAP-backed UserName token validator. New IUserAuthenticator abstraction in Backend/Security/: LdapUserAuthenticator binds to the configured directory (reuses the pattern from Admin.Security.LdapAuthService without the cross-app dependency — Novell.Directory.Ldap.NETStandard 3.6.0 package ref added to Server alongside the existing OPCFoundation packages) and maps group membership to OPC UA roles via LdapOptions.GroupToRole (case-insensitive). DenyAllUserAuthenticator is the default when Ldap.Enabled=false so UserName token attempts return a clean BadUserAccessDenied rather than hanging on a localhost:3893 bind attempt. OpcUaSecurityProfile enum + LdapOptions nested record on OpcUaServerOptions. Profile=None keeps the PR 17 shape (SecurityPolicies.None + Anonymous token only) so existing integration tests stay green; Profile=Basic256Sha256SignAndEncrypt adds a second ServerSecurityPolicy (Basic256Sha256 + SignAndEncrypt) to the collection and, when Ldap.Enabled=true, adds a UserName token policy scoped to SecurityPolicies.Basic256Sha256 only — passwords must ride an encrypted channel, the stack rejects UserName over None. OtOpcUaServer.OnServerStarted hooks SessionManager.ImpersonateUser: AnonymousIdentityToken passes through; UserNameIdentityToken delegates to IUserAuthenticator.AuthenticateAsync — rejected identities throw ServiceResultException(BadUserAccessDenied); accepted identities get a RoleBasedIdentity that carries the resolved roles through session.Identity so future PRs can gate writes by role. OpcUaApplicationHost + OtOpcUaServer constructors take IUserAuthenticator as a dependency. Program.cs binds the new OpcUaServer:Ldap section from appsettings (Enabled defaults false, GroupToRole parsed as Dictionary<string,string>), registers IUserAuthenticator as LdapUserAuthenticator when enabled or DenyAllUserAuthenticator otherwise. PR 17 integration test updated to pass DenyAllUserAuthenticator so it keeps exercising the anonymous-only path unchanged. Tests — SecurityConfigurationTests (new, 13 cases): DenyAllAuthenticator rejects every credential; LdapAuthenticator rejects blank creds without hitting the server; rejects when Enabled=false; rejects plaintext when both UseTls=false AND AllowInsecureLdap=false (safety guard matching the Admin service); EscapeLdapFilter theory (4 rows: plain passthrough, parens/asterisk/backslash → hex escape) — regression guard against LDAP injection; ExtractOuSegment theory (3 rows: finds ou=, returns null when absent, handles multiple ou segments by returning first); ExtractFirstRdnValue theory (3 rows: strips cn= prefix, handles single-segment DN, returns plain string unchanged when no =). OpcUaServerOptions_default_is_anonymous_only asserts the default posture preserves PR 17 behavior. InternalsVisibleTo('ZB.MOM.WW.OtOpcUa.Server.Tests') added to Server csproj so ExtractOuSegment and siblings are reachable from the tests. Full solution: 0 errors, 180 tests pass (8 Core + 14 Proxy + 24 Configuration + 6 Shared + 91 Galaxy.Host + 19 Server (17 unit + 2 integration) + 18 Admin). Live-LDAP integration test (connect via Basic256Sha256 endpoint with a real user from GLAuth, assert the session.Identity carries the mapped role) is deferred to a follow-up — it requires the GLAuth dev instance to be running at localhost:3893 which is dev-machine-specific, and the test harness for that also needs a fresh client-side certificate provisioned by the live server's trusted store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:49:46 -04:00
55696a8750 Merge pull request 'Phase 3 PR 18 — delete v1 archived projects' (#17) from phase-3-pr18-delete-v1 into v2 2026-04-18 08:41:56 -04:00
76 changed files with 7872 additions and 29 deletions

View File

@@ -8,6 +8,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
@@ -20,8 +21,11 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>

View File

@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
### Active Directory configuration
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
```json
{
"OpcUaServer": {
"Ldap": {
"Enabled": true,
"Server": "dc01.corp.example.com",
"Port": 636,
"UseTls": true,
"AllowInsecureLdap": false,
"SearchBase": "DC=corp,DC=example,DC=com",
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
"ServiceAccountPassword": "<from your secret store>",
"DisplayNameAttribute": "displayName",
"GroupAttribute": "memberOf",
"UserNameAttribute": "sAMAccountName",
"GroupToRole": {
"OPCUA-Operators": "WriteOperate",
"OPCUA-Engineers": "WriteConfigure",
"OPCUA-AlarmAck": "AlarmAck",
"OPCUA-Tuners": "WriteTune"
}
}
}
}
```
Notes:
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
### Security Considerations
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.

154
docs/v2/lmx-followups.md Normal file
View File

@@ -0,0 +1,154 @@
# LMX Galaxy bridge — remaining follow-ups
State after PR 19: the Galaxy driver is functionally at v1 parity through the
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
Basic256Sha256 endpoints and alarms are observable through
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
specific before the stack can fully replace the v1 deployment, in
rough priority order.
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler
wiring in `DriverNodeManager` remains as the next step; integration-test still
pending.
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
(default throwing implementations so existing impls keep compiling), added the
`HistoricalEvent` + `HistoricalEventsResult` records to
`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top
of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`)
is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`.
**Remaining**:
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
`AtTime` + `Events` service handlers.
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
## 2. Write-gating by role — **DONE (PR 26)**
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
`SecurityClassification` → required role (`FreeAccess` → no role required,
`Operate`/`SecuredWrite``WriteOperate`, `Tune``WriteTune`,
`Configure`/`VerifiedWrite``WriteConfigure`, `ViewOnly` → deny regardless).
`DriverNodeManager` caches the classification per variable during discovery and
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
can't write a `Tune` attribute unless it also carries `WriteTune`.
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
that authz stays at the server layer and never delegates to driver-specific auth.
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
Admin host that can reach the shared PKI directory.
Operator actions: Trust (moves `rejected/certs/*.der``trusted/certs/*.der`),
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
each new client handshake, so no explicit reload signal is needed —
operators retry the rejected client's connection after trusting.
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
deployment default. That's a production-hardening config change, not a code
gap — the Admin UI is now ready to be the trust gate.
## 4. Live-LDAP integration test — **DONE (PR 31)**
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
when the port is unreachable. Covers: valid bind, wrong password, unknown
user, empty credentials, single-group → WriteOperate mapping, multi-group
admin user surfacing all mapped roles.
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
compat) so Active Directory deployments can configure `sAMAccountName` /
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
`docs/security.md` §"Active Directory configuration" for the AD appsettings
snippet.
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
drive a full OPC UA session with username/password, then read an
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
That needs a test-only address-space node and is a separate PR.
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
every dependency a live smoke test needs and produces actionable skip
messages.
PR 37 shipped the live-stack smoke test project structure:
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
never spawns the Host process) and `LiveStackSmokeTests` covering:
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
- Driver reports `DriverState.Healthy` post-handshake.
- `DiscoverAsync` returns at least one variable from the live Galaxy.
- `GetHostStatuses` reports at least one Platform/AppEngine host.
- `ReadAsync` on a discovered variable round-trips through
Proxy → Host pipe → MXAccess → back without a BadInternalError.
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
registry-stored Environment values (requires elevated test host).
**Remaining**:
- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box
(`scripts/install/Install-Services.ps1`) so the skip-on-unready tests
actually execute and the smoke PR lands green.
- Subscribe-and-receive-data-change fact (needs a known tag that actually
ticks; deferred until operators confirm a scratch tag exists).
- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag
so we can't accidentally mutate a process-critical value).
## 6. Second driver instance on the same server — **DONE (PR 32)**
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
NamespaceUris, (2) browsing one subtree returns that driver's folder and
does NOT leak the other driver's folder, (3) reads route to the correct
driver — the alpha instance returns 42 while beta returns 99, so a misroute
would surface at the assertion layer.
Deferred: the alarm-event multi-driver parity case (two drivers each raising
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
condition node). Alarm tracking already has its own integration test
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
`IAlarmSource` that's worth its own focused PR.
## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
`LastSeenUtc`).
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
`BackgroundService` in the Server process that walks every registered
`IHostConnectivityProbe`-capable driver every 10s, calls
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
groups by cluster, shows four summary cards (Hosts / Running / Stale /
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
operators see crashed Servers without waiting for a state change.
Deferred as follow-ups:
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
10s polling is fine for operator-scale use.
- Failure-count column — needs the publisher to track a transition history
per host, not just current-state.
- SignalR fan-out to the Admin page (currently the page polls the DB, not
a hub). The DB-polled version is fine at current cadence but a hub push
would eliminate the 10s race where a new row sits in the DB before the
Admin page notices.

108
docs/v2/modbus-test-plan.md Normal file
View File

@@ -0,0 +1,108 @@
# Modbus driver — test plan + device-quirk catalog
The Modbus TCP driver unit tests (PRs 2124) cover the protocol surface against an
in-memory fake transport. They validate the codec, state machine, and function-code
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
populations disagree with the spec in small, device-specific ways, and a driver that
passes textbook tests can still misbehave against actual equipment.
This doc is the harness-and-quirks playbook. The project it describes lives at
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
- Scriptable enough to mimic device-specific behaviors (non-standard register
layouts, custom exception codes, intentional response delays).
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
simulator endpoint) isn't reachable.
- Free + long-maintained — physical PLC bench is unavailable in most dev
environments, and renting cloud PLCs isn't worth the per-test cost.
**Setup pattern** (not yet codified in a script — will land alongside the integration
test project):
1. Install ModbusPal, load the per-device `.xmpp` profile from
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
2. Start the simulator listening on `localhost:502` (or override via
`MODBUS_SIM_ENDPOINT` env var).
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
unreachable, so forgetting to start the simulator doesn't wedge CI.
## Per-device quirk catalog
### AutomationDirect DL205
First known target device. Quirks to document and cover with named tests (to be
filled in when user validates each behavior in ModbusPal with a DL205 profile):
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
register 0 with exception code 02 (illegal data address). If confirmed, the
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
triggers the rejection and operators must override; test name:
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
coil addresses; verify the driver's zero-based addressing matches the physical
PLC without an off-by-one adjustment.
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
models enforce a lower limit (e.g., 64). Test name:
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
- **Response framing under sustained load**: _pending_ — the driver's
single-flight semaphore assumes the server pairs requests/responses by
transaction id; at least one DL205 firmware revision is reported to drop the
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
path to `ModbusTcpTransport`.
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
setups protect internal coils; the driver should surface the PLC's exception
PDU as `BadNotWritable` rather than `BadInternalError`.
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
marker with the confirmed behavior and file a named test in the integration suite.
### Future devices
One section per device class, same shape as DL205. Quirks that apply across
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
patterns section below once we have enough data points.
## Cross-device patterns
Once multiple device catalogs accumulate, quirks that recur across two or more
vendors get promoted into driver defaults or opt-in options:
- _(empty — filled in as catalogs grow)_
## Test conventions
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
filtering by device class trivial (`--filter "DisplayName~DL205"`).
- **Skip with a clear SkipReason.** Follow the pattern from
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
it's set. Don't throw — skipped tests read cleanly in CI logs.
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
deliberately not used here — its value is speed + determinism, which doesn't
help reproduce device-specific issues.
- **Don't depend on ModbusPal state between tests.** Each test resets the
simulator's register bank or uses a unique address range. Avoid relying on
"previous test left value at register 10" setups that flake when tests run in
parallel or re-order.
## Next concrete PRs
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
(write-then-read round-trip). `ModbusPal/` directory holds the README
pointing at the to-be-committed `DL205.xmpp` profile.
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.

View File

@@ -5,15 +5,18 @@
<h5 class="mb-4">OtOpcUa Admin</h5>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
</ul>
<div class="mt-5">
<AuthorizeView>
<Authorized>
<div class="small text-light">
Signed in as <strong>@context.User.Identity?.Name</strong>
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
</div>
<div class="small text-muted">
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))

View File

@@ -0,0 +1,129 @@
@page "/account"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using System.Security.Claims
@using ZB.MOM.WW.OtOpcUa.Admin.Services
<h1 class="mb-4">My account</h1>
<AuthorizeView>
<Authorized>
@{
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
var displayName = context.User.Identity?.Name ?? "—";
var roles = context.User.Claims
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
var ldapGroups = context.User.Claims
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
}
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Identity</h5>
<dl class="row mb-0">
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Admin roles</h5>
@if (roles.Count == 0)
{
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
}
else
{
<div class="mb-2">
@foreach (var r in roles)
{
<span class="badge bg-primary me-1">@r</span>
}
</div>
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
}
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Capabilities</h5>
<p class="text-muted small">
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
is the ground truth — this table mirrors it for readability.
</p>
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Capability</th>
<th>Required role(s)</th>
<th class="text-end">You have it?</th>
</tr>
</thead>
<tbody>
@foreach (var cap in Capabilities)
{
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
<tr>
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
<td>@string.Join(" or ", cap.RequiredRoles)</td>
<td class="text-end">
@if (has)
{
<span class="badge bg-success">Yes</span>
}
else
{
<span class="badge bg-secondary">No</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-4">
<form method="post" action="/auth/logout">
<button class="btn btn-outline-danger" type="submit">Sign out</button>
</form>
</div>
</Authorized>
</AuthorizeView>
@code {
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
// When a new page or policy is added, extend this list so operators can self-service check
// whether their session has access without trial-and-error navigation.
private static readonly IReadOnlyList<Capability> Capabilities =
[
new("View clusters + fleet status",
"Read-only access to the cluster list, fleet dashboard, and generation history.",
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
new("Edit configuration drafts",
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
new("Publish generations",
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
[AdminRoles.FleetAdmin]),
new("Manage certificate trust",
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
[AdminRoles.FleetAdmin]),
new("Manage external-ID reservations",
"Reserve / release external IDs that map into Galaxy contained names.",
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
];
}

View File

@@ -0,0 +1,154 @@
@page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject CertTrustService Certs
@inject AuthenticationStateProvider AuthState
@inject ILogger<Certificates> Log
<h1 class="mb-4">Certificate trust</h1>
<div class="alert alert-info small mb-4">
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
</div>
@if (_status is not null)
{
<div class="alert alert-@_statusKind alert-dismissible">
@_status
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
</div>
}
<h2 class="h4">Rejected (@_rejected.Count)</h2>
@if (_rejected.Count == 0)
{
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
}
else
{
<table class="table table-sm align-middle">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody>
@foreach (var c in _rejected)
{
<tr>
<td>@c.Subject</td>
<td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
@if (_trusted.Count == 0)
{
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
}
else
{
<table class="table table-sm align-middle">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody>
@foreach (var c in _trusted)
{
<tr>
<td>@c.Subject</td>
<td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private IReadOnlyList<CertInfo> _rejected = [];
private IReadOnlyList<CertInfo> _trusted = [];
private string? _status;
private string _statusKind = "success";
protected override void OnInitialized() => Reload();
private void Reload()
{
_rejected = Certs.ListRejected();
_trusted = Certs.ListTrusted();
}
private async Task TrustAsync(CertInfo c)
{
if (Certs.TrustRejected(c.Thumbprint))
{
await LogActionAsync("cert.trust", c);
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
}
Reload();
}
private async Task DeleteRejectedAsync(CertInfo c)
{
if (Certs.DeleteRejected(c.Thumbprint))
{
await LogActionAsync("cert.delete.rejected", c);
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
}
Reload();
}
private async Task UntrustAsync(CertInfo c)
{
if (Certs.UntrustCert(c.Thumbprint))
{
await LogActionAsync("cert.untrust", c);
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
}
Reload();
}
private async Task LogActionAsync(string action, CertInfo c)
{
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
// move/delete; this line ties the action to the authenticated admin user so the two logs
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
// cluster-scoped and cert actions are cluster-agnostic.
var state = await AuthState.GetAuthenticationStateAsync();
var user = state.User.Identity?.Name ?? "(anonymous)";
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
user, action, c.Thumbprint, c.Subject);
}
private void Set(string message, string kind)
{
_status = message;
_statusKind = kind;
}
private void ClearStatus() => _status = null;
private static string Short(string thumbprint) =>
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
}

View File

@@ -0,0 +1,172 @@
@page "/fleet"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<div class="alert alert-info">
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
this list is empty, either no nodes have been registered or the poller hasn't run yet.
</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Nodes</h6>
<div class="fs-3">@_rows.Count</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Applied</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Failed</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
</div></div>
</div>
</div>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Cluster</th>
<th>Generation</th>
<th>Status</th>
<th>Last applied</th>
<th>Last seen</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td>@r.ClusterId</td>
<td>@(r.GenerationId?.ToString() ?? "—")</td>
<td>
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td>
<td>@FormatAge(r.AppliedAt)</td>
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr>
}
</tbody>
</table>
}
@code {
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
// the most recent published state without polling ahead of the broadcaster.
private const int RefreshIntervalSeconds = 5;
private List<FleetNodeRow>? _rows;
private bool _refreshing;
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
state: null,
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
}
private async Task RefreshAsync()
{
if (_refreshing) return;
_refreshing = true;
try
{
using var scope = ScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
s.NodeId, n.ClusterId, s.CurrentGenerationId,
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
.OrderBy(r => r.ClusterId)
.ThenBy(r => r.NodeId)
.ToListAsync();
_rows = rows;
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
private static bool IsStale(FleetNodeRow r)
{
if (r.SeenAt is null) return true;
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
}
private static string RowClass(FleetNodeRow r) => r.Status switch
{
"Failed" => "table-danger",
_ when IsStale(r) => "table-warning",
_ => "",
};
private static string StatusBadge(string? status) => status switch
{
"Applied" => "bg-success",
"Failed" => "bg-danger",
"Applying" => "bg-info",
_ => "bg-secondary",
};
private static string FormatAge(DateTime? t)
{
if (t is null) return "—";
var age = DateTime.UtcNow - t.Value;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
public void Dispose() => _timer?.Dispose();
internal sealed record FleetNodeRow(
string NodeId, string ClusterId, long? GenerationId,
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}

View File

@@ -0,0 +1,160 @@
@page "/hosts"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Driver host status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
<div class="alert alert-info small mb-4">
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
30s are flagged Stale, which usually means the owning Server process has crashed or lost
its DB connection.
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<div class="alert alert-secondary">
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>.
</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Hosts</h6>
<div class="fs-3">@_rows.Count</div>
</div></div></div>
<div class="col-md-3"><div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Running</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
</div></div></div>
<div class="col-md-3"><div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(HostStatusService.IsStale)</div>
</div></div></div>
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Faulted</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
</div></div></div>
</div>
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
{
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
<table class="table table-sm table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Driver</th>
<th>Host</th>
<th>State</th>
<th>Last transition</th>
<th>Last seen</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
@foreach (var r in cluster)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td><code>@r.DriverInstanceId</code></td>
<td>@r.HostName</td>
<td>
<span class="badge @StateBadge(r.State)">@r.State</span>
@if (HostStatusService.IsStale(r))
{
<span class="badge bg-warning text-dark ms-1">Stale</span>
}
</td>
<td class="small">@FormatAge(r.StateChangedUtc)</td>
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
</tr>
}
</tbody>
</table>
}
}
@code {
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
// produces stale-looking rows mid-cycle.
private const int RefreshIntervalSeconds = 10;
private List<HostStatusRow>? _rows;
private bool _refreshing;
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
state: null,
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
}
private async Task RefreshAsync()
{
if (_refreshing) return;
_refreshing = true;
try
{
using var scope = ScopeFactory.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
_rows = (await svc.ListAsync()).ToList();
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
private static string RowClass(HostStatusRow r) => r.State switch
{
DriverHostState.Faulted => "table-danger",
_ when HostStatusService.IsStale(r) => "table-warning",
_ => "",
};
private static string StateBadge(DriverHostState s) => s switch
{
DriverHostState.Running => "bg-success",
DriverHostState.Stopped => "bg-secondary",
DriverHostState.Faulted => "bg-danger",
_ => "bg-secondary",
};
private static string FormatAge(DateTime t)
{
var age = DateTime.UtcNow - t;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
public void Dispose() => _timer?.Dispose();
}

View File

@@ -47,6 +47,13 @@ builder.Services.AddScoped<NodeAclService>();
builder.Services.AddScoped<ReservationService>();
builder.Services.AddScoped<DraftValidationService>();
builder.Services.AddScoped<AuditLogService>();
builder.Services.AddScoped<HostStatusService>();
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
// filesystem operations.
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
builder.Services.AddSingleton<CertTrustService>();
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
builder.Services.Configure<LdapOptions>(

View File

@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Points the Admin UI at the OPC UA Server's PKI store root so
/// <see cref="CertTrustService"/> can list and move certs between the
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
/// </summary>
public sealed class CertTrustOptions
{
public const string SectionName = "CertTrust";
/// <summary>
/// Absolute path to the PKI root. Defaults to
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
/// default so a standard side-by-side install needs no override.
/// </summary>
public string PkiStoreRoot { get; init; } =
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"OtOpcUa", "pki");
}

View File

@@ -0,0 +1,135 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
/// it (for <see cref="CertStoreKind.Trusted"/>).
/// </summary>
public sealed record CertInfo(
string Thumbprint,
string Subject,
string Issuer,
DateTime NotBefore,
DateTime NotAfter,
string FilePath,
CertStoreKind Store);
public enum CertStoreKind
{
Rejected,
Trusted,
}
/// <summary>
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
/// with a filename derived from subject + thumbprint. This service exposes operators for the
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
/// </summary>
/// <remarks>
/// The Admin process is separate from the Server process; this service deliberately has no
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
/// (typical deployment has Admin + Server side-by-side on the same machine).
///
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
/// the Directory store on each new incoming connection, so there's no explicit signal
/// needed — the next client handshake picks up the change. Operators should retry the
/// rejected client's connection after trusting.
/// </remarks>
public sealed class CertTrustService
{
private readonly CertTrustOptions _options;
private readonly ILogger<CertTrustService> _logger;
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
{
_options = options.Value;
_logger = logger;
}
public string PkiStoreRoot => _options.PkiStoreRoot;
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
/// <summary>
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
/// silently — idempotent.
/// </summary>
public bool TrustRejected(string thumbprint)
{
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
if (cert is null) return false;
var trustedDir = CertsDir(CertStoreKind.Trusted);
Directory.CreateDirectory(trustedDir);
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
File.Move(cert.FilePath, destPath, overwrite: true);
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
return true;
}
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
{
var cert = FindInStore(store, thumbprint);
if (cert is null) return false;
File.Delete(cert.FilePath);
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
cert.Thumbprint, cert.Subject, store);
return true;
}
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
ListStore(store).FirstOrDefault(c =>
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
{
var dir = CertsDir(store);
if (!Directory.Exists(dir)) return [];
var results = new List<CertInfo>();
foreach (var path in Directory.EnumerateFiles(dir))
{
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
var ext = Path.GetExtension(path);
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
results.Add(new CertInfo(
cert.Thumbprint, cert.Subject, cert.Issuer,
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
path, store));
}
catch (Exception ex)
{
// A malformed file in the store shouldn't take down the page. Surface it in logs
// but skip — operators see the other certs and can clean the bad file manually.
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
}
}
return results;
}
private string CertsDir(CertStoreKind store) =>
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
}

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
/// groups by cluster and renders a per-node → per-driver → per-host tree.
/// </summary>
public sealed record HostStatusRow(
string NodeId,
string? ClusterId,
string DriverInstanceId,
string HostName,
DriverHostState State,
DateTime StateChangedUtc,
DateTime LastSeenUtc,
string? Detail);
/// <summary>
/// Read-side service for the Admin UI's per-host drill-down. Loads
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
/// </summary>
/// <remarks>
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
/// a generous buffer for clock skew and publisher GC pauses.
/// </remarks>
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
{
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
{
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
// the reporting server).
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
join n in db.ClusterNodes.AsNoTracking()
on s.NodeId equals n.NodeId into nodeJoin
from n in nodeJoin.DefaultIfEmpty()
orderby s.NodeId, s.DriverInstanceId, s.HostName
select new HostStatusRow(
s.NodeId,
n != null ? n.ClusterId : null,
s.DriverInstanceId,
s.HostName,
s.State,
s.StateChangedUtc,
s.LastSeenUtc,
s.Detail)).ToListAsync(ct);
return rows;
}
public static bool IsStale(HostStatusRow row) =>
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
}

View File

@@ -0,0 +1,61 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per-host connectivity snapshot the Server publishes for each driver's
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
/// rows, not 3, because each server node owns its own runtime view.
/// </summary>
/// <remarks>
/// <para>
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
/// drill-down). The publisher hosted service on the Server side subscribes to every
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
/// </para>
/// <para>
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
/// rather keep the status row than drop it. The Admin-side service left-joins on
/// NodeId when presenting rows.
/// </para>
/// </remarks>
public sealed class DriverHostStatus
{
/// <summary>Server node that's running the driver.</summary>
public required string NodeId { get; set; }
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
/// a display string.
/// </summary>
public required string HostName { get; set; }
public DriverHostState State { get; set; } = DriverHostState.Unknown;
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
public DateTime StateChangedUtc { get; set; }
/// <summary>
/// Advances on every publisher heartbeat — the Admin UI uses
/// <c>now - LastSeenUtc &gt; threshold</c> to flag rows whose owning Server has
/// stopped reporting (crashed, network-partitioned, etc.), independent of
/// <see cref="State"/>.
/// </summary>
public DateTime LastSeenUtc { get; set; }
/// <summary>
/// Optional human-readable detail populated when <see cref="State"/> is
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
/// driver's probe. Null for Running / Stopped / Unknown transitions.
/// </summary>
public string? Detail { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
/// Configuration project stays free of driver-runtime dependencies.
/// </summary>
/// <remarks>
/// The server-side publisher (follow-up PR) translates
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
/// </remarks>
public enum DriverHostState
{
Unknown,
Running,
Stopped,
Faulted,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverHostStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverHostStatus",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_LastSeen",
table: "DriverHostStatus",
column: "LastSeenUtc");
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_Node",
table: "DriverHostStatus",
column: "NodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverHostStatus");
}
}
}

View File

@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
{
b.Property<string>("NodeId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HostName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Detail")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("StateChangedUtc")
.HasColumnType("datetime2(3)");
b.HasKey("NodeId", "DriverInstanceId", "HostName");
b.HasIndex("LastSeenUtc")
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
b.HasIndex("NodeId")
.HasDatabaseName("IX_DriverHostStatus_Node");
b.ToTable("DriverHostStatus", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
{
b.Property<Guid>("DriverInstanceRowId")

View File

@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureClusterNodeGenerationState(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder);
ConfigureDriverHostStatus(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
});
}
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverHostStatus>(e =>
{
e.ToTable("DriverHostStatus");
// Composite key — one row per (server node, driver instance, probe-reported host).
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
// 6 rows because each server node owns its own runtime view; the composite key is
// what lets both views coexist without shadowing each other.
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.HostName).HasMaxLength(256);
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
e.Property(x => x.Detail).HasMaxLength(1024);
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
});
}
}

View File

@@ -30,6 +30,52 @@ public interface IHistoryProvider
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
/// <summary>
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
/// driver interpolates (or returns the prior-boundary sample) when no exact match
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
/// </summary>
/// <remarks>
/// Default implementation throws. Drivers opt in by overriding; keeps existing
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
/// they may not have a backend for.
/// </remarks>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
"Drivers whose backends support at-time reads override this method.");
/// <summary>
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
/// from the live event stream — historical rows come from an event historian (Galaxy's
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
/// </summary>
/// <param name="sourceName">
/// Optional filter: null means "all sources", otherwise restrict to events from that
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
/// </param>
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
/// <param name="maxEvents">Upper cap on returned events — the driver's backend enforces this.</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <remarks>
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
/// Wonderware Alarm &amp; Events log) override. Modbus / the OPC UA Client driver stay
/// with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
/// </remarks>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
DateTime endUtc,
int maxEvents,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadEventsAsync. " +
"Drivers whose backends have an event historian override this method.");
}
/// <summary>Result of a HistoryRead call.</summary>
@@ -48,3 +94,29 @@ public enum HistoryAggregateType
Total,
Count,
}
/// <summary>
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
/// <param name="Message">Human-readable message text.</param>
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint);

View File

@@ -339,6 +339,64 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
return new HistoryReadResult(samples, ContinuationPoint: null);
}
public async Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
var client = RequireClient();
var resp = await client.CallAsync<HistoryReadAtTimeRequest, HistoryReadAtTimeResponse>(
MessageKind.HistoryReadAtTimeRequest,
new HistoryReadAtTimeRequest
{
SessionId = _sessionId,
TagReference = fullReference,
TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())],
},
MessageKind.HistoryReadAtTimeResponse,
cancellationToken);
if (!resp.Success)
throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}");
// ReadAtTime returns one sample per requested timestamp in the same order — the Host
// pads with bad-quality snapshots when a timestamp can't be interpolated, so response
// length matches request length exactly. We trust that contract rather than
// re-aligning here, because the Host is the source-of-truth for interpolation policy.
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
return new HistoryReadResult(samples, ContinuationPoint: null);
}
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
{
var client = RequireClient();
var resp = await client.CallAsync<HistoryReadEventsRequest, HistoryReadEventsResponse>(
MessageKind.HistoryReadEventsRequest,
new HistoryReadEventsRequest
{
SessionId = _sessionId,
SourceName = sourceName,
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
MaxEvents = maxEvents,
},
MessageKind.HistoryReadEventsResponse,
cancellationToken);
if (!resp.Success)
throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}");
IReadOnlyList<HistoricalEvent> events = [.. resp.Events.Select(ToHistoricalEvent)];
return new HistoricalEventsResult(events, ContinuationPoint: null);
}
internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new(
EventId: wire.EventId,
SourceName: wire.SourceName,
EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime,
ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime,
Message: wire.DisplayText,
Severity: wire.Severity);
/// <summary>
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Abstraction over the Modbus TCP socket. Takes a <c>PDU</c> (function code + data, excluding
/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id
/// pairing, framing, and socket I/O. Tests supply in-memory fakes.
/// </summary>
public interface IModbusTransport : IAsyncDisposable
{
Task ConnectAsync(CancellationToken ct);
/// <summary>
/// Send a Modbus PDU (function code + function-specific data) and read the response PDU.
/// Throws <see cref="ModbusException"/> when the server returns an exception PDU
/// (function code + 0x80 + exception code).
/// </summary>
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
}
public sealed class ModbusException(byte functionCode, byte exceptionCode, string message)
: Exception(message)
{
public byte FunctionCode { get; } = functionCode;
public byte ExceptionCode { get; } = exceptionCode;
}

View File

@@ -0,0 +1,583 @@
using System.Buffers.Binary;
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
/// </summary>
/// <remarks>
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
/// + alarm capabilities are out of scope (the protocol doesn't express them).
/// </remarks>
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
// Active polling subscriptions. Each subscription owns a background Task that polls the
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextSubscriptionId;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
// display the PLC endpoint uniformly with Galaxy platforms/engines.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
private readonly ModbusDriverOptions _options = options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
private IModbusTransport? _transport;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
public string DriverInstanceId => driverInstanceId;
public string DriverType => "Modbus";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
_transport = _transportFactory(_options);
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
// PR 23: kick off the probe loop once the transport is up. Initial state stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any register round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken);
await InitializeAsync(driverConfigJson, cancellationToken);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
try { _probeCts?.Cancel(); } catch { }
_probeCts?.Dispose();
_probeCts = null;
foreach (var state in _subscriptions.Values)
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
}
_subscriptions.Clear();
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("Modbus", "Modbus");
foreach (var t in _options.Tags)
{
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false));
}
return Task.CompletedTask;
}
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var transport = RequireTransport();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
try
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
{
switch (tag.Region)
{
case ModbusRegion.Coils:
{
var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
return (resp[2] & 0x01) == 1;
}
case ModbusRegion.DiscreteInputs:
{
var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
return (resp[2] & 0x01) == 1;
}
case ModbusRegion.HoldingRegisters:
case ModbusRegion.InputRegisters:
{
var quantity = RegisterCount(tag);
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
// resp = [fc][byte-count][data...]
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
return DecodeRegister(data, tag);
}
default:
throw new InvalidOperationException($"Unknown region {tag.Region}");
}
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
var transport = RequireTransport();
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
{
results[i] = new WriteResult(StatusBadNodeIdUnknown);
continue;
}
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
{
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
try
{
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
catch (Exception)
{
results[i] = new WriteResult(StatusBadInternalError);
}
}
return results;
}
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
switch (tag.Region)
{
case ModbusRegion.Coils:
{
var on = Convert.ToBoolean(value);
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
on ? (byte)0xFF : (byte)0x00, 0x00 };
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
return;
}
case ModbusRegion.HoldingRegisters:
{
var bytes = EncodeRegister(value, tag);
if (bytes.Length == 2)
{
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
bytes[0], bytes[1] };
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
}
else
{
// FC 16 (Write Multiple Registers) for 32-bit types
var qty = (ushort)(bytes.Length / 2);
var pdu = new byte[6 + 1 + bytes.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
pdu[5] = (byte)bytes.Length;
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
}
return;
}
default:
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
}
}
// ---- ISubscribable (polling overlay) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
: publishingInterval;
var handle = new ModbusSubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
return Task.FromResult<ISubscriptionHandle>(handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
state.Cts.Cancel();
state.Cts.Dispose();
}
return Task.CompletedTask;
}
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
// current value per Part 4 convention, even if the value never changes thereafter.
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* first-read error — polling continues */ }
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* transient polling error — loop continues, health surface reflects it */ }
}
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
}
}
}
private sealed record SubscriptionState(
ModbusSubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"modbus-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
/// <summary>
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
private async Task ProbeLoopAsync(CancellationToken ct)
{
var transport = _transport; // captured reference; disposal tears the loop down via ct
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
var pdu = new byte[] { 0x03,
(byte)(_options.Probe.ProbeAddress >> 8),
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
success = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch
{
// transport / timeout / exception PDU — treated as Stopped below
}
TransitionTo(success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
// ---- codec ----
/// <summary>
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
/// from 2 chars per register).
/// </summary>
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
{
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
};
/// <summary>
/// Word-swap the input into the big-endian layout the decoders expect. For 2-register
/// types this reverses the two words; for 4-register types it reverses the four words
/// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
/// </summary>
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
{
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
var result = new byte[data.Length];
for (var word = 0; word < data.Length / 2; word++)
{
var srcWord = data.Length / 2 - 1 - word;
result[word * 2] = data[srcWord * 2];
result[word * 2 + 1] = data[srcWord * 2 + 1];
}
return result;
}
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
{
switch (tag.DataType)
{
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
case ModbusDataType.BitInRegister:
{
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
return (raw & (1 << tag.BitIndex)) != 0;
}
case ModbusDataType.Int32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadInt32BigEndian(b);
}
case ModbusDataType.UInt32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadUInt32BigEndian(b);
}
case ModbusDataType.Float32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadSingleBigEndian(b);
}
case ModbusDataType.Int64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadInt64BigEndian(b);
}
case ModbusDataType.UInt64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadUInt64BigEndian(b);
}
case ModbusDataType.Float64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadDoubleBigEndian(b);
}
case ModbusDataType.String:
{
// ASCII, 2 chars per register, packed high byte = first char.
// Respect the caller's StringLength (truncate nul-padded regions).
var chars = new char[tag.StringLength];
for (var i = 0; i < tag.StringLength; i++)
{
var b = data[i];
if (b == 0) { return new string(chars, 0, i); }
chars[i] = (char)b;
}
return new string(chars);
}
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}
}
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
{
switch (tag.DataType)
{
case ModbusDataType.Int16:
{
var v = Convert.ToInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
}
case ModbusDataType.UInt16:
{
var v = Convert.ToUInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
}
case ModbusDataType.Int32:
{
var v = Convert.ToInt32(value);
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.UInt32:
{
var v = Convert.ToUInt32(value);
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Float32:
{
var v = Convert.ToSingle(value);
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Int64:
{
var v = Convert.ToInt64(value);
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.UInt64:
{
var v = Convert.ToUInt64(value);
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Float64:
{
var v = Convert.ToDouble(value);
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.String:
{
var s = Convert.ToString(value) ?? string.Empty;
var regs = (tag.StringLength + 1) / 2;
var b = new byte[regs * 2];
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
// remaining bytes stay 0 — nul-padded per PLC convention
return b;
}
case ModbusDataType.BitInRegister:
throw new InvalidOperationException(
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}
}
private static DriverDataType MapDataType(ModbusDataType t) => t switch
{
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
ModbusDataType.Float32 => DriverDataType.Float32,
ModbusDataType.Float64 => DriverDataType.Float64,
ModbusDataType.String => DriverDataType.String,
_ => DriverDataType.Int32,
};
private IModbusTransport RequireTransport() =>
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadNodeIdUnknown = 0x80340000u;
private const uint StatusBadNotWritable = 0x803B0000u;
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
}
}

View File

@@ -0,0 +1,97 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
/// </summary>
public sealed class ModbusDriverOptions
{
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 502;
public byte UnitId { get; init; } = 1;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
/// <see cref="IHostConnectivityProbe"/>.
/// </summary>
public ModbusProbeOptions Probe { get; init; } = new();
}
public sealed class ModbusProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
public ushort ProbeAddress { get; init; } = 0;
}
/// <summary>
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
/// the documentation's 1-based coil/register conventions). Multi-register types
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
/// </summary>
/// <param name="Name">
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
/// unique within the driver.
/// </param>
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
/// <param name="Address">Zero-based address within the region.</param>
/// <param name="DataType">
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
/// </param>
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
public sealed record ModbusTagDefinition(
string Name,
ModbusRegion Region,
ushort Address,
ModbusDataType DataType,
bool Writable = true,
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
byte BitIndex = 0,
ushort StringLength = 0);
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
public enum ModbusDataType
{
Bool,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
/// <summary>Single bit within a holding register. <see cref="ModbusTagDefinition.BitIndex"/> selects 0-15 LSB-first.</summary>
BitInRegister,
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
String,
}
/// <summary>
/// Word ordering for multi-register types. Modbus TCP standard is <see cref="BigEndian"/>
/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several
/// Allen-Bradley series, some Modicon families — use <see cref="WordSwap"/> (CDAB), which
/// keeps bytes big-endian within each register but reverses the word pair(s).
/// </summary>
public enum ModbusByteOrder
{
BigEndian,
WordSwap,
}

View File

@@ -0,0 +1,113 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
/// support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs.
/// </summary>
public sealed class ModbusTcpTransport : IModbusTransport
{
private readonly string _host;
private readonly int _port;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private TcpClient? _client;
private NetworkStream? _stream;
private ushort _nextTx;
private bool _disposed;
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
{
_host = host;
_port = port;
_timeout = timeout;
}
public async Task ConnectAsync(CancellationToken ct)
{
_client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
}
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
if (_stream is null) throw new InvalidOperationException("Transport not connected");
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
var adu = new byte[7 + pdu.Length];
adu[0] = (byte)(txId >> 8);
adu[1] = (byte)(txId & 0xFF);
// protocol id already zero
var len = (ushort)(1 + pdu.Length); // unit id + pdu
adu[4] = (byte)(len >> 8);
adu[5] = (byte)(len & 0xFF);
adu[6] = unitId;
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7];
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId)
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
var respLen = (ushort)((header[4] << 8) | header[5]);
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
var respPdu = new byte[respLen - 1];
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
// Exception PDU: function code has high bit set.
if ((respPdu[0] & 0x80) != 0)
{
var fc = (byte)(respPdu[0] & 0x7F);
var ex = respPdu[1];
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
}
return respPdu;
}
finally
{
_gate.Release();
}
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
read += n;
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
_gate.Dispose();
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Server;
/// <summary>
/// Walks every registered driver once per heartbeat interval, asks each
/// <see cref="IHostConnectivityProbe"/>-capable driver for its current
/// <see cref="HostConnectivityStatus"/> list, and upserts one
/// <see cref="DriverHostStatus"/> row per (NodeId, DriverInstanceId, HostName) into the
/// central config DB. Powers the Admin UI's per-host drill-down page (LMX follow-up #7).
/// </summary>
/// <remarks>
/// <para>
/// Polling rather than event-driven: simpler, and matches the cadence the Admin UI
/// consumes. An event-subscription optimization (push on <c>OnHostStatusChanged</c> for
/// immediate reflection) is a straightforward follow-up but adds lifecycle complexity
/// — drivers can be registered after the publisher starts, and subscribing to each
/// one's event on register + unsubscribing on unregister requires DriverHost to expose
/// lifecycle events it doesn't today.
/// </para>
/// <para>
/// <see cref="DriverHostStatus.LastSeenUtc"/> advances every heartbeat so the Admin UI
/// can flag stale rows from a crashed Server process independent of
/// <see cref="DriverHostStatus.State"/> — a Faulted publisher that stops heartbeating
/// stays Faulted in the DB but its LastSeenUtc ages out, which is the signal
/// operators actually want.
/// </para>
/// <para>
/// If the DB is unreachable on a given tick, the publisher logs and moves on — it
/// does not retry or buffer. The next heartbeat picks up the current-state snapshot,
/// which is more useful than replaying stale transitions after a long outage.
/// </para>
/// </remarks>
public sealed class HostStatusPublisher(
DriverHost driverHost,
NodeOptions nodeOptions,
IServiceScopeFactory scopeFactory,
ILogger<HostStatusPublisher> logger) : BackgroundService
{
internal static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(10);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Wait a short moment at startup so NodeBootstrap's RegisterAsync calls have had a
// chance to land. First tick runs immediately after so a freshly-started Server
// surfaces its host topology in the Admin UI without waiting a full interval.
try { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
catch (OperationCanceledException) { return; }
while (!stoppingToken.IsCancellationRequested)
{
try { await PublishOnceAsync(stoppingToken); }
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
// Never take down the Server on a publisher failure. Log and continue —
// stale-row detection on the Admin side will surface the outage.
logger.LogWarning(ex, "Host-status publisher tick failed — will retry next heartbeat");
}
try { await Task.Delay(HeartbeatInterval, stoppingToken); }
catch (OperationCanceledException) { return; }
}
}
internal async Task PublishOnceAsync(CancellationToken ct)
{
var driverIds = driverHost.RegisteredDriverIds;
if (driverIds.Count == 0) return;
var now = DateTime.UtcNow;
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
foreach (var driverId in driverIds)
{
var driver = driverHost.GetDriver(driverId);
if (driver is not IHostConnectivityProbe probe) continue;
IReadOnlyList<HostConnectivityStatus> statuses;
try { statuses = probe.GetHostStatuses(); }
catch (Exception ex)
{
logger.LogWarning(ex, "Driver {DriverId} GetHostStatuses threw — skipping this tick", driverId);
continue;
}
foreach (var status in statuses)
{
await UpsertAsync(db, driverId, status, now, ct);
}
}
await db.SaveChangesAsync(ct);
}
private async Task UpsertAsync(OtOpcUaConfigDbContext db, string driverId,
HostConnectivityStatus status, DateTime now, CancellationToken ct)
{
var mapped = MapState(status.State);
var existing = await db.DriverHostStatuses.SingleOrDefaultAsync(r =>
r.NodeId == nodeOptions.NodeId
&& r.DriverInstanceId == driverId
&& r.HostName == status.HostName, ct);
if (existing is null)
{
db.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = nodeOptions.NodeId,
DriverInstanceId = driverId,
HostName = status.HostName,
State = mapped,
StateChangedUtc = status.LastChangedUtc,
LastSeenUtc = now,
});
return;
}
existing.LastSeenUtc = now;
if (existing.State != mapped)
{
existing.State = mapped;
existing.StateChangedUtc = status.LastChangedUtc;
}
}
internal static DriverHostState MapState(HostState state) => state switch
{
HostState.Running => DriverHostState.Running,
HostState.Stopped => DriverHostState.Stopped,
HostState.Faulted => DriverHostState.Faulted,
_ => DriverHostState.Unknown,
};
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -35,6 +36,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private FolderState? _driverRoot;
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
// PR 26: SecurityClassification per variable, populated during Variable() registration.
// OnWriteValue looks up the classification here to gate the write by the session's roles.
// Drivers never enforce authz themselves — the classification is discovery-time metadata
// only (feedback_acl_at_server_layer.md).
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
// Active building folder — set per Folder() call so Variable() lands under the right parent.
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
// returns a child builder per Folder call and the caller threads nesting through those references.
@@ -122,6 +129,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_currentFolder.AddChild(v);
AddPredefinedNode(SystemContext, v);
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
@@ -337,6 +345,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
// never sees a request we'd have refused here.
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
{
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
{
_logger.LogInformation(
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
fullRef, classification, string.Join(",", roles));
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
try
{
var results = _writable.WriteAsync(

View File

@@ -3,6 +3,7 @@ using Opc.Ua;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -18,6 +19,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaServerOptions _options;
private readonly DriverHost _driverHost;
private readonly IUserAuthenticator _authenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -25,10 +27,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private bool _disposed;
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
{
_options = options;
_driverHost = driverHost;
_authenticator = authenticator;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -55,7 +58,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
throw new InvalidOperationException(
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
_server = new OtOpcUaServer(_driverHost, _loggerFactory);
_server = new OtOpcUaServer(_driverHost, _authenticator, _loggerFactory);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
@@ -126,22 +129,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
ServerConfiguration = new ServerConfiguration
{
BaseAddresses = new StringCollection { _options.EndpointUrl },
SecurityPolicies = new ServerSecurityPolicyCollection
{
new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
},
UserTokenPolicies = new UserTokenPolicyCollection
{
new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "Anonymous",
SecurityPolicyUri = SecurityPolicies.None,
},
},
SecurityPolicies = BuildSecurityPolicies(),
UserTokenPolicies = BuildUserTokenPolicies(),
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
@@ -164,6 +153,58 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return cfg;
}
private ServerSecurityPolicyCollection BuildSecurityPolicies()
{
var policies = new ServerSecurityPolicyCollection
{
// Keep the None policy present so legacy clients can discover + browse. Locked-down
// deployments remove this by setting Ldap.Enabled=true + dropping None here; left in
// for PR 19 so the PR 17 test harness continues to pass unchanged.
new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
};
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)
{
policies.Add(new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
});
}
return policies;
}
private UserTokenPolicyCollection BuildUserTokenPolicies()
{
var tokens = new UserTokenPolicyCollection
{
new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "Anonymous",
SecurityPolicyUri = SecurityPolicies.None,
},
};
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt
&& _options.Ldap.Enabled)
{
tokens.Add(new UserTokenPolicy(UserTokenType.UserName)
{
PolicyId = "UserName",
// Passwords must ride an encrypted channel — scope this token to Basic256Sha256
// so the stack rejects any attempt to send UserName over the None endpoint.
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
});
}
return tokens;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;

View File

@@ -1,5 +1,23 @@
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// OPC UA transport security profile selector. Controls which <c>ServerSecurityPolicy</c>
/// entries the endpoint advertises + which token types the <c>UserTokenPolicies</c> permits.
/// </summary>
public enum OpcUaSecurityProfile
{
/// <summary>Anonymous only on <c>SecurityPolicies.None</c> — dev-only, no signing or encryption.</summary>
None,
/// <summary>
/// <c>Basic256Sha256 SignAndEncrypt</c> with <c>UserName</c> and <c>Anonymous</c> token
/// policies. Clients must present a valid application certificate + user credentials.
/// </summary>
Basic256Sha256SignAndEncrypt,
}
/// <summary>
/// OPC UA server endpoint + application-identity configuration. Bound from the
/// <c>OpcUaServer</c> section of <c>appsettings.json</c>. PR 17 minimum-viable scope: no LDAP,
@@ -39,4 +57,18 @@ public sealed class OpcUaServerOptions
/// Admin UI.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; init; } = true;
/// <summary>
/// Security profile advertised on the endpoint. Default <see cref="OpcUaSecurityProfile.None"/>
/// preserves the PR 17 endpoint shape; set to <see cref="OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt"/>
/// for production deployments with LDAP-backed UserName auth.
/// </summary>
public OpcUaSecurityProfile SecurityProfile { get; init; } = OpcUaSecurityProfile.None;
/// <summary>
/// LDAP binding for UserName token validation. Only consulted when the active
/// <see cref="SecurityProfile"/> advertises a UserName token policy. When
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
/// </summary>
public LdapOptions Ldap { get; init; } = new();
}

View File

@@ -5,6 +5,7 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -17,12 +18,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
public sealed class OtOpcUaServer : StandardServer
{
private readonly DriverHost _driverHost;
private readonly IUserAuthenticator _authenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly List<DriverNodeManager> _driverNodeManagers = new();
public OtOpcUaServer(DriverHost driverHost, ILoggerFactory loggerFactory)
public OtOpcUaServer(DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory)
{
_driverHost = driverHost;
_authenticator = authenticator;
_loggerFactory = loggerFactory;
}
@@ -50,6 +53,63 @@ public sealed class OtOpcUaServer : StandardServer
return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
}
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
// Hook UserName / Anonymous token validation here. Anonymous passes through; UserName
// is validated against the IUserAuthenticator (LDAP in production). Rejected identities
// throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid.
server.SessionManager.ImpersonateUser += OnImpersonateUser;
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
{
switch (args.NewIdentity)
{
case AnonymousIdentityToken:
args.Identity = new UserIdentity(); // anonymous
return;
case UserNameIdentityToken user:
{
var result = _authenticator.AuthenticateAsync(
user.UserName, user.DecryptedPassword, CancellationToken.None)
.GetAwaiter().GetResult();
if (!result.Success)
{
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid username or password ({0})", result.Error ?? "no detail");
}
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
return;
}
default:
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenInvalid,
"Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null");
}
}
/// <summary>
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
/// uses the stack's default.
/// </summary>
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
{
public IReadOnlyList<string> Roles { get; }
public string? Display { get; }
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
: base(userName, "")
{
Display = displayName;
Roles = roles;
}
}
protected override ServerProperties LoadServerProperties() => new()
{
ManufacturerName = "OtOpcUa",

View File

@@ -1,11 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
var builder = Host.CreateApplicationBuilder(args);
@@ -31,6 +35,20 @@ var options = new NodeOptions
};
var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName);
var ldapSection = opcUaSection.GetSection("Ldap");
var ldapOptions = new LdapOptions
{
Enabled = ldapSection.GetValue<bool?>("Enabled") ?? false,
Server = ldapSection.GetValue<string>("Server") ?? "localhost",
Port = ldapSection.GetValue<int?>("Port") ?? 3893,
UseTls = ldapSection.GetValue<bool?>("UseTls") ?? false,
AllowInsecureLdap = ldapSection.GetValue<bool?>("AllowInsecureLdap") ?? true,
SearchBase = ldapSection.GetValue<string>("SearchBase") ?? "dc=lmxopcua,dc=local",
ServiceAccountDn = ldapSection.GetValue<string>("ServiceAccountDn") ?? string.Empty,
ServiceAccountPassword = ldapSection.GetValue<string>("ServiceAccountPassword") ?? string.Empty,
GroupToRole = ldapSection.GetSection("GroupToRole").Get<Dictionary<string, string>>() ?? new(StringComparer.OrdinalIgnoreCase),
};
var opcUaOptions = new OpcUaServerOptions
{
EndpointUrl = opcUaSection.GetValue<string>("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa",
@@ -39,15 +57,28 @@ var opcUaOptions = new OpcUaServerOptions
PkiStoreRoot = opcUaSection.GetValue<string>("PkiStoreRoot")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"),
AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue<bool?>("AutoAcceptUntrustedClientCertificates") ?? true,
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
? p : OpcUaSecurityProfile.None,
Ldap = ldapOptions,
};
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(opcUaOptions);
builder.Services.AddSingleton(ldapOptions);
builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService<ILogger<LdapUserAuthenticator>>())
: new DenyAllUserAuthenticator());
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
builder.Services.AddSingleton<DriverHost>();
builder.Services.AddSingleton<NodeBootstrap>();
builder.Services.AddSingleton<OpcUaApplicationHost>();
builder.Services.AddHostedService<OpcUaServerService>();
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
// so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick.
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
opt.UseSqlServer(options.ConfigDbConnectionString));
builder.Services.AddHostedService<HostStatusPublisher>();
var host = builder.Build();
await host.RunAsync();

View File

@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Minimal interface a <see cref="Opc.Ua.IUserIdentity"/> implementation can expose so
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.OpcUa.DriverNodeManager"/> can read the session's
/// resolved roles without a hard dependency on any specific identity subtype. Implemented
/// by <c>OtOpcUaServer.RoleBasedIdentity</c>; tests implement it with stub identities to
/// drive the authz policy under different role sets.
/// </summary>
public interface IRoleBearer
{
IReadOnlyList<string> Roles { get; }
}

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Validates a (username, password) pair and returns the resolved OPC UA roles for the user.
/// The Server's <c>SessionManager_ImpersonateUser</c> hook delegates here so unit tests can
/// swap in a fake authenticator without a live LDAP.
/// </summary>
public interface IUserAuthenticator
{
Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
}
public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnlyList<string> Roles, string? Error);
/// <summary>
/// Always-reject authenticator used when no security config is provided. Lets the server
/// start (with only an anonymous endpoint) without throwing on UserName token attempts.
/// </summary>
public sealed class DenyAllUserAuthenticator : IUserAuthenticator
{
public Task<UserAuthResult> AuthenticateAsync(string _, string __, CancellationToken ___)
=> Task.FromResult(new UserAuthResult(false, null, [], "UserName token not supported"));
}

View File

@@ -0,0 +1,72 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults target the GLAuth dev instance
/// (localhost:3893, <c>dc=lmxopcua,dc=local</c>) for the stock inner-loop setup. Production
/// deployments are expected to point at Active Directory; see <see cref="UserNameAttribute"/>
/// and the per-field xml-docs for the AD-specific overrides.
/// </summary>
/// <remarks>
/// <para><b>Active Directory cheat-sheet</b>:</para>
/// <list type="bullet">
/// <item><see cref="Server"/>: one of the domain controllers, or the domain FQDN (will round-robin DCs).</item>
/// <item><see cref="Port"/>: <c>389</c> (LDAP) or <c>636</c> (LDAPS); use 636 + <see cref="UseTls"/> in production.</item>
/// <item><see cref="UseTls"/>: <c>true</c>. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement.</item>
/// <item><see cref="AllowInsecureLdap"/>: <c>false</c>. Dev escape hatch only.</item>
/// <item><see cref="SearchBase"/>: <c>DC=corp,DC=example,DC=com</c> — your domain's base DN.</item>
/// <item><see cref="ServiceAccountDn"/>: a dedicated service principal with read access to user + group entries
/// (e.g. <c>CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com</c>). Never a privileged admin.</item>
/// <item><see cref="UserNameAttribute"/>: <c>sAMAccountName</c> (classic login name) or <c>userPrincipalName</c>
/// (user@domain form). Default is <c>uid</c> which AD does <b>not</b> populate, so this override is required.</item>
/// <item><see cref="DisplayNameAttribute"/>: <c>displayName</c> gives the human name; <c>cn</c> works too but is less rich.</item>
/// <item><see cref="GroupAttribute"/>: <c>memberOf</c> — matches AD's default. Values are full DNs
/// (<c>CN=&lt;Group&gt;,OU=...,DC=...</c>); the authenticator strips the leading <c>CN=</c> RDN value and uses
/// that as the lookup key in <see cref="GroupToRole"/>.</item>
/// <item><see cref="GroupToRole"/>: maps your AD group common-names to OPC UA roles — e.g.
/// <c>{"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}</c>.</item>
/// </list>
/// <para>
/// Nested groups are <b>not</b> expanded — AD's <c>tokenGroups</c> / <c>LDAP_MATCHING_RULE_IN_CHAIN</c>
/// membership-chain filter isn't used. Assign users directly to the role-mapped groups, or pre-flatten
/// membership in your directory. If nested expansion becomes a requirement, it's an authenticator
/// enhancement (not a config change).
/// </para>
/// </remarks>
public sealed class LdapOptions
{
public bool Enabled { get; init; } = false;
public string Server { get; init; } = "localhost";
public int Port { get; init; } = 3893;
public bool UseTls { get; init; } = false;
/// <summary>Dev-only escape hatch — must be false in production.</summary>
public bool AllowInsecureLdap { get; init; } = true;
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
public string ServiceAccountDn { get; init; } = string.Empty;
public string ServiceAccountPassword { get; init; } = string.Empty;
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
/// <summary>
/// LDAP attribute used to match a login name against user entries in the directory.
/// Defaults to <c>uid</c> (RFC 2307). Common overrides:
/// <list type="bullet">
/// <item><c>sAMAccountName</c> — Active Directory, classic NT-style login names (e.g. <c>jdoe</c>).</item>
/// <item><c>userPrincipalName</c> — Active Directory, email-style (e.g. <c>jdoe@corp.example.com</c>).</item>
/// <item><c>cn</c> — GLAuth + some OpenLDAP deployments where users are keyed by common-name.</item>
/// </list>
/// Used only when <see cref="ServiceAccountDn"/> is non-empty (search-then-bind path) —
/// direct-bind fallback constructs the DN as <c>cn=&lt;name&gt;,&lt;SearchBase&gt;</c>
/// regardless of this setting and is not a production-grade path against AD.
/// </summary>
public string UserNameAttribute { get; init; } = "uid";
/// <summary>
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
/// + read), <c>WriteOperate</c>, <c>WriteTune</c>, <c>WriteConfigure</c>, <c>AlarmAck</c>.
/// </summary>
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,151 @@
using Microsoft.Extensions.Logging;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// <see cref="IUserAuthenticator"/> that binds to the configured LDAP directory to validate
/// the (username, password) pair, then pulls group membership and maps to OPC UA roles.
/// Mirrors the bind-then-search pattern in <c>Admin.Security.LdapAuthService</c> but stays
/// in the Server project so the Server process doesn't take a cross-app dependency on Admin.
/// </summary>
public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserAuthenticator> logger)
: IUserAuthenticator
{
public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (!options.Enabled)
return new UserAuthResult(false, null, [], "LDAP authentication disabled");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return new UserAuthResult(false, null, [], "Credentials required");
if (!options.UseTls && !options.AllowInsecureLdap)
return new UserAuthResult(false, null, [],
"Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");
try
{
using var conn = new LdapConnection();
if (options.UseTls) conn.SecureSocketLayer = true;
await Task.Run(() => conn.Connect(options.Server, options.Port), ct);
var bindDn = await ResolveUserDnAsync(conn, username, ct);
await Task.Run(() => conn.Bind(bindDn, password), ct);
// Rebind as service account for attribute read, if configured — otherwise the just-
// bound user reads their own entry (works when ACL permits self-read).
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
var displayName = username;
var groups = new List<string>();
try
{
var filter = $"(cn={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct);
while (results.HasMore())
{
try
{
var entry = results.Next();
var name = entry.GetAttribute(options.DisplayNameAttribute);
if (name is not null) displayName = name.StringValue;
var groupAttr = entry.GetAttribute(options.GroupAttribute);
if (groupAttr is not null)
{
foreach (var groupDn in groupAttr.StringValueArray)
groups.Add(ExtractFirstRdnValue(groupDn));
}
// GLAuth fallback: primary group is encoded as the ou= RDN above cn=.
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
{
var primary = ExtractOuSegment(entry.Dn);
if (primary is not null) groups.Add(primary);
}
}
catch (LdapException) { break; }
}
}
catch (LdapException ex)
{
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
}
conn.Disconnect();
var roles = groups
.Where(g => options.GroupToRole.ContainsKey(g))
.Select(g => options.GroupToRole[g])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new UserAuthResult(true, displayName, roles, null);
}
catch (LdapException ex)
{
logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
return new UserAuthResult(false, null, [], "Invalid username or password");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
return new UserAuthResult(false, null, [], "Authentication error");
}
}
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
{
if (username.Contains('=')) return username; // caller passed a DN directly
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
{
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
if (results.HasMore())
return results.Next().Dn;
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry for uid={username}");
}
return string.IsNullOrWhiteSpace(options.SearchBase)
? $"cn={username}"
: $"cn={username},{options.SearchBase}";
}
internal static string EscapeLdapFilter(string input) =>
input.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
internal static string? ExtractOuSegment(string dn)
{
foreach (var segment in dn.Split(','))
{
var trimmed = segment.Trim();
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
return trimmed[3..];
}
return null;
}
internal static string ExtractFirstRdnValue(string dn)
{
var eq = dn.IndexOf('=');
if (eq < 0) return dn;
var valueStart = eq + 1;
var comma = dn.IndexOf(',', valueStart);
return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..];
}
}

View File

@@ -0,0 +1,70 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
/// whether a given session is allowed to write a given attribute by checking the session's
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
/// role for the attribute's classification.
/// </summary>
/// <remarks>
/// Matches the table in <c>docs/Configuration.md</c>:
/// <list type="bullet">
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
/// <item><c>ViewOnly</c>: no role grants write access.</item>
/// </list>
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
/// </remarks>
public static class WriteAuthzPolicy
{
public const string RoleWriteOperate = "WriteOperate";
public const string RoleWriteTune = "WriteTune";
public const string RoleWriteConfigure = "WriteConfigure";
/// <summary>
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
/// attribute with the given <paramref name="classification"/>. Returns true for
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
/// the session to carry the mapped role — case-insensitive match.
/// </summary>
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
{
if (classification == SecurityClassification.FreeAccess) return true;
if (classification == SecurityClassification.ViewOnly) return false;
var required = RequiredRole(classification);
if (required is null) return false;
foreach (var r in userRoles)
{
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// Required role for a classification, or null when no role grants access
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
/// null themselves).
/// </summary>
public static string? RequiredRole(SecurityClassification classification) => classification switch
{
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
SecurityClassification.Operate => RoleWriteOperate,
SecurityClassification.SecuredWrite => RoleWriteOperate,
SecurityClassification.Tune => RoleWriteTune,
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
SecurityClassification.Configure => RoleWriteConfigure,
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
_ => null,
};
}

View File

@@ -23,12 +23,18 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.374.126"/>
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Server.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>

View File

@@ -0,0 +1,153 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class CertTrustServiceTests : IDisposable
{
private readonly string _root;
public CertTrustServiceTests()
{
_root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs"));
Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs"));
}
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
private CertTrustService Service() => new(
Options.Create(new CertTrustOptions { PkiStoreRoot = _root }),
NullLogger<CertTrustService>.Instance);
private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der");
File.WriteAllBytes(path, cert.Export(X509ContentType.Cert));
return cert;
}
[Fact]
public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir()
{
var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A");
var rows = Service().ListRejected();
rows.Count.ShouldBe(1);
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
rows[0].Subject.ShouldContain("test-client-A");
rows[0].Store.ShouldBe(CertStoreKind.Rejected);
}
[Fact]
public void ListTrusted_is_separate_from_rejected()
{
WriteTestCert(CertStoreKind.Rejected, "rej");
WriteTestCert(CertStoreKind.Trusted, "trust");
var svc = Service();
svc.ListRejected().Count.ShouldBe(1);
svc.ListTrusted().Count.ShouldBe(1);
svc.ListRejected()[0].Subject.ShouldContain("rej");
svc.ListTrusted()[0].Subject.ShouldContain("trust");
}
[Fact]
public void TrustRejected_moves_file_from_rejected_to_trusted()
{
var c = WriteTestCert(CertStoreKind.Rejected, "promoteme");
var svc = Service();
svc.TrustRejected(c.Thumbprint).ShouldBeTrue();
svc.ListRejected().ShouldBeEmpty();
var trusted = svc.ListTrusted();
trusted.Count.ShouldBe(1);
trusted[0].Thumbprint.ShouldBe(c.Thumbprint);
}
[Fact]
public void TrustRejected_returns_false_when_thumbprint_not_in_rejected()
{
var svc = Service();
svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse();
}
[Fact]
public void DeleteRejected_removes_the_file()
{
var c = WriteTestCert(CertStoreKind.Rejected, "killme");
var svc = Service();
svc.DeleteRejected(c.Thumbprint).ShouldBeTrue();
svc.ListRejected().ShouldBeEmpty();
}
[Fact]
public void UntrustCert_removes_from_trusted_only()
{
var c = WriteTestCert(CertStoreKind.Trusted, "revoke");
var svc = Service();
svc.UntrustCert(c.Thumbprint).ShouldBeTrue();
svc.ListTrusted().ShouldBeEmpty();
}
[Fact]
public void Thumbprint_match_is_case_insensitive()
{
var c = WriteTestCert(CertStoreKind.Rejected, "case");
var svc = Service();
// X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often
// lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving.
svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue();
}
[Fact]
public void Missing_store_directories_produce_empty_lists_not_exceptions()
{
// Fresh root with no certs subfolder — service should tolerate a pristine install.
var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}");
try
{
var svc = new CertTrustService(
Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }),
NullLogger<CertTrustService>.Instance);
svc.ListRejected().ShouldBeEmpty();
svc.ListTrusted().ShouldBeEmpty();
}
finally
{
if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true);
}
}
[Fact]
public void Malformed_file_is_skipped_not_fatal()
{
// Drop junk bytes that don't parse as a cert into the rejected/certs directory. The
// service must skip it and still return the valid certs — one bad file can't take the
// whole management page offline.
File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert");
var c = WriteTestCert(CertStoreKind.Rejected, "valid");
var rows = Service().ListRejected();
rows.Count.ShouldBe(1);
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
{
[Fact]
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
{
await using var ctx = NewContext();
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
// redundancy case. Both rows must be insertable because each server node owns its own
// runtime view of the shared host.
var now = DateTime.UtcNow;
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
State = DriverHostState.Running,
StateChangedUtc = now, LastSeenUtc = now,
});
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
State = DriverHostState.Stopped,
StateChangedUtc = now, LastSeenUtc = now,
Detail = "secondary hasn't taken over yet",
});
// Same server node + host, different driver instance — second driver doesn't clobber.
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
State = DriverHostState.Running,
StateChangedUtc = now, LastSeenUtc = now,
});
await ctx.SaveChangesAsync();
var rows = await ctx.DriverHostStatuses.AsNoTracking()
.Where(r => r.HostName == "GRPlatform").ToListAsync();
rows.Count.ShouldBe(3);
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
}
[Fact]
public async Task Upsert_pattern_for_same_key_updates_in_place()
{
// The publisher hosted service (follow-up PR) upserts on every transition +
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
// keyed on the composite PK. If the composite key ever changes, this test breaks
// loudly so the publisher gets a synchronized update.
await using var ctx = NewContext();
var t0 = DateTime.UtcNow;
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
State = DriverHostState.Running,
StateChangedUtc = t0, LastSeenUtc = t0,
});
await ctx.SaveChangesAsync();
var t1 = t0.AddSeconds(30);
await using (var ctx2 = NewContext())
{
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
existing.State = DriverHostState.Faulted;
existing.StateChangedUtc = t1;
existing.LastSeenUtc = t1;
existing.Detail = "transport reset by peer";
await ctx2.SaveChangesAsync();
}
await using var ctx3 = NewContext();
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
final.State.ShouldBe(DriverHostState.Faulted);
final.Detail.ShouldBe("transport reset by peer");
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
}
[Fact]
public async Task Enum_persists_as_string_not_int()
{
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
// string back via ADO; if someone drops the conversion the column will contain '1'
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
// directly should see readable state names, not enum ordinals.
await using var ctx = NewContext();
ctx.DriverHostStatuses.Add(new DriverHostStatus
{
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
State = DriverHostState.Faulted,
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
await using var conn = fixture.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
var rawValue = (string?)await cmd.ExecuteScalarAsync();
rawValue.ShouldBe("Faulted");
}
private OtOpcUaConfigDbContext NewContext()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(fixture.ConnectionString)
.Options;
return new OtOpcUaConfigDbContext(options);
}
}

View File

@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
"Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
};
var actual = QueryStrings(@"

View File

@@ -0,0 +1,127 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using Xunit.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
/// <summary>
/// Exercises <see cref="AvevaPrerequisites"/> against the live dev box so the helper
/// itself gets integration coverage — i.e. "do the probes return Pass for things that
/// really are Pass?" as validated against this machine's known-installed topology.
/// Category <c>LiveGalaxy</c> so CI / clean dev boxes skip cleanly.
/// </summary>
[Trait("Category", "LiveGalaxy")]
public sealed class AvevaPrerequisitesLiveTests
{
private readonly ITestOutputHelper _output;
public AvevaPrerequisitesLiveTests(ITestOutputHelper output) => _output = output;
[Fact]
public async Task CheckAll_on_live_box_reports_Framework_install()
{
var report = await AvevaPrerequisites.CheckAllAsync();
_output.WriteLine(report.ToString());
report.Checks.ShouldContain(c =>
c.Name == "registry:ArchestrA.Framework" && c.Status == PrerequisiteStatus.Pass,
"ArchestrA Framework registry root should be found on this machine.");
}
[Fact]
public async Task CheckAll_on_live_box_reports_aaBootstrap_running()
{
var report = await AvevaPrerequisites.CheckAllAsync();
var bootstrap = report.Checks.FirstOrDefault(c => c.Name == "service:aaBootstrap");
bootstrap.ShouldNotBeNull();
bootstrap.Status.ShouldBe(PrerequisiteStatus.Pass,
$"aaBootstrap must be Running for any live-Galaxy test to work — detail: {bootstrap.Detail}");
}
[Fact]
public async Task CheckAll_on_live_box_reports_aaGR_running()
{
var report = await AvevaPrerequisites.CheckAllAsync();
var gr = report.Checks.FirstOrDefault(c => c.Name == "service:aaGR");
gr.ShouldNotBeNull();
gr.Status.ShouldBe(PrerequisiteStatus.Pass,
$"aaGR (Galaxy Repository) must be Running — detail: {gr.Detail}");
}
[Fact]
public async Task CheckAll_on_live_box_reports_MxAccess_COM_registered()
{
var report = await AvevaPrerequisites.CheckAllAsync();
var com = report.Checks.FirstOrDefault(c => c.Name == "com:LMXProxy");
com.ShouldNotBeNull();
com.Status.ShouldBe(PrerequisiteStatus.Pass,
$"LMXProxy.LMXProxyServer ProgID must resolve to an InprocServer32 DLL — detail: {com.Detail}");
}
[Fact]
public async Task CheckRepositoryOnly_on_live_box_reports_ZB_reachable()
{
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(ct: CancellationToken.None);
var zb = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB");
zb.ShouldNotBeNull();
zb.Status.ShouldBe(PrerequisiteStatus.Pass,
$"ZB database must be reachable via SQL Server Windows auth — detail: {zb.Detail}");
}
[Fact]
public async Task CheckRepositoryOnly_on_live_box_reports_non_zero_deployed_objects()
{
// This box has 49 deployed objects per the research; we just assert > 0 so adding/
// removing objects doesn't break the test.
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync();
var deployed = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB.deployedObjects");
deployed.ShouldNotBeNull();
deployed.Status.ShouldBe(PrerequisiteStatus.Pass,
$"At least one deployed gobject should exist — detail: {deployed.Detail}");
}
[Fact]
public async Task Aveva_side_is_ready_on_this_machine()
{
// Narrower than "livetest ready" — our own services (OtOpcUa / OtOpcUaGalaxyHost)
// may not be installed on a developer's box while they're actively iterating on
// them, but the AVEVA side (Framework / Galaxy Repository / MXAccess COM /
// SQL / core services) should always be up on a machine with System Platform
// installed. This assertion is what gates live-Galaxy tests that go straight to
// the Galaxy Repository without routing through our stack.
var report = await AvevaPrerequisites.CheckAllAsync(
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = false });
_output.WriteLine(report.ToString());
_output.WriteLine(report.Warnings ?? "no warnings");
// Enumerate AVEVA-side failures (if any) for an actionable assertion message.
var avevaFails = report.Checks
.Where(c => c.Status == PrerequisiteStatus.Fail &&
c.Category != PrerequisiteCategory.OtOpcUaService)
.ToList();
report.IsAvevaSideReady.ShouldBeTrue(
avevaFails.Count == 0
? "unexpected state"
: "AVEVA-side failures: " + string.Join(" ; ",
avevaFails.Select(f => $"{f.Name}: {f.Detail}")));
}
[Fact]
public async Task Report_captures_OtOpcUa_services_state_even_when_not_installed()
{
// The helper reports the status of OtOpcUaGalaxyHost + OtOpcUa services even if
// they're not installed yet — absence is itself an actionable signal. This test
// doesn't assert Pass/Fail on those services (their state depends on what's
// installed when the test runs) — it only asserts the helper EMITTED the rows,
// so nobody can ship a prerequisite check that silently omits our own services.
var report = await AvevaPrerequisites.CheckAllAsync();
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUaGalaxyHost");
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUa");
report.Checks.ShouldContain(c => c.Name == "service:GLAuth");
}
}
}

View File

@@ -6,6 +6,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
@@ -16,6 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
/// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the
/// <c>DiscoverHierarchyResponse</c> shape.
/// </summary>
/// <remarks>
/// Since PR 36, skip logic is delegated to <see cref="AvevaPrerequisites.CheckRepositoryOnlyAsync"/>
/// so operators see exactly why a test skipped ("ZB db not found" vs "SQL Server
/// unreachable") instead of a silent return.
/// </remarks>
[Trait("Category", "LiveGalaxy")]
public sealed class GalaxyRepositoryLiveSmokeTests
{
@@ -26,15 +32,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
CommandTimeoutSeconds = 10,
};
private static async Task<string?> RepositorySkipReasonAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(
DevZbOptions().ConnectionString, cts.Token);
return report.SkipReason;
}
private static async Task<bool> ZbReachableAsync()
{
try
{
var repo = new GalaxyRepository(DevZbOptions());
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
return await repo.TestConnectionAsync(cts.Token);
}
catch { return false; }
// Legacy silent-skip adapter — keeps the existing tests compiling while
// gradually migrating to the Skip-with-reason pattern. Returns true when the
// prerequisite check has no Fail entries.
return (await RepositorySkipReasonAsync()) is null;
}
[Fact]

View File

@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
<Reference Include="System.ServiceProcess"/>
<!-- IMxProxy's delegate signatures mention ArchestrA.MxAccess.MXSTATUS_PROXY, so tests
implementing the interface must resolve that type at compile time. -->

View File

@@ -0,0 +1,81 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
/// <summary>
/// Pins <see cref="GalaxyProxyDriver.ToHistoricalEvent"/> — the wire-to-domain mapping
/// from <see cref="GalaxyHistoricalEvent"/> (MessagePack-annotated IPC contract,
/// Unix-ms timestamps) to <c>Core.Abstractions.HistoricalEvent</c> (domain record,
/// <see cref="DateTime"/> timestamps). Added in PR 35 alongside the new
/// <c>IHistoryProvider.ReadEventsAsync</c> method.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistoricalEventMappingTests
{
[Fact]
public void Maps_every_field_from_wire_to_domain_record()
{
var wire = new GalaxyHistoricalEvent
{
EventId = "evt-42",
SourceName = "Tank1.HiAlarm",
EventTimeUtcUnixMs = 1_700_000_000_000L, // 2023-11-14T22:13:20.000Z
ReceivedTimeUtcUnixMs = 1_700_000_000_500L,
DisplayText = "High level reached",
Severity = 750,
};
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
domain.EventId.ShouldBe("evt-42");
domain.SourceName.ShouldBe("Tank1.HiAlarm");
domain.EventTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc));
domain.ReceivedTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, 500, DateTimeKind.Utc));
domain.Message.ShouldBe("High level reached");
domain.Severity.ShouldBe((ushort)750);
}
[Fact]
public void Preserves_null_SourceName_and_DisplayText()
{
// Historical rows from the Galaxy event historian often omit source or message for
// system events (e.g. time sync). The mapping must preserve null — callers use it to
// distinguish system events from alarm events.
var wire = new GalaxyHistoricalEvent
{
EventId = "sys-1",
SourceName = null,
EventTimeUtcUnixMs = 0,
ReceivedTimeUtcUnixMs = 0,
DisplayText = null,
Severity = 1,
};
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
domain.SourceName.ShouldBeNull();
domain.Message.ShouldBeNull();
}
[Fact]
public void EventTime_and_ReceivedTime_are_produced_as_DateTimeKind_Utc()
{
// Unix-ms timestamps come off the wire timezone-agnostic; the mapping must tag the
// resulting DateTime as Utc so downstream serializers (JSON, OPC UA types) don't apply
// an unexpected local-time offset.
var wire = new GalaxyHistoricalEvent
{
EventId = "e",
EventTimeUtcUnixMs = 1_000L,
ReceivedTimeUtcUnixMs = 2_000L,
};
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
domain.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
domain.ReceivedTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
}
}

View File

@@ -0,0 +1,75 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// Resolves the pipe name + shared secret the live <see cref="GalaxyProxyDriver"/> needs
/// to connect to a running <c>OtOpcUaGalaxyHost</c> Windows service. Two sources are
/// consulted, first match wins:
/// <list type="number">
/// <item>Explicit env vars (<c>OTOPCUA_GALAXY_PIPE</c>, <c>OTOPCUA_GALAXY_SECRET</c>) — lets CI / benchwork override.</item>
/// <item>The service's per-process <c>Environment</c> registry values under
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c> — what
/// <c>Install-Services.ps1</c> writes at install time. Requires the test to run as a
/// principal with read access to that registry key (typically Administrators).</item>
/// </list>
/// </summary>
/// <remarks>
/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the
/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret
/// in tests would diverge from production the moment someone re-installed the service.
/// </remarks>
public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source)
{
public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE";
public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET";
public const string ServiceRegistryKey =
@"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost";
public const string DefaultPipeName = "OtOpcUaGalaxy";
public static LiveStackConfig? Resolve()
{
var envPipe = Environment.GetEnvironmentVariable(EnvPipeName);
var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret);
if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret))
return new LiveStackConfig(envPipe, envSecret, "env vars");
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return null;
return FromServiceRegistry();
}
[SupportedOSPlatform("windows")]
private static LiveStackConfig? FromServiceRegistry()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey);
if (key is null) return null;
var env = key.GetValue("Environment") as string[];
if (env is null || env.Length == 0) return null;
string? pipe = null, secret = null;
foreach (var line in env)
{
var eq = line.IndexOf('=');
if (eq <= 0) continue;
var name = line[..eq];
var value = line[(eq + 1)..];
if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value;
else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value;
}
if (string.IsNullOrWhiteSpace(secret)) return null;
return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry");
}
catch
{
// Access denied / key missing / malformed — caller gets null and surfaces a Skip.
return null;
}
}
}

View File

@@ -0,0 +1,120 @@
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
/// to translate that into <c>Assert.Skip</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
/// as a standalone Windows service — spawning a second instance from a test would
/// bypass the COM-apartment + service-account setup and fail differently than
/// production (see <c>project_galaxy_host_service.md</c> memory).
/// </para>
/// <para>
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
/// first, then the service's registry-stored <c>Environment</c> values. Requires
/// the test process to have read access to
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
/// that typically means running the test host elevated, or exporting
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
/// </para>
/// </remarks>
public sealed class LiveStackFixture : IAsyncLifetime
{
public GalaxyProxyDriver? Driver { get; private set; }
public string? SkipReason { get; private set; }
public PrerequisiteReport? PrerequisiteReport { get; private set; }
public LiveStackConfig? Config { get; private set; }
public async ValueTask InitializeAsync()
{
// 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync(
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false },
cts.Token);
if (!PrerequisiteReport.IsLivetestReady)
{
SkipReason = PrerequisiteReport.SkipReason;
return;
}
// 2. Secret / pipe-name resolution. If the service is running but we can't discover its
// env vars from registry (non-elevated test host), a clear message beats a silent
// connect-rejected failure 10 seconds later.
Config = LiveStackConfig.Resolve();
if (Config is null)
{
SkipReason =
$"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " +
$"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " +
$"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment.";
return;
}
// 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second
// ConnectTimeout gives enough headroom for a service that just started.
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
{
DriverInstanceId = "live-stack-smoke",
PipeName = Config.PipeName,
SharedSecret = Config.SharedSecret,
ConnectTimeout = TimeSpan.FromSeconds(5),
});
try
{
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
}
catch (Exception ex)
{
SkipReason =
$"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " +
$"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " +
$"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " +
$"test must run as that user), or Host's backend couldn't connect to ZB.";
Driver.Dispose();
Driver = null;
return;
}
}
public async ValueTask DisposeAsync()
{
if (Driver is not null)
{
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ }
Driver.Dispose();
}
}
/// <summary>
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. Tests call this at the
/// top of every fact so a fixture init failure shows up as a cleanly-skipped test with
/// the full prerequisites report, not a cascading NullReferenceException on
/// <see cref="Driver"/>.
/// </summary>
public void SkipIfUnavailable()
{
if (SkipReason is not null) Assert.Skip(SkipReason);
}
}
[CollectionDefinition(Name)]
public sealed class LiveStackCollection : ICollectionFixture<LiveStackFixture>
{
public const string Name = "LiveStack";
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
/// live MXAccess runtime → real Galaxy objects + attributes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
/// <c>Assert.Skip</c> when missing):
/// </para>
/// <list type="bullet">
/// <item>AVEVA System Platform installed + Platform deployed.</item>
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
/// <item>MXAccess COM server registered.</item>
/// <item>ZB database exists with at least one deployed gobject.</item>
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
/// service's registry Environment values (test host typically needs to be elevated
/// to read the latter).</item>
/// <item>Test process runs as the account listed in the service's pipe ACL
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
/// </list>
/// <para>
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
/// PR that reuses this fixture.
/// </para>
/// </remarks>
[Trait("Category", "LiveGalaxy")]
[Collection(LiveStackCollection.Name)]
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
{
[Fact]
public void Fixture_initialized_successfully()
{
fixture.SkipIfUnavailable();
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
// every other test in this class depends on it.
fixture.Driver.ShouldNotBeNull();
fixture.Config.ShouldNotBeNull();
fixture.PrerequisiteReport.ShouldNotBeNull();
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
}
[Fact]
public void Driver_reports_Healthy_after_IPC_handshake()
{
fixture.SkipIfUnavailable();
var health = fixture.Driver!.GetHealth();
health.State.ShouldBe(DriverState.Healthy,
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
}
[Fact]
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
{
fixture.SkipIfUnavailable();
var builder = new CapturingAddressSpaceBuilder();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
builder.Variables.Count.ShouldBeGreaterThan(0,
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
}
[Fact]
public void GetHostStatuses_reports_at_least_one_platform()
{
fixture.SkipIfUnavailable();
var statuses = fixture.Driver!.GetHostStatuses();
statuses.Count.ShouldBeGreaterThan(0,
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
// Host names are driver-opaque to the Core but non-empty by contract.
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
}
[Fact]
public async Task Can_read_a_discovered_variable_from_live_galaxy()
{
fixture.SkipIfUnavailable();
var builder = new CapturingAddressSpaceBuilder();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
builder.Variables.Count.ShouldBeGreaterThan(0);
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
var full = builder.Variables[0].AttributeInfo.FullName;
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
snapshots.Count.ShouldBe(1);
var snap = snapshots[0];
snap.StatusCode.ShouldNotBe(0x80020000u,
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
}
/// <summary>
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
/// Variable() call into a flat list so tests can inspect what discovery produced
/// without running the full OPC UA node-manager stack.
/// </summary>
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new NoopHandle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class NoopHandle(string fullReference) : IVariableHandle
{
public string FullReference { get; } = fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
private sealed class NoopSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
}

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,163 @@
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
/// <summary>
/// Entry point for live-AVEVA test fixtures. Runs every relevant probe and returns a
/// <see cref="PrerequisiteReport"/> whose <c>SkipReason</c> feeds <c>Assert.Skip</c> when
/// the environment isn't set up. Non-Windows hosts get a single aggregated Skip row per
/// category instead of a flood of individual skips.
/// </summary>
/// <remarks>
/// <para><b>Call shape</b>:</para>
/// <code>
/// var report = await AvevaPrerequisites.CheckAllAsync();
/// if (report.SkipReason is not null) Assert.Skip(report.SkipReason);
/// </code>
/// <para><b>Categories in rough order of 'would I want to know first?'</b>:</para>
/// <list type="number">
/// <item>Environment — process bitness, OS platform, RPCSS up.</item>
/// <item>AvevaInstall — Framework registry, install paths, no pending reboot.</item>
/// <item>AvevaCoreService — aaBootstrap / aaGR / NmxSvc running.</item>
/// <item>MxAccessCom — LMXProxy.LMXProxyServer ProgID → CLSID → file-on-disk.</item>
/// <item>GalaxyRepository — SQL reachable, ZB exists, deployed-object count.</item>
/// <item>OtOpcUaService — our two Windows services + GLAuth.</item>
/// <item>AvevaSoftService — aaLogger etc., warn only.</item>
/// <item>AvevaHistorian — aahClientAccessPoint etc., optional.</item>
/// </list>
/// <para><b>What's NOT checked here</b>: end-to-end subscribe / read / write against a real
/// Galaxy tag. That's the job of the live-smoke tests this helper gates — the helper just
/// tells them whether running is worthwhile.</para>
/// </remarks>
public static class AvevaPrerequisites
{
// -------- Individual service lists (kept as data so tests can inspect / override) --------
/// <summary>Services whose absence means live-Galaxy tests can't run at all.</summary>
internal static readonly (string Name, string Purpose)[] CoreServices =
[
("aaBootstrap", "master service that starts the Platform process + brokers aa* communication"),
("aaGR", "Galaxy Repository host — mediates IDE / runtime access to ZB"),
("NmxSvc", "Network Message Exchange — MXAccess + Bootstrap transport"),
("MSSQLSERVER", "SQL Server instance that hosts the ZB database"),
];
/// <summary>Warn-but-don't-fail AVEVA services.</summary>
internal static readonly (string Name, string Purpose)[] SoftServices =
[
("aaLogger", "ArchestrA Logger — diagnostic log receiver; stack runs without it but error visibility suffers"),
("aaUserValidator", "OS user/group auth for ArchestrA security; only required when Galaxy security mode isn't 'Open'"),
("aaGlobalDataCacheMonitorSvr", "cross-platform global data cache; single-node dev boxes run fine without it"),
];
/// <summary>Optional AVEVA Historian services — only required for HistoryRead IPC paths.</summary>
internal static readonly (string Name, string Purpose)[] HistorianServices =
[
("aahClientAccessPoint", "AVEVA Historian Client Access Point — HistoryRead IPC endpoint"),
("aahGateway", "AVEVA Historian Gateway"),
];
/// <summary>OtOpcUa-stack Windows services + third-party deps we manage.</summary>
internal static readonly (string Name, string Purpose, bool HardRequired)[] OtOpcUaServices =
[
("OtOpcUaGalaxyHost", "Galaxy.Host out-of-process service (net48 x86, STA + MXAccess)", true),
("OtOpcUa", "Main OPC UA server service (hosts Proxy + DriverHost + Admin-facing DB publisher)", false),
("GLAuth", "LDAP server (dev only) — glauth.exe on localhost:3893", false),
];
// -------- Orchestrator --------
public static async Task<PrerequisiteReport> CheckAllAsync(
Options? options = null, CancellationToken ct = default)
{
options ??= new Options();
var checks = new List<PrerequisiteCheck>();
// Environment
checks.Add(MxAccessComProbe.CheckProcessBitness());
// AvevaInstall — registry + files
checks.Add(RegistryProbe.CheckFrameworkInstalled());
checks.Add(RegistryProbe.CheckPlatformDeployed());
checks.Add(RegistryProbe.CheckRebootPending());
// AvevaCoreService
foreach (var (name, purpose) in CoreServices)
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaCoreService, hardRequired: true, whatItDoes: purpose));
// MxAccessCom
checks.Add(MxAccessComProbe.Check());
// GalaxyRepository
checks.Add(await SqlProbe.CheckZbDatabaseAsync(options.SqlConnectionString, ct));
// Deployed-object count only makes sense if the DB check passed.
if (checks[checks.Count - 1].Status == PrerequisiteStatus.Pass)
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(options.SqlConnectionString, ct));
// OtOpcUaService
foreach (var (name, purpose, hard) in OtOpcUaServices)
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.OtOpcUaService, hardRequired: hard, whatItDoes: purpose));
if (options.CheckGalaxyHostPipe)
checks.Add(await NamedPipeProbe.CheckGalaxyHostPipeAsync(options.GalaxyHostPipeName, ct));
// AvevaSoftService
foreach (var (name, purpose) in SoftServices)
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaSoftService, hardRequired: false, whatItDoes: purpose));
// AvevaHistorian
if (options.CheckHistorian)
{
foreach (var (name, purpose) in HistorianServices)
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaHistorian, hardRequired: false, whatItDoes: purpose));
}
return new PrerequisiteReport(checks);
}
/// <summary>
/// Narrower check for tests that only need the Galaxy Repository (SQL) path — don't
/// pay the cost of probing every aa* service when the test only reads gobject rows.
/// </summary>
public static async Task<PrerequisiteReport> CheckRepositoryOnlyAsync(
string? sqlConnectionString = null, CancellationToken ct = default)
{
var checks = new List<PrerequisiteCheck>
{
await SqlProbe.CheckZbDatabaseAsync(sqlConnectionString, ct),
};
if (checks[0].Status == PrerequisiteStatus.Pass)
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(sqlConnectionString, ct));
return new PrerequisiteReport(checks);
}
/// <summary>
/// Narrower check for the named-pipe endpoint — tests that drive the full Proxy
/// against a live Galaxy.Host service don't need the SQL or AVEVA-internal probes
/// (the Host does that work internally; we just need the pipe to accept).
/// </summary>
public static async Task<PrerequisiteReport> CheckGalaxyHostPipeOnlyAsync(
string? pipeName = null, CancellationToken ct = default)
{
var checks = new List<PrerequisiteCheck>
{
await NamedPipeProbe.CheckGalaxyHostPipeAsync(pipeName, ct),
};
return new PrerequisiteReport(checks);
}
/// <summary>Knobs for <see cref="CheckAllAsync"/>.</summary>
public sealed class Options
{
/// <summary>SQL Server connection string — defaults to Windows-auth <c>localhost\ZB</c>.</summary>
public string? SqlConnectionString { get; init; }
/// <summary>Named-pipe endpoint for OtOpcUaGalaxyHost — defaults to <c>OtOpcUaGalaxy</c>.</summary>
public string? GalaxyHostPipeName { get; init; }
/// <summary>Include the named-pipe probe. Off by default — it's a seconds-long TCP-like probe and some tests don't need it.</summary>
public bool CheckGalaxyHostPipe { get; init; } = true;
/// <summary>Include Historian service probes. Off by default — Historian is optional.</summary>
public bool CheckHistorian { get; init; } = false;
}
}

View File

@@ -0,0 +1,26 @@
#if NET48
// Polyfills for C# 9+ language features that the helper uses but that net48 BCL doesn't
// provide. Keeps the sources single-target-free at the language level — the same .cs files
// build on both frameworks without preprocessor guards in the callsites.
namespace System.Runtime.CompilerServices
{
/// <summary>Required by C# 9 <c>init</c>-only setters and <c>record</c> types.</summary>
internal static class IsExternalInit { }
}
namespace System.Runtime.Versioning
{
/// <summary>
/// Minimal shim for the .NET 5+ <c>SupportedOSPlatformAttribute</c>. Pure marker for the
/// compiler on net10; on net48 we still want the attribute to exist so the same
/// <c>[SupportedOSPlatform("windows")]</c> source compiles. The attribute is internal
/// and attribute-targets-everything to minimize surface.
/// </summary>
[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
internal sealed class SupportedOSPlatformAttribute(string platformName) : Attribute
{
public string PlatformName { get; } = platformName;
}
}
#endif

View File

@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
/// <summary>One prerequisite probe's outcome. <see cref="AvevaPrerequisites"/> returns many of these.</summary>
/// <param name="Name">Short diagnostic id — e.g. <c>service:aaBootstrap</c>, <c>sql:ZB</c>, <c>registry:ArchestrA.Framework</c>.</param>
/// <param name="Category">Which subsystem the probe belongs to — lets callers filter (e.g. "Historian warns don't gate the core Galaxy smoke").</param>
/// <param name="Status">Outcome.</param>
/// <param name="Detail">One-line specific message an operator can act on — <c>"aaGR not installed — install the Galaxy Repository role from the System Platform setup"</c> beats <c>"failed"</c>.</param>
public sealed record PrerequisiteCheck(
string Name,
PrerequisiteCategory Category,
PrerequisiteStatus Status,
string Detail);
public enum PrerequisiteStatus
{
/// <summary>Prerequisite is met; no action needed.</summary>
Pass,
/// <summary>Soft dependency missing — stack still runs but some feature (e.g. logging) is degraded.</summary>
Warn,
/// <summary>Hard dependency missing — live tests can't proceed; <see cref="PrerequisiteReport.SkipReason"/> surfaces this.</summary>
Fail,
/// <summary>Probe wasn't applicable in this environment (e.g. non-Windows host, Historian not installed).</summary>
Skip,
}
public enum PrerequisiteCategory
{
/// <summary>Platform sanity — process bitness, OS platform, DCOM/RPCSS.</summary>
Environment,
/// <summary>Hard-required AVEVA Windows services (aaBootstrap, aaGR, NmxSvc).</summary>
AvevaCoreService,
/// <summary>Soft-required AVEVA Windows services (aaLogger, aaUserValidator) — warn only.</summary>
AvevaSoftService,
/// <summary>ArchestrA Framework install markers (registry + files).</summary>
AvevaInstall,
/// <summary>MXAccess COM server registration + file on disk.</summary>
MxAccessCom,
/// <summary>SQL Server reachability + ZB database presence + deployed-object count.</summary>
GalaxyRepository,
/// <summary>Historian services (optional — only required for HistoryRead IPC paths).</summary>
AvevaHistorian,
/// <summary>OtOpcUa-side services (OtOpcUa, OtOpcUaGalaxyHost) + third-party deps (GLAuth).</summary>
OtOpcUaService,
}

View File

@@ -0,0 +1,94 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
/// <summary>
/// Aggregated result of an <see cref="AvevaPrerequisites.CheckAll"/> run. Test fixtures
/// typically call <see cref="SkipReason"/> to produce the argument for xUnit's
/// <c>Assert.Skip</c> when any hard dependency failed.
/// </summary>
public sealed class PrerequisiteReport
{
public IReadOnlyList<PrerequisiteCheck> Checks { get; }
public PrerequisiteReport(IEnumerable<PrerequisiteCheck> checks)
{
Checks = [.. checks];
}
/// <summary>True when every probe is Pass / Warn / Skip — no Fail entries.</summary>
public bool IsLivetestReady => !Checks.Any(c => c.Status == PrerequisiteStatus.Fail);
/// <summary>
/// True when only the AVEVA-side probes pass — ignores failures in the
/// <see cref="PrerequisiteCategory.OtOpcUaService"/> category. Lets a live-test gate
/// say "AVEVA is ready even if the v2 services aren't installed yet" without
/// conflating the two. Useful for tests that exercise Galaxy directly (e.g.
/// <see cref="GalaxyRepositoryLiveSmokeTests"/>) rather than through our stack.
/// </summary>
public bool IsAvevaSideReady =>
!Checks.Any(c => c.Status == PrerequisiteStatus.Fail && c.Category != PrerequisiteCategory.OtOpcUaService);
/// <summary>
/// Multi-line message for <c>Assert.Skip</c> when a hard dependency isn't met. Returns
/// null when <see cref="IsLivetestReady"/> is true.
/// </summary>
public string? SkipReason
{
get
{
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail).ToList();
if (fails.Count == 0) return null;
var sb = new StringBuilder();
sb.AppendLine($"Live-AVEVA prerequisites not met ({fails.Count} failed):");
foreach (var f in fails)
sb.AppendLine($" • [{f.Category}] {f.Name} — {f.Detail}");
sb.Append("Run `Get-Service aa*` / `sqlcmd -S localhost -d ZB -E -Q \"SELECT 1\"` to triage.");
return sb.ToString();
}
}
/// <summary>
/// Human-readable summary of warnings — caller decides whether to log or ignore. Useful
/// when a live test does pass but an operator should know their environment is degraded.
/// </summary>
public string? Warnings
{
get
{
var warns = Checks.Where(c => c.Status == PrerequisiteStatus.Warn).ToList();
if (warns.Count == 0) return null;
var sb = new StringBuilder();
sb.AppendLine($"AVEVA prerequisites with warnings ({warns.Count}):");
foreach (var w in warns)
sb.AppendLine($" • [{w.Category}] {w.Name} — {w.Detail}");
return sb.ToString();
}
}
/// <summary>
/// Throw <see cref="InvalidOperationException"/> if any <paramref name="categories"/>
/// contain a Fail — useful when a specific test needs, say, Galaxy Repository but doesn't
/// care about Historian. Call before <c>Assert.Skip</c> if you want to be strict.
/// </summary>
public void RequireCategories(params PrerequisiteCategory[] categories)
{
var set = categories.ToHashSet();
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail && set.Contains(c.Category)).ToList();
if (fails.Count == 0) return;
var detail = string.Join("; ", fails.Select(f => $"{f.Name}: {f.Detail}"));
throw new InvalidOperationException($"Required prerequisite categories failed: {detail}");
}
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine($"PrerequisiteReport: {Checks.Count} checks");
foreach (var c in Checks)
sb.AppendLine($" [{c.Status,-4}] {c.Category}/{c.Name}: {c.Detail}");
return sb.ToString();
}
}

View File

@@ -0,0 +1,102 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
/// <summary>
/// Confirms MXAccess COM server registration by resolving the
/// <c>LMXProxy.LMXProxyServer</c> ProgID to its CLSID, then checking that the CLSID's
/// 32-bit <c>InprocServer32</c> entry points at a file that exists on disk.
/// </summary>
/// <remarks>
/// A common failure mode on partial installs: ProgID is registered but the CLSID
/// InprocServer32 DLL is missing (previous install uninstalled but registry orphan remains).
/// This probe surfaces that case with an actionable message instead of the
/// <c>0x80040154 REGDB_E_CLASSNOTREG</c> you'd see from a late COM activation failure.
/// </remarks>
public static class MxAccessComProbe
{
public const string ProgId = "LMXProxy.LMXProxyServer";
public const string VersionedProgId = "LMXProxy.LMXProxyServer.1";
public static PrerequisiteCheck Check()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Skip, "COM registration probes only run on Windows.");
}
return CheckWindows();
}
[SupportedOSPlatform("windows")]
private static PrerequisiteCheck CheckWindows()
{
try
{
var (clsid, dll) = RegistryProbe.ResolveProgIdToInproc(ProgId);
if (clsid is null)
{
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Fail,
$"ProgID {ProgId} not registered — MXAccess COM server isn't installed. " +
$"Install System Platform's MXAccess component and re-run.");
}
if (string.IsNullOrWhiteSpace(dll))
{
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Fail,
$"ProgID {ProgId} → CLSID {clsid} but InprocServer32 is empty. " +
$"Registry is orphaned; re-register with: regsvr32 /s LmxProxy.dll (from an elevated cmd in the Framework bin dir).");
}
// Resolve the recorded path — sometimes registered as a bare filename that the COM
// runtime resolves via the current process's DLL-search path. Accept either an
// absolute path that exists, or a bare filename whose resolution we can't verify
// without loading it (treat as Pass-with-note).
if (Path.IsPathRooted(dll))
{
if (!File.Exists(dll))
{
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Fail,
$"ProgID {ProgId} → CLSID {clsid} → InprocServer32 {dll}, but the file is missing. " +
$"Re-install the Framework or restore from backup.");
}
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Pass,
$"ProgID {ProgId} → {dll} (file exists).");
}
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Pass,
$"ProgID {ProgId} → {dll} (bare filename — relies on PATH resolution at COM activation time).");
}
catch (Exception ex)
{
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
PrerequisiteStatus.Warn,
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Warn when running as a 64-bit process — MXAccess COM activation will fail with
/// <c>0x80040154</c> regardless of registration state. The production drivers run net48
/// x86; xunit hosts run 64-bit by default so this often surfaces first.
/// </summary>
public static PrerequisiteCheck CheckProcessBitness()
{
if (Environment.Is64BitProcess)
{
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
PrerequisiteStatus.Warn,
"Test host is 64-bit. Direct MXAccess COM activation would fail with REGDB_E_CLASSNOTREG (0x80040154); " +
"the production driver workaround is to run Galaxy.Host as a 32-bit process. Tests that only " +
"talk to the Host service over the named pipe aren't affected.");
}
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
PrerequisiteStatus.Pass, "Test host is 32-bit.");
}
}

View File

@@ -0,0 +1,59 @@
using System.IO.Pipes;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
/// <summary>
/// Verifies the <c>OtOpcUaGalaxyHost</c> named-pipe endpoint is accepting connections —
/// the handshake the Proxy performs at boot. A clean pipe connect without sending any
/// framed message proves the Host service is listening; we disconnect immediately so we
/// don't consume a session slot.
/// </summary>
/// <remarks>
/// Default pipe name matches the installer script's <c>OTOPCUA_GALAXY_PIPE</c> default.
/// Override when the Host service was installed with a non-default name (custom deployments).
/// </remarks>
public static class NamedPipeProbe
{
public const string DefaultGalaxyHostPipeName = "OtOpcUaGalaxy";
public static async Task<PrerequisiteCheck> CheckGalaxyHostPipeAsync(
string? pipeName = null, CancellationToken ct = default)
{
pipeName ??= DefaultGalaxyHostPipeName;
try
{
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(2));
await client.ConnectAsync(cts.Token);
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
PrerequisiteStatus.Pass,
$@"Pipe \\.\pipe\{pipeName} accepted a connection — OtOpcUaGalaxyHost is listening.");
}
catch (OperationCanceledException)
{
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
PrerequisiteStatus.Fail,
$@"Pipe \\.\pipe\{pipeName} not connectable within 2s — OtOpcUaGalaxyHost service isn't running. " +
"Start with: sc.exe start OtOpcUaGalaxyHost");
}
catch (TimeoutException)
{
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
PrerequisiteStatus.Fail,
$@"Pipe \\.\pipe\{pipeName} connect timed out — service may be starting or stuck. " +
"Check: sc.exe query OtOpcUaGalaxyHost");
}
catch (Exception ex)
{
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
PrerequisiteStatus.Fail,
$@"Pipe \\.\pipe\{pipeName} connect failed: {ex.GetType().Name}: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,162 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
/// <summary>
/// Reads HKLM registry keys to confirm ArchestrA Framework / System Platform install
/// markers. Matches the registered paths documented in
/// <c>docs/v2/implementation/</c> — System Platform is 32-bit so keys live under
/// <c>HKLM\SOFTWARE\WOW6432Node\ArchestrA\...</c>.
/// </summary>
public static class RegistryProbe
{
// Canonical install roots per the research on our dev box (System Platform 2020 R2).
public const string ArchestrARootKey = @"SOFTWARE\WOW6432Node\ArchestrA";
public const string FrameworkKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework";
public const string PlatformKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework\Platform";
public const string MsiInstallKey = @"SOFTWARE\WOW6432Node\ArchestrA\MSIInstall";
public static PrerequisiteCheck CheckFrameworkInstalled()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
}
return FrameworkInstalledWindows();
}
public static PrerequisiteCheck CheckPlatformDeployed()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new PrerequisiteCheck("registry:ArchestrA.Platform", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
}
return PlatformDeployedWindows();
}
public static PrerequisiteCheck CheckRebootPending()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
}
return RebootPendingWindows();
}
[SupportedOSPlatform("windows")]
private static PrerequisiteCheck FrameworkInstalledWindows()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(FrameworkKey);
if (key is null)
{
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Fail,
$"Missing {FrameworkKey} — ArchestrA Framework isn't installed. Install AVEVA System Platform from the setup media.");
}
var installPath = key.GetValue("InstallPath") as string;
var rootPath = key.GetValue("RootPath") as string;
if (string.IsNullOrWhiteSpace(installPath) || string.IsNullOrWhiteSpace(rootPath))
{
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
$"Framework key exists but InstallPath/RootPath values missing — install may be incomplete.");
}
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Pass,
$"Installed at {installPath} (RootPath {rootPath}).");
}
catch (Exception ex)
{
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
}
}
[SupportedOSPlatform("windows")]
private static PrerequisiteCheck PlatformDeployedWindows()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(PlatformKey);
var pfeConfig = key?.GetValue("PfeConfigOptions") as string;
if (string.IsNullOrWhiteSpace(pfeConfig))
{
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
$"No Platform object deployed locally (Platform\\PfeConfigOptions empty). MXAccess will connect but subscriptions will fail. Deploy a Platform from the IDE.");
}
// PfeConfigOptions format: "PlatformId=N,EngineId=N,EngineName=...,..."
// A non-deployed state leaves PlatformId=0 or the key empty.
if (pfeConfig.Contains("PlatformId=0,", StringComparison.OrdinalIgnoreCase))
{
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
$"Platform never deployed (PfeConfigOptions has PlatformId=0). Deploy a Platform from the IDE before running live tests.");
}
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Pass,
$"Platform deployed ({pfeConfig}).");
}
catch (Exception ex)
{
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
}
}
[SupportedOSPlatform("windows")]
private static PrerequisiteCheck RebootPendingWindows()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(MsiInstallKey);
var rebootRequired = key?.GetValue("RebootRequired") as string;
if (string.Equals(rebootRequired, "True", StringComparison.OrdinalIgnoreCase))
{
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
"An ArchestrA patch has been installed but the machine hasn't rebooted. Post-patch behavior is undefined until a reboot.");
}
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Pass,
"No pending reboot flagged.");
}
catch (Exception ex)
{
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
PrerequisiteStatus.Warn,
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Read the registered <see cref="ComProgIdCheck"/> CLSID for the given ProgID and
/// resolve the 32-bit <c>InprocServer32</c> file path. Returns null when either is missing.
/// </summary>
[SupportedOSPlatform("windows")]
internal static (string? Clsid, string? InprocDllPath) ResolveProgIdToInproc(string progId)
{
using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID");
var clsid = progIdKey?.GetValue(null) as string;
if (string.IsNullOrWhiteSpace(clsid)) return (null, null);
// 32-bit COM server under Wow6432Node\CLSID\{guid}\InprocServer32 default value.
using var inproc = Registry.LocalMachine.OpenSubKey(
$@"SOFTWARE\Classes\WOW6432Node\CLSID\{clsid}\InprocServer32");
var dll = inproc?.GetValue(null) as string;
return (clsid, dll);
}
}

View File

@@ -0,0 +1,85 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.ServiceProcess;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
/// <summary>
/// Queries the Windows Service Control Manager to report whether a named service is
/// installed, its current state, and its start type. Non-Windows hosts return Skip.
/// </summary>
public static class ServiceProbe
{
public static PrerequisiteCheck Check(
string serviceName,
PrerequisiteCategory category,
bool hardRequired,
string whatItDoes)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new PrerequisiteCheck(
Name: $"service:{serviceName}",
Category: category,
Status: PrerequisiteStatus.Skip,
Detail: "Service probes only run on Windows.");
}
return CheckWindows(serviceName, category, hardRequired, whatItDoes);
}
[SupportedOSPlatform("windows")]
private static PrerequisiteCheck CheckWindows(
string serviceName, PrerequisiteCategory category, bool hardRequired, string whatItDoes)
{
try
{
using var sc = new ServiceController(serviceName);
// Touch the Status to force the SCM lookup; if the service doesn't exist, this throws
// InvalidOperationException with message "Service ... was not found on computer.".
var status = sc.Status;
var startType = sc.StartType;
return status switch
{
ServiceControllerStatus.Running => new PrerequisiteCheck(
$"service:{serviceName}", category, PrerequisiteStatus.Pass,
$"Running ({whatItDoes})"),
// DemandStart services (like NmxSvc) that are Stopped are not necessarily a
// failure — the master service (aaBootstrap) brings them up on demand. Treat
// Stopped+Demand as Warn so operators know the situation but tests still proceed.
ServiceControllerStatus.Stopped when startType == ServiceStartMode.Manual =>
new PrerequisiteCheck(
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
$"Installed but Stopped (start type Manual — {whatItDoes}). " +
"Will be pulled up on demand by the master service; fine for tests."),
ServiceControllerStatus.Stopped => Fail(
$"Installed but Stopped. Start with: sc.exe start {serviceName} ({whatItDoes})"),
_ => new PrerequisiteCheck(
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
$"Transitional state {status} ({whatItDoes}) — try again in a few seconds."),
};
PrerequisiteCheck Fail(string detail) => new(
$"service:{serviceName}", category,
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
detail);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{
return new PrerequisiteCheck(
$"service:{serviceName}", category,
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
$"Not installed ({whatItDoes}). Install the relevant System Platform component and retry.");
}
catch (Exception ex)
{
return new PrerequisiteCheck(
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
$"Probe failed ({ex.GetType().Name}: {ex.Message}) — treat as unknown.");
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
/// <summary>
/// Verifies the Galaxy Repository SQL side: SQL Server reachable, <c>ZB</c> database
/// present, and at least one deployed object exists (so live tests have something to read).
/// Reuses the Windows-auth connection string the repo code defaults to.
/// </summary>
public static class SqlProbe
{
public const string DefaultConnectionString =
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=3;";
public static async Task<PrerequisiteCheck> CheckZbDatabaseAsync(
string? connectionString = null, CancellationToken ct = default)
{
connectionString ??= DefaultConnectionString;
try
{
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct);
// DB_ID returns null when the database doesn't exist on the connected server — distinct
// failure mode from "server unreachable", deserves a distinct message.
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT DB_ID('ZB')";
var dbIdObj = await cmd.ExecuteScalarAsync(ct);
if (dbIdObj is null || dbIdObj is DBNull)
{
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Fail,
"SQL Server reachable but database ZB does not exist. " +
"Create the Galaxy from the IDE or restore a .cab backup.");
}
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Pass, "Connected; ZB database exists.");
}
catch (SqlException ex)
{
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Fail,
$"SQL Server unreachable: {ex.Message}. Ensure MSSQLSERVER service is running (sc.exe start MSSQLSERVER) and TCP 1433 is open.");
}
catch (Exception ex)
{
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Fail,
$"Unexpected probe error: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Returns the count of deployed Galaxy objects (<c>deployed_version &gt; 0</c>). Zero
/// isn't a hard failure — lets someone boot a fresh Galaxy and still get meaningful
/// test-suite output — but it IS a warning because any live-read smoke will have
/// nothing to read.
/// </summary>
public static async Task<PrerequisiteCheck> CheckDeployedObjectCountAsync(
string? connectionString = null, CancellationToken ct = default)
{
connectionString ??= DefaultConnectionString;
try
{
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM gobject WHERE deployed_version > 0";
var countObj = await cmd.ExecuteScalarAsync(ct);
var count = countObj is int i ? i : 0;
return count > 0
? new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Pass, $"{count} objects deployed — live reads have data to return.")
: new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Warn,
"ZB contains no deployed objects. Discovery smoke tests will return empty hierarchies; " +
"deploy at least a Platform + AppEngine from the IDE to exercise the read path.");
}
catch (Exception ex)
{
return new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
PrerequisiteStatus.Warn,
$"Couldn't count deployed objects: {ex.GetType().Name}: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Multi-target: net10.0 for modern consumer projects (Galaxy.Proxy.Tests, E2E, Admin.Tests),
net48 for the Galaxy.Host.Tests project that has to stay on .NET Framework x86 for its
MXAccess-COM parent project. The helper uses no OS-level APIs that differ between the
two frameworks (registry / SQL / ServiceController are surface-compatible). -->
<TargetFrameworks>net10.0;net48</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport</RootNamespace>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<!-- System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are cross-platform
assemblies that throw PlatformNotSupportedException on non-Windows; the probes in
this project guard with RuntimeInformation.IsOSPlatform(OSPlatform.Windows) so they
return Skip on Linux/macOS rather than crashing the test host. -->
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0"/>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0"/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<!-- net48 ships System.ServiceProcess + Microsoft.Win32 in-box via BCL references. -->
<Reference Include="System.ServiceProcess"/>
<!-- Microsoft.Data.SqlClient v6 supports net462+; single-target for consistency. -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal
/// <c>.xmpp</c> profile in <c>ModbusPal/DL205.xmpp</c> exposes (or the real PLC, when
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
/// </summary>
/// <remarks>
/// This is the scaffold — each tag is deliberately generic so the smoke test has stable
/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
/// access, etc.) will land in their own test classes alongside this profile as the user
/// validates each behavior in ModbusPal; see <c>docs/v2/modbus-test-plan.md</c> §per-device
/// quirk catalog for the checklist.
/// </remarks>
public static class DL205Profile
{
/// <summary>Holding register the smoke test reads. Address 100 sidesteps the DL205
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
public const ushort SmokeHoldingRegister = 100;
/// <summary>Expected value the ModbusPal profile seeds into register 100. When running
/// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
/// test seeds this value first, then reads it back.</summary>
public const short SmokeHoldingValue = 1234;
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
{
Host = host,
Port = port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(
Name: "DL205_Smoke_HReg100",
Region: ModbusRegion.HoldingRegisters,
Address: SmokeHoldingRegister,
DataType: ModbusDataType.Int16,
Writable: true),
],
// Disable the background probe loop — integration tests drive reads explicitly and
// the probe would race with assertions.
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when
/// <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives the full <see cref="ModbusDriver"/>
/// + real <see cref="ModbusTcpTransport"/> stack — no fake transport. Success proves the
/// driver can initialize against the simulator, write a known value, and read it back
/// with the correct status and value, which is the baseline every device-quirk test
/// builds on.
/// </summary>
/// <remarks>
/// Device-specific quirk tests (word order, max-register, register-zero access, exception
/// code translation, etc.) land as separate test classes in this directory as each quirk
/// is validated in ModbusPal. Keep this smoke test deliberately narrow — any deviation
/// the driver hits beyond "happy-path FC16 + FC03 round-trip" belongs in its own named
/// test so filtering by device class (<c>--filter DisplayName~DL205</c>) surfaces the
/// quirk-specific failure mode.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_roundtrip_write_then_read_of_holding_register()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = DL205Profile.BuildOptions(sim.Host, sim.Port);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-smoke");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
// Write first so the test is self-contained — ModbusPal's default register bank is
// zeroed at simulator start, and tests must not depend on prior-test state per the
// test-plan conventions.
var writeResults = await driver.WriteAsync(
[new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)],
TestContext.Current.CancellationToken);
writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
var readResults = await driver.ReadAsync(
["DL205_Smoke_HReg100"],
TestContext.Current.CancellationToken);
readResults.Count.ShouldBe(1);
readResults[0].StatusCode.ShouldBe(0u);
readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue);
}
}

View File

@@ -0,0 +1,30 @@
# ModbusPal simulator profiles
Drop device-specific `.xmpp` profiles here. The integration tests connect to the
endpoint in `MODBUS_SIM_ENDPOINT` (default `localhost:502`) and expect the
simulator to already be running — tests do not launch ModbusPal themselves,
because its Java GUI + JRE requirement is heavier than the harness is worth.
## Getting started
1. Download ModbusPal from SourceForge (`modbuspal.jar`).
2. `java -jar modbuspal.jar` to launch the GUI.
3. Load a profile from this directory (or configure one manually) and start the
simulator on TCP port 502.
4. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — tests
auto-skip with a clear `SkipReason` if the TCP probe at the configured
endpoint fails within 2 seconds.
## Profile files
- `DL205.xmpp`_to be added_ — register map reflecting the AutomationDirect
DL205 quirks tracked in `docs/v2/modbus-test-plan.md`. The scaffolded smoke
test in `DL205/DL205SmokeTests.cs` needs holding register 100 writable and
present; a minimal ModbusPal profile with a single holding-register bank at
address 100 is sufficient.
## Environment variables
- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint. Accepts `host:port`;
defaults to `localhost:502`. Useful when pointing the suite at a real PLC on
the bench.

View File

@@ -0,0 +1,66 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:502</c>) and TCP-connects once at
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
/// <c>GalaxyRepositoryLiveSmokeTests</c>.
/// </summary>
/// <remarks>
/// <para>
/// Do NOT keep the probe socket open for the life of the fixture. The probe is a
/// one-shot liveness check; tests open their own transports (the real
/// <see cref="ModbusTcpTransport"/>) against the same endpoint. Sharing a socket
/// across tests would serialize them on a single TCP stream.
/// </para>
/// <para>
/// The fixture is a collection fixture so the reachability probe runs once per test
/// session, not per test — checking every test would waste several seconds against a
/// firewalled endpoint that times out each attempt.
/// </para>
/// </remarks>
public sealed class ModbusSimulatorFixture : IAsyncDisposable
{
private const string DefaultEndpoint = "localhost:502";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
public string Host { get; }
public int Port { get; }
public string? SkipReason { get; }
public ModbusSimulatorFixture()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
Host = parts[0];
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
try
{
using var client = new TcpClient();
var task = client.ConnectAsync(Host, Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
}
}
catch (Exception ex)
{
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class ModbusSimulatorCollection : Xunit.ICollectionFixture<ModbusSimulatorFixture>
{
public const string Name = "ModbusSimulator";
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="ModbusPal\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,175 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusDataTypeTests
{
/// <summary>
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
/// </summary>
[Theory]
[InlineData(ModbusDataType.BitInRegister, 1)]
[InlineData(ModbusDataType.Int16, 1)]
[InlineData(ModbusDataType.UInt16, 1)]
[InlineData(ModbusDataType.Int32, 2)]
[InlineData(ModbusDataType.UInt32, 2)]
[InlineData(ModbusDataType.Float32, 2)]
[InlineData(ModbusDataType.Int64, 4)]
[InlineData(ModbusDataType.UInt64, 4)]
[InlineData(ModbusDataType.Float64, 4)]
public void RegisterCount_returns_correct_register_count_per_type(ModbusDataType t, int expected)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, t);
ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected);
}
[Theory]
[InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes)
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 2)]
[InlineData(10, 5)]
public void RegisterCount_for_String_rounds_up_to_register_pair(ushort chars, int expectedRegs)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, StringLength: chars);
// 0-char is encoded as 0 regs; the test case expects 1 for lengths 1-2, 2 for 3-4, etc.
if (chars == 0) ModbusDriver.RegisterCount(tag).ShouldBe((ushort)0);
else ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expectedRegs);
}
// --- Int32 / UInt32 / Float32 with byte-order variants ---
[Fact]
public void Int32_BigEndian_decodes_ABCD_layout()
{
// Value 0x12345678 → bytes [0x12, 0x34, 0x56, 0x78] as PLC wrote them.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.BigEndian);
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Fact]
public void Int32_WordSwap_decodes_CDAB_layout()
{
// Siemens/AB PLC stored 0x12345678 as register[0] = 0x5678, register[1] = 0x1234.
// Wire bytes are [0x56, 0x78, 0x12, 0x34]; with ByteOrder=WordSwap we get 0x12345678 back.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
ByteOrder: ModbusByteOrder.WordSwap);
var bytes = new byte[] { 0x56, 0x78, 0x12, 0x34 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
}
[Fact]
public void Float32_WordSwap_encode_decode_roundtrips()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32,
ByteOrder: ModbusByteOrder.WordSwap);
var wire = ModbusDriver.EncodeRegister(25.5f, tag);
wire.Length.ShouldBe(4);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(25.5f);
}
// --- Int64 / UInt64 / Float64 ---
[Fact]
public void Int64_BigEndian_roundtrips()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int64);
var wire = ModbusDriver.EncodeRegister(0x0123456789ABCDEFL, tag);
wire.Length.ShouldBe(8);
BinaryPrimitives.ReadInt64BigEndian(wire).ShouldBe(0x0123456789ABCDEFL);
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL);
}
[Fact]
public void UInt64_WordSwap_reverses_four_words()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.UInt64,
ByteOrder: ModbusByteOrder.WordSwap);
var value = 0xAABBCCDDEEFF0011UL;
var wireBE = new byte[8];
BinaryPrimitives.WriteUInt64BigEndian(wireBE, value);
// Word-swap layout: [word3, word2, word1, word0] where each word keeps its bytes big-endian.
var wireWS = new byte[] { wireBE[6], wireBE[7], wireBE[4], wireBE[5], wireBE[2], wireBE[3], wireBE[0], wireBE[1] };
ModbusDriver.DecodeRegister(wireWS, tag).ShouldBe(value);
var roundtrip = ModbusDriver.EncodeRegister(value, tag);
roundtrip.ShouldBe(wireWS);
}
[Fact]
public void Float64_roundtrips_under_word_swap()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64,
ByteOrder: ModbusByteOrder.WordSwap);
var wire = ModbusDriver.EncodeRegister(3.14159265358979d, tag);
wire.Length.ShouldBe(8);
((double)ModbusDriver.DecodeRegister(wire, tag)!).ShouldBe(3.14159265358979d, tolerance: 1e-12);
}
// --- BitInRegister ---
[Theory]
[InlineData(0b0000_0000_0000_0001, 0, true)]
[InlineData(0b0000_0000_0000_0001, 1, false)]
[InlineData(0b1000_0000_0000_0000, 15, true)]
[InlineData(0b0100_0000_0100_0000, 6, true)]
[InlineData(0b0100_0000_0100_0000, 14, true)]
[InlineData(0b0100_0000_0100_0000, 7, false)]
public void BitInRegister_extracts_bit_at_index(ushort raw, byte bitIndex, bool expected)
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
BitIndex: bitIndex);
var bytes = new byte[] { (byte)(raw >> 8), (byte)(raw & 0xFF) };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected);
}
[Fact]
public void BitInRegister_write_is_not_supported_in_PR24()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
BitIndex: 5);
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
.Message.ShouldContain("read-modify-write");
}
// --- String ---
[Fact]
public void String_decodes_ASCII_packed_two_chars_per_register()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 6);
// "HELLO!" = 0x48 0x45 0x4C 0x4C 0x4F 0x21 across 3 registers.
var bytes = "HELLO!"u8.ToArray();
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!");
}
[Fact]
public void String_decode_truncates_at_first_nul()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 10);
var bytes = new byte[] { 0x48, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi");
}
[Fact]
public void String_encode_nul_pads_remaining_bytes()
{
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 8);
var wire = ModbusDriver.EncodeRegister("Hi", tag);
wire.Length.ShouldBe(8);
wire[0].ShouldBe((byte)'H');
wire[1].ShouldBe((byte)'i');
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
}
}

View File

@@ -0,0 +1,244 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusDriverTests
{
/// <summary>
/// In-memory Modbus TCP server impl that speaks the function codes the driver uses.
/// Maintains a register/coil bank so Read/Write round-trips work.
/// </summary>
private sealed class FakeTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[256];
public readonly ushort[] InputRegisters = new ushort[256];
public readonly bool[] Coils = new bool[256];
public readonly bool[] DiscreteInputs = new bool[256];
public bool ForceConnectFail { get; set; }
public Task ConnectAsync(CancellationToken ct)
=> ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var fc = pdu[0];
return fc switch
{
0x01 => Task.FromResult(ReadBits(pdu, Coils)),
0x02 => Task.FromResult(ReadBits(pdu, DiscreteInputs)),
0x03 => Task.FromResult(ReadRegs(pdu, HoldingRegisters)),
0x04 => Task.FromResult(ReadRegs(pdu, InputRegisters)),
0x05 => Task.FromResult(WriteCoil(pdu)),
0x06 => Task.FromResult(WriteSingleReg(pdu)),
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
};
}
private byte[] ReadBits(byte[] pdu, bool[] bank)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var byteCount = (byte)((qty + 7) / 8);
var resp = new byte[2 + byteCount];
resp[0] = pdu[0];
resp[1] = byteCount;
for (var i = 0; i < qty; i++)
if (bank[addr + i]) resp[2 + (i / 8)] |= (byte)(1 << (i % 8));
return resp;
}
private byte[] ReadRegs(byte[] pdu, ushort[] bank)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var byteCount = (byte)(qty * 2);
var resp = new byte[2 + byteCount];
resp[0] = pdu[0];
resp[1] = byteCount;
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(bank[addr + i] >> 8);
resp[3 + i * 2] = (byte)(bank[addr + i] & 0xFF);
}
return resp;
}
private byte[] WriteCoil(byte[] pdu)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
Coils[addr] = pdu[3] == 0xFF;
return pdu; // Modbus echoes the request on write success
}
private byte[] WriteSingleReg(byte[] pdu)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
HoldingRegisters[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
return pdu;
}
private byte[] WriteMultipleRegs(byte[] pdu)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
for (var i = 0; i < qty; i++)
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver driver, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
{
var fake = new FakeTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
return (drv, fake);
}
[Fact]
public async Task Initialize_connects_and_populates_tag_map()
{
var (drv, _) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Read_Int16_holding_register_returns_BigEndian_value()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[10] = 12345;
var r = await drv.ReadAsync(["Level"], CancellationToken.None);
r[0].Value.ShouldBe((short)12345);
r[0].StatusCode.ShouldBe(0u);
}
[Fact]
public async Task Read_Float32_spans_two_registers_BigEndian()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
await drv.InitializeAsync("{}", CancellationToken.None);
// IEEE 754 single for 25.5f is 0x41CC0000 — [41 CC][00 00] big-endian across two regs.
var bytes = new byte[4];
BinaryPrimitives.WriteSingleBigEndian(bytes, 25.5f);
fake.HoldingRegisters[4] = (ushort)((bytes[0] << 8) | bytes[1]);
fake.HoldingRegisters[5] = (ushort)((bytes[2] << 8) | bytes[3]);
var r = await drv.ReadAsync(["Temp"], CancellationToken.None);
r[0].Value.ShouldBe(25.5f);
}
[Fact]
public async Task Read_Coil_returns_boolean()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Run", ModbusRegion.Coils, 3, ModbusDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.Coils[3] = true;
var r = await drv.ReadAsync(["Run"], CancellationToken.None);
r[0].Value.ShouldBe(true);
}
[Fact]
public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception()
{
var (drv, _) = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["DoesNotExist"], CancellationToken.None);
r[0].StatusCode.ShouldBe(0x80340000u);
}
[Fact]
public async Task Write_UInt16_holding_register_roundtrips()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Setpoint", ModbusRegion.HoldingRegisters, 20, ModbusDataType.UInt16));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Setpoint", (ushort)42000)], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
fake.HoldingRegisters[20].ShouldBe((ushort)42000);
}
[Fact]
public async Task Write_Float32_uses_FC16_WriteMultipleRegisters()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Temp", 25.5f)], CancellationToken.None);
// Decode back through the fake bank to check the two-register shape.
var raw = new byte[4];
raw[0] = (byte)(fake.HoldingRegisters[4] >> 8);
raw[1] = (byte)(fake.HoldingRegisters[4] & 0xFF);
raw[2] = (byte)(fake.HoldingRegisters[5] >> 8);
raw[3] = (byte)(fake.HoldingRegisters[5] & 0xFF);
BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f);
}
[Fact]
public async Task Write_to_InputRegister_returns_BadNotWritable()
{
var (drv, _) = NewDriver(new ModbusTagDefinition("Ro", ModbusRegion.InputRegisters, 0, ModbusDataType.UInt16, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.WriteAsync([new WriteRequest("Ro", (ushort)7)], CancellationToken.None);
r[0].StatusCode.ShouldBe(0x803B0000u);
}
[Fact]
public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag()
{
var (drv, _) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32),
new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.Count.ShouldBe(1);
builder.Folders[0].BrowseName.ShouldBe("Modbus");
builder.Variables.Count.ShouldBe(3);
builder.Variables.ShouldContain(v => v.BrowseName == "Level" && v.Info.DriverDataType == DriverDataType.Int32);
builder.Variables.ShouldContain(v => v.BrowseName == "Temp" && v.Info.DriverDataType == DriverDataType.Float32);
builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean);
}
// --- helpers ---
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}

View File

@@ -0,0 +1,208 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusProbeTests
{
/// <summary>
/// Transport fake the probe tests flip between "responding" and "unreachable" to
/// exercise the state machine. Calls to SendAsync with FC=0x03 count as probe traffic
/// (the driver's probe loop issues exactly that shape).
/// </summary>
private sealed class FlappyTransport : IModbusTransport
{
public volatile bool Reachable = true;
public int ProbeCount;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
if (!Reachable)
return Task.FromException<byte[]>(new IOException("transport unreachable"));
// Happy path — return a valid FC03 response for 1 register at addr.
if (pdu[0] == 0x03)
{
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var resp = new byte[2 + qty * 2];
resp[0] = 0x03;
resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
return Task.FromException<byte[]>(new NotSupportedException());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver drv, FlappyTransport fake) NewDriver(ModbusProbeOptions probe)
{
var fake = new FlappyTransport();
var opts = new ModbusDriverOptions { Host = "fake", Port = 502, Probe = probe };
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
[Fact]
public async Task Initial_state_is_Unknown_before_first_probe_tick()
{
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", CancellationToken.None);
var statuses = drv.GetHostStatuses();
statuses.Count.ShouldBe(1);
statuses[0].State.ShouldBe(HostState.Unknown);
statuses[0].HostName.ShouldBe("fake:502");
}
[Fact]
public async Task First_successful_probe_transitions_to_Running()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for the first probe to complete.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (fake.ProbeCount == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
// Then wait for the event to actually arrive.
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
while (transitions.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
transitions.Count.ShouldBeGreaterThanOrEqualTo(1);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.OldState.ShouldBe(HostState.Unknown);
t.NewState.ShouldBe(HostState.Running);
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Transport_failure_transitions_to_Stopped()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
fake.Reachable = false;
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
transitions.Select(t => t.NewState).ShouldContain(HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Recovery_transitions_Stopped_back_to_Running()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
fake.Reachable = false;
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
fake.Reachable = true;
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
// We expect at minimum: Unknown→Running, Running→Stopped, Stopped→Running.
transitions.Count.ShouldBeGreaterThanOrEqualTo(3);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
{
var (drv, _) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromSeconds(1),
});
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
await Task.Delay(500); // several more probe ticks, all successful — state shouldn't thrash
transitions.Count.ShouldBe(1); // only the initial Unknown→Running
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
{
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(300);
transitions.Count.ShouldBe(0);
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Shutdown_stops_the_probe_loop()
{
var (drv, fake) = NewDriver(new ModbusProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromSeconds(1),
});
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
var before = fake.ProbeCount;
await drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(400);
// A handful of in-flight ticks may complete after shutdown in a narrow race; the
// contract is that the loop stops scheduling new ones. Tolerate ≤1 extra.
(fake.ProbeCount - before).ShouldBeLessThanOrEqualTo(1);
}
private static async Task WaitForStateAsync(ModbusDriver drv, HostState expected, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (drv.GetHostStatuses()[0].State == expected) return;
await Task.Delay(25);
}
}
}

View File

@@ -0,0 +1,180 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusSubscriptionTests
{
/// <summary>
/// Lightweight fake transport the subscription tests drive through — only the FC03
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
/// between polls is how each test simulates a PLC value change.
/// </summary>
private sealed class FakeTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[256];
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var resp = new byte[2 + qty * 2];
resp[0] = 0x03;
resp[1] = (byte)(qty * 2);
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
}
return Task.FromResult(resp);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver drv, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
{
var fake = new FakeTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
fake.HoldingRegisters[1] = 200;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
events.Select(e => e.FullReference).ShouldContain("Level");
events.Select(e => e.FullReference).ShouldContain("Temp");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task Unchanged_values_do_not_raise_after_initial_poll()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(500); // ~5 poll cycles at 100ms, value stable the whole time
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1); // only the initial-data push, no change events after
}
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
fake.HoldingRegisters[0] = 200; // simulate PLC update
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBeGreaterThanOrEqualTo(2);
events.Last().Snapshot.Value.ShouldBe((short)200);
}
[Fact]
public async Task Unsubscribe_stops_the_polling_loop()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var countAfterUnsub = events.Count;
fake.HoldingRegisters[0] = 999; // would trigger a change if still polling
await Task.Delay(400);
events.Count.ShouldBe(countAfterUnsub);
}
[Fact]
public async Task SubscribeAsync_floors_intervals_below_100ms()
{
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
// 10ms requested — implementation floors to 100ms. We verify indirectly: over 300ms, a
// 10ms interval would produce many more events than a 100ms interval would on a stable
// value. Since the value is unchanged, we only expect the initial-data push (1 event).
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(10), CancellationToken.None);
await Task.Delay(300);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1);
}
[Fact]
public async Task Multiple_subscriptions_fire_independently()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var eventsA = new ConcurrentQueue<DataChangeEventArgs>();
var eventsB = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == "A") eventsA.Enqueue(e);
else if (e.FullReference == "B") eventsB.Enqueue(e);
};
var ha = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var hb = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(eventsA, 1, TimeSpan.FromSeconds(1));
await WaitForCountAsync(eventsB, 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(ha, CancellationToken.None);
var aCount = eventsA.Count;
fake.HoldingRegisters[1] = 77; // only B should pick this up
await WaitForCountAsync(eventsB, 2, TimeSpan.FromSeconds(2));
eventsA.Count.ShouldBe(aCount); // unchanged since unsubscribe
eventsB.Count.ShouldBeGreaterThanOrEqualTo(2);
await drv.UnsubscribeAsync(hb, CancellationToken.None);
}
private static async Task WaitForCountAsync<T>(ConcurrentQueue<T> q, int target, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (q.Count < target && DateTime.UtcNow < deadline)
await Task.Delay(25);
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,197 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Integration")]
public sealed class HostStatusPublisherTests : IDisposable
{
private const string DefaultServer = "localhost,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}";
private readonly string _connectionString;
private readonly ServiceProvider _sp;
public HostStatusPublisherTests()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_connectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
_sp = services.BuildServiceProvider();
using var scope = _sp.CreateScope();
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
}
public void Dispose()
{
_sp.Dispose();
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
IF DB_ID(N'{_databaseName}') IS NOT NULL
BEGIN
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [{_databaseName}];
END";
cmd.ExecuteNonQuery();
}
[Fact]
public async Task Publisher_upserts_one_row_per_host_reported_by_each_probe_driver()
{
var driverHost = new DriverHost();
await driverHost.RegisterAsync(new ProbeStubDriver("driver-a",
new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow),
new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)),
"{}", CancellationToken.None);
await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None);
var nodeOptions = NewNodeOptions("node-a");
var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService<IServiceScopeFactory>(),
NullLogger<HostStatusPublisher>.Instance);
await publisher.PublishOnceAsync(CancellationToken.None);
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync();
rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it");
rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a");
rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a");
rows.ShouldAllBe(r => r.NodeId == "node-a");
}
[Fact]
public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows()
{
var driver = new ProbeStubDriver("driver-x",
new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow));
var driverHost = new DriverHost();
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"),
_sp.GetRequiredService<IServiceScopeFactory>(),
NullLogger<HostStatusPublisher>.Instance);
await publisher.PublishOnceAsync(CancellationToken.None);
var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances
await publisher.PublishOnceAsync(CancellationToken.None);
var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc,
"heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers");
// Still exactly one row — a naive Add-every-tick would have thrown or duplicated.
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
(await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1);
}
[Fact]
public async Task State_change_between_ticks_updates_State_and_StateChangedUtc()
{
var driver = new ProbeStubDriver("driver-y",
new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10)));
var driverHost = new DriverHost();
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"),
_sp.GetRequiredService<IServiceScopeFactory>(),
NullLogger<HostStatusPublisher>.Instance);
await publisher.PublishOnceAsync(CancellationToken.None);
var before = await SingleRowAsync("node-y", "driver-y", "HostY");
// Swap the driver's reported state to Faulted with a newer transition timestamp.
var newChange = DateTime.UtcNow;
driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)];
await publisher.PublishOnceAsync(CancellationToken.None);
var after = await SingleRowAsync("node-y", "driver-y", "HostY");
after.State.ShouldBe(DriverHostState.Faulted);
// datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks,
// so the stored value rounds down. Compare at millisecond granularity to stay clean.
after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1));
after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc,
"StateChangedUtc must advance when the state actually changed");
before.State.ShouldBe(DriverHostState.Running);
}
[Fact]
public void MapState_translates_every_HostState_member()
{
HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running);
HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped);
HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted);
HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown);
}
private async Task<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
return await db.DriverHostStatuses.AsNoTracking()
.SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host);
}
private static NodeOptions NewNodeOptions(string nodeId) => new()
{
NodeId = nodeId,
ClusterId = "cluster-t",
ConfigDbConnectionString = "unused-publisher-gets-db-from-scope",
};
private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial)
: IDriver, IHostConnectivityProbe
{
public HostConnectivityStatus[] Statuses { get; set; } = initial;
public string DriverInstanceId => id;
public string DriverType => "ProbeStub";
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => Statuses;
// Keeps the compiler happy — event is part of the interface contract even if unused here.
internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e);
}
private sealed class NonProbeStubDriver(string id) : IDriver
{
public string DriverInstanceId => id;
public string DriverType => "NonProbeStub";
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Deterministic guards for Active Directory compatibility of the internal helpers
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
/// green.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LdapUserAuthenticatorAdCompatTests
{
[Fact]
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
{
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
// returns the first RDN's value regardless of attribute-type case, so operators'
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
}
[Fact]
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
{
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
}
[Fact]
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
{
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
// The authenticator needs one extractor that tolerates both shapes since directories
// in the field mix them depending on schema.
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
}
[Fact]
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
{
// AD login names can contain characters that are meaningful to LDAP filter syntax
// (parens, backslashes). The authenticator builds filters as
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
// ) → \29, \0 → \00.
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
.ShouldBe("admin\\29\\28cn=\\2a");
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
.ShouldBe("domain\\5cuser");
}
[Fact]
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
{
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
// existing deployments (pre-AD config) keep working. Changing the default breaks
// everyone's config silently; require an explicit review.
new LdapOptions().UserNameAttribute.ShouldBe("uid");
}
}

View File

@@ -0,0 +1,154 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
/// when the port is unreachable so the test suite stays portable on boxes without a
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
/// not just the flow-shape unit tests from PR 19.
/// </summary>
/// <remarks>
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
/// Server authenticator has to work even when the Server process is on a machine that
/// doesn't have the Admin assemblies loaded, and the two share no code by design
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
/// construction, DN resolution, or memberOf parsing, these tests surface it.
/// </remarks>
[Trait("Category", "LiveLdap")]
public sealed class LdapUserAuthenticatorLiveTests
{
private const string GlauthHost = "localhost";
private const int GlauthPort = 3893;
private static bool GlauthReachable()
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync(GlauthHost, GlauthPort);
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
}
catch { return false; }
}
// GLAuth dev directory groups are named identically to the OPC UA roles
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
// identity translation. The authenticator still exercises every step of the pipeline —
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
// data; the identity map just means the assertion is phrased with no surprise rename
// in the middle.
private static LdapOptions GlauthOptions() => new()
{
Enabled = true,
Server = GlauthHost,
Port = GlauthPort,
UseTls = false,
AllowInsecureLdap = true,
SearchBase = "dc=lmxopcua,dc=local",
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
// user's password, then stays on the service-account session for memberOf lookup.
// Without this path, GLAuth ACLs block the authenticated user from reading their
// own entry in full — a plain self-search returns zero results and the role list
// ends up empty.
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
ServiceAccountPassword = "serviceaccount123",
DisplayNameAttribute = "cn",
GroupAttribute = "memberOf",
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
{
["ReadOnly"] = "ReadOnly",
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
["AlarmAck"] = "AlarmAck",
},
};
private static LdapUserAuthenticator NewAuthenticator() =>
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
[Fact]
public async Task Valid_credentials_bind_and_return_success()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.DisplayName.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
{
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
// (WriteOperate is the exact string the policy checks for), so the failure mode is
// concrete, not abstract.
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
}
[Fact]
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
// surface every mapped role, not just the primary group. Guards against a regression
// where the memberOf parsing stops after the first match or misses the primary-group
// fallback.
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Error);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
result.Roles.ShouldContain("AlarmAck");
}
[Fact]
public async Task Wrong_password_returns_failure()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Unknown_user_returns_failure()
{
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task Empty_credentials_fail_without_touching_the_directory()
{
// Pre-flight guard — doesn't require GLAuth.
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Error.ShouldContain("Credentials", Case.Insensitive);
}
}

View File

@@ -0,0 +1,191 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
/// only exercises a single-driver topology; this sibling fixture registers two.
/// </summary>
/// <remarks>
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> argument). A client
/// that browses one namespace must see only that driver's subtree, and a read against a
/// variable in one namespace must return that driver's value, not the other's — this is
/// what stops a cross-driver routing regression from going unnoticed when the v1
/// single-driver code path gets new knobs.
/// </remarks>
[Trait("Category", "Integration")]
public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime
{
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest";
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}");
private DriverHost _driverHost = null!;
private OpcUaApplicationHost _server = null!;
public async ValueTask InitializeAsync()
{
_driverHost = new DriverHost();
await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42),
"{}", CancellationToken.None);
await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99),
"{}", CancellationToken.None);
var options = new OpcUaServerOptions
{
EndpointUrl = _endpoint,
ApplicationName = "OtOpcUaMultiDriverTest",
ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest",
PkiStoreRoot = _pkiRoot,
AutoAcceptUntrustedClientCertificates = true,
};
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
await _server.StartAsync(CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
await _driverHost.DisposeAsync();
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public async Task Both_drivers_register_under_their_own_urn_namespace()
{
using var session = await OpenSessionAsync();
var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI");
betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI");
alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace");
}
[Fact]
public async Task Each_driver_subtree_exposes_only_its_own_folder()
{
using var session = await OpenSessionAsync();
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
var alphaRoot = new NodeId("alpha", alphaNs);
session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs);
alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder",
"alpha's subtree must contain alpha's folder");
alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder",
"alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs");
var betaRoot = new NodeId("beta", betaNs);
session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs);
betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder");
betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder");
}
[Fact]
public async Task Reads_route_to_the_correct_driver_by_namespace()
{
using var session = await OpenSessionAsync();
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
var alphaValue = session.ReadValue(new NodeId("AlphaFolder.Var1", alphaNs));
var betaValue = session.ReadValue(new NodeId("BetaFolder.Var1", betaNs));
alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99");
betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42");
}
private async Task<ISession> OpenSessionAsync()
{
var cfg = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaMultiDriverTestClient",
ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_pkiRoot, "client-own"),
SubjectName = "CN=OtOpcUaMultiDriverTestClient",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
};
await cfg.Validate(ApplicationType.Client);
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
var endpointConfig = EndpointConfiguration.Create(cfg);
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaMultiDriverTestClientSession", 60000,
new UserIdentity(new AnonymousIdentityToken()), null);
}
/// <summary>
/// Driver stub that returns a caller-specified folder + variable + read value so two
/// instances in the same server can be told apart at the assertion layer.
/// </summary>
private sealed class StubDriver(string driverInstanceId, string folderName, int readValue)
: IDriver, ITagDiscovery, IReadable
{
public string DriverInstanceId => driverInstanceId;
public string DriverType => "Stub";
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
var folder = builder.Folder(folderName, folderName);
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
$"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
return Task.CompletedTask;
}
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> result =
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
return Task.FromResult(result);
}
}
}

View File

@@ -7,6 +7,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
@@ -38,8 +39,8 @@ public sealed class OpcUaServerIntegrationTests : IAsyncLifetime
AutoAcceptUntrustedClientCertificates = true,
};
_server = new OpcUaApplicationHost(options, _driverHost, NullLoggerFactory.Instance,
NullLogger<OpcUaApplicationHost>.Instance);
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
await _server.StartAsync(CancellationToken.None);
}

View File

@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class SecurityConfigurationTests
{
[Fact]
public async Task DenyAllAuthenticator_rejects_every_credential()
{
var auth = new DenyAllUserAuthenticator();
var r = await auth.AuthenticateAsync("admin", "admin", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("not supported");
}
[Fact]
public async Task LdapAuthenticator_rejects_blank_credentials_without_hitting_server()
{
var options = new LdapOptions { Enabled = true, AllowInsecureLdap = true };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var empty = await auth.AuthenticateAsync("", "", CancellationToken.None);
empty.Success.ShouldBeFalse();
empty.Error.ShouldContain("Credentials");
}
[Fact]
public async Task LdapAuthenticator_rejects_when_disabled()
{
var options = new LdapOptions { Enabled = false };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("disabled");
}
[Fact]
public async Task LdapAuthenticator_rejects_plaintext_when_both_TLS_and_insecure_are_disabled()
{
var options = new LdapOptions { Enabled = true, UseTls = false, AllowInsecureLdap = false };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("Insecure");
}
[Theory]
[InlineData("hello", "hello")]
[InlineData("hi(there)", "hi\\28there\\29")]
[InlineData("name*", "name\\2a")]
[InlineData("a\\b", "a\\5cb")]
public void LdapFilter_escapes_reserved_characters(string input, string expected)
{
LdapUserAuthenticator.EscapeLdapFilter(input).ShouldBe(expected);
}
[Theory]
[InlineData("cn=alice,ou=Engineering,dc=example,dc=com", "Engineering")]
[InlineData("cn=bob,dc=example,dc=com", null)]
[InlineData("cn=carol,ou=Ops,dc=example,dc=com", "Ops")]
public void ExtractOuSegment_pulls_primary_group_from_DN(string dn, string? expected)
{
LdapUserAuthenticator.ExtractOuSegment(dn).ShouldBe(expected);
}
[Theory]
[InlineData("cn=Operators,ou=Groups,dc=example", "Operators")]
[InlineData("cn=LoneValue", "LoneValue")]
[InlineData("plain-no-equals", "plain-no-equals")]
public void ExtractFirstRdnValue_returns_first_rdn(string dn, string expected)
{
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe(expected);
}
[Fact]
public void OpcUaServerOptions_default_is_anonymous_only()
{
var opts = new OpcUaServerOptions();
opts.SecurityProfile.ShouldBe(OpcUaSecurityProfile.None);
opts.Ldap.Enabled.ShouldBeFalse();
}
}

View File

@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class WriteAuthzPolicyTests
{
// --- FreeAccess and ViewOnly special-cases ---
[Fact]
public void FreeAccess_allows_write_even_for_empty_role_set()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
}
[Fact]
public void FreeAccess_allows_write_for_arbitrary_roles()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
}
[Fact]
public void ViewOnly_denies_write_even_with_every_role()
{
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
}
// --- Operate tier ---
[Fact]
public void Operate_requires_WriteOperate_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
}
[Fact]
public void Operate_role_match_is_case_insensitive()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
}
[Fact]
public void Operate_denies_empty_role_set()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
}
[Fact]
public void Operate_denies_wrong_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
}
[Fact]
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
}
// --- Tune tier ---
[Fact]
public void Tune_requires_WriteTune_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
}
[Fact]
public void Tune_denies_WriteOperate_only_session()
{
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
// hierarchy the policy infers on its own.
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
}
// --- Configure tier ---
[Fact]
public void Configure_requires_WriteConfigure_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
}
[Fact]
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
}
// --- Multi-role sessions ---
[Fact]
public void Session_with_multiple_roles_is_allowed_when_any_matches()
{
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
}
[Fact]
public void Session_with_only_unrelated_roles_is_denied()
{
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
}
// --- Mapping table ---
[Theory]
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
{
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
}
[Theory]
[InlineData(SecurityClassification.FreeAccess)]
[InlineData(SecurityClassification.ViewOnly)]
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
{
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
}
}