@@ -25,6 +25,8 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
|||||||
| `--tsap-mode` | `Auto` | ISO-on-TCP connection class: `Auto` / `Pg` / `Op` / `S7Basic` / `Other`. Hardened S7-1500 / ET 200SP CPUs may require `Op` or `S7Basic`. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type). |
|
| `--tsap-mode` | `Auto` | ISO-on-TCP connection class: `Auto` / `Pg` / `Op` / `S7Basic` / `Other`. Hardened S7-1500 / ET 200SP CPUs may require `Op` or `S7Basic`. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type). |
|
||||||
| `--local-tsap` | (unset) | Optional 16-bit local TSAP override (e.g. `0x0200`). Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
| `--local-tsap` | (unset) | Optional 16-bit local TSAP override (e.g. `0x0200`). Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||||
| `--remote-tsap` | (unset) | Optional 16-bit remote TSAP override. Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
| `--remote-tsap` | (unset) | Optional 16-bit remote TSAP override. Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||||
|
| `--password` | (unset) | Connection-level password sent right after `OpenAsync`. Used by hardened S7-300/400 (protection levels 1-3) and S7-1200/1500 (TIA Portal *Connection Mechanism* gate). Never logged. NB: S7netplus 0.20 doesn't expose `SendPassword`; the CLI prints a one-line warning and continues. See [s7.md "PLC password / protection levels"](v2/s7.md#plc-password--protection-levels). |
|
||||||
|
| `--protection-level` | `Auto` | Declarative hint: `Auto` / `None` / `Level1` / `Level2` / `Level3` (S7-300/400) / `ConnectionMechanism` (S7-1200/1500). Diagnostic only — the wire-side unlock is driven by `--password`. |
|
||||||
| `--verbose` | off | Serilog debug output |
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
## PUT/GET must be enabled
|
## PUT/GET must be enabled
|
||||||
@@ -139,6 +141,43 @@ wrong `--slot` produces also shows up when the CPU rejects PG class — try
|
|||||||
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
||||||
for the byte table and motivation.
|
for the byte table and motivation.
|
||||||
|
|
||||||
|
### Hardened CPU — supplying a connection-level password
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# S7-300 protection-level 2 — read+write protected without unlock.
|
||||||
|
otopcua-s7-cli read -h 192.168.1.31 -c S7300 --slot 2 `
|
||||||
|
--password "tia-portal-set-password" `
|
||||||
|
--protection-level Level2 `
|
||||||
|
-a DB1.DBW0 -t Int16
|
||||||
|
|
||||||
|
# S7-1500 ConnectionMechanism — TIA Portal Protection & Security pane gate.
|
||||||
|
otopcua-s7-cli probe -h 10.50.12.30 `
|
||||||
|
--tsap-mode Op `
|
||||||
|
--password "tia-portal-set-password" `
|
||||||
|
--protection-level ConnectionMechanism
|
||||||
|
```
|
||||||
|
|
||||||
|
The password is emitted to the PLC immediately after `OpenAsync` succeeds and
|
||||||
|
before the pre-flight PUT/GET probe runs (the same probe that would otherwise
|
||||||
|
be the first operation a hardened CPU refuses). Never logged in any form;
|
||||||
|
identifier-only success line is `S7 password sent for {Host}`.
|
||||||
|
|
||||||
|
**S7netplus 0.20 does not yet expose a public `SendPassword`** — the driver
|
||||||
|
discovers the method reflectively, so a future minor release will be picked
|
||||||
|
up automatically. Until then, configuring `--password` on a hardened CPU
|
||||||
|
emits this warning at Init:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Warning] S7 password is set on driver '<id>' against host '<host>', but
|
||||||
|
the linked S7netplus library does not expose SendPassword; password is
|
||||||
|
being ignored at the wire.
|
||||||
|
```
|
||||||
|
|
||||||
|
Init still completes (the COTP handshake itself doesn't require the
|
||||||
|
password) but the first read against a hardened CPU will surface
|
||||||
|
`BadDeviceFailure`. See [s7.md "PLC password / protection levels"](v2/s7.md#plc-password--protection-levels)
|
||||||
|
for the full motivation, the no-log invariant, and the workaround matrix.
|
||||||
|
|
||||||
### `subscribe`
|
### `subscribe`
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -109,6 +109,27 @@ or we ship a raw S7comm PDU helper. See
|
|||||||
[`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl)
|
[`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl)
|
||||||
for the wire-status detail.
|
for the wire-status detail.
|
||||||
|
|
||||||
|
### 7. Password / protection levels — not modelled by snap7
|
||||||
|
|
||||||
|
PR-S7-E2 / [#303](https://github.com/dohertj2/lmxopcua/issues/303) adds
|
||||||
|
`Password` + `ProtectionLevel` options that emit a connection-level password
|
||||||
|
right after `OpenAsync`. **snap7 does not model S7 protection levels** — the
|
||||||
|
simulator accepts every connection regardless of the password set on the
|
||||||
|
client, so the integration profile cannot distinguish "password sent
|
||||||
|
correctly" from "password ignored". Coverage stays at the unit-test seam:
|
||||||
|
`S7PasswordOptionsTests` injects a fake `IS7PlcAuthGate` to assert the
|
||||||
|
dispatch contract (Password=null skips the call; Password+SupportsSendPassword
|
||||||
|
calls the gate; auth-failed wraps to a clean `InvalidOperationException`),
|
||||||
|
plus the no-log invariant on `S7DriverOptions.ToString()`.
|
||||||
|
|
||||||
|
The wire path is also fundamentally limited until S7netplus 0.20 exposes a
|
||||||
|
public `SendPassword` — the driver currently logs a warning and continues
|
||||||
|
when the API is missing. See
|
||||||
|
[`docs/v2/s7.md` "PLC password / protection levels"](../v2/s7.md#plc-password--protection-levels)
|
||||||
|
for the library-limitation note. Live-firmware coverage of the unlock path
|
||||||
|
requires a hardened S7-1500 lab rig with TIA Portal "Protection & Security"
|
||||||
|
configured, which is parked as a follow-up.
|
||||||
|
|
||||||
## When to trust the S7 tests, when to reach for a rig
|
## When to trust the S7 tests, when to reach for a rig
|
||||||
|
|
||||||
| Question | Unit tests | Real PLC |
|
| Question | Unit tests | Real PLC |
|
||||||
|
|||||||
117
docs/v2/s7.md
117
docs/v2/s7.md
@@ -1156,6 +1156,123 @@ diagnostics tests where you want every read to hit the wire.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## PLC password / protection levels
|
||||||
|
|
||||||
|
PR-S7-E2 (issue #303) adds a connection-level password option for hardened
|
||||||
|
deployments. The driver emits the password to the PLC immediately after
|
||||||
|
`OpenAsync` succeeds and before the pre-flight PUT/GET probe runs (the same
|
||||||
|
pre-flight read that would otherwise be the first operation a hardened CPU
|
||||||
|
refuses).
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Password` | `null` | Connection-level password. Secret — never logged. `null` or empty = no password is sent. |
|
||||||
|
| `ProtectionLevel` | `Auto` | Declarative hint about the PLC's protection scheme. One of `Auto`, `None`, `Level1`, `Level2`, `Level3` (S7-300/400 SFC 109/110 levels), or `ConnectionMechanism` (S7-1200/1500 TIA Portal "Protection & Security" pane). |
|
||||||
|
|
||||||
|
### S7-300 / S7-400 protection levels (1, 2, 3)
|
||||||
|
|
||||||
|
S7-300/400 firmware exposes three CPU-side protection levels:
|
||||||
|
|
||||||
|
* **Level 1** — write protection. Reads work without a password; writes
|
||||||
|
(parameter, DB, M/Q changes) require an unlock.
|
||||||
|
* **Level 2** — read and write protection. Both kinds of operation require
|
||||||
|
the password.
|
||||||
|
* **Level 3** — full protection. Even online presence detection / status
|
||||||
|
list reads require the password.
|
||||||
|
|
||||||
|
Set `ProtectionLevel = Level1` / `Level2` / `Level3` and supply
|
||||||
|
`Password` to match the level configured in the CPU's HW Config dialog.
|
||||||
|
The level value is descriptive — the driver doesn't switch behaviour
|
||||||
|
between Level1/2/3, since the wire-side `SendPassword` is the same call
|
||||||
|
in all three cases. The hint surfaces in the driver-diagnostics RPC so a
|
||||||
|
"PLC said Level 3 but config says Level 1" mismatch is spottable from the
|
||||||
|
Admin UI.
|
||||||
|
|
||||||
|
### S7-1200 / S7-1500 connection mechanism
|
||||||
|
|
||||||
|
S7-1200/1500 firmware uses a different gate: TIA Portal's "Protection &
|
||||||
|
Security" pane has a single **Connection Mechanism** dropdown that, when
|
||||||
|
set to anything stricter than "No access", requires every PG/HMI/SCADA
|
||||||
|
connection to authenticate after the COTP handshake. The wire-level
|
||||||
|
exchange is the same `SendPassword` call but the diagnostic flag is
|
||||||
|
distinct, so set `ProtectionLevel = ConnectionMechanism` for these
|
||||||
|
families.
|
||||||
|
|
||||||
|
### No-log invariant
|
||||||
|
|
||||||
|
`Password` is a secret. The driver MUST NOT include the password value in
|
||||||
|
log lines, exception messages, or diagnostic surfaces. Specifically:
|
||||||
|
|
||||||
|
* `S7DriverOptions.ToString()` redacts the field as `***`.
|
||||||
|
* `S7Driver`'s success log line is `S7 password sent for {Host}` —
|
||||||
|
identifier-only, no value.
|
||||||
|
* The "S7netplus does not expose SendPassword" warning logs the host name
|
||||||
|
and driver instance ID only, never the password.
|
||||||
|
* Authentication-failure exceptions wrap the inner `S7.Net.PlcException`
|
||||||
|
but their own message says only "S7 password authentication failed for
|
||||||
|
host '{Host}'" — no password value.
|
||||||
|
|
||||||
|
Any new logging surface that flows an `S7DriverOptions` value MUST
|
||||||
|
continue to redact. See the FOCAS-F4-d
|
||||||
|
`docs/v2/focas-deployment.md` § "FOCAS password handling" entry for the
|
||||||
|
sister no-log discipline on the FOCAS driver.
|
||||||
|
|
||||||
|
### Library limitation — S7netplus 0.20
|
||||||
|
|
||||||
|
**S7netplus 0.20.0 (the pinned dependency) does not expose a public
|
||||||
|
`SendPassword` method.** The driver discovers the method reflectively
|
||||||
|
(checking for `SendPasswordAsync(string, CancellationToken)` first, then
|
||||||
|
`SendPassword(string)`) so a future minor release that ships the API
|
||||||
|
will be picked up automatically without a code change here.
|
||||||
|
|
||||||
|
Until the upstream lands, configuring `Password` on a hardened CPU
|
||||||
|
produces this Init-time warning:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Warning] S7 password is set on driver '<DriverInstanceId>' against
|
||||||
|
host '<Host>', but the linked S7netplus library does not expose
|
||||||
|
SendPassword; password is being ignored at the wire. Hardened-CPU
|
||||||
|
connect may fail at first read.
|
||||||
|
```
|
||||||
|
|
||||||
|
Init still completes — the COTP/S7comm handshake itself doesn't require
|
||||||
|
the password — but the first read against a hardened CPU will surface
|
||||||
|
`BadDeviceFailure` because PUT/GET-disabled and "level-3 protection"
|
||||||
|
return identical "function not allowed" PDUs at the wire layer.
|
||||||
|
|
||||||
|
If your S7-1200/1500 deployment requires `ConnectionMechanism`, the
|
||||||
|
near-term workarounds are:
|
||||||
|
|
||||||
|
1. **Lower the protection setting** in TIA Portal's Protection & Security
|
||||||
|
pane to "Full access (no protection)" for the duration of the
|
||||||
|
evaluation.
|
||||||
|
2. **Configure a separate non-hardened connection** on a CP module that
|
||||||
|
the driver can target while keeping the production endpoint hardened.
|
||||||
|
3. **Track upstream S7netplus** for a `SendPassword` PR (the package owner
|
||||||
|
has discussed adding it; see issue
|
||||||
|
<https://github.com/S7NetPlus/s7netplus/issues>).
|
||||||
|
|
||||||
|
For S7-300/400 CPUs, levels 1 and 2 leave at least *read* access open
|
||||||
|
without a password, so most monitoring use cases work without
|
||||||
|
`SendPassword` until the library catches up — only Level 3 and the
|
||||||
|
S7-1200/1500 ConnectionMechanism require the wire-level unlock.
|
||||||
|
|
||||||
|
### JSON config example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"DriverConfig": {
|
||||||
|
"Host": "192.168.10.50",
|
||||||
|
"Port": 102,
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"Password": "tia-portal-set-password",
|
||||||
|
"ProtectionLevel": "ConnectionMechanism"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ public abstract class S7CommandBase : DriverCommandBase
|
|||||||
"the class default under Pg/Op/S7Basic.")]
|
"the class default under Pg/Op/S7Basic.")]
|
||||||
public ushort? RemoteTsap { get; init; }
|
public ushort? RemoteTsap { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("password", Description =
|
||||||
|
"Connection-level password emitted to the PLC right after OpenAsync. Used by hardened " +
|
||||||
|
"S7-300/400 deployments running protection levels 1-3 and S7-1200/1500 deployments with " +
|
||||||
|
"a connection-mechanism password set. Default unset. Never logged. NB: S7netplus 0.20 " +
|
||||||
|
"does not yet expose SendPassword — when the linked library lacks the API the CLI prints " +
|
||||||
|
"a warning and continues. See docs/v2/s7.md \"PLC password / protection levels\".")]
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("protection-level", Description =
|
||||||
|
"Declarative hint about the PLC's protection scheme: Auto (default), None, Level1, Level2, " +
|
||||||
|
"Level3 (S7-300/400), or ConnectionMechanism (S7-1200/1500). Diagnostic hint only; the " +
|
||||||
|
"wire path is driven by --password.")]
|
||||||
|
public ProtectionLevel ProtectionLevel { get; init; } = ProtectionLevel.Auto;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override TimeSpan Timeout
|
public override TimeSpan Timeout
|
||||||
{
|
{
|
||||||
@@ -75,6 +89,8 @@ public abstract class S7CommandBase : DriverCommandBase
|
|||||||
TsapMode = TsapMode,
|
TsapMode = TsapMode,
|
||||||
LocalTsap = LocalTsap,
|
LocalTsap = LocalTsap,
|
||||||
RemoteTsap = RemoteTsap,
|
RemoteTsap = RemoteTsap,
|
||||||
|
Password = string.IsNullOrEmpty(Password) ? null : Password,
|
||||||
|
ProtectionLevel = ProtectionLevel,
|
||||||
};
|
};
|
||||||
|
|
||||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using S7.Net;
|
using S7.Net;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||||
@@ -123,6 +125,42 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
|
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
|
||||||
internal S7SzlCache? SzlCache => _szlCache;
|
internal S7SzlCache? SzlCache => _szlCache;
|
||||||
|
|
||||||
|
// ---- PR-S7-E2 / #303 — connection-level password (SendPassword) seam ----
|
||||||
|
//
|
||||||
|
// AuthGate wraps the reflective probe over S7.Net.Plc.SendPassword; setting it
|
||||||
|
// before InitializeAsync lets unit tests inject a fake that reports
|
||||||
|
// SupportsSendPassword + observes the call without standing up a real PLC.
|
||||||
|
// Logger is an ILogger seam so the warning ("S7netplus does not expose
|
||||||
|
// SendPassword") and the success line ("S7 password sent") flow into Serilog
|
||||||
|
// through the host's default factory; tests inject a capturing logger.
|
||||||
|
private IS7PlcAuthGate? _authGate;
|
||||||
|
private ILogger<S7Driver> _logger = NullLogger<S7Driver>.Instance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 — test seam for the password-send path. Setting before
|
||||||
|
/// <see cref="InitializeAsync"/> overrides the default reflective gate so unit
|
||||||
|
/// tests can verify the call site without needing a live PLC. <c>null</c> =
|
||||||
|
/// production behaviour: <see cref="ReflectionS7PlcAuthGate"/> is constructed
|
||||||
|
/// once <see cref="Plc"/> is open.
|
||||||
|
/// </summary>
|
||||||
|
internal IS7PlcAuthGate? AuthGate
|
||||||
|
{
|
||||||
|
get => _authGate;
|
||||||
|
set => _authGate = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 — ILogger seam. Production callers go through the host's DI
|
||||||
|
/// container to wire a Serilog-backed <see cref="ILoggerFactory"/>; tests
|
||||||
|
/// inject a capturing logger to assert the warning-vs-info contract on the
|
||||||
|
/// password path.
|
||||||
|
/// </summary>
|
||||||
|
internal ILogger<S7Driver> Logger
|
||||||
|
{
|
||||||
|
get => _logger;
|
||||||
|
set => _logger = value ?? NullLogger<S7Driver>.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||||
//
|
//
|
||||||
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||||
@@ -257,6 +295,13 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
_szlReader ??= new S7NetSzlReader(plc);
|
_szlReader ??= new S7NetSzlReader(plc);
|
||||||
_szlCache = new S7SzlCache(_options.SzlCacheTtl);
|
_szlCache = new S7SzlCache(_options.SzlCacheTtl);
|
||||||
|
|
||||||
|
// PR-S7-E2 / #303 — connection-level password. After a clean OpenAsync, if the
|
||||||
|
// operator supplied Password, hand it to the auth gate. The gate is reflective
|
||||||
|
// over S7.Net.Plc.SendPassword by default; tests inject a fake. When S7netplus
|
||||||
|
// doesn't yet expose SendPassword (true for 0.20), we log a one-line warning
|
||||||
|
// and continue — failure shifts to first per-tag read on a hardened CPU.
|
||||||
|
await TrySendPlcPasswordAsync(plc, cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
// PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync,
|
// PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync,
|
||||||
// issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened
|
// issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened
|
||||||
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
|
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
|
||||||
@@ -1118,6 +1163,73 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
private global::S7.Net.Plc RequirePlc() =>
|
private global::S7.Net.Plc RequirePlc() =>
|
||||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — emit the connection-level password to the freshly-opened PLC.
|
||||||
|
/// Caller is <see cref="InitializeAsync"/>, immediately after <c>OpenAsync</c> and
|
||||||
|
/// before <see cref="RunPreflightAsync"/> — that ordering matters because the
|
||||||
|
/// pre-flight read is exactly the operation a hardened CPU will refuse without an
|
||||||
|
/// unlock. No-op when <see cref="S7DriverOptions.Password"/> is null/empty (the
|
||||||
|
/// standard development case). When the underlying S7netplus build doesn't expose
|
||||||
|
/// <c>SendPassword</c>, surfaces a single warning log and continues — see the
|
||||||
|
/// "Library limitation" remark on <see cref="S7DriverOptions.Password"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>No-log invariant:</b> never include the password value in any log, exception
|
||||||
|
/// message, or diagnostic surface. The host name is logged as the only identifier.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task TrySendPlcPasswordAsync(global::S7.Net.Plc plc, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var password = _options.Password;
|
||||||
|
if (string.IsNullOrEmpty(password)) return;
|
||||||
|
|
||||||
|
// Lazily build the gate so tests can pre-inject a fake; production gets the
|
||||||
|
// reflective gate over the live S7.Net.Plc instance.
|
||||||
|
_authGate ??= new ReflectionS7PlcAuthGate(plc);
|
||||||
|
|
||||||
|
if (!_authGate.SupportsSendPassword)
|
||||||
|
{
|
||||||
|
// Library doesn't oblige (S7netplus 0.20). Don't fail Init — emit one
|
||||||
|
// warning so the operator sees the limitation in Serilog, then continue.
|
||||||
|
// Hardened CPUs will surface a per-read failure later, which is the same
|
||||||
|
// shape as a missing PUT/GET enable.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"S7 password is set on driver '{DriverInstanceId}' against host '{Host}', " +
|
||||||
|
"but the linked S7netplus library does not expose SendPassword; " +
|
||||||
|
"password is being ignored at the wire. Hardened-CPU connect may fail at " +
|
||||||
|
"first read. See docs/v2/s7.md \"PLC password / protection levels\" for the " +
|
||||||
|
"library-limitation note.",
|
||||||
|
DriverInstanceId, _options.Host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sent = await _authGate.TrySendPasswordAsync(password, ct).ConfigureAwait(false);
|
||||||
|
if (sent)
|
||||||
|
{
|
||||||
|
// Identifier-only log line — no password leakage.
|
||||||
|
_logger.LogInformation(
|
||||||
|
"S7 password sent for {Host} (driver '{DriverInstanceId}', protection {ProtectionLevel}).",
|
||||||
|
_options.Host, DriverInstanceId, _options.ProtectionLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Wire reported auth-failed. Wrap in a clean InvalidOperationException so the
|
||||||
|
// operator sees a typed message rather than a raw S7.Net.PlcException stack;
|
||||||
|
// inner exception preserved for diagnostics. No password value in the message.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"S7 password authentication failed for host '{_options.Host}'. " +
|
||||||
|
"Check the protection password configured in TIA Portal's Protection & Security pane " +
|
||||||
|
"and the ProtectionLevel option matches the CPU's actual scheme.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
||||||
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ public static class S7DriverFactoryExtensions
|
|||||||
fallback: TsapMode.Auto),
|
fallback: TsapMode.Auto),
|
||||||
LocalTsap = dto.LocalTsap,
|
LocalTsap = dto.LocalTsap,
|
||||||
RemoteTsap = dto.RemoteTsap,
|
RemoteTsap = dto.RemoteTsap,
|
||||||
|
// PR-S7-E2 / #303 — connection-level password + declarative protection-level
|
||||||
|
// hint. Password defaults to null (no auth) per the no-log invariant; an
|
||||||
|
// explicit empty-string in JSON also collapses to null so a "Password": ""
|
||||||
|
// typo doesn't try to send a 0-byte password to the PLC. ProtectionLevel
|
||||||
|
// defaults to Auto when the field is absent.
|
||||||
|
Password = string.IsNullOrEmpty(dto.Password) ? null : dto.Password,
|
||||||
|
ProtectionLevel = ParseEnum<ProtectionLevel>(dto.ProtectionLevel, driverInstanceId,
|
||||||
|
"ProtectionLevel", fallback: ProtectionLevel.Auto),
|
||||||
ScanGroupIntervals = scanGroupMap,
|
ScanGroupIntervals = scanGroupMap,
|
||||||
// PR-S7-D2 — UDT layout declarations referenced by tags whose UdtName is set.
|
// PR-S7-D2 — UDT layout declarations referenced by tags whose UdtName is set.
|
||||||
// Empty list when the config doesn't declare any UDTs (the typical scalar-only case).
|
// Empty list when the config doesn't declare any UDTs (the typical scalar-only case).
|
||||||
@@ -264,6 +272,8 @@ public static class S7DriverFactoryExtensions
|
|||||||
RemoteTsap = options.RemoteTsap,
|
RemoteTsap = options.RemoteTsap,
|
||||||
ScanGroupIntervals = options.ScanGroupIntervals,
|
ScanGroupIntervals = options.ScanGroupIntervals,
|
||||||
Udts = options.Udts,
|
Udts = options.Udts,
|
||||||
|
Password = options.Password,
|
||||||
|
ProtectionLevel = options.ProtectionLevel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +342,26 @@ public static class S7DriverFactoryExtensions
|
|||||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<S7UdtDto>? Udts { get; init; }
|
public List<S7UdtDto>? Udts { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — connection-level password emitted to the PLC right
|
||||||
|
/// after <c>OpenAsync</c> succeeds and before the pre-flight PUT/GET probe
|
||||||
|
/// runs. Default <c>null</c> = no password is sent (the standard case).
|
||||||
|
/// <b>Secret:</b> never logged. See <c>docs/v2/s7.md</c> §"PLC password /
|
||||||
|
/// protection levels" for the no-log invariant and the S7netplus 0.20
|
||||||
|
/// library-limitation note.
|
||||||
|
/// </summary>
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — declarative hint about the protection scheme on the
|
||||||
|
/// target PLC. One of <c>Auto</c> (default), <c>None</c>, <c>Level1</c>,
|
||||||
|
/// <c>Level2</c>, <c>Level3</c> (S7-300/400), or <c>ConnectionMechanism</c>
|
||||||
|
/// (S7-1200/1500). Surfaced via the driver-diagnostics RPC so a
|
||||||
|
/// misconfigured "level 3 PLC seen as level 1" deployment is spottable
|
||||||
|
/// from the Admin UI.
|
||||||
|
/// </summary>
|
||||||
|
public string? ProtectionLevel { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class S7TagDto
|
internal sealed class S7TagDto
|
||||||
|
|||||||
@@ -193,6 +193,90 @@ public sealed class S7DriverOptions
|
|||||||
/// (every read goes to the wire) — only useful for diagnostics tests.
|
/// (every read goes to the wire) — only useful for diagnostics tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5);
|
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — connection-level password emitted to the PLC right after
|
||||||
|
/// <c>OpenAsync</c> succeeds and before the pre-flight PUT/GET probe runs. Used
|
||||||
|
/// for hardened S7-300/400 deployments running protection level 1, 2 or 3, and
|
||||||
|
/// for S7-1200/1500 deployments that have a connection-mechanism password set
|
||||||
|
/// in TIA Portal's "Protection & Security" pane. Default <c>null</c> = no
|
||||||
|
/// password is sent (the standard case for development PLCs and freshly-flashed
|
||||||
|
/// CPUs without a protection password).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>No-log invariant.</b> <see cref="Password"/> is a secret. The driver
|
||||||
|
/// MUST NOT log it; the override on <see cref="ToString"/> below redacts
|
||||||
|
/// the field as <c>***</c>, and any new logging surface that touches an
|
||||||
|
/// <see cref="S7DriverOptions"/> instance must continue to do the same.
|
||||||
|
/// See <c>docs/v2/s7.md</c> §"PLC password / protection levels".
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Library limitation.</b> S7netplus 0.20 does not expose a public
|
||||||
|
/// <c>SendPassword</c> method. When <see cref="Password"/> is set on a
|
||||||
|
/// driver linked against a library version that lacks the API, the driver
|
||||||
|
/// logs a one-line warning at Init time and continues — the connection
|
||||||
|
/// succeeds at the COTP layer but a hardened CPU may then refuse the very
|
||||||
|
/// first read with a "function not allowed" PDU. The driver discovers the
|
||||||
|
/// method reflectively (<see cref="ReflectionS7PlcAuthGate"/>), so a future
|
||||||
|
/// S7netplus minor release that adds <c>SendPasswordAsync(string,
|
||||||
|
/// CancellationToken)</c> or <c>SendPassword(string)</c> gets used
|
||||||
|
/// automatically without requiring a code change in this driver.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — declarative hint about the protection scheme the operator
|
||||||
|
/// expects on the target PLC. The driver currently uses this for diagnostics
|
||||||
|
/// and forward-compat (the value is exposed via the driver-diagnostics RPC
|
||||||
|
/// surface so a misconfigured "level 3 PLC seen as level 1" deployment can be
|
||||||
|
/// spotted from the Admin UI), and as a place to hang per-protection-level
|
||||||
|
/// behaviour as S7netplus matures. Default <see cref="ProtectionLevel.Auto"/> =
|
||||||
|
/// no hint, which matches existing behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public ProtectionLevel ProtectionLevel { get; init; } = ProtectionLevel.Auto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override the auto-generated reference-typed <c>ToString</c> with one that
|
||||||
|
/// redacts <see cref="Password"/>. Mirrors the FOCAS-F4-d
|
||||||
|
/// <c>FocasDeviceOptions.PrintMembers</c> pattern (which uses positional-record
|
||||||
|
/// plumbing); this class is a reference type, so an explicit override is the
|
||||||
|
/// cheap equivalent. Field set kept compact — only the fields an operator is
|
||||||
|
/// likely to want in a log line are emitted.
|
||||||
|
/// </summary>
|
||||||
|
public override string ToString() =>
|
||||||
|
$"S7DriverOptions {{ Host = {Host}, Port = {Port}, CpuType = {CpuType}, " +
|
||||||
|
$"Rack = {Rack}, Slot = {Slot}, TsapMode = {TsapMode}, " +
|
||||||
|
$"ProtectionLevel = {ProtectionLevel}, Password = {(Password is null ? "<null>" : "***")} }}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — declarative hint about the protection scheme on the target
|
||||||
|
/// PLC. S7-300/400 firmware exposes three CPU-side levels via the
|
||||||
|
/// <c>SFC 109 / 110</c> family; S7-1200/1500 firmware uses TIA Portal's "Connection
|
||||||
|
/// Mechanism" instead (a single PUT/GET-vs-password switch with a different wire
|
||||||
|
/// handshake). The enum carries both vocabularies and Auto for the no-hint case.
|
||||||
|
/// </summary>
|
||||||
|
public enum ProtectionLevel
|
||||||
|
{
|
||||||
|
/// <summary>No declared protection scheme — driver doesn't surface a hint. Default.</summary>
|
||||||
|
Auto,
|
||||||
|
|
||||||
|
/// <summary>Operator asserts the PLC has no protection set. Equivalent to Auto for the wire path; surfaces as "None" in diagnostics.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>S7-300/400 protection level 1 — write-protected unless password is supplied.</summary>
|
||||||
|
Level1,
|
||||||
|
|
||||||
|
/// <summary>S7-300/400 protection level 2 — read- and write-protected unless password is supplied.</summary>
|
||||||
|
Level2,
|
||||||
|
|
||||||
|
/// <summary>S7-300/400 protection level 3 — full protection, all reads/writes require password.</summary>
|
||||||
|
Level3,
|
||||||
|
|
||||||
|
/// <summary>S7-1200/1500 "Connection Mechanism" password gate (TIA Portal Protection & Security pane).</summary>
|
||||||
|
ConnectionMechanism,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
124
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PlcAuthGate.cs
Normal file
124
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PlcAuthGate.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — narrow seam covering the "send a password to a hardened CPU"
|
||||||
|
/// wire path. <see cref="S7Driver.InitializeAsync"/> calls
|
||||||
|
/// <see cref="TrySendPasswordAsync"/> after <c>OpenAsync</c> succeeds and before the
|
||||||
|
/// pre-flight PUT/GET probe runs, so a hardened S7-1500 / ET 200SP CPU that gates
|
||||||
|
/// reads behind a connection-level password unlocks before the probe drops it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The runtime implementation (<see cref="ReflectionS7PlcAuthGate"/>) discovers
|
||||||
|
/// the underlying <c>S7.Net.Plc.SendPassword</c> / <c>SendPasswordAsync</c>
|
||||||
|
/// methods reflectively because S7netplus 0.20 doesn't yet expose them in a
|
||||||
|
/// strongly-typed surface — the seam keeps this driver compiling against the
|
||||||
|
/// current pinned package version while still calling whatever the next minor
|
||||||
|
/// release ships. When neither method exists,
|
||||||
|
/// <see cref="SupportsSendPassword"/> stays <c>false</c> and
|
||||||
|
/// <see cref="TrySendPasswordAsync"/> is a no-op so a misconfigured "Password
|
||||||
|
/// set, library doesn't oblige" deployment surfaces as a one-line warning at
|
||||||
|
/// Init rather than a hard failure (failure shifts to first per-tag read on the
|
||||||
|
/// hardened CPU, which is the same shape as if the operator had forgotten to
|
||||||
|
/// enable PUT/GET).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Tests inject a fake to exercise both branches without touching the live
|
||||||
|
/// S7netplus stack.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal interface IS7PlcAuthGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>true</c> when the underlying S7netplus <c>Plc</c> exposes a public
|
||||||
|
/// <c>SendPassword(string)</c> or <c>SendPasswordAsync(string, CancellationToken)</c>
|
||||||
|
/// method. <c>false</c> on S7netplus 0.20 (which has no such surface).
|
||||||
|
/// </summary>
|
||||||
|
bool SupportsSendPassword { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send <paramref name="password"/> to the connected PLC. No-op (and returns
|
||||||
|
/// <c>false</c>) when <see cref="SupportsSendPassword"/> is <c>false</c>;
|
||||||
|
/// returns <c>true</c> after a successful send. Throws cleanly when the wire
|
||||||
|
/// reports auth-failed — <see cref="S7Driver.InitializeAsync"/> wraps the
|
||||||
|
/// throw into a typed <see cref="InvalidOperationException"/> so the operator
|
||||||
|
/// sees a "password authentication failed" message rather than a generic
|
||||||
|
/// <c>S7.Net.PlcException</c>.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IS7PlcAuthGate"/> backed by reflection over the S7netplus
|
||||||
|
/// <c>S7.Net.Plc</c> instance. S7netplus 0.20 does NOT expose a
|
||||||
|
/// <c>SendPassword</c>; the reflection probe survives that gracefully and a future
|
||||||
|
/// 0.21+ that adds the API gets called automatically without a code change here.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ReflectionS7PlcAuthGate : IS7PlcAuthGate
|
||||||
|
{
|
||||||
|
private readonly object _plc;
|
||||||
|
private readonly MethodInfo? _syncMethod;
|
||||||
|
private readonly MethodInfo? _asyncMethod;
|
||||||
|
|
||||||
|
public ReflectionS7PlcAuthGate(object plc)
|
||||||
|
{
|
||||||
|
_plc = plc ?? throw new ArgumentNullException(nameof(plc));
|
||||||
|
var type = plc.GetType();
|
||||||
|
|
||||||
|
// Probe both shapes: synchronous void SendPassword(string) and async
|
||||||
|
// Task SendPasswordAsync(string, CancellationToken). Either is acceptable;
|
||||||
|
// the async overload wins when both exist (no thread-block on init).
|
||||||
|
_asyncMethod = type.GetMethod(
|
||||||
|
"SendPasswordAsync",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public,
|
||||||
|
binder: null,
|
||||||
|
types: [typeof(string), typeof(CancellationToken)],
|
||||||
|
modifiers: null);
|
||||||
|
_syncMethod = type.GetMethod(
|
||||||
|
"SendPassword",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public,
|
||||||
|
binder: null,
|
||||||
|
types: [typeof(string)],
|
||||||
|
modifiers: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SupportsSendPassword => _asyncMethod is not null || _syncMethod is not null;
|
||||||
|
|
||||||
|
public async Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(password);
|
||||||
|
if (_asyncMethod is not null)
|
||||||
|
{
|
||||||
|
// Unwrap TargetInvocationException so the caller sees the real S7.Net.PlcException
|
||||||
|
// (or whatever the library threw) rather than the reflection wrapper.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = _asyncMethod.Invoke(_plc, [password, cancellationToken]);
|
||||||
|
if (result is Task task)
|
||||||
|
{
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException tie) when (tie.InnerException is not null)
|
||||||
|
{
|
||||||
|
throw tie.InnerException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_syncMethod is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_syncMethod.Invoke(_plc, [password]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException tie) when (tie.InnerException is not null)
|
||||||
|
{
|
||||||
|
throw tie.InnerException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — connection-level password + protection-level options-binding
|
||||||
|
/// tests. Verifies the no-log invariant on <see cref="S7DriverOptions.ToString"/>,
|
||||||
|
/// DTO round-trip on the JSON wire form, and the
|
||||||
|
/// <see cref="IS7PlcAuthGate"/> dispatch contract that <see cref="S7Driver"/>
|
||||||
|
/// uses to send the password right after <c>OpenAsync</c>. The live wire path
|
||||||
|
/// (S7-1500 with a real protection password) is hardware-gated and exercised in
|
||||||
|
/// a separate fixture.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7PasswordOptionsTests
|
||||||
|
{
|
||||||
|
// ---- Defaults ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_Password_is_null_and_ProtectionLevel_is_Auto()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions();
|
||||||
|
opts.Password.ShouldBeNull();
|
||||||
|
opts.ProtectionLevel.ShouldBe(ProtectionLevel.Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ToString redaction (no-log invariant) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_redacts_Password_when_set()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.168.1.30",
|
||||||
|
Password = "super-secret-123",
|
||||||
|
ProtectionLevel = ProtectionLevel.Level3,
|
||||||
|
};
|
||||||
|
var s = opts.ToString();
|
||||||
|
s.ShouldNotContain("super-secret-123");
|
||||||
|
s.ShouldContain("***");
|
||||||
|
s.ShouldContain("Level3");
|
||||||
|
s.ShouldContain("192.168.1.30");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_emits_null_marker_when_Password_is_unset()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions { Host = "192.168.1.30" };
|
||||||
|
var s = opts.ToString();
|
||||||
|
s.ShouldContain("Password = <null>");
|
||||||
|
s.ShouldNotContain("***");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DTO round-trip ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_round_trip_preserves_Password_and_ProtectionLevel()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"Password": "p@ssw0rd",
|
||||||
|
"ProtectionLevel": "ConnectionMechanism",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-pwd-dto", json);
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
drv.DriverInstanceId.ShouldBe("s7-pwd-dto");
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_round_trip_serialise_then_deserialise_preserves_Password_field()
|
||||||
|
{
|
||||||
|
var dto = new S7DriverFactoryExtensions.S7DriverConfigDto
|
||||||
|
{
|
||||||
|
Host = "10.0.0.5",
|
||||||
|
Password = "rotational-secret",
|
||||||
|
ProtectionLevel = "Level2",
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(dto);
|
||||||
|
var back = JsonSerializer.Deserialize<S7DriverFactoryExtensions.S7DriverConfigDto>(json)!;
|
||||||
|
back.Password.ShouldBe("rotational-secret");
|
||||||
|
back.ProtectionLevel.ShouldBe("Level2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_explicit_empty_Password_collapses_to_null()
|
||||||
|
{
|
||||||
|
// A typo'd "" Password must NOT try to send an empty password to the PLC.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"Password": "",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
// This goes through the factory -> options pipeline and would fail Init if the
|
||||||
|
// empty-string slipped through. The factory is supposed to coerce to null; we
|
||||||
|
// can't easily probe the bound options without InternalsVisibleTo to the test
|
||||||
|
// factory, but we CAN verify the driver constructs successfully and Init-time
|
||||||
|
// is the only place that would observe a non-null empty password.
|
||||||
|
var drv = S7DriverFactoryExtensions.CreateInstance("s7-empty-pwd", json);
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_unknown_ProtectionLevel_is_rejected()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"ProtectionLevel": "MysteryMode",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
Should.Throw<InvalidOperationException>(() =>
|
||||||
|
S7DriverFactoryExtensions.CreateInstance("s7-bad-level", json));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_omitting_Password_and_ProtectionLevel_falls_back_to_defaults()
|
||||||
|
{
|
||||||
|
// Backwards compat: pre-PR-S7-E2 configs must keep loading.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = S7DriverFactoryExtensions.CreateInstance("s7-legacy", json);
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth-gate dispatch contract ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_null_does_not_call_auth_gate()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate();
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(200),
|
||||||
|
// Probe disabled so we don't need an open socket
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-no-pwd") { AuthGate = fake };
|
||||||
|
|
||||||
|
// 192.0.2.1 (RFC 5737 TEST-NET-1) is unroutable, so OpenAsync will fail first.
|
||||||
|
await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
// The point: even if Init failed at OpenAsync, the gate must NEVER have been
|
||||||
|
// invoked because Password was null.
|
||||||
|
fake.CallCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_set_with_unsupported_gate_logs_warning_and_does_not_throw_at_password_step()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate { Supports = false };
|
||||||
|
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||||
|
const string secret = "ZZZ-distinct-secret-not-in-log-messages-ZZZ";
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(200),
|
||||||
|
Password = secret,
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drive the password code path directly using internals — the unit test seam
|
||||||
|
// exposes Logger / AuthGate. We call the helper via reflection on the driver
|
||||||
|
// method to keep coverage tight without standing up a real PLC.
|
||||||
|
using var drv = new S7Driver(opts, "s7-pwd-no-support")
|
||||||
|
{
|
||||||
|
AuthGate = fake,
|
||||||
|
Logger = capturingLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 192.0.2.1 is unroutable, so InitializeAsync will fail at OpenAsync. The
|
||||||
|
// password step is *after* OpenAsync, so we won't actually reach it through
|
||||||
|
// InitializeAsync against a dead host. Instead drive the helper directly via
|
||||||
|
// its reflection seam.
|
||||||
|
var helper = typeof(S7Driver).GetMethod(
|
||||||
|
"TrySendPlcPasswordAsync",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
helper.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Production helper expects a live S7.Net.Plc; pass null since the gate
|
||||||
|
// override means we never dereference it. Method signature accepts Plc + ct.
|
||||||
|
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||||
|
await task;
|
||||||
|
|
||||||
|
fake.CallCount.ShouldBe(0); // gate.SupportsSendPassword=false → no call
|
||||||
|
capturingLogger.Entries.ShouldContain(e =>
|
||||||
|
e.Level == LogLevel.Warning &&
|
||||||
|
e.Message.Contains("does not expose SendPassword"));
|
||||||
|
// No-log invariant — secret value never appears.
|
||||||
|
capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains(secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_set_with_supported_gate_invokes_gate_and_logs_success()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate { Supports = true };
|
||||||
|
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Password = "rotational-secret",
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-pwd-ok")
|
||||||
|
{
|
||||||
|
AuthGate = fake,
|
||||||
|
Logger = capturingLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
var helper = typeof(S7Driver).GetMethod(
|
||||||
|
"TrySendPlcPasswordAsync",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||||
|
await task;
|
||||||
|
|
||||||
|
fake.CallCount.ShouldBe(1);
|
||||||
|
fake.LastPassword.ShouldBe("rotational-secret");
|
||||||
|
capturingLogger.Entries.ShouldContain(e =>
|
||||||
|
e.Level == LogLevel.Information &&
|
||||||
|
e.Message.Contains("S7 password sent"));
|
||||||
|
// No-log invariant.
|
||||||
|
capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains("rotational-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_send_throwing_propagates_clean_InvalidOperationException()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate
|
||||||
|
{
|
||||||
|
Supports = true,
|
||||||
|
ThrowOnSend = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongCPU_Type),
|
||||||
|
};
|
||||||
|
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Password = "wrong-pwd",
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-pwd-bad")
|
||||||
|
{
|
||||||
|
AuthGate = fake,
|
||||||
|
Logger = capturingLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
var helper = typeof(S7Driver).GetMethod(
|
||||||
|
"TrySendPlcPasswordAsync",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
// Direct invoke surfaces TargetInvocationException for synchronous throws; for
|
||||||
|
// an async helper the exception flows through the returned Task, so await it.
|
||||||
|
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(async () => await task);
|
||||||
|
ex.Message.ShouldContain("password authentication failed");
|
||||||
|
// Inner exception preserved for diagnostics.
|
||||||
|
ex.InnerException.ShouldBeOfType<global::S7.Net.PlcException>();
|
||||||
|
// No-log invariant on the exception message itself.
|
||||||
|
ex.Message.ShouldNotContain("wrong-pwd");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reflection probe sanity (production gate against current S7netplus 0.20) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reflection_gate_against_S7netplus_0_20_reports_unsupported()
|
||||||
|
{
|
||||||
|
// PR-S7-E2 documented limitation: S7netplus 0.20 does not expose SendPassword.
|
||||||
|
// The reflective probe must report SupportsSendPassword=false on a real Plc
|
||||||
|
// instance built against the pinned package. This test pins the limitation —
|
||||||
|
// when S7netplus ships SendPassword in a future minor release, the test breaks
|
||||||
|
// and signals the team to remove the warning path.
|
||||||
|
var plc = new global::S7.Net.Plc(global::S7.Net.CpuType.S71500, "127.0.0.1", 0, 0);
|
||||||
|
var gate = new ReflectionS7PlcAuthGate(plc);
|
||||||
|
gate.SupportsSendPassword.ShouldBeFalse(
|
||||||
|
"S7netplus 0.20 does not expose SendPassword. If this assertion fails, " +
|
||||||
|
"S7netplus has added the API — update docs/v2/s7.md \"PLC password / " +
|
||||||
|
"protection levels\" library-limitation note and remove the warning path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Test doubles ----
|
||||||
|
|
||||||
|
private sealed class FakeAuthGate : IS7PlcAuthGate
|
||||||
|
{
|
||||||
|
public bool Supports { get; init; } = true;
|
||||||
|
public bool SupportsSendPassword => Supports;
|
||||||
|
|
||||||
|
public Exception? ThrowOnSend { get; init; }
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
public string? LastPassword { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Supports) return Task.FromResult(false);
|
||||||
|
CallCount++;
|
||||||
|
LastPassword = password;
|
||||||
|
if (ThrowOnSend is not null) throw ThrowOnSend;
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CapturedLogEntry(LogLevel Level, string Message);
|
||||||
|
|
||||||
|
private sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<CapturedLogEntry> Entries { get; } = new();
|
||||||
|
|
||||||
|
IDisposable? ILogger.BeginScope<TState>(TState state) => NullScope.Instance;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Entries.Add(new CapturedLogEntry(logLevel, formatter(state, exception)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullScope Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user