Auto: s7-e2 — PLC password / protection-level handling

Closes #303
This commit is contained in:
Joseph Doherty
2026-04-26 10:51:07 -04:00
parent e0f3d1c925
commit 30c3b10c94
9 changed files with 887 additions and 0 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -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}";

View File

@@ -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"

View File

@@ -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

View File

@@ -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 &amp; 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 &amp; Security pane).</summary>
ConnectionMechanism,
} }
/// <summary> /// <summary>

View 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;
}
}

View File

@@ -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() { }
}
}
}