Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e08b6b0e69 | |||
| 7339a4af07 | |||
| 872cf7e37a | |||
| f1534920de | |||
| 9bb237b794 | |||
| 1424a21419 | |||
| ce383df39a | |||
| a0be76b5f0 | |||
| 73d8439412 | |||
| 8843418c54 | |||
| 772d3a5f34 | |||
| ec6598ceae |
@@ -132,6 +132,9 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
<package pattern="ZB.MOM.WW.Theme" />
|
<package pattern="ZB.MOM.WW.Theme" />
|
||||||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||||||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||||||
|
<package pattern="ZB.MOM.WW.Secrets" />
|
||||||
|
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
</packageSourceMapping>
|
</packageSourceMapping>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -51,6 +51,66 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
|||||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||||
|
|
||||||
|
## Condition event identity fields (what a client reads on the wire)
|
||||||
|
|
||||||
|
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
|
||||||
|
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
|
||||||
|
does **not** synthesize them on this path (`Create` builds the children from the type definition
|
||||||
|
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
|
||||||
|
are set explicitly. Leaving them unset shipped them as **null** on every event — see issues #473
|
||||||
|
(the `BaseEventType` trio) and #475 (the `ConditionType` classification pair).
|
||||||
|
|
||||||
|
| Field | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
|
||||||
|
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
||||||
|
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
||||||
|
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
||||||
|
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
||||||
|
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
||||||
|
|
||||||
|
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
||||||
|
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
|
||||||
|
`BaseConditionClassType` is the honest, spec-conformant report of *"this server does not model
|
||||||
|
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
|
||||||
|
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
|
||||||
|
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
|
||||||
|
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
|
||||||
|
classification is a separate future feature: it needs the driver's alarm category, which today lives
|
||||||
|
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
|
||||||
|
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
|
||||||
|
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
|
||||||
|
|
||||||
|
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
||||||
|
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
||||||
|
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
|
||||||
|
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
|
||||||
|
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
|
||||||
|
|
||||||
|
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
|
||||||
|
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
|
||||||
|
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
|
||||||
|
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
|
||||||
|
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
|
||||||
|
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
|
||||||
|
|
||||||
|
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three `BaseEventType`
|
||||||
|
fields arrive populated on a real subscription using the standard `[EventType, SourceNode,
|
||||||
|
SourceName, Time, Message, Severity]` select clause, and — in a second test with its own clause —
|
||||||
|
that `ConditionClassId` / `ConditionClassName` do too. The two class fields are declared on
|
||||||
|
`ConditionType`, **not** `BaseEventType`, so a client must select them against that type.
|
||||||
|
`NodeManagerAlarmSourceFieldsTests` guards the node itself across both realms.
|
||||||
|
|
||||||
|
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
|
||||||
|
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
|
||||||
|
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
|
||||||
|
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
|
||||||
|
> alarm-history writer stamps that field with the **EquipmentPath**
|
||||||
|
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
|
||||||
|
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
|
||||||
|
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
||||||
|
> live-path fix above does not change the history path.)
|
||||||
|
|
||||||
## Galaxy driver path (driver-native)
|
## Galaxy driver path (driver-native)
|
||||||
|
|
||||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Secrets: Clustered Master-Key Posture (All Roles)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
|
||||||
|
pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||||
|
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) that runs at **every** OtOpcUa node
|
||||||
|
boot, before the host is built. It reads rows from an envelope-encrypted SQLite
|
||||||
|
store (`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
|
||||||
|
`Secrets:MasterKey:Source`. The runtime `ISecretResolver` (`AddZbSecrets`) is also
|
||||||
|
registered unconditionally, independent of node role.
|
||||||
|
|
||||||
|
OtOpcUa is Akka.NET-clustered (`builder.Services.AddAkka("otopcua", (ab, sp) => { ... })`
|
||||||
|
in `Program.cs`), with roles parsed by `RoleParser` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`):
|
||||||
|
`admin`, `driver`, `dev`. A node can carry any combination of these roles (e.g. a
|
||||||
|
fused admin+driver node, or a driver-only node). This runbook covers what a
|
||||||
|
production deployment needs so that secret resolution behaves identically on
|
||||||
|
**every node regardless of role** — not just admin nodes. That matters here more
|
||||||
|
than it might elsewhere: the pre-host expander and the runtime resolver both run
|
||||||
|
unconditionally on driver-only nodes too, and driver-only nodes are the ones that
|
||||||
|
will resolve Layer-B `DriverConfig` secret references (coming in Slice 2) at
|
||||||
|
runtime, not just at boot. It does **not** change any code; it is an
|
||||||
|
operations/deployment posture, delivered out-of-band from the committed config.
|
||||||
|
|
||||||
|
## The two hard requirements
|
||||||
|
|
||||||
|
For the pre-host expander (and the runtime resolver) to resolve the same
|
||||||
|
plaintext secret on every node, no matter its role:
|
||||||
|
|
||||||
|
1. **Identical KEK on every node.** All nodes — admin, driver, and any fused
|
||||||
|
combination — must unwrap the store with the exact same master key. A
|
||||||
|
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
|
||||||
|
decrypt every *other* node's ciphertext rows to garbage.
|
||||||
|
2. **Identical store rows on every node.** All nodes must read the same SQLite
|
||||||
|
database (same file, or a replicated/shared copy with the same rows) — not
|
||||||
|
independently-seeded stores that happen to share a KEK.
|
||||||
|
|
||||||
|
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
|
||||||
|
— there is no built-in cross-node replication today. Meeting both requirements in
|
||||||
|
production is a deployment concern, covered below.
|
||||||
|
|
||||||
|
## Recommended interim posture (G-5)
|
||||||
|
|
||||||
|
Until real replication exists (G-7, below), the recommended production posture is:
|
||||||
|
|
||||||
|
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
|
||||||
|
key file that is identical on every node of every role** — a base64-encoded
|
||||||
|
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
|
||||||
|
to each node's filesystem/secret-mount by the deployment tooling, and **never
|
||||||
|
committed** to the repo. Treat it with the same discipline as any other
|
||||||
|
production secret (restrictive file ACLs, no logging, rotated via a future
|
||||||
|
KEK-rotation runbook — not yet built).
|
||||||
|
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
|
||||||
|
that every node mounts (admin, driver, and dev/fused alike), so every node's
|
||||||
|
migrator opens and reads the same rows at boot.
|
||||||
|
|
||||||
|
Writes to the store are rare and human-driven — an operator using the
|
||||||
|
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
|
||||||
|
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
|
||||||
|
regardless of role. The access pattern is read-mostly / effectively
|
||||||
|
single-writer, which is what makes a shared SQLite volume viable as an interim
|
||||||
|
posture (see caveat below).
|
||||||
|
|
||||||
|
## How it's delivered (do NOT commit these values)
|
||||||
|
|
||||||
|
The File-KEK + shared-store posture is supplied per-node at deployment time —
|
||||||
|
**never** by editing the committed `appsettings.json` or the role-overlay files
|
||||||
|
(`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`).
|
||||||
|
Those committed overlays are also consumed by local dev and the
|
||||||
|
`TwoNodeClusterHarness` integration tests, so hardcoding a `Source=File` path
|
||||||
|
into them would break every dev/test/CI boot. Two acceptable delivery
|
||||||
|
mechanisms instead:
|
||||||
|
|
||||||
|
**Option A — environment variable overrides** (Windows Service / NSSM env block,
|
||||||
|
container `env_file`, etc.), applied identically on every node regardless of role:
|
||||||
|
|
||||||
|
```
|
||||||
|
# production deployment — do not commit to the dev appsettings
|
||||||
|
Secrets__MasterKey__Source=File
|
||||||
|
Secrets__MasterKey__FilePath=/run/secrets/otopcua-master.key
|
||||||
|
Secrets__SqlitePath=/shared/secrets/otopcua-secrets.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — a production-only config layer** that is *not* the committed dev
|
||||||
|
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
|
||||||
|
binaries, or an orchestrator-injected config mount):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// production deployment — do not commit to the dev appsettings
|
||||||
|
{
|
||||||
|
"Secrets": {
|
||||||
|
"MasterKey": {
|
||||||
|
"Source": "File",
|
||||||
|
"FilePath": "/run/secrets/otopcua-master.key"
|
||||||
|
},
|
||||||
|
"SqlitePath": "/shared/secrets/otopcua-secrets.db"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way, the file/path referenced must exist and be identical on every node
|
||||||
|
**before** that node boots — the pre-host expander runs unconditionally on every
|
||||||
|
role and will throw (`SecretNotFoundException` / migration failure) if the store
|
||||||
|
or key is missing.
|
||||||
|
|
||||||
|
## Caveat: SQLite over a shared volume is not real replication
|
||||||
|
|
||||||
|
SQLite's file-locking model does not tolerate concurrent multi-writer access well
|
||||||
|
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
|
||||||
|
block volume only one writer should be active at a time). The interim posture
|
||||||
|
above is acceptable because:
|
||||||
|
|
||||||
|
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
|
||||||
|
- Writes are rare, human-initiated, and effectively single-writer in practice
|
||||||
|
(an operator runs the CLI/UI against one admin node at a time).
|
||||||
|
|
||||||
|
It is **not** a substitute for real replication, and it is not safe if multiple
|
||||||
|
nodes attempt concurrent writes. Do not build automation that writes secrets
|
||||||
|
from more than one node simultaneously.
|
||||||
|
|
||||||
|
## Data Protection is independent — do not touch it here
|
||||||
|
|
||||||
|
OtOpcUa's cookie/session protection already has its own clustered-key story:
|
||||||
|
`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`),
|
||||||
|
which shares the Data Protection key ring across every node via the existing
|
||||||
|
ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption
|
||||||
|
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
|
||||||
|
work — doing so risks invalidating active sessions for an unrelated reason.
|
||||||
|
|
||||||
|
It is, however, the model for where the *next* iteration of secret storage
|
||||||
|
should go — see the G-7 hand-off below.
|
||||||
|
|
||||||
|
## The G-7 hand-off
|
||||||
|
|
||||||
|
The posture above is an interim, ops-only workaround. The long-term shape,
|
||||||
|
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
|
||||||
|
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
|
||||||
|
the Data Protection key ring:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddDataProtection()
|
||||||
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
.SetApplicationName("OtOpcUa");
|
||||||
|
```
|
||||||
|
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
|
||||||
|
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
|
||||||
|
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
|
||||||
|
the secret store is the natural next tenant of that same shared database instead
|
||||||
|
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
|
||||||
|
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
|
||||||
|
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
|
||||||
|
built as part of this cut. This runbook's shared-SQLite-volume posture is the
|
||||||
|
bridge until G-7 lands.
|
||||||
|
|
||||||
|
## Dev/test/default posture (unchanged)
|
||||||
|
|
||||||
|
The committed default in `appsettings.json` is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "otopcua-secrets.db",
|
||||||
|
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||||
|
"RunMigrationsOnStartup": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
|
||||||
|
path is relative to the working directory, so local dev, the role-overlay
|
||||||
|
appsettings (`appsettings.admin.json`, `appsettings.driver.json`,
|
||||||
|
`appsettings.admin-driver.json`), and the `TwoNodeClusterHarness` integration
|
||||||
|
tests all boot cleanly with no external mount. The File-KEK + shared-volume
|
||||||
|
posture in this runbook applies only to real clustered production deployments —
|
||||||
|
it must never be baked into the committed dev/role-overlay base, because the
|
||||||
|
expander runs unconditionally at every node boot (any role) and would break
|
||||||
|
dev/CI if pointed at a nonexistent `/shared` mount.
|
||||||
@@ -380,6 +380,55 @@ polling the node.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Config secrets (`${secret:}` delivery)
|
||||||
|
|
||||||
|
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
|
||||||
|
supplied at deploy time — either as a plain environment variable, or as a
|
||||||
|
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
|
||||||
|
|
||||||
|
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||||
|
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
|
||||||
|
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
|
||||||
|
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
|
||||||
|
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
|
||||||
|
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
|
||||||
|
the encrypted SQLite store lives at `Secrets:SqlitePath`.
|
||||||
|
|
||||||
|
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
|
||||||
|
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
|
||||||
|
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
|
||||||
|
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
|
||||||
|
|
||||||
|
The five config secrets and their canonical secret names:
|
||||||
|
|
||||||
|
| Config key | Owner | Secret name |
|
||||||
|
|---|---|---|
|
||||||
|
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
|
||||||
|
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
|
||||||
|
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
|
||||||
|
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
|
||||||
|
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
|
||||||
|
|
||||||
|
**Delivery.** By default these are delivered as plain environment variables (e.g.
|
||||||
|
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
|
||||||
|
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
|
||||||
|
the token in place of the literal — via env var or a deployment appsettings overlay:
|
||||||
|
|
||||||
|
```
|
||||||
|
secret set otopcua/historian/api-key <value> # seed the encrypted store first
|
||||||
|
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
|
||||||
|
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
|
||||||
|
secret value.
|
||||||
|
|
||||||
|
For the production posture needed so every clustered node (admin, driver, and any
|
||||||
|
fused role) resolves the same secrets from the same store, see
|
||||||
|
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Certificate trust failure
|
### Certificate trust failure
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
|
|
||||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||||
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
|
||||||
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||||
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
|
||||||
{
|
{
|
||||||
|
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||||
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||||
|
|
||||||
var clientOpts = BuildClientOptions(opts.Gateway);
|
var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||||
// an AdminUI cancel still wins early.
|
// an AdminUI cancel still wins early.
|
||||||
@@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||||
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
/// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
|
||||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||||
|
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
|
||||||
|
/// object initializer (you can't await inside one).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
var apiKey = await GalaxySecretRef
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
UseTls = gw.UseTls,
|
.ConfigureAwait(false);
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
return new MxGatewayClientOptions
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
{
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
ApiKey = apiKey,
|
||||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
UseTls = gw.UseTls,
|
||||||
: null,
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
};
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||||
|
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||||
|
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
|
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||||
/// supported, in priority order:
|
/// supported, in priority order:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||||
|
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||||
|
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||||
/// no startup warning.</item>
|
/// no startup warning.</item>
|
||||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
|
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
||||||
/// forms supported, evaluated in order:
|
/// forms supported, evaluated in order:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||||
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
||||||
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
||||||
/// doesn't emit a warning; production should never use this arm.</item>
|
/// doesn't emit a warning; production should never use this arm.</item>
|
||||||
|
/// <item><c>secret:NAME</c> — resolves NAME through the shared
|
||||||
|
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
||||||
|
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
||||||
|
/// throws rather than falling through to the literal arm — the production path
|
||||||
|
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
|
||||||
/// <item>Anything else — used as the literal API key for back-compat with
|
/// <item>Anything else — used as the literal API key for back-compat with
|
||||||
/// configs that pre-date this resolver. When a logger is supplied the
|
/// configs that pre-date this resolver. When a logger is supplied the
|
||||||
/// resolver emits a startup warning so an operator who accidentally
|
/// resolver emits a startup warning so an operator who accidentally
|
||||||
/// committed a cleartext key sees it.</item>
|
/// committed a cleartext key sees it.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
/// A future PR can swap any of these arms for a different backing store without
|
||||||
/// changing the call site.
|
/// changing the call site.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
public static class GalaxySecretRef
|
public static class GalaxySecretRef
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||||
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
|
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||||
/// opt-in path that doesn't warn.
|
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
|
||||||
|
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||||
/// <returns>The resolved API-key string.</returns>
|
/// <returns>The resolved API-key string.</returns>
|
||||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
public static async Task<string> ResolveApiKeyAsync(
|
||||||
|
string secretRef,
|
||||||
|
ISecretResolver resolver,
|
||||||
|
ILogger? logger = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
|
||||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
|||||||
return secretRef[4..];
|
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).");
|
||||||
|
}
|
||||||
|
|
||||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
// 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
|
// 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
|
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
|
||||||
|
|||||||
+1
@@ -13,5 +13,6 @@
|
|||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||||
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
|||||||
private readonly GalaxyDriverOptions _options;
|
private readonly GalaxyDriverOptions _options;
|
||||||
private readonly ILogger<GalaxyDriver> _logger;
|
private readonly ILogger<GalaxyDriver> _logger;
|
||||||
|
|
||||||
|
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
|
||||||
|
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
|
||||||
|
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
|
||||||
|
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
||||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||||
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
||||||
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
|
|||||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||||
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
public GalaxyDriver(
|
public GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null)
|
||||||
: this(driverInstanceId, options,
|
: this(driverInstanceId, options,
|
||||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
||||||
alarmAcknowledger: null, alarmFeed: null, logger)
|
alarmAcknowledger: null, alarmFeed: null, logger,
|
||||||
|
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
|
|||||||
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
|
||||||
|
/// null-object resolver (returns null for every name) so seam-injecting tests that
|
||||||
|
/// don't exercise a <c>secret:</c> ref need not supply one.
|
||||||
|
/// </param>
|
||||||
internal GalaxyDriver(
|
internal GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
|||||||
IGalaxySubscriber? subscriber = null,
|
IGalaxySubscriber? subscriber = null,
|
||||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||||
IGalaxyAlarmFeed? alarmFeed = null,
|
IGalaxyAlarmFeed? alarmFeed = null,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||||
? driverInstanceId
|
? driverInstanceId
|
||||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
_hierarchySource = hierarchySource;
|
_hierarchySource = hierarchySource;
|
||||||
_dataReader = dataReader;
|
_dataReader = dataReader;
|
||||||
_dataWriter = dataWriter;
|
_dataWriter = dataWriter;
|
||||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
|||||||
_driverInstanceId);
|
_driverInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
StartDeployWatcher();
|
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
|||||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_ownedMxSession is null) return;
|
if (_ownedMxSession is null) return;
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
||||||
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
||||||
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
// async and you can't await inside an initializer. Pass the logger so the
|
||||||
// warning rather than silently shipping the key. The resolver lives in
|
// literal-arm cleartext fallback surfaces a startup warning rather than
|
||||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
||||||
// AdminUI browser share one implementation.
|
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||||
UseTls = gw.UseTls,
|
var apiKey = await GalaxySecretRef
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
.ConfigureAwait(false);
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
return new MxGatewayClientOptions
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
{
|
||||||
};
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
UseTls = gw.UseTls,
|
||||||
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||||
|
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void StartDeployWatcher()
|
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_options.Repository.WatchDeployEvents) return;
|
if (!_options.Repository.WatchDeployEvents) return;
|
||||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||||
|
|
||||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
||||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||||
// rather than overwriting the field and leaking the first instance.
|
// runs it reuses this client rather than overwriting the field and leaking the
|
||||||
|
// first instance — the client-options build is now async (secret: arm).
|
||||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||||
BuildClientOptions(_options.Gateway));
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||||
_deployWatcher = new DeployWatcher(source, _logger);
|
_deployWatcher = new DeployWatcher(source, _logger);
|
||||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
|||||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
||||||
var source = _hierarchySource ??= BuildDefaultHierarchySource();
|
var source = _hierarchySource ??=
|
||||||
|
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
||||||
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||||
// keyed by RawPath, matching what WriteAsync resolves against.
|
// keyed by RawPath, matching what WriteAsync resolves against.
|
||||||
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
|
|||||||
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||||
/// internal ctor.
|
/// internal ctor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IGalaxyHierarchySource BuildDefaultHierarchySource()
|
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Reuse a client that StartDeployWatcher may have already created (??=) rather
|
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
|
||||||
// than always overwriting the field and leaking the first instance. Both paths
|
// than always overwriting the field and leaking the first instance. Both paths
|
||||||
// produce equivalent clients from the same options.
|
// produce equivalent clients from the same options. The client-options build is
|
||||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||||
|
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||||
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
return new TracedGalaxyHierarchySource(
|
return new TracedGalaxyHierarchySource(
|
||||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
{
|
{
|
||||||
public const string DriverTypeName = "GalaxyMxGateway";
|
public const string DriverTypeName = "GalaxyMxGateway";
|
||||||
|
|
||||||
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
|
/// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
|
||||||
/// <param name="registry">The driver factory registry to register with.</param>
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
|
||||||
|
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
|
||||||
|
/// </param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
|
ILoggerFactory? loggerFactory = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Convenience for tests + standalone callers.</summary>
|
/// <summary>
|
||||||
|
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
|
||||||
|
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
|
||||||
|
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
|
||||||
|
/// threads the DI resolver.
|
||||||
|
/// </summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
|
||||||
|
|
||||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
|
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||||
|
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(
|
public static GalaxyDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
RawTags = dto.RawTags ?? [],
|
RawTags = dto.RawTags ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
|
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||||
|
/// (absent). Used as the default for the internal test ctor and the parse-only
|
||||||
|
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
|
||||||
|
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
|
||||||
|
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
|
||||||
|
/// null object throws fail-closed (the secret is reported absent), which is the correct
|
||||||
|
/// behaviour for a mis-wired deployment.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <summary>The shared singleton instance.</summary>
|
||||||
|
public static readonly NullSecretResolver Instance = new();
|
||||||
|
|
||||||
|
private NullSecretResolver()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
+3
-1
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||||
/// protections cover it.
|
/// protections cover it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OpcUaClientDriverOptions
|
// A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a
|
||||||
|
// credential-resolved copy with a `with` expression — every property keeps its init setter.
|
||||||
|
public sealed record OpcUaClientDriverOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||||
|
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
|
||||||
|
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
|
||||||
|
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
|
||||||
|
/// <c>secret:</c> ref resolved against this null object throws fail-closed (the secret is
|
||||||
|
/// reported absent), which is the correct behaviour for a mis-wired deployment — the
|
||||||
|
/// <c>secret:</c> literal is never sent verbatim as a password.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <summary>The shared singleton instance.</summary>
|
||||||
|
public static readonly NullSecretResolver Instance = new();
|
||||||
|
|
||||||
|
private NullSecretResolver()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Opc.Ua;
|
|||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <param name="options">Driver configuration.</param>
|
/// <param name="options">Driver configuration.</param>
|
||||||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
||||||
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
|
||||||
|
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
|
||||||
|
/// secret absent) for the test/parse-only path; the production factory threads the
|
||||||
|
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
|
||||||
|
/// </param>
|
||||||
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||||||
ILogger<OpcUaClientDriver>? logger = null)
|
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly OpcUaClientDriverOptions _options;
|
private readonly OpcUaClientDriverOptions _options;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
// ---- IAlarmSource state ----
|
// ---- IAlarmSource state ----
|
||||||
|
|
||||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var candidates = ResolveEndpointCandidates(_options);
|
var candidates = ResolveEndpointCandidates(_options);
|
||||||
|
|
||||||
var identity = BuildUserIdentity(_options);
|
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||||
|
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
||||||
|
// are consumed. _options stays the raw config (with secret: refs) — only this
|
||||||
|
// connect-scoped local carries plaintext, and only for the duration of the connect.
|
||||||
|
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
|
||||||
|
// Both credential consumers — the Username password below and the Certificate password
|
||||||
|
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||||
|
// single resolve covers both paths.
|
||||||
|
var resolvedOptions = await OpcUaClientSecretResolution
|
||||||
|
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var identity = BuildUserIdentity(resolvedOptions);
|
||||||
|
|
||||||
// Failover sweep: try each endpoint in order, return the session from the first
|
// Failover sweep: try each endpoint in order, return the session from the first
|
||||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||||
|
|||||||
+23
-5
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
Converters = { new JsonStringEnumConverter() },
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
|
/// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
|
||||||
/// <param name="registry">The driver factory registry to register with.</param>
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
/// <param name="secretResolver">
|
||||||
|
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
|
||||||
|
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
|
||||||
|
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
|
||||||
|
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
|
||||||
|
/// </param>
|
||||||
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ILoggerFactory? loggerFactory = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
var resolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
|
||||||
|
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
|
||||||
|
/// </param>
|
||||||
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||||
public static OpcUaClientDriver CreateInstance(
|
public static OpcUaClientDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
|
return new OpcUaClientDriver(
|
||||||
|
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||||
|
secretResolver ?? NullSecretResolver.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
|
||||||
|
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
|
||||||
|
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
|
||||||
|
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
|
||||||
|
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
|
||||||
|
// credentials are actually consumed.
|
||||||
OpcUaClientDriverOptions? opts;
|
OpcUaClientDriverOptions? opts;
|
||||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
|
||||||
|
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||||
|
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
|
||||||
|
/// session-open path consumes them — retiring the cleartext password-in-DB model for
|
||||||
|
/// production.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
|
||||||
|
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
|
||||||
|
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
|
||||||
|
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
|
||||||
|
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
|
||||||
|
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
|
||||||
|
/// which would otherwise be sent verbatim to the remote server as the password.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class OpcUaClientSecretResolution
|
||||||
|
{
|
||||||
|
private const string SecretPrefix = "secret:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
|
||||||
|
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
|
||||||
|
/// returned unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||||
|
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||||
|
/// </exception>
|
||||||
|
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||||
|
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
|
||||||
|
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var certPassword = await ResolveFieldAsync(
|
||||||
|
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
||||||
|
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
||||||
|
if (ReferenceEquals(password, options.Password)
|
||||||
|
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||||
|
{
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||||
|
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||||
|
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||||
|
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||||
|
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||||
|
private static async Task<string?> ResolveFieldAsync(
|
||||||
|
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = value[SecretPrefix.Length..];
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -21,6 +21,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes/>
|
<Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<ThemeScripts />
|
<ThemeScripts />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||||
|
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||||
</NavRailSection>
|
</NavRailSection>
|
||||||
<NavRailSection Title="Scripting" Key="scripting">
|
<NavRailSection Title="Scripting" Key="scripting">
|
||||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
@@ -10,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||||
|
using ZB.MOM.WW.Secrets.Ui;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||||
|
|
||||||
@@ -28,8 +30,13 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||||
// UseAuthentication() — see Program.cs.
|
// UseAuthentication() — see Program.cs.
|
||||||
|
// The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
|
||||||
|
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
|
||||||
|
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
|
||||||
|
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
|
||||||
app.MapRazorComponents<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode()
|
||||||
|
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +50,15 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
services.AddOtOpcUaDriverStatusServices();
|
services.AddOtOpcUaDriverStatusServices();
|
||||||
|
|
||||||
|
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||||
|
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
|
||||||
|
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
|
||||||
|
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
|
||||||
|
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
|
||||||
|
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
|
||||||
|
// composition layer that already references the RCL) rather than in the core Security lib.
|
||||||
|
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
||||||
|
|
||||||
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
||||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||||
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
|
||||||
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
|
|||||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||||
Register(registry, loggerFactory, scriptRoot);
|
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||||
|
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||||
|
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||||
|
Register(registry, loggerFactory, secretResolver, scriptRoot);
|
||||||
return registry;
|
return registry;
|
||||||
});
|
});
|
||||||
services.AddSingleton<IDriverFactory>(sp =>
|
services.AddSingleton<IDriverFactory>(sp =>
|
||||||
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
|
|||||||
private static void Register(
|
private static void Register(
|
||||||
DriverFactoryRegistry registry,
|
DriverFactoryRegistry registry,
|
||||||
ILoggerFactory? loggerFactory,
|
ILoggerFactory? loggerFactory,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||||
{
|
{
|
||||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|||||||
using ZB.MOM.WW.Configuration;
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.Telemetry.Serilog;
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Configuration;
|
||||||
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||||
@@ -72,6 +77,27 @@ if (roleSuffix is not null)
|
|||||||
builder.Configuration.AddCommandLine(args);
|
builder.Configuration.AddCommandLine(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-host ${secret:} expansion: rewrites ${secret:name} tokens in the assembled configuration
|
||||||
|
// BEFORE the host is built, so every downstream binder/validator sees resolved plaintext.
|
||||||
|
// Placement matters: this runs AFTER all config providers are assembled (base appsettings, the
|
||||||
|
// role overlay, and the env-vars + command-line re-append above) but BEFORE anything READS a
|
||||||
|
// config value (AddZbSerilog's ReadFrom.Configuration, AddOtOpcUaConfigDb, and the first
|
||||||
|
// ValidateOnStart bind all follow). With no ${secret:} tokens present this is a no-op pass;
|
||||||
|
// it also migrates the secrets SQLite store on startup.
|
||||||
|
// ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it
|
||||||
|
// cannot use the app's DI. Fully disposed here; shares no singletons with the host container.
|
||||||
|
#pragma warning disable ASP0000
|
||||||
|
await using (var secretsProvider = new ServiceCollection()
|
||||||
|
.AddZbSecrets(builder.Configuration, "Secrets")
|
||||||
|
.BuildServiceProvider())
|
||||||
|
#pragma warning restore ASP0000
|
||||||
|
{
|
||||||
|
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
|
||||||
|
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
||||||
|
await new SecretReferenceExpander(resolver)
|
||||||
|
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
|
||||||
|
}
|
||||||
|
|
||||||
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
||||||
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
||||||
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
||||||
@@ -323,6 +349,9 @@ if (hasAdmin)
|
|||||||
builder.Services.AddOtOpcUaAdminClients();
|
builder.Services.AddOtOpcUaAdminClients();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||||
|
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
||||||
|
|
||||||
builder.Services.AddOtOpcUaHealth();
|
builder.Services.AddOtOpcUaHealth();
|
||||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,12 +11,21 @@
|
|||||||
"DisableLogin": false
|
"DisableLogin": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "otopcua-secrets.db",
|
||||||
|
"MasterKey": {
|
||||||
|
"Source": "Environment",
|
||||||
|
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||||
|
},
|
||||||
|
"RunMigrationsOnStartup": true,
|
||||||
|
"ResolveCacheTtl": "00:00:30"
|
||||||
|
},
|
||||||
"ServerHistorian": {
|
"ServerHistorian": {
|
||||||
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
"_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.",
|
"_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
|
||||||
"UseTls": true,
|
"UseTls": true,
|
||||||
"AllowUntrustedServerCertificate": false,
|
"AllowUntrustedServerCertificate": false,
|
||||||
"CaCertificatePath": null,
|
"CaCertificatePath": null,
|
||||||
|
|||||||
@@ -799,6 +799,34 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
||||||
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
||||||
|
|
||||||
|
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
|
||||||
|
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
|
||||||
|
// with NO default), and nothing downstream fills them in: the auto-filling
|
||||||
|
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
|
||||||
|
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
|
||||||
|
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
|
||||||
|
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
|
||||||
|
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
|
||||||
|
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
|
||||||
|
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
|
||||||
|
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
|
||||||
|
// is already carried by ConditionName below. See docs/AlarmTracking.md.
|
||||||
|
alarm.EventType.Value = alarm.TypeDefinitionId;
|
||||||
|
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
|
||||||
|
alarm.SourceName.Value = alarmNodeId;
|
||||||
|
|
||||||
|
// #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
|
||||||
|
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
|
||||||
|
// text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
|
||||||
|
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
|
||||||
|
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
|
||||||
|
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
|
||||||
|
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
|
||||||
|
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
|
||||||
|
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
|
||||||
|
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
|
||||||
|
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
|
||||||
|
|
||||||
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
||||||
alarm.SetEnableState(SystemContext, true);
|
alarm.SetEnableState(SystemContext, true);
|
||||||
alarm.SetActiveState(SystemContext, false);
|
alarm.SetActiveState(SystemContext, false);
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
||||||
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
||||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
|
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
|
||||||
/// <see cref="Enabled"/> is <c>true</c>.
|
/// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
|
||||||
|
/// expander resolves fail-closed (throwing if the secret is absent) before this options
|
||||||
|
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ApiKey { get; init; } = "";
|
public string ApiKey { get; init; } = "";
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,6 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
||||||
|
|
||||||
@@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
|||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class GalaxyDriverBrowserTests
|
public sealed class GalaxyDriverBrowserTests
|
||||||
{
|
{
|
||||||
private readonly GalaxyDriverBrowser _sut = new();
|
// These unit tests exercise only the pre-connect validation paths (empty endpoint,
|
||||||
|
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
|
||||||
|
// null-returning stub resolver suffices — it is never consulted.
|
||||||
|
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
|
||||||
|
|
||||||
|
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
||||||
/// so the factory wire-up picks the right browser implementation.</summary>
|
/// so the factory wire-up picks the right browser implementation.</summary>
|
||||||
|
|||||||
+94
-27
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Follow-up #2 — pins the three resolution forms supported by
|
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
|
||||||
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||||
/// and the literal-string fallback. A future DPAPI arm slots in here without
|
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
||||||
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
|
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
||||||
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
|
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
||||||
/// driver and the AdminUI browser share one copy.)
|
/// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
|
||||||
|
/// runtime driver and the AdminUI browser share one copy.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyDriverApiKeyResolverTests
|
public sealed class GalaxyDriverApiKeyResolverTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
|
||||||
|
private const string KnownSecretName = "galaxy/inst1/apikey";
|
||||||
|
|
||||||
|
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
|
||||||
|
private const string KnownSecretValue = "key-from-secret-store";
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
|
||||||
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||||
|
|
||||||
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Literal_string_is_returned_unchanged()
|
public async Task Literal_string_is_returned_unchanged()
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
|
(await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver()))
|
||||||
|
.ShouldBe("plain-text-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_resolves_to_environment_variable()
|
public async Task Env_prefix_resolves_to_environment_variable()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
||||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
|
||||||
|
.ShouldBe("key-from-env");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_unset_variable_throws_with_descriptive_message()
|
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
||||||
Environment.SetEnvironmentVariable(name, null);
|
Environment.SetEnvironmentVariable(name, null);
|
||||||
|
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
||||||
ex.Message.ShouldContain(name);
|
ex.Message.ShouldContain(name);
|
||||||
ex.Message.ShouldContain("unset");
|
ex.Message.ShouldContain("unset");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_resolves_to_trimmed_file_contents()
|
public async Task File_prefix_resolves_to_trimmed_file_contents()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
||||||
File.WriteAllText(path, " key-from-file \n");
|
File.WriteAllText(path, " key-from-file \n");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
|
||||||
|
.ShouldBe("key-from-file");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_missing_path_throws()
|
public async Task File_prefix_missing_path_throws()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||||
ex.Message.ShouldContain(path);
|
ex.Message.ShouldContain(path);
|
||||||
ex.Message.ShouldContain("doesn't exist");
|
ex.Message.ShouldContain("doesn't exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
|
||||||
|
|
||||||
|
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_resolves_through_secret_resolver()
|
||||||
|
{
|
||||||
|
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver()))
|
||||||
|
.ShouldBe(KnownSecretValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_absent_secret_throws_fail_closed()
|
||||||
|
{
|
||||||
|
const string missingRef = "secret:galaxy/missing/apikey";
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
|
||||||
|
|
||||||
|
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
|
||||||
|
// must NOT return the ref string as the key.
|
||||||
|
ex.Message.ShouldContain("galaxy/missing/apikey");
|
||||||
|
ex.Message.ShouldContain("absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_does_not_emit_literal_warning()
|
||||||
|
{
|
||||||
|
var logger = new CaptureLogger();
|
||||||
|
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger);
|
||||||
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
||||||
|
|
||||||
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Literal_string_emits_warning_when_logger_supplied()
|
public async Task Literal_string_emits_warning_when_logger_supplied()
|
||||||
{
|
{
|
||||||
// A literal API key on a production deployment means the cleartext key sits
|
// A literal API key on a production deployment means the cleartext key sits
|
||||||
// in the DriverConfig JSON. The resolver must surface a warning so an
|
// in the DriverConfig JSON. The resolver must surface a warning so an
|
||||||
// operator who committed one by accident sees it at startup.
|
// operator who committed one by accident sees it at startup.
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger);
|
||||||
|
|
||||||
key.ShouldBe("plain-text-key");
|
key.ShouldBe("plain-text-key");
|
||||||
logger.Entries.ShouldContain(e =>
|
logger.Entries.ShouldContain(e =>
|
||||||
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dev_prefix_returns_literal_without_warning()
|
public async Task Dev_prefix_returns_literal_without_warning()
|
||||||
{
|
{
|
||||||
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
||||||
// key (dev / parity rig). The resolver must accept it AND suppress the
|
// key (dev / parity rig). The resolver must accept it AND suppress the
|
||||||
// warning so production logs aren't polluted on a deliberate dev choice.
|
// warning so production logs aren't polluted on a deliberate dev choice.
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger);
|
||||||
|
|
||||||
key.ShouldBe("plain-text-key");
|
key.ShouldBe("plain-text-key");
|
||||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
@@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_does_not_emit_literal_warning()
|
public async Task Env_prefix_does_not_emit_literal_warning()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
||||||
Environment.SetEnvironmentVariable(name, "v");
|
Environment.SetEnvironmentVariable(name, "v");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
|
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
|
||||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Null_resolver_throws()
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(() =>
|
||||||
|
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A test logger that captures log entries for verification.</summary>
|
/// <summary>A test logger that captures log entries for verification.</summary>
|
||||||
private sealed class CaptureLogger : ILogger
|
private sealed class CaptureLogger : ILogger
|
||||||
{
|
{
|
||||||
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(
|
||||||
|
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
|
||||||
|
? KnownSecretValue
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_empty_file_throws()
|
public async Task File_prefix_empty_file_throws()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
||||||
File.WriteAllText(path, " \n ");
|
File.WriteAllText(path, " \n ");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||||
ex.Message.ShouldContain("empty");
|
ex.Message.ShouldContain("empty");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
|
|||||||
public void Register_AddsFactoryToRegistry()
|
public void Register_AddsFactoryToRegistry()
|
||||||
{
|
{
|
||||||
var registry = new DriverFactoryRegistry();
|
var registry = new DriverFactoryRegistry();
|
||||||
GalaxyDriverFactoryExtensions.Register(registry);
|
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
|
||||||
|
|
||||||
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests
|
|||||||
// _dataReader and _subscriber are both null. The follow-up read path can't
|
// _dataReader and _subscriber are both null. The follow-up read path can't
|
||||||
// synthesise a Read without one, so it surfaces a NotSupportedException
|
// synthesise a Read without one, so it surfaces a NotSupportedException
|
||||||
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
||||||
var driver = new GalaxyDriver("g", Opts());
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.ReadAsync(["x"], CancellationToken.None));
|
driver.ReadAsync(["x"], CancellationToken.None));
|
||||||
|
|||||||
+1
-1
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubscribeAsync_NoSubscriber_Throws()
|
public async Task SubscribeAsync_NoSubscriber_Throws()
|
||||||
{
|
{
|
||||||
using var driver = new GalaxyDriver("g", Opts());
|
using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
||||||
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_NoWriter_Throws()
|
public async Task WriteAsync_NoWriter_Throws()
|
||||||
{
|
{
|
||||||
var driver = new GalaxyDriver("g", Opts());
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
||||||
|
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
||||||
|
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||||
|
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
||||||
|
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
||||||
|
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
||||||
|
/// Non-secret literals and null/empty pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcUaClientSecretResolutionTests
|
||||||
|
{
|
||||||
|
private const string KnownPwName = "opcua/inst/password";
|
||||||
|
private const string KnownPwValue = "resolved-password-plaintext";
|
||||||
|
private const string KnownCertPwName = "opcua/inst/cert-pw";
|
||||||
|
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||||
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||||
|
|
||||||
|
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_password_resolves_to_store_plaintext()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
|
||||||
|
|
||||||
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.Password.ShouldBe(KnownPwValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_cert_password_resolves_to_store_plaintext()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
|
||||||
|
|
||||||
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Literal_and_null_pass_through_unchanged()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
Password = "plainpw",
|
||||||
|
UserCertificatePassword = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.Password.ShouldBe("plainpw");
|
||||||
|
resolved.UserCertificatePassword.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
||||||
|
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_secret_ref_is_fail_closed()
|
||||||
|
{
|
||||||
|
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||||
|
options, FakeResolver(), CancellationToken.None));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("Password");
|
||||||
|
ex.Message.ShouldContain("opcua/inst/absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(name.Value switch
|
||||||
|
{
|
||||||
|
KnownPwName => KnownPwValue,
|
||||||
|
KnownCertPwName => KnownCertPwValue,
|
||||||
|
_ => null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secrets-adoption (G-2c) regression guard: the AdminUI driver-config editor persists <c>DriverConfig</c>
|
||||||
|
/// as an OPAQUE JSON string through <see cref="RawTreeService"/> (see <c>UpdateDriverAsync</c> /
|
||||||
|
/// <c>CreateDriverAsync</c> — <c>entity.DriverConfig = driverConfigJson</c> verbatim, and
|
||||||
|
/// <c>LoadDriverForEditAsync</c> reads it back verbatim). A <c>secret:</c>/<c>env:</c>/<c>file:</c>
|
||||||
|
/// reference stored in a driver secret field (OpcUaClient <c>Password</c>/<c>UserCertificatePassword</c>,
|
||||||
|
/// Galaxy <c>Gateway.ApiKeySecretRef</c>) MUST survive save→reload as the literal ref string — the save
|
||||||
|
/// path must never resolve-then-resave cleartext (which would re-leak the secret into the config store and
|
||||||
|
/// defeat G-2). Secret resolution belongs ONLY at driver-instantiation / Test-connect time (Tasks 7-8),
|
||||||
|
/// never on the AdminUI persistence path — and indeed <see cref="RawTreeService"/> takes no secret resolver
|
||||||
|
/// (its only dependency is the DbContext factory), so resolution is structurally impossible here.
|
||||||
|
/// <para>
|
||||||
|
/// Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture, matching the WP3 driver-config tests.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class RawTreeServiceSecretRefRoundTripTests
|
||||||
|
{
|
||||||
|
private static (RawTreeService Service, string DbName) Seeded()
|
||||||
|
{
|
||||||
|
var name = RawTreeTestDb.SeedFresh();
|
||||||
|
return (new RawTreeService(RawTreeTestDb.Factory(name)), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] RowVersionOf(string dbName, string driverId)
|
||||||
|
{
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateDriver_preserves_OpcUaClient_secret_refs_verbatim_on_save_and_reload()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Seeded();
|
||||||
|
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
|
||||||
|
|
||||||
|
// The editor emits the raw ref strings the user typed — they must NOT be resolved on save.
|
||||||
|
const string config =
|
||||||
|
"{\"Password\":\"secret:opcua/x/pw\",\"UserCertificatePassword\":\"secret:opcua/x/certpw\"}";
|
||||||
|
|
||||||
|
var result = await service.UpdateDriverAsync(
|
||||||
|
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Reload through the same seam the modal uses.
|
||||||
|
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
|
||||||
|
reloaded.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Byte-identical: nothing on the save path rewrote the string.
|
||||||
|
reloaded!.DriverConfig.ShouldBe(config);
|
||||||
|
// The literal refs survived (not resolved to cleartext, not stripped).
|
||||||
|
reloaded.DriverConfig.ShouldContain("secret:opcua/x/pw");
|
||||||
|
reloaded.DriverConfig.ShouldContain("secret:opcua/x/certpw");
|
||||||
|
|
||||||
|
// And the raw column agrees with the projection.
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)
|
||||||
|
.DriverConfig.ShouldBe(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateDriver_preserves_Galaxy_ApiKeySecretRef_verbatim_on_save_and_reload()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Seeded();
|
||||||
|
|
||||||
|
const string config = "{\"Gateway\":{\"ApiKeySecretRef\":\"secret:galaxy/x/apikey\"}}";
|
||||||
|
|
||||||
|
var created = await service.CreateDriverAsync(
|
||||||
|
RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "galaxy-1", "Galaxy", config);
|
||||||
|
created.Ok.ShouldBeTrue();
|
||||||
|
created.CreatedId.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var reloaded = await service.LoadDriverForEditAsync(created.CreatedId!);
|
||||||
|
reloaded.ShouldNotBeNull();
|
||||||
|
|
||||||
|
reloaded!.DriverConfig.ShouldBe(config);
|
||||||
|
reloaded.DriverConfig.ShouldContain("secret:galaxy/x/apikey");
|
||||||
|
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId)
|
||||||
|
.DriverConfig.ShouldBe(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateDriver_preserves_env_and_file_ref_forms_verbatim()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Seeded();
|
||||||
|
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
|
||||||
|
|
||||||
|
const string config =
|
||||||
|
"{\"Password\":\"env:OPCUA_PW\",\"UserCertificatePassword\":\"file:/run/secrets/certpw\"}";
|
||||||
|
|
||||||
|
var result = await service.UpdateDriverAsync(
|
||||||
|
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
|
||||||
|
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
|
||||||
|
reloaded!.DriverConfig.ShouldBe(config);
|
||||||
|
reloaded.DriverConfig.ShouldContain("env:OPCUA_PW");
|
||||||
|
reloaded.DriverConfig.ShouldContain("file:/run/secrets/certpw");
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -4,6 +4,7 @@ using Xunit;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
|
||||||
|
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
|
||||||
|
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||||
services.AddOtOpcUaDriverFactories();
|
services.AddOtOpcUaDriverFactories();
|
||||||
|
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
@@ -38,6 +42,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
|
||||||
|
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
|
||||||
|
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||||
services.AddOtOpcUaDriverFactories();
|
services.AddOtOpcUaDriverFactories();
|
||||||
|
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
@@ -54,6 +61,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
|
||||||
|
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
|
||||||
|
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||||
services.AddOtOpcUaDriverFactories();
|
services.AddOtOpcUaDriverFactories();
|
||||||
|
|
||||||
using var sp = services.BuildServiceProvider();
|
using var sp = services.BuildServiceProvider();
|
||||||
@@ -65,4 +75,12 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
|
|||||||
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
|
sp.GetRequiredService<DriverResiliencePipelineBuilder>()
|
||||||
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
|
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal ISecretResolver so AddOtOpcUaDriverFactories' registration (which resolves it for
|
||||||
|
// Galaxy/OpcUaClient secret: refs) can be built. These tests exercise no secret: refs, so
|
||||||
|
// returning null for every name is sufficient — the real resolver comes from AddZbSecrets.
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+330
@@ -0,0 +1,330 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
|
||||||
|
/// <c>BaseEventType</c> identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. All three
|
||||||
|
/// previously arrived <b>null</b> on every condition event: <c>MaterialiseAlarmCondition</c> never assigned
|
||||||
|
/// them, and nothing downstream synthesises them (<c>ReportEvent</c> / <c>InstanceStateSnapshot</c> copy the
|
||||||
|
/// condition's children verbatim), so a conforming client could not attribute an alarm to its source.
|
||||||
|
///
|
||||||
|
/// <para>This is the WIRE-level guard the node-level
|
||||||
|
/// <c>NodeManagerAlarmSourceFieldsTests</c> cannot give: it proves the values survive the snapshot +
|
||||||
|
/// serialization all the way into a real client's <c>EventFieldList</c>. The select clause is deliberately
|
||||||
|
/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
|
||||||
|
/// <c>[EventType, SourceNode, SourceName, Time, Message, Severity]</c> — so this test fails the way that
|
||||||
|
/// client failed.</para>
|
||||||
|
///
|
||||||
|
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> and drives the production
|
||||||
|
/// <see cref="SdkAddressSpaceSink"/>, mirroring <c>NativeAlarmMultiNotifierEventDeliveryTests</c>. Heavy
|
||||||
|
/// in-process server+client integration — runs in the serial integration pass.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||||
|
{
|
||||||
|
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
|
||||||
|
private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
|
||||||
|
|
||||||
|
private const string RawDeviceFolder = "pymodbus/plc";
|
||||||
|
private const string RawAlarmPath = "pymodbus/plc/HR200";
|
||||||
|
private const string LeafName = "HR200";
|
||||||
|
private const string Equip1 = "EQ-filling-line1-station1";
|
||||||
|
private const string AlarmMessage = "HR200 over limit (identity-field test)";
|
||||||
|
|
||||||
|
// Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
|
||||||
|
private const int EventTypeIndex = 0;
|
||||||
|
private const int SourceNodeIndex = 1;
|
||||||
|
private const int SourceNameIndex = 2;
|
||||||
|
private const int TimeIndex = 3;
|
||||||
|
private const int MessageIndex = 4;
|
||||||
|
private const int SeverityIndex = 5;
|
||||||
|
|
||||||
|
// Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the
|
||||||
|
// ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing,
|
||||||
|
// so appending to it would silently shift them. Message is re-selected here only as the collector's filter key.
|
||||||
|
private const int ConditionClassIdIndex = 0;
|
||||||
|
private const int ConditionClassNameIndex = 1;
|
||||||
|
private const int ConditionClassMessageIndex = 2;
|
||||||
|
|
||||||
|
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
|
||||||
|
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireCondition(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
// Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
|
||||||
|
// expected SourceNode is built exactly as a real client would resolve it.
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
rawNs.ShouldBeGreaterThan((ushort)0);
|
||||||
|
|
||||||
|
var collector = new EventCollector(MessageIndex);
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
// The collector filters by our unique Message, so the item's ClientHandle is not needed here
|
||||||
|
// (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
|
||||||
|
AddEventItem(subscription, ObjectIds.Server, BuildEventFilter());
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
|
||||||
|
var fields = collector.Fields.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// --- the three fields this issue is about ---
|
||||||
|
// EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
|
||||||
|
fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
|
||||||
|
"EventType must carry the concrete condition type, not null");
|
||||||
|
|
||||||
|
// SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
|
||||||
|
fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
|
||||||
|
"SourceNode must identify the source condition node, not null");
|
||||||
|
|
||||||
|
// SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
|
||||||
|
// name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
|
||||||
|
fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
|
||||||
|
"SourceName must carry the unique RawPath, not null");
|
||||||
|
fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
|
||||||
|
"SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
|
||||||
|
|
||||||
|
// The rest of the standard envelope still arrives intact (guards against a regression that
|
||||||
|
// populated identity at the cost of the existing fields).
|
||||||
|
fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
|
||||||
|
((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
|
||||||
|
fields[SeverityIndex].Value.ShouldBe((ushort)700);
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName.
|
||||||
|
/// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an
|
||||||
|
/// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireCondition(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
|
||||||
|
var collector = new EventCollector(ConditionClassMessageIndex);
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter());
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
|
||||||
|
var fields = collector.Fields.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
|
||||||
|
// Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
|
||||||
|
fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
|
||||||
|
"ConditionClassId must carry a resolvable condition class, not null");
|
||||||
|
|
||||||
|
// ConditionClassName — the matching human-readable name, NOT empty text.
|
||||||
|
var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
|
||||||
|
className.Text.ShouldBe("BaseConditionClass",
|
||||||
|
"ConditionClassName must name the reported condition class, not be empty");
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
|
||||||
|
/// equipment folder wired as a notifier (the production topology).</summary>
|
||||||
|
private static void WireCondition(SdkAddressSpaceSink sink)
|
||||||
|
{
|
||||||
|
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
|
||||||
|
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||||
|
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmConditionSnapshot ActiveSnapshot() =>
|
||||||
|
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||||
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
|
||||||
|
|
||||||
|
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source, EventFilter filter)
|
||||||
|
{
|
||||||
|
var item = new MonitoredItem(subscription.DefaultItem)
|
||||||
|
{
|
||||||
|
StartNodeId = source,
|
||||||
|
AttributeId = Attributes.EventNotifier,
|
||||||
|
Filter = filter,
|
||||||
|
QueueSize = 100,
|
||||||
|
SamplingInterval = 0,
|
||||||
|
};
|
||||||
|
subscription.AddItem(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].</summary>
|
||||||
|
private static EventFilter BuildEventFilter()
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on
|
||||||
|
/// <c>ConditionType</c>, not BaseEventType, so they must be selected against that type or the server returns no
|
||||||
|
/// value for them regardless of the fix. Message rides along solely as the collector's filter key.</summary>
|
||||||
|
private static EventFilter BuildConditionClassEventFilter()
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
|
||||||
|
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
|
||||||
|
/// server events / refresh brackets never count.</summary>
|
||||||
|
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
|
||||||
|
/// with — the two clauses here place it differently, so it cannot be a shared constant.</param>
|
||||||
|
private sealed class EventCollector(int messageIndex)
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly List<VariantCollection> _fields = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<VariantCollection> Fields
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _fields.ToList(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var e in notification.Events)
|
||||||
|
{
|
||||||
|
if (e.EventFields.Count > messageIndex &&
|
||||||
|
e.EventFields[messageIndex].Value is LocalizedText lt &&
|
||||||
|
lt.Text == AlarmMessage)
|
||||||
|
{
|
||||||
|
_fields.Add(e.EventFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
|
||||||
|
int port, string pkiRoot, string appUri, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var options = new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = appUri,
|
||||||
|
ApplicationUri = appUri,
|
||||||
|
OpcUaPort = port,
|
||||||
|
PublicHostname = "127.0.0.1",
|
||||||
|
PkiStoreRoot = pkiRoot,
|
||||||
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
};
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
await host.StartAsync(server, ct);
|
||||||
|
return (server, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var appConfig = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||||
|
};
|
||||||
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||||
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||||
|
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||||
|
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||||
|
|
||||||
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||||
|
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||||
|
sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
|
||||||
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition()) return;
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
condition().ShouldBeTrue("condition not met within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SafeDelete(string dir)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #473 — every materialised Part 9 condition must carry the mandatory <c>BaseEventType</c>
|
||||||
|
/// identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. The SDK does NOT synthesise
|
||||||
|
/// them on this path: <see cref="NodeState.Create"/> initialises from the type's embedded definition,
|
||||||
|
/// which declares all three as mandatory children with NO default value, and the auto-filling
|
||||||
|
/// <see cref="BaseEventState.Initialize(ISystemContext, NodeState, EventSeverity, LocalizedText)"/>
|
||||||
|
/// overload (which would set SourceNode/SourceName from a source node) is only used for transient
|
||||||
|
/// events. <c>ReportEvent</c> and <c>InstanceStateSnapshot</c> copy the children verbatim and synthesise
|
||||||
|
/// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
|
||||||
|
/// <para>
|
||||||
|
/// The contract locked in here (see <c>docs/AlarmTracking.md</c>): the condition node IS the source
|
||||||
|
/// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
|
||||||
|
/// <c>SourceNode</c> self-references the condition's own NodeId and <c>SourceName</c> carries the
|
||||||
|
/// same identifying id string (<c>alarmNodeId</c>: the RawPath for native, the ScriptedAlarmId for
|
||||||
|
/// scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
|
||||||
|
/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Issue #475 — the same mechanism leaves the mandatory <c>ConditionType</c> classification fields
|
||||||
|
/// <c>ConditionClassId</c> / <c>ConditionClassName</c> unset. The contract locked in here: a server that
|
||||||
|
/// does not model condition classes must still report <c>BaseConditionClassType</c> (Part 9's
|
||||||
|
/// "no class modelled" value) rather than null. See <c>docs/AlarmTracking.md</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||||
|
private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";
|
||||||
|
|
||||||
|
private readonly string _pkiRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
|
||||||
|
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
|
||||||
|
/// remains on ConditionName so the short name is still available to clients.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
|
||||||
|
// where-clause) can resolve the type.
|
||||||
|
condition.EventType.ShouldNotBeNull();
|
||||||
|
condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||||
|
|
||||||
|
// SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
|
||||||
|
// for an alarm-bearing raw tag). Equal to ConditionId.
|
||||||
|
condition.SourceNode.ShouldNotBeNull();
|
||||||
|
condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
|
||||||
|
|
||||||
|
// SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
|
||||||
|
condition.SourceName.ShouldNotBeNull();
|
||||||
|
condition.SourceName.Value.ShouldBe(RawAlarmPath);
|
||||||
|
|
||||||
|
// The leaf name is NOT lost — it stays on ConditionName.
|
||||||
|
condition.ConditionName!.Value.ShouldBe("HR200");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
|
||||||
|
/// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
|
||||||
|
/// materialise path, so neither may regress independently.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||||
|
condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
|
||||||
|
condition.SourceName!.Value.ShouldBe("tank-overflow");
|
||||||
|
// The human-readable name stays on ConditionName.
|
||||||
|
condition.ConditionName!.Value.ShouldBe("Tank Overflow");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
|
||||||
|
/// falls back to the base <see cref="AlarmConditionState"/> and its EventType must report that base type
|
||||||
|
/// rather than a subtype the node does not have.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
|
||||||
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
|
||||||
|
/// kind-swap path), so a redeploy can never leave a condition emitting null identity.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Identity_fields_survive_a_kind_swap_rematerialise()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
// Kind swap (scripted → native) drops the prior instance and re-creates it.
|
||||||
|
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||||
|
condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
|
||||||
|
condition.SourceName!.Value.ShouldBe("alm-swap");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#475 — a NATIVE condition (Raw realm) carries the mandatory ConditionType classification fields.
|
||||||
|
/// We do not model condition classes, so Part 9's "no class modelled" value — BaseConditionClassType — is the
|
||||||
|
/// conformant answer; the point is that it is a resolvable class NodeId a client can bucket on rather than a
|
||||||
|
/// null that drops the alarm into an unclassified bin.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_condition_carries_ConditionClassId_and_ConditionClassName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
condition.ConditionClassId.ShouldNotBeNull();
|
||||||
|
condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||||
|
|
||||||
|
condition.ConditionClassName.ShouldNotBeNull();
|
||||||
|
condition.ConditionClassName.Value.ShouldNotBeNull();
|
||||||
|
condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
|
||||||
|
/// share the materialise path, so neither may regress independently.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Scripted_condition_carries_ConditionClassId_and_ConditionClassName()
|
||||||
|
{
|
||||||
|
await using var host = await BootAsync();
|
||||||
|
var nm = host.Nm;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-4", parentNodeId: null, displayName: "Station 4", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("tank-dry", "eq-4", "Tank Dry", "OffNormalAlarm", 700,
|
||||||
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("tank-dry", AddressSpaceRealm.Uns);
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||||
|
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
|
||||||
|
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
|
||||||
|
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public OtOpcUaNodeManager Nm { get; } = nm;
|
||||||
|
public ValueTask DisposeAsync() => host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BootedServer> BootAsync()
|
||||||
|
{
|
||||||
|
var host = new OpcUaApplicationHost(
|
||||||
|
new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
|
||||||
|
OpcUaPort = AllocateFreePort(),
|
||||||
|
PublicHostname = "localhost",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
},
|
||||||
|
NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
await host.StartAsync(server, Ct);
|
||||||
|
return new BootedServer(host, server.NodeManager!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_pkiRoot))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user