[focas] FOCAS — Password / unlock parameter #391
@@ -51,6 +51,7 @@ Every command accepts:
|
|||||||
| `-p` / `--cnc-port` | `8193` | FOCAS TCP port (FOCAS-over-EIP default) |
|
| `-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` |
|
| `-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 |
|
| `--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 |
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
## Addressing
|
## Addressing
|
||||||
|
|||||||
@@ -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
|
`WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter
|
||||||
value that the operator simply wants forced to a target).
|
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
|
### Status-code semantics post-F4-b
|
||||||
|
|
||||||
- `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag
|
- `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,
|
**`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
|
||||||
five distinct paths — operators distinguish by checking the knobs.
|
five distinct paths — operators distinguish by checking the knobs.
|
||||||
- `BadUserAccessDenied` — **F4-b** — the CNC reported `EW_PASSWD`
|
- `BadUserAccessDenied` — **F4-b** — the CNC reported `EW_PASSWD`
|
||||||
(parameter-write switch off / unlock required). F4-d will land the
|
(parameter-write switch off / unlock required). **F4-d** wires the
|
||||||
unlock workflow on top of this surface; today the deployment instructs
|
`cnc_wrunlockparam` retry path on top: when `Password` is configured
|
||||||
the operator to flip the parameter-write switch on the CNC pendant.
|
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
|
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
|
||||||
implement the kind being written (e.g. older transport variant). F4-a
|
implement the kind being written (e.g. older transport variant). F4-a
|
||||||
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` /
|
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` /
|
||||||
|
|||||||
@@ -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
|
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
|
flip on for windows where the ladder team has signed off on the write
|
||||||
path.
|
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.
|
||||||
|
|||||||
@@ -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** |
|
| **`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** |
|
| **`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** |
|
| **`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)** |
|
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
|
||||||
|
|
||||||
## `cnc_rdalmhistry` mock behaviour
|
## `cnc_rdalmhistry` mock behaviour
|
||||||
@@ -124,6 +125,12 @@ Each profile owns:
|
|||||||
- `unlock_state: bool` — defaults `False`. When `False`, every
|
- `unlock_state: bool` — defaults `False`. When `False`, every
|
||||||
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
|
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
|
||||||
parameter. Macro writes are NOT gated by `unlock_state`.
|
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
|
- `last_write: Optional[LastWrite]` — most-recent successful
|
||||||
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
|
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
|
||||||
below for audit-log assertions.
|
below for audit-log assertions.
|
||||||
@@ -163,6 +170,37 @@ POST /admin/mock_set_unlock_state
|
|||||||
{ "profile": "Series30i", "unlocked": true }
|
{ "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`
|
### Admin endpoint — `GET /admin/mock_get_last_write`
|
||||||
|
|
||||||
Returns the simulator's view of the most-recent successful write, used by
|
Returns the simulator's view of the most-recent successful write, used by
|
||||||
|
|||||||
@@ -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)** |
|
| **`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)** |
|
| **`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)** |
|
| **`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)** |
|
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
|
||||||
|
|
||||||
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
|
## 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
|
the data buffer from the request and emits a status int16 in the
|
||||||
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
|
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
|
||||||
the fake wire client.
|
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.
|
||||||
|
|||||||
@@ -56,6 +56,16 @@
|
|||||||
mistargeted bit can move motion or latch a feedhold) so the gate is
|
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
|
off by default — see docs/v2/focas-deployment.md "Write safety / PMC
|
||||||
pre-checks".
|
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(
|
param(
|
||||||
@@ -67,7 +77,8 @@ param(
|
|||||||
[switch]$Write,
|
[switch]$Write,
|
||||||
[string]$ParamAddress = "PARAM:1815",
|
[string]$ParamAddress = "PARAM:1815",
|
||||||
[string]$MacroAddress = "MACRO:500",
|
[string]$MacroAddress = "MACRO:500",
|
||||||
[string]$PmcBitAddress = "R100.3"
|
[string]$PmcBitAddress = "R100.3",
|
||||||
|
[string]$CncPassword = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -86,6 +97,15 @@ $opcUaCli = Get-CliInvocation `
|
|||||||
-ExeName "otopcua-cli"
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
$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 = @()
|
||||||
|
|
||||||
$results += Test-Probe `
|
$results += Test-Probe `
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ public abstract class FocasCommandBase : DriverCommandBase
|
|||||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
||||||
public int TimeoutMs { get; init; } = 2000;
|
public int TimeoutMs { get; init; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
|
||||||
|
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
|
||||||
|
/// gate <c>cnc_wrparam</c> + 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 <see cref="FocasDeviceOptions"/> layer (record's
|
||||||
|
/// overridden <c>ToString</c>).
|
||||||
|
/// </summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override TimeSpan Timeout
|
public override TimeSpan Timeout
|
||||||
{
|
{
|
||||||
@@ -51,7 +65,12 @@ public abstract class FocasCommandBase : DriverCommandBase
|
|||||||
Devices = [new FocasDeviceOptions(
|
Devices = [new FocasDeviceOptions(
|
||||||
HostAddress: HostAddress,
|
HostAddress: HostAddress,
|
||||||
DeviceName: $"cli-{CncHost}:{CncPort}",
|
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,
|
Tags = tags,
|
||||||
Timeout = Timeout,
|
Timeout = Timeout,
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
|||||||
@@ -451,10 +451,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
|
// PR F4-d (issue #271) — single-shot unlock + retry on
|
||||||
? await client.ReadDiagnosticAsync(
|
// BadUserAccessDenied. Some controllers gate selected reads (not just
|
||||||
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
|
// writes) behind cnc_wrunlockparam; mirrors the WriteAsync retry
|
||||||
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
// 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);
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||||
if (status == FocasStatusMapper.Good)
|
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
|
// issue #270). The fallback generic WriteAsync path is preserved for
|
||||||
// kinds that don't have a typed entry point yet, plus the unit-test
|
// kinds that don't have a typed entry point yet, plus the unit-test
|
||||||
// FakeFocasClient that overrides WriteAsync directly.
|
// 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<uint> 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);
|
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(
|
// Single-shot retry: re-issue cnc_wrunlockparam, then redispatch
|
||||||
parsed, w.Value, cancellationToken).ConfigureAwait(false);
|
// exactly once. A second EW_PASSWD bubbles up as-is so a wrong
|
||||||
}
|
// password doesn't loop forever on the wire.
|
||||||
else if (parsed.Kind == FocasAreaKind.Pmc
|
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
|
||||||
&& def.DataType == FocasDataType.Bit
|
{
|
||||||
&& parsed.BitIndex is int bit
|
status = await DispatchWriteAsync().ConfigureAwait(false);
|
||||||
&& 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);
|
|
||||||
}
|
}
|
||||||
results[i] = new WriteResult(status);
|
results[i] = new WriteResult(status);
|
||||||
}
|
}
|
||||||
@@ -1458,6 +1495,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
device.Client = null;
|
device.Client = null;
|
||||||
throw;
|
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
|
// 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
|
// 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
|
// 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;
|
return device.Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — re-issue <c>cnc_wrunlockparam</c> on the active
|
||||||
|
/// wire session and return <c>true</c> when the unlock succeeded so the caller
|
||||||
|
/// can retry the gated read/write once. <c>false</c> means the unlock itself
|
||||||
|
/// failed (mismatched password, transport refused) and the caller surfaces
|
||||||
|
/// <c>BadUserAccessDenied</c> as-is. PASSWORD INVARIANT: the password is never
|
||||||
|
/// logged from this method.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> 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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -1590,6 +1678,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int LastSetPath { get; set; }
|
public int LastSetPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — set when the driver successfully emitted
|
||||||
|
/// <c>cnc_wrunlockparam</c> on this wire session. Reset to <c>false</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool UnlockApplied { get; set; }
|
||||||
|
|
||||||
public void DisposeClient()
|
public void DisposeClient()
|
||||||
{
|
{
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ public static class FocasDriverFactoryExtensions
|
|||||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||||
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
DeviceName: d.DeviceName,
|
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 }
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||||
@@ -222,6 +228,20 @@ public static class FocasDriverFactoryExtensions
|
|||||||
public string? HostAddress { get; init; }
|
public string? HostAddress { get; init; }
|
||||||
public string? DeviceName { get; init; }
|
public string? DeviceName { get; init; }
|
||||||
public string? Series { get; init; }
|
public string? Series { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
|
||||||
|
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
|
||||||
|
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch. The
|
||||||
|
/// driver maps <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c> and re-issues
|
||||||
|
/// unlock + retries the gated call once on that mapping.
|
||||||
|
/// <para><b>No-log invariant:</b> never logged through the driver. The host
|
||||||
|
/// <c>FocasDeviceOptions</c> record overrides <c>ToString</c> to redact this
|
||||||
|
/// field. Stored in <c>appsettings.json</c> alongside the rest of the device
|
||||||
|
/// config; treat as a secret per <c>docs/v2/focas-deployment.md</c>
|
||||||
|
/// § "FOCAS password handling" + cross-link to <c>docs/Security.md</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public string? Password { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FocasTagDto
|
internal sealed class FocasTagDto
|
||||||
|
|||||||
@@ -197,12 +197,44 @@ public sealed record FocasFixedTreeOptions
|
|||||||
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
||||||
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
||||||
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
||||||
|
/// <paramref name="Password"/> (issue #271, plan PR F4-d) is the CNC connection-level
|
||||||
|
/// password emitted via <c>cnc_wrunlockparam</c> on connect when the controller
|
||||||
|
/// gates parameter writes / certain reads behind a password switch (16i + some
|
||||||
|
/// 30i firmwares with parameter-protect on).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>No-log invariant:</b> <see cref="Password"/> is a secret. The driver MUST NOT
|
||||||
|
/// log it. <c>FocasDeviceOptions.ToString()</c> would include the field by default
|
||||||
|
/// because it's a positional record member, so the record's auto-generated
|
||||||
|
/// <c>ToString</c> is overridden via <see cref="PrintMembers"/> below to redact
|
||||||
|
/// the password. Any new logging surface that touches <see cref="FocasDeviceOptions"/>
|
||||||
|
/// must continue to redact. See <c>docs/v2/focas-deployment.md</c> § "FOCAS password
|
||||||
|
/// handling" for the no-log invariant and rotation runbook.</para>
|
||||||
|
/// </remarks>
|
||||||
public sealed record FocasDeviceOptions(
|
public sealed record FocasDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||||
FocasOverrideParameters? OverrideParameters = null);
|
FocasOverrideParameters? OverrideParameters = null,
|
||||||
|
string? Password = null)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #271 (plan PR F4-d) — record auto-generated <c>ToString</c> would print
|
||||||
|
/// <see cref="Password"/> verbatim. Override the printer so the secret is replaced
|
||||||
|
/// with <c>"***"</c> when the field is non-null. The no-log invariant relies on
|
||||||
|
/// this — every Serilog destructure that flows a <see cref="FocasDeviceOptions"/>
|
||||||
|
/// value through <c>{Device}</c> gets redaction for free.
|
||||||
|
/// </summary>
|
||||||
|
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 ? "<null>" : "***");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||||
|
|||||||
@@ -48,6 +48,47 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — emit <c>cnc_wrunlockparam</c> to lift the
|
||||||
|
/// CNC's parameter-protect / read-protect gate. The password is ASCII-encoded
|
||||||
|
/// into a 4-byte buffer (right-padded with <c>0x00</c>; truncated when the
|
||||||
|
/// supplied string exceeds 4 chars — Fanuc's published password buffer is a
|
||||||
|
/// fixed 4-byte slot). Mismatch surfaces as <c>EW_PASSWD</c> mapped to
|
||||||
|
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>; the F4-d retry loop
|
||||||
|
/// in <see cref="FocasDriver"/> re-issues unlock + retries the call once on
|
||||||
|
/// that mapping.
|
||||||
|
/// <para><b>No-log invariant:</b> 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 <c>FocasDeviceOptions.Password</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
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(
|
public Task<(object? value, uint status)> ReadAsync(
|
||||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,25 @@ internal static class FwlibNative
|
|||||||
short length,
|
short length,
|
||||||
ref IODBPSD buffer);
|
ref IODBPSD buffer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_wrunlockparam</c> — 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 <c>EW_*</c> family — <c>EW_PASSWD</c> when the supplied bytes don't
|
||||||
|
/// match the configured password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>P/Invoke shape kept narrow: the caller passes a 4-byte buffer. The
|
||||||
|
/// driver layer ASCII-encodes <c>FocasDeviceOptions.Password</c> into the
|
||||||
|
/// buffer (right-padded with <c>0x00</c>, truncated to 4 bytes) — that's the
|
||||||
|
/// shape every public Fanuc password example we've seen uses.</para>
|
||||||
|
/// </remarks>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_wrunlockparam", ExactSpelling = true)]
|
||||||
|
public static extern short WrUnlockParam(
|
||||||
|
ushort handle,
|
||||||
|
[In] byte[] password);
|
||||||
|
|
||||||
// ---- Macro variables ----
|
// ---- Macro variables ----
|
||||||
|
|
||||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ public interface IFocasClient : IDisposable
|
|||||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — emit the CNC password via FOCAS
|
||||||
|
/// <c>cnc_wrunlockparam</c>. Some controllers (notably 16i + some 30i
|
||||||
|
/// firmwares with parameter-protect on) gate <c>cnc_wrparam</c> 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 <see cref="ConnectAsync"/>).
|
||||||
|
/// <para>Default impl is a no-op (<see cref="Task.CompletedTask"/>) so transport
|
||||||
|
/// variants that don't surface unlock (today: IPC and FAKE clients) keep
|
||||||
|
/// compiling. The FWLIB-backed client overrides this with a real
|
||||||
|
/// <c>cnc_wrunlockparam</c> call.</para>
|
||||||
|
/// <para><b>No-log invariant:</b> <paramref name="password"/> is a secret. The
|
||||||
|
/// wire-client implementation MUST NOT log the password — see
|
||||||
|
/// <c>FocasDeviceOptions.Password</c> + <c>docs/v2/focas-deployment.md</c>
|
||||||
|
/// § "FOCAS password handling".</para>
|
||||||
|
/// </summary>
|
||||||
|
Task UnlockAsync(string password, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read the value at <paramref name="address"/> in the requested
|
/// Read the value at <paramref name="address"/> in the requested
|
||||||
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
||||||
|
|||||||
@@ -40,6 +40,37 @@ internal class FakeFocasClient : IFocasClient
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — count of <see cref="UnlockAsync"/> invocations.
|
||||||
|
/// Tests assert this to verify the driver routed <c>cnc_wrunlockparam</c> on
|
||||||
|
/// connect when <c>FocasDeviceOptions.Password</c> was non-null and re-issued
|
||||||
|
/// unlock on EW_PASSWD retry exactly once.
|
||||||
|
/// </summary>
|
||||||
|
public int UnlockCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — last password observed by <see cref="UnlockAsync"/>.
|
||||||
|
/// Used by the round-trip test to confirm the driver passed the configured
|
||||||
|
/// password through unmodified. <b>This field exists ONLY in the fake — no
|
||||||
|
/// production wire client retains the password past the wire call.</b>
|
||||||
|
/// </summary>
|
||||||
|
public string? LastUnlockPassword { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan PR F4-d (issue #271) — when set, <see cref="UnlockAsync"/> throws on
|
||||||
|
/// invocation so tests can drive the failed-unlock retry path (where the
|
||||||
|
/// driver surfaces BadUserAccessDenied as-is rather than retrying).
|
||||||
|
/// </summary>
|
||||||
|
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(
|
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
359
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs
Normal file
359
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #271, plan PR F4-d — <c>cnc_wrunlockparam</c> coverage. Some controllers
|
||||||
|
/// (notably 16i + some 30i firmwares with parameter-protect on) gate
|
||||||
|
/// <c>cnc_wrparam</c> + selected reads behind a connection-level password. The
|
||||||
|
/// driver emits unlock on connect when <c>FocasDeviceOptions.Password</c> is set,
|
||||||
|
/// and on any read/write returning <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c>
|
||||||
|
/// it re-issues unlock and retries the gated call exactly once.
|
||||||
|
/// </summary>
|
||||||
|
[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("<null>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test fake — first parameter write returns EW_PASSWD-equivalent
|
||||||
|
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>, second returns Good.
|
||||||
|
/// Optionally extends the same shape to <see cref="ReadAsync"/> for
|
||||||
|
/// read-side tests.
|
||||||
|
/// </summary>
|
||||||
|
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<uint> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test fake — every parameter write returns
|
||||||
|
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>. Drives the
|
||||||
|
/// "second attempt also fails" recursion-guard test + the no-password
|
||||||
|
/// pass-through test.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class AlwaysPasswdClient : FakeFocasClient
|
||||||
|
{
|
||||||
|
public override Task<uint> WriteParameterAsync(
|
||||||
|
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ParameterWriteLog.Add((address, type, value));
|
||||||
|
return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #271, plan PR F4-d — series-level (would-be integration) coverage of
|
||||||
|
/// <c>cnc_wrunlockparam</c>. 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 <see cref="FocasUnlockTests"/>; this file is a scaffold that runs
|
||||||
|
/// against a simulator + matching <c>mock_set_password</c> admin endpoint when
|
||||||
|
/// <c>FOCAS_TRUST_WIRE=1</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Build-only today: the simulator gate (FOCAS_TRUST_WIRE) skips at runtime so
|
||||||
|
/// CI doesn't need the simulator binary. When the simulator's
|
||||||
|
/// <c>cnc_wrunlockparam</c> + <c>mock_set_password</c> endpoints land
|
||||||
|
/// (<c>docs/v2/implementation/focas-simulator-plan.md</c>) the gated test
|
||||||
|
/// becomes a real round-trip.
|
||||||
|
/// </remarks>
|
||||||
|
[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 <c>docs/v2/implementation/focas-simulator-plan.md</c>
|
||||||
|
// § "FOCAS password unlock" for the simulator-side endpoints.
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user