@@ -1,5 +1,7 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
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>
|
||||
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) ----
|
||||
//
|
||||
// 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);
|
||||
_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,
|
||||
// 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
|
||||
@@ -1118,6 +1163,73 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private global::S7.Net.Plc RequirePlc() =>
|
||||
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>
|
||||
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
||||
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
||||
|
||||
@@ -85,6 +85,14 @@ public static class S7DriverFactoryExtensions
|
||||
fallback: TsapMode.Auto),
|
||||
LocalTsap = dto.LocalTsap,
|
||||
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,
|
||||
// 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).
|
||||
@@ -264,6 +272,8 @@ public static class S7DriverFactoryExtensions
|
||||
RemoteTsap = options.RemoteTsap,
|
||||
ScanGroupIntervals = options.ScanGroupIntervals,
|
||||
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.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -193,6 +193,90 @@ public sealed class S7DriverOptions
|
||||
/// (every read goes to the wire) — only useful for diagnostics tests.
|
||||
/// </summary>
|
||||
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>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user