diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index ae4301f88..d59a8ef0a 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -235,6 +235,17 @@ Two things to know before relying on any of this: the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may **not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather than admitting with no roles. +- ⚠️ **The SDK's failed-auth lockout is OFF; ours is UserName-only (#833).** SDK 2.0.1 `SessionManager` refuses + EVERY activation (Anonymous included, before the token is read) from a client with 5 failures in 60 s, for 5 min, + keyed cert-thumbprint-else-peer-IP. `OpcUaApplicationHost.DisableSdkAuthenticationLockout` zeroes + `MaxFailedAuthenticationAttempts` on built AND loaded configs; `FailedCredentialLockout` (`OpcUa:CredentialLockout`, + 5 / 60 s / 300 s) counts and refuses UserName activations only, keyed thumbprint+peer together. Do not re-enable the + SDK setting. The check takes a verification SLOT (`BeginAsync`/`Complete`/`Abandon`) — a check and a + separate record let a parallel burst through. At most `max − failures` verify at once; the rest WAIT (never refused + for parallelism — ScadaBridge opens many keyed sessions from one IP), refused only on lockout or, uncounted, after + `SlotWaitSeconds`. An expired lockout resets the count. On + `None` endpoints the key is effectively IP-only (NAT neighbours share it, UserName only). `docs/security.md` + §"Failed-credential lockout". - **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 38c9b7086..aec25c30f 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -308,7 +308,7 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `InvalidDataException` — before #804 the 400 was a transport fault and the `OUT_OF_RANGE` re-baseline never ran live. The restart (`instanceId`) test also runs on every re-baseline snapshot (on 2.x that is where a restart is first seen). 3 re-baselines with no `/sample` chunk ⇒ Degraded with `LastSuccessfulRead` still advancing (no - watchdog reconnect loop over a `SampleCount` ≥ `bufferSize`). Every parse prohibits DTDs; the streams walk is + watchdog reconnect loop over a `SampleCount` > `bufferSize` — live: `count == bufferSize` is accepted; Test Connect + init warn, #825). Every parse prohibits DTDs; the streams walk is depth-bounded (64 — an unbounded recursion is a process-killing `StackOverflowException`). Non-finite floats are never Good; integer-valued decimals serve integral tags losslessly. Matrix: `docs/drivers/MTConnect.md`. - **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig` diff --git a/docs/Configuration.md b/docs/Configuration.md index a9f229797..23f2e0e55 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -74,6 +74,10 @@ Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the | `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. Must contain ≥1. Profile detail in [`security.md`](security.md). | | `AutoAcceptUntrustedClientCertificates` | bool | `false` | Auto-trust unknown client certs on first connect (dev convenience). Not validated. See [`security.md`](security.md). | | `PeerApplicationUris` | list of string | `[]` (empty) | Partner node `ApplicationUri`s published in `Server.ServerArray` for redundancy discovery. See [`Redundancy.md`](Redundancy.md). | +| `CredentialLockout:MaxFailedAttempts` | int | `5` | Failed UserName (API-key) activations from one client before its UserName activations are refused `BadUserAccessDenied` (#833). `≤ 0` disables. **Never refuses Anonymous.** See [`security.md`](security.md#failed-credential-lockout-opcuacredentiallockout). | +| `CredentialLockout:FailureWindowSeconds` | int | `60` | A failure more than this long after the client's previous one starts a fresh count. Must be `> 0` while the lockout is enabled (validated). | +| `CredentialLockout:SlotWaitSeconds` | int | `10` | How long a UserName activation waits for one of its client's verification slots (at most `MaxFailedAttempts − failures` verify at once; the rest queue) before that one attempt fails `BadTimeout`, uncounted. Must be `> 0` while enabled (validated). | +| `CredentialLockout:LockoutSeconds` | int | `300` | How long a locked-out client's UserName activations are refused; an expired lockout starts a fresh count. Must be `> 0` while the lockout is enabled (validated). | > **Transport security profiles** (the values in `EnabledSecurityProfiles` — `None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`) and the PKI trust flow are documented in full in [`security.md`](security.md). This page does not duplicate them. diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 3731bbb23..222216b11 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -362,7 +362,7 @@ what the driver's cursor expects) is detected **two ways**, both triggering a **Re-baselines without progress are bounded *and visible* (#804).** The reconnect backoff already paced a stream that only ever re-baselines (`A_rebaseline_that_delivers_nothing…`), but every `/current` also marked the stream delivered, so the driver read **Healthy** -for ever while no `/sample` chunk arrived — the live case is a `SampleCount` at or +for ever while no `/sample` chunk arrived — the live case is a `SampleCount` above the Agent's `bufferSize`, refused `OUT_OF_RANGE` (`'count' must be less than N`) on every open. After **3** consecutive re-baselines with no chunk in between (`MaxRebaselinesWithoutProgress`, the Java driver's `MAX_REBASELINES_WITHOUT_PROGRESS`) @@ -371,7 +371,13 @@ observations in between"` and one Warning naming the `bufferSize` cause; it stay Degraded across further re-baselines until a chunk is delivered. Values keep flowing off the re-baselines and `LastSuccessfulRead` keeps advancing — each `/current` is the Agent answering — so the comms-loss watchdog does not reconnect-loop a configuration -problem. Size `SampleCount` below the Agent's `bufferSize` (in the `/probe` header). +problem. Size `SampleCount` at most the Agent's `bufferSize` (in the `/probe` header). +**It is caught before it happens (#825):** the `/probe` header's `bufferSize` is parsed +(`MTConnectProbeModel.BufferSize`, advisory — missing/malformed is null, never a parse failure); **Test +Connect** reads `sampleCount` (default 1000 when absent) and answers Ok **with a warning** (an amber +"OK with warning" chip showing the text) when it is **above** the buffer (live, cppagent 2.7: `count == bufferSize` is +accepted, `bufferSize + 1` refused `'count' must be less than {bufferSize+1}`), and the driver's **initialize** +(every deploy) logs the same Warning and puts `agentBufferSize` on the `/hosts` diagnostics bag. **An Agent `instanceId` change means the Agent restarted**, and is checked **before** the sequence-gap check (a restart also usually trips the gap, so @@ -716,6 +722,23 @@ deliberately different, kept. - **Deferred live checks:** these legs ran through the driver, **not** through a deployed OtOpcUa server and an OPC UA client (the docker-dev rig was not used); the no-progress cap was observed via a `SampleCount` above the Agent's `bufferSize`, not a genuinely lapped ring buffer; no real machine tool or non-cppagent Agent was exercised. + **Closed by the #823 gate below**, except the real-machine / non-cppagent caveat. + +### End-to-end gate through a deployed server (#823, 2026-11-11, docker-dev) + +An MTConnect driver (`SampleCount` 1000, `HeartbeatMs` 10000, `RequestTimeoutMs` 5000) against the shared fixture, +two raw tags (`fixture_x_pos` Double, `fixture_execution` String) referenced into a UNS equipment, deployed to the +central pair, and two Client.CLI subscriptions on central-1 (`ns=3` equipment folder, `ns=2` raw device). Raw and UNS +NodeIds moved together in every leg. Fixture containers were restored healthy afterwards. + +| Leg | What happened on the wire | +|---|---| +| Adapter stop | Both NodeIds `BadNoCommunication` within 1 s of the adapter dying (Agent marks items UNAVAILABLE); driver stayed Healthy. Adapter start ⇒ Good 1–2 s later. | +| Agent restart (`docker restart`, ~12 s) | Stream lost and reconnected ~1 s; the stale cursor was refused `OUT_OF_RANGE`, the re-baseline saw the new `instanceId`, **rediscovery raised** — `/hosts` shows the **RE-BROWSE** chip. Values stayed Good (outage shorter than the comms-loss window). | +| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. | +| Agent paused (`docker pause`) | Stream idle watchdog at 24 s (2 × 10000 + 5000); **Bad at 94 s** (watchdog); unpause ⇒ Good within ~3 s. | +| Genuine buffer overrun | A stalling TCP proxy froze the driver's transport while 32 000 observations were injected (`AllowPut`) — the Agent's `firstSequence` passed the driver's cursor (75 856 → 77 083). On release the resume was refused `OUT_OF_RANGE`, the driver re-baselined from `/current` within 1 s and resumed; values Good, no restart misdiagnosed. A raised-count stream (count 16, interval 5 s) was **not** lapped by a 266 obs/s burst: chunks are sent back-to-back while data is pending, so `interval` does not cap throughput. | +| `SampleCount` vs `bufferSize` (#825) | Test Connect rendered the amber "OK with warning" chip and a deploy logged the Warning (`/hosts` shows `agentBufferSize`). The same gate measured the Agent's real edge — `count = 16384` streams, `16385` is `OUT_OF_RANGE` — so the first build's "at or above" threshold was corrected to "above" (pinned live by `Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size`). | ## Known limitations diff --git a/docs/security.md b/docs/security.md index 83854915b..b2fa3e8e4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -313,6 +313,56 @@ charged process-wide only — never handed a fresh budget. Clients behind one NA guard applies to a driver-only node: its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped. +### Failed-credential lockout (`OpcUa:CredentialLockout`) + +**A failed sign-in never locks a client out of anonymous access (#833).** OPC Foundation SDK 2.0.1's +`SessionManager` has its own lockout: after `ServerConfiguration.MaxFailedAuthenticationAttempts` (SDK +default **5**) failed identity validations — each within 60 s of the previous — it refuses **every** +`ActivateSession` from that client for **5 minutes** with `BadUserAccessDenied` ("Too many failed +authentication attempts"). Its check runs before the identity token is looked at, so it refused +**Anonymous** too (even a namespace-array read needs an activated session), a successful anonymous +activation did not clear it, and the authenticator was never called (no "anonymous admitted" line). Its key +is the client certificate thumbprint when one is presented, else the peer IP — so certificate-less clients +behind one NAT share a key, and on a `None` endpoint a presented certificate is not proven. That is what +the docker-dev live gate hit: four failed `multi-role` UserName activations against central-1, then every +anonymous session refused for five minutes while central-2 (which never saw the failures) kept admitting. + +The host now sets `MaxFailedAuthenticationAttempts = 0` on every configuration it builds or loads +(`OpcUaApplicationHost.DisableSdkAuthenticationLockout`) and enforces a narrower replacement, +`FailedCredentialLockout`, in its identity-registry adapter: + +- **UserName activations only.** A failed one counts; at `MaxFailedAttempts` (5) within + `FailureWindowSeconds` (60) of each other, that client's **UserName** activations are refused + `BadUserAccessDenied` for `LockoutSeconds` (300) without the key store being consulted, and a Warning is + logged once. A refusal during the lockout is not counted (it never extends itself); an **expired lockout + always starts a fresh count**, whatever `FailureWindowSeconds` is; a successful UserName activation clears the + count. **Anonymous activations never consult it**, from any client. The first refusal of each lockout episode + is logged at Information, the rest at Debug. +- **Parallel activations wait — they are never refused for parallelism.** A client gets at most + `MaxFailedAttempts − recordedFailures` verification **slots**; a UserName activation beyond that **queues** (FIFO) + until one frees, so no more than `MaxFailedAttempts` unverified guesses are ever in progress-or-failed per window, + while a legitimate consumer opening many keyed sessions at once (e.g. a ScadaBridge site node behind one NAT IP on a + `None` endpoint) just waits milliseconds and is admitted. A waiter is refused only when the key actually **locks + out** (`BadUserAccessDenied`, without reaching the authenticator), or after `SlotWaitSeconds` (default 10) + with `BadTimeout` — **not** counted as a credential failure. A cancelled activation leaves the queue and frees + any slot it held. +- **Keyed on certificate thumbprint *and* peer address together.** That separates clients only when they present + a certificate. ⚠️ On a `None` endpoint clients commonly present none, so the key is **effectively the IP alone**: + NAT neighbours there **do** share one count (for UserName activations only — never for anonymous ones). When the + transport reports no peer address at all, every certificate-less client shares the single key `-@-`. (Observed on + docker-dev: every host client arrives as `-@192.168.97.1`.) +- **Validated at host start:** with `MaxFailedAttempts > 0`, `LockoutSeconds`, `FailureWindowSeconds` and + `SlotWaitSeconds` must all be `> 0` (`OpcUaApplicationHostOptionsValidator`); `MaxFailedAttempts <= 0` disables + the lockout. +- Every denial counts, including a key-store timeout or miss-budget refusal (as the SDK's did) — a ConfigDb + outage can therefore lock a keyed client out for up to `LockoutSeconds` after recovery. +- Bounded at 4096 tracked clients (expired entries are pruned; a client arriving at a full table is not + tracked). API-key secrets are not guessable, so this is defence in depth, not the brute-force control. + +Pinned over the wire by `CredentialLockoutWireTests` (OpcUaServer.IntegrationTests — anonymous admitted after +six failures; a valid key refused while locked out; `MaxFailedAttempts = 0` admits after seven failures, +proving the SDK lockout is off) and unit-tested by `FailedCredentialLockoutTests`. + ### LDAP bind flow (`OtOpcUaLdapAuthService`) LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs index 210bf153e..1d130fbd1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs @@ -55,6 +55,28 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics /// Times a pump raised its count. Diagnostics only. private long _sampleCountRaises; + /// The Agent's /probe bufferSize as of the last initialize, or 0 when unknown. Diagnostics only. + private long _agentBufferSize; + + /// + /// lmxopcua#825 — the configuration warning for a the Agent + /// will refuse. An Agent answers /sample?count=N with OUT_OF_RANGE ("'count' must be less than + /// bufferSize + 1") when N > bufferSize — live-measured on cppagent 2.7 (bufferSize 16384: 16384 is + /// accepted, 16385 refused) — so such a driver never streams: it re-baselines from + /// /current and reports Degraded after three of them. Shared by + /// (Test Connect) and the driver's initialize (deploy), so both say the same thing. + /// + /// The configured count. + /// The Agent's buffer size, or null when its header did not say. + /// The warning text, or when the count fits (or the buffer size is unknown). + internal static string? SampleCountBufferWarning(int sampleCount, long? bufferSize) => + bufferSize is { } size && sampleCount > size + ? $"SampleCount {sampleCount.ToString(CultureInfo.InvariantCulture)} is above the Agent's bufferSize " + + $"{size.ToString(CultureInfo.InvariantCulture)}: the Agent refuses every /sample stream (OUT_OF_RANGE), so " + + "values will only re-baseline from /current and the driver reports Degraded. Set SampleCount to at most " + + $"{size.ToString(CultureInfo.InvariantCulture)}." + : null; + /// The ceiling a pump started with may raise its count to. /// The configured . /// The ceiling — never below . @@ -77,6 +99,12 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics bag["sampleLag"] = lag.ToString(CultureInfo.InvariantCulture); } + var bufferSize = Interlocked.Read(ref _agentBufferSize); + if (bufferSize > 0) + { + bag["agentBufferSize"] = bufferSize.ToString(CultureInfo.InvariantCulture); + } + bag["sampleSaturatedChunks"] = Interlocked.Read(ref _saturatedChunks).ToString(CultureInfo.InvariantCulture); bag["sampleCountRaises"] = Interlocked.Read(ref _sampleCountRaises).ToString(CultureInfo.InvariantCulture); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index dd6b53940..8cc6566a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -2350,7 +2350,7 @@ public sealed partial class MTConnectDriver // #804 — the backoff above PACES a stream that only ever re-baselines, but each /current // also marked the stream delivered, so it read Healthy for ever while no /sample chunk - // arrived (a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on + // arrived (a SampleCount above the Agent's bufferSize is refused OUT_OF_RANGE on // EVERY open). After MaxRebaselinesWithoutProgress in a row it is reported Degraded, and // stays so (holdStalled) until a chunk is delivered. if (!outcome.DeliveredAny && @@ -2363,7 +2363,7 @@ public sealed partial class MTConnectDriver _logger.LogWarning( outcome.Error, - "MTConnect driver {DriverInstanceId}: {Count} consecutive /current re-baselines of {AgentUri} without a single /sample chunk in between; reporting the stream Degraded until one is delivered. Values are re-baselined, not streamed. An Agent answering every /sample with OUT_OF_RANGE usually means SampleCount is at or above its bufferSize.", + "MTConnect driver {DriverInstanceId}: {Count} consecutive /current re-baselines of {AgentUri} without a single /sample chunk in between; reporting the stream Degraded until one is delivered. Values are re-baselined, not streamed. An Agent answering every /sample with OUT_OF_RANGE usually means SampleCount is above its bufferSize.", _driverInstanceId, rebaselinesWithoutProgress, AgentDisplay(options)); } @@ -3333,6 +3333,15 @@ public sealed partial class MTConnectDriver // ---- commit point: everything below is non-throwing bookkeeping ---- Volatile.Write(ref _options, options); + // lmxopcua#825 — a SampleCount the Agent will refuse is a configuration error; say so at deploy rather than + // leaving it to surface as three progress-free re-baselines and a Degraded stream. + Interlocked.Exchange(ref _agentBufferSize, probe.BufferSize ?? 0); + if (SampleCountBufferWarning(options.SampleCount, probe.BufferSize) is { } bufferWarning) + { + _logger.LogWarning("MTConnect driver {DriverInstanceId} ({AgentUri}): {Warning}", + _driverInstanceId, AgentDisplay(options), bufferWarning); + } + // Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig // blob declares no type takes the type the Agent itself declares for that DataItem, so a // browse-committed tag (whose blob carries only the address) publishes as the number it diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs index 0e668b404..62cd96dc0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs @@ -132,8 +132,18 @@ public sealed class MTConnectDriverProbe : IDriverProbe /// driver's tag/timing configuration — see the "reachable ≠ deployable" remarks on the type. /// public string? DeviceName { get; set; } + + /// + /// The /sample count (lmxopcua#825). Not needed to reach the Agent, but read so Test Connect + /// can warn when it is above the Agent's bufferSize — a config the Agent will refuse on every + /// stream. Absent ⇒ the driver's own default. + /// + public int? SampleCount { get; set; } } + /// The driver's default SampleCount (MTConnectDriver.ParseOptions) when the config omits it. + private const int DefaultSampleCount = 1_000; + /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { @@ -195,10 +205,17 @@ public sealed class MTConnectDriverProbe : IDriverProbe try { using var client = new MTConnectAgentClient(options, _handler); - _ = await client.ProbeAsync(cts.Token).ConfigureAwait(false); + var model = await client.ProbeAsync(cts.Token).ConfigureAwait(false); sw.Stop(); - return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed); + // Reachable but misconfigured stays Ok (the endpoint answered — Test Connect's question), with the + // warning in the message so the operator sees it before deploying (lmxopcua#825). + var warning = MTConnectDriver.SampleCountBufferWarning(dto.SampleCount ?? DefaultSampleCount, model.BufferSize); + return new(true, + warning is null + ? $"MTConnect /probe OK ({safeUri})" + : $"MTConnect /probe OK ({safeUri}) — WARNING: {warning}", + sw.Elapsed); } catch (OperationCanceledException) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index d9992bf01..3a1b48c4c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -8,7 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// XElement. /// /// Every device the Agent's probe response declares. -public sealed record MTConnectProbeModel(IReadOnlyList Devices); +/// +/// The Agent's observation ring-buffer size (the /probe Header's bufferSize), or +/// when the header omits it or it is not a positive integer. A /sample +/// count above it is refused OUT_OF_RANGE by the Agent (lmxopcua#825; live: count == bufferSize is accepted). +/// +public sealed record MTConnectProbeModel(IReadOnlyList Devices, long? BufferSize = null); /// /// Common shape shared by and : diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs index d50d66bfa..e9561a22b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs @@ -132,7 +132,21 @@ internal static class MTConnectProbeParser "MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe."); } - return new MTConnectProbeModel(devices); + return new MTConnectProbeModel(devices, ReadBufferSize(root)); + } + + /// + /// The Header's bufferSize (lmxopcua#825). Advisory only — a missing, malformed or non-positive + /// value is , never a parse failure, because nothing but a configuration warning reads it. + /// + private static long? ReadBufferSize(XElement root) + { + var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault(); + var raw = header is null ? null : MTConnectXml.OptionalAttribute(header, "bufferSize"); + return long.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var size) + && size > 0 + ? size + : null; } private static MTConnectDevice ReadDevice(XElement element) => diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs index c9237a7fd..8285b25dd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs @@ -76,7 +76,7 @@ internal static class MTConnectXml /// /// Why this exists (#804). A modern Agent (cppagent 2.x, the fixture's /// mtconnect/agent:2.7) does not serve its error documents under HTTP 200 the way older - /// ones did: it answers an out-of-range /sample?from= — and a count at or above its + /// ones did: it answers an out-of-range /sample?from= — and a count above its /// buffer — with HTTP 400, and an unknown device with 404, each carrying a /// well-formed MTConnectError body. A status check alone turns those into an /// , which the pump treats as a transient transport loss and diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor index 105fa06b1..e3adbff5b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor @@ -311,12 +311,14 @@ else - - @* One confirm for every destructive row action on this page (R04 H1) — the same RawConfirmDialog - /raw routes its deletes through. A mis-click used to delete immediately. *@ - } + + @* One confirm for every destructive row action on this page (R04 H1) — the same RawConfirmDialog + /raw routes its deletes through. A mis-click used to delete immediately. It must render OUTSIDE the + tab branches (#832): inside the Alarms branch it existed only on that tab, so the Tags tab's Remove + and the Virtual Tags tab's Delete opened a dialog that was never on the page. *@ + } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor index 06ec04ec1..356b8ef7f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor @@ -12,7 +12,16 @@ @if (_result is not null) { - @if (_result.Ok) + @if (_result.Ok && WarningOf(_result.Message) is { } warning) + { + @* Reachable but misconfigured (e.g. MTConnect SampleCount > the Agent's bufferSize, #825): a probe + that answers Ok with a "WARNING:" message is shown amber with the warning, never a plain green OK. *@ + + OK with warning · @_result.LatencyMs?.ToString("F0") ms + +
@warning
+ } + else if (_result.Ok) { OK · @_result.LatencyMs?.ToString("F0") ms @@ -28,6 +37,16 @@ @code { + /// The marker a probe puts in an Ok message to flag a warning (see MTConnectDriverProbe). + internal const string WarningMarker = "WARNING:"; + + /// The warning text after , or null when the message carries none. + internal static string? WarningOf(string? message) + { + var at = message?.IndexOf(WarningMarker, StringComparison.Ordinal) ?? -1; + return at < 0 ? null : message![(at + WarningMarker.Length)..].Trim(); + } + /// Driver type key — must match an installed IDriverProbe.DriverType. [Parameter, EditorRequired] public string DriverType { get; set; } = ""; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs index 789afca9b..c51ed0d40 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs @@ -29,5 +29,15 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase< // IReadOnlyCollection, so it can't bind to MinCount's IReadOnlyCollection parameter // directly. ToList() bridges to the shared primitive while preserving the count (and message). builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles"); + + // #833 review: an enabled credential lockout needs positive durations — a zero LockoutSeconds would lock + // nothing, a zero FailureWindowSeconds would never accumulate. MaxFailedAttempts <= 0 disables it. + var lockout = o.CredentialLockout; + builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0 + || (lockout.LockoutSeconds > 0 && lockout.FailureWindowSeconds > 0 && lockout.SlotWaitSeconds > 0), + "OpcUa:CredentialLockout:LockoutSeconds, OpcUa:CredentialLockout:FailureWindowSeconds and " + + "OpcUa:CredentialLockout:SlotWaitSeconds must all be " + + "greater than 0 while OpcUa:CredentialLockout:MaxFailedAttempts is greater than 0 (set " + + "MaxFailedAttempts to 0 to disable the lockout)"); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index cfaecdca3..61f8db9ab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -113,6 +113,12 @@ public sealed class OpcUaApplicationHostOptions /// own transport defaults, so an absent section changes nothing. ///
public OpcUaQuotaOptions Quotas { get; set; } = new(); + + /// + /// Failed-credential lockout for UserName activations (OpcUa:CredentialLockout:*, lmxopcua#833). + /// Replaces the SDK's own lockout, which also refused Anonymous; see . + /// + public OpcUaCredentialLockoutOptions CredentialLockout { get; set; } = new(); } /// @@ -175,6 +181,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private StandardServer? _server; private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { }); private readonly List _identityAuthenticators = []; + private readonly FailedCredentialLockout _credentialLockout; private readonly object _lifetimeLock = new(); private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _disposeStarted; @@ -191,6 +198,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _options = options; _logger = logger; _userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance; + _credentialLockout = new FailedCredentialLockout(options.CredentialLockout ?? new OpcUaCredentialLockoutOptions()); } /// Gets the OPC Foundation application instance, or null if not yet started. @@ -260,14 +268,73 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable ct.ThrowIfCancellationRequested(); var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription); var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress; - // Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port. - await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous, - peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false); - return ServiceResult.IsBad(args.IdentityValidationError) - ? AuthenticationResult.Reject(args.IdentityValidationError) - : args.Identity is { } identity - ? AuthenticationResult.Accept(identity) - : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected)); + + // lmxopcua#833 — only credential-bearing (UserName) activations are counted and refused. Anonymous + // never consults the lockout, so failed sign-ins cannot lock a client (or its NAT neighbours) out of + // anonymous access the way the SDK's own lockout did. + var lockoutKey = tokenType == UserTokenType.UserName + ? FailedCredentialLockout.KeyFor(context.ChannelCertificate?.Thumbprint, peer) + : null; + if (lockoutKey is not null) + { + // Waits (bounded) for one of this client's verification slots — parallel activations queue, they are + // never refused for parallelism. Refused only when locked out, or (uncounted) when no slot freed in time. + var decision = await host._credentialLockout.BeginAsync(lockoutKey, ct).ConfigureAwait(false); + if (decision.Verdict == CredentialLockoutVerdict.LockedOut) + { + // Logged once per lockout episode at Information; every further refusal at Debug, so a client + // hammering a lockout cannot flood the log (#833 review). + var seconds = (long)Math.Ceiling(decision.Remaining.TotalSeconds); + host._logger.Log(decision.FirstRefusal ? LogLevel.Information : LogLevel.Debug, + "OpcUaApplicationHost: UserName activation refused — client {Client} is locked out for {Seconds}s more", + lockoutKey, seconds); + return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadUserAccessDenied, + $"Too many failed authentication attempts. Try again in {seconds} seconds.")); + } + + if (decision.Verdict == CredentialLockoutVerdict.SlotWaitTimedOut) + { + host._logger.LogWarning( + "OpcUaApplicationHost: UserName activation for client {Client} timed out waiting for a " + + "verification slot (OpcUa:CredentialLockout:SlotWaitSeconds); not counted as a failure", + lockoutKey); + return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadTimeout, + "Timed out waiting to verify the credential. Try again.")); + } + } + + // The slot BeginAsync granted is released on every path — completed with the outcome, or abandoned + // (uncounted) when the activation was cancelled or threw. + var completed = false; + try + { + // Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port. + await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous, + peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false); + var result = ServiceResult.IsBad(args.IdentityValidationError) + ? AuthenticationResult.Reject(args.IdentityValidationError) + : args.Identity is { } identity + ? AuthenticationResult.Accept(identity) + : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected)); + + if (lockoutKey is not null) + { + var admitted = args.Identity is not null && !ServiceResult.IsBad(args.IdentityValidationError); + completed = true; + if (host._credentialLockout.Complete(lockoutKey, admitted)) + { + host._logger.LogWarning( + "OpcUaApplicationHost: client {Client} locked out of UserName activation for {Seconds}s after " + + "{Attempts} failed attempts (OpcUa:CredentialLockout); anonymous activation is unaffected", + lockoutKey, host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts); + } + } + return result; + } + finally + { + if (lockoutKey is not null && !completed) host._credentialLockout.Abandon(lockoutKey); + } } } @@ -488,7 +555,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath)) { - return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct); + var loaded = await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct); + if (loaded.ServerConfiguration is { } loadedServer) DisableSdkAuthenticationLockout(loadedServer); + return loaded; } var serverConfig = new ServerConfiguration @@ -496,6 +565,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"], }; ApplyRequestQuotas(serverConfig, _options.Quotas); + DisableSdkAuthenticationLockout(serverConfig); serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray(); serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray(); @@ -531,6 +601,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return config; } + /// + /// Switches off the SDK's per-client failed-authentication lockout (lmxopcua#833). It refuses EVERY + /// activation from a locked client — Anonymous included — for 5 minutes; + /// enforces the UserName-only replacement. + /// + /// The configuration to set. + internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) => + serverConfig.MaxFailedAuthenticationAttempts = 0; + /// /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at /// 5 / 100 / 200) to the SDK server configuration. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs new file mode 100644 index 000000000..24fffa4dc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/FailedCredentialLockout.cs @@ -0,0 +1,414 @@ +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +/// +/// The OpcUa:CredentialLockout section: how many failed UserName activations one client may make +/// before its UserName activations are refused for a while (lmxopcua#833). +/// +/// +/// The defaults are the OPC Foundation SDK 2.0.1 SessionManager lockout this replaces: 5 failures, +/// each inside 60 s of the previous one, lock the client out for 5 minutes. When enabled +/// ( > 0) every duration must be positive — enforced at host start by +/// OpcUaApplicationHostOptionsValidator. +/// +public sealed class OpcUaCredentialLockoutOptions +{ + /// Failed UserName activations that lock a client out; 0 or less disables the lockout. + public int MaxFailedAttempts { get; set; } = 5; + + /// A failure more than this many seconds after the previous one starts a fresh count. + public int FailureWindowSeconds { get; set; } = 60; + + /// How long a locked-out client's UserName activations are refused, in seconds. + public int LockoutSeconds { get; set; } = 300; + + /// + /// How long a UserName activation waits for one of its client's verification slots before that one attempt + /// is failed (BadTimeout, NOT counted as a credential failure). Only reached when a client has more + /// verifications in progress than minus its recorded failures; a verification + /// is milliseconds, so in practice this bounds a pathological store stall. Kept well under the SDK's + /// operation timeout so the client gets this answer rather than a transport timeout. + /// + public int SlotWaitSeconds { get; set; } = 10; +} + +/// The outcome of . +public enum CredentialLockoutVerdict +{ + /// A verification slot is held — authenticate, then call . + Proceed, + + /// The client is locked out; refuse without authenticating. + LockedOut, + + /// No slot freed within the wait; fail this one attempt, uncounted. + SlotWaitTimedOut, +} + +/// What decided. +/// Proceed, locked out, or timed out waiting. +/// Time left on the lockout when . +/// True for the first refusal of a lockout episode (log it at Information, the rest at Debug). +public readonly record struct CredentialLockoutDecision( + CredentialLockoutVerdict Verdict, TimeSpan Remaining, bool FirstRefusal); + +/// +/// Brute-force lockout for UserName (API-key) activations, scoped so it can never refuse an +/// anonymous activation (lmxopcua#833). +/// +/// +/// +/// Why this exists instead of the SDK's own lockout. OPC Foundation SDK 2.0.1's +/// SessionManager.ActivateSessionAsync counts every failed identity validation per client and, +/// at ServerConfiguration.MaxFailedAuthenticationAttempts (default 5), refuses every +/// activation from that client for 5 minutes with BadUserAccessDenied — the lockout check runs +/// before the token is even looked at, so it refuses Anonymous too, and a successful anonymous +/// activation does not clear it. Its key is the client certificate thumbprint when one is presented, +/// else the peer IP: on a None endpoint the presented certificate is not proven, so a client +/// could lock out another client's sessions by presenting its certificate, and behind a NAT every +/// certificate-less client shares one key. The host therefore sets +/// MaxFailedAuthenticationAttempts = 0 and enforces this narrower lockout instead. +/// +/// +/// What it does. Only UserName activations are counted and refused; Anonymous never consults it. +/// The key is the presented client certificate thumbprint and the observed peer address together. +/// ⚠️ That only separates clients that present a certificate: on a None endpoint clients commonly +/// present none, so the key is effectively the IP alone and NAT neighbours there share one count +/// (for UserName activations only — never for anonymous ones); and when the transport reports no peer +/// address, every certificate-less client shares the single key -@-. Timing: a failure within +/// of the previous one adds to the +/// count; at the client is refused for +/// ; a refusal during the lockout is not +/// counted (the lockout never extends itself); an expired lockout starts a fresh count (whatever the +/// window); a successful UserName activation clears the client's count. +/// +/// +/// Concurrency — parallelism waits, it is never refused. The authenticate call is awaited between the +/// check and the outcome, so a check and a separate record would let a parallel burst of guesses all pass +/// before the first failure lands. therefore hands out at most +/// MaxFailedAttempts − recordedFailures verification slots per key; an activation beyond that +/// waits (FIFO) for a slot, and / release it and wake the +/// next waiter. So no more than MaxFailedAttempts unverified guesses are ever in progress-or-failed +/// per window, while a legitimate client opening many keyed sessions at once (a ScadaBridge site node behind +/// one NAT IP) just queues for milliseconds. Waiters are refused only when the key actually locks out (they +/// never reach the authenticator) or after +/// (uncounted); a cancelled waiter is removed from the queue. +/// +/// +/// Memory is bounded at : entries with no failures, slots or waiters are +/// pruned when it fills, and a client that still finds it full is not tracked (it can only evade by +/// rotating certificates or addresses, which it could do against the SDK lockout too; API-key secrets are +/// not guessable). +/// +/// +public sealed class FailedCredentialLockout +{ + /// The most clients tracked at once. + public const int MaxTrackedClients = 4096; + + private readonly OpcUaCredentialLockoutOptions _options; + private readonly TimeProvider _time; + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + /// Initializes a new instance of the class. + /// The configured thresholds. + /// The clock; when null. + public FailedCredentialLockout(OpcUaCredentialLockoutOptions options, TimeProvider? time = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _time = time ?? TimeProvider.System; + } + + /// Whether the lockout is switched on. + public bool Enabled => _options.MaxFailedAttempts > 0; + + /// Builds the lockout key for a client. + /// The presented client certificate's thumbprint, if any. + /// The transport-observed peer address, if known. + /// The key; never null. + public static string KeyFor(string? certificateThumbprint, System.Net.IPAddress? peer) => + $"{(string.IsNullOrEmpty(certificateThumbprint) ? "-" : certificateThumbprint)}@{peer?.ToString() ?? "-"}"; + + /// + /// Acquires a verification slot for , waiting for one when the client already has as + /// many in progress as it may. On the caller MUST release the + /// slot with or . + /// + /// The client key from . + /// Cancels the wait; a cancelled waiter is dequeued and the exception propagates. + /// The decision. + public async ValueTask BeginAsync(string key, CancellationToken ct = default) + { + if (!Enabled) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false); + + Waiter waiter; + Entry entry; + lock (_gate) + { + var now = _time.GetUtcNow(); + if (!_entries.TryGetValue(key, out var existing)) + { + if (_entries.Count >= MaxTrackedClients) Prune(now); + // Untracked when still full: it gets no slot accounting, as before (see remarks). + if (_entries.Count >= MaxTrackedClients) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false); + existing = new Entry(); + _entries[key] = existing; + } + + entry = existing; + Refresh(entry, now); + if (TryLockedOut(entry, now, out var refused)) return refused; + if (entry.Waiters.Count == 0 && HasFreeSlot(entry)) + { + entry.InFlight++; + return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false); + } + + waiter = new Waiter(); + entry.Waiters.AddLast(waiter.Node = new LinkedListNode(waiter)); + } + + var wait = TimeSpan.FromSeconds(Math.Max(1, _options.SlotWaitSeconds)); + try + { + return await waiter.Completion.Task.WaitAsync(wait, _time, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is TimeoutException or OperationCanceledException) + { + lock (_gate) + { + if (waiter.Completion.Task.IsCompletedSuccessfully) + { + // Decided in the same instant the wait gave up. A timeout takes the decision as made; a + // cancellation hands a granted slot back (waking the next waiter) so nothing leaks. + var decided = waiter.Completion.Task.Result; + if (ex is not OperationCanceledException) return decided; + if (decided.Verdict == CredentialLockoutVerdict.Proceed) ReleaseCore(key, entry, outcome: null); + } + else + { + entry.Waiters.Remove(waiter.Node!); + waiter.Completion.TrySetCanceled(); + RemoveIfIdle(key, entry); + } + } + + if (ex is OperationCanceledException) throw; + return new(CredentialLockoutVerdict.SlotWaitTimedOut, TimeSpan.Zero, false); + } + } + + /// Releases a slot from and records the activation's outcome. + /// The client key. + /// Whether the activation was admitted. + /// when this failure locked the client out. + public bool Complete(string key, bool succeeded) + { + if (!Enabled) return false; + lock (_gate) + { + return _entries.TryGetValue(key, out var entry) && ReleaseCore(key, entry, succeeded); + } + } + + /// Releases a slot without recording an outcome (the activation was cancelled or threw). + /// The client key. + public void Abandon(string key) + { + if (!Enabled) return; + lock (_gate) + { + if (_entries.TryGetValue(key, out var entry)) ReleaseCore(key, entry, outcome: null); + } + } + + /// Whether is locked out right now (observation; reserves nothing). + /// The client key from . + /// The time left on the lockout when this returns true. + /// when the client's UserName activations are refused. + public bool IsLockedOut(string key, out TimeSpan remaining) + { + remaining = TimeSpan.Zero; + if (!Enabled) return false; + var now = _time.GetUtcNow(); + lock (_gate) + { + if (!_entries.TryGetValue(key, out var entry)) return false; + Refresh(entry, now); + if (entry.LockedUntil <= now) return false; + remaining = entry.LockedUntil - now; + return true; + } + } + + /// Records one failed UserName activation with no slot (test seam / direct callers). + /// The client key. + /// when this failure locked the client out. + public bool RecordFailure(string key) + { + if (!Enabled) return false; + var now = _time.GetUtcNow(); + lock (_gate) + { + if (!_entries.TryGetValue(key, out var entry)) + { + if (_entries.Count >= MaxTrackedClients) Prune(now); + if (_entries.Count >= MaxTrackedClients) return false; + entry = new Entry(); + _entries[key] = entry; + } + var locked = RecordFailureCore(entry, now); + Pump(entry, now); + return locked; + } + } + + /// Clears a client's count after a successful UserName activation (no slot). + /// The client key. + public void RecordSuccess(string key) + { + lock (_gate) + { + if (!_entries.TryGetValue(key, out var entry)) return; + ClearCount(entry); + Pump(entry, _time.GetUtcNow()); + RemoveIfIdle(key, entry); + } + } + + /// How many clients are tracked (test observation). + internal int TrackedCount + { + get { lock (_gate) return _entries.Count; } + } + + /// How many activations are waiting for a slot on (test observation). + internal int WaitingCount(string key) + { + lock (_gate) return _entries.TryGetValue(key, out var e) ? e.Waiters.Count : 0; + } + + private bool HasFreeSlot(Entry entry) => entry.Failures + entry.InFlight < _options.MaxFailedAttempts; + + private bool TryLockedOut(Entry entry, DateTimeOffset now, out CredentialLockoutDecision refused) + { + refused = default; + if (entry.LockedUntil <= now) return false; + refused = new(CredentialLockoutVerdict.LockedOut, entry.LockedUntil - now, !entry.RefusalLogged); + entry.RefusalLogged = true; + return true; + } + + /// Releases one slot, records (null = uncounted) and wakes waiters. + private bool ReleaseCore(string key, Entry entry, bool? outcome) + { + var now = _time.GetUtcNow(); + if (entry.InFlight > 0) entry.InFlight--; + var lockedNow = false; + if (outcome == true) ClearCount(entry); + else if (outcome == false) lockedNow = RecordFailureCore(entry, now); + Pump(entry, now); + RemoveIfIdle(key, entry); + return lockedNow; + } + + /// + /// Hands freed slots to waiters in order — or, when the key is locked out, refuses every waiter (they never + /// reach the authenticator). Called under the gate after anything that can free a slot or lock the key. + /// + private void Pump(Entry entry, DateTimeOffset now) + { + Refresh(entry, now); + while (entry.Waiters.First is { } first) + { + if (TryLockedOut(entry, now, out var refused)) + { + entry.Waiters.RemoveFirst(); + first.Value.Completion.TrySetResult(refused); + continue; + } + + if (!HasFreeSlot(entry)) return; + entry.Waiters.RemoveFirst(); + entry.InFlight++; + if (!first.Value.Completion.TrySetResult(new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false))) + entry.InFlight--; // a waiter that already gave up never takes the slot + } + } + + private static void ClearCount(Entry entry) + { + entry.Failures = 0; + entry.LockedUntil = default; + entry.RefusalLogged = false; + } + + private bool RecordFailureCore(Entry entry, DateTimeOffset now) + { + Refresh(entry, now); + if (entry.LockedUntil > now) return false; // a refusal (or a late in-flight failure) never extends it + entry.Failures++; + entry.LastFailure = now; + if (entry.Failures < _options.MaxFailedAttempts) return false; + entry.LockedUntil = now + TimeSpan.FromSeconds(Math.Max(0, _options.LockoutSeconds)); + entry.RefusalLogged = false; + return entry.LockedUntil > now; + } + + /// + /// Forgets a run of failures that has gone stale: an expired lockout always starts a fresh count (else a + /// LockoutSeconds shorter than FailureWindowSeconds relocked on the first failure after it), + /// and so does a failure older than the window. + /// + private void Refresh(Entry entry, DateTimeOffset now) + { + if (entry.LockedUntil != default && entry.LockedUntil <= now) + { + ClearCount(entry); + } + else if (entry.LockedUntil == default && entry.Failures > 0 + && now - entry.LastFailure > TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds))) + { + entry.Failures = 0; + entry.RefusalLogged = false; + } + } + + private void RemoveIfIdle(string key, Entry entry) + { + if (IsIdle(entry, _time.GetUtcNow())) _entries.Remove(key); + } + + private void Prune(DateTimeOffset now) + { + foreach (var key in _entries.Where(e => IsIdle(e.Value, now)).Select(e => e.Key).ToList()) + _entries.Remove(key); + } + + /// Idle = nothing to remember: no failures, no lockout, no slot held and nobody waiting. + private bool IsIdle(Entry entry, DateTimeOffset now) + { + Refresh(entry, now); + return entry.InFlight == 0 && entry.Waiters.Count == 0 && entry.Failures == 0 && entry.LockedUntil == default; + } + + /// One activation waiting for a slot. + private sealed class Waiter + { + public readonly TaskCompletionSource Completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public LinkedListNode? Node; + } + + /// One client's failure count, slots, waiters and lockout. Mutated only under the gate. + private sealed class Entry + { + public int Failures; + public int InFlight; + public DateTimeOffset LastFailure; + public DateTimeOffset LockedUntil; + public bool RefusalLogged; + public readonly LinkedList Waiters = new(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs index 47a57aa6d..7df610d57 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -868,7 +868,7 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) } /// - /// #804 — a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on + /// #804 — a SampleCount above the Agent's bufferSize is refused OUT_OF_RANGE on /// EVERY /sample open (live: 'count' must be less than N). The driver keeps serving values off /// /current re-baselines, and — after three in a row with no /sample chunk — reports the stream /// Degraded with LastSuccessfulRead still advancing, rather than Healthy for ever (before #804 the @@ -947,6 +947,44 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) seen.Skip(before).ShouldContain(e => e.Snapshot.StatusCode == Good); } + /// + /// #825 — Test Connect reads the live Agent's /probe bufferSize and warns (still Ok) when the + /// configured SampleCount would be refused. The live Agent accepts count == bufferSize and refuses + /// bufferSize + 1, so both sides of that edge are checked against the Agent itself as well as the probe. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size() + { + SkipIfDown(); + + var bufferSize = (int?)agent.ProbeDocument?.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "Header") + ?.Attribute("bufferSize"); + if (bufferSize is not > 1) + { + Assert.Skip($"The Agent at {agent.AgentUri} declares no usable bufferSize in its /probe header."); + } + + // The Agent's own edge, so the warning threshold is pinned to what the Agent actually does. + using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }) + { + (await http.GetStringAsync($"{agent.AgentUri}/sample?count={bufferSize.Value}", Ct)).ShouldNotContain("OUT_OF_RANGE"); + var over = await http.GetAsync($"{agent.AgentUri}/sample?count={bufferSize.Value + 1}", Ct); + (await over.Content.ReadAsStringAsync(Ct)).ShouldContain("OUT_OF_RANGE"); + } + + var refused = await new MTConnectDriverProbe().ProbeAsync( + JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value + 1 }), + TimeSpan.FromSeconds(15), Ct); + refused.Ok.ShouldBeTrue(refused.Message); + refused.Message!.ShouldContain($"WARNING: SampleCount {bufferSize.Value + 1} is above the Agent's bufferSize {bufferSize.Value}"); + + var fits = await new MTConnectDriverProbe().ProbeAsync( + JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value }), + TimeSpan.FromSeconds(15), Ct); + fits.Ok.ShouldBeTrue(fits.Message); + fits.Message!.ShouldNotContain("WARNING"); + } + private static bool Is(string? value, string expected) => string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs new file mode 100644 index 000000000..dbe00e969 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Net.Http; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// lmxopcua#825 — a SampleCount above the Agent's bufferSize is refused OUT_OF_RANGE on +/// every /sample stream. The /probe header carries bufferSize, so Test Connect and the +/// driver's initialize (deploy) both warn instead of leaving it to surface as a Degraded stream. +/// +[Trait("Category", "Unit")] +public sealed class MTConnectBufferSizeWarningTests +{ + private static string ProbeXml(string? bufferSizeAttribute) => + $""" + + +
+ + + + + + + """; + + [Theory] + [InlineData("16384", 16384L)] + [InlineData(null, null)] + [InlineData("0", null)] + [InlineData("-5", null)] + [InlineData("lots", null)] + public void The_probe_parser_reads_the_header_buffer_size_and_never_fails_on_it(string? attribute, long? expected) => + MTConnectProbeParser.Parse(ProbeXml(attribute)).BufferSize.ShouldBe(expected); + + [Theory] + [InlineData(16384, 16384L, false)] // live, cppagent 2.7: count == bufferSize is accepted + [InlineData(16385, 16384L, true)] // ... and bufferSize + 1 refused OUT_OF_RANGE + [InlineData(20000, 16384L, true)] + [InlineData(1_000_000, null, false)] + public void The_warning_fires_only_above_the_buffer_size(int sampleCount, long? bufferSize, bool warns) + { + var warning = MTConnectDriver.SampleCountBufferWarning(sampleCount, bufferSize); + (warning is not null).ShouldBe(warns); + if (warns) warning!.ShouldContain($"at most {bufferSize}"); + } + + [Fact] + public async Task Test_Connect_stays_ok_but_carries_the_warning_when_SampleCount_does_not_fit() + { + var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("16384"))); + var r = await probe.ProbeAsync( + "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":16385}", TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + r.Ok.ShouldBeTrue("the Agent answered — reachability is not the problem"); + r.Message!.ShouldContain("WARNING:"); + r.Message!.ShouldContain("bufferSize 16384"); + } + + [Fact] + public async Task Test_Connect_checks_the_driver_default_when_SampleCount_is_absent() + { + // The default SampleCount is 1000 — an Agent with a tiny buffer refuses it too. + var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("512"))); + var r = await probe.ProbeAsync( + "{\"agentUri\":\"http://stub-agent:5000\"}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + + r.Ok.ShouldBeTrue(); + r.Message!.ShouldContain("SampleCount 1000 is above the Agent's bufferSize 512"); + } + + [Fact] + public async Task Test_Connect_message_is_unchanged_when_SampleCount_fits() + { + var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("16384"))); + var r = await probe.ProbeAsync( + "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":16384}", TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + r.Ok.ShouldBeTrue(); + r.Message!.ShouldNotContain("WARNING"); + } + + [Theory] + [InlineData(131073, true)] + [InlineData(131072, false)] + [InlineData(1000, false)] + public async Task Initialize_warns_and_surfaces_the_buffer_size_on_the_diagnostics_bag(int sampleCount, bool warns) + { + var client = CannedAgentClient.FromFixtures(); // Fixtures/probe.xml: bufferSize="131072" + client.Probe.BufferSize.ShouldBe(131072L); + var logger = new RecordingDriverLogger(); + var driver = new MTConnectDriver( + new MTConnectDriverOptions + { + AgentUri = "http://fixture-agent:5000", + RequestTimeoutMs = 5000, + SampleCount = sampleCount, + Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)], + }, + "mt-825", + _ => client, + logger); + try + { + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + logger.Warnings.Any(w => w.Contains("bufferSize", StringComparison.Ordinal)).ShouldBe(warns); + driver.GetDiagnostics()["agentBufferSize"].ShouldBe("131072"); + } + finally + { + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + } + } + + private sealed class StubHandler(string probeXml) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(probeXml, System.Text.Encoding.UTF8, "application/xml"), + }); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs index ccaccf26a..b60d1683e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs @@ -1168,7 +1168,7 @@ public sealed class MTConnectSubscribeTests /// /// #804 (Java-driver parity: MAX_REBASELINES_WITHOUT_PROGRESS). An Agent that answers EVERY - /// /sample open with OUT_OF_RANGE (live: a count at or above its bufferSize) is + /// /sample open with OUT_OF_RANGE (live: a count above its bufferSize) is /// re-baselined from /current each time, and the backoff paces that — but every re-baseline also marked /// the stream delivered, so the driver read Healthy for ever while not one /sample chunk arrived. /// After in a row it is Degraded and stays so diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DeleteConfirmationWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DeleteConfirmationWiringTests.cs index 3505c267b..767554a60 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DeleteConfirmationWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DeleteConfirmationWiringTests.cs @@ -28,6 +28,31 @@ public sealed class DeleteConfirmationWiringTests Regex.Matches(src, "btn-outline-danger").Count.ShouldBe(3); } + /// + /// #832: the shared confirm used to sit INSIDE the Alarms tab branch, so it was on the page only while that + /// tab was showing — the Tags tab's Remove and the Virtual Tags tab's Delete set the dialog visible and + /// nothing appeared. Pinned by brace depth: the dialog must be at the depth of the tab @if chain, + /// not inside any branch of it. + /// + [Fact] + public void Equipment_page_confirm_renders_outside_every_tab_branch() + { + var src = AdminUiSource.Read("Components", "Pages", "Uns", "EquipmentPage.razor"); + var markup = src[..src.IndexOf("@code", StringComparison.Ordinal)]; + + int DepthAt(int index) => + markup[..index].Count(c => c == '{') - markup[..index].Count(c => c == '}'); + + var tabChain = markup.IndexOf("@if (_activeTab == \"details\")", StringComparison.Ordinal); + var dialog = markup.IndexOf("#833 review: an enabled lockout with a non-positive duration fails; a disabled one is not checked. + [Theory] + [InlineData(5, 0, 60, false)] + [InlineData(5, 300, 0, false)] + [InlineData(5, -1, -1, false)] + [InlineData(5, 30, 600, true)] + [InlineData(0, 0, 0, true)] + [InlineData(-1, 0, 0, true)] + public void Credential_lockout_durations_must_be_positive_while_enabled( + int maxFailedAttempts, int lockoutSeconds, int windowSeconds, bool valid) + { + var result = Sut.Validate(null, new OpcUaApplicationHostOptions + { + CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions + { + MaxFailedAttempts = maxFailedAttempts, LockoutSeconds = lockoutSeconds, FailureWindowSeconds = windowSeconds, + }, + }); + + result.Succeeded.ShouldBe(valid); + if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:LockoutSeconds")); + } + + /// #833 review: the slot wait must be positive while the lockout is enabled. + [Fact] + public void Credential_lockout_slot_wait_must_be_positive_while_enabled() + { + var result = Sut.Validate(null, new OpcUaApplicationHostOptions + { + CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions { SlotWaitSeconds = 0 }, + }); + + result.Failed.ShouldBeTrue(); + result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:SlotWaitSeconds")); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs new file mode 100644 index 000000000..342c67c33 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// lmxopcua#833 over the wire. A few failed UserName activations used to trip the OPC Foundation SDK's +/// per-client lockout, which then refused EVERY activation from that client — Anonymous included — with +/// BadUserAccessDenied for 5 minutes. The host now disables the SDK lockout and enforces a +/// UserName-only replacement (). +/// +public sealed class CredentialLockoutWireTests +{ + private const string GoodKey = "otua_k-good_secret"; + + [Fact] + public async Task Failed_credentials_never_lock_the_client_out_of_anonymous_activation() + { + var ct = TestContext.Current.CancellationToken; + var auth = new CountingAuthenticator(); + await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions(), ct); + + for (var i = 0; i < 6; i++) + { + var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); + rejected.StatusCode.ShouldBeOneOf(StatusCodes.BadIdentityTokenRejected, StatusCodes.BadUserAccessDenied); + } + + using var anonymous = await rig.ConnectAsync(credential: null, ct); + var ns = await anonymous.ReadValueAsync(VariableIds.Server_NamespaceArray, ct); + StatusCode.IsGood(ns.StatusCode).ShouldBeTrue(); + auth.AnonymousCalls.ShouldBe(1, "the anonymous activation must reach the authenticator, not be refused before it"); + } + + [Fact] + public async Task Failed_credentials_still_lock_out_further_UserName_activations_from_that_client() + { + var ct = TestContext.Current.CancellationToken; + var auth = new CountingAuthenticator(); + await using var rig = await Rig.StartAsync( + auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, ct); + + for (var i = 0; i < 3; i++) + { + var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); + rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); + } + + var callsBefore = auth.UserNameCalls; + var locked = await Should.ThrowAsync(() => rig.ConnectAsync(GoodKey, ct)); + locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + auth.UserNameCalls.ShouldBe(callsBefore, "a locked-out client's credential is refused without being checked"); + } + + /// + /// #833 review — one client opening more keyed sessions at once than MaxFailedAttempts (a ScadaBridge site + /// node behind one NAT IP) is never refused for parallelism: the extra activations wait for a slot. + /// + [Fact] + public async Task Parallel_valid_activations_beyond_the_threshold_all_succeed() + { + var ct = TestContext.Current.CancellationToken; + var auth = new CountingAuthenticator(); + await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct); + + var sessions = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => rig.ConnectAsync(GoodKey, ct))); + try + { + sessions.ShouldAllBe(s => s.Connected); + auth.UserNameCalls.ShouldBe(8); + } + finally + { + foreach (var s in sessions) s.Dispose(); + } + } + + [Fact] + public async Task A_disabled_lockout_admits_a_valid_key_after_any_number_of_failures() + { + // Also proves the SDK's own lockout (default 5) is switched off: with ours disabled nothing may refuse. + var ct = TestContext.Current.CancellationToken; + var auth = new CountingAuthenticator(); + await using var rig = await Rig.StartAsync( + auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, ct); + + for (var i = 0; i < 7; i++) + { + var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); + rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); + } + + using var session = await rig.ConnectAsync(GoodKey, ct); + session.Connected.ShouldBeTrue(); + } + + private sealed class Rig : IAsyncDisposable + { + private readonly string _pkiRoot; + private OpcUaApplicationHost _host = null!; + private int _port; + + private Rig(string pkiRoot) => _pkiRoot = pkiRoot; + + public static async Task StartAsync( + IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct) + { + var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}")); + var applicationUri = $"urn:OtOpcUa.Lockout.{Guid.NewGuid():N}"; + (rig._host, _) = await OpcUaHostStart.StartAsync( + port => + { + rig._port = port; + return new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Lockout", + ApplicationUri = applicationUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = rig._pkiRoot, + EnabledSecurityProfiles = [OpcUaSecurityProfile.None], + AutoAcceptUntrustedClientCertificates = true, + CredentialLockout = lockout, + }, + NullLogger.Instance, + authenticator); + }, + () => new OtOpcUaSdkServer(), ct); + return rig; + } + + /// Opens a session; a null credential activates anonymously. + public async Task ConnectAsync(string? credential, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) + { + ApplicationName = "OtOpcUa.LockoutClient", + ApplicationUri = "urn:OtOpcUa.LockoutClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, + new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("no endpoint"), EndpointConfiguration.Create(appConfig)), + updateBeforeConnect: false, + checkDomain: false, + sessionName: "CredentialLockoutWireTests", + sessionTimeout: 60_000, + identity: credential is null + ? new UserIdentity() + : new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), + preferredLocales: default, + ct: ct); + } + + public async ValueTask DisposeAsync() + { + await _host.DisposeAsync(); + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch (IOException) { /* best effort */ } + } + } + } + + /// Admits and anonymous; denies everything else; counts calls. + private sealed class CountingAuthenticator : IOpcUaUserAuthenticator + { + private int _userNameCalls; + private int _anonymousCalls; + + public int UserNameCalls => Volatile.Read(ref _userNameCalls); + public int AnonymousCalls => Volatile.Read(ref _anonymousCalls); + + public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) + { + Interlocked.Increment(ref _userNameCalls); + return Task.FromResult(password == GoodKey + ? OpcUaUserAuthResult.AllowKey("good", [], "k-good") + : OpcUaUserAuthResult.Deny("Invalid API key.")); + } + + public Task AuthenticateUserNameAsync( + string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) => + AuthenticateUserNameAsync(username, password, ct); + + public Task AuthenticateAnonymousAsync(CancellationToken ct) + { + Interlocked.Increment(ref _anonymousCalls); + return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, [])); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs new file mode 100644 index 000000000..1bd619e8f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs @@ -0,0 +1,302 @@ +using System.Net; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout. +public sealed class FailedCredentialLockoutTests +{ + private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5")); + + [Fact] + public void Locks_at_the_threshold_and_releases_after_the_lockout() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock); + + for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks"); + lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue(); + remaining.ShouldBe(TimeSpan.FromMinutes(5)); + + clock.Advance(TimeSpan.FromSeconds(299)); + lockout.IsLockedOut(Client, out _).ShouldBeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count"); + } + + [Fact] + public void A_refusal_during_the_lockout_does_not_extend_it() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock); + lockout.RecordFailure(Client).ShouldBeTrue(); + clock.Advance(TimeSpan.FromSeconds(200)); + lockout.RecordFailure(Client).ShouldBeFalse(); + clock.Advance(TimeSpan.FromSeconds(100)); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + } + + [Fact] + public void Failures_spaced_wider_than_the_window_never_accumulate() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock); + for (var i = 0; i < 10; i++) + { + lockout.RecordFailure(Client).ShouldBeFalse(); + clock.Advance(TimeSpan.FromSeconds(61)); + } + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + } + + [Fact] + public void A_success_clears_the_count() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock()); + for (var i = 0; i < 4; i++) lockout.RecordFailure(Client); + lockout.RecordSuccess(Client); + for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + } + + [Fact] + public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock()); + var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99")); + var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5")); + var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99")); + + lockout.RecordFailure(attacker).ShouldBeTrue(); + lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address"); + lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate"); + } + + [Fact] + public void Zero_attempts_disables_it() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock()); + for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.TrackedCount.ShouldBe(0); + } + + [Fact] + public void Tracking_is_bounded_and_prunes_expired_clients_first() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock); + for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}"); + lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients); + + lockout.RecordFailure("overflow"); + lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked"); + + clock.Advance(TimeSpan.FromSeconds(61)); + lockout.RecordFailure("overflow"); + lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room"); + } + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// + /// #833 review — a legitimate client opening many keyed sessions at once (a ScadaBridge site node behind one + /// NAT IP) must never be refused for parallelism: at most MaxFailedAttempts verify concurrently, the rest + /// WAIT for a slot, and all 20 succeed. + /// + [Fact] + public async Task An_all_valid_parallel_burst_queues_for_slots_and_every_activation_succeeds() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock()); + var concurrent = 0; + var maxConcurrent = 0; + var proceeded = 0; + + await Task.WhenAll(Enumerable.Range(0, 20).Select(attempt => Task.Run(async () => + { + var decision = await lockout.BeginAsync(Client, Ct); + decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + Interlocked.Increment(ref proceeded); + var now = Interlocked.Increment(ref concurrent); + InterlockedMax(ref maxConcurrent, now); + await Task.Delay(20, Ct); // the authenticate call + Interlocked.Decrement(ref concurrent); + lockout.Complete(Client, succeeded: true); + }, Ct))); + + proceeded.ShouldBe(20); + maxConcurrent.ShouldBe(5, "exactly MaxFailedAttempts verify at once — no more, and the cap is actually reached"); + lockout.TrackedCount.ShouldBe(0, "nothing left behind once the burst drains"); + } + + /// + /// #833 review — the security half: a parallel burst of guesses that all arrive before any failure is + /// recorded still reaches the authenticator only MaxFailedAttempts times; once those fail, every waiter is + /// refused as locked out without being authenticated. + /// + [Fact] + public async Task An_all_bad_parallel_burst_authenticates_only_max_attempts_and_refuses_the_waiters_as_locked_out() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock()); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var authenticated = 0; + var lockedOut = 0; + + var attempts = Enumerable.Range(0, 20).Select(attempt => Task.Run(async () => + { + var decision = await lockout.BeginAsync(Client, Ct); + if (decision.Verdict == CredentialLockoutVerdict.LockedOut) + { + Interlocked.Increment(ref lockedOut); + return; + } + + decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + Interlocked.Increment(ref authenticated); + await release.Task; // hold the slots until all 15 others are queued behind them + lockout.Complete(Client, succeeded: false); + }, Ct)).ToArray(); + + await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15); + authenticated.ShouldBe(5); + release.SetResult(); + await Task.WhenAll(attempts); + + authenticated.ShouldBe(5, "no waiter may reach the authenticator once the key locks out"); + lockedOut.ShouldBe(15); + lockout.IsLockedOut(Client, out _).ShouldBeTrue(); + lockout.WaitingCount(Client).ShouldBe(0); + } + + /// After a recorded failure the client has one slot fewer: capacity is MaxFailedAttempts − failures. + [Fact] + public async Task Recorded_failures_shrink_the_number_of_concurrent_verifications() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, new ManualClock()); + lockout.RecordFailure(Client); + lockout.RecordFailure(Client); + + (await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + var second = lockout.BeginAsync(Client, Ct).AsTask(); + await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1); + second.IsCompleted.ShouldBeFalse("one failure short of the threshold leaves exactly one slot"); + + lockout.Complete(Client, succeeded: true); // a success clears the count and hands the slot on + (await second).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + lockout.Complete(Client, succeeded: true); + lockout.TrackedCount.ShouldBe(0); + } + + /// A cancelled waiter is dequeued and leaves nothing behind (no leaked slot, entry prunable). + [Fact] + public async Task A_cancelled_waiter_is_removed_and_leaks_nothing() + { + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock()); + (await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct); + var waiting = lockout.BeginAsync(Client, cts.Token).AsTask(); + await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1); + cts.Cancel(); + await Should.ThrowAsync(waiting); + lockout.WaitingCount(Client).ShouldBe(0); + + lockout.Complete(Client, succeeded: true); + lockout.TrackedCount.ShouldBe(0, "the held slot and the cancelled waiter are both gone"); + (await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + lockout.Abandon(Client); + lockout.TrackedCount.ShouldBe(0); + } + + /// A waiter that never gets a slot times out, uncounted — the client is not one failure closer. + [Fact] + public async Task A_slot_wait_timeout_fails_that_attempt_without_counting_it() + { + var lockout = new FailedCredentialLockout( + new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, SlotWaitSeconds = 1 }, new ManualClock()); + (await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + + (await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.SlotWaitTimedOut); + lockout.WaitingCount(Client).ShouldBe(0); + + lockout.Complete(Client, succeeded: true); + lockout.TrackedCount.ShouldBe(0, "the timed-out attempt recorded no failure"); + } + + [Fact] + public async Task Only_the_first_refusal_of_a_lockout_episode_is_flagged_for_Information_logging() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, LockoutSeconds = 10 }, clock); + lockout.RecordFailure(Client).ShouldBeTrue(); + + var r1 = await lockout.BeginAsync(Client, Ct); + r1.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut); + r1.FirstRefusal.ShouldBeTrue(); + (await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeFalse(); + + clock.Advance(TimeSpan.FromSeconds(11)); + (await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed); + lockout.Complete(Client, succeeded: false).ShouldBeTrue("a new episode"); + (await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeTrue("each lockout episode logs its first refusal once"); + } + + private static void InterlockedMax(ref int target, int value) + { + int seen; + while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen) + { + } + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (!condition()) + { + DateTime.UtcNow.ShouldBeLessThan(deadline, "condition not reached in time"); + await Task.Delay(5, Ct); + } + } + + /// + /// #833 review — with LockoutSeconds shorter than FailureWindowSeconds, the count survived the expired lockout + /// (the last failure was still inside the window), so the very next failure relocked the client. + /// + [Fact] + public void An_expired_lockout_starts_a_fresh_count_even_inside_the_failure_window() + { + var clock = new ManualClock(); + var lockout = new FailedCredentialLockout( + new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, FailureWindowSeconds = 600, LockoutSeconds = 10 }, clock); + lockout.RecordFailure(Client).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeTrue(); + + clock.Advance(TimeSpan.FromSeconds(11)); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeFalse("one failure after the lockout is the first of a new run"); + lockout.IsLockedOut(Client, out _).ShouldBeFalse(); + lockout.RecordFailure(Client).ShouldBeTrue(); + } + + [Fact] + public void The_host_switches_the_sdk_lockout_off() + { + var config = new Opc.Ua.ServerConfiguration(); + config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against"); + OpcUaApplicationHost.DisableSdkAuthenticationLockout(config); + config.MaxFailedAuthenticationAttempts.ShouldBe(0); + } + + private sealed class ManualClock : TimeProvider + { + private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now += by; + } +}