diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 63579321b..ce69f08be 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -121,11 +121,12 @@ Two things to know before relying on any of this: same-named narrow grant — except a 4-segment (reference-shaped) id, which keeps its split. ⇒ narrow grantees do NOT receive NodeDeleted for removed UNS folders / vtags / conditions (by decision; they re-browse). Identifiers containing NUL are denied before the cache (resolved-scope keys are NUL-prefixed — a client could otherwise - poison a real folder's cached verdict). ⚠️ The folder-owned rule keys on "first id segment == nearest slash-free - live UNS folder ancestor's id", which can only narrow (an Area named exactly like its equipment's id loses that - variable's Tag-grant match). ⚠️ `ConditionVisibilityScopes` returns the condition ALONE for a scripted condition, - so a moved alarm can't reach its old owner via the Server object; direct per-folder event items still ride the - SDK fan-out of the folder it hangs under (follow-up). A UNS node with NO live hierarchy-folder ancestor (rig-only + poison a real folder's cached verdict). The folder-owned rule keys on "first id segment == nearest slash-free + live UNS folder ancestor's id", EXCEPT for a variable tracked in `_unsReferenceVariables` (the source of a + UNS→Raw `Organizes` edge from `AddReference`), which is always a reference variable (#826 — an Area named exactly + like its equipment's id used to make its references folder-owned). ⚠️ `ConditionVisibilityScopes` returns the + condition ALONE for a scripted condition, so a moved alarm can't reach its old owner via the Server object — and + since #826 not via a direct folder item either (below). A UNS node with NO live hierarchy-folder ancestor (rig-only shape) keeps the plain split. Unplaceable ⇒ denied for every grant incl. Cluster (a behaviour change for Cluster keys only in the stale window / corrupt topology). Tests: `UnsFolderScopeWireTests` (opaque ids, incl. one fed by `DeploymentArtifact.ParseDataPlaneAuthority`); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`) @@ -175,6 +176,34 @@ Two things to know before relying on any of this: **Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other event type fails closed. Auditing is off by default (never configured in this repo). + **Direct (per-folder) event items are filtered per event too (Gitea #826).** The SDK's `MonitoredNode2` fan-out + asks `INodeManager3.ValidateEventRolePermissions` per (event, item) on its own delivery task; the base method is + NOT virtual, so `OtOpcUaNodeManager` **re-implements `INodeManager3`** and adds `IsNotifierEventVisibleTo`: the + event's condition (snapshot handle, or an audit's SourceNode) must be the LIVE instance, the item's notifier must + still be a legitimate notifier of it (an ancestor, or a wired folder the CURRENT generation still projects its + RawPath into — `DataPlaneAuthoritySnapshot.TagReferencingEquipment`, from the artifact's `UnsTagReferences` via + `AclScopePathResolver.ResolveTagRawPath`), and `IsConditionVisibleTo(item, ConditionVisibilityScopes(c))` — the + Server-object rule, whose wired folders are ALSO generation-filtered now. ConditionRefresh on a direct item no + longer calls the base (it uses the non-virtual base check, which bypasses the re-implementation): `RefreshDirectItems` + mirrors the base loop plus the same decision. ⚠️ **The SDK memoizes that verdict per `(item, eventType, + SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one + `MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change: + the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and + `CompleteAddressSpaceApply` — and is SKIPPED while enforcement is off (the authorizer setter forces it across an + on/off transition). Without it a cached allow survives the deploy (caught by the native wire test's restore leg / + `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ (review) It clears each node's + cache WHOLE through the SDK's private `MonitoredNode2.InvalidatePermissionCaches` (reflection, pinned by + `FolderEventVerdictInvalidationTests.The_sdks_node_wide…`): the public `InvalidatePermissionCacheForSession` + misses ORPHANED subscriptions (session closed without deleting subs ⇒ `Session` null), which then queued a moved + alarm's events and delivered them on `TransferSubscriptions`, and rescanned the cache per item. Fallback if the SDK + drops it: per-session drop + `Remove`/`Add` of each session-less item. ⚠️ The verdict is decided on the LIVE + instance holding the event's condition NodeId, never the event's own instance — the SDK key has no instance + identity, so denying a replaced instance's late event cached a deny the new instance inherited. + `TagReferencingEquipment` holds ALARM-BEARING tags only (`TagConfigIntent.Alarm`). ⚠️ A hand-built + authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under + enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests: + `UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`, + `NativeAlarmFolderEventAclWireTests`. - **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`, `NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node, diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 8047bfd87..aec25c30f 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -61,7 +61,9 @@ design treated it as an implementation detail. ⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT, -MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an +MTConnect and MQTT raise natively and are unaffected. Heidenhain (#831) raises on the event-driven shape this +recommends: a reconnect whose `R_VR` identity (model / NC / PLC version / options) differs from the previous session — +no timer, no extra request. If that coverage matters later, reach for an event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`, `DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` / @@ -288,6 +290,16 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa AFTER configured ones; more than one discovered server without `discoveryServerUri` fails the connect. Session events are the OTel counter `otopcua.driver.opcuaclient.events`. `HistoryAggregateType` carries the full Part 13 set; an aggregate the gateway can't serve is `BadAggregateNotSupported` per node. +- **OpcUaClient server host-name check (#816):** the SDK checks the server certificate's host only with + `checkDomain: true`; its 8-arg `DefaultSessionFactory.CreateAsync` hard-codes `false`, so driver, AdminUI browser and + Client.Shared never checked before #816. Now `certificateValidation.validateServerHostName` (tri-state, unset = off = + unchanged; AdminUI keeps it unset) / CLI `--validate-host-name`. It checks the **configured** host (the rewritten + `ConfiguredEndpoint.EndpointUrl`) and auto-accept accepts a mismatch. ⚠️ The SDK checks in TWO places — + `DefaultSessionFactory.CreateChannelAsync` (GetEndpoints cert, BEFORE any security-mode test, so it fired on `None`) + and `Session.OpenAsync` — so pass `checkDomain` only for a non-`None` endpoint (`OpcUaEndpointSelection.CheckDomainFor`). + Any new session-create call site must do that — the overload without the argument silently disables the check. + Zone-qualified IPv6 hosts never match (zone compared); the match is loose (first label alone counts). The rewriter keeps an IPv6 + zone index verbatim (#819; .NET `Uri` drops it from every re-serialisation). `docs/drivers/OpcUaClient.md`. - **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device (`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second). @@ -319,6 +331,26 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. +- **Heidenhain alarms (#831):** an `ALARM:` selector (`*`, `OTHER`, `CLASS=[/GROUP=][/NUMBER=]`, `` = `n` | + `lo..hi`) + the `alarm` object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save + path/CSV/deploy agree; data type fixed `Boolean`). Opt-in per tag, no driver flag (FOCAS #647 precedent); `Alarms/*` + arrays unchanged. Walked on `Polling.AlarmMs` (2 s) by the control's single loop with the SAME `R_RI 27/28` — the + allowlist is unchanged. FOCAS transition rules; identity = class+group+number+TEXT (numbers repeat, `-1`). A failed walk + (refusal/overflow/comms) is quality-only, never a clear. ⚠️ Ack is LOCAL-ONLY (`AcknowledgeAsync` no-op success) — + never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented names or severities. +- **Credential references are namespaced per driver (#815):** ONE table (`Core.Abstractions.DriverCredentialPolicy`) + + ONE parser/resolver (`CredentialReferences`) for Galaxy (`secret:galaxy/`·`secret:otopcua/galaxy/`·`env:OTOPCUA_GALAXY_`· + `env:GALAXY_MXGW_`), OpcUaClient (`secret:opcuaclient/`·`env:OTOPCUA_OPCUACLIENT_`) and Heidenhain. Enforced at save + (`DriverConfigSaveGate`), deploy (`CredentialReferenceOutOfScope`), Test Connect (`AdminOperationsActor`, before the + probe), browse (`BrowserSessionService`, before the browser) and at resolve time before any store/env read; `file:` is + refused everywhere. A NEW driver that resolves a `secret:`/`env:` field and sends it to an authored endpoint MUST add a + row there and resolve through `CredentialReferences` — never `ISecretResolver.GetAsync` on a raw authored name. + ⚠️ **Deprecated warn-only window:** a RUNNING Galaxy (init, every `ReopenAsync` gateway reconnect, ack fallback) or + OpcUaClient (session open) driver passes its `LegacyCredentialReferenceTracker` and so still resolves a pre-#815 + out-of-namespace ref with the OLD semantics + a rate-limited Warning + a `credential-compat:` diagnostics entry; + browsers/probes pass none (hard-fail), Heidenhain never has one. Chosen by call site — do not add a global flag, and do + not hand the tracker to an admin-node path. Upgrade preflight: `CredentialReferencePreflightService` + the `/raw` banner. + `docs/security.md` §"Credential references are namespaced per driver". - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/.claude/rules/historian.md b/.claude/rules/historian.md index 5a2f94211..2d37cf743 100644 --- a/.claude/rules/historian.md +++ b/.claude/rules/historian.md @@ -32,11 +32,11 @@ historian tagname, which is the tag's driver `FullName`. ### Pluggable backends, SQL Server and HistoryUpdate (2026-10) - **Selection:** `ServerHistorian:Backend` (`Gateway` default | `SqlServer` | `QuestDb`; unknown fails host start). `HistorianBackendRegistry` → `IHistorianBackendProvider` builds all five seams; the gateway keys are only required for `Gateway`. `SqlServer` uses `ServerHistorian:SqlServer:{ConnectionString (required), Schema=hist, CommandTimeout=30s, AutoCreateSchema=true}`. -- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. +- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680, and DurationGood/DurationBad, #820 — ms, Uncertain time in neither; the live AggregateFilter still counts Uncertain as Good, #809) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION` (shared host `10.100.0.35,14330`, `sa`/`OtOpcUaDev_2026!` — not docker-dev's `OtOpcUa!Dev123`; QuestDB: `HISTORIAN_QUESTDB_TEST_CONNECTION` against the shared `/opt/otopcua-questdb` fixture `10.100.0.35:8812`, #822, `infra/README.md` §2a). **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. - **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend". - ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates. - ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density at ≈ N + 8 rows and carried page to page in the continuation point — #689: it used to aim at half `MaxTieClusterOverfetch`, ~32k rows per page on a dense tag; a read asks for `2N + 18` rows (a discovery segment ≤ 1,026), `MaxTieClusterOverfetch` is only the tie-cluster ceiling — a full read is re-read, never answered truncated; only more than max(1, `MaxTieClusterOverfetch`) samples within ONE MILLISECOND ⇒ `BadTooManyOperations` (per ms, not per timestamp — requests are ms-rounded, and letting a segment narrow below 1 ms stepped a page through one ms a tick per read, ~10k reads); walks no later than now + 1 day and never before the Unix epoch; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `docs/Historian.md` §"Open-ended and reverse raw reads". -- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 17 via `AggregateFamilies.Served`, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates. +- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 19 via `AggregateFamilies.Served` — DurationGood/DurationBad added by #820, the PercentGood/PercentBad region times in ms, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates. - **Raw `ReturnBounds` is honoured on every backend (#691, client-visible):** Part 11 bounding values (§4.6 Table 1 is the test oracle) are composed in the server by `HistoryBounds` from each backend's raw reads — "at or before" = capped reverse read (native on SQL backends, the #686 `ReverseRawReader` walk-back on Gateway, never AVEVA's synthetic boundary row), "at or after" = capped forward read. Bounds are stored samples of any quality, count toward `NumValuesPerNode`, start bound on the first page / end bound on the last (pending end bound rides the continuation point); missing ⇒ `Bad_BoundNotFound` (null, at the bound time; a one-sided read's open side ±1 s from the previous value). ⚠️ **Breaking for SDK-default clients:** the .NET SDK's `new ReadRawModifiedDetails()` defaults BOTH `ReturnBounds = true` AND `IsReadModified = true` — a client that cleared only `IsReadModified` now gets bounds, and one that set neither is a modified read with bounds ⇒ `Bad_InvalidArgument` (§6.5.3.3); set both explicitly (Client.Shared does; tests building details must too). No data AND no bound found ⇒ `GoodNoData`, no values (the pre-bounds answer). On Gateway the at-or-after lookup ends at now + 1 day (never a year-9999 end to AVEVA). Kill switch `ServerHistorian:HonorReturnBounds` (default `true`; `false` = pre-#691). `docs/Historian.md` §Bounding values. - ⚠️ **Raw pages are full (#692, client-visible, every backend):** each raw page asks the backend for `N + skip + 1` rows (the boundary ties it will trim + one lookahead row) — a resumed page used to ask only N, trim the ties and come back short; at N=1 that was an EMPTY page + continuation point that then fell into the `MaxTieClusterOverfetch + 1` over-fetch (live: 8 values = 17 pages / 25 reads / 524k rows ⇒ now 8/8/≤24). No lookahead row ⇒ last page, no CP (an exact multiple no longer ends on an empty page). Exception: an oversized tie cluster — its last slice is `min(N, remaining)` with a CP, and stepping past one at the end of data can give one empty final page. The over-fetch fires only when every row past the trimmed ties sits on the boundary timestamp; a cursor inside such a cluster is marked `WithinTieCluster` (next page goes straight to the over-fetch). `SliceTieCluster` resumes only strictly before EndTime (truncated to the backend resolution) — it used to resume AT the exclusive EndTime and return samples there. Bound placeholders carry an explicit marker (`HistoryBounds.IsPlaceholder` no longer matches a stored `Bad_BoundNotFound`/null row); the N=1 bounds probe carries its walk hint + resolved end bound into the cursor. Reviewer fuzz harnesses (paged vs unpaged over random tie series) are the right test for any paging change — repo tests passed while master mismatched 1-in-160. - ⚠️ **Strict-ceiling paging (#694):** `IHistorianDataSource.MaxRawValuesPerRead` = L = learned gateway ceiling − 16 (0 on SQL backends/Null, guard-tested; 0 until a refusal names the ceiling). `RawPageLimit.Plan(want, k, L)` sizes every raw page so page + boundary ties + lookahead ≤ L (room = L − k − 1, signed; first page L−1, resumed L−k−1; uncapped `N = 0` reads page too) — a capped read above the ceiling used to be `BadTooManyOperations`, now a ceiling-sized page + CP. One re-plan when the read taught a new limit or was refused for crowding (`HistoryReadLimitExceededException.PreWindowRows`, set only above the 16-row allowance; the page shrinks by it). No room on a resumed page ⇒ the tie-cluster over-fetch, bounded min(M, L−1); the slice size is never 0. The page path passes `retryOnLearnedLimit: false` to `ReverseRawReader` (the walk re-running at the old N cost 200–3,000 reads cold); bounds lookups keep it. ⚠️ A Gateway window starting at/before the Unix epoch counts AVEVA's synthetic row as pre-window — it used to set `reachedEnd`, so a ceiling-truncated read came back Good with no CP (forward-from-1970 = first page only; end-only past the data = the OLDEST rows as "latest"; pre-existing). The #687 crowding re-read now doubles its room up to 8 re-reads, never past a known ceiling, and its room is capped at `MaxTieClusterOverfetch` (passed to the Gateway source by the factory; past it ⇒ `BadTooManyOperations` with `PreWindowRows`). A raised ceiling is not re-probed by raw paging (pages stay small until another read re-probes or restart). @@ -50,7 +50,7 @@ historian tagname, which is the tag's driver `FullName`. The server dispatches all OPC UA HistoryRead to the registered `IHistorianDataSource` — the `GatewayHistorianDataSource` read client when enabled, else the `NullHistorianDataSource` default (historized nodes return `GoodNoData` for Raw/AtTime — Processed is `BadAggregateNotSupported` since #682). Supported variants: Raw (⚠️ #687, client-visible: Gateway raw reads no longer return AVEVA's synthetic start-boundary row — asked from 1 ms early (epoch-floored) with `NumValuesPerNode + 16` rows (unclamped; 0 ⇒ `int.MaxValue`) and filtered by timestamp — so an empty window is `GoodNoData`, not a fabricated value at StartTime; the forward end is now exclusive (a point read inclusive); pre-window rows that crowd the budget trigger ONE re-read with room for them, else `BadTooManyOperations`; `ReturnBounds` is ignored on every backend; ⚠️ #690: a Gateway Bad row — a stored NULL, which histsdk sends as value 0 — now reads value **null** + Bad on Raw and AtTime, as on the SQL backends; ⚠️ #691.3: a strict gateway (`AllowStreamingBeyondCeiling=false`) that refuses a raw budget is re-asked ONCE at the ceiling its refusal names — raw and per-sample reads alike; the ceiling is then remembered per data source (`GatewayRowCeiling`: refusals count as gateway errors), re-learned from a refusal when lowered, and re-probed once at the full budget when a ceiling-bounded read comes back full (raised / streaming) — so an uncapped (`NumValuesPerNode = 0`) read of a small window is served; a ceiling-bounded stream that is not provably complete is `BadTooManyOperations`, never a truncated answer), Processed -(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27; ⚠️ #691.2: native reads use the SQL backends' grid in both directions via forward requests only — a REVERSE read (refused by the gateway before) is anchored at its later time (#677), and the clipped interval (earliest reverse, LATEST forward — which used to be averaged past EndTime) is its own exact-span read marked Partial unless Bad; an interval wider than the window is sent as the window, interval 0 as one interval; more than 100,000 intervals is refused up front (`BadHistoryOperationUnsupported`, as on the SQL backends — forward used to be served up to the gateway's 1,000,000); until historiangw#27 lands both directions return Good-status wrong values); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes (⚠️ #693, client-visible: Gateway AtTime is evaluated from RAW reads by the shared `Sql.Common` `AtTimeEvaluator` — exact stored value at a stored time, null + Bad at/after a stored NULL, Uncertain hold before a Bad neighbour, linear + Interpolated bit, `BadNoData` before the first sample — never AVEVA's own interpolation (which answered 107 Good at a NULL, null+Bad at a Good sample after it, 99.99999999944 for 100, no Interpolated bit); `GatewayAtTimeReader`: one ≤1,024-row window read for the batch (jumps to far-apart times use 64-row reads) + a walk-back for the first time's prev (gallop ×4, whole-remainder read after two empties, then real BISECTION seeded by any full read's last row — a dense tag stopped a year ago ≈ 25–31 reads; the first cut regrew segments after a full read and gave up at 65) + an 18-row probe for the last time's next — 10 dense times = 1 read (exact) / 3 (interpolated), 10 hourly = 12; read cap 64 + 16 per distinct time (a runaway guard: a time in a gap of a bursty tag needs ≈ 6 reads; 4/time refused a 1 h/day tag asked daily ×30) and a strict ceiling < 4 rows ⇒ `BadTooManyOperations` with a message naming the limit; DateTime hints stepped, non-finite/out-of-range values under an integer hint unusable; all request bounds whole ms, rows counted only strictly inside a request; the node manager passes the node's scalar type (`ReadAtTimeAsync(…, TypeCode? valueType, …)` default-interface overload, ignored by SqlServer/QuestDB/Null) and the Gateway converts raw rows to it before evaluating — Boolean stepped, integers interpolated + rounded half-away and returned as the node's integer type, floats Double; no hint ⇒ Double; ⚠️ the shared evaluator used to box interpolated Int64 as Double (`cond ? (long)x : double` widens) — fixed for SQL backends too, and Shouldly `ShouldBe` hides numeric-type bugs (assert `ShouldBeOfType`); kill switch `ServerHistorian:GatewayAtTimeFromRaw` (default `true`); `docs/Historian.md` §Gateway AtTime); Events over +(backend-dependent: SqlServer/QuestDb serve nineteen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27; ⚠️ #691.2: native reads use the SQL backends' grid in both directions via forward requests only — a REVERSE read (refused by the gateway before) is anchored at its later time (#677), and the clipped interval (earliest reverse, LATEST forward — which used to be averaged past EndTime) is its own exact-span read marked Partial unless Bad; an interval wider than the window is sent as the window, interval 0 as one interval; more than 100,000 intervals is refused up front (`BadHistoryOperationUnsupported`, as on the SQL backends — forward used to be served up to the gateway's 1,000,000); until historiangw#27 lands both directions return Good-status wrong values); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes (⚠️ #693, client-visible: Gateway AtTime is evaluated from RAW reads by the shared `Sql.Common` `AtTimeEvaluator` — exact stored value at a stored time, null + Bad at/after a stored NULL, Uncertain hold before a Bad neighbour, linear + Interpolated bit, `BadNoData` before the first sample — never AVEVA's own interpolation (which answered 107 Good at a NULL, null+Bad at a Good sample after it, 99.99999999944 for 100, no Interpolated bit); `GatewayAtTimeReader`: one ≤1,024-row window read for the batch (jumps to far-apart times use 64-row reads) + a walk-back for the first time's prev (gallop ×4, whole-remainder read after two empties, then real BISECTION seeded by any full read's last row — a dense tag stopped a year ago ≈ 25–31 reads; the first cut regrew segments after a full read and gave up at 65) + an 18-row probe for the last time's next — 10 dense times = 1 read (exact) / 3 (interpolated), 10 hourly = 12; read cap 64 + 16 per distinct time (a runaway guard: a time in a gap of a bursty tag needs ≈ 6 reads; 4/time refused a 1 h/day tag asked daily ×30) and a strict ceiling < 4 rows ⇒ `BadTooManyOperations` with a message naming the limit; DateTime hints stepped, non-finite/out-of-range values under an integer hint unusable; all request bounds whole ms, rows counted only strictly inside a request; the node manager passes the node's scalar type (`ReadAtTimeAsync(…, TypeCode? valueType, …)` default-interface overload, ignored by SqlServer/QuestDB/Null) and the Gateway converts raw rows to it before evaluating — Boolean stepped, integers interpolated + rounded half-away and returned as the node's integer type, floats Double; no hint ⇒ Double; ⚠️ the shared evaluator used to box interpolated Int64 as Double (`cond ? (long)x : double` widens) — fixed for SQL backends too, and Shouldly `ShouldBe` hides numeric-type bugs (assert `ShouldBeOfType`); kill switch `ServerHistorian:GatewayAtTimeFromRaw` (default `true`); `docs/Historian.md` §Gateway AtTime); Events over alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node); authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization. diff --git a/.github/workflows/v2-integration.yml b/.github/workflows/v2-integration.yml index 48d4b1bd8..039480404 100644 --- a/.github/workflows/v2-integration.yml +++ b/.github/workflows/v2-integration.yml @@ -74,6 +74,8 @@ jobs: - name: assert not-all-skipped if: always() env: + # #816/#818 added 8 executing OpcUaClient tests; raise to 448 once a hosted run confirms them + # (scripts/ci/README.md §"Fixture-backed integration"). MIN_EXECUTED: "440" run: bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx - name: fixture logs on failure diff --git a/CLAUDE.md b/CLAUDE.md index c70af7e8e..89375e172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ central SQL Server. > **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28). -> ⚠️ **The `project: lmxopcua` label is aspirational, not universal.** This note used to claim every fixture compose file carries it. As of 2026-07-24 only the **MQTT** fixture actually does (joined 2026-11-10 by the OpcUaClient port-NAT fixture, `Docker/nat/`, #813) — `grep -rn "labels:" tests --include=*.yml` matches `Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` and nothing else. So `docker ps --filter label=project=lmxopcua` returns the MQTT fixture alone, not the fleet. Add the label when you touch an older fixture; until then enumerate by container name. +> ⚠️ **The `project: lmxopcua` label is not universal.** As of 2026-11-11 it is carried by the MQTT, Modbus, FOCAS, MTConnect, Sql (SQL Server/Postgres/MySQL/Oracle), QuestDB historian and OpcUaClient port-NAT fixtures (`grep -rln lmxopcua tests --include=docker-compose.yml`); older stacks (e.g. AB CIP, S7, the plain opc-plc) do not, so `docker ps --filter label=project=lmxopcua` is not the whole fleet. Add the label when you touch an older fixture; until then enumerate by container name. Docker workloads run on a shared Linux host at **`10.100.0.35`** — not on this VM. Stacks live at `/opt/otopcua-/` on the host and carry the `project=lmxopcua` label so they're discoverable via `docker ps --filter label=project=lmxopcua`. @@ -317,6 +317,9 @@ lmxopcua-fix sync modbus # rsync this repo's tests/.../Docker/ - AB CIP: `10.100.0.35:44818` (`AB_SERVER_ENDPOINT`) - S7: `10.100.0.35:1102` (`S7_SIM_ENDPOINT`) - OPC UA reference (opc-plc): `opc.tcp://10.100.0.35:50000` (`OPCUA_SIM_ENDPOINT`) +- QuestDB historian fixture (#822, stack `/opt/otopcua-questdb`): PG wire `10.100.0.35:8812` — + `HISTORIAN_QUESTDB_TEST_CONNECTION='Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading'`; + the SQL Server historian suite uses the central SQL above (`HISTORIAN_SQL_TEST_CONNECTION`, `infra/README.md` §2a). - MQTT (Mosquitto, **auth on both listeners — no anonymous fallback**): `10.100.0.35:8883` TLS+auth (`MQTT_FIXTURE_ENDPOINT`) · `10.100.0.35:1883` plaintext-but-authenticated (`MQTT_FIXTURE_PLAIN_ENDPOINT`). Also needs `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD`, and `MQTT_FIXTURE_CA_CERT` to pin the diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 42baee8d3..abfc17977 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -679,7 +679,8 @@ services: Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" # Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's - # Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY". + # Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY" (#815: GALAXY_MXGW_* is an allowed + # Galaxy env prefix alongside OTOPCUA_GALAXY_*; any other variable is refused). GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" # Pair-local config cache + REPLICATION ON — the site-a pair is the enablement demo. # site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer. diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index e603776ff..5f71be86b 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -39,6 +39,7 @@ All commands accept these options: | `-P` / `--password` | Password for `UserName` token authentication | | `-S` / `--security` | Transport security mode: `none`, `sign`, `encrypt`, `signandencrypt` (default: `none`) | | `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy | +| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Only with `-S sign`/`encrypt` — ignored for `-S none` (the SDK would otherwise check the GetEndpoints certificate even on an unsecured channel); off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits / for a zone-qualified IPv6 host | | `--verbose` | Enable debug-level Serilog console logging (default: warning) | **`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and diff --git a/docs/Configuration.md b/docs/Configuration.md index b2e887300..f100852c7 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -388,7 +388,7 @@ The Galaxy/MxAccess connection settings are **not an `appsettings` section.** Th | Record | Key fields (default) | Meaning | |---|---|---| -| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `env:NAME` / `file:PATH` / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `env:`/`file:` in production. Never store a cleartext key. | +| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `secret:galaxy/` (or `secret:otopcua/galaxy/`) / `env:OTOPCUA_GALAXY_` (or legacy `env:GALAXY_MXGW_`) / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `secret:` in production. Any other `secret:`/`env:` reference and every `file:` reference is refused at save, deploy (`CredentialReferenceOutOfScope`), Test Connect, browse and resolve — #815, see `docs/security.md` §"Credential references are namespaced per driver". Never store a cleartext key. | | `GalaxyMxAccessOptions` (`MxAccess`) | `ClientName`; `PublishingIntervalMs` (`1000`); `WriteUserId` (`0` = anonymous); `EventPumpChannelCapacity` (`50000`) | MXAccess client identity + tuning. `ClientName` **must be unique per OtOpcUa instance** (redundancy pairs enforce this). | | `GalaxyRepositoryOptions` (`Repository`) | `DiscoverPageSize` (`5000`); `WatchDeployEvents` (`true`) | Galaxy Repository browse paging + deploy-event watching. | | `GalaxyReconnectOptions` (`Reconnect`) | `InitialBackoffMs` (`500`); `MaxBackoffMs` (`30000`); `ReplayOnSessionLost` (`true`) | In-driver reconnect-supervisor backoff. | diff --git a/docs/Historian.md b/docs/Historian.md index 1d4a21705..6d7fa9963 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -108,7 +108,7 @@ consumes the historian. The existing enable gates (`ServerHistorian:Enabled`, `A | Value kinds recorded | `Double`, `Int64`, `Boolean` (numeric analog only) | all: `Double`, `Int64`, `Boolean`, `String`, `DateTime`, plus `Empty` (a Bad sample with no value) | same as `SqlServer` | | Status code stored | Sent as DA quality but **discarded** by the gateway (C-002); AVEVA stores every sample Good | Stored bit-for-bit (`StatusCode` int column) | Stored bit-for-bit (unsigned, in a `LONG`) | | Comms-loss / driver-Bad publishes | Dropped, counted in `DroppedUnsupportedKind` | Stored as an `Empty` outage-marker sample carrying the Bad code (see [below](#typed-values-real-status-and-outage-markers)) | Stored as an `Empty` outage-marker sample, as on `SqlServer` | -| Processed aggregates | **Ten** by default: the per-sample ones (Average, Count, Minimum, Maximum, Range, Start, End, Delta, Min/MaxActualTime) evaluated from raw reads with the SQL backends' evaluator — identical results (#681). TimeAverage and Total from AVEVA's native modes **only with `ServerHistorian:Gateway:EnableNativeTimeWeighted=true`** (default off: Good-status wrong values until historiangw#27). TimeAverage2 / Total2 / Interpolative / PercentGood / PercentBad: never | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results | +| Processed aggregates | **Ten** by default: the per-sample ones (Average, Count, Minimum, Maximum, Range, Start, End, Delta, Min/MaxActualTime) evaluated from raw reads with the SQL backends' evaluator — identical results (#681). TimeAverage and Total from AVEVA's native modes **only with `ServerHistorian:Gateway:EnableNativeTimeWeighted=true`** (default off: Good-status wrong values until historiangw#27). TimeAverage2 / Total2 / Interpolative / PercentGood / PercentBad / DurationGood / DurationBad: never | The nineteen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671, and DurationGood/DurationBad, #820) | The same nineteen, identical results | | Reverse / end-only Raw and Events reads | Emulated: walked back from bounded forward reads by the server (`ReverseRawReader` / `ReverseEventsReader`) — correct, but many round trips on a sparse tag | Native newest-first | Native newest-first | | Capped Events read | The cap is applied client-side (the gateway RPC has no max-events field); the stream past it is drained up to `Gateway:EventDrainBudget` rows | `TOP` | `LIMIT` | | Retention | AVEVA Historian's own | `SqlServer:RetentionDays` purge (opt-in) | `QuestDb:RetentionDays` TTL (opt-in) | @@ -192,7 +192,7 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions | `EventReadHorizon` | TimeSpan | `00:00:00` | (#694, all backends, **opt-in, lossy**.) `00:00:00` = no horizon: every HistoryReadEvents read is exact to its own window. A positive span raises the earlier bound of every fresh events window to `now − EventReadHorizon` (the floor instant itself included), in **both** directions — a window wholly older is `GoodNoData` and is not sent; a page-through keeps the first page's floor. It exists for the Gateway, where a walked reverse / end-only read proves "nothing older" down to the Unix epoch (~3.8 s live; an unfiltered one can hit the gateway's 30 s SQL timeout) and a forward read from a far-past `StartTime` scans the same range. ⚠️ While set, events older than the horizon are **invisible** to HistoryReadEvents. Raw/Processed/AtTime are unaffected. Negative ⇒ treated as none (a `Validate()` warning). See [Continuation-point paging (Events)](#continuation-point-paging-events-669). | | `HistoryReadBatchParallelism` | int | `4` | Max historized nodes served **concurrently** within one HistoryRead batch (arch-review 03/S3). The SDK's `HistoryRead*` override surface is synchronous, so each arm bridges once at its boundary; this turns per-node serving from sequential (`N × CallTimeout` worst case) into `⌈N / P⌉ × CallTimeout`. Also caps the gateway-load multiplication a single client can command. `≤ 1` serves sequentially. | | `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. | -| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). | +| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**, since #820 **DurationGood/DurationBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). | | `HistoryReadDeadline` | TimeSpan | `00:01:00` | Per-request wall-clock deadline for a HistoryRead (and HistoryUpdate) batch (arch-review 03/S3). A node still in flight at the deadline surfaces `BadTimeout`, so one request can never hold an SDK request thread longer than this regardless of node count. Non-positive = unbounded (a `Validate()` warning). | | `HistoryUpdateMaxFutureSkew` | TimeSpan | `1.00:00:00` | #674. How far past the server clock a HistoryUpdate (Insert / Replace / Update) item's `SourceTimestamp` may lie; an item beyond `now + skew` is refused **`BadOutOfRange`** by the node manager before any backend sees it (every backend). Guards against a far-future typo (a 2062 timestamp). The live recorder is not bounded. Non-positive disables the bound (no warning — a deliberate choice). See [HistoryUpdate](#historyupdate). | @@ -608,7 +608,7 @@ The server supports all four OPC UA HistoryRead variants: | Variant | Node type | CLI `--aggregate` | |---|---|---| | **Raw** | Historized variable | (omit `--aggregate`) | -| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`, `StandardDeviation` (the CLI's own list; `SqlServer` / `QuestDb` serve seventeen aggregates — see the [aggregate table](#aggregates-processed-reads)) | +| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`, `StandardDeviation` (the CLI's own list; `SqlServer` / `QuestDb` serve nineteen aggregates — see the [aggregate table](#aggregates-processed-reads)) | | **AtTime** | Historized variable | n/a (client supplies exact timestamps) | | **Events** | Equipment folder (event notifier) | `--events` (+ `--fields`, `--of-type`; #776) | | **Modified** (Raw with `IsReadModified = true`, #668) | Historized variable | `--modified` (see [Modified-value history](#modified-value-history-part-11)) | @@ -1312,7 +1312,7 @@ field (`tail + 43`) its own reverse-engineering notes still mark unverified (His > `Integral`) — ⚠️ **and are refused `BadAggregateNotSupported` unless `ServerHistorian:Gateway:EnableNativeTimeWeighted` is > `true`** (default `false`, until historiangw#27 is live-verified; the default set is the ten raw-evaluated aggregates, > `GatewayHistorianDataSource.RawEvaluatedAggregates`) — now stamped with the interval start (was its end) and carrying the Calculated bit; `TimeAverage2`, `Total2`, -> `Interpolative`, `PercentGood` and `PercentBad` remain `BadAggregateNotSupported` on Gateway. The most the backend can serve is +> `Interpolative`, `PercentGood`, `PercentBad`, `DurationGood` and `DurationBad` remain `BadAggregateNotSupported` on Gateway. The most the backend can serve is > `GatewayHistorianDataSource.ServedAggregates` (12); an instance serves and advertises `SupportedAggregates` — those 12 with the > flag on, the 10 raw-evaluated ones without it. @@ -1378,8 +1378,8 @@ and one ending at `now + 1 day` are accepted and cheap; (4) the gateway's ceilin Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `AggregateFunctionMap` (`OpcUaServer`) maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and -**`QuestDb`** backends serve the seventeen listed in the [aggregate table](#aggregates-processed-reads); everything else -(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend serves twelve (#681 — +**`QuestDb`** backends serve the nineteen listed in the [aggregate table](#aggregates-processed-reads); everything else +(e.g. `StartBound`, `WorstQuality`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend serves twelve (#681 — `GatewayHistorianDataSource.ServedAggregates`): the ten per-sample aggregates from raw reads, plus TimeAverage and Total through AVEVA retrieval modes (`AggregateModeMapper`); for any other member it throws `HistoryAggregateNotSupportedException` without calling the gateway (and since #682 the server refuses it up front), which the HistoryRead path turns into @@ -1407,7 +1407,7 @@ aggregate-function objects. A client choosing a history aggregate should read ** | `ServerHistorian:Backend` | i=11201 (history) | i=2997 (server) | Declared by | |---|---|---|---| | `Gateway` | 12 — the ten per-sample aggregates (raw-evaluated, #681) + TimeAverage, Total (native AVEVA modes) | all 37 | `GatewayHistorianDataSource.ServedAggregates` (the per-sample families of `AggregateFamilies` ∪ `AggregateModeMapper.NativeAggregates`, derived from its mapping switch) | -| `SqlServer`, `QuestDb` | 17 — the [aggregate table](#aggregates-processed-reads) | all 37 | `AggregateFamilies.Served` (Sql.Common; every member with a family) | +| `SqlServer`, `QuestDb` | 19 — the [aggregate table](#aggregates-processed-reads) (DurationGood/DurationBad added by #820) | all 37 | `AggregateFamilies.Served` (Sql.Common; every member with a family) | | historian disabled (Null source) | **none** | all 37 | `NullHistorianDataSource` | ⚠️ **Client-visible: with no historian configured a processed HistoryRead is now `BadAggregateNotSupported`, not @@ -1573,9 +1573,10 @@ point (see [Continuation-point paging (Events)](#continuation-point-paging-event > **Two kinds.** The *per-sample* aggregates (Average, Minimum, Maximum, Count, Range, Start/End/Delta, Min/MaxActualTime) are > simple per-sample definitions — no time-weighting, no interval-boundary values — pushed down to SQL. The *time-weighted* aggregates -> (TimeAverage, TimeAverage2, Total, Total2, Interpolative; #671 — and the duration-based PercentGood/PercentBad since #680) follow +> (TimeAverage, TimeAverage2, Total, Total2, Interpolative; #671 — and the duration-based PercentGood/PercentBad since #680, +> DurationGood/DurationBad since #820) follow > OPC UA Part 13 with bounding values — see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671). Any `HistoryAggregateType` not in the tables throws -> `HistoryAggregateNotSupportedException` ⇒ `BadAggregateNotSupported` for that node (e.g. `StartBound`, `DurationGood`, +> `HistoryAggregateNotSupportedException` ⇒ `BadAggregateNotSupported` for that node (e.g. `StartBound`, `WorstQuality`, > `StandardDeviationPopulation`). > > ⚠️ **Release note / migration (#671): `Total` changed meaning on the `SqlServer` and `QuestDb` backends.** It was the plain `SUM` of @@ -1716,12 +1717,12 @@ Status and edge rules: - An interval with samples but **no Good numeric value** (only Uncertain / Bad) ⇒ `BadNoData` for `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` (#684). - `Average` is `Good` when every sample in the bucket is Good, else `UncertainDataSubNormal` (value-based, the SDK's rule). `Minimum`/`Maximum`/`Range`/`*ActualTime` are `UncertainDataSubNormal` when the bucket holds a Bad sample or an Uncertain value beyond the reported Good extreme (Part 13 Tables 57–62; strictly beyond), else `Good` (#684). `Count`'s status is value-based (all Good ⇒ Good, all Bad ⇒ Bad, else Uncertain); `Start`/`End` carry the raw sample's own status; `Delta` is Good unless the interval's first or last sample is not usable (then Uncertain) — #680. - `Count`, `Start`, `End` accept any value kind. `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` on a bucket that holds a **Good** `String`/`DateTime` sample ⇒ `BadAggregateInvalidInputs` for that interval; an Uncertain one is skipped and makes `Minimum`/`Maximum`/`Range`/`*ActualTime` `UncertainDataSubNormal` (#684). `Delta` is refused by any usable non-numeric sample. -- `PercentGood` / `PercentBad` are **duration-based since #680** — see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671). +- `PercentGood` / `PercentBad` are **duration-based since #680**, and `DurationGood` / `DurationBad` (#820) are the same region times — see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671). - Everything except `Count` and `Start`/`End` is a `double`; `Int64` samples aggregate as doubles and `Boolean` as 0/1. ### Time-weighted aggregates (Part 13, #671) -`TimeAverage`, `TimeAverage2`, `Total`, `Total2`, `Interpolative` and (since #680) `PercentGood` / `PercentBad`, evaluated by the +`TimeAverage`, `TimeAverage2`, `Total`, `Total2`, `Interpolative`, (since #680) `PercentGood` / `PercentBad` and (since #820) `DurationGood` / `DurationBad`, evaluated by the shared `Driver.Historian.Sql.Common/TimeWeightedAggregator.cs` for **both** SQL backends (so `SqlServer` and `QuestDb` agree exactly). **This whole family — `Total` and the percentages included — is evaluated in C#, not pushed down to SQL** (see *Pushdown trade-off* and the raw-row cap below). Bucketing (edges, truncated interval, reverse anchoring, the 100 000-interval cap) is the same as above. @@ -1784,6 +1785,7 @@ samples (sloped / stepped) straight across an outage, and holds the last usable | `Total2` | ∫ simple-bounding signal over non-Bad time (value·s) | excluded | | `Interpolative` | interpolated-bounding value **at the interval start** (reverse read: see below) | interpolated across | | `PercentGood` / `PercentBad` | `100 ×` Good / Bad time ÷ interval duration (#680, Part 13 §5.4.3.31-34) — see below | counted as Bad time | +| `DurationGood` / `DurationBad` | Good / Bad time of the interval in **milliseconds** (an OPC UA `Duration`; #820, Part 13 §5.4.3.31-32) — the same regions, see below | counted as Bad time | **Status, `TimeAverage` / `Total`** (interpolated bounding, #680 — Part 13 "Uncertain if Bad values are skipped or Uncertain values are used", and the SDK's `AverageAggregateCalculator`): @@ -1822,6 +1824,14 @@ to the **end of its own interval** (Part 13 — the SDK gives it 1 ms) and later after it is Bad to the window end. An interval that ends before the first sample ⇒ `BadNoData`. Any value kind is accepted; the status is `Good` (plus Calculated / Partial). Value = `100 × region time ÷ interval duration`. +**`DurationGood` / `DurationBad`** (#820) are the same Good / Bad region times as `PercentGood` / `PercentBad` — every rule above +(regions, simple bounds, time before the first sample = Bad, end of data, `BadNoData`, status, Partial) applies unchanged — reported as +an OPC UA `Duration`, i.e. a `double` in **milliseconds** (the SDK's unit): `DurationGood = PercentGood × interval ms ÷ 100`. Uncertain +time counts toward neither (Mantis 0006166). Before #820 both were `BadAggregateNotSupported` on the SQL backends. ⚠️ A **live** +`AggregateFilter` subscription computes DurationGood/PercentGood with the frozen 1.5.378.182 calculators, which count Uncertain time as +Good — they disagree with HistoryRead whenever Uncertain data is present (#809, owner decision pending). The Gateway backend still +refuses both (`BadAggregateNotSupported`, not advertised in i=11201). + All carry the Calculated bit. A non-Bad result also carries the **Partial** bit (`0x4`, `AggregateBits.Partial`) — never on a Bad result — for: the **truncated interval** (shorter than the processing interval — the latest one forward, the earliest one reverse; Part 13 §5.3.3.2 Case 3 requires it — the SDK does not set it, a deliberate difference); the interval the tag's **data starts inside** @@ -1856,7 +1866,7 @@ value at **10:00**, not 09:00, and no point ever falls before the window. The in forward and reverse, and compare every interval's timestamp, value, status code and Partial bit. The per-sample comparison covers Average, Minimum, Maximum, Count, Range, Start, End, Delta and Minimum/MaximumActualTime; the time-weighted one TimeAverage, TimeAverage2, Total, Total2, Interpolative and -PercentGood/Bad (#680), including datasets whose data ends inside the window and with an interval wholly inside an outage; reverse +PercentGood/Bad (#680) and DurationGood/Bad (#820), including datasets whose data ends inside the window and with an interval wholly inside an outage; reverse reads over data that starts inside the window are compared too. Known differences that remain (tracked as #680) — documented and masked in the tests, deliberately not changed here because forward results were to stay as they are — are: an Uncertain value beyond the Good extreme makes Minimum/Maximum/Range/*ActualTime Uncertain here (Part 13), Good in the SDK @@ -1895,9 +1905,14 @@ And an interval holding a **Good String (or DateTime) sample** is `BadAggregateI string and serves the numeric samples (e.g. Average 4 Good); an Uncertain non-numeric sample refuses nothing on either side. One more, found by the #810 Uncertain conformance data (kept deliberately): over the interval the data **starts inside** when the **first sample is Uncertain**, the SDK's `TimeAverage`/`Total` integrate the uncovered span before it as a ramp from 0 (QP: `Total` -188.96 for 163.96 over the covered 15 s); ours integrates only the covered part (Part 13, as with a Good first sample, where the two -agree). Pinned by `QualityMixConformance.Time_weighted_values_over_partial_data_cover_only_the_data` on both SQL backends. The same -suite feeds PercentGood/PercentBad (and Count, Average, TimeAverage(2), Total(2), Start/End/Delta, Interpolative) Good/Uncertain/Bad +188.96 for 163.96 over the covered 15 s, and `TimeAverage` 9.448 = 188.96 ÷ 20 s for 10.931 = 163.96 ÷ 15 s); ours integrates only +the covered part (Part 13, as with a Good first sample, where all agree). The same answer comes from the frozen calculators the server +registers for **live** `AggregateFilter` items and from SDK 2.0.1's own stock calculators, in both read directions — pinned by +`SdkAggregateParityTests.An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data` +(ours / frozen / stock 2.0.1, with a Good-first control) and `OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests` (through +the booted server's runtime `AggregateManager`), and on both SQL backends by `QualityMixConformance.Time_weighted_values_over_partial_data_cover_only_the_data` +(forward) and `…_in_a_reverse_read` (#821). Whether live subscriptions should follow is the open #809/#821 owner decision. The same +suite feeds PercentGood/PercentBad, DurationGood/DurationBad (and Count, Average, TimeAverage(2), Total(2), Start/End/Delta, Interpolative) Good/Uncertain/Bad mixes, all-Uncertain intervals (0 % Good **and** 0 % Bad, Mantis 0006166) and partial-data intervals, hand-computed and against the #796 oracle — the baseline calculator's Uncertain-as-Good PercentGood fails it. @@ -1991,10 +2006,15 @@ round-trip, redelivery, raw / AtTime / aggregate / event reads, every update res run creates a throwaway `OtOpcUaHistorian_T_` database and drops it on dispose. ```bash -export HISTORIAN_SQL_TEST_CONNECTION='Server=localhost,14330;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;Encrypt=False' +# The shared always-on dev SQL Server on the Docker host (otopcua-mssql; the committed dev sa credential, see +# docs/v2/dev-environment.md — not a secret): +export HISTORIAN_SQL_TEST_CONNECTION='Server=10.100.0.35,14330;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False' dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests ``` +The local docker-dev rig's `sql` service also publishes `localhost,14330`, but with **its own** committed password +(`OtOpcUa!Dev123`, `docker-dev/docker-compose.yml`) — the two are different instances; do not mix the host and the password (#822). + --- ## QuestDB backend @@ -2006,7 +2026,7 @@ the spike that pinned every QuestDB-specific form: [plans/2026-10-10-questdb-spi `questdb/questdb:10.0.1`. QuestDB serves every value kind (`Double`, `Int64`, `Boolean`, `String`, `DateTime`, `Empty`), stores the real status, records comms-loss -outage markers, serves the seventeen aggregates (time-weighted ones through the same shared evaluator, so results are identical) and supports **HistoryUpdate** (soft delete), exactly like `SqlServer`. The same conformance +outage markers, serves the nineteen aggregates (time-weighted ones through the same shared evaluator, so results are identical) and supports **HistoryUpdate** (soft delete), exactly like `SqlServer`. The same conformance suite (`tests/Drivers/Historian.Conformance`) runs against both backends; the differences below are the only ones. ### Configuration (`ServerHistorian:QuestDb`) @@ -2276,11 +2296,18 @@ tombstones and redelivery, the visibility wait, updater, reads, paging, and the unique throwaway prefix (`t__`) and drops its tables afterwards. The QuestDB server allows 64 connections, so the fixture prunes idle pool connections. ```bash +# The always-on shared fixture on the Docker host (#822; stack /opt/otopcua-questdb, infra/README.md §2a): +export HISTORIAN_QUESTDB_TEST_CONNECTION='Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading' +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests + +# …or a local one from the docker-dev overlay (same image, PG wire on localhost:18812): docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.questdb.yml up -d --no-deps questdb export HISTORIAN_QUESTDB_TEST_CONNECTION='Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading' -dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests ``` +`admin` / `quest` is QuestDB's stock default — a throwaway fixture credential, not a secret. The fixture's compose file is +`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml` (label `project=lmxopcua`). + --- ## HistoryUpdate diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 3cc617714..b768aaed8 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -81,7 +81,7 @@ Project root files: `DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are: -- **`Gateway`** — endpoint, API key secret ref, TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. +- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815 (an already-deployed driver keeps resolving a pre-#815 ref on init and every gateway reconnect in deprecated warn-only mode until its next deploy), [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. - **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric). - **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`. - **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag). @@ -103,8 +103,10 @@ Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowle not silently downgraded to the fallback. 2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` / `FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous - sessions. The password ref must be `secret:NAME` / `${secret:NAME}` (production), `env:NAME`, - `file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig. + sessions. The password ref must be `secret:galaxy/NAME` / `secret:otopcua/galaxy/NAME` (or the `${secret:…}` + spelling; production), `env:OTOPCUA_GALAXY_NAME` / `env:GALAXY_MXGW_NAME`, or `dev:VALUE`; a bare literal, a + `file:` reference or a reference outside the Galaxy namespace is refused (#815) — the password is sent to the + authored gateway endpoint, so it must not be able to name another subsystem's secret. 3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway health). How the client learns of it (#760): diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index 978088a90..703a3cd4f 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -1,7 +1,7 @@ # Heidenhain LSV2 driver (read-only) `DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) · -Gitea **#803**. +Gitea **#803** (native alarms + rediscovery: **#831**; authoring UX: **#830**). A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the @@ -21,9 +21,12 @@ Three independent layers, each sufficient on its own for its concern: manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed - address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the - save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older - (one rule, `HeidenhainTagRules`). + address — `Float64` ⇒ `Double` — or the binding's declared type; `Boolean` for an `ALARM:` condition): the tag + modal and the manual-entry grid **lock** the access level and data type to the driver's values (#830 — the grid + used to offer a choice it silently replaced on commit), the CSV import's staged review grid flags a ReadWrite or + mistyped row **in the preview**, the save path refuses a mismatch, and the deploy gate + `HeidenhainTagDataTypeMismatch` catches anything older. One rule (`HeidenhainTagRules`), reached by every AdminUI + path through the driver-dispatched `RawTagRowRules` (no call site names a driver). 3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s public surface is pinned by a reflection test) and no configuration knob that widens it. @@ -66,6 +69,7 @@ enums, so the numeric-enum form/factory trap cannot occur): | `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) | | `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | | `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | +| `Polling.AlarmMs` | 2000 | 100–3600000; the alarm walk behind `ALARM:` conditions — only for a control carrying an `ALARM:` tag ([Alarm conditions](#alarm-conditions-alarm-gitea-831)) | | `Polling.MaxAlarms` | 64 | 1–256 | | `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 | | `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded | @@ -90,8 +94,13 @@ Host at their own machine. Anything outside the namespace is refused by **every* (`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time** (`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path** (`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the -driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the -deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. +driver/device modals disable Save while it is invalid and show the refusal **once**, below the form — the typed +forms report only ranges/endpoints, #830 — so a literal never reaches the config database), and the deploy gate +`HeidenhainCredentialPersisted`. Messages never echo the value. Since #815 the parser and resolver are +the shared `CredentialReferences` / `DriverCredentialPolicy` (`Core.Abstractions`) that Galaxy and OpcUaClient use +too, and Test Connect / live browse also refuse an out-of-namespace reference before any probe or browser runs; a +prefix in another casing (`SECRET:`) is refused, and `${secret:heidenhain/}` is accepted as a spelling of +`secret:heidenhain/`. Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the @@ -147,6 +156,70 @@ truncates or wraps — otherwise `BadTypeMismatch`. At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). +## Alarm conditions (`ALARM:`, Gitea #831) + +The control exposes one flat active-alarm list (`R_RI 27` first, `28` next — class, group, number, text; only refusal +57 ends it). Besides the `Alarms/*` typed arrays (unchanged), the list can drive **native OPC UA Part 9 conditions**: +a raw tag whose address is an `ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object is +exactly one condition, `ConditionId` = the tag's RawPath (the key the host routes and fans on — raw device folder + +every referencing equipment folder, per the v3 native-alarm model). Same opt-in-per-tag shape as FOCAS #647 and +MTConnect #626. + +```jsonc +{ "address": "ALARM:CLASS=7", "dataType": "Boolean", + "alarm": { "alarmType": "AlarmCondition", "severity": 800 } } +``` + +| Address | Condition is active while… | +|---|---| +| `ALARM:*` | any alarm is active on the control (summary) | +| `ALARM:OTHER` | an alarm is active that no **filter** selector on the same control claims | +| `ALARM:CLASS=[/GROUP=][/NUMBER=]` (any subset, any order, each key once) | an alarm matches every named field | + +`` is `n` or `lo..hi` (inclusive; `..` because numbers can be negative — a real iTNC 530 reports `-1`). CLASS/GROUP +are 0–65535 (wire uint16), NUMBER is int32. **No semantic class/group names**: their meaning is undocumented in the +reference (pyLSV2 / the Java port keep them raw), so nothing is guessed — observe `Alarms/Classes` / `Alarms/Groups` or +a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy inspector and the +driver. + +- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the + `Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored + tag is the switch) and MTConnect #626 (opt-in because alarm tags leave the value subscription). There is no + driver-level enable flag. +- **Pairing is enforced** (`HeidenhainTagDefinitionFactory`, so the editor, the save path, CSV and the deploy inspector + agree): an `ALARM:` address without the `alarm` object, or an `alarm` object on any other address, is refused. The + tag's data type is fixed at `Boolean`; it is never a value (`ReadAsync` answers `BadNotSupported`), never in a data + subscription, never writable (the `HeidenhainTagWritable` gate still applies). The typed editor seeds + `{"alarmType":"AlarmCondition","severity":700}` when you pick an `ALARM:` selector and drops it when you leave one. +- **Identity** = (class, group, number, **text**) — the number alone is not unique (`-1` above). **Displayed alarm** is + deterministic (ascending class, group, number, text), never wire order. Message: + `-1 Run test of cutout channels (class 7, group 4) [+N more]`. +- **Transitions** (the FOCAS #647 rules): inactive → active `Raise`; a key not in the told set appears, or the displayed + alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a + non-displayed member leaves → nothing; nothing matches → `Clear`. +- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No + class→severity table is invented. +- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the + driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its + first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept, + `NativeAlarmProjector` #647). +- **A failed walk is not "no alarms."** A refusal (e.g. 23/49 access denied → `BadUserAccessDenied`, 22/24 → + `BadNotSupported`), a list overflow (`MaxAlarms` → `BadOutOfRange`), a decode error, telemetry disabled, or comms loss + (`BadNoCommunication`) keeps every condition's Active/Acked state and annotates its **quality** once (quality-only + event, no `/alerts` row, one Warning per episode); the next good walk lifts it to Good before diffing. Only a down + session degrades health (the existing rule) — a refusal never does. +- **Acknowledgement is local-only.** LSV2 has no acknowledge request and the read-only allowlist is **not** widened: + `AcknowledgeAsync` is a no-op success, so the Part 9 Acknowledge / AddComment the host routes to it is applied (and + pair-replicated) server-side — the FOCAS / MTConnect posture. Confirm, the shelve methods and Enable/Disable are + refused `BadNotSupported` by the host for every native condition. The control clears an alarm when its cause is reset; + an operator ack never reaches it. +- **Traffic:** the walk is the same allowlisted `R_RI 27/28` the arrays use, on the control's single acquisition loop + every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists + (`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged). +- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the + alarm object) and CSV (`Alarm.AlarmType` / `Alarm.Severity` columns). The browse picker offers only catalog leaves. +- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`). + ## Quality | Condition | Value status / capability | @@ -183,6 +256,11 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re - Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never receives an older value after a newer one. A re-init disposes every control loop first; a late start of a disposed loop is a no-op. +- **Rediscovery signal (`IRediscoverable`, #831):** every session already reads `R_VR` at connect; when a control + reconnects reporting a **different** identity (model, NC/PLC software version or option string) than the previous + session on this driver instance, the driver raises `OnRediscoveryNeeded` once with both identities in the reason — + the `/hosts` "re-browse" chip. Advisory only (drivers.md §Change Detection): nothing is re-materialised. The first + session records the baseline; a deploy respawn forgets it (the next session is a new baseline). No extra traffic. - **Health:** `Degraded` only while a control's session is down (connect failing / lost), with `LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one @@ -202,7 +280,13 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three). - `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`. -- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet +- `HeidenhainAlarmTests` (#831) — selector grammar, pairing, transition rules, and against the simulator: the captured + iTNC 530 alarm record raising/clearing conditions, local-only ack (zero bytes sent), quality-only comms-loss/refusal, + handler-detached recording, identity-change rediscovery; every case asserts the wire log stays inside the read set. +- `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` — driver → simulator → real `DriverHostActor` condition. +- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests`, + `AdminUI.Tests/Uns/HeidenhainAuthoringUxTests` (#830 CSV preview ⇔ commit parity, data-type lock, single + credential notice) + the fleet parity suites. - **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional `HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a @@ -223,5 +307,5 @@ No real control was available for this port. Still open: Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH, -PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are -published as typed arrays). +PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm +conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)). diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 8b4ad31b5..2795ef89f 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -81,6 +81,13 @@ for every field (security policy/mode, auth type, session timeout, keep-alive, browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save. +**Credentials (#815).** `Password` / `UserCertificatePassword` are a literal, `secret:opcuaclient/` (the node's +secret store) or `env:OTOPCUA_OPCUACLIENT_`. They are resolved on the node and sent to the configured endpoint, +so any other `secret:`/`env:` reference (and `file:`) is refused at save, deploy (`CredentialReferenceOutOfScope`), Test +Connect, browse and (for the AdminUI browser) again before anything is read; an already-deployed driver's session open +keeps the pre-#815 semantics in deprecated warn-only mode until its next deploy. Migrate an existing `secret:` by re-creating +the secret as `opcuaclient/`. See [`docs/security.md`](../security.md) §"Credential references are namespaced per driver". + `ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing). @@ -108,7 +115,14 @@ The rule, for `opc.tcp` (and any scheme, as long as advertised and configured sc a real rewrite keeps the advertised path's percent-escaping (`AbsoluteUri`, #812); - a **different scheme** (an `https` endpoint next to an `opc.tcp` one) is a different transport and is left alone; - an unusable advertised URL (null / blank / unparseable) falls back to the configured one in the driver and the - browser; the Client CLI/UI instead reports it (it validates before calling the rewriter). + browser; the Client CLI/UI instead reports it (it validates before calling the rewriter); +- an **IPv6 zone index** on the configured host (`opc.tcp://[fe80::1%eth0]:4840`) is **kept, verbatim** (#819). + .NET's `Uri` parses the zone but drops it from `Host`/`Authority`/`AbsoluteUri`/`ToString()` — only + `DnsSafeHost`/`IdnHost` carry it — so the rewriter splices it back into the string. The SDK dials + `new Uri(EndpointUrl).IdnHost` (`TcpByteTransport` → `DnsEndPoint`), which keeps it, so a link-local endpoint now + routes; before #819 the session dialled a zone-less `fe80::` address. The zone text is not normalised: write the + form your OS resolves (`%eth0` / `%4`). .NET does **not** decode RFC 6874's `%25eth0` (its `DnsSafeHost` is + `fe80::1%25eth0`), so that form is passed through as written — GetEndpoints and the session see the same string. **What the rewrite does NOT touch.** It only chooses the network address. The **security policy and mode** are still selected from the server's advertisement exactly as before — the driver and browser filter on the configured @@ -118,7 +132,72 @@ user-token policies** are the advertised ones. **Certificate validation is uncha validates the certificate of the server actually reached, against the same trust store (the driver's PKI, the browse picker's separate `adminui-browse-pki`, the CLI's own) with the same `AutoAcceptCertificates` semantics. The NAT tests below pin that a secure browse through the NAT is refused on an untrusted certificate and succeeds once it -is trusted. +is trusted. The optional **host-name** check (#816, next section) is the one place the rewrite matters to validation: +it runs against the rewritten URL, i.e. the **configured** host. + +### Server certificate host name (#816) + +**Finding (SDK 2.0.1).** The SDK only checks that the server certificate names the host you connected to when the +session is created with `checkDomain: true`. All three surfaces — the driver (`OpenSessionOnEndpointAsync`), the +AdminUI browser (`OpcUaClientDriverBrowser.OpenAsync`) and Client.Shared (`DefaultSessionFactory`) — called the +8-argument `DefaultSessionFactory.CreateAsync(config, endpoint, updateBeforeConnect, sessionName, …)`, which +forwards `checkDomain: false`. So **no host-name check ever ran**; trust was the trusted-peer / issuer store alone. +That is why, in #813, a trusted NAT certificate for `otopcua-opc-plc-nat-private.invalid` browsed fine over an IP. + +Where the check lives when enabled — **two** places in SDK 2.0.1, both calling +`CertificateValidationExtensions.ValidateDomains` → `CertificateValidationCore.ValidateDomains` → +`CertificateValidationHelpers.FindDomain`: + +1. `DefaultSessionFactory.CreateChannelAsync` — before the channel opens, against the certificate in the + **GetEndpoints reply** (`endpoint.Description.ServerCertificate`), whenever that reply carries one and **before any + security-mode test**. opc-plc sends its certificate on its `None` endpoint too, so `checkDomain: true` refused a + `None` connect (found in review, reproduced against `:50000`). +2. `Session.OpenAsync` — after `CertificateManager.ValidateAsync` accepts the chain the server returned in + CreateSession (only when the channel or the user-token policy needs the server certificate). + +`FindDomain` compares `ConfiguredEndpoint.EndpointUrl.IdnHost` with the names `X509Utils.GetDomainsFromCertificate` +returns (DNS / IP SANs, and the subject's `DC=` parts); IP literals are normalised, and a `localhost` / `127.0.0.1` / +`::1` URL is compared as the machine's own host name + FQDN. A miss is `BadCertificateHostNameInvalid` ("The domain +'…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook first. +Consequences: + +- **It checks the CONFIGURED host.** `ConfiguredEndpoint.EndpointUrl` is the URL `OpcUaEndpointSelection` / + `EndpointSelector` rewrote, so the advertised host the server named itself never takes part. +- **Sign / SignAndEncrypt endpoints only — enforced here, not by the SDK.** Because of site 1, all three call sites + pass `checkDomain` only when the selected endpoint's `SecurityMode != None` (`OpcUaEndpointSelection.CheckDomainFor` + for the driver and the browser, the same expression in Client.Shared's `ConnectToEndpointAsync`; both cover the + failover sweep, which goes through the same per-endpoint open). A host-name check on an unauthenticated channel + proves nothing. +- **It is a loose match.** For a DNS host the SDK also accepts a match on the **first label alone**: + `plc1.site-a.corp` passes a certificate whose only SAN is `PLC1` (case-insensitive), and so would + `plc1.site-b.corp`. There is no wildcard support (`*.site-a.corp` matches nothing), and the subject's `DC=` + components count as names. +- **A zone-qualified IPv6 host never passes.** `FindDomain` compares the host *with* its zone (`fe80::1%eth0`; macOS + normalises `%en0` to `%14`) against the certificate's zone-less IP SANs, so a link-local endpoint (#819) needs + `validateServerHostName: false`. The repo does not fork the SDK check to strip the zone. +- **`autoAcceptCertificates` accepts a mismatch too** (the driver's auto-accept `AcceptError` handler returns `true` + for everything but revocation). The Client CLI always auto-accepts. +- Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no + certificate or host-name check. + +**Policy: `certificateValidation.validateServerHostName`** (driver JSON; AdminUI driver form "Server host-name check"; +the browser reads the same JSON): + +| Value | Meaning | +|---|---| +| absent / `null` (**default**) | `OpcUaCertificateValidationOptions.DefaultValidateServerHostName` = **off** — exactly the pre-#816 behaviour, so no existing deployment changes on upgrade. The AdminUI form keeps it absent ("Default (off)") rather than writing a concrete `false`. | +| `true` | Validate: the certificate must list the configured endpoint host. The driver's connect failure then appends the remedy (fix the certificate, connect by a name it lists, or opt out). | +| `false` | Explicit opt-out — the NAT / container port-publish / connect-by-an-IP-the-cert-omits case. | + +When to turn it on: it earns its keep when trust comes from a **CA** (a trusted issuer vouches for every certificate +it signed, so only the host check ties the certificate to *this* server) — within the limits above: a +first-label-only match means a certificate issued as `PLC1` for one site is accepted for `plc1` at every site, so give +certificates fully-qualified, site-unique names. With the server's own self-signed +certificate pinned in the trusted-peer store it adds little — the pin already identifies the server. Client +CLI: `--validate-host-name` (`ConnectionSettings.ValidateServerHostName`, default `false`). Pinned by +`ServerHostNameValidationTests` (driver vs. the plain opc-plc, whose certificate names only its container id) and +`NatPublishedEndpointBrowseTests` (browser vs. the NAT opc-plc): unset/`false` connect, `true` is refused naming the +configured host. **Running the NAT tests.** `NatPublishedEndpointBrowseTests` (`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests`, `Fixture=opc-plc-nat`) run against @@ -133,13 +212,20 @@ OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat ``` -The secure case asserts a certificate **refusal** by default. To make it positive, trust the fixture's server -certificate in the browse PKI and set `OPCUA_NAT_SERVER_CERT_TRUSTED=1` — the certificate is regenerated whenever -the container is recreated, so fetch the current one: -`ssh dohertj2@10.100.0.35 'docker exec otopcua-opc-plc-nat sh -c "cat /app/pki/own/certs/*.der"' > "/OtOpcUa/adminui-browse-pki/trusted/certs/opc-plc-nat.der"` -(`~/Library/Application Support/...` on macOS, `~/.local/share/...` on Linux), and delete it again afterwards. CI -(`v2-integration.yml`) starts its own throwaway NAT copy (`scripts/ci/integration-fixtures.sh`); `v2-e2e.yml` points at -the shared one. Neither trusts the certificate, so CI runs the refusal form. +Every secure browse there runs on a **throwaway browse PKI** (an internal PKI-root seam on the browser), never your +`adminui-browse-pki`. The refusal case trusts nothing and always runs. The positive case (unset / `false` host check) +and the host-name refusal (`true`) trust the fixture's **public** certificate (#818), from: + +1. `OPCUA_NAT_SERVER_CERT` — a DER or PEM file; must be the certificate the server presents, or the test **fails**; else +2. the committed `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der` — the shared-host + stack's certificate, stable because its PKI lives on the `opc-plc-nat-pki` volume. If it is not the certificate + the server presents (another NAT fixture, or the stack's certificate was rolled) these tests **skip**. + +The private key never leaves the volume. The committed certificate expires **2027-11-10**; to roll it, see the comment +at the top of `Docker/nat/docker-compose.yml`. CI (`v2-integration.yml`) starts its own throwaway NAT copy +(`scripts/ci/integration-fixtures.sh`), copies that container's public certificate out with `docker exec` and exports +`OPCUA_NAT_SERVER_CERT`, so it runs the positive cases too; `v2-e2e.yml` points at the shared stack and uses the +committed certificate. ### Session lifetime @@ -379,6 +465,7 @@ also refuses them explicitly — defence in depth (#277), not a behaviour change |---|---|---| | `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. | | `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. | +| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Sign / SignAndEncrypt endpoints only (never passed for `None`); accepted anyway under `autoAcceptCertificates`; loose (first-label) match; never matches a zone-qualified IPv6 host. `false` = explicit NAT / link-local opt-out. See "Server certificate host name" above. | ### Subscription tuning (#273) diff --git a/docs/plans/2026-05-28-driver-browsers-design.md b/docs/plans/2026-05-28-driver-browsers-design.md index 39f3a3d4e..41c34ed75 100644 --- a/docs/plans/2026-05-28-driver-browsers-design.md +++ b/docs/plans/2026-05-28-driver-browsers-design.md @@ -280,8 +280,9 @@ Razor picker body BrowserSessionService IDriverBrowser (`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose up -d'`, compose in `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/`), and CI starts a throwaway copy. Run: `OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat`. - The secure case asserts a certificate refusal unless the fixture cert is trusted in the browse PKI and - `OPCUA_NAT_SERVER_CERT_TRUSTED=1` (see `docs/drivers/OpcUaClient.md`). + The secure cases run on a throwaway browse PKI: a refusal with nothing trusted, and (#818) positive / #816 + host-name cases that trust the fixture's public certificate (`OPCUA_NAT_SERVER_CERT`, or the committed + `Docker/nat/opc-plc-nat-server.der`) — see `docs/drivers/OpcUaClient.md`. (`OPCUA_NAT_SERVER_CERT_TRUSTED` is retired.) - No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred) ### Specific regression tests diff --git a/docs/security.md b/docs/security.md index 50d7ab563..247a824f2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -647,9 +647,13 @@ and Write under old and new names until the address space re-points them. An id folder / virtual tag / condition, a probe, a NodeDeleted model change) is decided only by a `Cluster` or `uns` `Namespace` grant — so narrow grantees do not receive NodeDeleted for removed opaque-id nodes, and a removed id can never match an unrelated narrow grant that shares its name; a four-segment (reference-shaped) id keeps its split. -Identifiers containing NUL are refused. Server-object (`i=2253`) delivery of a +Identifiers containing NUL are refused. A UNS variable the reference pass wired to its raw node (the `Organizes` +edge) is placed as a reference variable whatever its id spells (Gitea #826 — the id rule alone made an equipment's +references folder-owned when an Area was named exactly like that equipment's id, losing their `Tag`-grant match). +Server-object (`i=2253`) delivery of a **scripted** condition is now decided on the condition alone (its folders add nothing in steady state and would -leak a moved alarm to its old owner). So a key holding only an `Equipment` grant can navigate to its equipment, +leak a moved alarm to its old owner). Direct event items on a folder get the same per-event decision (Gitea #826, +below). So a key holding only an `Equipment` grant can navigate to its equipment, read its alarm children, receive its scripted alarm events through the Server object and Acknowledge / Confirm / AddComment with the EventId it received. Fail-closed throughout: an unplaceable node is denied under every grant. | HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** | @@ -756,6 +760,31 @@ Behaviours worth knowing before enabling it: condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock. + **Direct (per-folder) event items are filtered per event too** (Gitea #826). An item on an equipment or raw + device folder is fed by the SDK's own per-notifier fan-out (`MonitoredNode2`), which the #586 dispatcher never + touches — so after a deployment moved a scripted alarm (or dropped the reference projecting a native alarm into + an equipment), the OLD folder's subscribers kept receiving it, EventId included, until the rebuild landed, and + indefinitely after a failed apply (commands were already denied). With the flag **on**, each event to such an + item now also needs, against the CURRENT state: + - the event's condition to be the **live** instance (a torn-down one is withheld); + - the folder to still be a legitimate notifier of it — a node it hangs beneath, or a folder it is wired into + **that the current generation still projects its raw tag into** (`DataPlaneAuthoritySnapshot.TagReferencingEquipment`, + `RawPath → EquipmentIds`, decoded from the artifact's `UnsTagReferences` by the same resolver as the grants); + - and the condition to pass exactly the Server-object rule above for the item's session. + + The generation check also narrows the **Server-object** rule: a wired folder whose reference the generation + dropped no longer admits the native alarm there either. **`ConditionRefresh`** on a folder item is filtered the + same way. The hook is `INodeManager3.ValidateEventRolePermissions`, which the node manager re-implements (the + base method is not virtual); the SDK memoizes that verdict per `(item, event type, SourceNode)`, so the node + manager clears each notifier's cache whole (sessions and orphaned, session-less subscriptions alike — an orphaned + subscription must not queue a moved alarm's events for a later `TransferSubscriptions`) on every new generation or + authorizer, on each `AclRevalidationTimer` tick, and at the end of every apply, and not at all while enforcement is + off. A verdict is decided on the condition currently live under the event's NodeId, so a replaced instance's late + event cannot leave a deny behind for its successor. Only alarm-bearing raw tags are carried in the reference map. Withheld events are not replayed and the client is not told; restoring the reference or + grant resumes the feed on the next event. ⚠️ A native alarm reaches an equipment folder's items under + enforcement only while the installed generation carries that reference — a hand-built authority (tests) must + supply `tagReferencingEquipment`. Inert with the flag off. + **Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the `AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where @@ -1191,6 +1220,98 @@ Supporting hygiene, none of which is the control: If a deployment needs the browse surface narrower than "anyone who may author config", the lever is the page policy (and the LDAP group→role mapping behind it), not the browser. +#### Credential references are namespaced per driver (#815) + +The outbound-connect capability above has a sharper edge whenever the config carries a **credential +reference**: a Designer authors both the endpoint *and* the reference, and the node resolves the reference +and sends the value to that endpoint — on Test Connect, on a live browse, and at runtime. Before #815 an +unscoped reference let anyone who may author config exfiltrate any value the admin node could read, with no +deploy: point a Galaxy `Gateway.Endpoint` at their own host, set `ApiKeySecretRef` to +`env:ConnectionStrings__ConfigDb` (or `env:ServerHistorian__ApiKey`, or `file:`), and click Test +Connect / browse; or set an OpcUaClient `Password` to `secret:`. + +Every operator-authored reference is now confined to its driver's namespace — one table, +`DriverCredentialPolicy` (`Core.Abstractions`), one parser/resolver, `CredentialReferences`: + +| Driver | Fields | Allowed references | Literal | +|---|---|---|---| +| `GalaxyMxGateway` | `Gateway.ApiKeySecretRef` | `secret:galaxy/`, `secret:otopcua/galaxy/`, `env:OTOPCUA_GALAXY_`, legacy `env:GALAXY_MXGW_` | allowed (warns), `dev:` | +| `GalaxyMxGateway` | `AlarmAck.FallbackPasswordSecretRef` | same | `dev:` only | +| `OpcUaClient` | `Password`, `UserCertificatePassword` | `secret:opcuaclient/`, `env:OTOPCUA_OPCUACLIENT_` | allowed | +| `Heidenhain` | `InspectPassword`, `DncPassword`, `DataPassword` | `secret:heidenhain/`, `env:OTOPCUA_HEIDENHAIN_` (#803) | refused | + +Rules (all drivers): the prefixes are lower-case and exact (`SECRET:` / `Env:` are refused, never treated +as a literal); `${secret:}` is accepted as a spelling of `secret:`; secret names are +`[A-Za-z0-9._-]` segments with no empty, `.` or `..` segment; the variable suffix is `[A-Z0-9_]+`; **`file:` is +refused everywhere** (Galaxy used to read any file the node could open). A field is matched at any depth of +the DriverConfig / DeviceConfig, case-insensitively, as the drivers bind it. + +Enforced, fail-closed, at every seam — the earlier ones exist for a clear message, the last one is the guarantee: + +1. **AdminUI save** — `DriverConfigSaveGate` (`RawTreeService` driver/device create + update): the config is + never stored. +2. **Deploy gate** — `CredentialReferenceDeployChecks`, code **`CredentialReferenceOutOfScope`** (Heidenhain keeps + `HeidenhainCredentialPersisted`). +3. **Test Connect** — `AdminOperationsActor` refuses before any probe runs (`"Test Connect refused: …"`). +4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens. +5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` and + `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope **before** any + secret-store or environment read. This is hard-fail on every admin-node path (the Galaxy and OpcUaClient + AdminUI browsers, every Test Connect probe) and everywhere for Heidenhain. The one exception is the deprecated + warn-only compatibility path for already-deployed drivers, below. + +Messages name the field and the allowed forms, never the value. Out of scope by design: `${secret:}` tokens in +`appsettings.json` (host config, not operator-authored), and the `Sql` driver's `connectionStringRef`, which is +already a fixed prefix (`Sql__ConnectionStrings__`) whose value carries its own server — a Designer can +choose *which* provisioned database a ref names, but cannot redirect its credentials to another host. + +**Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the upgrade +preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message names the +migration), and refused by Test Connect / live browse. Values most likely to be in real config databases, with +the exact replacement (create the new secret / variable first — on every node that runs the driver **and** on +the admin nodes, which run Test Connect and browse — then edit the field): + +| Old value | Why it is refused | Replace with | +|---|---|---| +| Galaxy `env:MX_API_KEY` — the **old AdminUI Galaxy form default** (pre-#815 `GalaxyDriverForm.razor`), so the likeliest value in a real DB | variable outside the namespace | rename the variable to `OTOPCUA_GALAXY_API_KEY` and set `env:OTOPCUA_GALAXY_API_KEY` (or store it and use `secret:galaxy//apikey`) | +| Galaxy `env:` (e.g. `env:MY_API_KEY`) | same | variable `OTOPCUA_GALAXY_` → `env:OTOPCUA_GALAXY_`. `env:GALAXY_MXGW_API_KEY` (docker-dev, parity docs) needs **no** change | +| Galaxy `file:` — `docs/Configuration.md` used to recommend it for production | `file:` is refused everywhere | put the file's contents in the store as `galaxy//apikey` → `secret:galaxy//apikey` (or a variable `OTOPCUA_GALAXY_`) | +| Galaxy `secret:` not under `galaxy/` (e.g. the old `docs/v2/driver-specs.md` example `secret:galaxy-gw-api-key`) | secret outside the namespace | re-create it as `galaxy/` (e.g. `galaxy/gw/apikey`) → `secret:galaxy/gw/apikey`. Names already under `galaxy/` or `otopcua/galaxy/` need no change | +| Galaxy `Env:X`, `SECRET:x`, `File:x`, … (any non-lower-case prefix) — **pre-#815 resolved these case-insensitively** | prefixes are now exact | the lower-case namespaced form from the rows above | +| Galaxy `AlarmAck.FallbackPasswordSecretRef` = `env:` / `file:` / `secret:` outside the namespace | same | as above; the documented `secret:otopcua/galaxy/` needs no change | +| OpcUaClient `Password` / `UserCertificatePassword` = `secret:` not under `opcuaclient/` (e.g. `secret:opcua/plant/pw`) | secret outside the namespace | re-create the secret as `opcuaclient/` → `secret:opcuaclient/` | +| OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/` | +| OpcUaClient **literal** password that happens to start with `env:`, `file:`, `secret:`/`${secret:` in any casing (e.g. a real password `Env:Prod1`) — pre-#815 sent it verbatim | it now parses as a reference | store it: `secret:opcuaclient/` (a literal that does not start with one of those prefixes is unaffected) | + +**Warn-only compatibility for already-deployed drivers (⚠️ deprecated — a later release fails closed).** Refusing +at resolve time would fault running plant drivers at the upgrade restart — and, for Galaxy, at **every gateway +reconnect**: `GalaxyDriver.ReopenAsync` (the `ReconnectSupervisor` callback) rebuilds the client options through +`BuildClientOptionsAsync`, which re-resolves `Gateway.ApiKeySecretRef` each time, so a running driver would fault +at its next reconnect, not at a deploy. So, for one compatibility window, the running-driver call sites — Galaxy +`InitializeAsync` / `ReopenAsync` / the ack-fallback resolver, and OpcUaClient session open — resolve an +out-of-namespace reference with the **old** semantics (Galaxy: case-insensitive `env:`/`file:`/`dev:`/`secret:`, +literal otherwise; OpcUaClient: case-insensitive `secret:`, literal otherwise) and log a Warning naming the +driver, field and allowed form (never the value), at most once per field per hour. The use also rides the +driver's diagnostics bag (`credential-compat:`) to `/hosts` and to the `/raw` credential banner. This is +chosen **by call site**, not by a flag (`LegacyCredentialReferenceTracker`): the AdminUI browsers and every probe +never get the tracker and stay hard-fail, and Heidenhain (new driver, no legacy configs) is hard-fail everywhere. +It cannot be used to exfiltrate: save, Test Connect, browse and the deploy gate all refuse such a reference, so it +can only be the reference an administrator deployed **before** the upgrade, and it lives only until that driver's +next successful deploy (which requires migrating it). The hard-fail release deletes the tracker, the per-driver +`Legacy…` resolve methods and their `legacy` parameters — nothing else changes. + +**Upgrade preflight.** On an admin node, `CredentialReferencePreflightService` runs once, 15 s after +`ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only). It scans +every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft**) and the latest +deployment artifact (**Deployed** — what the running drivers were given) and logs one Warning per finding: +driver, device, field, allowed form, never the value. The same list, plus running drivers that reported a +warn-only resolution (**Running**, from driver-health diagnostics, fleet-wide), is shown as a banner on `/raw`, +with a Re-check button. + +Residual (accepted): within one namespace, a Designer can still point one driver instance at another +instance's secret (e.g. `secret:galaxy//apikey`) — the value then reaches the endpoint the Designer +chose. Per-instance scoping is a possible follow-up. + ### Role grant source Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI. diff --git a/docs/v2/driver-specs.md b/docs/v2/driver-specs.md index 2501b1a8c..59bcc45e4 100644 --- a/docs/v2/driver-specs.md +++ b/docs/v2/driver-specs.md @@ -51,7 +51,7 @@ Per `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs`: { "Gateway": { "Endpoint": "http://localhost:5120", - "ApiKeySecretRef": "secret:galaxy-gw-api-key", + "ApiKeySecretRef": "secret:galaxy/gw/apikey", "UseTls": true, "CaCertificatePath": null, "ConnectTimeoutSeconds": 10, diff --git a/infra/README.md b/infra/README.md index d04ea3993..6787b19ad 100644 --- a/infra/README.md +++ b/infra/README.md @@ -37,6 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{ | Stack dir | Purpose | |---|---| | `/opt/otopcua-mssql` | central SQL (always-on) | + | `/opt/otopcua-questdb` | QuestDB historian-backend test fixture (always-on, #822) — see §2a | | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-opcuaclient-nat` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` · `/opt/otopcua-focas` | driver fixtures | | `~/otopcua-ablegacy` | driver fixture (user-owned) | | `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 | @@ -55,6 +56,30 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{ Nuclear reset: `ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mssql && docker compose down -v && docker compose up -d'`. +## 2a. QuestDB historian fixture (always-on, #822) + +| | | +|---|---| +| Container | `otopcua-questdb` (`questdb/questdb:10.0.1`, label `project=lmxopcua`), stack `/opt/otopcua-questdb` | +| Source of truth | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml` | +| Endpoint | PG wire `10.100.0.35:8812` · web console `http://10.100.0.35:19000` (host `:9000` is taken) | +| Creds (dev) | QuestDB stock default `admin` / `quest` (throwaway fixture credential) | +| Env var | `HISTORIAN_QUESTDB_TEST_CONNECTION='Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading'` | +| Used by | `Driver.Historian.QuestDb.IntegrationTests` (incl. the shared `Historian.Conformance` suite); absent env var ⇒ every test skips | + +Deploy / update (the `/opt` dir was created with `sudo mkdir` + `chown dohertj2`): + +```bash +rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/ \ + dohertj2@10.100.0.35:/opt/otopcua-questdb/ +ssh dohertj2@10.100.0.35 'cd /opt/otopcua-questdb && docker compose up -d' +``` + +Tests isolate by a unique table prefix per class and drop their tables on dispose, so the instance is safe to share. +Nuclear reset: `ssh dohertj2@10.100.0.35 'cd /opt/otopcua-questdb && docker compose down -v && docker compose up -d'`. +The SQL Server historian suite uses §2's instance: `HISTORIAN_SQL_TEST_CONNECTION='Server=10.100.0.35,14330;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False'` +(each run creates and drops its own `OtOpcUaHistorian_T_` database). + ## 3. Driver integration fixtures Each driver's integration suite TCP-probes its fixture at collection init and **skips cleanly** @@ -77,6 +102,7 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live **Endpoint overrides** point a suite at a real PLC instead of the sim: `MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` · `OPCUA_NAT_ENDPOINT` (opt-in, no default) · `MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT` · `OTOPCUA_FOCAS_SIM_ENDPOINT`. +The historian backends are gated on full connection strings instead (`HISTORIAN_SQL_TEST_CONNECTION` · `HISTORIAN_QUESTDB_TEST_CONNECTION`, §2a). > **`otopcua-focas-sim` on the host is NOT ours (#651).** It is ignitionoee's mock (created by that > project's `provision-oee-focas.sh` for `ignition-site`; no published port, different defaults: @@ -92,7 +118,7 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live > CA + server cert/key, gitignored) and `MQTT_FIXTURE_USERNAME`/`MQTT_FIXTURE_PASSWORD` are exported > at `docker compose up`. The live suite additionally wants `MQTT_FIXTURE_CA_CERT` pointing at a local > copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`. -> It and the OpcUaClient NAT fixture are the only ones whose compose files carry the `project: lmxopcua` label (see CLAUDE.md). +> Not every fixture's compose file carries the `project: lmxopcua` label yet (MQTT, Modbus, FOCAS, MTConnect, Sql, the OpcUaClient NAT stack and the QuestDB historian fixture §2a do); enumerate by container name when in doubt. > **The OpcUaClient NAT fixture exists for what it ADVERTISES (#806/#813).** `--ph` sets a private `.invalid` > host (RFC 6761: never resolves, so no DNS search domain can make it reachable) and the published port 50010 differs @@ -100,9 +126,12 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live > `NatPublishedEndpointBrowseTests` (Browser.IntegrationTests, `Fixture=opc-plc-nat`) use it, and each asserts that > premise before testing the shared `OpcUaEndpointUrlRewriter` (configured host:port win, advertised path kept): > `OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat`. -> A separate stack so it never touches `otopcua-opc-plc`; it carries the `project: lmxopcua` label. The secure case -> turns positive with `OPCUA_NAT_SERVER_CERT_TRUSTED=1` once the container's cert is in the browse PKI -> (`docs/drivers/OpcUaClient.md` §"Endpoint address"). CI runs its own throwaway copy (`otopcua-ci-opcplc-nat`, §10). +> A separate stack so it never touches `otopcua-opc-plc`; it carries the `project: lmxopcua` label. Its PKI is on the +> `opc-plc-nat-pki` volume (#818), so its certificate survives a recreate; the PUBLIC half is committed as +> `Docker/nat/opc-plc-nat-server.der` and the positive secure / #816 host-name cases trust it in a throwaway browse PKI +> (they skip if the server presents a different one; `OPCUA_NAT_SERVER_CERT=` overrides). `docker compose down -v` +> rolls the certificate — refresh the committed copy then (`docs/drivers/OpcUaClient.md` §"Endpoint address"). CI runs +> its own throwaway copy (`otopcua-ci-opcplc-nat`, §10) and exports its certificate as `OPCUA_NAT_SERVER_CERT`. > **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone > Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 4cd24ccc4..567fb2ff4 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -75,9 +75,19 @@ Neither touches `v2-ci.yml`; each has its own concurrency group (`lmxopcua-v2-in Local run: 468 executed / 8 skipped (Oracle x2, the blackhole `docker pause` gate, one QuestDB stress test, 4 others), about 4.5 min of test time, QuestDB the longest (~3.3 min). Not covered (no pullable image or needs credentials): Modbus/S7/AB/FOCAS/TwinCAT sims, Mqtt, MTConnect, Oracle, Host real-infra mode. + **#816/#818 added 6 executing tests here, floor NOT yet raised:** `ServerHostNameValidationTests` (3, vs the plain + `otopcua-ci-opcplc`) and the NAT positive-secure / host-name cases (3, which run because `integration-fixtures.sh` + now exports the NAT container's public certificate as `OPCUA_NAT_SERVER_CERT`). Locally (2026-11-11, shared host) + the two OpcUaClient projects went 13 → 19 executed with 0 skipped, and `assert-not-all-skipped.sh` counted them. + Once a hosted run's TRX shows `Driver.OpcUaClient.trx` executed=8 and `Driver.OpcUaClient.Browser.trx` executed=11, + raise `MIN_EXECUTED` in `v2-integration.yml` **440 → 448** (and `v2-e2e.yml` 35 → 39). If the NAT three show as skipped there, the certificate + hand-off (`docker exec … /app/pki/own/certs/*.der`) did not work on the runner — fix that, do not lower the bump. - **`v2-e2e.yml`** (manual + weekly cron only; `timeout-minutes: 90`). `probe-e2e-fixtures.sh` TCP-probes 10.100.0.35 ports 5020/1102/50000; if any is closed every later step is skipped and the run is green with a notice. Otherwise runs the Modbus, S7, OpcUaClient and OpcUaClient.Browser integration projects against the - shared fleet with `MIN_EXECUTED=35` (`OPCUA_NAT_ENDPOINT` points at the shared NAT stack on :50010; its 3 tests - skip if it is down, so it is not a probe-required port) (local: 42 executed / 38 skipped; Modbus skips the profiles that are not + shared fleet with `MIN_EXECUTED=35` (`OPCUA_NAT_ENDPOINT` points at the shared NAT stack on :50010; its 6 tests + skip if it is down, so it is not a probe-required port — and its 3 certificate-trusting ones also skip if the stack's + certificate no longer matches the committed `Docker/nat/opc-plc-nat-server.der`, so do not count them in the floor; + #816's 3 `ServerHostNameValidationTests` need only the probe-required :50000, so this floor may go **35 → 38** after + a hosted run shows them executed) (local: 42 executed / 38 skipped; Modbus skips the profiles that are not up). No test carries `Category=E2E` today, so the zero-match guard is this executed floor. diff --git a/scripts/ci/integration-fixtures.sh b/scripts/ci/integration-fixtures.sh index 05a203603..8da099e3b 100755 --- a/scripts/ci/integration-fixtures.sh +++ b/scripts/ci/integration-fixtures.sh @@ -80,6 +80,14 @@ up() { wait_for "questdb" 120 curl -fsS "http://$(fixture_host):$QDB_HTTP_PORT/exec?query=select%201" wait_for "opc-plc" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_PORT" wait_for "opc-plc (NAT)" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_NAT_PORT" + # #818: hand the NAT server's PUBLIC certificate to NatPublishedEndpointBrowseTests (OPCUA_NAT_SERVER_CERT) so the + # positive secure / host-name cases run here too. Copied out of the container — the trust channel is docker itself, + # not the wire. The throwaway container makes a fresh certificate per run, so the committed shared-host copy cannot + # be used; the private key never leaves the container. + local nat_cert="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/otopcua-ci-fixtures/opc-plc-nat-server.der" + mkdir -p "$(dirname "$nat_cert")" + wait_for "opc-plc (NAT) certificate" 60 bash -c \ + "docker exec otopcua-ci-opcplc-nat bash -c 'cat /app/pki/own/certs/*.der' > '$nat_cert' && [ -s '$nat_cert' ]" local h; h="$(fixture_host)" local -a exports=( @@ -92,6 +100,7 @@ up() { "HISTORIAN_QUESTDB_TEST_CONNECTION=Host=$h;Port=$QDB_PORT;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading" "OPCUA_SIM_ENDPOINT=opc.tcp://$h:$OPC_PORT" "OPCUA_NAT_ENDPOINT=opc.tcp://$h:$OPC_NAT_PORT" + "OPCUA_NAT_SERVER_CERT=$nat_cert" ) for e in "${exports[@]}"; do echo "export \"$e\"" diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs index 86bddc6e9..8bb02aca5 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs @@ -57,6 +57,13 @@ public abstract class CommandBase : ICommand [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] public string? FailoverUrls { get; init; } + /// + /// Gets a value indicating whether the server certificate must name the host given in (#816). + /// + [CommandOption("validate-host-name", + Description = "Require the server certificate to list the --url host (sign/encrypt only, never with --security none; off by default — leave off behind NAT)")] + public bool ValidateHostName { get; init; } + /// /// Gets a value indicating whether verbose Serilog output should be enabled for troubleshooting. /// @@ -88,7 +95,8 @@ public abstract class CommandBase : ICommand Username = Username, Password = Password, SecurityMode = securityMode, - AutoAcceptCertificates = true + AutoAcceptCertificates = true, + ValidateServerHostName = ValidateHostName }; return settings; diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index fc18bc634..77921b4f8 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -48,7 +48,7 @@ public class HistoryReadCommand : CommandBase /// /// Gets the optional aggregate name used when the operator wants processed history instead of raw samples. /// - [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")] + [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad, DurationGood, DurationBad")] public string? Aggregate { get; init; } /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs index 0619ec1e0..367f2680a 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs @@ -18,6 +18,7 @@ internal sealed class DefaultSessionFactory : ISessionFactory string sessionName, uint sessionTimeoutMs, UserIdentity identity, + bool validateServerHostName, CancellationToken ct) { var endpointConfig = EndpointConfiguration.Create(config); @@ -26,7 +27,10 @@ internal sealed class DefaultSessionFactory : ISessionFactory var created = await new Opc.Ua.Client.DefaultSessionFactory(ClientTelemetry.Context).CreateAsync( config, configuredEndpoint, - false, + false, // updateBeforeConnect + // #816: the overload without checkDomain hard-codes false. The endpoint URL here is the operator's host + // (EndpointSelector rewrote the advertised one), so that is the host the certificate is checked against. + validateServerHostName, // checkDomain sessionName, sessionTimeoutMs, identity, diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs index 0593be846..e3149db8f 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs @@ -15,6 +15,9 @@ internal interface ISessionFactory /// The session name. /// Session timeout in milliseconds. /// The user identity. + /// + /// The SDK's checkDomain: require the server certificate to name the endpoint URL's host (#816). + /// /// Cancellation token. /// A session adapter wrapping the created session. Task CreateSessionAsync( @@ -23,5 +26,6 @@ internal interface ISessionFactory string sessionName, uint sessionTimeoutMs, UserIdentity identity, + bool validateServerHostName, CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs index 23b70edeb..197399a82 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs @@ -34,6 +34,8 @@ public static class AggregateTypeMapper AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime, AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, + AggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood, + AggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad, _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.") }; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs index 17dddc51b..683eb6093 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models; /// -/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends +/// Aggregate functions for processed history reads — exactly the nineteen the server's SQL historian backends /// serve (AggregateFamilies.Served; a parity test pins it). The Gateway backend serves a subset and refuses /// the rest with BadAggregateNotSupported. /// @@ -60,5 +60,11 @@ public enum AggregateType PercentGood = 16, /// Percentage of the interval with Bad data (Part 13 PercentBad). - PercentBad = 17 + PercentBad = 17, + + /// Time of the interval with Good data, in milliseconds (Part 13 DurationGood). + DurationGood = 18, + + /// Time of the interval with Bad data, in milliseconds (Part 13 DurationBad). + DurationBad = 19 } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs index d9cf64882..233242dd9 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs @@ -40,6 +40,17 @@ public sealed class ConnectionSettings /// public bool AutoAcceptCertificates { get; set; } = true; + /// + /// Gitea #816 — require the server certificate to list the host of the URL being connected (the operator's + /// host: discovery keeps it in place of the server-advertised one) among its subject-alternative names; + /// a mismatch fails the connect with BadCertificateHostNameInvalid. Defaults to false, the + /// behaviour before #816 (the SDK overload used never checked). Applied to Sign / SignAndEncrypt endpoints only + /// — never passed for , where SDK 2.0.1 would check the GetEndpoints + /// certificate at channel creation anyway. Leave it off behind NAT / a port publish / when connecting by an IP + /// the certificate does not name / for a zone-qualified IPv6 host (the SDK compares the zone too). + /// + public bool ValidateServerHostName { get; set; } + /// /// Path to the certificate store. Defaults to ; the /// consuming application configuration factory resolves the canonical path via diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 513fb30fe..8ea477560 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -777,6 +777,7 @@ public sealed class OpcUaClientService : IOpcUaClientService SecurityMode = settings.SecurityMode, SessionTimeoutSeconds = settings.SessionTimeoutSeconds, AutoAcceptCertificates = settings.AutoAcceptCertificates, + ValidateServerHostName = settings.ValidateServerHostName, CertificateStorePath = settings.CertificateStorePath, Username = settings.Username, Password = settings.Password @@ -793,8 +794,12 @@ public sealed class OpcUaClientService : IOpcUaClientService : new UserIdentity(); var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); + // #816: never on a None endpoint — SDK 2.0.1 also checks the host at channel creation against the + // GetEndpoints certificate, before any security-mode test, and on an unauthenticated channel that proves + // nothing (same rule as OpcUaEndpointSelection.CheckDomainFor for the driver and the AdminUI browser). + var checkDomain = settings.ValidateServerHostName && endpoint.SecurityMode != MessageSecurityMode.None; return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity, - ct); + checkDomain, ct); } catch { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs new file mode 100644 index 000000000..6b62bf087 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs @@ -0,0 +1,56 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// Deploy gate for credential references (lmxopcua#815): every DriverConfig and DeviceConfig of a driver type +/// listed in must carry its credential fields as blank, an acceptable +/// literal, or a reference inside that driver's namespace (e.g. Galaxy secret:galaxy/… / +/// env:OTOPCUA_GALAXY_…). The same table the AdminUI save path, Test Connect, live browse and every +/// driver's resolve-time check read — so the gate and the runtime cannot disagree. +/// +/// +/// Heidenhain is excluded here only because already reports the same rule +/// (as ) through the shared parser; reporting it twice would +/// duplicate every error. The message names the field and the allowed forms, never the value. +/// +public static class CredentialReferenceDeployChecks +{ + /// A credential field holding a reference outside the driver's namespace (or a refused literal). + public const string Code = "CredentialReferenceOutOfScope"; + + /// + /// Appended to every error: a config deployed before #815 is still RUNNING in warn-only compatibility mode, and + /// this deploy is where it stops — so the message names the migration, not just the rule. + /// + public const string MigrationHint = + " Migrate it before deploying: create the value under the allowed name (secret store via /admin/secrets or the " + + "secret CLI, or the environment variable on every node that runs this driver AND the admin nodes) and point the " + + "field at it. A driver deployed before this upgrade keeps resolving the old reference (warn-only, deprecated) " + + "only until this deploy succeeds. See docs/security.md §\"Credential references are namespaced per driver\"."; + + /// Runs the gate. + /// The draft. + /// The errors. + public static IReadOnlyList Errors(DraftSnapshot draft) + { + var errors = new List(); + foreach (var driver in draft.DriverInstances) + { + if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue; + if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue; + + foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig)) + errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId)); + + foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))) + { + foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig)) + errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId)); + } + } + + return errors; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 5f9ed4f6e..c2880f739 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -47,6 +47,7 @@ public static class DraftValidator errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft)); ValidateMTConnectSingleDevice(draft, errors); errors.AddRange(HeidenhainDeployChecks.Errors(draft)); + errors.AddRange(CredentialReferenceDeployChecks.Errors(draft)); ValidateNodeAclScopes(draft, errors); return errors; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs new file mode 100644 index 000000000..725e3ab82 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs @@ -0,0 +1,302 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the +/// secret-store prefix a secret: reference must start with, and the environment-variable prefixes an +/// env: reference must start with. +/// +/// +/// The scope is the exfiltration guard. A Designer authors the endpoint AND the credential reference, and the +/// node resolves the reference and sends the value to that endpoint (Test Connect, live browse, deploy). An +/// unscoped reference could name any secret or process variable the node can read — +/// env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, the mesh PSK — and send it in +/// cleartext to a host the Designer controls. Inside its own namespace a reference can only name values that +/// were provisioned to be sent to that driver's kind of endpoint. +/// +/// Driver label used in messages (e.g. Galaxy). +/// +/// The ONLY secret-store prefixes allowed, each ending in / (e.g. galaxy/). The first entry is the +/// documented form. +/// +/// +/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. OTOPCUA_GALAXY_); the remainder +/// must be [A-Z0-9_]+. The first entry is the documented form. +/// +public sealed record CredentialScope(string Label, IReadOnlyList SecretNamespaces, IReadOnlyList EnvVariablePrefixes); + +/// Whether a credential field may carry a literal (non-reference) value. +public enum CredentialLiteralPolicy +{ + /// A literal is refused — the field must be blank or a namespaced reference. + Refused, + + /// A literal is accepted and used verbatim (the author already knows it; nothing is resolved). + Allowed, + + /// Only the explicit dev:VALUE literal is accepted (Galaxy's opt-in cleartext marker). + DevPrefixOnly, +} + +/// The kind of a parsed credential field value. +public enum CredentialReferenceKind +{ + /// Null or empty. + Blank, + + /// A secret: (or ${secret:…}) reference inside the scope's namespace. + Secret, + + /// An env: reference inside the scope's prefixes. + Environment, + + /// Not a reference: a literal value (acceptability depends on the field's ). + Literal, + + /// + /// A reference (secret:, ${secret:…}, env:, file:) that is outside the scope or + /// malformed. Never resolved; always refused. + /// + OutOfScope, +} + +/// A credential reference that may not (or cannot) be resolved. The message never carries a resolved value. +/// The message. +public sealed class CredentialReferenceException(string message) : InvalidOperationException(message); + +/// +/// The one parser + resolver of operator-authored credential references (secret: / env:), shared by +/// every driver, probe, browser, the AdminUI save path and the deploy gate (lmxopcua#815, generalising #803). +/// Resolution is fail-closed and re-checks the scope itself, so a reference that skipped every earlier gate is +/// still never looked up. +/// +public static class CredentialReferences +{ + /// Prefix of a secret-store reference. + public const string SecretPrefix = "secret:"; + + /// Prefix of an environment-variable reference. + public const string EnvPrefix = "env:"; + + /// Prefix of a file reference — never allowed in operator-authored config (it reads any node file). + public const string FilePrefix = "file:"; + + /// Galaxy's explicit cleartext marker. + public const string DevPrefix = "dev:"; + + private const string TokenSecretPrefix = "${secret:"; + + /// + /// Parses against . A prefix in another casing + /// (SECRET:x) is — never a literal sent verbatim and + /// never resolved; the prefix, the namespace and the name are matched exactly. + /// + /// The authored field value. + /// The driver's scope. + /// The full secret name (incl. the namespace) or the environment variable; empty otherwise. + /// The kind. + public static CredentialReferenceKind Parse(string? value, CredentialScope scope, out string name) + { + ArgumentNullException.ThrowIfNull(scope); + name = ""; + if (string.IsNullOrEmpty(value)) return CredentialReferenceKind.Blank; + + // A prefix in any casing is a reference (never a literal sent verbatim), but only the exact lower-case + // spelling is ever resolved — 'SECRET:galaxy/x' is refused, not silently honoured. + if (HasPrefixOtherCase(value, TokenSecretPrefix) || HasPrefixOtherCase(value, SecretPrefix) + || HasPrefixOtherCase(value, EnvPrefix)) + return CredentialReferenceKind.OutOfScope; + + string? secret = null; + if (value.StartsWith(TokenSecretPrefix, StringComparison.Ordinal)) + { + if (!value.EndsWith('}')) return CredentialReferenceKind.OutOfScope; + secret = value[TokenSecretPrefix.Length..^1]; + } + else if (value.StartsWith(SecretPrefix, StringComparison.Ordinal)) + { + secret = value[SecretPrefix.Length..]; + } + + if (secret is not null) + { + if (!IsScopedSecretName(secret, scope)) return CredentialReferenceKind.OutOfScope; + name = secret; + return CredentialReferenceKind.Secret; + } + + if (value.StartsWith(EnvPrefix, StringComparison.Ordinal)) + { + var variable = value[EnvPrefix.Length..]; + if (!IsScopedVariable(variable, scope)) return CredentialReferenceKind.OutOfScope; + name = variable; + return CredentialReferenceKind.Environment; + } + + return value.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase) + ? CredentialReferenceKind.OutOfScope + : CredentialReferenceKind.Literal; + } + + /// True when may be stored/deployed in a field with the given literal policy. + /// The authored value. + /// The driver's scope. + /// The field's literal policy. + /// Whether it is acceptable. + public static bool IsAcceptable(string? value, CredentialScope scope, CredentialLiteralPolicy literals) => + Parse(value, scope, out _) switch + { + CredentialReferenceKind.Blank or CredentialReferenceKind.Secret or CredentialReferenceKind.Environment => true, + CredentialReferenceKind.Literal => literals switch + { + CredentialLiteralPolicy.Allowed => true, + CredentialLiteralPolicy.DevPrefixOnly => value!.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase), + _ => false, + }, + _ => false, + }; + + /// The one wording of the rule — names the allowed forms, never the value. + /// The field. + /// The driver's scope. + /// The field's literal policy. + /// The message. + public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals) + { + ArgumentNullException.ThrowIfNull(scope); + var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}'")); + var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}'")); + var forms = $"{secrets} or {env}"; + return literals switch + { + CredentialLiteralPolicy.Allowed => + $"{field} must be a literal value, {forms} — a secret:/env:/file: reference outside the {scope.Label} " + + "namespace is refused (lmxopcua#815).", + CredentialLiteralPolicy.DevPrefixOnly => + $"{field} must be blank, {forms} (or 'dev:' on a dev rig) — a literal, a file: reference, or a " + + $"reference outside the {scope.Label} namespace is refused (lmxopcua#815).", + _ => + $"{field} must be blank, {forms} — a literal password, or a reference outside the {scope.Label} " + + "namespace, is refused (lmxopcua#815).", + }; + } + + /// + /// Resolves a field value, fail-closed. Blank returns ""; an acceptable literal returns verbatim; an + /// out-of-scope reference or a refused literal throws BEFORE any + /// store or environment read; an absent secret / unset variable returns . + /// + /// The authored value. + /// The driver's scope. + /// Field label for messages. + /// The field's literal policy. + /// Secret-store lookup (by full name); null ⇒ a secret: reference throws. + /// Environment reader; null ⇒ . + /// Cancellation. + /// The value, or null when the referenced value is absent. + public static async ValueTask ResolveOrNullAsync( + string? value, + CredentialScope scope, + string field, + CredentialLiteralPolicy literals, + Func>? secretLookup, + Func? environment, + CancellationToken ct) + { + var kind = Parse(value, scope, out var name); + if (!IsAcceptable(value, scope, literals)) + throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)"); + switch (kind) + { + case CredentialReferenceKind.Blank: + return ""; + case CredentialReferenceKind.Literal: + return value; + case CredentialReferenceKind.Environment: + return (environment ?? System.Environment.GetEnvironmentVariable)(name); + case CredentialReferenceKind.Secret: + if (secretLookup is null) + throw new CredentialReferenceException( + $"{field} references secret '{name}' but no secret store is available (fail-closed)."); + return await secretLookup(name, ct).ConfigureAwait(false); + default: + throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)"); + } + } + + /// + /// As , but an absent secret / unset (or empty) variable throws + /// naming the secret or variable (never a value). + /// + /// The authored value. + /// The driver's scope. + /// Field label for messages. + /// The field's literal policy. + /// Secret-store lookup; null ⇒ a secret: reference throws. + /// Environment reader; null ⇒ the process environment. + /// Cancellation. + /// The value ("" for blank). + public static async ValueTask ResolveAsync( + string? value, + CredentialScope scope, + string field, + CredentialLiteralPolicy literals, + Func>? secretLookup, + Func? environment, + CancellationToken ct) + { + var resolved = await ResolveOrNullAsync(value, scope, field, literals, secretLookup, environment, ct).ConfigureAwait(false); + var kind = Parse(value, scope, out var name); + if (!string.IsNullOrEmpty(resolved) || kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) + return resolved ?? ""; + throw kind == CredentialReferenceKind.Environment + ? new CredentialReferenceException($"{field} environment variable '{name}' is not set (fail-closed).") + : new CredentialReferenceException($"{field} secret '{name}' is absent from the store (fail-closed)."); + } + + /// A print-safe form of a value: the reference itself, or a redaction for a literal / dev value. + /// The authored value. + /// A string safe to log. + public static string Describe(string? value) + { + if (string.IsNullOrEmpty(value)) return ""; + var v = value.Trim(); + if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:"; + return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase) + || v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase) + || v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase) + || v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase) + ? v + : ""; + } + + private static bool HasPrefixOtherCase(string value, string prefix) => + value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !value.StartsWith(prefix, StringComparison.Ordinal); + + private static bool IsScopedSecretName(string secret, CredentialScope scope) + { + foreach (var ns in scope.SecretNamespaces) + { + if (!secret.StartsWith(ns, StringComparison.Ordinal)) continue; + var rest = secret[ns.Length..]; + if (rest.Length is 0 or > 200) continue; + if (!rest.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) continue; + if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) continue; + return true; + } + + return false; + } + + private static bool IsScopedVariable(string variable, CredentialScope scope) + { + foreach (var prefix in scope.EnvVariablePrefixes) + { + if (!variable.StartsWith(prefix, StringComparison.Ordinal)) continue; + var rest = variable[prefix.Length..]; + if (rest.Length is > 0 and <= 100 && rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_')) + return true; + } + + return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs new file mode 100644 index 000000000..2b3eeb745 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs @@ -0,0 +1,148 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// One credential field of a driver config and whether it may carry a literal. +/// The JSON property name (matched case-insensitively, as every driver binds). +/// The literal policy. +public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals); + +/// The credential scope + fields of one driver type. +/// The value. +/// The namespace its references are confined to. +/// The credential fields its DriverConfig/DeviceConfig may carry. +public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList Fields); + +/// +/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate, +/// Test Connect, live browse and every driver's resolve-time check read. +/// +/// +/// Every driver that RESOLVES a secret:/env: reference from operator-authored config and sends the +/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only — +/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's +/// Sql__ConnectionStrings__<ref>, already a fixed prefix) are not. +/// +public static class DriverCredentialPolicy +{ + /// Heidenhain LSV2 passwords: secret:heidenhain/… / env:OTOPCUA_HEIDENHAIN_… (#803). + public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]); + + /// + /// Galaxy gateway API key + fallback ack password: secret:galaxy/… (or secret:otopcua/galaxy/…, the + /// form the ack-fallback docs used) / env:OTOPCUA_GALAXY_…, plus the + /// legacy env:GALAXY_MXGW_… prefix the docker-dev rig and the parity-rig docs already use + /// (GALAXY_MXGW_API_KEY) — a variable provisioned to be sent to an mxaccessgw. + /// + public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]); + + /// OPC UA Client user / certificate passwords: secret:opcuaclient/… / env:OTOPCUA_OPCUACLIENT_…. + public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]); + + /// Galaxy Gateway.ApiKeySecretRef (literal accepted for back-compat, with a warning at resolve). + public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed); + + /// Galaxy AlarmAck.FallbackPasswordSecretRef (reference or explicit dev: only). + public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly); + + private static readonly IReadOnlyDictionary Rules = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope, + [ + new("InspectPassword", CredentialLiteralPolicy.Refused), + new("DncPassword", CredentialLiteralPolicy.Refused), + new("DataPassword", CredentialLiteralPolicy.Refused), + ]), + [DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]), + [DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope, + [ + new("Password", CredentialLiteralPolicy.Allowed), + new("UserCertificatePassword", CredentialLiteralPolicy.Allowed), + ]), + }; + + /// The rules for a driver type, if it resolves credential references. + /// The driver type. + /// The rules. + /// Whether the type has rules. + public static bool TryGet(string? driverType, out DriverCredentialRules rules) + { + if (driverType is not null && Rules.TryGetValue(driverType, out var r)) + { + rules = r; + return true; + } + + rules = null!; + return false; + } + + /// + /// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of + /// whose value is not acceptable — at ANY depth (a field is bound wherever the + /// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value. + /// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty. + /// + /// The owning driver's type. + /// The config JSON. + /// The problems; empty when acceptable or the type has no credential rules. + public static IReadOnlyList DescribeProblems(string? driverType, string? json) + { + var problems = new List(); + if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems; + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + } + catch (JsonException) + { + problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked."); + return problems; + } + + using (doc) + { + Scan(doc.RootElement, "", rules, problems, 0); + } + + return problems; + } + + private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List problems, int depth) + { + if (depth > 32) return; + if (element.ValueKind == JsonValueKind.Array) + { + var i = 0; + foreach (var item in element.EnumerateArray()) + { + Scan(item, $"{path}[{i}]", rules, problems, depth + 1); + i++; + } + + return; + } + + if (element.ValueKind != JsonValueKind.Object) return; + foreach (var p in element.EnumerateObject()) + { + var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}"; + var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase)); + if (field is null) + { + Scan(p.Value, fieldPath, rules, problems, depth + 1); + continue; + } + + var ok = p.Value.ValueKind switch + { + JsonValueKind.Null => true, + JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals), + _ => false, + }; + if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals)); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 084e54a7d..707a40fa7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// Driver capability for alarm events. Optional — only drivers whose backends expose /// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS /// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect -/// (CONDITION data items). +/// (CONDITION data items), AbCip (ALMD/ALMA), Heidenhain (LSV2 controller alarm list, #831). /// public interface IAlarmSource { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs new file mode 100644 index 000000000..4138e552f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential +/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy +/// InitializeAsync/ReopenAsync + ack fallback, OpcUaClient session open) passes its instance's tracker +/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of +/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass +/// and stay hard-fail. +/// +/// +/// +/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every +/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed +/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs +/// only until its next deploy (which the gate refuses until it is migrated). +/// +/// +/// Removing it (the planned hard-fail release): delete this type, the per-driver Legacy… resolve +/// methods it gates and the legacy parameters; every caller then takes the enforce branch it already +/// has. Nothing else changes. +/// +/// +/// Each use is reported through at most once per per field +/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as so the +/// driver's bag carries it to the AdminUI (/hosts and the /raw +/// credential banner). Messages name the driver, field and allowed forms — never the value. +/// +/// +/// The driver's scope (for the allowed forms). +/// The driver instance (for messages). +/// Sink for a rate-limited warning line. +/// Clock (tests); null ⇒ . +public sealed class LegacyCredentialReferenceTracker( + CredentialScope scope, string driverInstanceId, Action warn, Func? clock = null) +{ + /// The key prefix (one entry per field). + public const string DiagnosticsKeyPrefix = "credential-compat:"; + + /// Minimum spacing between two warnings for the same field. + public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1); + + private readonly object _gate = new(); + private readonly Dictionary _lastWarned = new(StringComparer.Ordinal); + private readonly SortedDictionary _diagnostics = new(StringComparer.Ordinal); + + /// The scope this tracker reports against. + public CredentialScope Scope => scope; + + /// The current legacy uses, keyed credential-compat:<field>. + public IReadOnlyDictionary Diagnostics + { + get + { + lock (_gate) return new Dictionary(_diagnostics, StringComparer.Ordinal); + } + } + + /// Records that was resolved through the legacy path (warns, rate-limited). + /// The field label. + public void Report(string field) + { + var forms = $"secret:{scope.SecretNamespaces[0]} or env:{scope.EnvVariablePrefixes[0]}"; + var now = (clock ?? (() => DateTime.UtcNow))(); + bool log; + lock (_gate) + { + _diagnostics[DiagnosticsKeyPrefix + field] = + $"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}"; + log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval; + if (log) _lastWarned[field] = now; + } + + if (log) + { + warn($"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} " + + $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only " + + $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate " + + $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\"."); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs index ea473172d..086d2a719 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs @@ -231,6 +231,17 @@ public sealed class AclScopePathResolver } } + /// + /// The RawPath of raw tag — the identifier its raw NodeId (and a native alarm on it, as + /// ConditionId) carries — or null when its chain does not resolve (Gitea #826). + /// + /// The raw tag's logical id. + /// The RawPath, or null. + public string? ResolveTagRawPath(string tagId) => + !string.IsNullOrEmpty(tagId) && _tags.TryGetValue(tagId, out var tag) + ? _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) + : null; + private IReadOnlyList ResolveTag(string tagId) { var paths = new List(); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs index e6aa7e761..dc4a03bd2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -37,7 +37,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyDictionary> grantsByPrincipal, IReadOnlyDictionary equipmentScopePaths, IReadOnlyDictionary scriptedAlarmOwners, - IReadOnlyDictionary unsFolderScopes) + IReadOnlyDictionary unsFolderScopes, + IReadOnlyDictionary> tagReferencingEquipment) { ClusterId = clusterId; RevisionHash = revisionHash; @@ -51,6 +52,7 @@ public sealed class DataPlaneAuthoritySnapshot EquipmentScopePaths = equipmentScopePaths; ScriptedAlarmOwners = scriptedAlarmOwners; UnsFolderScopes = unsFolderScopes; + TagReferencingEquipment = tagReferencingEquipment; } private readonly IReadOnlyDictionary _keysById; @@ -160,6 +162,31 @@ public sealed class DataPlaneAuthoritySnapshot /// public IReadOnlyDictionary UnsFolderScopes { get; } + /// + /// RawPath → the EquipmentIds whose UnsTagReference projects that ALARM-BEARING raw tag, as THIS generation deploys + /// them (Gitea #826). Keyed Ordinal (a RawPath is a NodeId identifier); the id sets are + /// OrdinalIgnoreCase (identifier rule). + /// + /// + /// A native alarm materialises once at its raw tag and is wired as an extra event notifier into every + /// referencing equipment's folder. The generation is installed BEFORE its address space is applied, and an + /// apply that throws keeps the old one, so a dropped reference leaves the alarm wired into an equipment folder + /// this generation no longer projects it into. The node manager admits a wired folder only while this map + /// agrees. An entry confers nothing; an absent RawPath means no equipment references it. + /// + public IReadOnlyDictionary> TagReferencingEquipment { get; } + + /// Whether this generation projects the raw tag at into + /// (Gitea #826). + /// The raw tag's RawPath (a native condition's ConditionId). + /// The equipment (its folder's s= identifier). + /// when a reference of this generation connects the two. + public bool IsTagReferencedBy(string rawPath, string equipmentId) => + !string.IsNullOrEmpty(rawPath) + && !string.IsNullOrEmpty(equipmentId) + && TagReferencingEquipment.TryGetValue(rawPath, out var equipment) + && equipment.Contains(equipmentId); + /// /// The ACL path WITHIN the UNS realm (realm segment stripped) of the UNS folder , /// provided the live folder's ancestry is exactly the one this generation @@ -279,6 +306,8 @@ public sealed class DataPlaneAuthoritySnapshot /// (see ); null ⇒ none. /// Every UNS folder's path + id chain (see ); /// null ⇒ none, which denies every UNS folder-scoped node under enforcement. + /// RawPath → EquipmentIds (see ); + /// null ⇒ none, so no native alarm reaches an equipment folder's event items under enforcement. /// The built snapshot. public static DataPlaneAuthoritySnapshot Create( string? clusterId, @@ -290,7 +319,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyList? droppedGrants = null, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { ArgumentNullException.ThrowIfNull(revisionHash); ArgumentNullException.ThrowIfNull(keys); @@ -359,9 +389,26 @@ public sealed class DataPlaneAuthoritySnapshot } } + var referencing = new Dictionary>(StringComparer.Ordinal); + if (tagReferencingEquipment is not null) + { + foreach (var (rawPath, equipmentIds) in tagReferencingEquipment) + { + if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue; + // Re-keyed under the identifier comparer, as the maps above are — but a set already built under it + // (what DeploymentArtifact produces) is adopted rather than copied a second time. + referencing[rawPath] = equipmentIds is HashSet set + && ReferenceEquals(set.Comparer, StringComparer.OrdinalIgnoreCase) + && !set.Any(string.IsNullOrWhiteSpace) + ? set + : new HashSet( + equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); + } + } + return new DataPlaneAuthoritySnapshot( clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen, - equipment, owners, folders); + equipment, owners, folders, referencing); } } @@ -446,6 +493,9 @@ public interface IDataPlaneAuthoritySink /// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement. /// Every UNS Area/Line/Equipment folder's path + id chain from the same /// artifact (Gitea #808). Null ⇒ none, which denies every UNS folder-scoped node under enforcement. + /// RawPath → EquipmentIds from the same artifact's + /// UnsTagReferences (Gitea #826). Null ⇒ none, so no native alarm reaches an equipment folder's event items + /// under enforcement. /// The installed snapshot. DataPlaneAuthoritySnapshot Apply( string? clusterId, @@ -455,7 +505,8 @@ public interface IDataPlaneAuthoritySink IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null); + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null); } /// @@ -506,7 +557,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { ArgumentNullException.ThrowIfNull(revisionHash); @@ -540,7 +592,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl published = DataPlaneAuthoritySnapshot.Create( clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths, - scriptedAlarmOwners, unsFolderScopes); + scriptedAlarmOwners, unsFolderScopes, tagReferencingEquipment); Volatile.Write(ref _current, published); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs index 56fff75ae..bca223edd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs @@ -64,9 +64,11 @@ public sealed record GalaxyDriverOptions( /// /// /// -/// must be a reference — secret:NAME (production), -/// env:NAME, file:PATH or dev:VALUE (rigs only). A bare literal is rejected so a -/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB. +/// must be a Galaxy-namespaced reference (lmxopcua#815) — +/// secret:galaxy/NAME / secret:otopcua/galaxy/NAME (production), env:OTOPCUA_GALAXY_NAME +/// / env:GALAXY_MXGW_NAME, or dev:VALUE (rigs only). A bare literal, file: and any other +/// reference are rejected so a Galaxy password never sits in cleartext in the config DB and the field can +/// never name another subsystem's secret. /// /// /// Galaxy user the fallback acknowledges as; null/empty = no fallback. @@ -84,20 +86,16 @@ public sealed record GalaxyAlarmAckOptions( /// /// Connection details for the MxAccess gateway. is -/// resolved by at InitializeAsync time. Five forms -/// supported, in priority order: +/// resolved by at InitializeAsync time. References are confined +/// to the Galaxy namespace (lmxopcua#815 — the key is sent to the authored Endpoint): /// -/// env:NAME — read from an environment variable (recommended for -/// production; the central config DB holds only the indirection, not the key). -/// file:PATH — read from an ACL'd file outside the repo. -/// secret:NAME — resolved through the shared ZB.MOM.WW.Secrets +/// secret:galaxy/NAME (or secret:otopcua/galaxy/NAME) — the shared ZB.MOM.WW.Secrets /// encrypted store; fail-closed if absent (the production path). -/// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; -/// no startup warning. -/// Anything else — treated as a literal cleartext API key for back-compat. -/// The resolver emits a Warning at startup so an operator who accidentally -/// committed a cleartext key sees it; production should migrate to env: -/// or file:. +/// env:OTOPCUA_GALAXY_NAME (or legacy env:GALAXY_MXGW_NAME) — an environment variable. +/// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; no startup warning. +/// Any other unprefixed string — a literal cleartext API key for back-compat, with a startup +/// Warning. +/// Any other secret:/env: reference, and every file: reference — refused. /// /// // PR 6.5 tuning notes: diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs index d7bca47f3..7af820c19 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs @@ -1,33 +1,24 @@ using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; /// -/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. Five -/// forms supported, evaluated in order: +/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. The reference is resolved on the node +/// and sent to the operator-authored Gateway.Endpoint, so references are confined to the Galaxy +/// namespace (lmxopcua#815, ) and the check is repeated here, at +/// resolve time, before any store or environment read: /// -/// env:NAME — reads Environment.GetEnvironmentVariable(NAME). -/// Throws when the variable is unset, so a misconfigured deployment fails -/// fast rather than silently sending an empty key. -/// file:PATH — reads UTF-8 text from PATH, trimming -/// whitespace. Lets operators stash the key in an ACL'd file outside the -/// repo (the same pattern as the legacy .local/galaxy-host-secret.txt). -/// dev:KEY — explicit cleartext literal. The dev: prefix -/// is a deliberate opt-in signal (dev box, parity rig) so the resolver -/// doesn't emit a warning; production should never use this arm. -/// secret:NAME — resolves NAME through the shared -/// ZB.MOM.WW.Secrets (the encrypted-at-rest -/// store). Fail-closed: a secret: ref whose secret is absent/tombstoned -/// throws rather than falling through to the literal arm — the production path -/// that retires the cleartext dev:/literal-in-DB model. -/// Anything else — used as the literal API key for back-compat with -/// configs that pre-date this resolver. When a logger is supplied the -/// resolver emits a startup warning so an operator who accidentally -/// committed a cleartext key sees it. +/// secret:galaxy/NAME (or ${secret:galaxy/NAME}) — resolved through the shared +/// ZB.MOM.WW.Secrets ; fail-closed when absent. +/// env:OTOPCUA_GALAXY_NAME / legacy env:GALAXY_MXGW_NAME — the process environment; +/// fail-closed when unset. +/// dev:KEY — explicit cleartext literal (dev box, parity rig); no warning. +/// Any other unprefixed string — the literal key (back-compat), with a warning when a logger is supplied. +/// A secret:/env: reference OUTSIDE the namespace, and every file: reference (it +/// read any file the node could open) — refused with . /// -/// A future PR can swap any of these arms for a different backing store without -/// changing the call site. /// /// /// Lives in the Contracts project so both the runtime GalaxyDriver and the @@ -37,80 +28,108 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; public static class GalaxySecretRef { /// - /// Resolves the supplied secret reference. The secret:NAME arm resolves - /// through and is fail-closed (throws when the secret - /// is absent). When the ref falls through to the back-compat literal arm (an - /// unprefixed cleartext API key in DriverConfig JSON) and a - /// is supplied, emits a . - /// The dev: prefix is the explicit opt-in path that doesn't warn. + /// Resolves the supplied API-key reference (see the type remarks). Fail-closed for an out-of-namespace + /// reference, a file: reference, an absent secret and an unset variable. /// /// The secret reference string to resolve. /// The shared secret resolver used by the secret: arm. /// Optional logger for warning on cleartext keys. /// Cancellation token for the async secret: resolution. + /// Environment reader (tests); null ⇒ the process environment. + /// + /// ⚠️ Deprecated #815 compatibility: the RUNNING driver passes its tracker, so a pre-#815 out-of-namespace + /// reference is resolved with the old semantics and warned about instead of failing. The AdminUI browser + /// passes null (hard-fail). See . + /// /// The resolved API-key string. public static async Task ResolveApiKeyAsync( string secretRef, ISecretResolver resolver, ILogger? logger = null, - CancellationToken ct = default) + CancellationToken ct = default, + Func? environment = null, + LegacyCredentialReferenceTracker? legacy = null) { ArgumentException.ThrowIfNullOrEmpty(secretRef); ArgumentNullException.ThrowIfNull(resolver); + var scope = DriverCredentialPolicy.GalaxyScope; + var kind = CredentialReferences.Parse(secretRef, scope, out _); + if (kind == CredentialReferenceKind.OutOfScope && legacy is not null) + { + legacy.Report("Gateway.ApiKeySecretRef"); + return await LegacyResolveApiKeyAsync(secretRef, resolver, logger, ct, environment).ConfigureAwait(false); + } + + if (kind == CredentialReferenceKind.Literal) + { + if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)) + { + // Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal. + return secretRef[CredentialReferences.DevPrefix.Length..]; + } + + // Back-compat literal arm. Warn so an operator who accidentally committed a cleartext key sees it. + logger?.LogWarning( + "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " + + "Prefer secret:galaxy/ or env:OTOPCUA_GALAXY_, or the explicit dev:KEY prefix for dev rigs — " + + "a literal key in DriverConfig JSON is stored in cleartext in the central config DB."); + return secretRef; + } + + // secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read. + return await CredentialReferences.ResolveAsync( + secretRef, + scope, + "Galaxy.Gateway.ApiKeySecretRef", + DriverCredentialPolicy.GalaxyApiKeyField.Literals, + async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false), + environment, + ct).ConfigureAwait(false); + } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim — case-insensitive env: / file: / dev: / secret:, anything else a + /// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. + /// + private static async Task LegacyResolveApiKeyAsync( + string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func? environment) + { if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) { var name = secretRef[4..]; - var value = Environment.GetEnvironmentVariable(name); + var value = (environment ?? Environment.GetEnvironmentVariable)(name); return !string.IsNullOrEmpty(value) ? value : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset."); + $"Galaxy.Gateway.ApiKeySecretRef resolves to env var '{name}', but it is unset."); } if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) { var path = secretRef[5..]; if (!File.Exists(path)) - { - throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist."); - } - var contents = File.ReadAllText(path).Trim(); + throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef points at '{path}', which doesn't exist."); + var contents = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim(); return !string.IsNullOrEmpty(contents) ? contents - : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty."); + : throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef file '{path}' is empty."); } - if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) - { - // Explicit dev opt-in — no warning, the operator deliberately chose a - // cleartext literal (dev box, parity rig). - return secretRef[4..]; - } + if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return secretRef[4..]; if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) { - // Production path: resolve the name through the shared encrypted secret store. - // Fail-closed — an absent/tombstoned secret throws rather than falling through - // to the literal arm (which would silently treat the ref string as the key). var name = secretRef["secret:".Length..]; var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); return !string.IsNullOrEmpty(value) ? value : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed)."); + $"Galaxy.Gateway.ApiKeySecretRef resolves secret '{name}', but it is absent from the store (fail-closed)."); } - // Back-compat literal arm. An unprefixed string is treated as the literal - // API key — but emit a warning so an operator who accidentally committed a - // cleartext key into DriverConfig sees it. Use the dev: prefix to suppress - // this warning when the literal is intentional. - logger?.LogWarning( - "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " + - "Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " + - "a literal key in DriverConfig JSON is stored in cleartext in the central config DB."); + logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key."); return secretRef; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 72e5b4a70..d1be1bb04 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -38,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// /// public sealed class GalaxyDriver - : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDisposable, IAsyncDisposable + : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable { + // ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815 + // out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the + // ack fallback password) with the old semantics, warning rate-limited and surfacing it on the diagnostics bag. + // The AdminUI browser and the probe never get a tracker, so they stay hard-fail. + private readonly LegacyCredentialReferenceTracker _legacyCredentials; + + /// + IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; + /// Authored tags this driver could not dial — a TagConfig with no usable /// attributeRef has no MXAccess address to bind. private readonly SkippedTagLedger _skippedTags = new(); @@ -342,6 +351,8 @@ public sealed class GalaxyDriver _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; + _legacyCredentials = new LegacyCredentialReferenceTracker( + DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message)); _hierarchySource = hierarchySource; _dataReader = dataReader; _dataWriter = dataWriter; @@ -349,7 +360,7 @@ public sealed class GalaxyDriver _alarmAcknowledger = alarmAcknowledger; _alarmFeed = alarmFeed; _ackCredentialResolver = ackCredentialResolver - ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger); + ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials); // Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags // are authored (live-browse path), leaving every translation an identity no-op. @@ -1098,7 +1109,9 @@ public sealed class GalaxyDriver } } - private async Task BuildClientOptionsAsync( + // Internal: the ONE client-options build InitializeAsync, ReopenAsync (every gateway reconnect) and the + // repository/alarm clients share — the #815 warn-only tests drive it directly to prove a reconnect re-resolves. + internal async Task BuildClientOptionsAsync( GalaxyGatewayOptions gw, CancellationToken cancellationToken) { // Resolve the API-key ref BEFORE the object initializer — the secret: arm is @@ -1108,7 +1121,7 @@ public sealed class GalaxyDriver // (GalaxySecretRef) so the runtime driver and the AdminUI browser share one // implementation; the secret: arm resolves through the shared ISecretResolver. var apiKey = await GalaxySecretRef - .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken) + .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials) .ConfigureAwait(false); return new MxGatewayClientOptions { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs index c20295d3c..6dc58a0a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.Secrets.Abstractions; @@ -20,15 +21,22 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver private readonly GalaxyAlarmAckOptions _options; private readonly ISecretResolver _secrets; private readonly ILogger _logger; + private readonly Func? _environment; + private readonly LegacyCredentialReferenceTracker? _legacy; /// Initializes a new instance of the class. /// The owning driver instance id (for messages). /// The driver's ack-credential options. /// The shared secret resolver. /// Logger; never receives a secret. + /// Environment reader (tests); null ⇒ the process environment. + /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. public GalaxyAckCredentialResolver( - string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger) + string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger, + Func? environment = null, LegacyCredentialReferenceTracker? legacy = null) { + _environment = environment; + _legacy = legacy; _driverInstanceId = driverInstanceId; _options = options ?? new GalaxyAlarmAckOptions(); _secrets = secrets ?? throw new ArgumentNullException(nameof(secrets)); @@ -142,56 +150,57 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver } /// - /// Resolves a password reference. Accepts secret:NAME, ${secret:NAME}, env:NAME, - /// file:PATH and dev:VALUE; returns null when the referenced value is absent; throws - /// for a bare literal (a cleartext password in DriverConfig). + /// Resolves the fallback password reference through the shared lmxopcua#815 resolver: secret:galaxy/NAME + /// (or secret:otopcua/galaxy/NAME, ${secret:…}), env:OTOPCUA_GALAXY_NAME / + /// env:GALAXY_MXGW_NAME, or dev:VALUE; returns null when the referenced value is absent; throws + /// — before any store/environment read — for a bare literal, a + /// file: reference, or a reference outside the Galaxy namespace (the password is sent to the + /// operator-authored gateway endpoint, so it must never be able to name another subsystem's secret). /// internal async Task ResolvePasswordRefAsync(string reference, CancellationToken ct) + { + if (_legacy is not null + && CredentialReferences.Parse(reference, DriverCredentialPolicy.GalaxyScope, out _) == CredentialReferenceKind.OutOfScope) + { + _legacy.Report("AlarmAck.FallbackPasswordSecretRef"); + return await LegacyResolvePasswordRefAsync(reference, ct).ConfigureAwait(false); + } + + var resolved = await CredentialReferences.ResolveOrNullAsync( + reference, + DriverCredentialPolicy.GalaxyScope, + "AlarmAck.FallbackPasswordSecretRef", + DriverCredentialPolicy.GalaxyAckPasswordField.Literals, + async (name, c) => await _secrets.GetAsync(new SecretName(name), c).ConfigureAwait(false), + _environment, + ct).ConfigureAwait(false); + return resolved is not null && reference.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase) + ? resolved[CredentialReferences.DevPrefix.Length..] + : resolved; + } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. + /// + private async Task LegacyResolvePasswordRefAsync(string reference, CancellationToken ct) { if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}')) - { reference = "secret:" + reference["${secret:".Length..^1]; - } - if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) - { return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false); - } - if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) - { - return Environment.GetEnvironmentVariable(reference[4..]); - } - + return (_environment ?? Environment.GetEnvironmentVariable)(reference[4..]); if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) { var path = reference[5..]; - return File.Exists(path) - ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') - : null; + return File.Exists(path) ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') : null; } - if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) - { - return reference[4..]; - } - - throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal."); + if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return reference[4..]; + throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/dev: reference, not a literal."); } // Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal. - internal static string DescribeRef(string reference) - { - var r = reference.Trim(); - if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:"; - if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) - || r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase) - || r.StartsWith("env:", StringComparison.OrdinalIgnoreCase) - || r.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - return r; - } - - return ""; - } + internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs index 6dcbe5def..4c719b996 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs @@ -30,6 +30,12 @@ public enum HeidenhainAddressKind /// A data-path value read via R_DP (DATA role). DataPath, + + /// + /// An ALARM: selector — a native Part 9 condition driven from the alarm list (R_RI 27/28), never a + /// value (lmxopcua#831). Requires the cross-driver alarm object on the tag. + /// + Alarm, } /// A parsed, validated Heidenhain tag address. @@ -39,13 +45,15 @@ public enum HeidenhainAddressKind /// The PLC area, for . /// The PLC element index (not an absolute address), for . /// The data path as authored, for . +/// The alarm selector, for . public sealed record HeidenhainAddress( HeidenhainAddressKind Kind, string Text, HeidenhainCatalogEntry? Catalog = null, HeidenhainPlcArea? Area = null, long Index = 0, - string? DataPath = null) + string? DataPath = null, + HeidenhainAlarmSelector? AlarmSelector = null) { /// Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header). public const int MaxPayload = 247; @@ -117,6 +125,14 @@ public sealed record HeidenhainAddress( return false; } + if (t.StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal)) + { + if (!HeidenhainAlarmSelector.TryParse(t[HeidenhainAlarmSelector.Prefix.Length..], out var selector, out error)) return false; + address = new HeidenhainAddress(HeidenhainAddressKind.Alarm, HeidenhainAlarmSelector.Prefix + selector.Canonical, + AlarmSelector: selector); + return true; + } + if (t.StartsWith(DataPrefix, StringComparison.Ordinal)) { var path = t[DataPrefix.Length..]; @@ -157,7 +173,7 @@ public sealed record HeidenhainAddress( return true; } - error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding."; + error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding, a DATA:/path binding or an ALARM: selector."; return false; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs new file mode 100644 index 000000000..7fb8c0823 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs @@ -0,0 +1,191 @@ +using System.Globalization; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// Which controller alarms an ALARM: address selects. +public enum HeidenhainAlarmSelectorKind +{ + /// ALARM:* — any active alarm on the tag's control (a device summary condition). + Any, + + /// + /// ALARM:OTHER — active alarms that no selector on the same control matches (the + /// "unmatched" bucket). and other tags do not claim an alarm. + /// + Other, + + /// ALARM:CLASS=7, ALARM:CLASS=7/GROUP=4, ALARM:NUMBER=100..199, … + Filter, +} + +/// An inclusive integer range; == for a single value. +/// Lowest matched value. +/// Highest matched value (inclusive). +public readonly record struct HeidenhainAlarmRange(long Low, long High) +{ + /// Whether lies in the range. + /// The value. + /// True when matched. + public bool Contains(long value) => value >= Low && value <= High; + + /// + public override string ToString() => Low == High + ? Low.ToString(CultureInfo.InvariantCulture) + : $"{Low.ToString(CultureInfo.InvariantCulture)}..{High.ToString(CultureInfo.InvariantCulture)}"; +} + +/// +/// The selector half of an ALARM: Heidenhain address (lmxopcua#831): which entries of the control's +/// active-alarm list (R_RI 27 then 28 — class, group, number, text) drive the ONE Part 9 condition +/// the raw tag is materialised as. +/// +/// +/// Grammar of the text after ALARM: (keys case-insensitive, no whitespace): +/// +/// FormMatches +/// *any active alarm on the control +/// OTHERalarms no filter selector on the control matches +/// CLASS=<r>, GROUP=<r>, NUMBER=<r>, joined by / +/// every named field in its range (each key at most once) +/// +/// A range <r> is n or lo..hi (inclusive; .. rather than - because +/// alarm numbers can be negative — a real iTNC 530 reports -1). CLASS and GROUP are the wire's uint16 +/// fields (0–65535); NUMBER is int32. +/// No semantic names. The meaning of the class/group codes is not documented in the reference +/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed: +/// observe the raw values on Alarms/Classes / Alarms/Groups or in a condition message, then filter. +/// One authority. Backs the AdminUI editor, the deploy inspector and the driver's table build. +/// +/// The selector kind. +/// The class range, or null for any class (filter only). +/// The group range, or null for any group (filter only). +/// The number range, or null for any number (filter only). +public sealed record HeidenhainAlarmSelector( + HeidenhainAlarmSelectorKind Kind, + HeidenhainAlarmRange? Class = null, + HeidenhainAlarmRange? Group = null, + HeidenhainAlarmRange? Number = null) +{ + /// Address prefix of an alarm condition. + public const string Prefix = "ALARM:"; + + /// The * body. + public const string AnyToken = "*"; + + /// The OTHER body. + public const string OtherToken = "OTHER"; + + /// The canonical text after ALARM:. + public string Canonical => Kind switch + { + HeidenhainAlarmSelectorKind.Any => AnyToken, + HeidenhainAlarmSelectorKind.Other => OtherToken, + _ => string.Join("/", new[] + { + Class is { } c ? $"CLASS={c}" : null, + Group is { } g ? $"GROUP={g}" : null, + Number is { } n ? $"NUMBER={n}" : null, + }.Where(s => s is not null)), + }; + + /// + /// Whether this selector matches one alarm on its own terms. + /// answers false here — "unmatched" depends on the other selectors on the control, which the caller evaluates. + /// + /// The alarm's class. + /// The alarm's group. + /// The alarm's number. + /// True when selected. + public bool Matches(int errorClass, int group, int number) => Kind switch + { + HeidenhainAlarmSelectorKind.Any => true, + HeidenhainAlarmSelectorKind.Other => false, + _ => (Class is not { } c || c.Contains(errorClass)) + && (Group is not { } g || g.Contains(group)) + && (Number is not { } n || n.Contains(number)), + }; + + /// Parses the text after ALARM:. Never throws. + /// The selector text. + /// The selector on success. + /// Why it was rejected. + /// True when valid. + public static bool TryParse(string? body, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAlarmSelector? selector, out string? error) + { + selector = null; + error = null; + var t = body?.Trim() ?? ""; + if (t.Length == 0) + { + error = Grammar("an alarm selector is required"); + return false; + } + + if (t == AnyToken) + { + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Any); + return true; + } + + if (string.Equals(t, OtherToken, StringComparison.OrdinalIgnoreCase)) + { + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Other); + return true; + } + + HeidenhainAlarmRange? cls = null, grp = null, num = null; + foreach (var segment in t.Split('/')) + { + var eq = segment.IndexOf('='); + if (eq <= 0) + { + error = Grammar($"'{segment}' is not KEY=VALUE"); + return false; + } + + var key = segment[..eq].ToUpperInvariant(); + var value = segment[(eq + 1)..]; + var (min, max) = key == "NUMBER" ? ((long)int.MinValue, (long)int.MaxValue) : (0L, 65535L); + if (key is not ("CLASS" or "GROUP" or "NUMBER")) + { + error = Grammar($"unknown key '{segment[..eq]}'"); + return false; + } + + if (!TryParseRange(value, min, max, out var range)) + { + error = Grammar($"{key} value '{value}' must be an integer {min}–{max} or lo..hi with lo ≤ hi"); + return false; + } + + ref var slot = ref key == "CLASS" ? ref cls : ref key == "GROUP" ? ref grp : ref num; + if (slot is not null) + { + error = Grammar($"{key} appears twice"); + return false; + } + + slot = range; + } + + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Filter, cls, grp, num); + return true; + } + + private static bool TryParseRange(string text, long min, long max, out HeidenhainAlarmRange range) + { + range = default; + var dots = text.IndexOf("..", StringComparison.Ordinal); + var loText = dots < 0 ? text : text[..dots]; + var hiText = dots < 0 ? text : text[(dots + 2)..]; + if (!long.TryParse(loText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var lo) + || !long.TryParse(hiText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var hi)) + return false; + if (lo < min || hi > max || lo > hi) return false; + range = new HeidenhainAlarmRange(lo, hi); + return true; + } + + private static string Grammar(string why) => + $"Invalid alarm selector: {why}. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=[/GROUP=][/NUMBER=|..]."; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs index 5cef4a304..e76775e47 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs @@ -13,6 +13,9 @@ public enum HeidenhainPollGroup /// Identity, tool, alarms, timers and capability strings (Polling.SlowMs). Slow, + + /// The alarm-list walk behind ALARM: Part 9 conditions (Polling.AlarmMs, lmxopcua#831). + Alarm, } /// One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it. @@ -50,6 +53,9 @@ public static class HeidenhainCatalog /// Feature id reported for the optional DNC login. public const string DncLoginFeature = "DncLogin"; + /// Feature id of the alarm-list walk (R_RI 27/28) — shared by the Alarms/* leaves and ALARM: conditions. + public const string AlarmsFeature = "Alarms"; + /// Feature id reported for the opt-in DATA login. public const string DataLoginFeature = "DataLogin"; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs index 880505ad2..852f4182a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs @@ -107,6 +107,13 @@ public sealed class HeidenhainPollingOptions /// Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms. public int SlowMs { get; init; } = 10000; + /// + /// Alarm-condition group: the alarm-list walk that drives authored ALARM: Part 9 conditions + /// (lmxopcua#831). Default 2000 ms (the FOCAS alarm-poll precedent). Only polled for a control that carries an + /// ALARM: tag; the Alarms/* typed arrays stay on . + /// + public int AlarmMs { get; init; } = 2000; + /// Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64. public int MaxAlarms { get; init; } = 64; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs index 925b4735c..708476aa5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; @@ -21,14 +22,14 @@ public static class HeidenhainOptionsParser /// exfiltration guard: the reference is resolved on the node and sent in cleartext in A_LG to a Host /// the author chose, so it must never be able to name another subsystem's secret. /// - public const string SecretNamespace = "heidenhain/"; + public static readonly string SecretNamespace = DriverCredentialPolicy.HeidenhainScope.SecretNamespaces[0]; /// /// The ONLY environment variables a Heidenhain password may name: env:OTOPCUA_HEIDENHAIN_<NAME> /// (exact upper-case prefix; NAME is [A-Z0-9_]+) — never ConnectionStrings__*, /// ServerHistorian__ApiKey or any other process variable. /// - public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_"; + public static readonly string EnvVariablePrefix = DriverCredentialPolicy.HeidenhainScope.EnvVariablePrefixes[0]; /// The password fields a Heidenhain DriverConfig/DeviceConfig may carry. public static readonly IReadOnlyList PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"]; @@ -110,7 +111,7 @@ public static class HeidenhainOptionsParser var errors = new List(); if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms."); var p = o.Polling; - foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) }) + foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs), ("AlarmMs", p.AlarmMs) }) if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms."); if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256."); var r = o.Reconnect; @@ -164,105 +165,41 @@ public static class HeidenhainOptionsParser /// True for blank or a namespaced reference. public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name) { - kind = CredentialKind.None; - name = ""; - if (string.IsNullOrEmpty(value)) return true; - if (value.StartsWith(SecretPrefix, StringComparison.Ordinal)) + // The shared lmxopcua#815 parser — one rule for every driver that resolves credential references. + switch (CredentialReferences.Parse(value, DriverCredentialPolicy.HeidenhainScope, out name)) { - var secret = value[SecretPrefix.Length..]; - if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false; - var rest = secret[SecretNamespace.Length..]; - if (rest.Length is 0 or > 200) return false; - foreach (var c in rest) - if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false; - if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false; - kind = CredentialKind.Secret; - name = secret; - return true; - } - - if (value.StartsWith(EnvPrefix, StringComparison.Ordinal)) - { - var variable = value[EnvPrefix.Length..]; - if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false; - var rest = variable[EnvVariablePrefix.Length..]; - if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_')) + case CredentialReferenceKind.Blank: + kind = CredentialKind.None; + return true; + case CredentialReferenceKind.Secret: + kind = CredentialKind.Secret; + return true; + case CredentialReferenceKind.Environment: + kind = CredentialKind.Environment; + return true; + default: + kind = CredentialKind.None; + name = ""; return false; - kind = CredentialKind.Environment; - name = variable; - return true; } - - return false; } /// /// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not - /// namespaced references — at the top level (a device / merged config), under Access, and under every - /// Devices[] entry; keys matched case-insensitively, as the parser binds them. The messages name the - /// field, never the value. Unreadable JSON is reported, since it cannot be checked. + /// namespaced references — at any depth (top level, Access, Devices[]…); keys matched + /// case-insensitively, as the parser binds them. The messages name the field, never the value. Unreadable + /// JSON is reported, since it cannot be checked. Delegates to the shared . /// /// The config JSON. /// The problems; empty when every password field is acceptable. - public static IReadOnlyList DescribeCredentialProblems(string? json) - { - var problems = new List(); - if (string.IsNullOrWhiteSpace(json)) return problems; - JsonDocument doc; - try - { - doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); - } - catch (JsonException) - { - problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked."); - return problems; - } - - using (doc) - { - if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems; - ScanObject(doc.RootElement, "", problems); - foreach (var p in doc.RootElement.EnumerateObject()) - { - if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object) - ScanObject(p.Value, "Access.", problems); - if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array) - { - var i = 0; - foreach (var d in p.Value.EnumerateArray()) - { - if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems); - i++; - } - } - } - } - - return problems; - } + public static IReadOnlyList DescribeCredentialProblems(string? json) => + DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Heidenhain, json); /// The one wording of the credential rule (never includes the value). /// The field. /// The message. public static string CredentialRuleMessage(string field) => - $"{field} must be blank, 'secret:{SecretNamespace}' or 'env:{EnvVariablePrefix}' — a literal " - + "password, or a reference outside the Heidenhain namespace, is refused."; - - private static void ScanObject(JsonElement obj, string prefix, List problems) - { - foreach (var p in obj.EnumerateObject()) - { - if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue; - var ok = p.Value.ValueKind switch - { - JsonValueKind.Null => true, - JsonValueKind.String => IsCredentialReference(p.Value.GetString()), - _ => false, - }; - if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name)); - } - } + CredentialReferences.RuleMessage(field, DriverCredentialPolicy.HeidenhainScope, CredentialLiteralPolicy.Refused); private static void CheckRef(List errors, string field, string? value) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs index a3c774701..aa2929acd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs @@ -9,15 +9,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// The parsed address. /// The OPC data type the driver publishes. /// Whether the value is a 1-D array. +/// +/// The cross-driver alarm object, present exactly when is an ALARM: selector +/// (lmxopcua#831); the tag is then a Part 9 condition, never a value. +/// public sealed record HeidenhainTagDefinition( - string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray) + string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray, + HeidenhainTagAlarm? Alarm = null) { /// The poll cadence this tag rides. - public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard - ? Address.Catalog!.Group - : HeidenhainPollGroup.Custom; + public HeidenhainPollGroup Group => Address.Kind switch + { + HeidenhainAddressKind.Standard => Address.Catalog!.Group, + HeidenhainAddressKind.Alarm => HeidenhainPollGroup.Alarm, + _ => HeidenhainPollGroup.Custom, + }; + + /// True for an ALARM: condition tag. + public bool IsAlarmCondition => Address.Kind == HeidenhainAddressKind.Alarm; } +/// The driver's view of a tag's cross-driver alarm object. +/// The authored integer alarm.severity, or null when absent / not an integer. +public sealed record HeidenhainTagAlarm(int? Severity); + /// /// The single authority on a Heidenhain raw tag's TagConfig: consumed by the driver's table build, /// the deploy-time gate () and the AdminUI typed editor's Validate(), so the @@ -29,6 +44,10 @@ public sealed record HeidenhainTagDefinition( /// {"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}. Array leaves /// (Axes/*, Alarms/Classes|Groups|Numbers|Messages) must carry "isArray": true so the /// node's rank matches what the driver publishes; every other address must not. +/// Alarm conditions (lmxopcua#831): {"address": "ALARM:CLASS=7", "dataType": "Boolean", +/// "alarm": {...}}. An ALARM: address and the alarm object only work together — either alone is +/// refused (a condition nothing can raise, or a selector with no value to read). The data type is fixed at +/// Boolean. /// Never a write flag. There is no writable key: the driver is read-only and the deploy /// gate refuses a ReadWrite access level on any Heidenhain tag. /// Data types are parsed by NAME only. A digits-only value (a numerically-serialized enum) is @@ -59,6 +78,7 @@ public static class HeidenhainTagDefinitionFactory string? dataTypeText; bool dataTypePresent; bool? isArray; + HeidenhainTagAlarm? alarm; try { using var doc = JsonDocument.Parse(tagConfig); @@ -95,6 +115,15 @@ public static class HeidenhainTagDefinitionFactory } } + alarm = null; + // Exact-case, exactly as the host's TagConfigIntent reads it: the condition exists iff the host sees it. + if (root.TryGetProperty("alarm", out var a) && a.ValueKind == JsonValueKind.Object) + { + int? severity = a.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.Number + && sev.TryGetInt32(out var sv) ? sv : null; + alarm = new HeidenhainTagAlarm(severity); + } + if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True) { error = "Heidenhain tags are read-only; remove \"writable\": true."; @@ -121,9 +150,34 @@ public static class HeidenhainTagDefinitionFactory declared = parsed; } + if (address.Kind == HeidenhainAddressKind.Alarm && alarm is null) + { + error = $"'{address.Text}' is an alarm selector — enable the tag's native alarm (the 'alarm' object); " + + "an ALARM: tag is a Part 9 condition and has no value to read."; + return false; + } + + if (address.Kind != HeidenhainAddressKind.Alarm && alarm is not null) + { + error = $"The tag carries an 'alarm' object but '{address.Text}' is not an ALARM: selector — nothing could " + + "ever raise the condition. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=[/GROUP=][/NUMBER=|..]."; + return false; + } + DriverDataType type; bool array; - if (address.Kind == HeidenhainAddressKind.Standard) + if (address.Kind == HeidenhainAddressKind.Alarm) + { + if (declared is { } d && d != AlarmConditionDataType) + { + error = $"An ALARM: condition tag is {AlarmConditionDataType}; dataType {d} does not match."; + return false; + } + + type = AlarmConditionDataType; + array = false; + } + else if (address.Kind == HeidenhainAddressKind.Standard) { var entry = address.Catalog!; if (declared is { } d && d != entry.DataType) @@ -172,10 +226,13 @@ public static class HeidenhainTagDefinitionFactory return false; } - definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array); + definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array, alarm); return true; } + /// The fixed Tag.DataType of an ALARM: condition tag (active / inactive). + public const DriverDataType AlarmConditionDataType = DriverDataType.Boolean; + /// /// Deploy-time inspection: empty when accepts the blob (never stricter), /// otherwise one warning explaining the rejection. Never throws. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs new file mode 100644 index 000000000..53e6c7c86 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs @@ -0,0 +1,287 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// An authored ALARM: raw tag the driver drives a Part 9 condition for. +/// The tag's RawPath — the ConditionId, the only key the host routes on. +/// The control (host:port) whose alarm list the tag selects from. +/// Which alarms on that control drive the condition. +/// The authored alarm.severity, or null. +internal sealed record HeidenhainAlarmTag(string RawPath, string HostName, HeidenhainAlarmSelector Selector, int? AuthoredSeverity); + +/// +/// A controller alarm's identity: class, group, number AND text. The number alone is not an identity — a real +/// iTNC 530 reports -1 for (at least) "Run test of cutout channels" — so two different texts under one +/// number are two alarms. +/// +internal readonly record struct HeidenhainAlarmKey(int Class, int Group, int Number, string Message) +{ + public static HeidenhainAlarmKey Of(Lsv2Alarm a) => new(a.ErrorClass, a.Group, a.Number, (a.Message ?? "").Trim()); +} + +/// What one condition last told the server — the diff baseline (re-subscribe / reconnect never re-raise). +internal sealed record HeidenhainToldCondition( + bool Active, + IReadOnlySet Keys, + HeidenhainAlarmKey? Winner, + string Message, + AlarmSeverity Severity, + int? OpcUaSeverity); + +/// +/// Pure selection + transition rules for Heidenhain alarm conditions (lmxopcua#831) — the FOCAS #647 semantics +/// applied to the LSV2 alarm list. +/// +/// +/// +/// Overlap is intended: one alarm drives every selector that matches it (* and +/// CLASS=7). OTHER is what no FILTER selector on the same control claims. +/// Displayed alarm is deterministic — ascending (class, group, number, text), never wire order, +/// so a reordered unchanged list cannot re-raise. +/// Transitions: inactive → active = Raise; a key not in the told set appears, or the +/// displayed alarm leaves while others remain = Retrigger (un-acknowledges: a statement the operator +/// has not seen); a non-displayed member leaves = nothing; nothing matches = Clear. +/// Severity: an authored integer alarm.severity is sent exactly (OpcUaSeverity); +/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no +/// class→severity table is invented. +/// +/// +internal static class HeidenhainAlarmRules +{ + /// The every Heidenhain condition event carries. + public const string AlarmType = "HeidenhainAlarm"; + + /// The alarms one condition matches, in display order (distinct by key). + public static IReadOnlyList Evaluate( + HeidenhainAlarmSelector selector, IReadOnlyList alarms, IReadOnlyCollection deviceSelectors) + { + var filters = selector.Kind == HeidenhainAlarmSelectorKind.Other + ? deviceSelectors.Where(s => s.Kind == HeidenhainAlarmSelectorKind.Filter).ToList() + : null; + return alarms + .Where(a => filters is null + ? selector.Matches(a.ErrorClass, a.Group, a.Number) + : !filters.Any(f => f.Matches(a.ErrorClass, a.Group, a.Number))) + .Select(HeidenhainAlarmKey.Of) + .Distinct() + .OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number) + .ThenBy(k => k.Message, StringComparer.Ordinal) + .ToList(); + } + + /// The event moving the server from to , and the new record. + public static (AlarmEventArgs? Event, HeidenhainToldCondition Told) Transition( + HeidenhainToldCondition? told, IReadOnlyList matched, HeidenhainAlarmTag tag, + IAlarmSubscriptionHandle handle, DateTime nowUtc) + { + var keys = matched.ToHashSet(); + if (matched.Count == 0) + { + if (told is not { Active: true }) + return (null, told ?? new HeidenhainToldCondition(false, keys, null, "", AlarmSeverity.Low, null)); + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}", told.Severity, told.OpcUaSeverity), + told with { Active = false, Keys = keys, Winner = null }); + } + + var winner = matched[0]; + var (bucket, exact) = tag.AuthoredSeverity is { } s ? (BucketOf(s), (int?)s) : (AlarmSeverity.High, null); + var message = MessageOf(matched); + var raised = new HeidenhainToldCondition(true, keys, winner, message, bucket, exact); + if (told is not { Active: true }) + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised); + if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner) + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised); + return (null, told with { Keys = keys }); + } + + /// The nearest bucket of an exact 1..1000 severity. + public static AlarmSeverity BucketOf(int severity) => severity switch + { + < 400 => AlarmSeverity.Low, + < 600 => AlarmSeverity.Medium, + < 800 => AlarmSeverity.High, + _ => AlarmSeverity.Critical, + }; + + /// -1 Run test of cutout channels (class 7, group 4) [+2 more]. + public static string MessageOf(IReadOnlyList matched) + { + var w = matched[0]; + var b = new StringBuilder(); + b.Append(w.Number); + if (w.Message.Length > 0) b.Append(' ').Append(w.Message); + b.Append(" (class ").Append(w.Class).Append(", group ").Append(w.Group).Append(')'); + if (matched.Count > 1) b.Append(" [+").Append(matched.Count - 1).Append(" more]"); + return b.ToString(); + } + + public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc, + AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) => + new(handle, + SourceNodeId: tag.HostName, + ConditionId: tag.RawPath, + AlarmType: AlarmType, + Message: message, + Severity: bucket, + SourceTimestampUtc: nowUtc, + Kind: kind, + OpcUaSeverity: exact, + QualityStatusCode: quality); +} + +/// +/// Drives one Part 9 condition per subscribed ALARM: raw tag from the alarm walks the device loops already +/// perform (lmxopcua#831). There is NO extra connection and NO extra request type: the walk is the same +/// allowlisted R_RI 27/28 the Alarms/* arrays use, issued by the control's single acquisition loop on +/// the Polling.AlarmMs cadence. +/// +/// +/// Lives as long as the driver (not re-created on re-initialise), so the told record survives a +/// reconnect, a same-set re-subscribe and an in-place re-init — re-raising there would un-acknowledge. A changed +/// subscribed set prunes only the RawPaths that left it. +/// A failed walk is not "no alarms". A refusal, a list overflow, a decode error or comms loss keeps +/// every condition's Active/Acked state and annotates its quality once (quality-only event, no /alerts +/// row); the next good walk lifts it to Good before diffing. Only a down session degrades driver health (the +/// runtime's rule) — a refusal never does, or the #527 watchdog would reconnect-loop a production control. +/// Read-only acknowledgement. LSV2 has no acknowledge request in the allowlist, and none is added: +/// is the no-op success the host routes a Part 9 Acknowledge to +/// (the FOCAS / MTConnect posture) — the ack is applied server-side only; the control clears an alarm when its +/// cause is reset. Confirm / shelve / enable are refused by the host for every native condition. +/// Recorded only while a handler is attached (the host detaches while it reconnects), so the re-subscribe +/// that follows still sees the difference. Delivery runs under ; the only production handler +/// is DriverInstanceActor's non-blocking Tell. +/// +internal sealed class HeidenhainAlarmProjection(object sender, Func?> handlers, ILogger logger) +{ + private readonly Lock _lock = new(); + private readonly Dictionary _told = new(StringComparer.Ordinal); + private readonly Dictionary _qualityTold = new(StringComparer.Ordinal); + private readonly Dictionary _failing = new(StringComparer.Ordinal); + private IReadOnlyDictionary _tags = new Dictionary(); + private Subscription? _current; + private long _nextId; + + /// The authored alarm tags (RawPath → tag). Swapped whole on every table build. + public IReadOnlyDictionary Tags + { + get => Volatile.Read(ref _tags); + set => Volatile.Write(ref _tags, value); + } + + /// The live subscription (test seam). + internal Subscription? Current => Volatile.Read(ref _current); + + public IAlarmSubscriptionHandle Subscribe(IReadOnlyList sourceNodeIds) + { + ArgumentNullException.ThrowIfNull(sourceNodeIds); + var refs = sourceNodeIds.Where(r => !string.IsNullOrWhiteSpace(r)).ToHashSet(StringComparer.Ordinal); + var sub = new Subscription(new HeidenhainAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs); + lock (_lock) + { + foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone); + // A (re)subscribe follows a host-side reconnect, where the host re-annotates its conditions; forget which + // ones this projection marked, so a still-failing control re-asserts its quality on the next walk. + _qualityTold.Clear(); + Volatile.Write(ref _current, sub); + } + + return sub.Handle; + } + + public void Unsubscribe(IAlarmSubscriptionHandle handle) + { + lock (_lock) + { + if (Volatile.Read(ref _current) is { } live && ReferenceEquals(live.Handle, handle)) Volatile.Write(ref _current, null); + } + } + + /// One alarm-walk outcome of (Good + list, or a non-Good status). + public void OnWalk(string hostName, uint status, IReadOnlyList? alarms) + { + var now = DateTime.UtcNow; + var tags = Tags; + lock (_lock) + { + if (Volatile.Read(ref _current) is not { } sub) return; + var deviceTags = tags.Values.Where(t => t.HostName == hostName).ToList(); + var subscribed = deviceTags.Where(t => sub.References.Contains(t.RawPath)).ToList(); + if (subscribed.Count == 0) return; + + if (status != HeidenhainStatus.Good || alarms is null) + { + if (!_failing.TryGetValue(hostName, out var was) || was != status) + { + _failing[hostName] = status; + logger.LogWarning( + "Heidenhain {Host}: alarm list unavailable ({Status}); its {Count} alarm condition(s) keep their state and are marked with that quality", + hostName, HeidenhainStatus.State(status), subscribed.Count); + } + } + else if (_failing.Remove(hostName)) + { + logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName); + } + + var h = handlers(); + if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again + + if (status != HeidenhainStatus.Good || alarms is null) + { + foreach (var tag in subscribed) + { + if (_qualityTold.TryGetValue(tag.RawPath, out var q) && q == status) continue; + _qualityTold[tag.RawPath] = status; + Deliver(h, QualityOnly(tag, sub.Handle, status, now)); + } + + return; + } + + var selectors = deviceTags.Select(t => t.Selector).ToList(); + foreach (var tag in subscribed) + { + // Lift a non-Good this projection put there BEFORE the diff, so recovery + a change reads Good then the transition. + if (_qualityTold.Remove(tag.RawPath)) Deliver(h, QualityOnly(tag, sub.Handle, HeidenhainStatus.Good, now)); + _told.TryGetValue(tag.RawPath, out var told); + var (evt, nowTold) = HeidenhainAlarmRules.Transition( + told, HeidenhainAlarmRules.Evaluate(tag.Selector, alarms, selectors), tag, sub.Handle, now); + _told[tag.RawPath] = nowTold; + if (evt is not null) Deliver(h, evt); + } + } + } + + private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now) + { + _told.TryGetValue(tag.RawPath, out var told); + return HeidenhainAlarmRules.Build(tag, handle, now, AlarmTransitionKind.Unspecified, told?.Message ?? "", + told?.Severity ?? AlarmSeverity.Low, told?.OpcUaSeverity, quality); + } + + private void Deliver(EventHandler h, AlarmEventArgs evt) + { + foreach (var registration in h.GetInvocationList()) + { + try { ((EventHandler)registration).Invoke(sender, evt); } + catch (Exception ex) + { + logger.LogWarning(ex, "Heidenhain alarm subscriber threw for {ConditionId}; delivery continues", evt.ConditionId); + } + } + } + + /// The live subscription: its handle and the ConditionIds (RawPaths) it names. + internal sealed record Subscription(HeidenhainAlarmSubscriptionHandle Handle, IReadOnlySet References); +} + +/// Handle returned by . +/// The driver-local subscription number. +public sealed record HeidenhainAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + /// + public string DiagnosticId => $"heidenhain-alarm-sub-{Id}"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs index f441dfcb1..a83518677 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; @@ -31,26 +32,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, FuncResolves one reference. + /// Resolves one reference through the shared lmxopcua#815 resolver (scope re-checked before any read). public async ValueTask ResolveReferenceAsync(string? reference, string role, CancellationToken ct) { - if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name)) - throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)"); - switch (kind) + try { - case HeidenhainOptionsParser.CredentialKind.None: - return ""; - case HeidenhainOptionsParser.CredentialKind.Environment: - return _environment(name) is { Length: > 0 } value - ? value - : throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed)."); - default: - if (resolver is null) - throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed)."); - var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); - return !string.IsNullOrEmpty(resolved) - ? resolved - : throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed)."); + return await CredentialReferences.ResolveAsync( + reference, + DriverCredentialPolicy.HeidenhainScope, + $"{role} password", + CredentialLiteralPolicy.Refused, + resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false), + _environment, + ct).ConfigureAwait(false); + } + catch (CredentialReferenceException ex) + { + throw new HeidenhainCredentialException(ex.Message); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs index cdfe9dbed..4950300db 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs @@ -26,6 +26,15 @@ public enum HeidenhainConnectionState /// Builds the client for an endpoint (test seam). internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout); +/// +/// The alarm-walk outcome for a control's ALARM: conditions (lmxopcua#831): Good +/// with the complete list, or a non-Good status (refusal, overflow, comms loss) with null — never "no alarms". +/// +internal delegate void HeidenhainAlarmSink(HeidenhainDeviceRuntime runtime, uint status, IReadOnlyList? alarms); + +/// A session's controller identity, reported once per established session (lmxopcua#831 rediscovery). +internal delegate void HeidenhainIdentitySink(HeidenhainDeviceRuntime runtime, Lsv2Versions identity); + /// /// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction /// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache @@ -54,6 +63,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private readonly Lsv2ClientFactory _clientFactory; private readonly TimeProvider _time; private readonly ILogger _logger; + private readonly HeidenhainAlarmSink? _alarms; + private readonly HeidenhainIdentitySink? _identity; + private readonly IReadOnlyList _alarmTags; private readonly object _publication = new(); private readonly object _lifecycle = new(); private readonly Dictionary _groupGood = new(); @@ -81,7 +93,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable Action stateChanged, Lsv2ClientFactory clientFactory, TimeProvider time, - ILogger logger) + ILogger logger, + HeidenhainAlarmSink? alarms = null, + HeidenhainIdentitySink? identity = null) { _device = device; _options = HeidenhainOptionsParser.Normalize(options); @@ -92,6 +106,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable _clientFactory = clientFactory; _time = time; _logger = logger; + _alarms = alarms; + _identity = identity; + _alarmTags = tags.Where(t => t.IsAlarmCondition).ToList(); _stateChangedUtc = time.GetUtcNow().UtcDateTime; _groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } @@ -220,6 +237,12 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable SetState(HeidenhainConnectionState.Connected, null); _logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model); + if (session.Identity is { } identity && _identity is not null) + { + try { _identity(this, identity); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} identity handler threw", HostName); } + } + await PollAsync(session, generation, ct).ConfigureAwait(false); // Poll returns only on session loss. Retire(generation, "LSV2 communication lost"); @@ -254,6 +277,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable [HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)), [HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)), [HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)), + [HeidenhainPollGroup.Alarm] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.AlarmMs)), }; var start = _time.GetTimestamp(); var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero); @@ -267,7 +291,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable while (!ct.IsCancellationRequested) { var elapsed = _time.GetElapsedTime(start); - foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow }) + foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Alarm, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow }) { if (!due.TryGetValue(group, out var at) || at > elapsed) continue; var tags = _groups[group]; @@ -291,9 +315,19 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable if (generation != _generation || !ReferenceEquals(_session, session)) return false; if (session.CommunicationLost) return false; var good = true; + var alarmsDelivered = false; for (var i = 0; i < tags.Count; i++) { var s = samples[i]; + if (tags[i].IsAlarmCondition) + { + // One alarm walk per batch serves every ALARM: condition of this control (they share the outcome). + if (!alarmsDelivered) DeliverAlarms(s.Status, s.Status == HeidenhainStatus.Good ? s.Value as IReadOnlyList : null); + alarmsDelivered = true; + if (s.Status != HeidenhainStatus.Good) good = false; + continue; + } + _publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now)); if (tags[i].Address.Catalog?.IsCapability == true) continue; // Explicitly disabled optional timers are not partial data. @@ -341,14 +375,25 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private void Invalidate(DateTime now) { + if (_alarmTags.Count > 0) DeliverAlarms(HeidenhainStatus.BadNoCommunication, null); foreach (var tag in _tags) { + if (tag.IsAlarmCondition) continue; _publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true ? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now) : new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now)); } } + /// Hands an alarm-walk outcome to the projection. Called under (ordered with the generation). + private void DeliverAlarms(uint status, IReadOnlyList? alarms) + { + if (_alarms is null) return; + if (status == HeidenhainStatus.Good && alarms is null) status = HeidenhainStatus.BadDecodingError; + try { _alarms(this, status, status == HeidenhainStatus.Good ? alarms : null); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} alarm projection threw", HostName); } + } + private void SetState(HeidenhainConnectionState next, string? error) { HeidenhainConnectionState old; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index 65e9ce831..8f5980ac8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// dedup (subscriptions dedup on value AND status). /// public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, - IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable + IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAlarmSource, IRediscoverable, IAsyncDisposable { /// Most custom (PLC/DATA) bindings one control may carry (reference limit). public const int MaxCustomBindingsPerDevice = 256; @@ -45,6 +45,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi private readonly object _publishLock = new(); private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _subscriptions = new(); + + /// + /// The ALARM: condition projection (lmxopcua#831). Lives as long as the driver — NOT re-created per + /// initialise — so its told record survives a reconnect and an in-place re-init (re-raising would un-ack). + /// + private readonly HeidenhainAlarmProjection _alarms; + + /// + /// Last controller identity seen per control (host:port). Driver-lifetime, so a re-initialise does not + /// forget the baseline; an identity that differs on a later session raises . + /// + private readonly ConcurrentDictionary _identities = new(StringComparer.Ordinal); private HeidenhainDriverOptions _options; private IReadOnlyDictionary _byRawPath = new Dictionary(); private IReadOnlyList _devices = []; @@ -75,6 +87,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi _credentials = credentials; _clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t)); _time = time ?? TimeProvider.System; + _alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger); } /// @@ -89,6 +102,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi /// public event EventHandler? OnHostStatusChanged; + /// + public event EventHandler? OnAlarmEvent; + + /// + /// + /// Raised (advisory — the host shows a "re-browse" chip; nothing is re-materialised) when a control reconnects + /// reporting a different identity (R_VR: model, NC/PLC software version or option string) than the + /// previous session — a software update or option install can change what the control serves (selectors, + /// PLC layout). The first session of a control only records the baseline. No extra traffic: the identity is + /// the R_VR every session already reads at connect. + /// + public event EventHandler? OnRediscoveryNeeded; + /// Parses + validates a config document (the one authority the factory and the driver share). /// The config JSON. /// The options. @@ -138,6 +164,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var devices = _options.Devices ?? []; _skipped.BeginRebuild(_options.RawTags.Count); var perDevice = new Dictionary>(); + var alarmTags = new Dictionary(StringComparer.Ordinal); foreach (var d in devices) perDevice[d] = []; foreach (var raw in _options.RawTags) @@ -156,8 +183,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi } var list = perDevice[device]; - if (def.Address.Kind != HeidenhainAddressKind.Standard - && list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice) + if (def.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath + && list.Count(t => t.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath) >= MaxCustomBindingsPerDevice) { _skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control"); continue; @@ -187,17 +214,27 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var runtime = new HeidenhainDeviceRuntime( dev, _options, tags, (role, ct) => _credentials.ResolveAsync(_options, dev, role, ct), - Publish, OnRuntimeState, _clientFactory, _time, _logger); + Publish, OnRuntimeState, _clientFactory, _time, _logger, + (r, status, alarms) => _alarms.OnWalk(r.HostName, status, alarms), + OnIdentity); runtimes.Add(runtime); foreach (var t in tags) { byPath[t.RawPath] = runtime; + if (t.IsAlarmCondition) + { + // A condition, never a value: it never enters the value cache or a data subscription. + alarmTags[t.RawPath] = new HeidenhainAlarmTag(t.RawPath, runtime.HostName, t.Address.AlarmSelector!, t.Alarm?.Severity); + continue; + } + _cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now); } } _devices = runtimes; _byRawPath = byPath; + _alarms.Tags = alarmTags; } private static HeidenhainDeviceOptions? Route(IEnumerable devices, string deviceName) @@ -270,7 +307,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi for (var i = 0; i < fullReferences.Count; i++) result[i] = _cache.TryGetValue(fullReferences[i], out var s) ? s - : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); + : new DataValueSnapshot(null, Unknown(fullReferences[i]), null, now); return Task.FromResult>(result); } @@ -288,7 +325,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var now = _time.GetUtcNow().UtcDateTime; foreach (var r in sub.References) { - var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); + var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, Unknown(r), null, now); Raise(sub, r, snapshot); } } @@ -297,6 +334,10 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.FromResult(sub); } + /// Status for a reference with no cached value: an ALARM: condition has no value to read. + private uint Unknown(string reference) => + _alarms.Tags.ContainsKey(reference) ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.BadNodeIdUnknown; + /// public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { @@ -411,6 +452,60 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.CompletedTask; } + // ---- IAlarmSource (lmxopcua#831) ---- + + /// + /// + /// are the RawPaths of the alarm-bearing raw tags (the host's alarm set). + /// Collapses to ONE live subscription (the owning actor re-subscribes on every Connected entry). Starts the + /// control loops when nothing else had (an alarm-only driver); the first walk follows within + /// Polling.AlarmMs. + /// + public Task SubscribeAlarmsAsync(IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + var handle = _alarms.Subscribe(sourceNodeIds); + EnsureStarted(); + return Task.FromResult(handle); + } + + /// + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(handle); + _alarms.Unsubscribe(handle); + return Task.CompletedTask; + } + + /// + /// + /// Local-only. LSV2 has no acknowledge request, and the read-only allowlist () + /// is deliberately NOT widened for one: nothing is sent to the control. This is the no-op success the host routes + /// a Part 9 Acknowledge / AddComment to, so the ack is applied (and replicated) server-side — the FOCAS and + /// MTConnect posture. The control clears an alarm itself when its cause is reset. + /// + public Task AcknowledgeAsync(IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(acknowledgements); + return Task.CompletedTask; + } + + /// The alarm projection (test seam). + internal HeidenhainAlarmProjection AlarmProjection => _alarms; + + private void OnIdentity(HeidenhainDeviceRuntime runtime, Lsv2Versions identity) + { + var previous = _identities.GetValueOrDefault(runtime.HostName); + _identities[runtime.HostName] = identity; + if (previous is null || previous == identity) return; + var reason = $"Heidenhain control {runtime.HostName} reconnected with a different identity " + + $"({Describe(previous)} → {Describe(identity)}); re-browse the device and review its tags."; + _logger.LogWarning("{Reason}", reason); + try { OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, null)); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain rediscovery handler threw"); } + + static string Describe(Lsv2Versions v) => $"{v.Model} NC {v.Nc} PLC {v.Plc} options '{v.Options}'"; + } + /// public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs index 46b255b0d..0c2b831ed 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs @@ -187,9 +187,20 @@ internal sealed class HeidenhainSession : IAsyncDisposable return result; } - private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard - ? tag.Address.Catalog!.Feature - : "Custom|" + tag.Address.Text + "|" + tag.DataType; + private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind switch + { + HeidenhainAddressKind.Standard => tag.Address.Catalog!.Feature, + HeidenhainAddressKind.Alarm => HeidenhainCatalog.AlarmsFeature, + _ => "Custom|" + tag.Address.Text + "|" + tag.DataType, + }; + + /// True for a PLC/DATA binding (as opposed to a catalog feature or the alarm walk). + private static bool IsCustom(HeidenhainTagDefinition tag) => + tag.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath; + + /// The catalog feature a non-custom tag is acquired through. + private static string FeatureOf(HeidenhainTagDefinition tag) => + tag.Address.Kind == HeidenhainAddressKind.Alarm ? HeidenhainCatalog.AlarmsFeature : tag.Address.Catalog!.Feature; /// The current capability string for a feature. public string Capability(string feature) @@ -223,8 +234,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable private async Task AcquireAsync(HeidenhainTagDefinition tag, Dictionary wire, CancellationToken ct) { if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication); - var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard; - var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature; + var isCustom = IsCustom(tag); + var feature = isCustom ? tag.Address.Text : FeatureOf(tag); var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature; if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus); try @@ -251,7 +262,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable "Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)), "Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)), "Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)), - "Alarms" => await AlarmsAsync(ct).ConfigureAwait(false), + HeidenhainCatalog.AlarmsFeature => await AlarmsAsync(ct).ConfigureAwait(false), _ => await TimerAsync(feature, ct).ConfigureAwait(false), }; } @@ -419,6 +430,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable private static object? Leaf(HeidenhainTagDefinition tag, object? value) { + // An ALARM: condition receives the whole (immutable copy of the) list; the projection evaluates it. + if (tag.IsAlarmCondition) return value is List alarms ? alarms.ToArray() : null; if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value; var address = tag.Address.Catalog!.Address; var name = address[(address.IndexOf('/') + 1)..]; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs index ac02c2077..e3fd86a6f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs @@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture); /// - /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). + /// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value over the Good numeric samples /// (DOUBLE, NULL when none — Part 13 / the SDK), then 10 unc_min, 11 unc_max over the Uncertain ones (the /// Part 13 Minimum/Maximum/Range status rule) and 12 good_non_numeric_count. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs index 172561b0f..eb640d0e3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs @@ -434,7 +434,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource /// public bool SupportsReverseEvents => true; - // The seventeen AggregateFamilies.Served - shared with the other SQL backend. + // The nineteen AggregateFamilies.Served - shared with the other SQL backend. /// public IReadOnlySet SupportedAggregates => AggregateFamilies.Served; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs index cac2cc02e..e83e63136 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs @@ -16,7 +16,8 @@ public enum AggregateFamily ActualTime, /// - /// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based PercentGood / PercentBad: + /// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based DurationGood / DurationBad / + /// PercentGood / PercentBad: /// Part 13 time-weighted, with bounding values. /// The backend streams the window's samples plus the bounding rows and /// evaluates them (identically for every backend). @@ -38,12 +39,13 @@ public static class AggregateFamilies HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => AggregateFamily.ActualTime, HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total or HistoryAggregateType.Total2 or HistoryAggregateType.Interpolative - or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => AggregateFamily.TimeWeighted, + or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad + or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad => AggregateFamily.TimeWeighted, _ => null, }; /// - /// The seventeen aggregates the SQL backends serve — every member assigns a family, derived + /// The nineteen aggregates the SQL backends serve — every member assigns a family, derived /// from it so the set and the dispatch can never disagree. Both SqlServerHistorianDataSource and /// QuestDbHistorianDataSource return it as . /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs index ab6a81c7a..a474a8ffe 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs @@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// /// The OPC UA Part 13 time-weighted aggregates (): TimeAverage, -/// TimeAverage2, Total, Total2, Interpolative and the duration-based PercentGood / -/// PercentBad, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results +/// TimeAverage2, Total, Total2, Interpolative and the duration-based DurationGood / +/// DurationBad / PercentGood / PercentBad, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results /// by construction. /// /// @@ -50,6 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// GetSimpleBound); time before the first sample is Bad; at the end of the data the last region runs to the /// end of its own interval (Part 13; the SDK gives it 1 ms) and later intervals are BadNoData; an interval /// ending before the first sample ⇒ BadNoData. Status Good; any value kind. +/// DurationGood / DurationBadThe same Good / Bad region time, in milliseconds (an +/// OPC UA Duration, as the SDK reports it) — PercentGood / PercentBad × interval ÷ 100, with the same +/// BadNoData, status and Partial rules (#820). /// InterpolativeThe interpolated-bounding signal's value at each interval start — /// for a reverse read, at End − i·interval (Part 13 anchors a reverse read at its later time), emitted /// latest first. @@ -78,7 +81,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// interval the data starts inside in the read direction (forward: the first sample fed lies strictly within it; /// reverse: the latest sample fed lies strictly within it, nothing after — the SDK's m_startOfData, /// keyed on the first value it is fed); and, forward, the end-of-data interval of TimeAverage2 / Total2 / -/// PercentGood / PercentBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total). +/// PercentGood / PercentBad / DurationGood / DurationBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total). /// The timestamp is the interval's (its start forward, its later edge /// reverse); the value is a (Boolean = 0/1, Int64 as double). /// @@ -358,14 +361,22 @@ public sealed class TimeWeightedAggregator ? Calc(_rawArea[i] / _usableTicks[i], status, endOfData) : Calc(_rawArea[i] / TicksPerSecond, status, endOfData); } - case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad: + case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad + or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad: { // No data in or around the interval (it ends before the first sample — the SDK's out-of-range test — or - // starts after the end of the data) ⇒ BadNoData; otherwise the share of the interval's duration. + // starts after the end of the data) ⇒ BadNoData; otherwise the region time (Duration*, in milliseconds — + // an OPC UA Duration) or its share of the interval's duration (Percent*). Uncertain regions count toward + // neither (Part 13 §5.4.3.31-34, Mantis 0006166). if (_firstSampleUtc is not { } first || hi < first.Ticks || (_dataEndTicks is { } de2 && lo > de2)) return AggregateEvaluator.NoData(stamp, now); - var ticks = _aggregate == HistoryAggregateType.PercentGood ? _goodRegionTicks[i] : _badRegionTicks[i]; - return Calc(100d * ticks / duration, HistorianStatusCodes.Good, endOfData); + var ticks = _aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood + ? _goodRegionTicks[i] + : _badRegionTicks[i]; + var value = _aggregate is HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad + ? (double)ticks / TimeSpan.TicksPerMillisecond + : 100d * ticks / duration; + return Calc(value, HistorianStatusCodes.Good, endOfData); } default: throw new HistoryAggregateNotSupportedException(_aggregate); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs index 5843d882b..590e0f8ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs @@ -47,7 +47,7 @@ internal static class SqlAggregateQueries private const string NonNumericKinds = "4, 5"; /// - /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). + /// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue over the Good numeric samples (float, /// NULL when none — Part 13 and the SDK compute these over Good values only), then 10 UncMin, 11 UncMax /// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, ), and diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index 9dd0d75c3..3375ddd30 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -359,7 +359,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource /// public bool SupportsReverseEvents => true; - // The seventeen — shared with the other SQL backend. + // The nineteen — shared with the other SQL backend. /// public IReadOnlySet SupportedAggregates => AggregateFamilies.Served; @@ -463,7 +463,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource // bucket start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒ // earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest // sample lies strictly inside (forward) or its latest sample lies in (reverse: strictly inside). - // PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's + // DurationGood / DurationBad / PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's // data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's // first or last sample is not usable (see ). // @@ -475,7 +475,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource // , which fills every interval (an empty one between bounds gets a value). // // - // The aggregate is not one of the seventeen served. + // The aggregate is not one of the nineteen served. // The window holds more than intervals. /// public async Task ReadProcessedAsync( diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index a815a79ae..cb318298c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -35,6 +35,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser private readonly ILogger _logger; private readonly ISecretResolver? _secretResolver; + private readonly string? _pkiRootOverride; /// Creates a new browser. Logger defaults to NullLogger when not supplied. /// Optional logger; defaults to . @@ -49,6 +50,18 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser _secretResolver = secretResolver; } + /// + /// Test seam (Gitea #818): a browser whose PKI lives under instead of the shared + /// %LocalAppData%/OtOpcUa/adminui-browse-pki, so an integration test can trust a fixture's public + /// certificate in a throwaway store without touching the operator's browse trust list. + /// + /// Directory holding the own/trusted/issuers/rejected stores. + internal OpcUaClientDriverBrowser(string pkiRoot) : this() + { + ArgumentException.ThrowIfNullOrWhiteSpace(pkiRoot); + _pkiRootOverride = pkiRoot; + } + /// // Sourced from the shared DriverTypeNames set rather than the hand-written literal // this line used to carry. The value is unchanged; the constant is what @@ -82,7 +95,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser "driver's PKI store instead.", endpoint); - var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); + var app = await BuildBrowseAppConfigurationAsync(_pkiRootOverride, cancellationToken).ConfigureAwait(false); ownedApplication = app; var appConfig = app.ApplicationConfiguration!; @@ -101,6 +114,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser appConfig, configuredEndpoint, updateBeforeConnect: false, + // Gitea #816: the same host-name policy as the deployed driver (the SDK's overload without this + // argument hard-codes false), checked against the configured host the endpoint was rewritten to — + // and, like the driver, never on a None endpoint. + checkDomain: OpcUaEndpointSelection.CheckDomainFor( + endpointDesc, opts.CertificateValidation.EffectiveValidateServerHostName), sessionName: "OtOpcUa AdminUI Browse", (uint)opts.SessionTimeout.TotalMilliseconds, identity, @@ -143,8 +161,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser /// the runtime driver (%LocalAppData%/OtOpcUa/adminui-browse-pki/). Browse /// trust decisions don't leak into the deployed driver's cert store. /// + /// Test-only PKI root; null = the shared browse PKI. /// Cancellation token for the configuration build. - private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) + private static async Task BuildBrowseAppConfigurationAsync( + string? pkiRootOverride, CancellationToken ct) { // The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK // internals off any logging provider. @@ -154,7 +174,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser ApplicationType = ApplicationType.Client, }; - var pkiRoot = Path.Combine( + var pkiRoot = pkiRootOverride ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "adminui-browse-pki"); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj index 94fd7e3bc..6e73ee159 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj @@ -19,5 +19,6 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs index e08683626..df2c4ca34 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs @@ -231,6 +231,38 @@ public sealed record OpcUaCertificateValidationOptions /// operator-managed CRL set. null/empty (default) = %LocalAppData%/OtOpcUa/pki/issuers. /// public string? IssuerStorePath { get; init; } + + /// + /// The value an unset resolves to. false keeps the behaviour every + /// deployment had before Gitea #816 (the SDK never checked the host name for this driver family). + /// + public const bool DefaultValidateServerHostName = false; + + /// + /// Gitea #816 — whether the server certificate must list the host the operator CONFIGURED (the endpoint URL's + /// host, which the shared keeps in place of the server-advertised one) + /// among its DNS / IP subject-alternative names. A mismatch refuses the session with + /// BadCertificateHostNameInvalid. + /// + /// null (default) = (off). + /// true = validate. Worth it when trust comes from a CA (a trusted issuer vouches for every + /// certificate it signed, so only the host check ties the certificate to THIS server); adds little when the + /// server's own self-signed certificate is pinned in the trusted-peer store. + /// false = explicit opt-out — the NAT / port-publish / IP-addressed case, where the server's + /// certificate names its private host and the reachable address is something else. + /// + /// Only applies to Sign / SignAndEncrypt endpoints: the driver and browser never pass the SDK's + /// checkDomain for a None endpoint, because SDK 2.0.1 would otherwise check at channel creation + /// against the GetEndpoints certificate even there. + /// accepts a host-name mismatch like any other certificate error. A zone-qualified IPv6 host + /// ([fe80::1%eth0]) never matches (the SDK compares the host WITH its zone against the certificate's + /// zone-less IP SANs), so a link-local endpoint needs false. + /// + public bool? ValidateServerHostName { get; init; } + + /// with the default applied. + [System.Text.Json.Serialization.JsonIgnore] + public bool EffectiveValidateServerHostName => ValidateServerHostName ?? DefaultValidateServerHostName; } /// OPC UA message security mode. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs index c15217a60..68f98169d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs @@ -46,6 +46,33 @@ public static class OpcUaEndpointUrlRewriter return advertisedUrl; var builder = new UriBuilder(advertised) { Host = configured.DnsSafeHost, Port = port }; // AbsoluteUri, not ToString(): ToString() un-escapes, turning an advertised "/x%20y" into "/x y" (#812). - return builder.Uri.AbsoluteUri; + return RestoreZoneIndex(builder.Uri, configured); + } + + /// + /// Gitea #819 — keep an IPv6 zone index ([fe80::1%eth0]) from the configured host. .NET's + /// parses the zone (only / carry it) but drops it from + /// , , and ToString(), so + /// any re-serialised URL loses it. The SDK dials new Uri(EndpointUrl).IdnHost, which DOES carry the zone + /// — so the zone is spliced back into the string, verbatim as the operator wrote it (%eth0, %4, or + /// an RFC 6874 %25eth0: not normalised, so the session dials exactly what GetEndpoints just reached). + /// Without it a link-local endpoint is unroutable: the session would dial a zone-less fe80:: address. + /// + /// The rewritten URL (configured host, zone already lost). + /// The operator-configured URL. + /// 's absolute URI with the configured zone index restored. + private static string RestoreZoneIndex(Uri rebuilt, Uri configured) + { + var absolute = rebuilt.AbsoluteUri; + if (configured.HostNameType != UriHostNameType.IPv6) return absolute; + var zoned = configured.DnsSafeHost; // "fe80::1%eth0" — the only accessor that keeps it + if (zoned.IndexOf('%') < 0) return absolute; + + var bracketed = rebuilt.Host; // "[fe80::1]" + var authorityStart = absolute.IndexOf("://", StringComparison.Ordinal); + if (authorityStart < 0) return absolute; + var hostAt = absolute.IndexOf(bracketed, authorityStart + 3, StringComparison.OrdinalIgnoreCase); + if (hostAt < 0) return absolute; + return absolute[..hostAt] + "[" + zoned + "]" + absolute[(hostAt + bracketed.Length)..]; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs index f29898288..8b2d2fe70 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs @@ -46,6 +46,23 @@ public static class OpcUaEndpointSelection return match; } + /// + /// Gitea #816 — the checkDomain to hand the SDK for : the operator's choice, but + /// never on a endpoint. SDK 2.0.1 checks the host in TWO places — in + /// DefaultSessionFactory.CreateChannelAsync, against the certificate in the GetEndpoints reply, BEFORE any + /// security-mode test (opc-plc sends one even on its None endpoint), and in Session.OpenAsync once the + /// chain is trusted — so passing true on a None session refused it on a certificate the channel never + /// uses. A host-name check on an unauthenticated channel proves nothing. + /// + /// The endpoint the session will open on. + /// The configured (effective) host-name policy. + /// The checkDomain argument. + public static bool CheckDomainFor(EndpointDescription selected, bool validateServerHostName) + { + ArgumentNullException.ThrowIfNull(selected); + return validateServerHostName && selected.SecurityMode != MessageSecurityMode.None; + } + /// Convert a driver to the OPC UA policy URI. /// The driver security policy to map. /// The corresponding OPC UA security policy URI. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index 20605be4e..e3e111f90 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -1,5 +1,6 @@ using Opc.Ua; using Opc.Ua.Security.Certificates; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; @@ -12,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// public static class OpcUaUserIdentityFactory { - private const string SecretPrefix = "secret:"; /// /// Build the user-identity token from options whose credential fields are already resolved plaintext @@ -103,25 +103,34 @@ public static class OpcUaUserIdentityFactory } /// - /// Return a copy of with any secret:-prefixed - /// / - /// resolved through . Non-secret / null / empty fields pass through unchanged (the - /// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a - /// secret: ref with no resolver available, throws rather than sending the literal as a credential. + /// Return a copy of with a secret:opcuaclient/… or + /// env:OTOPCUA_OPCUACLIENT_… / + /// resolved (lmxopcua#815 namespace). Literal / + /// null / empty fields pass through unchanged (the reference-equal original is returned when nothing changed). + /// Fail-closed: a reference outside the namespace (or file:) throws before any read, and an absent + /// secret, an unset variable, or a secret: ref with no resolver available throws rather than sending + /// the reference string as a credential. /// /// The raw options (credential fields may carry secret: refs). /// The shared secret resolver, or null when none is available. /// Cancellation token. + /// Environment reader (tests); null ⇒ the process environment. + /// + /// ⚠️ Deprecated #815 compatibility: the RUNNING driver's session open passes its tracker, so a pre-#815 + /// out-of-namespace value keeps the old semantics (a case-insensitive secret: resolves, anything else is + /// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail). + /// /// An options copy whose credential fields carry resolved plaintext. public static async Task ResolveSecretRefsAsync( - OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct) + OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct, + Func? environment = null, LegacyCredentialReferenceTracker? legacy = null) { ArgumentNullException.ThrowIfNull(options); - var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct) + var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy) .ConfigureAwait(false); var certPassword = await ResolveFieldAsync( - options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct) + options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy) .ConfigureAwait(false); if (ReferenceEquals(password, options.Password) @@ -134,24 +143,48 @@ public static class OpcUaUserIdentityFactory } private static async Task ResolveFieldAsync( - string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct) + string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct, + Func? environment, LegacyCredentialReferenceTracker? legacy) { - if (string.IsNullOrEmpty(value) - || !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)) + // A literal (or blank) passes through verbatim, reference-equal — the author already knows it. + var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _); + if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value; + + if (kind == CredentialReferenceKind.OutOfScope && legacy is not null) { - return value; + legacy.Report(fieldName); + return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false); } - var name = value[SecretPrefix.Length..]; + // lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and + // env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read. + return await CredentialReferences.ResolveAsync( + value, + DriverCredentialPolicy.OpcUaClientScope, + $"OpcUaClientDriverOptions.{fieldName}", + CredentialLiteralPolicy.Allowed, + resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false), + environment, + ct).ConfigureAwait(false); + } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim — a case-insensitive secret: resolves (fail-closed), anything else passes through as a + /// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver. + /// + private static async Task LegacyResolveFieldAsync( + string value, string fieldName, ISecretResolver? resolver, CancellationToken ct) + { + if (!value.StartsWith(CredentialReferences.SecretPrefix, StringComparison.OrdinalIgnoreCase)) return value; + var name = value[CredentialReferences.SecretPrefix.Length..]; if (resolver is null) throw new InvalidOperationException( - $"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " + - "available here (fail-closed)."); + $"OpcUaClientDriverOptions.{fieldName} references secret '{name}', but no secret store is available here (fail-closed)."); var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); return !string.IsNullOrEmpty(resolved) ? resolved : throw new InvalidOperationException( - $"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " + - "absent from the store (fail-closed)."); + $"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed)."); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4d1820985..06d462228 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,8 +29,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// back on its own recovers in place. /// /// -public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable +public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, IDisposable, IAsyncDisposable { + // ⚠️ lmxopcua#815 warn-only compatibility (deprecated): the session open of this RUNNING driver keeps + // resolving a pre-#815 out-of-namespace credential with the old semantics, warning rate-limited and surfacing + // it on the diagnostics bag. The AdminUI browser never gets a tracker, so it stays hard-fail. + private readonly LegacyCredentialReferenceTracker _legacyCredentials; + + /// + IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; + /// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node /// to dial. This driver's skip path was the quietest of the twelve: NamespaceMap /// drops such an entry with a bare continue and not even a log line. @@ -82,6 +90,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _diag = new OpcUaClientDiagnostics(driverInstanceId); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; + _legacyCredentials = new LegacyCredentialReferenceTracker( + DriverCredentialPolicy.OpcUaClientScope, driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message)); _sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger); PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval; } @@ -554,7 +564,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a // single resolve covers both paths. var resolvedOptions = await OpcUaClientSecretResolution - .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false); + .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false); var identity = BuildUserIdentity(resolvedOptions); @@ -581,7 +591,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}"); + attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}{HostNameRefusalHint(ex)}"); } } @@ -726,6 +736,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit private void WriteLifecycleHealth(long generation, Func next) => UpdateHealth(generation, next); + /// + /// Test seam (Gitea #816): when set, the PKI stores live under this directory instead of the shared + /// %LocalAppData%/OtOpcUa/pki, so an integration test can trust a fixture's certificate in a throwaway + /// store with off (auto-accept also accepts a + /// host-name mismatch, which would hide the check under test). Set before InitializeAsync. + /// + internal string? PkiRootOverride { get; set; } + /// /// Build a minimal in-memory . Certificates live /// under the OS user profile — on Windows that's %LocalAppData%\OtOpcUa\pki @@ -747,7 +765,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit }; certValidation.Application = app; - var pkiRoot = Path.Combine( + var pkiRoot = PkiRootOverride ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "pki"); @@ -1051,10 +1069,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + // Gitea #816: the SDK's 8-argument CreateAsync hard-codes checkDomain:false, so before #816 the server + // certificate's host name was never checked. The check (when enabled) runs against endpoint.EndpointUrl — + // the CONFIGURED host, because SelectMatching rewrote the advertised one — and never on a None endpoint + // (CheckDomainFor; the SDK would otherwise check at channel creation even there). var session = await new DefaultSessionFactory(_sdkTelemetry).CreateAsync( appConfig, endpoint, false, // updateBeforeConnect + OpcUaEndpointSelection.CheckDomainFor( // checkDomain (#816) + selected, _options.CertificateValidation.EffectiveValidateServerHostName), _options.SessionName, (uint)_options.SessionTimeout.TotalMilliseconds, identity, @@ -1085,6 +1109,19 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit return OpcUaEndpointSelection.SelectMatching(all.ToList(), endpointUrl, policy, mode); } + /// + /// Gitea #816 — the operator-facing remedy appended to a BadCertificateHostNameInvalid connect failure (only + /// reachable with certificateValidation.validateServerHostName on). The SDK's own text names the host it + /// checked but not the knob. + /// + /// The per-endpoint connect failure. + /// The hint, or an empty string for any other failure. + internal static string HostNameRefusalHint(Exception ex) => + ex is ServiceResultException sre && sre.StatusCode == StatusCodes.BadCertificateHostNameInvalid + ? " (the server certificate does not name the configured endpoint host; add that host to the certificate, " + + "connect by a name it lists, or — behind NAT / a port publish — set certificateValidation.validateServerHostName=false)" + : string.Empty; + /// Delegates to the shared (Gitea #676 / #806). /// The EndpointUrl the server returned from GetEndpoints. /// The URL the driver was configured with (the one GetEndpoints was sent to). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs index bda9a35dd..8e5eb290d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs @@ -31,16 +31,18 @@ internal static class OpcUaClientSecretResolution /// The raw driver options (credential fields may carry secret: refs). /// The shared secret resolver used by the secret: arm. /// Cancellation token for the async secret resolution. + /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. /// An options copy whose credential fields carry resolved plaintext. /// /// A secret: ref names a secret that is absent/tombstoned in the store (fail-closed). /// internal static Task ResolveSecretRefsAsync( - OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct) + OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct, + Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(resolver); // One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session). - return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct); + return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj index a93a9d070..1b656d9dd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj @@ -28,6 +28,8 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs index 2121697ff..f3861c768 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; @@ -36,6 +37,13 @@ public sealed class BrowserSessionService( /// public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { + // lmxopcua#815: a live browse resolves the config's credential references and sends them to the + // author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the + // Discover-backed universal browser) is opened. Nothing is read from the store or environment here. + var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson); + if (credentialProblems.Count > 0) + return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty); + if (!_browsersByType.TryGetValue(driverType, out var browser)) return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false); try diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor index 7ef8633ac..07fd06670 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -23,6 +23,8 @@ Changes apply on the next deployment. + +
Project tree diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs index 4d9b43db2..d4f13462e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs @@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel return TagConfigJson.Serialize(_bag); } + /// + /// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830): + /// the hosting DeviceModal shows the save gate's refusal (DriverConfigSaveGate, which covers every + /// password field) once and disables Save — reporting it here too printed the same error twice. + /// public string? Validate() { - var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList(); - foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) }) - if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v))) - errors.Add($"{name} password must be blank, secret:heidenhain/ or env:OTOPCUA_HEIDENHAIN_ — never a literal."); + var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }); return errors.Count == 0 ? null : string.Join(" ", errors); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor index 656cdf383..865fd7339 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor @@ -21,8 +21,8 @@
-
Forms: env:NAME, file:PATH, dev:KEY. Cleartext is accepted but triggers a startup warning.
+ placeholder="env:OTOPCUA_GALAXY_API_KEY" /> +
Forms: secret:galaxy/NAME, env:OTOPCUA_GALAXY_NAME (or legacy env:GALAXY_MXGW_NAME), dev:KEY. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and file: are refused (#815).
@@ -107,7 +107,7 @@ -
Forms: secret:NAME (production), env:NAME, file:PATH, dev:VALUE. A bare literal is refused.
+
Forms: secret:galaxy/NAME or secret:otopcua/galaxy/NAME (production), env:OTOPCUA_GALAXY_NAME, dev:VALUE. A bare literal, file:, or a reference outside the Galaxy namespace is refused (#815).
@@ -220,7 +220,7 @@ } private static GalaxyDriverOptions CreateDefaultOptions() => new( - Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"), + Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"), MxAccess: new GalaxyMxAccessOptions("OtOpcUa"), Repository: new GalaxyRepositoryOptions(), Reconnect: new GalaxyReconnectOptions()); @@ -241,7 +241,7 @@ { // GalaxyGatewayOptions public string GatewayEndpoint { get; set; } = "https://localhost:5001"; - public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY"; + public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY"; public bool GatewayUseTls { get; set; } = true; public string? GatewayCaCertificatePath { get; set; } public int GatewayConnectTimeoutSeconds { get; set; } = 10; @@ -273,7 +273,7 @@ public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) { - var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"); + var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"); var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa"); var repo = r.Repository ?? new GalaxyRepositoryOptions(); var rc = r.Reconnect ?? new GalaxyReconnectOptions(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor index 96faaa293..4390cb413 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -90,6 +90,10 @@ +
+ + +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs index a5717b33b..8ec2cd114 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs @@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel public int StatusMs { get; set; } = 1000; public int CustomMs { get; set; } = 1000; public int SlowMs { get; set; } = 10000; + public int AlarmMs { get; set; } = 2000; public int MaxAlarms { get; set; } = 64; public bool TimersEnabled { get; set; } public int ReconnectInitialMs { get; set; } = 1000; @@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel StatusMs = o.Polling.StatusMs, CustomMs = o.Polling.CustomMs, SlowMs = o.Polling.SlowMs, + AlarmMs = o.Polling.AlarmMs, MaxAlarms = o.Polling.MaxAlarms, TimersEnabled = o.Polling.TimersEnabled, ReconnectInitialMs = o.Reconnect.InitialDelayMs, @@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel ["StatusMs"] = StatusMs, ["CustomMs"] = CustomMs, ["SlowMs"] = SlowMs, + ["AlarmMs"] = AlarmMs, ["MaxAlarms"] = MaxAlarms, ["TimersEnabled"] = TimersEnabled, }; @@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel return _bag.ToJsonString(JsonOptions); } - /// The driver's own verdict on this config (device endpoints are validated on the device form). + /// + /// The driver's own verdict on this config's ranges (device endpoints are validated on the device form). + /// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting DriverConfigModal + /// shows the save gate's refusal (DriverConfigSaveGate, which scans every password field of the stored + /// document) once and disables Save — reporting it here too printed the same error twice. + /// public string? Validate() { var o = HeidenhainOptionsParser.Parse(ToJson()); var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions { - TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access, + TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + Access = new HeidenhainAccessOptions + { + TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled, + DataEnabled = o.Access.DataEnabled, + }, Polling = o.Polling, Reconnect = o.Reconnect, }); return errors.Count == 0 ? null : string.Join(" ", errors); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index 2b0f68094..bc27c6c1b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -138,6 +138,7 @@
+
Literal, secret:opcuaclient/NAME or env:OTOPCUA_OPCUACLIENT_NAME. Other references are refused (#815).
} @if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate) @@ -150,6 +151,7 @@
+
Literal, secret:opcuaclient/NAME or env:OTOPCUA_OPCUACLIENT_NAME.
}
@@ -192,6 +194,15 @@
Revoked certificates are always rejected.
+
+ + + + + + +
Sign / SignAndEncrypt only (never on None): the server certificate must name the endpoint host you configured. Off for link-local IPv6.
+
@@ -349,6 +360,8 @@ public int SubscriptionPriority { get; set; } public bool RejectUnknownRevocationStatus { get; set; } public string? IssuerStorePath { get; set; } + // #816 tri-state: "" = unset (the driver default) so the form never re-emits a concrete default; "true"/"false". + public string ValidateServerHostName { get; set; } = ""; public string? DiscoveryUrl { get; set; } public string? DiscoveryServerUri { get; set; } @@ -381,6 +394,12 @@ SubscriptionPriority = r.SubscriptionPriority, RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus, IssuerStorePath = r.CertificateValidation.IssuerStorePath, + ValidateServerHostName = r.CertificateValidation.ValidateServerHostName switch + { + true => "true", + false => "false", + null => "", + }, DiscoveryUrl = r.DiscoveryUrl, DiscoveryServerUri = r.DiscoveryServerUri, }; @@ -417,6 +436,12 @@ { RejectUnknownRevocationStatus = RejectUnknownRevocationStatus, IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath, + ValidateServerHostName = ValidateServerHostName switch + { + "true" => true, + "false" => false, + _ => null, + }, }, DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl, DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor new file mode 100644 index 000000000..90a3fedf1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor @@ -0,0 +1,85 @@ +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs +@using ZB.MOM.WW.OtOpcUa.Configuration +@inject CredentialReferencePreflightState State +@inject IDriverStatusSnapshotStore DriverStatus +@inject IServiceProvider Services + +@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their + driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the + deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. *@ + +@if (_findings.Count > 0 || _error is not null) +{ + +} + +@code { + private const int MaxRows = 25; + private IReadOnlyList _findings = []; + private string? _error; + private bool _busy; + + protected override void OnInitialized() => Compose(State.Current); + + private void Compose(CredentialReferencePreflightState.Snapshot snapshot) + { + _error = snapshot.Error; + _findings = snapshot.Findings + .Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll())) + .ToList(); + } + + private async Task RecheckAsync() + { + var factory = Services.GetService(typeof(IDbContextFactory)) as IDbContextFactory; + if (factory is null) return; + _busy = true; + try + { + var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None); + var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null); + State.Set(snapshot); + Compose(snapshot); + } + catch (Exception) + { + _error = "The credential-reference preflight could not read the config DB."; + } + finally + { + _busy = false; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor index abfe80c7c..fafc9658b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -49,13 +49,23 @@ } - + @if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated) + { + @* lmxopcua#830: the driver dictates the type for this address — show it, locked, + rather than offer a choice the commit would replace. *@ + + } + else + { + + } +
Set by the address.
+ } + else + { + + @foreach (var dt in DataTypes) + { + + } + + }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor index a5a44cb6c..c20f37718 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor @@ -12,13 +12,15 @@ + placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8 · ALARM:*" /> @foreach (var e in HeidenhainCatalog.Entries) { }
A fixed leaf, PLC:AREA:INDEX (element index into the control's advertised region; AREA one of - @string.Join(", ", HeidenhainAddress.AreaTokens)) or DATA:/path (needs DATA access enabled on the driver). + @string.Join(", ", HeidenhainAddress.AreaTokens)), DATA:/path (needs DATA access enabled on the driver), + or an alarm condition ALARM:* · ALARM:OTHER · + ALARM:CLASS=7[/GROUP=4][/NUMBER=100..199] (enable Native alarm below; acknowledgement is server-side only).
@@ -28,6 +30,11 @@
Fixed by the namespace catalog.
} + else if (_m.IsAlarmAddress) + { + +
A Part 9 condition (active / inactive), not a value.
+ } else {