diff --git a/docs/Driver.FOCAS.Cli.md b/docs/Driver.FOCAS.Cli.md index e11aab9..f066419 100644 --- a/docs/Driver.FOCAS.Cli.md +++ b/docs/Driver.FOCAS.Cli.md @@ -51,6 +51,7 @@ Every command accepts: | `-p` / `--cnc-port` | `8193` | FOCAS TCP port (FOCAS-over-EIP default) | | `-s` / `--series` | `Unknown` | CNC series — `Unknown` / `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` / `Sixteen_i` / `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` / `PowerMotion_i` | | `--timeout-ms` | `2000` | Per-operation timeout | +| `--cnc-password` | (none) | **F4-d (issue #271)** — optional CNC connection-level password emitted via `cnc_wrunlockparam` on connect. Required only by controllers that gate parameter writes / selected reads behind a password switch (16i + some 30i firmwares with parameter-protect on). **PASSWORD INVARIANT: never logged.** The CLI's Serilog config does not destructure this flag and `FocasDeviceOptions.ToString` redacts the value. See [`v2/focas-deployment.md`](v2/focas-deployment.md) § "FOCAS password handling". | | `--verbose` | off | Serilog debug output | ## Addressing diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index 2a883ff..94d612a 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -147,6 +147,64 @@ non-idempotent action (alarm acks, M-code pulses, recipe steps). Flip `WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter value that the operator simply wants forced to a target). +### FOCAS password — issue #271 (F4-d) + +Some controllers — notably 16i and certain 30i firmwares with the +parameter-protect switch on — gate `cnc_wrparam` and a handful of reads +behind a connection-level password. Without unlocking the session, every +gated wire call returns `EW_PASSWD`, which the F4-b mapping surfaces as +`BadUserAccessDenied`. + +`FocasDeviceOptions.Password` plumbs the password through the device config: + +```jsonc +{ + "Devices": [ + { + "HostAddress": "focas://10.0.0.5:8193", + "Password": "1234" // F4-d — optional CNC password + } + ] +} +``` + +When set, the driver: + +1. **On connect**, calls `IFocasClient.UnlockAsync(password, ct)` after + the FWLIB handle opens but before any read/write fires. The FWLIB-backed + client emits `cnc_wrunlockparam` with the password ASCII-encoded into + the 4-byte FOCAS password slot (right-padded with `0x00`, truncated at + 4 bytes — that's the shape the public Fanuc samples document). +2. **On `BadUserAccessDenied` from any gated read or write**, re-issues + `UnlockAsync` and retries the call **exactly once**. A second + `EW_PASSWD` propagates unchanged so a wrong password doesn't loop + forever on the wire. +3. **Reset on reconnect** — FWLIB unlock state lives on the handle, so + any reconnect path (planned or unplanned) re-runs unlock automatically + via `EnsureConnectedAsync`. + +**No-log invariant.** The password is a secret. The driver MUST NOT log +it. Specifically: + +- `FocasDeviceOptions` overrides the record's auto-generated `ToString` + to print `Password = ***` when the field is non-null. Any Serilog + destructure that flows the device options through `{Device}` gets the + redaction for free. +- `FwlibFocasClient.UnlockAsync` does not include the password in any + exception message — only the FWLIB return code (`EW_PASSWD`, + `EW_HANDLE`, etc.) makes it into the surface. +- `FocasDriver` logs only `"FOCAS unlock applied for {host}"` when the + unlock succeeds — no password. +- The Driver.FOCAS.Cli `--cnc-password` flag is also redacted at the + same `FocasDeviceOptions` choke point. +- See [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md) + § "FOCAS password handling" for the storage/rotation runbook + the + cross-link to [`docs/Security.md`](../Security.md). + +When the controller does **not** need a password, leave `Password` +unset (`null`) and the driver short-circuits the unlock call entirely — +no wire-level cost. + ### Status-code semantics post-F4-b - `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag @@ -155,9 +213,13 @@ value that the operator simply wants forced to a target). **`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code, five distinct paths — operators distinguish by checking the knobs. - `BadUserAccessDenied` — **F4-b** — the CNC reported `EW_PASSWD` - (parameter-write switch off / unlock required). F4-d will land the - unlock workflow on top of this surface; today the deployment instructs - the operator to flip the parameter-write switch on the CNC pendant. + (parameter-write switch off / unlock required). **F4-d** wires the + `cnc_wrunlockparam` retry path on top: when `Password` is configured + the driver re-issues unlock + retries the gated call once before + surfacing this status. A persistent `BadUserAccessDenied` after F4-d + means either (a) the password doesn't match the controller, or (b) + the parameter-write switch on the pendant is still off and the + controller wants both the switch + the password. - `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't implement the kind being written (e.g. older transport variant). F4-a wired the generic dispatch; F4-b adds typed `WriteParameterAsync` / diff --git a/docs/v2/focas-deployment.md b/docs/v2/focas-deployment.md index e5c9376..c32f97c 100644 --- a/docs/v2/focas-deployment.md +++ b/docs/v2/focas-deployment.md @@ -183,3 +183,108 @@ granular kill switches are lightweight runtime toggles, not config-DB redeploys. PMC in particular should default OFF in production and only flip on for windows where the ladder team has signed off on the write path. + +## FOCAS password handling — issue #271 (F4-d) + +Some controllers (16i + certain 30i firmwares with parameter-protect on) +gate `cnc_wrparam` and selected reads behind a connection-level password. +The driver supports this via the `Password` field on `FocasDeviceOptions` +which is emitted via `cnc_wrunlockparam` on connect and re-emitted on any +`EW_PASSWD` read/write retry path. See +[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" for the +driver-side behaviour; this section covers the deployment side. + +### Storage in `appsettings.json` + +```jsonc +{ + "Drivers": { + "Focas01": { + "DriverConfigJson": { + "Backend": "fwlib", + "Series": "Sixteen_i", + "Devices": [ + { + "HostAddress": "focas://10.0.0.5:8193", + "Password": "1234" + } + ] + } + } + } +} +``` + +For dev environments, the password is materialised under +`.local/focas-passwords.txt` (or whichever .local subkey the deployment +team prefers); production deployments use the same secrets-store / +KeyVault pattern the LDAP `Authentication.Ldap.Password` field follows. +**The `.local/` directory is .gitignore'd** — this is the same posture +as `.local/galaxy-host-secret.txt` and other dev secrets in this repo. + +### No-log invariant + +The driver guarantees the password is **never logged**: + +1. **`FocasDeviceOptions` ToString redaction.** The record overrides + `PrintMembers` so any Serilog destructure of the device options renders + `Password = ***` when the field is non-null. This catches the most + common leak path — a structured-log statement that included + `{@Device}` for diagnostic context. +2. **No password in exception messages.** `FwlibFocasClient.UnlockAsync` + omits the password from its `InvalidOperationException` text — only + the FWLIB error code (`EW_PASSWD`, `EW_HANDLE`, etc.) makes it through. +3. **Driver log line uses host only.** When unlock succeeds the driver + updates `DriverHealth.StatusText` to `"FOCAS unlock applied for + {host}"` — no password. +4. **CLI flag covered by the same choke point.** The + `Driver.FOCAS.Cli --cnc-password` flag flows through + `FocasDeviceOptions.Password`, so its redaction is identical to the + server's. The PowerShell e2e harness (`scripts/e2e/test-focas.ps1 + -CncPassword`) follows the same path. + +Any new logging surface that touches `FocasDeviceOptions` MUST continue +to use the record's `ToString` (or otherwise omit `Password`). A code +review checklist item: "no log statement contains `device.Options.Password` +or `device.Password` directly." + +### Password-rotation runbook + +When the CNC password rotates (operator team flipped a parameter-protect +gate, or your security policy requires periodic rotation): + +1. **Update the password on the controller** (CNC pendant or vendor's + admin tool). The exact path varies by series — Fanuc service manual + page reference depends on the MTB. +2. **Update `appsettings.json`** in place with the new value. + - Production: bump the secrets-store entry that backs the + `Devices[*].Password` config-DB column. Same workflow as rotating + the LDAP service-account password. + - Dev: update `.local/focas-passwords.txt` (or wherever the dev + deployment sources the secret). +3. **Restart the OtOpcUa server** (or trigger a config-DB bump that + forces driver reinitialise). The driver picks up the new password + on the next `EnsureConnectedAsync` call. **No need to manually + reconnect each device** — `cnc_wrunlockparam` emits on the next + wire-call boundary. +4. **Verify**. The first wire call after restart logs + `"FOCAS unlock applied for focas://{host}:{port}"` at info. A wrong + password surfaces as `BadUserAccessDenied` on the next gated read or + write. +5. **Audit.** OPC UA wrote-event entries (per + [`audit-log-rules.md`](audit-log-rules.md)) cover the + parameter/macro write paths. Password rotation itself is NOT logged + beyond "unlock applied" — same posture as LDAP service-account + rotation, where the password change is logged out-of-band by the IAM + system. + +### Cross-references + +- [`docs/Security.md`](../Security.md) — server-wide secrets handling + + the same `.local/` pattern used for LDAP and the Galaxy.Host pipe + secret. The FOCAS password follows the same posture. +- [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" — + driver-side behaviour, EW_PASSWD retry semantics, status-code + surface. +- [`docs/v2/implementation/focas-wire-protocol.md`](implementation/focas-wire-protocol.md) + § "cnc_wrunlockparam" — wire-frame layout for the password buffer. diff --git a/docs/v2/implementation/focas-simulator-plan.md b/docs/v2/implementation/focas-simulator-plan.md index e3c407b..557c26b 100644 --- a/docs/v2/implementation/focas-simulator-plan.md +++ b/docs/v2/implementation/focas-simulator-plan.md @@ -32,6 +32,7 @@ command ids (and their request/response payloads) don't drift between the | **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** | | **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** | | **`0x0104`** | **`pmc_wrpmcrng`** | **mutates per-profile PMC byte tables; byte-aligned writes preserve untouched bytes; bit-level writes never reach the simulator (driver wraps with RMW) — issue #270, plan PR F4-c** | +| **`0x0105`** | **`cnc_wrunlockparam`** | **flips the per-profile `unlock_state` to true when the supplied 4-byte password buffer matches the profile's `unlock_password`; otherwise returns `EW_PASSWD`. State persists for the connection lifetime (per-session). — issue #271, plan PR F4-d** | | **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** | ## `cnc_rdalmhistry` mock behaviour @@ -124,6 +125,12 @@ Each profile owns: - `unlock_state: bool` — defaults `False`. When `False`, every `cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of parameter. Macro writes are NOT gated by `unlock_state`. +- `unlock_password: bytes` (4-byte buffer) — defaults to the profile's + fixture default (e.g. `b"1234"` for Series30i). Compared byte-for-byte + by the `cnc_wrunlockparam` handler; flips `unlock_state = True` on + match, leaves it untouched on mismatch (and returns `EW_PASSWD`). + Mutable via `POST /admin/mock_set_password` for tests that exercise + rotation. Issue #271, plan PR F4-d. - `last_write: Optional[LastWrite]` — most-recent successful `(kind, number, value, ts)` tuple, surfaced via the admin endpoint below for audit-log assertions. @@ -163,6 +170,37 @@ POST /admin/mock_set_unlock_state { "profile": "Series30i", "unlocked": true } ``` +### `cnc_wrunlockparam` request decode — issue #271, plan PR F4-d + +``` +[byte[4] password] +``` + +Match `password == profile.unlock_password` byte-for-byte. On match: +flip `unlock_state = True`, return `[int16 LE 0]`. On mismatch: leave +`unlock_state` untouched, return `[int16 LE 11]` (`EW_PASSWD`). + +The simulator deliberately keeps unlock state per-session (per OpenSession +handle) so a reconnect drops back to `unlock_state = False` — matching the +FWLIB lifetime semantics described in +[`focas-wire-protocol.md`](./focas-wire-protocol.md) § "cnc_wrunlockparam". + +### Admin endpoint — `POST /admin/mock_set_password` + +Rotates the per-profile `unlock_password` for tests that exercise the +F4-d password-rotation runbook (`docs/v2/focas-deployment.md` +§ "FOCAS password handling"). Idempotent — call again to revert. + +``` +POST /admin/mock_set_password +{ "profile": "Series30i", "password": "5678" } +``` + +The endpoint accepts the password as a UTF-8/ASCII string and applies +the same right-pad-to-4-bytes / truncate-to-4-bytes normalisation the +driver does, so simulator-side matching is byte-symmetric with the +production wire encoder. + ### Admin endpoint — `GET /admin/mock_get_last_write` Returns the simulator's view of the most-recent successful write, used by diff --git a/docs/v2/implementation/focas-wire-protocol.md b/docs/v2/implementation/focas-wire-protocol.md index 686a119..b0a48e6 100644 --- a/docs/v2/implementation/focas-wire-protocol.md +++ b/docs/v2/implementation/focas-wire-protocol.md @@ -22,6 +22,7 @@ Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are | **`0x0102`** | **`cnc_wrparam`** | **IODBPSD parameter-write packet (issue #269, plan PR F4-b)** | | **`0x0103`** | **`cnc_wrmacro`** | **ODBM macro-write packet (issue #269, plan PR F4-b)** | | **`0x0104`** | **`pmc_wrpmcrng`** | **IODBPMC PMC range-write packet (issue #270, plan PR F4-c)** | +| **`0x0105`** | **`cnc_wrunlockparam`** | **4-byte password buffer for the parameter-protect / read-protect unlock (issue #271, plan PR F4-d)** | | `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** | ## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`) @@ -214,3 +215,53 @@ data buffer in the response; the write side parses all five fields plus the data buffer from the request and emits a status int16 in the response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on the fake wire client. + +## cnc_wrunlockparam — connection-level password unlock (command `0x0105`) + +Issue #271, plan PR F4-d. Some controllers (notably 16i + certain 30i +firmwares with parameter-protect on) gate `cnc_wrparam` and selected +reads behind a connection-level password switch. The driver emits this +frame on connect when `FocasDeviceOptions.Password` is configured, and +re-emits it on any read/write that returns `EW_PASSWD` (then retries the +gated call once). + +### Request + +| Offset | Width | Field | +| --- | --- | --- | +| 0 | `byte[4]` | `password[4]` — 4-byte password buffer. ASCII-encoded from `FocasDeviceOptions.Password`, right-padded with `0x00`, truncated at 4 bytes. | + +The 4-byte fixed slot matches the FANUC published shape — the controller +compares byte-for-byte. Longer / shorter source strings are normalised at +the driver layer before they hit this frame so the wire surface stays +canonical. + +### Response + +Same single-int16 envelope as the write frames: + +| Offset | Width | Field | +| --- | --- | --- | +| 0 | `int16 LE` | `ew_status` — `0` = success (gate now lifted for the lifetime of this FWLIB handle), `EW_PASSWD` = supplied password did not match the controller's slot, `EW_HANDLE` = handle invalid. | + +### Lifetime + +Unlock is bound to the FWLIB handle: it persists until the handle closes +(disconnect / reconnect). The driver reinvokes unlock on every +`EnsureConnectedAsync` reconnect path so a planned or unplanned wire +restart self-heals without operator intervention. A `BadUserAccessDenied` +on a read/write triggers a single-shot retry: re-emit unlock + redispatch +the gated call once. A second `EW_PASSWD` propagates unchanged so a +mismatched password doesn't loop forever on the wire. + +### No-log invariant + +The password is a secret. Wire-client implementations MUST NOT log the +password on either request or response. The current +`FwlibFocasClient.UnlockAsync` constructs an exception that includes +only the `EW_*` return code; the `FocasDeviceOptions` record overrides +its auto-generated `ToString` so any Serilog destructure renders +`Password = ***`. See +[`docs/v2/focas-deployment.md`](../focas-deployment.md) +§ "FOCAS password handling" for the deployment-side guarantees + +rotation runbook. diff --git a/scripts/e2e/test-focas.ps1 b/scripts/e2e/test-focas.ps1 index b2c861c..43a8a96 100644 --- a/scripts/e2e/test-focas.ps1 +++ b/scripts/e2e/test-focas.ps1 @@ -56,6 +56,16 @@ mistargeted bit can move motion or latch a feedhold) so the gate is off by default — see docs/v2/focas-deployment.md "Write safety / PMC pre-checks". + +.PARAMETER CncPassword + Issue #271 (F4-d) — optional CNC connection-level password emitted via + cnc_wrunlockparam on connect. Required only when the controller gates + parameter writes behind a password switch (16i + some 30i firmwares + with parameter-protect on). Threaded through to every CLI invocation + in the -Write stage as --cnc-password. PASSWORD INVARIANT: never + logged — the CLI's Serilog config does not destructure this flag. + See docs/v2/focas-deployment.md § "FOCAS password handling" for the + no-log invariant + rotation runbook. #> param( @@ -67,7 +77,8 @@ param( [switch]$Write, [string]$ParamAddress = "PARAM:1815", [string]$MacroAddress = "MACRO:500", - [string]$PmcBitAddress = "R100.3" + [string]$PmcBitAddress = "R100.3", + [string]$CncPassword = "" ) $ErrorActionPreference = "Stop" @@ -86,6 +97,15 @@ $opcUaCli = Get-CliInvocation ` -ExeName "otopcua-cli" $commonFocas = @("-h", $CncHost, "-p", $CncPort) +# F4-d (issue #271) — thread the CNC connection password through to every CLI +# invocation. The CLI's --cnc-password flag emits cnc_wrunlockparam on connect +# and the driver's per-call retry path re-issues unlock + retries once on +# EW_PASSWD. PASSWORD INVARIANT: the password is NOT logged here. Write-Host +# and Test-* helpers never destructure $commonFocas, but we still avoid +# Write-Host'ing the array directly; the CLI's Serilog config also redacts. +if (-not [string]::IsNullOrWhiteSpace($CncPassword)) { + $commonFocas += @("--cnc-password", $CncPassword) +} $results = @() $results += Test-Probe ` diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs index 5f65dbe..a3f85c3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs @@ -26,6 +26,20 @@ public abstract class FocasCommandBase : DriverCommandBase [CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")] public int TimeoutMs { get; init; } = 2000; + /// + /// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted + /// via cnc_wrunlockparam on connect. Required only by controllers that + /// gate cnc_wrparam + selected reads behind a password switch. + /// PASSWORD INVARIANT: never logged. The CLI's Serilog config does not + /// include this option in any console / file destructure; the redaction is + /// enforced at the layer (record's + /// overridden ToString). + /// + [CommandOption("cnc-password", Description = + "Optional CNC connection password emitted via cnc_wrunlockparam on connect. " + + "Required by controllers that gate parameter writes behind a password switch.")] + public string? CncPassword { get; init; } + /// public override TimeSpan Timeout { @@ -51,7 +65,12 @@ public abstract class FocasCommandBase : DriverCommandBase Devices = [new FocasDeviceOptions( HostAddress: HostAddress, DeviceName: $"cli-{CncHost}:{CncPort}", - Series: Series)], + Series: Series, + OverrideParameters: null, + // PR F4-d (issue #271) — thread the CLI's --cnc-password through to + // the driver. Null when the operator didn't supply the flag — the + // driver short-circuits the unlock call in that case. + Password: CncPassword)], Tags = tags, Timeout = Timeout, Probe = new FocasProbeOptions { Enabled = false }, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 6b0bda9..4c616a7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -451,10 +451,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, continue; } - var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic - ? await client.ReadDiagnosticAsync( - parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false) - : await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); + // PR F4-d (issue #271) — single-shot unlock + retry on + // BadUserAccessDenied. Some controllers gate selected reads (not just + // writes) behind cnc_wrunlockparam; mirrors the WriteAsync retry + // shape so a session that lost its unlock state mid-flight (e.g. + // because the controller cycled it) self-heals on the first read. + async Task<(object? value, uint status)> DispatchReadAsync() + { + return parsed.Kind == FocasAreaKind.Diagnostic + ? await client.ReadDiagnosticAsync( + parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false) + : await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); + } + + var (value, status) = await DispatchReadAsync().ConfigureAwait(false); + if (status == FocasStatusMapper.BadUserAccessDenied + && !string.IsNullOrEmpty(device.Options.Password)) + { + if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false)) + { + (value, status) = await DispatchReadAsync().ConfigureAwait(false); + } + } results[i] = new DataValueSnapshot(value, status, now, now); if (status == FocasStatusMapper.Good) @@ -573,39 +591,58 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // issue #270). The fallback generic WriteAsync path is preserved for // kinds that don't have a typed entry point yet, plus the unit-test // FakeFocasClient that overrides WriteAsync directly. - uint status; - if (parsed.Kind == FocasAreaKind.Parameter) + // + // PR F4-d (issue #271) — wrap the dispatch in a single-shot retry on + // BadUserAccessDenied (EW_PASSWD mapping from F4-b). When the device + // has a password configured AND the wire call fires EW_PASSWD, the + // retry path re-issues UnlockAsync and re-dispatches once. The + // `attempted` flag bounds the loop so a second EW_PASSWD propagates + // unchanged — no infinite retry on a mismatched password. + async Task DispatchWriteAsync() { - status = await client.WriteParameterAsync( + if (parsed.Kind == FocasAreaKind.Parameter) + { + return await client.WriteParameterAsync( + parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); + } + if (parsed.Kind == FocasAreaKind.Macro) + { + return await client.WriteMacroAsync( + parsed, w.Value, cancellationToken).ConfigureAwait(false); + } + if (parsed.Kind == FocasAreaKind.Pmc + && def.DataType == FocasDataType.Bit + && parsed.BitIndex is int bit + && parsed.PmcLetter is string letter) + { + return await client.WritePmcBitAsync( + letter, parsed.PathId, parsed.Number, bit, + Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false); + } + if (parsed.Kind == FocasAreaKind.Pmc + && def.DataType == FocasDataType.Byte + && parsed.PmcLetter is string byteLetter) + { + var b = unchecked((byte)Convert.ToSByte(w.Value)); + return await client.WritePmcRangeAsync( + byteLetter, parsed.PathId, parsed.Number, new[] { b }, + cancellationToken).ConfigureAwait(false); + } + return await client.WriteAsync( parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); } - else if (parsed.Kind == FocasAreaKind.Macro) + + var status = await DispatchWriteAsync().ConfigureAwait(false); + if (status == FocasStatusMapper.BadUserAccessDenied + && !string.IsNullOrEmpty(device.Options.Password)) { - status = await client.WriteMacroAsync( - parsed, w.Value, cancellationToken).ConfigureAwait(false); - } - else if (parsed.Kind == FocasAreaKind.Pmc - && def.DataType == FocasDataType.Bit - && parsed.BitIndex is int bit - && parsed.PmcLetter is string letter) - { - status = await client.WritePmcBitAsync( - letter, parsed.PathId, parsed.Number, bit, - Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false); - } - else if (parsed.Kind == FocasAreaKind.Pmc - && def.DataType == FocasDataType.Byte - && parsed.PmcLetter is string byteLetter) - { - var b = unchecked((byte)Convert.ToSByte(w.Value)); - status = await client.WritePmcRangeAsync( - byteLetter, parsed.PathId, parsed.Number, new[] { b }, - cancellationToken).ConfigureAwait(false); - } - else - { - status = await client.WriteAsync( - parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); + // Single-shot retry: re-issue cnc_wrunlockparam, then redispatch + // exactly once. A second EW_PASSWD bubbles up as-is so a wrong + // password doesn't loop forever on the wire. + if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false)) + { + status = await DispatchWriteAsync().ConfigureAwait(false); + } } results[i] = new WriteResult(status); } @@ -1458,6 +1495,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, device.Client = null; throw; } + // PR F4-d (issue #271) — emit cnc_wrunlockparam on connect when the device + // configured a password. Resets on every reconnect because FWLIB unlock state + // is bound to the handle's lifetime. Failure here is non-fatal (the + // controller may surface password requirements only on certain reads/writes, + // not on every session) — the per-call retry path catches EW_PASSWD on the + // actual gated wire call. We still record the failure on _health for + // operator visibility. PASSWORD INVARIANT: never log device.Options.Password. + if (!string.IsNullOrEmpty(device.Options.Password)) + { + try + { + await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false); + device.UnlockApplied = true; + // Status text deliberately uses the host address only — no password. + _health = new DriverHealth(_health.State, _health.LastSuccessfulRead, + $"FOCAS unlock applied for {device.Options.HostAddress}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + device.UnlockApplied = false; + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"FOCAS unlock attempt failed for {device.Options.HostAddress}: {ex.Message}"); + } + } // Multi-path bootstrap (issue #264). cnc_rdpathnum runs once per session — the // controller's path topology is fixed at boot. A reconnect resets the wire // session's "last set path" so the next non-default-path read forces a fresh @@ -1474,6 +1536,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, return device.Client; } + /// + /// Plan PR F4-d (issue #271) — re-issue cnc_wrunlockparam on the active + /// wire session and return true when the unlock succeeded so the caller + /// can retry the gated read/write once. false means the unlock itself + /// failed (mismatched password, transport refused) and the caller surfaces + /// BadUserAccessDenied as-is. PASSWORD INVARIANT: the password is never + /// logged from this method. + /// + private async Task TryReunlockAsync(DeviceState device, CancellationToken ct) + { + if (device.Client is null || !device.Client.IsConnected) return false; + if (string.IsNullOrEmpty(device.Options.Password)) return false; + try + { + await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false); + device.UnlockApplied = true; + return true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch + { + device.UnlockApplied = false; + return false; + } + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); @@ -1590,6 +1678,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public int LastSetPath { get; set; } + /// + /// Plan PR F4-d (issue #271) — set when the driver successfully emitted + /// cnc_wrunlockparam on this wire session. Reset to false + /// on every reconnect because FWLIB unlock state is bound to the handle's + /// lifetime. Surfaced as a flag (not a counter) so a future diagnostics + /// surface can light up "unlocked" / "needs unlock" in the Admin UI + /// without changing the public API. + /// + public bool UnlockApplied { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs index 265d44b..b5fd0c2 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs @@ -62,7 +62,13 @@ public static class FocasDriverFactoryExtensions HostAddress: d.HostAddress ?? throw new InvalidOperationException( $"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"), DeviceName: d.DeviceName, - Series: ParseSeries(d.Series ?? dto.Series)))] + Series: ParseSeries(d.Series ?? dto.Series), + OverrideParameters: null, + // Plan PR F4-d (issue #271) — optional CNC password for cnc_wrunlockparam. + // The DTO carries it through JSON config round-trip; the driver-layer + // record overrides ToString to redact (no-log invariant). See + // docs/v2/focas-deployment.md § "FOCAS password handling". + Password: d.Password))] : [], Tags = dto.Tags is { Count: > 0 } ? [.. dto.Tags.Select(t => new FocasTagDefinition( @@ -222,6 +228,20 @@ public static class FocasDriverFactoryExtensions public string? HostAddress { get; init; } public string? DeviceName { get; init; } public string? Series { get; init; } + + /// + /// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted + /// via cnc_wrunlockparam on connect. Required only by controllers that + /// gate cnc_wrparam + selected reads behind a password switch. The + /// driver maps EW_PASSWD -> BadUserAccessDenied and re-issues + /// unlock + retries the gated call once on that mapping. + /// No-log invariant: never logged through the driver. The host + /// FocasDeviceOptions record overrides ToString to redact this + /// field. Stored in appsettings.json alongside the rest of the device + /// config; treat as a secret per docs/v2/focas-deployment.md + /// § "FOCAS password handling" + cross-link to docs/Security.md. + /// + public string? Password { get; init; } } internal sealed class FocasTagDto diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs index dc700a0..0d661dc 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -197,12 +197,44 @@ public sealed record FocasFixedTreeOptions /// declares the four MTB-specific override /// cnc_rdparam numbers surfaced under Override/; pass null to /// suppress the entire Override/ subfolder for that device (issue #259). +/// (issue #271, plan PR F4-d) is the CNC connection-level +/// password emitted via cnc_wrunlockparam on connect when the controller +/// gates parameter writes / certain reads behind a password switch (16i + some +/// 30i firmwares with parameter-protect on). /// +/// +/// No-log invariant: is a secret. The driver MUST NOT +/// log it. FocasDeviceOptions.ToString() would include the field by default +/// because it's a positional record member, so the record's auto-generated +/// ToString is overridden via below to redact +/// the password. Any new logging surface that touches +/// must continue to redact. See docs/v2/focas-deployment.md § "FOCAS password +/// handling" for the no-log invariant and rotation runbook. +/// public sealed record FocasDeviceOptions( string HostAddress, string? DeviceName = null, FocasCncSeries Series = FocasCncSeries.Unknown, - FocasOverrideParameters? OverrideParameters = null); + FocasOverrideParameters? OverrideParameters = null, + string? Password = null) +{ + /// + /// Issue #271 (plan PR F4-d) — record auto-generated ToString would print + /// verbatim. Override the printer so the secret is replaced + /// with "***" when the field is non-null. The no-log invariant relies on + /// this — every Serilog destructure that flows a + /// value through {Device} gets redaction for free. + /// + private bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append("HostAddress = ").Append(HostAddress); + builder.Append(", DeviceName = ").Append(DeviceName); + builder.Append(", Series = ").Append(Series); + builder.Append(", OverrideParameters = ").Append(OverrideParameters); + builder.Append(", Password = ").Append(Password is null ? "" : "***"); + return true; + } +} /// /// One FOCAS-backed OPC UA variable. is the canonical FOCAS diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 5af8243..04e6ed6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -48,6 +48,47 @@ internal sealed class FwlibFocasClient : IFocasClient return Task.CompletedTask; } + /// + /// Plan PR F4-d (issue #271) — emit cnc_wrunlockparam to lift the + /// CNC's parameter-protect / read-protect gate. The password is ASCII-encoded + /// into a 4-byte buffer (right-padded with 0x00; truncated when the + /// supplied string exceeds 4 chars — Fanuc's published password buffer is a + /// fixed 4-byte slot). Mismatch surfaces as EW_PASSWD mapped to + /// ; the F4-d retry loop + /// in re-issues unlock + retries the call once on + /// that mapping. + /// No-log invariant: the password is NOT logged from this method + /// and never appears in any exception message. The caller logs only "FOCAS + /// unlock applied for {host}" (no password). See FocasDeviceOptions.Password. + /// + public Task UnlockAsync(string password, CancellationToken cancellationToken) + { + if (!_connected) + throw new InvalidOperationException( + "FOCAS UnlockAsync called before Connect — handle is not yet open."); + cancellationToken.ThrowIfCancellationRequested(); + + // Fixed 4-byte buffer (FOCAS password slot). Right-pad with 0x00; truncate + // longer inputs. Truncation is a deployment-error not a runtime concern — + // the password the operator put in appsettings.json must already match the + // controller's slot exactly. We don't surface a different error code for + // length mismatch because the controller will reject with EW_PASSWD anyway. + var buf = new byte[4]; + var pwd = password ?? string.Empty; + var bytes = System.Text.Encoding.ASCII.GetBytes(pwd); + Array.Copy(bytes, 0, buf, 0, Math.Min(bytes.Length, buf.Length)); + + var ret = FwlibNative.WrUnlockParam(_handle, buf); + if (ret != 0) + { + // Note: deliberately do NOT include `password` in the exception message — + // exceptions get logged. The error code is enough for diagnosis. + throw new InvalidOperationException( + $"FWLIB cnc_wrunlockparam failed with EW_{ret}."); + } + return Task.CompletedTask; + } + public Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken cancellationToken) { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index 8ff9713..b4901ca 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -66,6 +66,25 @@ internal static class FwlibNative short length, ref IODBPSD buffer); + /// + /// cnc_wrunlockparam — emit the connection-level password that lifts + /// the parameter-protect / read-protect gate on certain firmwares (issue #271, + /// plan PR F4-d). The Fanuc FOCAS reference describes the password buffer as + /// a 4-byte binary array (the controller compares byte-for-byte). Returns the + /// usual EW_* family — EW_PASSWD when the supplied bytes don't + /// match the configured password. + /// + /// + /// P/Invoke shape kept narrow: the caller passes a 4-byte buffer. The + /// driver layer ASCII-encodes FocasDeviceOptions.Password into the + /// buffer (right-padded with 0x00, truncated to 4 bytes) — that's the + /// shape every public Fanuc password example we've seen uses. + /// + [DllImport(Library, EntryPoint = "cnc_wrunlockparam", ExactSpelling = true)] + public static extern short WrUnlockParam( + ushort handle, + [In] byte[] password); + // ---- Macro variables ---- [DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)] diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 3776c3c..ff6118a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -23,6 +23,25 @@ public interface IFocasClient : IDisposable /// True when the FWLIB handle is valid + the socket is up. bool IsConnected { get; } + /// + /// Plan PR F4-d (issue #271) — emit the CNC password via FOCAS + /// cnc_wrunlockparam. Some controllers (notably 16i + some 30i + /// firmwares with parameter-protect on) gate cnc_wrparam and selected + /// reads behind a connection-level password; this call lifts the gate for + /// the lifetime of the FWLIB handle (resets on reconnect, hence the driver + /// re-issues unlock on every ). + /// Default impl is a no-op () so transport + /// variants that don't surface unlock (today: IPC and FAKE clients) keep + /// compiling. The FWLIB-backed client overrides this with a real + /// cnc_wrunlockparam call. + /// No-log invariant: is a secret. The + /// wire-client implementation MUST NOT log the password — see + /// FocasDeviceOptions.Password + docs/v2/focas-deployment.md + /// § "FOCAS password handling". + /// + Task UnlockAsync(string password, CancellationToken cancellationToken) + => Task.CompletedTask; + /// /// Read the value at in the requested /// . Returns a boxed .NET value + the OPC UA status mapped diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index 247ea89..22ae8fe 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -40,6 +40,37 @@ internal class FakeFocasClient : IFocasClient return Task.CompletedTask; } + /// + /// Plan PR F4-d (issue #271) — count of invocations. + /// Tests assert this to verify the driver routed cnc_wrunlockparam on + /// connect when FocasDeviceOptions.Password was non-null and re-issued + /// unlock on EW_PASSWD retry exactly once. + /// + public int UnlockCount { get; private set; } + + /// + /// Plan PR F4-d (issue #271) — last password observed by . + /// Used by the round-trip test to confirm the driver passed the configured + /// password through unmodified. This field exists ONLY in the fake — no + /// production wire client retains the password past the wire call. + /// + public string? LastUnlockPassword { get; private set; } + + /// + /// Plan PR F4-d (issue #271) — when set, throws on + /// invocation so tests can drive the failed-unlock retry path (where the + /// driver surfaces BadUserAccessDenied as-is rather than retrying). + /// + public bool ThrowOnUnlock { get; set; } + + public virtual Task UnlockAsync(string password, CancellationToken ct) + { + UnlockCount++; + LastUnlockPassword = password; + if (ThrowOnUnlock) throw Exception ?? new InvalidOperationException("Unlock fails"); + return Task.CompletedTask; + } + public virtual Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken ct) { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs new file mode 100644 index 0000000..f86d8c6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs @@ -0,0 +1,359 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Issue #271, plan PR F4-d — cnc_wrunlockparam coverage. Some controllers +/// (notably 16i + some 30i firmwares with parameter-protect on) gate +/// cnc_wrparam + selected reads behind a connection-level password. The +/// driver emits unlock on connect when FocasDeviceOptions.Password is set, +/// and on any read/write returning EW_PASSWD -> BadUserAccessDenied +/// it re-issues unlock and retries the gated call exactly once. +/// +[Trait("Category", "Unit")] +public sealed class FocasUnlockTests +{ + private const string Host = "focas://10.0.0.5:8193"; + private const string Pwd = "1234"; + + private static FocasDriver NewDriver( + string? password, + FocasWritesOptions writes, + FocasTagDefinition[] tags, + out FakeFocasClientFactory factory) + { + factory = new FakeFocasClientFactory(); + return new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: password)], + Tags = tags, + Probe = new FocasProbeOptions { Enabled = false }, + Writes = writes, + }, "drv-1", factory); + } + + [Fact] + public async Task Password_set_invokes_UnlockAsync_on_connect() + { + // First connect attempt happens on the first wire call; trigger one read so + // EnsureConnectedAsync runs. + var drv = NewDriver( + password: Pwd, + writes: new FocasWritesOptions { Enabled = true, AllowParameter = true }, + tags: + [ + new FocasTagDefinition("R100", Host, "R100", FocasDataType.Int16, Writable: false), + ], + out var factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Drive one wire call to force the connect path. + _ = await drv.ReadAsync(["R100"], CancellationToken.None); + + var fake = factory.Clients.Single(); + fake.ConnectCount.ShouldBe(1); + fake.UnlockCount.ShouldBe(1); + fake.LastUnlockPassword.ShouldBe(Pwd); + } + + [Fact] + public async Task Password_null_does_NOT_invoke_UnlockAsync() + { + var drv = NewDriver( + password: null, + writes: new FocasWritesOptions { Enabled = true, AllowParameter = true }, + tags: + [ + new FocasTagDefinition("R100", Host, "R100", FocasDataType.Int16, Writable: false), + ], + out var factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + _ = await drv.ReadAsync(["R100"], CancellationToken.None); + + var fake = factory.Clients.Single(); + fake.ConnectCount.ShouldBe(1); + fake.UnlockCount.ShouldBe(0); + } + + [Fact] + public async Task EW_PASSWD_on_write_with_Password_set_triggers_unlock_and_retries() + { + var drv = NewDriver( + password: Pwd, + writes: new FocasWritesOptions { Enabled = true, AllowParameter = true }, + tags: + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), + ], + out var factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Seed: first WriteParameterAsync returns BadUserAccessDenied, second returns Good. + // We model this as a fake that flips the write status after the first call. + // Use a subclass with one-shot EW_PASSWD then Good. + factory.Customise = () => new OneShotPasswdClient(Pwd); + + // Re-init by issuing a fresh write — Customise applies on next Create() call. + // The first driver instance already constructed a non-customised client. Build + // a fresh driver here so the customised factory wins. + var factory2 = new FakeFocasClientFactory + { + Customise = () => new OneShotPasswdClient(Pwd), + }; + var drv2 = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: Pwd)], + Tags = + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), + ], + Probe = new FocasProbeOptions { Enabled = false }, + Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true }, + }, "drv-2", factory2); + await drv2.InitializeAsync("{}", CancellationToken.None); + + var results = await drv2.WriteAsync( + [new WriteRequest("Param", 42)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + var fake = (OneShotPasswdClient)factory2.Clients.Single(); + // Unlock fires twice: once on connect (Password set) + once on retry. + fake.UnlockCount.ShouldBe(2); + // Two writes attempted (first returned EW_PASSWD, retry returned Good). + fake.ParameterWriteLog.Count.ShouldBe(2); + } + + [Fact] + public async Task EW_PASSWD_on_write_without_Password_propagates_BadUserAccessDenied() + { + // No password configured — the driver must propagate BadUserAccessDenied + // immediately rather than attempting a (would-fail) unlock. The retry path + // is only meaningful when the deployment has a password to re-emit. + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: null)], + Tags = + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), + ], + Probe = new FocasProbeOptions { Enabled = false }, + Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true }, + }, "drv-3", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Seed an EW_PASSWD response on the parameter write — fake returns it as-is. + factory.Customise = null; // first Create returns plain FakeFocasClient + // Trigger the connect first so Customise can swap behaviour: + // simpler: pre-seed status on the existing fake by creating one and registering it. + // Use a derived class instead. + // -- redo via direct subclass approach: + var factory2 = new FakeFocasClientFactory + { + Customise = () => new AlwaysPasswdClient(), + }; + var drv2 = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: null)], + Tags = + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), + ], + Probe = new FocasProbeOptions { Enabled = false }, + Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true }, + }, "drv-3b", factory2); + await drv2.InitializeAsync("{}", CancellationToken.None); + + var results = await drv2.WriteAsync( + [new WriteRequest("Param", 42)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied); + var fake = (AlwaysPasswdClient)factory2.Clients.Single(); + fake.UnlockCount.ShouldBe(0); // no password -> no unlock attempt at all + fake.ParameterWriteLog.Count.ShouldBe(1); // single attempt, no retry + } + + [Fact] + public async Task Retry_happens_at_most_once_when_second_attempt_also_returns_EW_PASSWD() + { + // Recursion guard — the driver retries exactly once. A second EW_PASSWD on + // the retry surfaces BadUserAccessDenied unchanged so a wrong password + // doesn't loop forever. + var factory = new FakeFocasClientFactory + { + Customise = () => new AlwaysPasswdClient(), + }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: Pwd)], + Tags = + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), + ], + Probe = new FocasProbeOptions { Enabled = false }, + Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true }, + }, "drv-4", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("Param", 42)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied); + var fake = (AlwaysPasswdClient)factory.Clients.Single(); + // Connect-time unlock + one retry-time unlock. + fake.UnlockCount.ShouldBe(2); + // Two write attempts: original + one retry. Never three. + fake.ParameterWriteLog.Count.ShouldBe(2); + } + + [Fact] + public async Task EW_PASSWD_on_read_also_triggers_unlock_and_retries() + { + // Some firmwares gate selected reads behind cnc_wrunlockparam too — mirrors the + // write retry shape so a session that lost its unlock state mid-flight self-heals. + var factory = new FakeFocasClientFactory + { + Customise = () => new OneShotPasswdClient(Pwd) { GateReads = true }, + }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: Pwd)], + Tags = + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: false), + ], + Probe = new FocasProbeOptions { Enabled = false }, + Writes = new FocasWritesOptions { Enabled = false }, + }, "drv-5", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["Param"], CancellationToken.None); + + snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + var fake = (OneShotPasswdClient)factory.Clients.Single(); + // Connect-time unlock + retry unlock = 2. + fake.UnlockCount.ShouldBe(2); + } + + [Fact] + public async Task Password_is_NOT_present_in_FakeFocasClient_WriteLog_invocations() + { + // Write calls only ever see the address + data type + value — the password + // never appears in the per-call wire arguments. This guards against a future + // refactor accidentally threading the secret through the per-call surface. + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, Password: "supersecret")], + Tags = + [ + new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true), + ], + Probe = new FocasProbeOptions { Enabled = false }, + Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true }, + }, "drv-6", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.WriteAsync([new WriteRequest("Param", 42)], CancellationToken.None); + + var fake = factory.Clients.Single(); + // None of the captured write-log tuples contains the password string. + foreach (var entry in fake.WriteLog.Concat( + fake.ParameterWriteLog.Select(p => (p.addr, p.type, p.value)))) + { + entry.ToString()!.ShouldNotContain("supersecret"); + } + } + + [Fact] + public void DTO_JSON_round_trip_preserves_Password() + { + const string json = """ + { + "Backend": "fwlib", + "Series": "Sixteen_i", + "Devices": [ + { "HostAddress": "focas://10.0.0.5:8193", "Password": "1234" } + ], + "Tags": [ + { "Name": "Param", "DeviceHostAddress": "focas://10.0.0.5:8193", + "Address": "PARAM:1815", "DataType": "Int32", "Writable": true } + ] + } + """; + var driver = FocasDriverFactoryExtensions.CreateInstance("focas-pwd", json); + + driver.ShouldNotBeNull(); + // The instance was constructed; we don't expose Password on the driver + // surface (that would invert the no-log invariant), but ToString of + // FocasDeviceOptions redacts the password — verify that round-trip. + var opts = new FocasDeviceOptions("focas://1.2.3.4:8193", Password: "topsecret"); + opts.ToString().ShouldNotContain("topsecret"); + opts.ToString().ShouldContain("***"); + new FocasDeviceOptions("focas://1.2.3.4:8193", Password: null) + .ToString().ShouldContain(""); + } + + /// + /// Test fake — first parameter write returns EW_PASSWD-equivalent + /// , second returns Good. + /// Optionally extends the same shape to for + /// read-side tests. + /// + private sealed class OneShotPasswdClient : FakeFocasClient + { + private readonly string _expectedPassword; + private bool _firstWriteSeen; + private bool _firstReadSeen; + + public OneShotPasswdClient(string expectedPassword) + { + _expectedPassword = expectedPassword; + } + + public bool GateReads { get; set; } + + public override Task WriteParameterAsync( + FocasAddress address, FocasDataType type, object? value, CancellationToken ct) + { + ParameterWriteLog.Add((address, type, value)); + if (!_firstWriteSeen) + { + _firstWriteSeen = true; + return Task.FromResult(FocasStatusMapper.BadUserAccessDenied); + } + return Task.FromResult(FocasStatusMapper.Good); + } + + public override Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + if (GateReads && !_firstReadSeen) + { + _firstReadSeen = true; + return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadUserAccessDenied)); + } + return Task.FromResult<(object?, uint)>(((object?)0, FocasStatusMapper.Good)); + } + } + + /// + /// Test fake — every parameter write returns + /// . Drives the + /// "second attempt also fails" recursion-guard test + the no-password + /// pass-through test. + /// + private sealed class AlwaysPasswdClient : FakeFocasClient + { + public override Task WriteParameterAsync( + FocasAddress address, FocasDataType type, object? value, CancellationToken ct) + { + ParameterWriteLog.Add((address, type, value)); + return Task.FromResult(FocasStatusMapper.BadUserAccessDenied); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/PasswordUnlockTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/PasswordUnlockTests.cs new file mode 100644 index 0000000..d754866 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/PasswordUnlockTests.cs @@ -0,0 +1,61 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series; + +/// +/// Issue #271, plan PR F4-d — series-level (would-be integration) coverage of +/// cnc_wrunlockparam. Hardware-gated: the FOCAS driver has no public +/// simulator (task #222) so the live-controller cases require a real CNC with +/// parameter-protect on. The CI lane for this assembly runs the unit-test fakes +/// under ; this file is a scaffold that runs +/// against a simulator + matching mock_set_password admin endpoint when +/// FOCAS_TRUST_WIRE=1. +/// +/// +/// Build-only today: the simulator gate (FOCAS_TRUST_WIRE) skips at runtime so +/// CI doesn't need the simulator binary. When the simulator's +/// cnc_wrunlockparam + mock_set_password endpoints land +/// (docs/v2/implementation/focas-simulator-plan.md) the gated test +/// becomes a real round-trip. +/// +[Trait("Category", "Series")] +public sealed class PasswordUnlockTests +{ + [Fact] + public void Single_unlock_retry_path_is_documented() + { + // Build-only scaffold — see FocasUnlockTests for the actual fake-backed + // assertion. The integration version of this test (gated on a FOCAS + // simulator with mock_set_password) will: + // 1. Configure the simulator with password "1234". + // 2. Spin up FocasDriver with FocasDeviceOptions.Password = "1234". + // 3. Issue a cnc_wrparam against PARAM:1815 — expect Good (unlock applied + // on connect). + // 4. Use the simulator's admin endpoint to flip the password to "5678" + // mid-session (forces EW_PASSWD on the next write). + // 5. Issue another write — expect EW_PASSWD on attempt 1, then BadUserAccessDenied + // surfaced because the new password doesn't match the cached one. + // 6. Reconfigure FocasDeviceOptions.Password = "5678" on a new instance, + // issue write — expect Good (unlock applied on first connect). + // For now this test merely asserts the type contract; the simulator is + // tracked under task #222 + the focas-simulator-plan.md document. + typeof(IFocasClient).GetMethod(nameof(IFocasClient.UnlockAsync)) + .ShouldNotBeNull(); + // Driver-side records the password redaction invariant. + var dev = new FocasDeviceOptions("focas://1.2.3.4:8193", Password: "1234"); + dev.ToString().ShouldNotContain("1234"); + } + + [Fact(Skip = "Hardware-gated — requires the FOCAS simulator with cnc_wrunlockparam + mock_set_password endpoints (task #222 / focas-simulator-plan.md).")] + public Task Live_simulator_unlock_retry_round_trip() + { + // Body deliberately empty — the [Skip] attribute keeps this off the CI lane. + // When the simulator lands, this test materialises a FocasDriver pointed at + // the simulator + drives the EW_PASSWD -> unlock -> retry path through real + // wire calls. See docs/v2/implementation/focas-simulator-plan.md + // § "FOCAS password unlock" for the simulator-side endpoints. + return Task.CompletedTask; + } +}