From e7b485c0a2646443c7715d0f75d3e199a1f888ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:28:54 -0500 Subject: [PATCH 01/16] feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the same Good / Bad region totals TimeWeightedAggregator already computes for PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status / Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the up-front refusal follow automatically; Gateway still refuses both (explicit served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both. The #796 oracle answers DurationGood through the Good-only subclass as well. QualityMix conformance asserts both directly (Q and QP, hand values, ms, and = PercentGood x interval), replaces the "refused up front" test, and adds Duration* to the forward/reverse/truncated oracle comparisons. #821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first late-start interval (QP over [0,20), both directions) against ours (Part 13 Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96, 9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control (all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted server's runtime AggregateManager (live AggregateFilter) incl. DurationGood 15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix adds the reverse late-start variant (reverse TW pin + reverse oracle windows). Co-Authored-By: Claude --- .../Commands/HistoryReadCommand.cs | 2 +- .../Helpers/AggregateTypeMapper.cs | 2 + .../Models/AggregateType.cs | 10 +- .../QuestDbAggregateQueries.cs | 2 +- .../QuestDbHistorianDataSource.cs | 2 +- .../AggregateFamily.cs | 8 +- .../TimeWeightedAggregator.cs | 25 +++-- .../SqlAggregateQueries.cs | 2 +- .../SqlServerHistorianDataSource.cs | 6 +- .../Models/ModelConstructionTests.cs | 6 +- .../ProcessedConformance.cs | 2 - .../QualityMixConformance.cs | 84 +++++++++++++-- .../SdkAggregateOracle.cs | 33 +++--- .../ProcessedReadTests.cs | 16 +-- .../SdkAggregateParityTests.cs | 102 +++++++++++++++++- .../TimeWeightedAggregatorTests.cs | 27 +++++ .../HistorianBackendRegistryTests.cs | 3 +- .../LiveAggregateLateStartFactsTests.cs | 55 ++++++++++ 18 files changed, 328 insertions(+), 59 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index fc18bc634..77921b4f8 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -48,7 +48,7 @@ public class HistoryReadCommand : CommandBase /// /// Gets the optional aggregate name used when the operator wants processed history instead of raw samples. /// - [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")] + [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad, DurationGood, DurationBad")] public string? Aggregate { get; init; } /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs index 23b70edeb..197399a82 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs @@ -34,6 +34,8 @@ public static class AggregateTypeMapper AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime, AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, + AggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood, + AggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad, _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.") }; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs index 17dddc51b..683eb6093 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models; /// -/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends +/// Aggregate functions for processed history reads — exactly the nineteen the server's SQL historian backends /// serve (AggregateFamilies.Served; a parity test pins it). The Gateway backend serves a subset and refuses /// the rest with BadAggregateNotSupported. /// @@ -60,5 +60,11 @@ public enum AggregateType PercentGood = 16, /// Percentage of the interval with Bad data (Part 13 PercentBad). - PercentBad = 17 + PercentBad = 17, + + /// Time of the interval with Good data, in milliseconds (Part 13 DurationGood). + DurationGood = 18, + + /// Time of the interval with Bad data, in milliseconds (Part 13 DurationBad). + DurationBad = 19 } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs index ac02c2077..e3fd86a6f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs @@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture); /// - /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). + /// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value over the Good numeric samples /// (DOUBLE, NULL when none — Part 13 / the SDK), then 10 unc_min, 11 unc_max over the Uncertain ones (the /// Part 13 Minimum/Maximum/Range status rule) and 12 good_non_numeric_count. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs index 172561b0f..eb640d0e3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs @@ -434,7 +434,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource /// public bool SupportsReverseEvents => true; - // The seventeen AggregateFamilies.Served - shared with the other SQL backend. + // The nineteen AggregateFamilies.Served - shared with the other SQL backend. /// public IReadOnlySet SupportedAggregates => AggregateFamilies.Served; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs index cac2cc02e..e83e63136 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs @@ -16,7 +16,8 @@ public enum AggregateFamily ActualTime, /// - /// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based PercentGood / PercentBad: + /// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based DurationGood / DurationBad / + /// PercentGood / PercentBad: /// Part 13 time-weighted, with bounding values. /// The backend streams the window's samples plus the bounding rows and /// evaluates them (identically for every backend). @@ -38,12 +39,13 @@ public static class AggregateFamilies HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => AggregateFamily.ActualTime, HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total or HistoryAggregateType.Total2 or HistoryAggregateType.Interpolative - or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => AggregateFamily.TimeWeighted, + or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad + or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad => AggregateFamily.TimeWeighted, _ => null, }; /// - /// The seventeen aggregates the SQL backends serve — every member assigns a family, derived + /// The nineteen aggregates the SQL backends serve — every member assigns a family, derived /// from it so the set and the dispatch can never disagree. Both SqlServerHistorianDataSource and /// QuestDbHistorianDataSource return it as . /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs index ab6a81c7a..a474a8ffe 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs @@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// /// The OPC UA Part 13 time-weighted aggregates (): TimeAverage, -/// TimeAverage2, Total, Total2, Interpolative and the duration-based PercentGood / -/// PercentBad, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results +/// TimeAverage2, Total, Total2, Interpolative and the duration-based DurationGood / +/// DurationBad / PercentGood / PercentBad, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results /// by construction. /// /// @@ -50,6 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// GetSimpleBound); time before the first sample is Bad; at the end of the data the last region runs to the /// end of its own interval (Part 13; the SDK gives it 1 ms) and later intervals are BadNoData; an interval /// ending before the first sample ⇒ BadNoData. Status Good; any value kind. +/// DurationGood / DurationBadThe same Good / Bad region time, in milliseconds (an +/// OPC UA Duration, as the SDK reports it) — PercentGood / PercentBad × interval ÷ 100, with the same +/// BadNoData, status and Partial rules (#820). /// InterpolativeThe interpolated-bounding signal's value at each interval start — /// for a reverse read, at End − i·interval (Part 13 anchors a reverse read at its later time), emitted /// latest first. @@ -78,7 +81,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// interval the data starts inside in the read direction (forward: the first sample fed lies strictly within it; /// reverse: the latest sample fed lies strictly within it, nothing after — the SDK's m_startOfData, /// keyed on the first value it is fed); and, forward, the end-of-data interval of TimeAverage2 / Total2 / -/// PercentGood / PercentBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total). +/// PercentGood / PercentBad / DurationGood / DurationBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total). /// The timestamp is the interval's (its start forward, its later edge /// reverse); the value is a (Boolean = 0/1, Int64 as double). /// @@ -358,14 +361,22 @@ public sealed class TimeWeightedAggregator ? Calc(_rawArea[i] / _usableTicks[i], status, endOfData) : Calc(_rawArea[i] / TicksPerSecond, status, endOfData); } - case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad: + case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad + or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad: { // No data in or around the interval (it ends before the first sample — the SDK's out-of-range test — or - // starts after the end of the data) ⇒ BadNoData; otherwise the share of the interval's duration. + // starts after the end of the data) ⇒ BadNoData; otherwise the region time (Duration*, in milliseconds — + // an OPC UA Duration) or its share of the interval's duration (Percent*). Uncertain regions count toward + // neither (Part 13 §5.4.3.31-34, Mantis 0006166). if (_firstSampleUtc is not { } first || hi < first.Ticks || (_dataEndTicks is { } de2 && lo > de2)) return AggregateEvaluator.NoData(stamp, now); - var ticks = _aggregate == HistoryAggregateType.PercentGood ? _goodRegionTicks[i] : _badRegionTicks[i]; - return Calc(100d * ticks / duration, HistorianStatusCodes.Good, endOfData); + var ticks = _aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood + ? _goodRegionTicks[i] + : _badRegionTicks[i]; + var value = _aggregate is HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad + ? (double)ticks / TimeSpan.TicksPerMillisecond + : 100d * ticks / duration; + return Calc(value, HistorianStatusCodes.Good, endOfData); } default: throw new HistoryAggregateNotSupportedException(_aggregate); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs index 5843d882b..590e0f8ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs @@ -47,7 +47,7 @@ internal static class SqlAggregateQueries private const string NonNumericKinds = "4, 5"; /// - /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). + /// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue over the Good numeric samples (float, /// NULL when none — Part 13 and the SDK compute these over Good values only), then 10 UncMin, 11 UncMax /// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, ), and diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index 9dd0d75c3..3375ddd30 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -359,7 +359,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource /// public bool SupportsReverseEvents => true; - // The seventeen — shared with the other SQL backend. + // The nineteen — shared with the other SQL backend. /// public IReadOnlySet SupportedAggregates => AggregateFamilies.Served; @@ -463,7 +463,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource // bucket start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒ // earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest // sample lies strictly inside (forward) or its latest sample lies in (reverse: strictly inside). - // PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's + // DurationGood / DurationBad / PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's // data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's // first or last sample is not usable (see ). // @@ -475,7 +475,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource // , which fills every interval (an empty one between bounds gets a value). // // - // The aggregate is not one of the seventeen served. + // The aggregate is not one of the nineteen served. // The window holds more than intervals. /// public async Task ReadProcessedAsync( diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs index 0a98d1a20..1baea8723 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs @@ -132,10 +132,12 @@ public class ModelConstructionTests ((int)ConnectionState.Reconnecting).ShouldBe(3); } - /// AggregateType holds the seventeen served aggregates (the parity test pins which). + /// AggregateType holds the nineteen served aggregates (the parity test pins which). [Fact] public void AggregateType_Enum_HasExpectedValues() { - Enum.GetValues().Length.ShouldBe(17); + Enum.GetValues().Length.ShouldBe(19); + ((int)AggregateType.DurationGood).ShouldBe(18); + ((int)AggregateType.DurationBad).ShouldBe(19); } } \ No newline at end of file diff --git a/tests/Drivers/Historian.Conformance/ProcessedConformance.cs b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs index 71858e26c..cb220b5b9 100644 --- a/tests/Drivers/Historian.Conformance/ProcessedConformance.cs +++ b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs @@ -713,8 +713,6 @@ public abstract class ProcessedConformance : ConformanceBase [InlineData(HistoryAggregateType.StartBound)] [InlineData(HistoryAggregateType.EndBound)] [InlineData(HistoryAggregateType.DeltaBounds)] - [InlineData(HistoryAggregateType.DurationGood)] - [InlineData(HistoryAggregateType.DurationBad)] [InlineData(HistoryAggregateType.WorstQuality)] [InlineData(HistoryAggregateType.NumberOfTransitions)] [InlineData(HistoryAggregateType.AnnotationCount)] diff --git a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs index 051f5b6ee..daf57deeb 100644 --- a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs +++ b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs @@ -11,7 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// Uncertain time as Good (#796) as well as against the Part 13 oracle. Every expectation here is derived by hand from /// Part 13 (§5.4.3.31-34: DurationGood / DurationBad are the Good / Bad region time of the interval, PercentGood / /// PercentBad that time ÷ the interval; Uncertain regions count toward neither — Mantis 0006166 — and the time before -/// the first sample is Bad, no data), and the oracle itself is checked against the same hand values. +/// the first sample is Bad, no data), and the oracle itself is checked against the same hand values. Since #820 the SQL +/// backends serve DurationGood / DurationBad (the same region times, in ms) and this suite asserts them directly. /// public abstract class QualityMixConformance : ConformanceBase { @@ -155,15 +156,52 @@ public abstract class QualityMixConformance : ConformanceBase } [Fact] - public async Task DurationGood_and_DurationBad_are_refused_rather_than_answered_from_the_baseline_rule() + public async Task DurationGood_and_DurationBad_over_a_Good_Uncertain_Bad_mix_are_the_Part_13_region_times() { - // Part 13 DurationGood / DurationBad are not served by the SQL backends (#682 advertises 17 aggregates); they - // must stay refused up front — never answered by a calculator that counts Uncertain time as Good. + // #820: served on both SQL backends from the same region totals as PercentGood / PercentBad. Part 13 + // §5.4.3.31/32: the Good / Bad region time of the interval as an OPC UA Duration (milliseconds); Uncertain regions + // count toward neither (Mantis 0006166) — never the 1.5.378.182 baseline rule that counts Uncertain time as Good. await B.SeedAsync("Q", Q); var src = B.CreateDataSource(); - foreach (var agg in new[] { HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad }) - (await Should.ThrowAsync( - () => src.ReadProcessedAsync("Q", At(0), At(80), Ten, agg, Ct))).Aggregate.ShouldBe(agg); + src.SupportedAggregates.ShouldContain(HistoryAggregateType.DurationGood); + src.SupportedAggregates.ShouldContain(HistoryAggregateType.DurationBad); + var good = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.DurationGood, Ct)).Samples; + var bad = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.DurationBad, Ct)).Samples; + + ShouldBeClose(Values(good), QDurationGood.Select(d => d * 1000).ToArray(), "DurationGood"); + ShouldBeClose(Values(bad), QDurationBad.Select(d => d * 1000).ToArray(), "DurationBad"); + good.ShouldAllBe(x => x.Value is double); + good.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc); + bad.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc); + good.Select(x => x.SourceTimestampUtc).ShouldBe(Enumerable.Range(0, 8).Select(i => (DateTime?)B.Round(At(i * 10)))); + + // Uncertain time is neither Good nor Bad: only I4 (no Uncertain region) adds up to the whole 10 s. + Values(good).Zip(Values(bad), (g, b) => Math.Round(g + b, 6)).ShouldBe([7000d, 3000d, 0d, 0d, 10000d, 4000d, 3000d, 5000d]); + + // DurationGood / DurationBad = PercentGood / PercentBad × interval ÷ 100, interval by interval. + var pGood = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples; + ShouldBeClose(Values(good), Values(pGood).Select(p => p * Ten.TotalMilliseconds / 100).ToArray(), "DurationGood vs PercentGood"); + + // The baseline (the live AggregateFilter calculator, #809) answers Q's DurationGood with the Uncertain time added. + var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), Ten, + Q.Select(r => (r.T, r.V, r.Status))); + ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(), + QBaselinePercentGood.Select(p => p * 100).ToArray(), "baseline DurationGood (#809)"); + } + + [Fact] + public async Task DurationGood_and_DurationBad_over_partial_data_count_no_data_as_Bad_and_Uncertain_as_neither() + { + // #820 over QP: [0,20) 5 s no data (Bad), Uncertain 7 s, Good 8 s; [20,40) all Uncertain; outside the data BadNoData. + await B.SeedAsync("QP", QP); + var src = B.CreateDataSource(); + var good = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.DurationGood, Ct)).Samples; + var bad = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.DurationBad, Ct)).Samples; + good.Select(x => x.Value).ShouldBe([null, 8000d, 0d, null]); + bad.Select(x => x.Value).ShouldBe([null, 5000d, 0d, null]); + var statuses = new[] { NoDataCalc, GoodCalc | Partial, GoodCalc | Partial, NoDataCalc }; + good.Select(x => x.StatusCode).ShouldBe(statuses); + bad.Select(x => x.StatusCode).ShouldBe(statuses); } [Theory] @@ -182,7 +220,8 @@ public abstract class QualityMixConformance : ConformanceBase await B.SeedAsync("Q", Q); await AssertMatchesOracle("Q", Rounded(Q), from, to, intervalS, [ - HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.Count, + HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood, + HistoryAggregateType.DurationBad, HistoryAggregateType.Count, HistoryAggregateType.Average, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta, HistoryAggregateType.Interpolative, @@ -192,15 +231,18 @@ public abstract class QualityMixConformance : ConformanceBase [Theory] [InlineData(-20, 60, 20)] [InlineData(-20, 60, 10)] + [InlineData(60, -20, 20)] + [InlineData(60, -20, 10)] public async Task Processed_reads_over_partial_data_match_the_Part_13_oracle(double from, double to, double intervalS) { - // QP's start-of-data and end-of-data intervals against the oracle. Left out, each a documented or pinned divergence: + // QP's start-of-data and end-of-data intervals against the oracle, forward and reverse (#821). Left out, each a documented or pinned divergence: // TimeAverage2 / Total2 (the SDK's 1 ms end-of-data region), the min/max family (the SDK sets Partial on a Bad // result), and TimeAverage / Total (pinned by Time_weighted_values_over_partial_data_cover_only_the_data below). await B.SeedAsync("QP", QP); await AssertMatchesOracle("QP", Rounded(QP), from, to, intervalS, [ - HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.Count, + HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood, + HistoryAggregateType.DurationBad, HistoryAggregateType.Count, HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta, HistoryAggregateType.Interpolative, ]); @@ -229,6 +271,28 @@ public abstract class QualityMixConformance : ConformanceBase Convert.ToDouble(sdk.Value).ShouldBe(total + 25, 1e-6, "baseline divergence — if this moves, compare QP's TimeAverage/Total to the oracle"); } + [Fact] + public async Task Time_weighted_values_over_partial_data_cover_only_the_data_in_a_reverse_read() + { + // #821: the reverse-order variant of the late-start case above — (0,20] read 20 → 0. The same Part 13 integral and + // average over the covered [5,20); Uncertain; NOT Partial (a reverse read keys start-of-data on the latest sample, + // which is past the window — the SDK's rule, SdkAggregateParityTests); stamped with the later edge (#677). The + // baseline's reverse answer carries the same +25 ramp divergence. + await B.SeedAsync("QP", QP); + var src = B.CreateDataSource(); + const double total = 73.5 + 88 + 32d / 13; + var tot = (await src.ReadProcessedAsync("QP", At(20), At(0), Twenty, HistoryAggregateType.Total, Ct)).Samples.Single(); + ((double)tot.Value!).ShouldBe(total, 1e-6); + tot.StatusCode.ShouldBe(UncertainCalc); + tot.SourceTimestampUtc.ShouldBe(B.Round(At(20))); + var avg = (await src.ReadProcessedAsync("QP", At(20), At(0), Twenty, HistoryAggregateType.TimeAverage, Ct)).Samples.Single(); + ((double)avg.Value!).ShouldBe(total / 15, 1e-6); + avg.StatusCode.ShouldBe(UncertainCalc); + + var sdk = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(20)), B.Round(At(0)), Twenty, Rounded(QP)).Single(); + Convert.ToDouble(sdk.Value).ShouldBe(total + 25, 1e-6, "baseline divergence (reverse) — #821"); + } + private async Task AssertMatchesOracle(string tag, (DateTime, HistorianValue, uint)[] rows, double from, double to, double intervalS, HistoryAggregateType[] aggregates) { diff --git a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs index c9e3818c1..b791aa83b 100644 --- a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs +++ b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs @@ -18,16 +18,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// Shared (source-linked) by Sql.Common.Tests and both SQL backends' integration suites. /// -/// PercentGood is the one aggregate not taken as-is (#796). The 1.5.378.182 baseline changed +/// PercentGood (and, since #820, DurationGood) are the aggregates not taken as-is (#796). The 1.5.378.182 baseline changed /// StatusAggregateCalculator.ComputeDurationGoodBad (1.5.378.106 and earlier counted Good regions only): /// with TreatUncertainAsBad = false it counts Uncertain regions as Good time (as Bad when true). Part 13 /// keeps Uncertain out of both (§5.4.3.31 DurationGood counts Good regions; §5.4.3.33 PercentGood = /// DurationGood ÷ interval; Mantis 0006166, closed "no change required": an all-Uncertain interval is /// PercentGood 0 and PercentBad 0), and so does ours. therefore answers PercentGood -/// through : the SAME baseline calculator, region machinery (simple +/// (and DurationGood) through : the SAME baseline calculator, region machinery (simple /// bounds, end-of-data 1 ms region, status and Partial bits) and server-default configuration, counting only -/// Good regions — exactly what the pre-.182 oracle computed. PercentBad still counts Bad regions only (unchanged -/// by .182) and is compared as-is. runs the baseline untouched, for the test that pins +/// Good regions — exactly what the pre-.182 oracle computed. PercentBad / DurationBad still count Bad regions only +/// (unchanged by .182) and are compared as-is. runs the baseline untouched, for the test that pins /// the divergence. /// /// @@ -55,7 +55,7 @@ public static class SdkAggregateOracle /// /// Runs the Part 13 oracle for over : the baseline calculator, - /// except PercentGood, which counts Good regions only (see the remarks, #796). + /// except PercentGood / DurationGood, which count Good regions only (see the remarks, #796/#820). /// /// The aggregate. /// Request start (later than for a reverse read). @@ -65,9 +65,10 @@ public static class SdkAggregateOracle /// The oracle's processed values, in the order it returns them. public static IReadOnlyList Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) => - aggregate == HistoryAggregateType.PercentGood - ? RunCore(new LegacyCalculatorAdapter(new Part13PercentGoodCalculator(ObjectIds.AggregateFunction_PercentGood, - start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry)), start, end, samples) + aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood + ? RunCore(new LegacyCalculatorAdapter(new Part13GoodCalculator(AggregateId(aggregate), + start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry, + usePercent: aggregate == HistoryAggregateType.PercentGood)), start, end, samples) : RunSdkAsIs(aggregate, start, end, interval, samples); /// @@ -155,12 +156,14 @@ public static class SdkAggregateOracle } /// - /// The baseline with PercentGood counting Good regions only — Part 13 - /// §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial, status) is the - /// baseline's own code, run under the same configuration as PercentBad. + /// The baseline with PercentGood / DurationGood counting Good + /// regions only — Part 13 §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial, + /// status, the millisecond Duration unit) is the baseline's own code, run under the same configuration as + /// PercentBad / DurationBad. /// - private sealed class Part13PercentGoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime, - double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry) + private sealed class Part13GoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime, + double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry, + bool usePercent) : BaselineStatusAggregateCalculator(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry) { protected override LegacyDataValue ComputeValue(TimeSlice slice) @@ -178,7 +181,7 @@ public static class SdkAggregateOracle var value = new LegacyDataValue { - WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(good / total * 100), + WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(usePercent ? good / total * 100 : good), SourceTimestamp = GetTimestamp(slice), ServerTimestamp = GetTimestamp(slice), }; @@ -223,6 +226,8 @@ public static class SdkAggregateOracle HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count, HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, + HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood, + HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad, HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start, HistoryAggregateType.End => ObjectIds.AggregateFunction_End, HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs index a63e46d0d..a47d3e4f8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs @@ -10,21 +10,23 @@ public sealed class ProcessedReadTests [Fact] public void Every_enum_member_is_either_served_or_refused() { - // Seventeen served (#671 added TimeAverage, TimeAverage2, Interpolative and Total2, and moved Total to the - // time-weighted family; #680 moved PercentGood/PercentBad there too — they are duration-based); anything appended to - // the enum later falls to the refusal (total default). - Enum.GetValues().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(17); + // Nineteen served (#671 added TimeAverage, TimeAverage2, Interpolative and Total2, and moved Total to the + // time-weighted family; #680 moved PercentGood/PercentBad there too — they are duration-based; #820 added + // DurationGood/DurationBad, the same region time); anything appended to the enum later falls to the refusal + // (total default). + Enum.GetValues().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(19); Enum.GetValues().Where(a => AggregateFamilies.FamilyOf(a) == AggregateFamily.TimeWeighted) .ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.Interpolative, HistoryAggregateType.TimeAverage, - HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.TimeAverage2, - HistoryAggregateType.Total2]); + HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad, HistoryAggregateType.PercentGood, + HistoryAggregateType.PercentBad, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total2], + ignoreOrder: true); } [Fact] public void Served_is_exactly_the_members_with_a_family() { // #682: the advertised set and the dispatch are one definition — a member is served iff it has a family. - AggregateFamilies.Served.Count.ShouldBe(17); + AggregateFamilies.Served.Count.ShouldBe(19); foreach (var a in Enum.GetValues()) AggregateFamilies.Served.Contains(a).ShouldBe(AggregateFamilies.FamilyOf(a) is not null, a.ToString()); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs index fa8d21216..d7e768fc2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs @@ -213,7 +213,8 @@ public sealed class SdkAggregateParityTests /// /// Applies the documented divergences listed on the class (other than truncation): strips the Calculated bit from our /// BadNoData; maps our generic-Bad TimeAverage2/Total2 outage interval to the SDK's BadNoData; takes the - /// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad (its 1 ms artifact). + /// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad/DurationGood/DurationBad + /// (its 1 ms artifact). /// private static IReadOnlyList MaskDocumented(HistoryAggregateType agg, IReadOnlyList ours, IReadOnlyList sdk, double from, double to, double intervalS, StoredSample[] data) @@ -225,7 +226,8 @@ public sealed class SdkAggregateParityTests var dataEnds = endsInWindow && data.Single(x => x.TimeUtc.Ticks == last).IsUsable; var outageEnds = endsInWindow && !dataEnds; var boolean = data.Any(x => x.Value.Kind == HistorianValueKind.Boolean); - var percent = agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad; + var percent = agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad + or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad; for (long i = 0; i < plan.BucketCount; i++) { var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i); @@ -244,7 +246,7 @@ public sealed class SdkAggregateParityTests && code == HistorianStatusCodes.Good && (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.UncertainDataSubNormal) list[k] = list[k] with { StatusCode = (list[k].StatusCode & ~StatusBits.CodeBitsMask) | HistorianStatusCodes.UncertainDataSubNormal }; - if (dataEnds && (twoVariant || agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad) + if (dataEnds && (twoVariant || percent) && last >= plan.LowerTicks(i) && last < plan.UpperTicks(i) && sdk[k].Value is not null) list[k] = list[k] with { Value = sdk[k].Value }; // An outage marker as the last sample: ours runs the outage to the window end (no end-of-data Partial on the @@ -290,7 +292,7 @@ public sealed class SdkAggregateParityTests { HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood, - HistoryAggregateType.PercentBad, + HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad, }) foreach (var (name, data) in new[] { @@ -473,6 +475,98 @@ public sealed class SdkAggregateParityTests (100 - Pct(AsIs(HistoryAggregateType.PercentBad, 0, 20, data, cfg))).ShouldBe(50d, 1e-9); } + /// + /// #821: data that starts inside [0, 20) at +5 with an Uncertain first sample — the #810 QualityMix tag QP: + /// Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after. + /// + private static readonly StoredSample[] UncertainFirstLateStart = + [ + S(5, HistorianValue.FromDouble(10), Uncertain), + S(12, HistorianValue.FromDouble(11), Good), + S(25, HistorianValue.FromDouble(12), Uncertain), + ]; + + /// The control: the same data with a Good first sample. + private static readonly StoredSample[] GoodFirstLateStart = + [S(5, HistorianValue.FromDouble(10), Good), .. UncertainFirstLateStart[1..]]; + + /// + /// Runs SDK 2.0.1's OWN stock calculator (, not the frozen 1.5.378.182 copy + /// the server registers) under the server-default configuration — for the #809/#821 facts only. + /// + private static List<(double? Value, uint Status)> Stock201(HistoryAggregateType agg, double from, double to, + StoredSample[] data) + { + var id = agg switch + { + HistoryAggregateType.Total => Opc.Ua.ObjectIds.AggregateFunction_Total, + HistoryAggregateType.TimeAverage => Opc.Ua.ObjectIds.AggregateFunction_TimeAverage, + HistoryAggregateType.DurationGood => Opc.Ua.ObjectIds.AggregateFunction_DurationGood, + HistoryAggregateType.PercentGood => Opc.Ua.ObjectIds.AggregateFunction_PercentGood, + _ => throw new ArgumentOutOfRangeException(nameof(agg)), + }; + var calc = Opc.Ua.Server.Aggregators.CreateStandardCalculator(id, At(from), At(to), + (At(to) - At(from)).Duration().TotalMilliseconds, false, SdkAggregateOracle.DefaultConfiguration, + Opc.Ua.DefaultTelemetry.Create(_ => { }))!; + var ordered = data.OrderBy(s => s.TimeUtc).ToList(); + if (to < from) ordered.Reverse(); + foreach (var s in ordered) + calc.QueueRawValue(new Opc.Ua.DataValue(Opc.Ua.Variant.From(s.Value.Number), s.Status, s.TimeUtc, s.TimeUtc)); + var results = new List<(double?, uint)>(); + while (calc.TryGetProcessedValue(true, out var dv)) + results.Add((dv.WrappedValue.TryGetValue(out double d) ? d : null, dv.StatusCode.Code)); + return results; + } + + [Theory] + [InlineData(0, 20)] + [InlineData(20, 0)] + public void An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data( + double from, double to) + { + // #821, pinned divergence (pending the #809/#821 owner decision — this test records, it does not choose). + // Part 13 §5.4.3.3/§5.4.3.8: the interpolated-bounding signal is defined from the first sample only — 10 → 11 over + // [5,12) (73.5) and 11 → 11 + 8/13 over [12,20) (4·(22 + 8/13)) ⇒ Total 163.96…, TimeAverage = Total ÷ 15 s. + const double part13Total = 73.5 + 88 + 32d / 13; + Pct(Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total, 1e-9); + Pct(Ours(HistoryAggregateType.TimeAverage, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total / 15, 1e-9); + + // The frozen 1.5.378.182 calculators — exactly what the server registers for live AggregateFilter items + // (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager serves the same code) + // — integrate the uncovered [0,5) as a ramp 0 → 10 (+25) and divide TimeAverage by the WHOLE 20 s interval. + Pct(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart)).ShouldBe(part13Total + 25, 1e-9); + Pct(AsIs(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart)).ShouldBe((part13Total + 25) / 20, 1e-9); + // SDK 2.0.1's own stock calculators answer the same: the upgrade did not change this. + Stock201(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().Value!.Value + .ShouldBe(part13Total + 25, 1e-9); + Stock201(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart).Single().Value!.Value + .ShouldBe((part13Total + 25) / 20, 1e-9); + // Status agrees everywhere (Uncertain — an Uncertain value is used and the interval is not fully covered). + (AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().StatusCode & StatusBits.CodeBitsMask) + .ShouldBe(HistorianStatusCodes.UncertainDataSubNormal); + (Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart).Single().StatusCode & StatusBits.CodeBitsMask) + .ShouldBe(HistorianStatusCodes.UncertainDataSubNormal); + + // Control: the SAME data with a Good first sample — all three agree on the Part 13 answer, so the trigger is the + // Uncertain first sample, not the late start. + foreach (var agg in new[] { HistoryAggregateType.Total, HistoryAggregateType.TimeAverage }) + { + var expected = agg == HistoryAggregateType.Total ? part13Total : part13Total / 15; + Pct(Ours(agg, from, to, 0, GoodFirstLateStart)).ShouldBe(expected, 1e-9); + Pct(AsIs(agg, from, to, GoodFirstLateStart)).ShouldBe(expected, 1e-9); + Stock201(agg, from, to, GoodFirstLateStart).Single().Value!.Value.ShouldBe(expected, 1e-9); + } + + // #809 on the same input: DurationGood / PercentGood — Part 13 (ours) and stock SDK 2.0.1 count Good regions only + // ([12,20) = 8 s); the frozen live calculators also count the Uncertain [5,12) as Good (15 s). + Pct(Ours(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9); + Stock201(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(8_000d, 1e-9); + Pct(AsIs(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart)).ShouldBe(15_000d, 1e-9); + Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9); + Stock201(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(40d, 1e-9); + Pct(AsIs(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart)).ShouldBe(75d, 1e-9); + } + /// #684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6). private static readonly StoredSample[] UncertainBeyond = [ diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs index 6965e13d0..6bb87e2d8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs @@ -274,6 +274,33 @@ public sealed class TimeWeightedAggregatorTests bad.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]); } + [Fact] + public void Duration_aggregates_are_the_percent_regions_in_milliseconds() + { + // #820: DurationGood / DurationBad are the same Good / Bad region time as PercentGood / PercentBad, as an OPC UA + // Duration (ms, the SDK's unit), with the same statuses and Partial bits — tag A over [0,80) at 20 s. + var good = Run(HistoryAggregateType.DurationGood, 0, 80, 20, A); + Values(good, 20_000, 10_000, 20_000, null); + good.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]); + good.Where(x => x.Value is not null).ShouldAllBe(x => x.Value is double); + var bad = Run(HistoryAggregateType.DurationBad, 0, 80, 20, A); + Values(bad, 0, 10_000, 0, null); + bad.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]); + + // Uncertain time counts toward neither (Mantis 0006166): −3 G, +4 outage, +6 U, +12 G, +30 G over [0,20) at 10 s ⇒ + // B0 [0,4) Uncertain (bound into the outage), [4,6) Bad, [6,10) Uncertain; B1 [10,12) Uncertain, [12,20) Good. + StoredSample[] s = + [ + S(-3, HistorianValue.FromDouble(10), Good), S(4, HistorianValue.Empty, BadNoCommunication), + S(6, HistorianValue.FromDouble(30), Uncertain), S(12, HistorianValue.FromDouble(3), Good), + S(30, HistorianValue.FromDouble(3), Good), + ]; + Values(Run(HistoryAggregateType.DurationGood, 0, 20, 10, s), 0, 8_000); + Values(Run(HistoryAggregateType.DurationBad, 0, 20, 10, s), 2_000, 0); + // A truncated interval measures its own (shorter) span: [10,15) holds [10,12) Uncertain + [12,15) Good. + Values(Run(HistoryAggregateType.DurationGood, 0, 15, 10, s), 0, 3_000); + } + [Fact] public void With_no_end_bound_the_last_region_runs_to_the_interval_end() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs index 21f9eb95d..1fdbc870f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs @@ -130,7 +130,7 @@ public sealed class HistorianBackendRegistryTests o.TablePrefix.ShouldBe("rig_"); } - /// The seventeen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the + /// The nineteen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the /// family table or a backend's declaration fails here (#682). private static readonly HistoryAggregateType[] SqlServed = [ @@ -139,6 +139,7 @@ public sealed class HistorianBackendRegistryTests HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, + HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad, ]; [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs new file mode 100644 index 000000000..6dffd4484 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs @@ -0,0 +1,55 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// #821 / #809 facts, through the BOOTED server's runtime AggregateManager — the calculators a live +/// AggregateFilter monitored item gets, under the server's own default configuration. This pins current behaviour for +/// the owner decision; it does not choose. The HistoryRead (Part 13) answers and SDK 2.0.1's stock answers for the +/// same input are pinned beside it in SdkAggregateParityTests. +/// +public sealed class LiveAggregateLateStartFactsTests +{ + private static readonly DateTime T0 = new(2026, 11, 11, 8, 0, 0, DateTimeKind.Utc); + + /// The #810 QualityMix tag QP: Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after. + private static readonly (double Seconds, double Value, uint Status)[] Qp = + [ + (5, 10, 0x40000000u), (12, 11, 0x00000000u), (25, 12, 0x40000000u), // Uncertain, Good, Uncertain + ]; + + [Fact] + public async Task Live_AggregateFilter_calculators_on_an_Uncertain_first_late_start_interval() + { + await using var rig = await ServerEventRig.StartAsync("AggregateLateStart", encrypted: false, + TestContext.Current.CancellationToken); + var manager = rig.Server.CurrentInstance.AggregateManager; + var config = manager.GetDefaultConfiguration(NodeId.Null); + config.TreatUncertainAsBad.ShouldBeFalse("the server pins TreatUncertainAsBad = false (#782)"); + + double One(NodeId aggregate) + { + var calc = manager.CreateCalculator(aggregate, T0, T0.AddSeconds(20), 20_000, false, config); + calc.ShouldNotBeNull(); + foreach (var (s, v, st) in Qp) + calc.QueueRawValue(new DataValue(Variant.From(v), st, T0.AddSeconds(s), T0.AddSeconds(s))); + calc.TryGetProcessedValue(true, out var dv).ShouldBeTrue(); + dv.WrappedValue.TryGetValue(out double d).ShouldBeTrue(); + return d; + } + + // Part 13 (HistoryRead on SqlServer/QuestDB): Total = 73.5 + 88 + 32/13 = 163.96…, TimeAverage = Total ÷ 15 s. + // The live calculators integrate the uncovered [0,5) as a ramp from 0 (+25) and divide by the whole 20 s. + const double part13Total = 73.5 + 88 + 32d / 13; + One(ObjectIds.AggregateFunction_Total).ShouldBe(part13Total + 25, 1e-9); // 188.96… + One(ObjectIds.AggregateFunction_TimeAverage).ShouldBe((part13Total + 25) / 20, 1e-9); // 9.448… (Part 13: 10.93…) + // #809: the Uncertain [5,12) counts as Good live (HistoryRead: 8 000 ms / 40 %). + One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(15_000d, 1e-9); + One(ObjectIds.AggregateFunction_PercentGood).ShouldBe(75d, 1e-9); + // DurationBad / PercentBad agree with HistoryRead: the 5 s before the data is Bad. + One(ObjectIds.AggregateFunction_DurationBad).ShouldBe(5_000d, 1e-9); + One(ObjectIds.AggregateFunction_PercentBad).ShouldBe(25d, 1e-9); + } +} From a224fd639198ea54fb3222464a33453dfd96c000 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:28:58 -0500 Subject: [PATCH 02/16] fix(security): scope operator-authored credential references per driver (#815) A Designer could point a Galaxy / OpcUaClient endpoint at a host they control, set a credential field to any reference the node can resolve (env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:, Galaxy file:) and click Test Connect / live browse; the node resolved the value and sent it to that host. One shared rule in Core.Abstractions (CredentialReferences + DriverCredentialPolicy) generalises the #803 Heidenhain namespace: - Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... | legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged. - OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged. - Heidenhain adopts the shared parser/resolver (namespace unchanged). Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate (CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test Connect (AdminOperationsActor, before any probe), live browse (BrowserSessionService, before any browser) and again at resolve time in every driver/browser (fail closed, before any store/env read). Co-Authored-By: Claude --- .../CredentialReferenceDeployChecks.cs | 46 +++ .../Validation/DraftValidator.cs | 1 + .../CredentialReferences.cs | 302 ++++++++++++++++++ .../DriverCredentialPolicy.cs | 148 +++++++++ .../GalaxySecretRef.cs | 117 +++---- .../Runtime/GalaxyAckCredentialResolver.cs | 71 ++-- .../HeidenhainOptionsParser.cs | 109 ++----- .../HeidenhainCredentials.cs | 32 +- .../OpcUaUserIdentityFactory.cs | 54 ++-- .../Browsing/BrowserSessionService.cs | 8 + .../Drivers/Forms/GalaxyDriverForm.razor | 12 +- .../Drivers/Forms/OpcUaClientDriverForm.razor | 2 + .../Uns/DriverConfigSaveGate.cs | 12 +- .../AdminOperations/AdminOperationsActor.cs | 14 + .../DraftValidatorCredentialReferenceTests.cs | 61 ++++ .../CredentialReferencesTests.cs | 134 ++++++++ .../GalaxyDriverApiKeyResolverTests.cs | 111 ++++--- .../Runtime/GalaxyAckCredentialTests.cs | 42 +++ .../OpcUaClientBrowserCertificateAuthTests.cs | 6 +- .../OpcUaClientSecretResolutionTests.cs | 63 +++- .../BrowserSessionCredentialScopeTests.cs | 78 +++++ .../Uns/CredentialReferenceSavePathTests.cs | 77 +++++ ...dminOperationsActorCredentialScopeTests.cs | 66 ++++ 23 files changed, 1252 insertions(+), 314 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialReferencesTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionCredentialScopeTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CredentialReferenceSavePathTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs new file mode 100644 index 000000000..5badc1b7f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs @@ -0,0 +1,46 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// Deploy gate for credential references (lmxopcua#815): every DriverConfig and DeviceConfig of a driver type +/// listed in must carry its credential fields as blank, an acceptable +/// literal, or a reference inside that driver's namespace (e.g. Galaxy secret:galaxy/… / +/// env:OTOPCUA_GALAXY_…). The same table the AdminUI save path, Test Connect, live browse and every +/// driver's resolve-time check read — so the gate and the runtime cannot disagree. +/// +/// +/// Heidenhain is excluded here only because already reports the same rule +/// (as ) through the shared parser; reporting it twice would +/// duplicate every error. The message names the field and the allowed forms, never the value. +/// +public static class CredentialReferenceDeployChecks +{ + /// A credential field holding a reference outside the driver's namespace (or a refused literal). + public const string Code = "CredentialReferenceOutOfScope"; + + /// Runs the gate. + /// The draft. + /// The errors. + public static IReadOnlyList Errors(DraftSnapshot draft) + { + var errors = new List(); + foreach (var driver in draft.DriverInstances) + { + if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue; + if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue; + + foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig)) + errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}", driver.DriverInstanceId)); + + foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))) + { + foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig)) + errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}", device.DeviceId)); + } + } + + return errors; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 5f9ed4f6e..c2880f739 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -47,6 +47,7 @@ public static class DraftValidator errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft)); ValidateMTConnectSingleDevice(draft, errors); errors.AddRange(HeidenhainDeployChecks.Errors(draft)); + errors.AddRange(CredentialReferenceDeployChecks.Errors(draft)); ValidateNodeAclScopes(draft, errors); return errors; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs new file mode 100644 index 000000000..725e3ab82 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs @@ -0,0 +1,302 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the +/// secret-store prefix a secret: reference must start with, and the environment-variable prefixes an +/// env: reference must start with. +/// +/// +/// The scope is the exfiltration guard. A Designer authors the endpoint AND the credential reference, and the +/// node resolves the reference and sends the value to that endpoint (Test Connect, live browse, deploy). An +/// unscoped reference could name any secret or process variable the node can read — +/// env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, the mesh PSK — and send it in +/// cleartext to a host the Designer controls. Inside its own namespace a reference can only name values that +/// were provisioned to be sent to that driver's kind of endpoint. +/// +/// Driver label used in messages (e.g. Galaxy). +/// +/// The ONLY secret-store prefixes allowed, each ending in / (e.g. galaxy/). The first entry is the +/// documented form. +/// +/// +/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. OTOPCUA_GALAXY_); the remainder +/// must be [A-Z0-9_]+. The first entry is the documented form. +/// +public sealed record CredentialScope(string Label, IReadOnlyList SecretNamespaces, IReadOnlyList EnvVariablePrefixes); + +/// Whether a credential field may carry a literal (non-reference) value. +public enum CredentialLiteralPolicy +{ + /// A literal is refused — the field must be blank or a namespaced reference. + Refused, + + /// A literal is accepted and used verbatim (the author already knows it; nothing is resolved). + Allowed, + + /// Only the explicit dev:VALUE literal is accepted (Galaxy's opt-in cleartext marker). + DevPrefixOnly, +} + +/// The kind of a parsed credential field value. +public enum CredentialReferenceKind +{ + /// Null or empty. + Blank, + + /// A secret: (or ${secret:…}) reference inside the scope's namespace. + Secret, + + /// An env: reference inside the scope's prefixes. + Environment, + + /// Not a reference: a literal value (acceptability depends on the field's ). + Literal, + + /// + /// A reference (secret:, ${secret:…}, env:, file:) that is outside the scope or + /// malformed. Never resolved; always refused. + /// + OutOfScope, +} + +/// A credential reference that may not (or cannot) be resolved. The message never carries a resolved value. +/// The message. +public sealed class CredentialReferenceException(string message) : InvalidOperationException(message); + +/// +/// The one parser + resolver of operator-authored credential references (secret: / env:), shared by +/// every driver, probe, browser, the AdminUI save path and the deploy gate (lmxopcua#815, generalising #803). +/// Resolution is fail-closed and re-checks the scope itself, so a reference that skipped every earlier gate is +/// still never looked up. +/// +public static class CredentialReferences +{ + /// Prefix of a secret-store reference. + public const string SecretPrefix = "secret:"; + + /// Prefix of an environment-variable reference. + public const string EnvPrefix = "env:"; + + /// Prefix of a file reference — never allowed in operator-authored config (it reads any node file). + public const string FilePrefix = "file:"; + + /// Galaxy's explicit cleartext marker. + public const string DevPrefix = "dev:"; + + private const string TokenSecretPrefix = "${secret:"; + + /// + /// Parses against . A prefix in another casing + /// (SECRET:x) is — never a literal sent verbatim and + /// never resolved; the prefix, the namespace and the name are matched exactly. + /// + /// The authored field value. + /// The driver's scope. + /// The full secret name (incl. the namespace) or the environment variable; empty otherwise. + /// The kind. + public static CredentialReferenceKind Parse(string? value, CredentialScope scope, out string name) + { + ArgumentNullException.ThrowIfNull(scope); + name = ""; + if (string.IsNullOrEmpty(value)) return CredentialReferenceKind.Blank; + + // A prefix in any casing is a reference (never a literal sent verbatim), but only the exact lower-case + // spelling is ever resolved — 'SECRET:galaxy/x' is refused, not silently honoured. + if (HasPrefixOtherCase(value, TokenSecretPrefix) || HasPrefixOtherCase(value, SecretPrefix) + || HasPrefixOtherCase(value, EnvPrefix)) + return CredentialReferenceKind.OutOfScope; + + string? secret = null; + if (value.StartsWith(TokenSecretPrefix, StringComparison.Ordinal)) + { + if (!value.EndsWith('}')) return CredentialReferenceKind.OutOfScope; + secret = value[TokenSecretPrefix.Length..^1]; + } + else if (value.StartsWith(SecretPrefix, StringComparison.Ordinal)) + { + secret = value[SecretPrefix.Length..]; + } + + if (secret is not null) + { + if (!IsScopedSecretName(secret, scope)) return CredentialReferenceKind.OutOfScope; + name = secret; + return CredentialReferenceKind.Secret; + } + + if (value.StartsWith(EnvPrefix, StringComparison.Ordinal)) + { + var variable = value[EnvPrefix.Length..]; + if (!IsScopedVariable(variable, scope)) return CredentialReferenceKind.OutOfScope; + name = variable; + return CredentialReferenceKind.Environment; + } + + return value.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase) + ? CredentialReferenceKind.OutOfScope + : CredentialReferenceKind.Literal; + } + + /// True when may be stored/deployed in a field with the given literal policy. + /// The authored value. + /// The driver's scope. + /// The field's literal policy. + /// Whether it is acceptable. + public static bool IsAcceptable(string? value, CredentialScope scope, CredentialLiteralPolicy literals) => + Parse(value, scope, out _) switch + { + CredentialReferenceKind.Blank or CredentialReferenceKind.Secret or CredentialReferenceKind.Environment => true, + CredentialReferenceKind.Literal => literals switch + { + CredentialLiteralPolicy.Allowed => true, + CredentialLiteralPolicy.DevPrefixOnly => value!.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase), + _ => false, + }, + _ => false, + }; + + /// The one wording of the rule — names the allowed forms, never the value. + /// The field. + /// The driver's scope. + /// The field's literal policy. + /// The message. + public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals) + { + ArgumentNullException.ThrowIfNull(scope); + var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}'")); + var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}'")); + var forms = $"{secrets} or {env}"; + return literals switch + { + CredentialLiteralPolicy.Allowed => + $"{field} must be a literal value, {forms} — a secret:/env:/file: reference outside the {scope.Label} " + + "namespace is refused (lmxopcua#815).", + CredentialLiteralPolicy.DevPrefixOnly => + $"{field} must be blank, {forms} (or 'dev:' on a dev rig) — a literal, a file: reference, or a " + + $"reference outside the {scope.Label} namespace is refused (lmxopcua#815).", + _ => + $"{field} must be blank, {forms} — a literal password, or a reference outside the {scope.Label} " + + "namespace, is refused (lmxopcua#815).", + }; + } + + /// + /// Resolves a field value, fail-closed. Blank returns ""; an acceptable literal returns verbatim; an + /// out-of-scope reference or a refused literal throws BEFORE any + /// store or environment read; an absent secret / unset variable returns . + /// + /// The authored value. + /// The driver's scope. + /// Field label for messages. + /// The field's literal policy. + /// Secret-store lookup (by full name); null ⇒ a secret: reference throws. + /// Environment reader; null ⇒ . + /// Cancellation. + /// The value, or null when the referenced value is absent. + public static async ValueTask ResolveOrNullAsync( + string? value, + CredentialScope scope, + string field, + CredentialLiteralPolicy literals, + Func>? secretLookup, + Func? environment, + CancellationToken ct) + { + var kind = Parse(value, scope, out var name); + if (!IsAcceptable(value, scope, literals)) + throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)"); + switch (kind) + { + case CredentialReferenceKind.Blank: + return ""; + case CredentialReferenceKind.Literal: + return value; + case CredentialReferenceKind.Environment: + return (environment ?? System.Environment.GetEnvironmentVariable)(name); + case CredentialReferenceKind.Secret: + if (secretLookup is null) + throw new CredentialReferenceException( + $"{field} references secret '{name}' but no secret store is available (fail-closed)."); + return await secretLookup(name, ct).ConfigureAwait(false); + default: + throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)"); + } + } + + /// + /// As , but an absent secret / unset (or empty) variable throws + /// naming the secret or variable (never a value). + /// + /// The authored value. + /// The driver's scope. + /// Field label for messages. + /// The field's literal policy. + /// Secret-store lookup; null ⇒ a secret: reference throws. + /// Environment reader; null ⇒ the process environment. + /// Cancellation. + /// The value ("" for blank). + public static async ValueTask ResolveAsync( + string? value, + CredentialScope scope, + string field, + CredentialLiteralPolicy literals, + Func>? secretLookup, + Func? environment, + CancellationToken ct) + { + var resolved = await ResolveOrNullAsync(value, scope, field, literals, secretLookup, environment, ct).ConfigureAwait(false); + var kind = Parse(value, scope, out var name); + if (!string.IsNullOrEmpty(resolved) || kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) + return resolved ?? ""; + throw kind == CredentialReferenceKind.Environment + ? new CredentialReferenceException($"{field} environment variable '{name}' is not set (fail-closed).") + : new CredentialReferenceException($"{field} secret '{name}' is absent from the store (fail-closed)."); + } + + /// A print-safe form of a value: the reference itself, or a redaction for a literal / dev value. + /// The authored value. + /// A string safe to log. + public static string Describe(string? value) + { + if (string.IsNullOrEmpty(value)) return ""; + var v = value.Trim(); + if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:"; + return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase) + || v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase) + || v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase) + || v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase) + ? v + : ""; + } + + private static bool HasPrefixOtherCase(string value, string prefix) => + value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !value.StartsWith(prefix, StringComparison.Ordinal); + + private static bool IsScopedSecretName(string secret, CredentialScope scope) + { + foreach (var ns in scope.SecretNamespaces) + { + if (!secret.StartsWith(ns, StringComparison.Ordinal)) continue; + var rest = secret[ns.Length..]; + if (rest.Length is 0 or > 200) continue; + if (!rest.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) continue; + if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) continue; + return true; + } + + return false; + } + + private static bool IsScopedVariable(string variable, CredentialScope scope) + { + foreach (var prefix in scope.EnvVariablePrefixes) + { + if (!variable.StartsWith(prefix, StringComparison.Ordinal)) continue; + var rest = variable[prefix.Length..]; + if (rest.Length is > 0 and <= 100 && rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_')) + return true; + } + + return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs new file mode 100644 index 000000000..2b3eeb745 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs @@ -0,0 +1,148 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// One credential field of a driver config and whether it may carry a literal. +/// The JSON property name (matched case-insensitively, as every driver binds). +/// The literal policy. +public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals); + +/// The credential scope + fields of one driver type. +/// The value. +/// The namespace its references are confined to. +/// The credential fields its DriverConfig/DeviceConfig may carry. +public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList Fields); + +/// +/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate, +/// Test Connect, live browse and every driver's resolve-time check read. +/// +/// +/// Every driver that RESOLVES a secret:/env: reference from operator-authored config and sends the +/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only — +/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's +/// Sql__ConnectionStrings__<ref>, already a fixed prefix) are not. +/// +public static class DriverCredentialPolicy +{ + /// Heidenhain LSV2 passwords: secret:heidenhain/… / env:OTOPCUA_HEIDENHAIN_… (#803). + public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]); + + /// + /// Galaxy gateway API key + fallback ack password: secret:galaxy/… (or secret:otopcua/galaxy/…, the + /// form the ack-fallback docs used) / env:OTOPCUA_GALAXY_…, plus the + /// legacy env:GALAXY_MXGW_… prefix the docker-dev rig and the parity-rig docs already use + /// (GALAXY_MXGW_API_KEY) — a variable provisioned to be sent to an mxaccessgw. + /// + public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]); + + /// OPC UA Client user / certificate passwords: secret:opcuaclient/… / env:OTOPCUA_OPCUACLIENT_…. + public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]); + + /// Galaxy Gateway.ApiKeySecretRef (literal accepted for back-compat, with a warning at resolve). + public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed); + + /// Galaxy AlarmAck.FallbackPasswordSecretRef (reference or explicit dev: only). + public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly); + + private static readonly IReadOnlyDictionary Rules = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope, + [ + new("InspectPassword", CredentialLiteralPolicy.Refused), + new("DncPassword", CredentialLiteralPolicy.Refused), + new("DataPassword", CredentialLiteralPolicy.Refused), + ]), + [DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]), + [DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope, + [ + new("Password", CredentialLiteralPolicy.Allowed), + new("UserCertificatePassword", CredentialLiteralPolicy.Allowed), + ]), + }; + + /// The rules for a driver type, if it resolves credential references. + /// The driver type. + /// The rules. + /// Whether the type has rules. + public static bool TryGet(string? driverType, out DriverCredentialRules rules) + { + if (driverType is not null && Rules.TryGetValue(driverType, out var r)) + { + rules = r; + return true; + } + + rules = null!; + return false; + } + + /// + /// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of + /// whose value is not acceptable — at ANY depth (a field is bound wherever the + /// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value. + /// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty. + /// + /// The owning driver's type. + /// The config JSON. + /// The problems; empty when acceptable or the type has no credential rules. + public static IReadOnlyList DescribeProblems(string? driverType, string? json) + { + var problems = new List(); + if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems; + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + } + catch (JsonException) + { + problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked."); + return problems; + } + + using (doc) + { + Scan(doc.RootElement, "", rules, problems, 0); + } + + return problems; + } + + private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List problems, int depth) + { + if (depth > 32) return; + if (element.ValueKind == JsonValueKind.Array) + { + var i = 0; + foreach (var item in element.EnumerateArray()) + { + Scan(item, $"{path}[{i}]", rules, problems, depth + 1); + i++; + } + + return; + } + + if (element.ValueKind != JsonValueKind.Object) return; + foreach (var p in element.EnumerateObject()) + { + var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}"; + var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase)); + if (field is null) + { + Scan(p.Value, fieldPath, rules, problems, depth + 1); + continue; + } + + var ok = p.Value.ValueKind switch + { + JsonValueKind.Null => true, + JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals), + _ => false, + }; + if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals)); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs index d7bca47f3..5e685ad6d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs @@ -1,33 +1,24 @@ using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; /// -/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. Five -/// forms supported, evaluated in order: +/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. The reference is resolved on the node +/// and sent to the operator-authored Gateway.Endpoint, so references are confined to the Galaxy +/// namespace (lmxopcua#815, ) and the check is repeated here, at +/// resolve time, before any store or environment read: /// -/// env:NAME — reads Environment.GetEnvironmentVariable(NAME). -/// Throws when the variable is unset, so a misconfigured deployment fails -/// fast rather than silently sending an empty key. -/// file:PATH — reads UTF-8 text from PATH, trimming -/// whitespace. Lets operators stash the key in an ACL'd file outside the -/// repo (the same pattern as the legacy .local/galaxy-host-secret.txt). -/// dev:KEY — explicit cleartext literal. The dev: prefix -/// is a deliberate opt-in signal (dev box, parity rig) so the resolver -/// doesn't emit a warning; production should never use this arm. -/// secret:NAME — resolves NAME through the shared -/// ZB.MOM.WW.Secrets (the encrypted-at-rest -/// store). Fail-closed: a secret: ref whose secret is absent/tombstoned -/// throws rather than falling through to the literal arm — the production path -/// that retires the cleartext dev:/literal-in-DB model. -/// Anything else — used as the literal API key for back-compat with -/// configs that pre-date this resolver. When a logger is supplied the -/// resolver emits a startup warning so an operator who accidentally -/// committed a cleartext key sees it. +/// secret:galaxy/NAME (or ${secret:galaxy/NAME}) — resolved through the shared +/// ZB.MOM.WW.Secrets ; fail-closed when absent. +/// env:OTOPCUA_GALAXY_NAME / legacy env:GALAXY_MXGW_NAME — the process environment; +/// fail-closed when unset. +/// dev:KEY — explicit cleartext literal (dev box, parity rig); no warning. +/// Any other unprefixed string — the literal key (back-compat), with a warning when a logger is supplied. +/// A secret:/env: reference OUTSIDE the namespace, and every file: reference (it +/// read any file the node could open) — refused with . /// -/// A future PR can swap any of these arms for a different backing store without -/// changing the call site. /// /// /// Lives in the Contracts project so both the runtime GalaxyDriver and the @@ -37,80 +28,50 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; public static class GalaxySecretRef { /// - /// Resolves the supplied secret reference. The secret:NAME arm resolves - /// through and is fail-closed (throws when the secret - /// is absent). When the ref falls through to the back-compat literal arm (an - /// unprefixed cleartext API key in DriverConfig JSON) and a - /// is supplied, emits a . - /// The dev: prefix is the explicit opt-in path that doesn't warn. + /// Resolves the supplied API-key reference (see the type remarks). Fail-closed for an out-of-namespace + /// reference, a file: reference, an absent secret and an unset variable. /// /// The secret reference string to resolve. /// The shared secret resolver used by the secret: arm. /// Optional logger for warning on cleartext keys. /// Cancellation token for the async secret: resolution. + /// Environment reader (tests); null ⇒ the process environment. /// The resolved API-key string. public static async Task ResolveApiKeyAsync( string secretRef, ISecretResolver resolver, ILogger? logger = null, - CancellationToken ct = default) + CancellationToken ct = default, + Func? environment = null) { ArgumentException.ThrowIfNullOrEmpty(secretRef); ArgumentNullException.ThrowIfNull(resolver); - if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + var scope = DriverCredentialPolicy.GalaxyScope; + if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal) { - var name = secretRef[4..]; - var value = Environment.GetEnvironmentVariable(name); - return !string.IsNullOrEmpty(value) - ? value - : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset."); - } - - if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var path = secretRef[5..]; - if (!File.Exists(path)) + if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist."); + // Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal. + return secretRef[CredentialReferences.DevPrefix.Length..]; } - var contents = File.ReadAllText(path).Trim(); - return !string.IsNullOrEmpty(contents) - ? contents - : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty."); + + // Back-compat literal arm. Warn so an operator who accidentally committed a cleartext key sees it. + logger?.LogWarning( + "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " + + "Prefer secret:galaxy/ or env:OTOPCUA_GALAXY_, or the explicit dev:KEY prefix for dev rigs — " + + "a literal key in DriverConfig JSON is stored in cleartext in the central config DB."); + return secretRef; } - if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) - { - // Explicit dev opt-in — no warning, the operator deliberately chose a - // cleartext literal (dev box, parity rig). - return secretRef[4..]; - } - - if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) - { - // Production path: resolve the name through the shared encrypted secret store. - // Fail-closed — an absent/tombstoned secret throws rather than falling through - // to the literal arm (which would silently treat the ref string as the key). - var name = secretRef["secret:".Length..]; - var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); - return !string.IsNullOrEmpty(value) - ? value - : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed)."); - } - - // Back-compat literal arm. An unprefixed string is treated as the literal - // API key — but emit a warning so an operator who accidentally committed a - // cleartext key into DriverConfig sees it. Use the dev: prefix to suppress - // this warning when the literal is intentional. - logger?.LogWarning( - "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " + - "Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " + - "a literal key in DriverConfig JSON is stored in cleartext in the central config DB."); - return secretRef; + // secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read. + return await CredentialReferences.ResolveAsync( + secretRef, + scope, + "Galaxy.Gateway.ApiKeySecretRef", + DriverCredentialPolicy.GalaxyApiKeyField.Literals, + async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false), + environment, + ct).ConfigureAwait(false); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs index c20295d3c..77b2ad751 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.Secrets.Abstractions; @@ -20,15 +21,19 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver private readonly GalaxyAlarmAckOptions _options; private readonly ISecretResolver _secrets; private readonly ILogger _logger; + private readonly Func? _environment; /// Initializes a new instance of the class. /// The owning driver instance id (for messages). /// The driver's ack-credential options. /// The shared secret resolver. /// Logger; never receives a secret. + /// Environment reader (tests); null ⇒ the process environment. public GalaxyAckCredentialResolver( - string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger) + string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger, + Func? environment = null) { + _environment = environment; _driverInstanceId = driverInstanceId; _options = options ?? new GalaxyAlarmAckOptions(); _secrets = secrets ?? throw new ArgumentNullException(nameof(secrets)); @@ -142,56 +147,28 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver } /// - /// Resolves a password reference. Accepts secret:NAME, ${secret:NAME}, env:NAME, - /// file:PATH and dev:VALUE; returns null when the referenced value is absent; throws - /// for a bare literal (a cleartext password in DriverConfig). + /// Resolves the fallback password reference through the shared lmxopcua#815 resolver: secret:galaxy/NAME + /// (or secret:otopcua/galaxy/NAME, ${secret:…}), env:OTOPCUA_GALAXY_NAME / + /// env:GALAXY_MXGW_NAME, or dev:VALUE; returns null when the referenced value is absent; throws + /// — before any store/environment read — for a bare literal, a + /// file: reference, or a reference outside the Galaxy namespace (the password is sent to the + /// operator-authored gateway endpoint, so it must never be able to name another subsystem's secret). /// internal async Task ResolvePasswordRefAsync(string reference, CancellationToken ct) { - if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}')) - { - reference = "secret:" + reference["${secret:".Length..^1]; - } - - if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) - { - return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false); - } - - if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) - { - return Environment.GetEnvironmentVariable(reference[4..]); - } - - if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var path = reference[5..]; - return File.Exists(path) - ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') - : null; - } - - if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) - { - return reference[4..]; - } - - throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal."); + var resolved = await CredentialReferences.ResolveOrNullAsync( + reference, + DriverCredentialPolicy.GalaxyScope, + "AlarmAck.FallbackPasswordSecretRef", + DriverCredentialPolicy.GalaxyAckPasswordField.Literals, + async (name, c) => await _secrets.GetAsync(new SecretName(name), c).ConfigureAwait(false), + _environment, + ct).ConfigureAwait(false); + return resolved is not null && reference.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase) + ? resolved[CredentialReferences.DevPrefix.Length..] + : resolved; } // Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal. - internal static string DescribeRef(string reference) - { - var r = reference.Trim(); - if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:"; - if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) - || r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase) - || r.StartsWith("env:", StringComparison.OrdinalIgnoreCase) - || r.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - return r; - } - - return ""; - } + internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs index 925b4735c..bd1aa3899 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; @@ -21,14 +22,14 @@ public static class HeidenhainOptionsParser /// exfiltration guard: the reference is resolved on the node and sent in cleartext in A_LG to a Host /// the author chose, so it must never be able to name another subsystem's secret. /// - public const string SecretNamespace = "heidenhain/"; + public static readonly string SecretNamespace = DriverCredentialPolicy.HeidenhainScope.SecretNamespaces[0]; /// /// The ONLY environment variables a Heidenhain password may name: env:OTOPCUA_HEIDENHAIN_<NAME> /// (exact upper-case prefix; NAME is [A-Z0-9_]+) — never ConnectionStrings__*, /// ServerHistorian__ApiKey or any other process variable. /// - public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_"; + public static readonly string EnvVariablePrefix = DriverCredentialPolicy.HeidenhainScope.EnvVariablePrefixes[0]; /// The password fields a Heidenhain DriverConfig/DeviceConfig may carry. public static readonly IReadOnlyList PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"]; @@ -164,105 +165,41 @@ public static class HeidenhainOptionsParser /// True for blank or a namespaced reference. public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name) { - kind = CredentialKind.None; - name = ""; - if (string.IsNullOrEmpty(value)) return true; - if (value.StartsWith(SecretPrefix, StringComparison.Ordinal)) + // The shared lmxopcua#815 parser — one rule for every driver that resolves credential references. + switch (CredentialReferences.Parse(value, DriverCredentialPolicy.HeidenhainScope, out name)) { - var secret = value[SecretPrefix.Length..]; - if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false; - var rest = secret[SecretNamespace.Length..]; - if (rest.Length is 0 or > 200) return false; - foreach (var c in rest) - if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false; - if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false; - kind = CredentialKind.Secret; - name = secret; - return true; - } - - if (value.StartsWith(EnvPrefix, StringComparison.Ordinal)) - { - var variable = value[EnvPrefix.Length..]; - if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false; - var rest = variable[EnvVariablePrefix.Length..]; - if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_')) + case CredentialReferenceKind.Blank: + kind = CredentialKind.None; + return true; + case CredentialReferenceKind.Secret: + kind = CredentialKind.Secret; + return true; + case CredentialReferenceKind.Environment: + kind = CredentialKind.Environment; + return true; + default: + kind = CredentialKind.None; + name = ""; return false; - kind = CredentialKind.Environment; - name = variable; - return true; } - - return false; } /// /// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not - /// namespaced references — at the top level (a device / merged config), under Access, and under every - /// Devices[] entry; keys matched case-insensitively, as the parser binds them. The messages name the - /// field, never the value. Unreadable JSON is reported, since it cannot be checked. + /// namespaced references — at any depth (top level, Access, Devices[]…); keys matched + /// case-insensitively, as the parser binds them. The messages name the field, never the value. Unreadable + /// JSON is reported, since it cannot be checked. Delegates to the shared . /// /// The config JSON. /// The problems; empty when every password field is acceptable. - public static IReadOnlyList DescribeCredentialProblems(string? json) - { - var problems = new List(); - if (string.IsNullOrWhiteSpace(json)) return problems; - JsonDocument doc; - try - { - doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); - } - catch (JsonException) - { - problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked."); - return problems; - } - - using (doc) - { - if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems; - ScanObject(doc.RootElement, "", problems); - foreach (var p in doc.RootElement.EnumerateObject()) - { - if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object) - ScanObject(p.Value, "Access.", problems); - if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array) - { - var i = 0; - foreach (var d in p.Value.EnumerateArray()) - { - if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems); - i++; - } - } - } - } - - return problems; - } + public static IReadOnlyList DescribeCredentialProblems(string? json) => + DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Heidenhain, json); /// The one wording of the credential rule (never includes the value). /// The field. /// The message. public static string CredentialRuleMessage(string field) => - $"{field} must be blank, 'secret:{SecretNamespace}' or 'env:{EnvVariablePrefix}' — a literal " - + "password, or a reference outside the Heidenhain namespace, is refused."; - - private static void ScanObject(JsonElement obj, string prefix, List problems) - { - foreach (var p in obj.EnumerateObject()) - { - if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue; - var ok = p.Value.ValueKind switch - { - JsonValueKind.Null => true, - JsonValueKind.String => IsCredentialReference(p.Value.GetString()), - _ => false, - }; - if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name)); - } - } + CredentialReferences.RuleMessage(field, DriverCredentialPolicy.HeidenhainScope, CredentialLiteralPolicy.Refused); private static void CheckRef(List errors, string field, string? value) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs index f441dfcb1..a83518677 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; @@ -31,26 +32,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, FuncResolves one reference. + /// Resolves one reference through the shared lmxopcua#815 resolver (scope re-checked before any read). public async ValueTask ResolveReferenceAsync(string? reference, string role, CancellationToken ct) { - if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name)) - throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)"); - switch (kind) + try { - case HeidenhainOptionsParser.CredentialKind.None: - return ""; - case HeidenhainOptionsParser.CredentialKind.Environment: - return _environment(name) is { Length: > 0 } value - ? value - : throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed)."); - default: - if (resolver is null) - throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed)."); - var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); - return !string.IsNullOrEmpty(resolved) - ? resolved - : throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed)."); + return await CredentialReferences.ResolveAsync( + reference, + DriverCredentialPolicy.HeidenhainScope, + $"{role} password", + CredentialLiteralPolicy.Refused, + resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false), + _environment, + ct).ConfigureAwait(false); + } + catch (CredentialReferenceException ex) + { + throw new HeidenhainCredentialException(ex.Message); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index 20605be4e..f1433c253 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -1,5 +1,6 @@ using Opc.Ua; using Opc.Ua.Security.Certificates; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; @@ -12,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// public static class OpcUaUserIdentityFactory { - private const string SecretPrefix = "secret:"; /// /// Build the user-identity token from options whose credential fields are already resolved plaintext @@ -103,25 +103,29 @@ public static class OpcUaUserIdentityFactory } /// - /// Return a copy of with any secret:-prefixed - /// / - /// resolved through . Non-secret / null / empty fields pass through unchanged (the - /// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a - /// secret: ref with no resolver available, throws rather than sending the literal as a credential. + /// Return a copy of with a secret:opcuaclient/… or + /// env:OTOPCUA_OPCUACLIENT_… / + /// resolved (lmxopcua#815 namespace). Literal / + /// null / empty fields pass through unchanged (the reference-equal original is returned when nothing changed). + /// Fail-closed: a reference outside the namespace (or file:) throws before any read, and an absent + /// secret, an unset variable, or a secret: ref with no resolver available throws rather than sending + /// the reference string as a credential. /// /// The raw options (credential fields may carry secret: refs). /// The shared secret resolver, or null when none is available. /// Cancellation token. + /// Environment reader (tests); null ⇒ the process environment. /// An options copy whose credential fields carry resolved plaintext. public static async Task ResolveSecretRefsAsync( - OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct) + OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct, + Func? environment = null) { ArgumentNullException.ThrowIfNull(options); - var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct) + var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment) .ConfigureAwait(false); var certPassword = await ResolveFieldAsync( - options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct) + options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment) .ConfigureAwait(false); if (ReferenceEquals(password, options.Password) @@ -134,24 +138,22 @@ public static class OpcUaUserIdentityFactory } private static async Task ResolveFieldAsync( - string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct) + string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct, + Func? environment) { - if (string.IsNullOrEmpty(value) - || !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)) - { - return value; - } + // A literal (or blank) passes through verbatim, reference-equal — the author already knows it. + var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _); + if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value; - var name = value[SecretPrefix.Length..]; - if (resolver is null) - throw new InvalidOperationException( - $"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " + - "available here (fail-closed)."); - var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); - return !string.IsNullOrEmpty(resolved) - ? resolved - : throw new InvalidOperationException( - $"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " + - "absent from the store (fail-closed)."); + // lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and + // env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read. + return await CredentialReferences.ResolveAsync( + value, + DriverCredentialPolicy.OpcUaClientScope, + $"OpcUaClientDriverOptions.{fieldName}", + CredentialLiteralPolicy.Allowed, + resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false), + environment, + ct).ConfigureAwait(false); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs index 2121697ff..f3861c768 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; @@ -36,6 +37,13 @@ public sealed class BrowserSessionService( /// public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { + // lmxopcua#815: a live browse resolves the config's credential references and sends them to the + // author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the + // Discover-backed universal browser) is opened. Nothing is read from the store or environment here. + var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson); + if (credentialProblems.Count > 0) + return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty); + if (!_browsersByType.TryGetValue(driverType, out var browser)) return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false); try diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor index 656cdf383..865fd7339 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor @@ -21,8 +21,8 @@
-
Forms: env:NAME, file:PATH, dev:KEY. Cleartext is accepted but triggers a startup warning.
+ placeholder="env:OTOPCUA_GALAXY_API_KEY" /> +
Forms: secret:galaxy/NAME, env:OTOPCUA_GALAXY_NAME (or legacy env:GALAXY_MXGW_NAME), dev:KEY. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and file: are refused (#815).
@@ -107,7 +107,7 @@ -
Forms: secret:NAME (production), env:NAME, file:PATH, dev:VALUE. A bare literal is refused.
+
Forms: secret:galaxy/NAME or secret:otopcua/galaxy/NAME (production), env:OTOPCUA_GALAXY_NAME, dev:VALUE. A bare literal, file:, or a reference outside the Galaxy namespace is refused (#815).
@@ -220,7 +220,7 @@ } private static GalaxyDriverOptions CreateDefaultOptions() => new( - Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"), + Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"), MxAccess: new GalaxyMxAccessOptions("OtOpcUa"), Repository: new GalaxyRepositoryOptions(), Reconnect: new GalaxyReconnectOptions()); @@ -241,7 +241,7 @@ { // GalaxyGatewayOptions public string GatewayEndpoint { get; set; } = "https://localhost:5001"; - public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY"; + public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY"; public bool GatewayUseTls { get; set; } = true; public string? GatewayCaCertificatePath { get; set; } public int GatewayConnectTimeoutSeconds { get; set; } = 10; @@ -273,7 +273,7 @@ public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) { - var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"); + var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"); var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa"); var repo = r.Repository ?? new GalaxyRepositoryOptions(); var rc = r.Reconnect ?? new GalaxyReconnectOptions(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index 2b0f68094..bd35b42d2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -138,6 +138,7 @@
+
Literal, secret:opcuaclient/NAME or env:OTOPCUA_OPCUACLIENT_NAME. Other references are refused (#815).
} @if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate) @@ -150,6 +151,7 @@
+
Literal, secret:opcuaclient/NAME or env:OTOPCUA_OPCUACLIENT_NAME.
} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs index 9b46518e7..9b62b3a16 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs @@ -1,5 +1,4 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; @@ -10,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// block Save while the form is invalid. ///
/// -/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain -/// namespace () — otherwise a literal password -/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it. +/// Today it carries one rule, for every driver that resolves credential references +/// ( — Heidenhain, Galaxy, OpcUaClient): a credential field must be blank, +/// an acceptable literal for that field, or a reference inside the driver's namespace (lmxopcua#803/#815) — +/// otherwise an exfiltration-shaped reference (or a Heidenhain literal password) would be persisted in a draft +/// (and replicated) even though the deploy gate and every resolver would later refuse it. /// public static class DriverConfigSaveGate { @@ -22,8 +23,7 @@ public static class DriverConfigSaveGate /// The refusal (never containing a credential value), or null. public static string? Validate(string? driverType, string? configJson) { - if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null; - var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson); + var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson); return problems.Count == 0 ? null : string.Join(" ", problems); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index d4de355e3..0f8ed08f8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -585,6 +585,20 @@ public sealed class AdminOperationsActor : ReceiveActor return; } + // lmxopcua#815: refuse a credential reference outside the driver's namespace BEFORE any probe runs — the + // probe would otherwise be the seam that resolves it and sends it to the author-chosen endpoint. Nothing + // is read from the secret store or the environment on this path. + var credentialProblems = DriverCredentialPolicy.DescribeProblems(msg.DriverType, msg.ConfigJson); + if (credentialProblems.Count > 0) + { + replyTo.Tell(new TestDriverConnectResult( + false, + "Test Connect refused: " + string.Join(" ", credentialProblems), + null, + msg.CorrelationId)); + return; + } + Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo); } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs new file mode 100644 index 000000000..a682e54aa --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs @@ -0,0 +1,61 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// lmxopcua#815: the credential-reference namespace deploy gate (Galaxy, OpcUaClient). +[Trait("Category", "Unit")] +public sealed class DraftValidatorCredentialReferenceTests +{ + private static IReadOnlyList Validate(string type, string driverConfig, string deviceConfig = "{}") => + DraftValidator.Validate(new DraftSnapshot + { + GenerationId = 1, + ClusterId = "c", + DriverInstances = [new DriverInstance { DriverInstanceId = "drv", ClusterId = "c", Name = "drv", DriverType = type, DriverConfig = driverConfig }], + Devices = [new Device { DeviceId = "dev", DriverInstanceId = "drv", Name = "dev1", DeviceConfig = deviceConfig }], + Tags = [], + }).Where(e => e.Code == CredentialReferenceDeployChecks.Code).ToList(); + + [Theory] + [InlineData("GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")] + [InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")] + [InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"file:C:\\otopcua\\appsettings.json"}}""")] + [InlineData("GalaxyMxGateway", """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")] + [InlineData("OpcUaClient", """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")] + [InlineData("OpcUaClient", """{"UserCertificatePassword":"env:ConnectionStrings__ConfigDb"}""")] + public void An_out_of_namespace_reference_is_refused_naming_the_allowed_form(string type, string config) + { + var e = Validate(type, config).ShouldHaveSingleItem(); + e.Context.ShouldBe("drv"); + e.Message.ShouldContain("secret:"); + e.Message.ShouldContain("lmxopcua#815"); + e.Message.ShouldNotContain("ConnectionStrings__ConfigDb"); + } + + [Fact] + public void A_reference_in_a_device_config_is_checked_too() + => Validate("GalaxyMxGateway", "{}", """{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""") + .ShouldHaveSingleItem().Context.ShouldBe("dev"); + + [Theory] + [InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")] + [InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""")] + [InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"dev:k"}}""")] + [InlineData("OpcUaClient", """{"Password":"literal","UserCertificatePassword":"env:OTOPCUA_OPCUACLIENT_CERT_PW"}""")] + [InlineData("Modbus", """{"Password":"env:ConnectionStrings__ConfigDb"}""")] + public void Namespaced_literal_and_unrelated_configs_pass(string type, string config) + => Validate(type, config).ShouldBeEmpty(); + + [Fact] + public void Heidenhain_is_reported_once_by_its_own_gate_not_twice() + => DraftValidator.Validate(new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + DriverInstances = [new DriverInstance { DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = "Heidenhain", DriverConfig = """{"Access":{"InspectPassword":"env:ConnectionStrings__ConfigDb"}}""" }], + Devices = [], Tags = [], + }) + .Count(e => e.Code is CredentialReferenceDeployChecks.Code or HeidenhainDeployChecks.CredentialCode).ShouldBe(1); +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialReferencesTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialReferencesTests.cs new file mode 100644 index 000000000..d387ad539 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialReferencesTests.cs @@ -0,0 +1,134 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +/// lmxopcua#815 — the shared credential-reference parser, resolver and per-driver policy table. +public sealed class CredentialReferencesTests +{ + private static readonly CredentialScope Galaxy = DriverCredentialPolicy.GalaxyScope; + + [Theory] + [InlineData(null, CredentialReferenceKind.Blank)] + [InlineData("", CredentialReferenceKind.Blank)] + [InlineData("secret:galaxy/inst/apikey", CredentialReferenceKind.Secret)] + [InlineData("${secret:galaxy/inst/apikey}", CredentialReferenceKind.Secret)] + [InlineData("secret:otopcua/galaxy/ack-fallback", CredentialReferenceKind.Secret)] + [InlineData("env:OTOPCUA_GALAXY_API_KEY", CredentialReferenceKind.Environment)] + [InlineData("env:GALAXY_MXGW_API_KEY", CredentialReferenceKind.Environment)] + [InlineData("plain-key", CredentialReferenceKind.Literal)] + [InlineData("dev:k", CredentialReferenceKind.Literal)] + [InlineData("secret:otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)] + [InlineData("secret:otopcua/apikeys/ab/galaxy-ack-password", CredentialReferenceKind.OutOfScope)] + [InlineData("secret:OT-MESH-PSK-MAIN", CredentialReferenceKind.OutOfScope)] + [InlineData("secret:galaxy/", CredentialReferenceKind.OutOfScope)] + [InlineData("secret:galaxy/../otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)] + [InlineData("secret:galaxy//x", CredentialReferenceKind.OutOfScope)] + [InlineData("secret:galaxyx/y", CredentialReferenceKind.OutOfScope)] + [InlineData("${secret:galaxy/x", CredentialReferenceKind.OutOfScope)] + [InlineData("SECRET:galaxy/x", CredentialReferenceKind.OutOfScope)] + [InlineData("Env:OTOPCUA_GALAXY_X", CredentialReferenceKind.OutOfScope)] + [InlineData("env:ConnectionStrings__ConfigDb", CredentialReferenceKind.OutOfScope)] + [InlineData("env:ServerHistorian__ApiKey", CredentialReferenceKind.OutOfScope)] + [InlineData("env:OTOPCUA_GALAXY_", CredentialReferenceKind.OutOfScope)] + [InlineData("env:OTOPCUA_GALAXY_lower", CredentialReferenceKind.OutOfScope)] + [InlineData("env:OTOPCUA_HEIDENHAIN_X", CredentialReferenceKind.OutOfScope)] + [InlineData("file:appsettings.json", CredentialReferenceKind.OutOfScope)] + [InlineData("FILE:x", CredentialReferenceKind.OutOfScope)] + public void Parse_classifies_against_the_galaxy_scope(string? value, CredentialReferenceKind expected) => + CredentialReferences.Parse(value, Galaxy, out _).ShouldBe(expected); + + [Fact] + public void Scopes_do_not_overlap_across_drivers() + { + CredentialReferences.Parse("secret:heidenhain/x", Galaxy, out _).ShouldBe(CredentialReferenceKind.OutOfScope); + CredentialReferences.Parse("secret:galaxy/x", DriverCredentialPolicy.OpcUaClientScope, out _) + .ShouldBe(CredentialReferenceKind.OutOfScope); + CredentialReferences.Parse("env:OTOPCUA_GALAXY_X", DriverCredentialPolicy.HeidenhainScope, out _) + .ShouldBe(CredentialReferenceKind.OutOfScope); + } + + [Theory] + [InlineData(CredentialLiteralPolicy.Allowed, "plain", true)] + [InlineData(CredentialLiteralPolicy.Refused, "plain", false)] + [InlineData(CredentialLiteralPolicy.Refused, "dev:x", false)] + [InlineData(CredentialLiteralPolicy.DevPrefixOnly, "dev:x", true)] + [InlineData(CredentialLiteralPolicy.DevPrefixOnly, "plain", false)] + [InlineData(CredentialLiteralPolicy.Allowed, "env:ConnectionStrings__ConfigDb", false)] + public void IsAcceptable_applies_the_literal_policy(CredentialLiteralPolicy policy, string value, bool expected) => + CredentialReferences.IsAcceptable(value, Galaxy, policy).ShouldBe(expected); + + /// The fail-closed core: an out-of-scope reference throws before either source is consulted. + [Theory] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("secret:otopcua/historian/api-key")] + [InlineData("file:appsettings.json")] + public async Task Resolve_never_reads_for_an_out_of_scope_reference(string value) + { + var reads = 0; + var ex = await Should.ThrowAsync(async () => await CredentialReferences.ResolveAsync( + value, Galaxy, "F", CredentialLiteralPolicy.Allowed, + (_, _) => { reads++; return ValueTask.FromResult("LEAKED"); }, + _ => { reads++; return "LEAKED"; }, + CancellationToken.None)); + reads.ShouldBe(0); + ex.Message.ShouldContain("'secret:galaxy/'"); + ex.Message.ShouldContain("'env:OTOPCUA_GALAXY_'"); + ex.Message.ShouldNotContain("LEAKED"); + } + + [Fact] + public async Task Resolve_reads_scoped_references_and_fails_closed_when_absent() + { + (await CredentialReferences.ResolveAsync("secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused, + (n, _) => ValueTask.FromResult(n == "galaxy/a" ? "v" : null), _ => null, CancellationToken.None)) + .ShouldBe("v"); + (await CredentialReferences.ResolveAsync("env:OTOPCUA_GALAXY_A", Galaxy, "F", CredentialLiteralPolicy.Refused, + null, n => n == "OTOPCUA_GALAXY_A" ? "e" : null, CancellationToken.None)).ShouldBe("e"); + (await Should.ThrowAsync(async () => await CredentialReferences.ResolveAsync( + "secret:galaxy/missing", Galaxy, "F", CredentialLiteralPolicy.Refused, + (_, _) => ValueTask.FromResult(null), null, CancellationToken.None))).Message.ShouldContain("absent"); + (await Should.ThrowAsync(async () => await CredentialReferences.ResolveAsync( + "env:OTOPCUA_GALAXY_UNSET", Galaxy, "F", CredentialLiteralPolicy.Refused, + null, _ => null, CancellationToken.None))).Message.ShouldContain("not set"); + (await Should.ThrowAsync(async () => await CredentialReferences.ResolveAsync( + "secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused, + null, null, CancellationToken.None))).Message.ShouldContain("no secret store"); + } + + [Fact] + public void DescribeProblems_scans_every_depth_case_insensitively_and_never_echoes_the_value() + { + const string json = """ + { + "gateway": { "endpoint": "http://evil:5001", "apiKeySecretRef": "env:ConnectionStrings__ConfigDb" }, + "Devices": [ { "AlarmAck": { "FallbackPasswordSecretRef": "hunter2" } } ] + } + """; + var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, json); + problems.Count.ShouldBe(2); + problems.ShouldContain(p => p.StartsWith("gateway.apiKeySecretRef", StringComparison.Ordinal)); + problems.ShouldContain(p => p.StartsWith("Devices[0].AlarmAck.FallbackPasswordSecretRef", StringComparison.Ordinal)); + problems.ShouldAllBe(p => !p.Contains("hunter2") && !p.Contains("ConnectionStrings__ConfigDb")); + } + + [Theory] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"dev:x"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"literal-key"}}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"Password":"plain","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")] + [InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"env:OTOPCUA_HEIDENHAIN_INSPECT"}}""")] + [InlineData(DriverTypeNames.Modbus, """{"Password":"env:ConnectionStrings__ConfigDb"}""")] + [InlineData(DriverTypeNames.Galaxy, "")] + public void DescribeProblems_accepts_namespaced_and_unrelated_configs(string driverType, string json) => + DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldBeEmpty(); + + [Theory] + [InlineData(DriverTypeNames.OpcUaClient, """{"Password":"secret:galaxy/main/apikey"}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:C:\\ProgramData\\x.txt"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":42}}""")] + [InlineData(DriverTypeNames.Heidenhain, """{"InspectPassword":"plain"}""")] + [InlineData(DriverTypeNames.Galaxy, "{not json")] + public void DescribeProblems_refuses_out_of_namespace_configs(string driverType, string json) => + DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldNotBeEmpty(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs index 08a6bbd85..c32520531 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs @@ -1,14 +1,15 @@ using Microsoft.Extensions.Logging; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; /// -/// Follow-up #2 + G-2a — pins the five resolution forms supported by -/// : env:NAME, file:PATH, +/// Follow-up #2 + G-2a + lmxopcua#815 — pins the resolution forms supported by +/// : env:OTOPCUA_GALAXY_NAME (file: is refused), /// dev:KEY, secret:NAME (via the shared ), and /// the literal-string fallback. The secret: arm is fail-closed — an absent secret /// throws rather than leaking the ref string as the key. (The resolver was extracted from @@ -38,7 +39,7 @@ public sealed class GalaxyDriverApiKeyResolverTests [Fact] public async Task Env_prefix_resolves_to_environment_variable() { - const string name = "OTOPCUA_TEST_GALAXY_API_KEY"; + const string name = "OTOPCUA_GALAXY_TEST_API_KEY"; Environment.SetEnvironmentVariable(name, "key-from-env"); try { @@ -55,41 +56,13 @@ public sealed class GalaxyDriverApiKeyResolverTests [Fact] public async Task Env_prefix_unset_variable_throws_with_descriptive_message() { - const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET"; + const string name = "OTOPCUA_GALAXY_TEST_API_KEY_UNSET"; Environment.SetEnvironmentVariable(name, null); var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver())); ex.Message.ShouldContain(name); - ex.Message.ShouldContain("unset"); - } - - /// Verifies that file: prefix resolves to trimmed file contents. - [Fact] - public async Task File_prefix_resolves_to_trimmed_file_contents() - { - var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt"); - File.WriteAllText(path, " key-from-file \n"); - try - { - (await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver(), ct: TestContext.Current.CancellationToken)) - .ShouldBe("key-from-file"); - } - finally - { - File.Delete(path); - } - } - - /// Verifies that file: prefix with missing path throws. - [Fact] - public async Task File_prefix_missing_path_throws() - { - var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt"); - var ex = await Should.ThrowAsync(() => - GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())); - ex.Message.ShouldContain(path); - ex.Message.ShouldContain("doesn't exist"); + ex.Message.ShouldContain("not set"); } // ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed ===== @@ -161,7 +134,7 @@ public sealed class GalaxyDriverApiKeyResolverTests [Fact] public async Task Env_prefix_does_not_emit_literal_warning() { - const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN"; + const string name = "OTOPCUA_GALAXY_TEST_API_KEY_NOWARN"; Environment.SetEnvironmentVariable(name, "v"); try { @@ -211,21 +184,77 @@ public sealed class GalaxyDriverApiKeyResolverTests : null); } - /// Verifies that file: prefix with empty file throws. - [Fact] - public async Task File_prefix_empty_file_throws() + // ===== lmxopcua#815: references are confined to the Galaxy namespace, checked before any read ===== + + /// + /// An out-of-namespace reference — another subsystem's secret or process variable — throws, and neither the + /// store nor the environment is consulted (the value is never read, so it can never be sent). + /// + [Theory] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("env:ServerHistorian__ApiKey")] + [InlineData("env:OTOPCUA_GALAXY_lower")] + [InlineData("secret:otopcua/historian/api-key")] + [InlineData("secret:OT-MESH-PSK-MAIN")] + [InlineData("secret:galaxy/../otopcua/historian/api-key")] + [InlineData("${secret:otopcua/apikeys/abc/galaxy-ack-password}")] + [InlineData("SECRET:galaxy/inst1/apikey")] + [InlineData("file:/tmp/any-file")] + [InlineData("file:appsettings.json")] + public async Task Out_of_namespace_reference_is_refused_without_reading_store_or_environment(string reference) { - var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt"); - File.WriteAllText(path, " \n "); + var store = new CountingResolver(); + var envReads = 0; + var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync( + reference, store, ct: TestContext.Current.CancellationToken, environment: _ => { envReads++; return "LEAKED"; })); + ex.Message.ShouldContain("secret:galaxy/"); + ex.Message.ShouldContain("env:OTOPCUA_GALAXY_"); + ex.Message.ShouldNotContain("LEAKED"); + store.Calls.ShouldBe(0); + envReads.ShouldBe(0); + } + + /// The namespaced environment forms (incl. the legacy GALAXY_MXGW_ variable) resolve. + [Theory] + [InlineData("env:OTOPCUA_GALAXY_API_KEY", "OTOPCUA_GALAXY_API_KEY")] + [InlineData("env:GALAXY_MXGW_API_KEY", "GALAXY_MXGW_API_KEY")] + public async Task Namespaced_environment_reference_resolves(string reference, string variable) + { + var key = await GalaxySecretRef.ResolveApiKeyAsync( + reference, FakeResolver(), ct: TestContext.Current.CancellationToken, + environment: n => n == variable ? "the-key" : null); + key.ShouldBe("the-key"); + } + + /// A file reference is refused even when the file exists — its contents are never returned. + [Fact] + public async Task File_reference_is_refused_even_when_the_file_exists() + { + var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt"); + File.WriteAllText(path, "key-from-file"); try { - var ex = await Should.ThrowAsync(() => + var ex = await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())); - ex.Message.ShouldContain("empty"); + ex.Message.ShouldNotContain("key-from-file"); } finally { File.Delete(path); } } + + /// Counts store lookups. + private sealed class CountingResolver : ISecretResolver + { + /// Gets the number of lookups. + public int Calls { get; private set; } + + /// + public Task GetAsync(SecretName name, CancellationToken ct) + { + Calls++; + return Task.FromResult("LEAKED"); + } + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs index ec932f926..4dfd7db3a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs @@ -300,6 +300,48 @@ public sealed class GalaxyAckCredentialTests .Password.ShouldBe(FallbackPassword); } + /// + /// lmxopcua#815: the fallback password is sent to the operator-authored gateway endpoint, so a reference + /// outside the Galaxy namespace is refused — the store/environment value is never read, never sent. + /// + [Theory] + [InlineData("secret:otopcua/apikeys/0a1b/galaxy-ack-password")] + [InlineData("${secret:otopcua/historian/api-key}")] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("file:appsettings.json")] + public async Task Resolver_refuses_a_fallback_reference_outside_the_galaxy_namespace(string reference) + { + const string Stolen = "stolen-value"; + var secrets = new DictSecretResolver + { + ["otopcua/apikeys/0a1b/galaxy-ack-password"] = Stolen, + ["otopcua/historian/api-key"] = Stolen, + }; + var envReads = 0; + var logger = new CapturingLogger(); + var resolver = new GalaxyAckCredentialResolver( + "drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, logger, + _ => { envReads++; return Stolen; }); + + var result = await resolver.ResolveAsync(null, CancellationToken.None); + + result.Credential.ShouldBeNull(); + result.RefusalReason.ShouldNotBeNull().ShouldNotContain(Stolen); + logger.AllText.ShouldNotContain(Stolen); + envReads.ShouldBe(0); + } + + [Fact] + public async Task Resolver_resolves_a_namespaced_environment_fallback() + { + var resolver = new GalaxyAckCredentialResolver( + "drv", new GalaxyAlarmAckOptions("ack-svc", null, "env:OTOPCUA_GALAXY_ACK_PASSWORD"), new DictSecretResolver(), + new CapturingLogger(), n => n == "OTOPCUA_GALAXY_ACK_PASSWORD" ? FallbackPassword : null); + + (await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull() + .Password.ShouldBe(FallbackPassword); + } + [Fact] public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowserCertificateAuthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowserCertificateAuthTests.cs index a20c12d65..aedd293c2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowserCertificateAuthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowserCertificateAuthTests.cs @@ -56,11 +56,11 @@ public sealed class OpcUaClientBrowserCertificateAuthTests { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = path, - UserCertificatePassword = "secret:opcua/browse-cert", + UserCertificatePassword = "secret:opcuaclient/browse-cert", }; var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync( - opts, new OneSecret("opcua/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken); + opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken); identity.TokenType.ShouldBe(UserTokenType.Certificate); } @@ -78,7 +78,7 @@ public sealed class OpcUaClientBrowserCertificateAuthTests { AuthType = OpcUaAuthType.Username, Username = "op", - Password = "secret:opcua/pw", + Password = "secret:opcuaclient/pw", }; var ex = await Should.ThrowAsync(() => diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs index 642ede3d3..d319072e3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs @@ -1,5 +1,6 @@ using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; using ZB.MOM.WW.Secrets.Abstractions; @@ -16,9 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// public sealed class OpcUaClientSecretResolutionTests { - private const string KnownPwName = "opcua/inst/password"; + private const string KnownPwName = "opcuaclient/inst/password"; private const string KnownPwValue = "resolved-password-plaintext"; - private const string KnownCertPwName = "opcua/inst/cert-pw"; + private const string KnownCertPwName = "opcuaclient/inst/cert-pw"; private const string KnownCertPwValue = "resolved-cert-pw-plaintext"; /// A fake resolver: returns known values for two known names, null otherwise. @@ -70,14 +71,68 @@ public sealed class OpcUaClientSecretResolutionTests [Fact] public async Task Unknown_secret_ref_is_fail_closed() { - var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" }; + var options = new OpcUaClientDriverOptions { Password = "secret:opcuaclient/inst/absent" }; var ex = await Should.ThrowAsync(() => OpcUaClientSecretResolution.ResolveSecretRefsAsync( options, FakeResolver(), CancellationToken.None)); ex.Message.ShouldContain("Password"); - ex.Message.ShouldContain("opcua/inst/absent"); + ex.Message.ShouldContain("opcuaclient/inst/absent"); + } + + /// + /// lmxopcua#815: the password is sent to the operator-authored endpoint, so a reference outside the + /// OpcUaClient namespace throws BEFORE the store or the environment is read — it is never resolved, never sent. + /// + [Theory] + [InlineData("secret:otopcua/historian/api-key")] + [InlineData("secret:galaxy/inst1/apikey")] + [InlineData("secret:OT-MESH-PSK-MAIN")] + [InlineData("env:ConnectionStrings__ConfigDb")] + [InlineData("env:ServerHistorian__ApiKey")] + [InlineData("Secret:opcuaclient/inst/password")] + [InlineData("file:appsettings.json")] + public async Task Out_of_namespace_reference_is_refused_without_reading(string reference) + { + var store = new CountingResolver(); + var envReads = 0; + foreach (var options in new[] + { + new OpcUaClientDriverOptions { Password = reference }, + new OpcUaClientDriverOptions { UserCertificatePassword = reference }, + }) + { + var ex = await Should.ThrowAsync(() => + OpcUaUserIdentityFactory.ResolveSecretRefsAsync( + options, store, CancellationToken.None, _ => { envReads++; return "LEAKED"; })); + ex.Message.ShouldContain("secret:opcuaclient/"); + ex.Message.ShouldNotContain("LEAKED"); + } + + store.Calls.ShouldBe(0); + envReads.ShouldBe(0); + } + + /// A namespaced environment reference resolves. + [Fact] + public async Task Namespaced_environment_reference_resolves() + { + var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync( + new OpcUaClientDriverOptions { Password = "env:OTOPCUA_OPCUACLIENT_PLANT_PW" }, FakeResolver(), + CancellationToken.None, n => n == "OTOPCUA_OPCUACLIENT_PLANT_PW" ? "pw" : null); + resolved.Password.ShouldBe("pw"); + } + + private sealed class CountingResolver : ISecretResolver + { + public int Calls { get; private set; } + + public Task GetAsync(SecretName name, CancellationToken ct) + { + Calls++; + return Task.FromResult("LEAKED"); + } } /// A fake resolver: returns known values for two known names, null otherwise. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionCredentialScopeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionCredentialScopeTests.cs new file mode 100644 index 000000000..c22c5ce14 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionCredentialScopeTests.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// +/// lmxopcua#815: the AdminUI live browse (Galaxy / OpcUaClient bespoke browsers, and the Discover-backed universal +/// browser) resolves the config's credential references and sends them to the author-chosen endpoint — an +/// out-of-namespace reference is refused before any browser is opened. +/// +[Trait("Category", "Unit")] +public sealed class BrowserSessionCredentialScopeTests +{ + [Theory] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")] + [InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"}}""")] + public async Task Out_of_namespace_reference_never_opens_a_browser(string driverType, string config) + { + var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy); + var bespoke2 = new RecordingBrowser(DriverTypeNames.OpcUaClient); + var universal = new RecordingUniversal(); + var svc = new BrowserSessionService( + [bespoke, bespoke2], new BrowseSessionRegistry(), NullLogger.Instance, universal, null!, null!); + + var result = await svc.OpenAsync(driverType, config, TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#815"); + bespoke.Opens.ShouldBe(0); + bespoke2.Opens.ShouldBe(0); + universal.Opens.ShouldBe(0); + } + + /// Positive control: a namespaced reference reaches the browser. + [Fact] + public async Task A_namespaced_reference_reaches_the_browser() + { + var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy); + var svc = new BrowserSessionService( + [bespoke], new BrowseSessionRegistry(), NullLogger.Instance, new RecordingUniversal(), null!, null!); + + await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", + TestContext.Current.CancellationToken); + + bespoke.Opens.ShouldBe(1); + } + + private sealed class RecordingBrowser(string driverType) : IDriverBrowser + { + public int Opens { get; private set; } + + public string DriverType => driverType; + + public Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + Opens++; + throw new InvalidOperationException("opened"); + } + } + + private sealed class RecordingUniversal : IUniversalDriverBrowser + { + public int Opens { get; private set; } + + public bool CanBrowse(string driverType, string configJson) => true; + + public Task OpenAsync(string driverType, string configJson, CancellationToken ct) + { + Opens++; + throw new InvalidOperationException("opened"); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CredentialReferenceSavePathTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CredentialReferenceSavePathTests.cs new file mode 100644 index 000000000..4b79985c4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CredentialReferenceSavePathTests.cs @@ -0,0 +1,77 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// lmxopcua#815 at the SAVE path (RawTreeService): a Galaxy / OpcUaClient credential reference outside the +/// driver's namespace never reaches the config DB, through driver and device create + update. +/// +[Trait("Category", "Unit")] +public sealed class CredentialReferenceSavePathTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:appsettings.json"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")] + public async Task CreateDriver_refuses_an_out_of_namespace_reference_and_persists_nothing(string type, string config) + { + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "d815", type, config, Ct); + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("lmxopcua#815"); + using var db = RawTreeTestDb.CreateNamed(name); + db.DriverInstances.ShouldNotContain(d => d.Name == "d815"); + } + + [Fact] + public async Task Update_paths_refuse_and_keep_the_stored_config() + { + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gx", DriverTypeNames.Galaxy, + """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/gx/apikey"}}""", Ct); + created.Ok.ShouldBeTrue(created.Error); + + byte[] rv; + string deviceId; + byte[] deviceRv; + using (var db = RawTreeTestDb.CreateNamed(name)) + { + rv = db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).RowVersion; + var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId); + deviceId = device.DeviceId; + deviceRv = device.RowVersion; + } + + (await svc.UpdateDriverAsync(created.CreatedId!, "gx", + """{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", null, rv, Ct)).Ok.ShouldBeFalse(); + (await svc.UpdateDeviceAsync(deviceId, "dev", + """{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", true, deviceRv, Ct)).Ok.ShouldBeFalse(); + (await svc.CreateDeviceAsync(created.CreatedId!, "dev2", + """{"Gateway":{"ApiKeySecretRef":"secret:otopcua/historian/api-key"}}""", Ct)).Ok.ShouldBeFalse(); + + using var check = RawTreeTestDb.CreateNamed(name); + check.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).DriverConfig.ShouldContain("secret:galaxy/gx/apikey"); + check.Devices.Where(d => d.DriverInstanceId == created.CreatedId) + .ShouldAllBe(d => !d.DeviceConfig.Contains("ConnectionStrings") && !d.DeviceConfig.Contains("historian")); + } + + [Theory] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"Password":"literal-password","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")] + public async Task Namespaced_references_save(string type, string config) + { + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ok815", type, config, Ct); + result.Ok.ShouldBeTrue(result.Error); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs new file mode 100644 index 000000000..0303f9991 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs @@ -0,0 +1,66 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; + +/// +/// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any +/// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value +/// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read". +/// +public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase +{ + [Theory] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")] + [InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")] + public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config) + { + var probe = new RecordingProbe(driverType); + var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); + + actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid())); + + var reply = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); + reply.Ok.ShouldBeFalse(); + reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused"); + reply.Message.ShouldContain("lmxopcua#815"); + probe.Calls.ShouldBe(0); + } + + /// Positive control: a namespaced reference does reach the probe (the refusal is not blanket). + [Fact] + public void A_namespaced_reference_reaches_the_probe() + { + var probe = new RecordingProbe(DriverTypeNames.Galaxy); + var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe })); + + actor.Tell(new TestDriverConnect( + DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid())); + + ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue(); + probe.Calls.ShouldBe(1); + } + + private sealed class RecordingProbe(string driverType) : IDriverProbe + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public string DriverType => driverType; + + public Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + Interlocked.Increment(ref _calls); + return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero)); + } + } +} From 1a4d46a70284b753f1c6b33dead0e6f3a949b74c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:29:01 -0500 Subject: [PATCH 03/16] docs(historian)+infra: shared QuestDB fixture, correct SQL test credential, DurationGood/Bad + #821 docs (#822, #820, #821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #822: HISTORIAN_SQL_TEST_CONNECTION example now targets the shared host 10.100.0.35,14330 with its committed dev sa password (OtOpcUaDev_2026!); the docker-dev rig's own localhost,14330 password (OtOpcUa!Dev123) is called out as a different instance. New labelled (project: lmxopcua) QuestDB fixture stack tests/.../Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml (questdb/questdb:10.0.1, PG wire :8812, console :19000), deployed to /opt/otopcua-questdb on the Docker host; documented in infra/README.md §2a, docs/Historian.md, CLAUDE.md endpoints and the historian rules. Also corrects infra/README's stale "only MQTT/NAT carry the label" note. Docs for #820 (nineteen served aggregates, DurationGood/DurationBad in ms, Gateway still refuses) and the #821 facts (Part 13 vs frozen live vs stock SDK 2.0.1, both directions; decision open with #809). Co-Authored-By: Claude --- .claude/rules/historian.md | 6 +- CLAUDE.md | 3 + docs/Historian.md | 63 +++++++++++++------ infra/README.md | 28 ++++++++- .../Docker/docker-compose.yml | 41 ++++++++++++ 5 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml diff --git a/.claude/rules/historian.md b/.claude/rules/historian.md index 5a2f94211..2d37cf743 100644 --- a/.claude/rules/historian.md +++ b/.claude/rules/historian.md @@ -32,11 +32,11 @@ historian tagname, which is the tag's driver `FullName`. ### Pluggable backends, SQL Server and HistoryUpdate (2026-10) - **Selection:** `ServerHistorian:Backend` (`Gateway` default | `SqlServer` | `QuestDb`; unknown fails host start). `HistorianBackendRegistry` → `IHistorianBackendProvider` builds all five seams; the gateway keys are only required for `Gateway`. `SqlServer` uses `ServerHistorian:SqlServer:{ConnectionString (required), Schema=hist, CommandTimeout=30s, AutoCreateSchema=true}`. -- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. +- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680, and DurationGood/DurationBad, #820 — ms, Uncertain time in neither; the live AggregateFilter still counts Uncertain as Good, #809) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION` (shared host `10.100.0.35,14330`, `sa`/`OtOpcUaDev_2026!` — not docker-dev's `OtOpcUa!Dev123`; QuestDB: `HISTORIAN_QUESTDB_TEST_CONNECTION` against the shared `/opt/otopcua-questdb` fixture `10.100.0.35:8812`, #822, `infra/README.md` §2a). **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. - **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend". - ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates. - ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density at ≈ N + 8 rows and carried page to page in the continuation point — #689: it used to aim at half `MaxTieClusterOverfetch`, ~32k rows per page on a dense tag; a read asks for `2N + 18` rows (a discovery segment ≤ 1,026), `MaxTieClusterOverfetch` is only the tie-cluster ceiling — a full read is re-read, never answered truncated; only more than max(1, `MaxTieClusterOverfetch`) samples within ONE MILLISECOND ⇒ `BadTooManyOperations` (per ms, not per timestamp — requests are ms-rounded, and letting a segment narrow below 1 ms stepped a page through one ms a tick per read, ~10k reads); walks no later than now + 1 day and never before the Unix epoch; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `docs/Historian.md` §"Open-ended and reverse raw reads". -- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 17 via `AggregateFamilies.Served`, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates. +- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 19 via `AggregateFamilies.Served` — DurationGood/DurationBad added by #820, the PercentGood/PercentBad region times in ms, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates. - **Raw `ReturnBounds` is honoured on every backend (#691, client-visible):** Part 11 bounding values (§4.6 Table 1 is the test oracle) are composed in the server by `HistoryBounds` from each backend's raw reads — "at or before" = capped reverse read (native on SQL backends, the #686 `ReverseRawReader` walk-back on Gateway, never AVEVA's synthetic boundary row), "at or after" = capped forward read. Bounds are stored samples of any quality, count toward `NumValuesPerNode`, start bound on the first page / end bound on the last (pending end bound rides the continuation point); missing ⇒ `Bad_BoundNotFound` (null, at the bound time; a one-sided read's open side ±1 s from the previous value). ⚠️ **Breaking for SDK-default clients:** the .NET SDK's `new ReadRawModifiedDetails()` defaults BOTH `ReturnBounds = true` AND `IsReadModified = true` — a client that cleared only `IsReadModified` now gets bounds, and one that set neither is a modified read with bounds ⇒ `Bad_InvalidArgument` (§6.5.3.3); set both explicitly (Client.Shared does; tests building details must too). No data AND no bound found ⇒ `GoodNoData`, no values (the pre-bounds answer). On Gateway the at-or-after lookup ends at now + 1 day (never a year-9999 end to AVEVA). Kill switch `ServerHistorian:HonorReturnBounds` (default `true`; `false` = pre-#691). `docs/Historian.md` §Bounding values. - ⚠️ **Raw pages are full (#692, client-visible, every backend):** each raw page asks the backend for `N + skip + 1` rows (the boundary ties it will trim + one lookahead row) — a resumed page used to ask only N, trim the ties and come back short; at N=1 that was an EMPTY page + continuation point that then fell into the `MaxTieClusterOverfetch + 1` over-fetch (live: 8 values = 17 pages / 25 reads / 524k rows ⇒ now 8/8/≤24). No lookahead row ⇒ last page, no CP (an exact multiple no longer ends on an empty page). Exception: an oversized tie cluster — its last slice is `min(N, remaining)` with a CP, and stepping past one at the end of data can give one empty final page. The over-fetch fires only when every row past the trimmed ties sits on the boundary timestamp; a cursor inside such a cluster is marked `WithinTieCluster` (next page goes straight to the over-fetch). `SliceTieCluster` resumes only strictly before EndTime (truncated to the backend resolution) — it used to resume AT the exclusive EndTime and return samples there. Bound placeholders carry an explicit marker (`HistoryBounds.IsPlaceholder` no longer matches a stored `Bad_BoundNotFound`/null row); the N=1 bounds probe carries its walk hint + resolved end bound into the cursor. Reviewer fuzz harnesses (paged vs unpaged over random tie series) are the right test for any paging change — repo tests passed while master mismatched 1-in-160. - ⚠️ **Strict-ceiling paging (#694):** `IHistorianDataSource.MaxRawValuesPerRead` = L = learned gateway ceiling − 16 (0 on SQL backends/Null, guard-tested; 0 until a refusal names the ceiling). `RawPageLimit.Plan(want, k, L)` sizes every raw page so page + boundary ties + lookahead ≤ L (room = L − k − 1, signed; first page L−1, resumed L−k−1; uncapped `N = 0` reads page too) — a capped read above the ceiling used to be `BadTooManyOperations`, now a ceiling-sized page + CP. One re-plan when the read taught a new limit or was refused for crowding (`HistoryReadLimitExceededException.PreWindowRows`, set only above the 16-row allowance; the page shrinks by it). No room on a resumed page ⇒ the tie-cluster over-fetch, bounded min(M, L−1); the slice size is never 0. The page path passes `retryOnLearnedLimit: false` to `ReverseRawReader` (the walk re-running at the old N cost 200–3,000 reads cold); bounds lookups keep it. ⚠️ A Gateway window starting at/before the Unix epoch counts AVEVA's synthetic row as pre-window — it used to set `reachedEnd`, so a ceiling-truncated read came back Good with no CP (forward-from-1970 = first page only; end-only past the data = the OLDEST rows as "latest"; pre-existing). The #687 crowding re-read now doubles its room up to 8 re-reads, never past a known ceiling, and its room is capped at `MaxTieClusterOverfetch` (passed to the Gateway source by the factory; past it ⇒ `BadTooManyOperations` with `PreWindowRows`). A raised ceiling is not re-probed by raw paging (pages stay small until another read re-probes or restart). @@ -50,7 +50,7 @@ historian tagname, which is the tag's driver `FullName`. The server dispatches all OPC UA HistoryRead to the registered `IHistorianDataSource` — the `GatewayHistorianDataSource` read client when enabled, else the `NullHistorianDataSource` default (historized nodes return `GoodNoData` for Raw/AtTime — Processed is `BadAggregateNotSupported` since #682). Supported variants: Raw (⚠️ #687, client-visible: Gateway raw reads no longer return AVEVA's synthetic start-boundary row — asked from 1 ms early (epoch-floored) with `NumValuesPerNode + 16` rows (unclamped; 0 ⇒ `int.MaxValue`) and filtered by timestamp — so an empty window is `GoodNoData`, not a fabricated value at StartTime; the forward end is now exclusive (a point read inclusive); pre-window rows that crowd the budget trigger ONE re-read with room for them, else `BadTooManyOperations`; `ReturnBounds` is ignored on every backend; ⚠️ #690: a Gateway Bad row — a stored NULL, which histsdk sends as value 0 — now reads value **null** + Bad on Raw and AtTime, as on the SQL backends; ⚠️ #691.3: a strict gateway (`AllowStreamingBeyondCeiling=false`) that refuses a raw budget is re-asked ONCE at the ceiling its refusal names — raw and per-sample reads alike; the ceiling is then remembered per data source (`GatewayRowCeiling`: refusals count as gateway errors), re-learned from a refusal when lowered, and re-probed once at the full budget when a ceiling-bounded read comes back full (raised / streaming) — so an uncapped (`NumValuesPerNode = 0`) read of a small window is served; a ceiling-bounded stream that is not provably complete is `BadTooManyOperations`, never a truncated answer), Processed -(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27; ⚠️ #691.2: native reads use the SQL backends' grid in both directions via forward requests only — a REVERSE read (refused by the gateway before) is anchored at its later time (#677), and the clipped interval (earliest reverse, LATEST forward — which used to be averaged past EndTime) is its own exact-span read marked Partial unless Bad; an interval wider than the window is sent as the window, interval 0 as one interval; more than 100,000 intervals is refused up front (`BadHistoryOperationUnsupported`, as on the SQL backends — forward used to be served up to the gateway's 1,000,000); until historiangw#27 lands both directions return Good-status wrong values); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes (⚠️ #693, client-visible: Gateway AtTime is evaluated from RAW reads by the shared `Sql.Common` `AtTimeEvaluator` — exact stored value at a stored time, null + Bad at/after a stored NULL, Uncertain hold before a Bad neighbour, linear + Interpolated bit, `BadNoData` before the first sample — never AVEVA's own interpolation (which answered 107 Good at a NULL, null+Bad at a Good sample after it, 99.99999999944 for 100, no Interpolated bit); `GatewayAtTimeReader`: one ≤1,024-row window read for the batch (jumps to far-apart times use 64-row reads) + a walk-back for the first time's prev (gallop ×4, whole-remainder read after two empties, then real BISECTION seeded by any full read's last row — a dense tag stopped a year ago ≈ 25–31 reads; the first cut regrew segments after a full read and gave up at 65) + an 18-row probe for the last time's next — 10 dense times = 1 read (exact) / 3 (interpolated), 10 hourly = 12; read cap 64 + 16 per distinct time (a runaway guard: a time in a gap of a bursty tag needs ≈ 6 reads; 4/time refused a 1 h/day tag asked daily ×30) and a strict ceiling < 4 rows ⇒ `BadTooManyOperations` with a message naming the limit; DateTime hints stepped, non-finite/out-of-range values under an integer hint unusable; all request bounds whole ms, rows counted only strictly inside a request; the node manager passes the node's scalar type (`ReadAtTimeAsync(…, TypeCode? valueType, …)` default-interface overload, ignored by SqlServer/QuestDB/Null) and the Gateway converts raw rows to it before evaluating — Boolean stepped, integers interpolated + rounded half-away and returned as the node's integer type, floats Double; no hint ⇒ Double; ⚠️ the shared evaluator used to box interpolated Int64 as Double (`cond ? (long)x : double` widens) — fixed for SQL backends too, and Shouldly `ShouldBe` hides numeric-type bugs (assert `ShouldBeOfType`); kill switch `ServerHistorian:GatewayAtTimeFromRaw` (default `true`); `docs/Historian.md` §Gateway AtTime); Events over +(backend-dependent: SqlServer/QuestDb serve nineteen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27; ⚠️ #691.2: native reads use the SQL backends' grid in both directions via forward requests only — a REVERSE read (refused by the gateway before) is anchored at its later time (#677), and the clipped interval (earliest reverse, LATEST forward — which used to be averaged past EndTime) is its own exact-span read marked Partial unless Bad; an interval wider than the window is sent as the window, interval 0 as one interval; more than 100,000 intervals is refused up front (`BadHistoryOperationUnsupported`, as on the SQL backends — forward used to be served up to the gateway's 1,000,000); until historiangw#27 lands both directions return Good-status wrong values); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes (⚠️ #693, client-visible: Gateway AtTime is evaluated from RAW reads by the shared `Sql.Common` `AtTimeEvaluator` — exact stored value at a stored time, null + Bad at/after a stored NULL, Uncertain hold before a Bad neighbour, linear + Interpolated bit, `BadNoData` before the first sample — never AVEVA's own interpolation (which answered 107 Good at a NULL, null+Bad at a Good sample after it, 99.99999999944 for 100, no Interpolated bit); `GatewayAtTimeReader`: one ≤1,024-row window read for the batch (jumps to far-apart times use 64-row reads) + a walk-back for the first time's prev (gallop ×4, whole-remainder read after two empties, then real BISECTION seeded by any full read's last row — a dense tag stopped a year ago ≈ 25–31 reads; the first cut regrew segments after a full read and gave up at 65) + an 18-row probe for the last time's next — 10 dense times = 1 read (exact) / 3 (interpolated), 10 hourly = 12; read cap 64 + 16 per distinct time (a runaway guard: a time in a gap of a bursty tag needs ≈ 6 reads; 4/time refused a 1 h/day tag asked daily ×30) and a strict ceiling < 4 rows ⇒ `BadTooManyOperations` with a message naming the limit; DateTime hints stepped, non-finite/out-of-range values under an integer hint unusable; all request bounds whole ms, rows counted only strictly inside a request; the node manager passes the node's scalar type (`ReadAtTimeAsync(…, TypeCode? valueType, …)` default-interface overload, ignored by SqlServer/QuestDB/Null) and the Gateway converts raw rows to it before evaluating — Boolean stepped, integers interpolated + rounded half-away and returned as the node's integer type, floats Double; no hint ⇒ Double; ⚠️ the shared evaluator used to box interpolated Int64 as Double (`cond ? (long)x : double` widens) — fixed for SQL backends too, and Shouldly `ShouldBe` hides numeric-type bugs (assert `ShouldBeOfType`); kill switch `ServerHistorian:GatewayAtTimeFromRaw` (default `true`); `docs/Historian.md` §Gateway AtTime); Events over alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node); authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization. diff --git a/CLAUDE.md b/CLAUDE.md index c70af7e8e..8d6e834a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -317,6 +317,9 @@ lmxopcua-fix sync modbus # rsync this repo's tests/.../Docker/ - AB CIP: `10.100.0.35:44818` (`AB_SERVER_ENDPOINT`) - S7: `10.100.0.35:1102` (`S7_SIM_ENDPOINT`) - OPC UA reference (opc-plc): `opc.tcp://10.100.0.35:50000` (`OPCUA_SIM_ENDPOINT`) +- QuestDB historian fixture (#822, stack `/opt/otopcua-questdb`): PG wire `10.100.0.35:8812` — + `HISTORIAN_QUESTDB_TEST_CONNECTION='Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading'`; + the SQL Server historian suite uses the central SQL above (`HISTORIAN_SQL_TEST_CONNECTION`, `infra/README.md` §2a). - MQTT (Mosquitto, **auth on both listeners — no anonymous fallback**): `10.100.0.35:8883` TLS+auth (`MQTT_FIXTURE_ENDPOINT`) · `10.100.0.35:1883` plaintext-but-authenticated (`MQTT_FIXTURE_PLAIN_ENDPOINT`). Also needs `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD`, and `MQTT_FIXTURE_CA_CERT` to pin the diff --git a/docs/Historian.md b/docs/Historian.md index 1d4a21705..6d7fa9963 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -108,7 +108,7 @@ consumes the historian. The existing enable gates (`ServerHistorian:Enabled`, `A | Value kinds recorded | `Double`, `Int64`, `Boolean` (numeric analog only) | all: `Double`, `Int64`, `Boolean`, `String`, `DateTime`, plus `Empty` (a Bad sample with no value) | same as `SqlServer` | | Status code stored | Sent as DA quality but **discarded** by the gateway (C-002); AVEVA stores every sample Good | Stored bit-for-bit (`StatusCode` int column) | Stored bit-for-bit (unsigned, in a `LONG`) | | Comms-loss / driver-Bad publishes | Dropped, counted in `DroppedUnsupportedKind` | Stored as an `Empty` outage-marker sample carrying the Bad code (see [below](#typed-values-real-status-and-outage-markers)) | Stored as an `Empty` outage-marker sample, as on `SqlServer` | -| Processed aggregates | **Ten** by default: the per-sample ones (Average, Count, Minimum, Maximum, Range, Start, End, Delta, Min/MaxActualTime) evaluated from raw reads with the SQL backends' evaluator — identical results (#681). TimeAverage and Total from AVEVA's native modes **only with `ServerHistorian:Gateway:EnableNativeTimeWeighted=true`** (default off: Good-status wrong values until historiangw#27). TimeAverage2 / Total2 / Interpolative / PercentGood / PercentBad: never | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results | +| Processed aggregates | **Ten** by default: the per-sample ones (Average, Count, Minimum, Maximum, Range, Start, End, Delta, Min/MaxActualTime) evaluated from raw reads with the SQL backends' evaluator — identical results (#681). TimeAverage and Total from AVEVA's native modes **only with `ServerHistorian:Gateway:EnableNativeTimeWeighted=true`** (default off: Good-status wrong values until historiangw#27). TimeAverage2 / Total2 / Interpolative / PercentGood / PercentBad / DurationGood / DurationBad: never | The nineteen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671, and DurationGood/DurationBad, #820) | The same nineteen, identical results | | Reverse / end-only Raw and Events reads | Emulated: walked back from bounded forward reads by the server (`ReverseRawReader` / `ReverseEventsReader`) — correct, but many round trips on a sparse tag | Native newest-first | Native newest-first | | Capped Events read | The cap is applied client-side (the gateway RPC has no max-events field); the stream past it is drained up to `Gateway:EventDrainBudget` rows | `TOP` | `LIMIT` | | Retention | AVEVA Historian's own | `SqlServer:RetentionDays` purge (opt-in) | `QuestDb:RetentionDays` TTL (opt-in) | @@ -192,7 +192,7 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions | `EventReadHorizon` | TimeSpan | `00:00:00` | (#694, all backends, **opt-in, lossy**.) `00:00:00` = no horizon: every HistoryReadEvents read is exact to its own window. A positive span raises the earlier bound of every fresh events window to `now − EventReadHorizon` (the floor instant itself included), in **both** directions — a window wholly older is `GoodNoData` and is not sent; a page-through keeps the first page's floor. It exists for the Gateway, where a walked reverse / end-only read proves "nothing older" down to the Unix epoch (~3.8 s live; an unfiltered one can hit the gateway's 30 s SQL timeout) and a forward read from a far-past `StartTime` scans the same range. ⚠️ While set, events older than the horizon are **invisible** to HistoryReadEvents. Raw/Processed/AtTime are unaffected. Negative ⇒ treated as none (a `Validate()` warning). See [Continuation-point paging (Events)](#continuation-point-paging-events-669). | | `HistoryReadBatchParallelism` | int | `4` | Max historized nodes served **concurrently** within one HistoryRead batch (arch-review 03/S3). The SDK's `HistoryRead*` override surface is synchronous, so each arm bridges once at its boundary; this turns per-node serving from sequential (`N × CallTimeout` worst case) into `⌈N / P⌉ × CallTimeout`. Also caps the gateway-load multiplication a single client can command. `≤ 1` serves sequentially. | | `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. | -| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). | +| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**, since #820 **DurationGood/DurationBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). | | `HistoryReadDeadline` | TimeSpan | `00:01:00` | Per-request wall-clock deadline for a HistoryRead (and HistoryUpdate) batch (arch-review 03/S3). A node still in flight at the deadline surfaces `BadTimeout`, so one request can never hold an SDK request thread longer than this regardless of node count. Non-positive = unbounded (a `Validate()` warning). | | `HistoryUpdateMaxFutureSkew` | TimeSpan | `1.00:00:00` | #674. How far past the server clock a HistoryUpdate (Insert / Replace / Update) item's `SourceTimestamp` may lie; an item beyond `now + skew` is refused **`BadOutOfRange`** by the node manager before any backend sees it (every backend). Guards against a far-future typo (a 2062 timestamp). The live recorder is not bounded. Non-positive disables the bound (no warning — a deliberate choice). See [HistoryUpdate](#historyupdate). | @@ -608,7 +608,7 @@ The server supports all four OPC UA HistoryRead variants: | Variant | Node type | CLI `--aggregate` | |---|---|---| | **Raw** | Historized variable | (omit `--aggregate`) | -| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`, `StandardDeviation` (the CLI's own list; `SqlServer` / `QuestDb` serve seventeen aggregates — see the [aggregate table](#aggregates-processed-reads)) | +| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`, `StandardDeviation` (the CLI's own list; `SqlServer` / `QuestDb` serve nineteen aggregates — see the [aggregate table](#aggregates-processed-reads)) | | **AtTime** | Historized variable | n/a (client supplies exact timestamps) | | **Events** | Equipment folder (event notifier) | `--events` (+ `--fields`, `--of-type`; #776) | | **Modified** (Raw with `IsReadModified = true`, #668) | Historized variable | `--modified` (see [Modified-value history](#modified-value-history-part-11)) | @@ -1312,7 +1312,7 @@ field (`tail + 43`) its own reverse-engineering notes still mark unverified (His > `Integral`) — ⚠️ **and are refused `BadAggregateNotSupported` unless `ServerHistorian:Gateway:EnableNativeTimeWeighted` is > `true`** (default `false`, until historiangw#27 is live-verified; the default set is the ten raw-evaluated aggregates, > `GatewayHistorianDataSource.RawEvaluatedAggregates`) — now stamped with the interval start (was its end) and carrying the Calculated bit; `TimeAverage2`, `Total2`, -> `Interpolative`, `PercentGood` and `PercentBad` remain `BadAggregateNotSupported` on Gateway. The most the backend can serve is +> `Interpolative`, `PercentGood`, `PercentBad`, `DurationGood` and `DurationBad` remain `BadAggregateNotSupported` on Gateway. The most the backend can serve is > `GatewayHistorianDataSource.ServedAggregates` (12); an instance serves and advertises `SupportedAggregates` — those 12 with the > flag on, the 10 raw-evaluated ones without it. @@ -1378,8 +1378,8 @@ and one ending at `now + 1 day` are accepted and cheap; (4) the gateway's ceilin Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `AggregateFunctionMap` (`OpcUaServer`) maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and -**`QuestDb`** backends serve the seventeen listed in the [aggregate table](#aggregates-processed-reads); everything else -(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend serves twelve (#681 — +**`QuestDb`** backends serve the nineteen listed in the [aggregate table](#aggregates-processed-reads); everything else +(e.g. `StartBound`, `WorstQuality`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend serves twelve (#681 — `GatewayHistorianDataSource.ServedAggregates`): the ten per-sample aggregates from raw reads, plus TimeAverage and Total through AVEVA retrieval modes (`AggregateModeMapper`); for any other member it throws `HistoryAggregateNotSupportedException` without calling the gateway (and since #682 the server refuses it up front), which the HistoryRead path turns into @@ -1407,7 +1407,7 @@ aggregate-function objects. A client choosing a history aggregate should read ** | `ServerHistorian:Backend` | i=11201 (history) | i=2997 (server) | Declared by | |---|---|---|---| | `Gateway` | 12 — the ten per-sample aggregates (raw-evaluated, #681) + TimeAverage, Total (native AVEVA modes) | all 37 | `GatewayHistorianDataSource.ServedAggregates` (the per-sample families of `AggregateFamilies` ∪ `AggregateModeMapper.NativeAggregates`, derived from its mapping switch) | -| `SqlServer`, `QuestDb` | 17 — the [aggregate table](#aggregates-processed-reads) | all 37 | `AggregateFamilies.Served` (Sql.Common; every member with a family) | +| `SqlServer`, `QuestDb` | 19 — the [aggregate table](#aggregates-processed-reads) (DurationGood/DurationBad added by #820) | all 37 | `AggregateFamilies.Served` (Sql.Common; every member with a family) | | historian disabled (Null source) | **none** | all 37 | `NullHistorianDataSource` | ⚠️ **Client-visible: with no historian configured a processed HistoryRead is now `BadAggregateNotSupported`, not @@ -1573,9 +1573,10 @@ point (see [Continuation-point paging (Events)](#continuation-point-paging-event > **Two kinds.** The *per-sample* aggregates (Average, Minimum, Maximum, Count, Range, Start/End/Delta, Min/MaxActualTime) are > simple per-sample definitions — no time-weighting, no interval-boundary values — pushed down to SQL. The *time-weighted* aggregates -> (TimeAverage, TimeAverage2, Total, Total2, Interpolative; #671 — and the duration-based PercentGood/PercentBad since #680) follow +> (TimeAverage, TimeAverage2, Total, Total2, Interpolative; #671 — and the duration-based PercentGood/PercentBad since #680, +> DurationGood/DurationBad since #820) follow > OPC UA Part 13 with bounding values — see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671). Any `HistoryAggregateType` not in the tables throws -> `HistoryAggregateNotSupportedException` ⇒ `BadAggregateNotSupported` for that node (e.g. `StartBound`, `DurationGood`, +> `HistoryAggregateNotSupportedException` ⇒ `BadAggregateNotSupported` for that node (e.g. `StartBound`, `WorstQuality`, > `StandardDeviationPopulation`). > > ⚠️ **Release note / migration (#671): `Total` changed meaning on the `SqlServer` and `QuestDb` backends.** It was the plain `SUM` of @@ -1716,12 +1717,12 @@ Status and edge rules: - An interval with samples but **no Good numeric value** (only Uncertain / Bad) ⇒ `BadNoData` for `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` (#684). - `Average` is `Good` when every sample in the bucket is Good, else `UncertainDataSubNormal` (value-based, the SDK's rule). `Minimum`/`Maximum`/`Range`/`*ActualTime` are `UncertainDataSubNormal` when the bucket holds a Bad sample or an Uncertain value beyond the reported Good extreme (Part 13 Tables 57–62; strictly beyond), else `Good` (#684). `Count`'s status is value-based (all Good ⇒ Good, all Bad ⇒ Bad, else Uncertain); `Start`/`End` carry the raw sample's own status; `Delta` is Good unless the interval's first or last sample is not usable (then Uncertain) — #680. - `Count`, `Start`, `End` accept any value kind. `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` on a bucket that holds a **Good** `String`/`DateTime` sample ⇒ `BadAggregateInvalidInputs` for that interval; an Uncertain one is skipped and makes `Minimum`/`Maximum`/`Range`/`*ActualTime` `UncertainDataSubNormal` (#684). `Delta` is refused by any usable non-numeric sample. -- `PercentGood` / `PercentBad` are **duration-based since #680** — see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671). +- `PercentGood` / `PercentBad` are **duration-based since #680**, and `DurationGood` / `DurationBad` (#820) are the same region times — see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671). - Everything except `Count` and `Start`/`End` is a `double`; `Int64` samples aggregate as doubles and `Boolean` as 0/1. ### Time-weighted aggregates (Part 13, #671) -`TimeAverage`, `TimeAverage2`, `Total`, `Total2`, `Interpolative` and (since #680) `PercentGood` / `PercentBad`, evaluated by the +`TimeAverage`, `TimeAverage2`, `Total`, `Total2`, `Interpolative`, (since #680) `PercentGood` / `PercentBad` and (since #820) `DurationGood` / `DurationBad`, evaluated by the shared `Driver.Historian.Sql.Common/TimeWeightedAggregator.cs` for **both** SQL backends (so `SqlServer` and `QuestDb` agree exactly). **This whole family — `Total` and the percentages included — is evaluated in C#, not pushed down to SQL** (see *Pushdown trade-off* and the raw-row cap below). Bucketing (edges, truncated interval, reverse anchoring, the 100 000-interval cap) is the same as above. @@ -1784,6 +1785,7 @@ samples (sloped / stepped) straight across an outage, and holds the last usable | `Total2` | ∫ simple-bounding signal over non-Bad time (value·s) | excluded | | `Interpolative` | interpolated-bounding value **at the interval start** (reverse read: see below) | interpolated across | | `PercentGood` / `PercentBad` | `100 ×` Good / Bad time ÷ interval duration (#680, Part 13 §5.4.3.31-34) — see below | counted as Bad time | +| `DurationGood` / `DurationBad` | Good / Bad time of the interval in **milliseconds** (an OPC UA `Duration`; #820, Part 13 §5.4.3.31-32) — the same regions, see below | counted as Bad time | **Status, `TimeAverage` / `Total`** (interpolated bounding, #680 — Part 13 "Uncertain if Bad values are skipped or Uncertain values are used", and the SDK's `AverageAggregateCalculator`): @@ -1822,6 +1824,14 @@ to the **end of its own interval** (Part 13 — the SDK gives it 1 ms) and later after it is Bad to the window end. An interval that ends before the first sample ⇒ `BadNoData`. Any value kind is accepted; the status is `Good` (plus Calculated / Partial). Value = `100 × region time ÷ interval duration`. +**`DurationGood` / `DurationBad`** (#820) are the same Good / Bad region times as `PercentGood` / `PercentBad` — every rule above +(regions, simple bounds, time before the first sample = Bad, end of data, `BadNoData`, status, Partial) applies unchanged — reported as +an OPC UA `Duration`, i.e. a `double` in **milliseconds** (the SDK's unit): `DurationGood = PercentGood × interval ms ÷ 100`. Uncertain +time counts toward neither (Mantis 0006166). Before #820 both were `BadAggregateNotSupported` on the SQL backends. ⚠️ A **live** +`AggregateFilter` subscription computes DurationGood/PercentGood with the frozen 1.5.378.182 calculators, which count Uncertain time as +Good — they disagree with HistoryRead whenever Uncertain data is present (#809, owner decision pending). The Gateway backend still +refuses both (`BadAggregateNotSupported`, not advertised in i=11201). + All carry the Calculated bit. A non-Bad result also carries the **Partial** bit (`0x4`, `AggregateBits.Partial`) — never on a Bad result — for: the **truncated interval** (shorter than the processing interval — the latest one forward, the earliest one reverse; Part 13 §5.3.3.2 Case 3 requires it — the SDK does not set it, a deliberate difference); the interval the tag's **data starts inside** @@ -1856,7 +1866,7 @@ value at **10:00**, not 09:00, and no point ever falls before the window. The in forward and reverse, and compare every interval's timestamp, value, status code and Partial bit. The per-sample comparison covers Average, Minimum, Maximum, Count, Range, Start, End, Delta and Minimum/MaximumActualTime; the time-weighted one TimeAverage, TimeAverage2, Total, Total2, Interpolative and -PercentGood/Bad (#680), including datasets whose data ends inside the window and with an interval wholly inside an outage; reverse +PercentGood/Bad (#680) and DurationGood/Bad (#820), including datasets whose data ends inside the window and with an interval wholly inside an outage; reverse reads over data that starts inside the window are compared too. Known differences that remain (tracked as #680) — documented and masked in the tests, deliberately not changed here because forward results were to stay as they are — are: an Uncertain value beyond the Good extreme makes Minimum/Maximum/Range/*ActualTime Uncertain here (Part 13), Good in the SDK @@ -1895,9 +1905,14 @@ And an interval holding a **Good String (or DateTime) sample** is `BadAggregateI string and serves the numeric samples (e.g. Average 4 Good); an Uncertain non-numeric sample refuses nothing on either side. One more, found by the #810 Uncertain conformance data (kept deliberately): over the interval the data **starts inside** when the **first sample is Uncertain**, the SDK's `TimeAverage`/`Total` integrate the uncovered span before it as a ramp from 0 (QP: `Total` -188.96 for 163.96 over the covered 15 s); ours integrates only the covered part (Part 13, as with a Good first sample, where the two -agree). Pinned by `QualityMixConformance.Time_weighted_values_over_partial_data_cover_only_the_data` on both SQL backends. The same -suite feeds PercentGood/PercentBad (and Count, Average, TimeAverage(2), Total(2), Start/End/Delta, Interpolative) Good/Uncertain/Bad +188.96 for 163.96 over the covered 15 s, and `TimeAverage` 9.448 = 188.96 ÷ 20 s for 10.931 = 163.96 ÷ 15 s); ours integrates only +the covered part (Part 13, as with a Good first sample, where all agree). The same answer comes from the frozen calculators the server +registers for **live** `AggregateFilter` items and from SDK 2.0.1's own stock calculators, in both read directions — pinned by +`SdkAggregateParityTests.An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data` +(ours / frozen / stock 2.0.1, with a Good-first control) and `OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests` (through +the booted server's runtime `AggregateManager`), and on both SQL backends by `QualityMixConformance.Time_weighted_values_over_partial_data_cover_only_the_data` +(forward) and `…_in_a_reverse_read` (#821). Whether live subscriptions should follow is the open #809/#821 owner decision. The same +suite feeds PercentGood/PercentBad, DurationGood/DurationBad (and Count, Average, TimeAverage(2), Total(2), Start/End/Delta, Interpolative) Good/Uncertain/Bad mixes, all-Uncertain intervals (0 % Good **and** 0 % Bad, Mantis 0006166) and partial-data intervals, hand-computed and against the #796 oracle — the baseline calculator's Uncertain-as-Good PercentGood fails it. @@ -1991,10 +2006,15 @@ round-trip, redelivery, raw / AtTime / aggregate / event reads, every update res run creates a throwaway `OtOpcUaHistorian_T_` database and drops it on dispose. ```bash -export HISTORIAN_SQL_TEST_CONNECTION='Server=localhost,14330;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;Encrypt=False' +# The shared always-on dev SQL Server on the Docker host (otopcua-mssql; the committed dev sa credential, see +# docs/v2/dev-environment.md — not a secret): +export HISTORIAN_SQL_TEST_CONNECTION='Server=10.100.0.35,14330;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False' dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests ``` +The local docker-dev rig's `sql` service also publishes `localhost,14330`, but with **its own** committed password +(`OtOpcUa!Dev123`, `docker-dev/docker-compose.yml`) — the two are different instances; do not mix the host and the password (#822). + --- ## QuestDB backend @@ -2006,7 +2026,7 @@ the spike that pinned every QuestDB-specific form: [plans/2026-10-10-questdb-spi `questdb/questdb:10.0.1`. QuestDB serves every value kind (`Double`, `Int64`, `Boolean`, `String`, `DateTime`, `Empty`), stores the real status, records comms-loss -outage markers, serves the seventeen aggregates (time-weighted ones through the same shared evaluator, so results are identical) and supports **HistoryUpdate** (soft delete), exactly like `SqlServer`. The same conformance +outage markers, serves the nineteen aggregates (time-weighted ones through the same shared evaluator, so results are identical) and supports **HistoryUpdate** (soft delete), exactly like `SqlServer`. The same conformance suite (`tests/Drivers/Historian.Conformance`) runs against both backends; the differences below are the only ones. ### Configuration (`ServerHistorian:QuestDb`) @@ -2276,11 +2296,18 @@ tombstones and redelivery, the visibility wait, updater, reads, paging, and the unique throwaway prefix (`t__`) and drops its tables afterwards. The QuestDB server allows 64 connections, so the fixture prunes idle pool connections. ```bash +# The always-on shared fixture on the Docker host (#822; stack /opt/otopcua-questdb, infra/README.md §2a): +export HISTORIAN_QUESTDB_TEST_CONNECTION='Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading' +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests + +# …or a local one from the docker-dev overlay (same image, PG wire on localhost:18812): docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.questdb.yml up -d --no-deps questdb export HISTORIAN_QUESTDB_TEST_CONNECTION='Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading' -dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests ``` +`admin` / `quest` is QuestDB's stock default — a throwaway fixture credential, not a secret. The fixture's compose file is +`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml` (label `project=lmxopcua`). + --- ## HistoryUpdate diff --git a/infra/README.md b/infra/README.md index d04ea3993..3aedacc0b 100644 --- a/infra/README.md +++ b/infra/README.md @@ -37,6 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{ | Stack dir | Purpose | |---|---| | `/opt/otopcua-mssql` | central SQL (always-on) | + | `/opt/otopcua-questdb` | QuestDB historian-backend test fixture (always-on, #822) — see §2a | | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-opcuaclient-nat` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` · `/opt/otopcua-focas` | driver fixtures | | `~/otopcua-ablegacy` | driver fixture (user-owned) | | `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 | @@ -55,6 +56,30 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{ Nuclear reset: `ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mssql && docker compose down -v && docker compose up -d'`. +## 2a. QuestDB historian fixture (always-on, #822) + +| | | +|---|---| +| Container | `otopcua-questdb` (`questdb/questdb:10.0.1`, label `project=lmxopcua`), stack `/opt/otopcua-questdb` | +| Source of truth | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml` | +| Endpoint | PG wire `10.100.0.35:8812` · web console `http://10.100.0.35:19000` (host `:9000` is taken) | +| Creds (dev) | QuestDB stock default `admin` / `quest` (throwaway fixture credential) | +| Env var | `HISTORIAN_QUESTDB_TEST_CONNECTION='Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading'` | +| Used by | `Driver.Historian.QuestDb.IntegrationTests` (incl. the shared `Historian.Conformance` suite); absent env var ⇒ every test skips | + +Deploy / update (the `/opt` dir was created with `sudo mkdir` + `chown dohertj2`): + +```bash +rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/ \ + dohertj2@10.100.0.35:/opt/otopcua-questdb/ +ssh dohertj2@10.100.0.35 'cd /opt/otopcua-questdb && docker compose up -d' +``` + +Tests isolate by a unique table prefix per class and drop their tables on dispose, so the instance is safe to share. +Nuclear reset: `ssh dohertj2@10.100.0.35 'cd /opt/otopcua-questdb && docker compose down -v && docker compose up -d'`. +The SQL Server historian suite uses §2's instance: `HISTORIAN_SQL_TEST_CONNECTION='Server=10.100.0.35,14330;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False'` +(each run creates and drops its own `OtOpcUaHistorian_T_` database). + ## 3. Driver integration fixtures Each driver's integration suite TCP-probes its fixture at collection init and **skips cleanly** @@ -77,6 +102,7 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live **Endpoint overrides** point a suite at a real PLC instead of the sim: `MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` · `OPCUA_NAT_ENDPOINT` (opt-in, no default) · `MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT` · `OTOPCUA_FOCAS_SIM_ENDPOINT`. +The historian backends are gated on full connection strings instead (`HISTORIAN_SQL_TEST_CONNECTION` · `HISTORIAN_QUESTDB_TEST_CONNECTION`, §2a). > **`otopcua-focas-sim` on the host is NOT ours (#651).** It is ignitionoee's mock (created by that > project's `provision-oee-focas.sh` for `ignition-site`; no published port, different defaults: @@ -92,7 +118,7 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live > CA + server cert/key, gitignored) and `MQTT_FIXTURE_USERNAME`/`MQTT_FIXTURE_PASSWORD` are exported > at `docker compose up`. The live suite additionally wants `MQTT_FIXTURE_CA_CERT` pointing at a local > copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`. -> It and the OpcUaClient NAT fixture are the only ones whose compose files carry the `project: lmxopcua` label (see CLAUDE.md). +> Not every fixture's compose file carries the `project: lmxopcua` label yet (MQTT, Modbus, FOCAS, MTConnect, Sql, the OpcUaClient NAT stack and the QuestDB historian fixture §2a do); enumerate by container name when in doubt. > **The OpcUaClient NAT fixture exists for what it ADVERTISES (#806/#813).** `--ph` sets a private `.invalid` > host (RFC 6761: never resolves, so no DNS search domain can make it reachable) and the published port 50010 differs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 000000000..b099afa42 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,41 @@ +# QuestDB historian integration-test fixture (#822) — the QuestDB backend's shared, always-available instance. +# +# Deployed on the shared Docker host (see infra/README.md §1) as /opt/otopcua-questdb: +# +# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/ \ +# dohertj2@10.100.0.35:/opt/otopcua-questdb/ +# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-questdb && docker compose up -d' +# +# Endpoint (HISTORIAN_QUESTDB_TEST_CONNECTION): +# Host=10.100.0.35;Port=8812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading +# Web console: http://10.100.0.35:19000 (host :9000 is taken on the Docker host). +# +# admin/quest is QuestDB's stock default — a throwaway dev fixture credential, not a secret. The tests isolate by a +# unique table prefix per test class and drop their tables on dispose, so the instance can be shared by concurrent runs. +# +# Image pinned as in docker-dev/docker-compose.questdb.yml (questdb/questdb:10.0.1, the #666 T0 spike's version; ≥ 8.2.3 +# for table TTL). cairo.ttl.use.wall.clock stays at its default (true) — the historian relies on it (#674.1). +services: + questdb: + image: questdb/questdb:10.0.1 + container_name: otopcua-questdb + restart: unless-stopped + # Every lmxopcua fixture container carries this so the shared Docker host stays + # discoverable with `docker ps --filter label=project=lmxopcua`. + labels: + project: lmxopcua + ports: + - "8812:8812" # PG wire (the historian backend + tests) + - "19000:9000" # web console / REST + volumes: + - otopcua-questdb-fixture-data:/var/lib/questdb + healthcheck: + test: ["CMD-SHELL", "curl -fs http://localhost:9003/status || exit 1"] + interval: 10s + timeout: 5s + retries: 12 +volumes: + otopcua-questdb-fixture-data: + name: otopcua-questdb-fixture-data + labels: + project: lmxopcua From 56e642239faabd3a152876218024eef4024ff05e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:30:51 -0500 Subject: [PATCH 04/16] test(opcua): i=11201 advertises DurationGood/DurationBad on the SQL backends (#820) Co-Authored-By: Claude --- .../AdvertisedAggregateFunctionsWireTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AdvertisedAggregateFunctionsWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AdvertisedAggregateFunctionsWireTests.cs index d2119760d..b50bef258 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AdvertisedAggregateFunctionsWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AdvertisedAggregateFunctionsWireTests.cs @@ -27,9 +27,11 @@ public sealed class AdvertisedAggregateFunctionsWireTests "Average", "Count", "Delta", "End", "Maximum", "MaximumActualTime", "Minimum", "MinimumActualTime", "Range", "Start", ]; + // #820 added DurationBad / DurationGood (the PercentBad / PercentGood region times, in ms). private static readonly string[] SqlNames = [ - "Average", "Count", "Delta", "End", "Interpolative", "Maximum", "MaximumActualTime", "Minimum", "MinimumActualTime", + "Average", "Count", "Delta", "DurationBad", "DurationGood", "End", "Interpolative", "Maximum", "MaximumActualTime", + "Minimum", "MinimumActualTime", "PercentBad", "PercentGood", "Range", "Start", "TimeAverage", "TimeAverage2", "Total", "Total2", ]; From 8e8408a9ca68def9851bcc7539bdcc3111c6dc5a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:32:38 -0500 Subject: [PATCH 05/16] fix(opcuaclient): keep the configured IPv6 zone index through the endpoint rewrite (#819) .NET's Uri parses a zone ([fe80::1%eth0]) but drops it from Host, Authority, AbsoluteUri and ToString(); only DnsSafeHost/IdnHost carry it. The shared OpcUaEndpointUrlRewriter rebuilt the URL through UriBuilder, so the zone was lost and link-local endpoints were unroutable. The SDK dials new Uri(EndpointUrl).IdnHost (TcpByteTransport -> DnsEndPoint), which keeps it, so splicing the zone back into the string is enough. Kept verbatim (%eth0, %4, or RFC 6874 %25eth0) so the session dials exactly what GetEndpoints reached. Applies to the driver, the AdminUI browser and the Client CLI/UI (all share the rewriter). --- .../OpcUaEndpointUrlRewriter.cs | 29 ++++++++++++++- .../EndpointSelectorRewriteParityTests.cs | 6 ++- .../OpcUaClientEndpointRewriteTests.cs | 37 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs index c15217a60..68f98169d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaEndpointUrlRewriter.cs @@ -46,6 +46,33 @@ public static class OpcUaEndpointUrlRewriter return advertisedUrl; var builder = new UriBuilder(advertised) { Host = configured.DnsSafeHost, Port = port }; // AbsoluteUri, not ToString(): ToString() un-escapes, turning an advertised "/x%20y" into "/x y" (#812). - return builder.Uri.AbsoluteUri; + return RestoreZoneIndex(builder.Uri, configured); + } + + /// + /// Gitea #819 — keep an IPv6 zone index ([fe80::1%eth0]) from the configured host. .NET's + /// parses the zone (only / carry it) but drops it from + /// , , and ToString(), so + /// any re-serialised URL loses it. The SDK dials new Uri(EndpointUrl).IdnHost, which DOES carry the zone + /// — so the zone is spliced back into the string, verbatim as the operator wrote it (%eth0, %4, or + /// an RFC 6874 %25eth0: not normalised, so the session dials exactly what GetEndpoints just reached). + /// Without it a link-local endpoint is unroutable: the session would dial a zone-less fe80:: address. + /// + /// The rewritten URL (configured host, zone already lost). + /// The operator-configured URL. + /// 's absolute URI with the configured zone index restored. + private static string RestoreZoneIndex(Uri rebuilt, Uri configured) + { + var absolute = rebuilt.AbsoluteUri; + if (configured.HostNameType != UriHostNameType.IPv6) return absolute; + var zoned = configured.DnsSafeHost; // "fe80::1%eth0" — the only accessor that keeps it + if (zoned.IndexOf('%') < 0) return absolute; + + var bracketed = rebuilt.Host; // "[fe80::1]" + var authorityStart = absolute.IndexOf("://", StringComparison.Ordinal); + if (authorityStart < 0) return absolute; + var hostAt = absolute.IndexOf(bracketed, authorityStart + 3, StringComparison.OrdinalIgnoreCase); + if (hostAt < 0) return absolute; + return absolute[..hostAt] + "[" + zoned + "]" + absolute[(hostAt + bracketed.Length)..]; } } diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs index 84d861b46..4ba60048a 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs @@ -42,8 +42,10 @@ public sealed class EndpointSelectorRewriteParityTests [InlineData("opc.tcp://plc-a:4840/Server", "opc.tcp://PLC-A:4840/", "opc.tcp://plc-a:4840/Server")] // A real rewrite keeps the advertised path's percent-escaping intact. [InlineData("opc.tcp://a:4840/x%20y?q=1", "opc.tcp://b:1/", "opc.tcp://b:1/x%20y?q=1")] - // An IPv6 zone index on the configured host is dropped (UriBuilder behaviour, same in both copies). - [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1]:4841/OtOpcUa")] + // #819: an IPv6 zone index on the configured host is KEPT, verbatim (it used to be dropped — UriBuilder behaviour, + // preserved deliberately through #812 — leaving link-local endpoints unroutable). + [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1%25eth0]:4841/OtOpcUa")] + [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%eth0]:4841", "opc.tcp://[fe80::1%eth0]:4841/OtOpcUa")] public void SelectBest_rewrites_exactly_as_before(string advertised, string configured, string expected) { Select(advertised, configured).ShouldBe(expected); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs index 53e9b7a9c..2d27d1f27 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs @@ -94,6 +94,43 @@ public sealed class OpcUaClientEndpointRewriteTests .ShouldBe("opc.tcp://[fd00::1]:54840/OtOpcUa"); } + /// + /// Gitea #819 — a configured IPv6 zone index survives the rewrite verbatim. .NET's drops it from + /// every re-serialisation (Host, Authority, AbsoluteUri, ToString), so before #819 a link-local endpoint came out + /// zone-less and unroutable. + /// + [Theory] + [InlineData("opc.tcp://[fe80::1%eth0]:4841", "opc.tcp://[fe80::1%eth0]:4841/OtOpcUa")] + [InlineData("opc.tcp://[fe80::1%4]:4841/", "opc.tcp://[fe80::1%4]:4841/OtOpcUa")] + // RFC 6874's encoded delimiter is kept as written, not normalised: the session dials what GetEndpoints reached. + [InlineData("opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1%25eth0]:4841/OtOpcUa")] + // Port-less configured URL: the advertised port is kept, the zone still is. + [InlineData("opc.tcp://[fe80::1%eth0]", "opc.tcp://[fe80::1%eth0]:4840/OtOpcUa")] + public void An_IPv6_zone_index_on_the_configured_host_is_preserved(string configured, string expected) + { + OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", configured).ShouldBe(expected); + } + + [Fact] + public void A_zone_index_reaches_the_host_the_SDK_dials() + { + // The SDK's TCP transport connects to new Uri(EndpointDescription.EndpointUrl).IdnHost (ConfiguredEndpoint + // re-parses the string), so the zone must survive into that accessor — and the advertised path with it. + var rewritten = OpcUaEndpointUrlRewriter.Rewrite( + "opc.tcp://[fe80::9]:4840/Ua", "opc.tcp://[fe80::1%eth0]:4841"); + var endpoint = new ConfiguredEndpoint(null, new EndpointDescription { EndpointUrl = rewritten }, EndpointConfiguration.Create()); + endpoint.EndpointUrl!.IdnHost.ShouldBe("fe80::1%eth0"); + endpoint.EndpointUrl.Port.ShouldBe(4841); + endpoint.EndpointUrl.AbsolutePath.ShouldBe("/Ua"); + } + + [Fact] + public void A_zone_matching_no_op_returns_the_advertised_string_verbatim() + { + OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://[fe80::1%eth0]:4840/Ua", "opc.tcp://[fe80::1%eth0]:4840/") + .ShouldBe("opc.tcp://[fe80::1%eth0]:4840/Ua"); + } + [Fact] public void An_IPv6_advertised_host_is_replaced_by_a_configured_name() { From bde6cce2fc6bcc8d0da6e55708c62e1e4fb57920 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:32:38 -0500 Subject: [PATCH 06/16] test(infra): plain opc-plc fixture healthcheck uses bash /dev/tcp (#817) The opc-plc image ships no netstat, so the old check left otopcua-opc-plc permanently unhealthy. Same probe as the NAT fixture (27302314). Redeployed to /opt/otopcua-opcuaclient on the shared docker host; reports healthy. --- .../Docker/docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml index 18849ce10..8b8f83c16 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml @@ -38,7 +38,9 @@ services: # opc-plc doesn't expose an HTTP health endpoint by default; use a TCP # probe via a shell the base image ships with. The fixture does its own # TCP probe but healthcheck surfaces status in `docker ps` for humans. - test: ["CMD-SHELL", "netstat -an | grep -q ':50000.*LISTEN' || exit 1"] + # #817: bash's /dev/tcp, NOT netstat — the image ships no netstat, so the old + # `netstat -an | grep` check left the container permanently "unhealthy" (same fix as nat/, 27302314). + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/50000"] interval: 5s timeout: 2s retries: 10 From ae91e6b7f75b910b12e13f99bb34ea63fe6959b6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:33:00 -0500 Subject: [PATCH 07/16] feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #816 finding: the SDK checks that the server certificate names the host only when a session is created with checkDomain:true. The driver, the AdminUI browser and Client.Shared all called the 8-argument DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine over an IP in #813). When enabled the SDK (Session.OpenAsync -> ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost — the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector rewrote the advertised one — against the cert's DNS/IP SANs; secured sessions only, and the auto-accept handler accepts a mismatch. Policy: certificateValidation.validateServerHostName, tri-state. Unset = OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the pre-#816 behaviour, so nothing changes on upgrade; true = validate the configured host; false = explicit NAT/port-publish opt-out. Threaded into the driver, the AdminUI browser (same JSON) and the AdminUI form (a select that keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName and the CLI --validate-host-name. A driver refusal names the opt-out. #818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified: same thumbprint across --force-recreate), its PUBLIC cert is committed as Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run every secure browse on a throwaway PKI (internal browser seam): refusal with nothing trusted, positive with the cert trusted (unset/false host check), and a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch); OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT container's public cert. ServerHostNameValidationTests pin the driver against the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in v2-integration (440 -> 446), documented in scripts/ci/README.md. --- .claude/rules/drivers.md | 7 + .github/workflows/v2-integration.yml | 2 + docs/Client.CLI.md | 1 + docs/drivers/OpcUaClient.md | 77 +++++++- .../2026-05-28-driver-browsers-design.md | 5 +- infra/README.md | 9 +- scripts/ci/README.md | 14 +- scripts/ci/integration-fixtures.sh | 9 + .../CommandBase.cs | 10 +- .../Adapters/DefaultSessionFactory.cs | 6 +- .../Adapters/ISessionFactory.cs | 4 + .../Models/ConnectionSettings.cs | 9 + .../OpcUaClientService.cs | 3 +- .../OpcUaClientDriverBrowser.cs | 24 ++- ....OtOpcUa.Driver.OpcUaClient.Browser.csproj | 1 + .../OpcUaClientDriverOptions.cs | 29 +++ .../OpcUaClientDriver.cs | 29 ++- ...B.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj | 2 + .../Drivers/Forms/OpcUaClientDriverForm.razor | 23 +++ .../CommandBaseTests.cs | 16 ++ .../Fakes/FakeSessionFactory.cs | 6 +- .../OpcUaClientServiceTests.cs | 19 ++ .../NatPublishedEndpointBrowseTests.cs | 176 +++++++++++++++--- ...pcUaClient.Browser.IntegrationTests.csproj | 7 + .../Docker/README.md | 6 +- .../Docker/nat/docker-compose.yml | 23 ++- .../Docker/nat/opc-plc-nat-server.der | Bin 0 -> 1043 bytes .../ServerHostNameValidationTests.cs | 108 +++++++++++ .../OpcUaClientServerHostNameOptionTests.cs | 57 ++++++ ...aClientDriverPageFormSerializationTests.cs | 25 +++ 30 files changed, 648 insertions(+), 59 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 1c2dfd3e1..ecdbfcca6 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -288,6 +288,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa AFTER configured ones; more than one discovered server without `discoveryServerUri` fails the connect. Session events are the OTel counter `otopcua.driver.opcuaclient.events`. `HistoryAggregateType` carries the full Part 13 set; an aggregate the gateway can't serve is `BadAggregateNotSupported` per node. +- **OpcUaClient server host-name check (#816):** the SDK checks the server certificate's host only with + `checkDomain: true`; its 8-arg `DefaultSessionFactory.CreateAsync` hard-codes `false`, so driver, AdminUI browser and + Client.Shared never checked before #816. Now `certificateValidation.validateServerHostName` (tri-state, unset = off = + unchanged; AdminUI keeps it unset) / CLI `--validate-host-name`. It checks the **configured** host (the rewritten + `ConfiguredEndpoint.EndpointUrl`), secured sessions only, and auto-accept accepts a mismatch. Any new session-create + call site must pass the flag — the overload without it silently disables the check. The rewriter keeps an IPv6 + zone index verbatim (#819; .NET `Uri` drops it from every re-serialisation). `docs/drivers/OpcUaClient.md`. - **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device (`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second). diff --git a/.github/workflows/v2-integration.yml b/.github/workflows/v2-integration.yml index 48d4b1bd8..aa0af29eb 100644 --- a/.github/workflows/v2-integration.yml +++ b/.github/workflows/v2-integration.yml @@ -74,6 +74,8 @@ jobs: - name: assert not-all-skipped if: always() env: + # #816/#818 added 6 executing OpcUaClient tests; raise to 446 once a hosted run confirms them + # (scripts/ci/README.md §"Fixture-backed integration"). MIN_EXECUTED: "440" run: bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx - name: fixture logs on failure diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index e603776ff..6cb9eaeec 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -39,6 +39,7 @@ All commands accept these options: | `-P` / `--password` | Password for `UserName` token authentication | | `-S` / `--security` | Transport security mode: `none`, `sign`, `encrypt`, `signandencrypt` (default: `none`) | | `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy | +| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Secured connections only; off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits | | `--verbose` | Enable debug-level Serilog console logging (default: warning) | **`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 8b4ad31b5..9ed288728 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -108,7 +108,14 @@ The rule, for `opc.tcp` (and any scheme, as long as advertised and configured sc a real rewrite keeps the advertised path's percent-escaping (`AbsoluteUri`, #812); - a **different scheme** (an `https` endpoint next to an `opc.tcp` one) is a different transport and is left alone; - an unusable advertised URL (null / blank / unparseable) falls back to the configured one in the driver and the - browser; the Client CLI/UI instead reports it (it validates before calling the rewriter). + browser; the Client CLI/UI instead reports it (it validates before calling the rewriter); +- an **IPv6 zone index** on the configured host (`opc.tcp://[fe80::1%eth0]:4840`) is **kept, verbatim** (#819). + .NET's `Uri` parses the zone but drops it from `Host`/`Authority`/`AbsoluteUri`/`ToString()` — only + `DnsSafeHost`/`IdnHost` carry it — so the rewriter splices it back into the string. The SDK dials + `new Uri(EndpointUrl).IdnHost` (`TcpByteTransport` → `DnsEndPoint`), which keeps it, so a link-local endpoint now + routes; before #819 the session dialled a zone-less `fe80::` address. The zone text is not normalised: write the + form your OS resolves (`%eth0` / `%4`). .NET does **not** decode RFC 6874's `%25eth0` (its `DnsSafeHost` is + `fe80::1%25eth0`), so that form is passed through as written — GetEndpoints and the session see the same string. **What the rewrite does NOT touch.** It only chooses the network address. The **security policy and mode** are still selected from the server's advertisement exactly as before — the driver and browser filter on the configured @@ -118,7 +125,51 @@ user-token policies** are the advertised ones. **Certificate validation is uncha validates the certificate of the server actually reached, against the same trust store (the driver's PKI, the browse picker's separate `adminui-browse-pki`, the CLI's own) with the same `AutoAcceptCertificates` semantics. The NAT tests below pin that a secure browse through the NAT is refused on an untrusted certificate and succeeds once it -is trusted. +is trusted. The optional **host-name** check (#816, next section) is the one place the rewrite matters to validation: +it runs against the rewritten URL, i.e. the **configured** host. + +### Server certificate host name (#816) + +**Finding (SDK 2.0.1).** The SDK only checks that the server certificate names the host you connected to when the +session is created with `checkDomain: true`. All three surfaces — the driver (`OpenSessionOnEndpointAsync`), the +AdminUI browser (`OpcUaClientDriverBrowser.OpenAsync`) and Client.Shared (`DefaultSessionFactory`) — called the +8-argument `DefaultSessionFactory.CreateAsync(config, endpoint, updateBeforeConnect, sessionName, …)`, which +forwards `checkDomain: false`. So **no host-name check ever ran**; trust was the trusted-peer / issuer store alone. +That is why, in #813, a trusted NAT certificate for `otopcua-opc-plc-nat-private.invalid` browsed fine over an IP. + +Where the check lives when enabled: `Session.OpenAsync` → after `CertificateManager.ValidateAsync` accepts the chain, +`if (checkDomain) CertificateValidationExtensions.ValidateDomains(validator, serverCert, session.ConfiguredEndpoint)` +→ `CertificateValidationCore.ValidateDomains` → `CertificateValidationHelpers.FindDomain`, which compares +`ConfiguredEndpoint.EndpointUrl.IdnHost` with the certificate's DNS / IP SANs (IP literals normalised; a `localhost` +/ `127.0.0.1` / `::1` URL is compared as the machine's own host name + FQDN). A miss is `BadCertificateHostNameInvalid` +("The domain '…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook +first. Consequences: + +- **It checks the CONFIGURED host.** `ConfiguredEndpoint.EndpointUrl` is the URL `OpcUaEndpointSelection` / + `EndpointSelector` rewrote, so the advertised host the server named itself never takes part. +- **Secured sessions only** — the block runs only when the channel (or the user-token policy) needs the server + certificate; a `None` session validates no certificate at all. +- **`autoAcceptCertificates` accepts a mismatch too** (the driver's auto-accept `AcceptError` handler returns `true` + for everything but revocation). The Client CLI always auto-accepts. +- Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no + certificate or host-name check. + +**Policy: `certificateValidation.validateServerHostName`** (driver JSON; AdminUI driver form "Server host-name check"; +the browser reads the same JSON): + +| Value | Meaning | +|---|---| +| absent / `null` (**default**) | `OpcUaCertificateValidationOptions.DefaultValidateServerHostName` = **off** — exactly the pre-#816 behaviour, so no existing deployment changes on upgrade. The AdminUI form keeps it absent ("Default (off)") rather than writing a concrete `false`. | +| `true` | Validate: the certificate must list the configured endpoint host. The driver's connect failure then appends the remedy (fix the certificate, connect by a name it lists, or opt out). | +| `false` | Explicit opt-out — the NAT / container port-publish / connect-by-an-IP-the-cert-omits case. | + +When to turn it on: it earns its keep when trust comes from a **CA** (a trusted issuer vouches for every certificate +it signed, so only the host check ties the certificate to *this* server). With the server's own self-signed +certificate pinned in the trusted-peer store it adds little — the pin already identifies the server. Client +CLI: `--validate-host-name` (`ConnectionSettings.ValidateServerHostName`, default `false`). Pinned by +`ServerHostNameValidationTests` (driver vs. the plain opc-plc, whose certificate names only its container id) and +`NatPublishedEndpointBrowseTests` (browser vs. the NAT opc-plc): unset/`false` connect, `true` is refused naming the +configured host. **Running the NAT tests.** `NatPublishedEndpointBrowseTests` (`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests`, `Fixture=opc-plc-nat`) run against @@ -133,13 +184,20 @@ OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat ``` -The secure case asserts a certificate **refusal** by default. To make it positive, trust the fixture's server -certificate in the browse PKI and set `OPCUA_NAT_SERVER_CERT_TRUSTED=1` — the certificate is regenerated whenever -the container is recreated, so fetch the current one: -`ssh dohertj2@10.100.0.35 'docker exec otopcua-opc-plc-nat sh -c "cat /app/pki/own/certs/*.der"' > "/OtOpcUa/adminui-browse-pki/trusted/certs/opc-plc-nat.der"` -(`~/Library/Application Support/...` on macOS, `~/.local/share/...` on Linux), and delete it again afterwards. CI -(`v2-integration.yml`) starts its own throwaway NAT copy (`scripts/ci/integration-fixtures.sh`); `v2-e2e.yml` points at -the shared one. Neither trusts the certificate, so CI runs the refusal form. +Every secure browse there runs on a **throwaway browse PKI** (an internal PKI-root seam on the browser), never your +`adminui-browse-pki`. The refusal case trusts nothing and always runs. The positive case (unset / `false` host check) +and the host-name refusal (`true`) trust the fixture's **public** certificate (#818), from: + +1. `OPCUA_NAT_SERVER_CERT` — a DER or PEM file; must be the certificate the server presents, or the test **fails**; else +2. the committed `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der` — the shared-host + stack's certificate, stable because its PKI lives on the `opc-plc-nat-pki` volume. If it is not the certificate + the server presents (another NAT fixture, or the stack's certificate was rolled) these tests **skip**. + +The private key never leaves the volume. The committed certificate expires **2027-11-10**; to roll it, see the comment +at the top of `Docker/nat/docker-compose.yml`. CI (`v2-integration.yml`) starts its own throwaway NAT copy +(`scripts/ci/integration-fixtures.sh`), copies that container's public certificate out with `docker exec` and exports +`OPCUA_NAT_SERVER_CERT`, so it runs the positive cases too; `v2-e2e.yml` points at the shared stack and uses the +committed certificate. ### Session lifetime @@ -379,6 +437,7 @@ also refuses them explicitly — defence in depth (#277), not a behaviour change |---|---|---| | `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. | | `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. | +| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Secured sessions only; accepted anyway under `autoAcceptCertificates`. `false` = explicit NAT opt-out. See "Server certificate host name" above. | ### Subscription tuning (#273) diff --git a/docs/plans/2026-05-28-driver-browsers-design.md b/docs/plans/2026-05-28-driver-browsers-design.md index 39f3a3d4e..41c34ed75 100644 --- a/docs/plans/2026-05-28-driver-browsers-design.md +++ b/docs/plans/2026-05-28-driver-browsers-design.md @@ -280,8 +280,9 @@ Razor picker body BrowserSessionService IDriverBrowser (`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose up -d'`, compose in `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/`), and CI starts a throwaway copy. Run: `OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat`. - The secure case asserts a certificate refusal unless the fixture cert is trusted in the browse PKI and - `OPCUA_NAT_SERVER_CERT_TRUSTED=1` (see `docs/drivers/OpcUaClient.md`). + The secure cases run on a throwaway browse PKI: a refusal with nothing trusted, and (#818) positive / #816 + host-name cases that trust the fixture's public certificate (`OPCUA_NAT_SERVER_CERT`, or the committed + `Docker/nat/opc-plc-nat-server.der`) — see `docs/drivers/OpcUaClient.md`. (`OPCUA_NAT_SERVER_CERT_TRUSTED` is retired.) - No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred) ### Specific regression tests diff --git a/infra/README.md b/infra/README.md index d04ea3993..8ee322e17 100644 --- a/infra/README.md +++ b/infra/README.md @@ -100,9 +100,12 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live > `NatPublishedEndpointBrowseTests` (Browser.IntegrationTests, `Fixture=opc-plc-nat`) use it, and each asserts that > premise before testing the shared `OpcUaEndpointUrlRewriter` (configured host:port win, advertised path kept): > `OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat`. -> A separate stack so it never touches `otopcua-opc-plc`; it carries the `project: lmxopcua` label. The secure case -> turns positive with `OPCUA_NAT_SERVER_CERT_TRUSTED=1` once the container's cert is in the browse PKI -> (`docs/drivers/OpcUaClient.md` §"Endpoint address"). CI runs its own throwaway copy (`otopcua-ci-opcplc-nat`, §10). +> A separate stack so it never touches `otopcua-opc-plc`; it carries the `project: lmxopcua` label. Its PKI is on the +> `opc-plc-nat-pki` volume (#818), so its certificate survives a recreate; the PUBLIC half is committed as +> `Docker/nat/opc-plc-nat-server.der` and the positive secure / #816 host-name cases trust it in a throwaway browse PKI +> (they skip if the server presents a different one; `OPCUA_NAT_SERVER_CERT=` overrides). `docker compose down -v` +> rolls the certificate — refresh the committed copy then (`docs/drivers/OpcUaClient.md` §"Endpoint address"). CI runs +> its own throwaway copy (`otopcua-ci-opcplc-nat`, §10) and exports its certificate as `OPCUA_NAT_SERVER_CERT`. > **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone > Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 4cd24ccc4..a1393437b 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -75,9 +75,19 @@ Neither touches `v2-ci.yml`; each has its own concurrency group (`lmxopcua-v2-in Local run: 468 executed / 8 skipped (Oracle x2, the blackhole `docker pause` gate, one QuestDB stress test, 4 others), about 4.5 min of test time, QuestDB the longest (~3.3 min). Not covered (no pullable image or needs credentials): Modbus/S7/AB/FOCAS/TwinCAT sims, Mqtt, MTConnect, Oracle, Host real-infra mode. + **#816/#818 added 6 executing tests here, floor NOT yet raised:** `ServerHostNameValidationTests` (3, vs the plain + `otopcua-ci-opcplc`) and the NAT positive-secure / host-name cases (3, which run because `integration-fixtures.sh` + now exports the NAT container's public certificate as `OPCUA_NAT_SERVER_CERT`). Locally (2026-11-11, shared host) + the two OpcUaClient projects went 13 → 19 executed with 0 skipped, and `assert-not-all-skipped.sh` counted them. + Once a hosted run's TRX shows `Driver.OpcUaClient.trx` executed=8 and `Driver.OpcUaClient.Browser.trx` executed=11, + raise `MIN_EXECUTED` in `v2-integration.yml` **440 → 446**. If the NAT three show as skipped there, the certificate + hand-off (`docker exec … /app/pki/own/certs/*.der`) did not work on the runner — fix that, do not lower the bump. - **`v2-e2e.yml`** (manual + weekly cron only; `timeout-minutes: 90`). `probe-e2e-fixtures.sh` TCP-probes 10.100.0.35 ports 5020/1102/50000; if any is closed every later step is skipped and the run is green with a notice. Otherwise runs the Modbus, S7, OpcUaClient and OpcUaClient.Browser integration projects against the - shared fleet with `MIN_EXECUTED=35` (`OPCUA_NAT_ENDPOINT` points at the shared NAT stack on :50010; its 3 tests - skip if it is down, so it is not a probe-required port) (local: 42 executed / 38 skipped; Modbus skips the profiles that are not + shared fleet with `MIN_EXECUTED=35` (`OPCUA_NAT_ENDPOINT` points at the shared NAT stack on :50010; its 6 tests + skip if it is down, so it is not a probe-required port — and its 3 certificate-trusting ones also skip if the stack's + certificate no longer matches the committed `Docker/nat/opc-plc-nat-server.der`, so do not count them in the floor; + #816's 3 `ServerHostNameValidationTests` need only the probe-required :50000, so this floor may go **35 → 38** after + a hosted run shows them executed) (local: 42 executed / 38 skipped; Modbus skips the profiles that are not up). No test carries `Category=E2E` today, so the zero-match guard is this executed floor. diff --git a/scripts/ci/integration-fixtures.sh b/scripts/ci/integration-fixtures.sh index 05a203603..8da099e3b 100755 --- a/scripts/ci/integration-fixtures.sh +++ b/scripts/ci/integration-fixtures.sh @@ -80,6 +80,14 @@ up() { wait_for "questdb" 120 curl -fsS "http://$(fixture_host):$QDB_HTTP_PORT/exec?query=select%201" wait_for "opc-plc" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_PORT" wait_for "opc-plc (NAT)" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_NAT_PORT" + # #818: hand the NAT server's PUBLIC certificate to NatPublishedEndpointBrowseTests (OPCUA_NAT_SERVER_CERT) so the + # positive secure / host-name cases run here too. Copied out of the container — the trust channel is docker itself, + # not the wire. The throwaway container makes a fresh certificate per run, so the committed shared-host copy cannot + # be used; the private key never leaves the container. + local nat_cert="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/otopcua-ci-fixtures/opc-plc-nat-server.der" + mkdir -p "$(dirname "$nat_cert")" + wait_for "opc-plc (NAT) certificate" 60 bash -c \ + "docker exec otopcua-ci-opcplc-nat bash -c 'cat /app/pki/own/certs/*.der' > '$nat_cert' && [ -s '$nat_cert' ]" local h; h="$(fixture_host)" local -a exports=( @@ -92,6 +100,7 @@ up() { "HISTORIAN_QUESTDB_TEST_CONNECTION=Host=$h;Port=$QDB_PORT;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading" "OPCUA_SIM_ENDPOINT=opc.tcp://$h:$OPC_PORT" "OPCUA_NAT_ENDPOINT=opc.tcp://$h:$OPC_NAT_PORT" + "OPCUA_NAT_SERVER_CERT=$nat_cert" ) for e in "${exports[@]}"; do echo "export \"$e\"" diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs index 86bddc6e9..ed95be7c6 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs @@ -57,6 +57,13 @@ public abstract class CommandBase : ICommand [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] public string? FailoverUrls { get; init; } + /// + /// Gets a value indicating whether the server certificate must name the host given in (#816). + /// + [CommandOption("validate-host-name", + Description = "Require the server certificate to list the --url host (secured connections only; off by default — leave off behind NAT)")] + public bool ValidateHostName { get; init; } + /// /// Gets a value indicating whether verbose Serilog output should be enabled for troubleshooting. /// @@ -88,7 +95,8 @@ public abstract class CommandBase : ICommand Username = Username, Password = Password, SecurityMode = securityMode, - AutoAcceptCertificates = true + AutoAcceptCertificates = true, + ValidateServerHostName = ValidateHostName }; return settings; diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs index 0619ec1e0..367f2680a 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs @@ -18,6 +18,7 @@ internal sealed class DefaultSessionFactory : ISessionFactory string sessionName, uint sessionTimeoutMs, UserIdentity identity, + bool validateServerHostName, CancellationToken ct) { var endpointConfig = EndpointConfiguration.Create(config); @@ -26,7 +27,10 @@ internal sealed class DefaultSessionFactory : ISessionFactory var created = await new Opc.Ua.Client.DefaultSessionFactory(ClientTelemetry.Context).CreateAsync( config, configuredEndpoint, - false, + false, // updateBeforeConnect + // #816: the overload without checkDomain hard-codes false. The endpoint URL here is the operator's host + // (EndpointSelector rewrote the advertised one), so that is the host the certificate is checked against. + validateServerHostName, // checkDomain sessionName, sessionTimeoutMs, identity, diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs index 0593be846..e3149db8f 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionFactory.cs @@ -15,6 +15,9 @@ internal interface ISessionFactory /// The session name. /// Session timeout in milliseconds. /// The user identity. + /// + /// The SDK's checkDomain: require the server certificate to name the endpoint URL's host (#816). + /// /// Cancellation token. /// A session adapter wrapping the created session. Task CreateSessionAsync( @@ -23,5 +26,6 @@ internal interface ISessionFactory string sessionName, uint sessionTimeoutMs, UserIdentity identity, + bool validateServerHostName, CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs index d9cf64882..952bda84b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs @@ -40,6 +40,15 @@ public sealed class ConnectionSettings ///
public bool AutoAcceptCertificates { get; set; } = true; + /// + /// Gitea #816 — require the server certificate to list the host of the URL being connected (the operator's + /// host: discovery keeps it in place of the server-advertised one) among its subject-alternative names; + /// a mismatch fails the connect with BadCertificateHostNameInvalid. Defaults to false, the + /// behaviour before #816 (the SDK overload used never checked). Secured connections only. Leave it off behind + /// NAT / a port publish / when connecting by an IP the certificate does not name. + /// + public bool ValidateServerHostName { get; set; } + /// /// Path to the certificate store. Defaults to ; the /// consuming application configuration factory resolves the canonical path via diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 513fb30fe..012a3c261 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -777,6 +777,7 @@ public sealed class OpcUaClientService : IOpcUaClientService SecurityMode = settings.SecurityMode, SessionTimeoutSeconds = settings.SessionTimeoutSeconds, AutoAcceptCertificates = settings.AutoAcceptCertificates, + ValidateServerHostName = settings.ValidateServerHostName, CertificateStorePath = settings.CertificateStorePath, Username = settings.Username, Password = settings.Password @@ -794,7 +795,7 @@ public sealed class OpcUaClientService : IOpcUaClientService var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity, - ct); + settings.ValidateServerHostName, ct); } catch { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index a815a79ae..f80b054bf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -35,6 +35,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser private readonly ILogger _logger; private readonly ISecretResolver? _secretResolver; + private readonly string? _pkiRootOverride; /// Creates a new browser. Logger defaults to NullLogger when not supplied. /// Optional logger; defaults to . @@ -49,6 +50,18 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser _secretResolver = secretResolver; } + /// + /// Test seam (Gitea #818): a browser whose PKI lives under instead of the shared + /// %LocalAppData%/OtOpcUa/adminui-browse-pki, so an integration test can trust a fixture's public + /// certificate in a throwaway store without touching the operator's browse trust list. + /// + /// Directory holding the own/trusted/issuers/rejected stores. + internal OpcUaClientDriverBrowser(string pkiRoot) : this() + { + ArgumentException.ThrowIfNullOrWhiteSpace(pkiRoot); + _pkiRootOverride = pkiRoot; + } + /// // Sourced from the shared DriverTypeNames set rather than the hand-written literal // this line used to carry. The value is unchanged; the constant is what @@ -82,7 +95,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser "driver's PKI store instead.", endpoint); - var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); + var app = await BuildBrowseAppConfigurationAsync(_pkiRootOverride, cancellationToken).ConfigureAwait(false); ownedApplication = app; var appConfig = app.ApplicationConfiguration!; @@ -101,6 +114,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser appConfig, configuredEndpoint, updateBeforeConnect: false, + // Gitea #816: the same host-name policy as the deployed driver (the SDK's overload without this + // argument hard-codes false), checked against the configured host the endpoint was rewritten to. + checkDomain: opts.CertificateValidation.EffectiveValidateServerHostName, sessionName: "OtOpcUa AdminUI Browse", (uint)opts.SessionTimeout.TotalMilliseconds, identity, @@ -143,8 +159,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser /// the runtime driver (%LocalAppData%/OtOpcUa/adminui-browse-pki/). Browse /// trust decisions don't leak into the deployed driver's cert store. /// + /// Test-only PKI root; null = the shared browse PKI. /// Cancellation token for the configuration build. - private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) + private static async Task BuildBrowseAppConfigurationAsync( + string? pkiRootOverride, CancellationToken ct) { // The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK // internals off any logging provider. @@ -154,7 +172,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser ApplicationType = ApplicationType.Client, }; - var pkiRoot = Path.Combine( + var pkiRoot = pkiRootOverride ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "adminui-browse-pki"); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj index 94fd7e3bc..6e73ee159 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj @@ -19,5 +19,6 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs index e08683626..ea9824cc0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs @@ -231,6 +231,35 @@ public sealed record OpcUaCertificateValidationOptions /// operator-managed CRL set. null/empty (default) = %LocalAppData%/OtOpcUa/pki/issuers. ///
public string? IssuerStorePath { get; init; } + + /// + /// The value an unset resolves to. false keeps the behaviour every + /// deployment had before Gitea #816 (the SDK never checked the host name for this driver family). + /// + public const bool DefaultValidateServerHostName = false; + + /// + /// Gitea #816 — whether the server certificate must list the host the operator CONFIGURED (the endpoint URL's + /// host, which the shared keeps in place of the server-advertised one) + /// among its DNS / IP subject-alternative names. A mismatch refuses the session with + /// BadCertificateHostNameInvalid. + /// + /// null (default) = (off). + /// true = validate. Worth it when trust comes from a CA (a trusted issuer vouches for every + /// certificate it signed, so only the host check ties the certificate to THIS server); adds little when the + /// server's own self-signed certificate is pinned in the trusted-peer store. + /// false = explicit opt-out — the NAT / port-publish / IP-addressed case, where the server's + /// certificate names its private host and the reachable address is something else. + /// + /// Only applies to secured sessions (a None channel validates no server certificate at all), and + /// accepts a host-name mismatch like any other + /// certificate error. + /// + public bool? ValidateServerHostName { get; init; } + + /// with the default applied. + [System.Text.Json.Serialization.JsonIgnore] + public bool EffectiveValidateServerHostName => ValidateServerHostName ?? DefaultValidateServerHostName; } /// OPC UA message security mode. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4d1820985..c39baf985 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -581,7 +581,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}"); + attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}{HostNameRefusalHint(ex)}"); } } @@ -726,6 +726,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit private void WriteLifecycleHealth(long generation, Func next) => UpdateHealth(generation, next); + /// + /// Test seam (Gitea #816): when set, the PKI stores live under this directory instead of the shared + /// %LocalAppData%/OtOpcUa/pki, so an integration test can trust a fixture's certificate in a throwaway + /// store with off (auto-accept also accepts a + /// host-name mismatch, which would hide the check under test). Set before InitializeAsync. + /// + internal string? PkiRootOverride { get; set; } + /// /// Build a minimal in-memory . Certificates live /// under the OS user profile — on Windows that's %LocalAppData%\OtOpcUa\pki @@ -747,7 +755,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit }; certValidation.Application = app; - var pkiRoot = Path.Combine( + var pkiRoot = PkiRootOverride ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "pki"); @@ -1051,10 +1059,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + // Gitea #816: the SDK's 8-argument CreateAsync hard-codes checkDomain:false, so before #816 the server + // certificate's host name was never checked. The check (when enabled) runs against endpoint.EndpointUrl — + // the CONFIGURED host, because SelectMatching rewrote the advertised one. var session = await new DefaultSessionFactory(_sdkTelemetry).CreateAsync( appConfig, endpoint, false, // updateBeforeConnect + _options.CertificateValidation.EffectiveValidateServerHostName, // checkDomain (#816) _options.SessionName, (uint)_options.SessionTimeout.TotalMilliseconds, identity, @@ -1085,6 +1097,19 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit return OpcUaEndpointSelection.SelectMatching(all.ToList(), endpointUrl, policy, mode); } + /// + /// Gitea #816 — the operator-facing remedy appended to a BadCertificateHostNameInvalid connect failure (only + /// reachable with certificateValidation.validateServerHostName on). The SDK's own text names the host it + /// checked but not the knob. + /// + /// The per-endpoint connect failure. + /// The hint, or an empty string for any other failure. + internal static string HostNameRefusalHint(Exception ex) => + ex is ServiceResultException sre && sre.StatusCode == StatusCodes.BadCertificateHostNameInvalid + ? " (the server certificate does not name the configured endpoint host; add that host to the certificate, " + + "connect by a name it lists, or — behind NAT / a port publish — set certificateValidation.validateServerHostName=false)" + : string.Empty; + /// Delegates to the shared (Gitea #676 / #806). /// The EndpointUrl the server returned from GetEndpoints. /// The URL the driver was configured with (the one GetEndpoints was sent to). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj index a93a9d070..1b656d9dd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj @@ -28,6 +28,8 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index 2b0f68094..d080f2dc9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -192,6 +192,15 @@
Revoked certificates are always rejected.
+
+ + + + + + +
Secured sessions only: the server certificate must name the endpoint host you configured.
+
@@ -349,6 +358,8 @@ public int SubscriptionPriority { get; set; } public bool RejectUnknownRevocationStatus { get; set; } public string? IssuerStorePath { get; set; } + // #816 tri-state: "" = unset (the driver default) so the form never re-emits a concrete default; "true"/"false". + public string ValidateServerHostName { get; set; } = ""; public string? DiscoveryUrl { get; set; } public string? DiscoveryServerUri { get; set; } @@ -381,6 +392,12 @@ SubscriptionPriority = r.SubscriptionPriority, RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus, IssuerStorePath = r.CertificateValidation.IssuerStorePath, + ValidateServerHostName = r.CertificateValidation.ValidateServerHostName switch + { + true => "true", + false => "false", + null => "", + }, DiscoveryUrl = r.DiscoveryUrl, DiscoveryServerUri = r.DiscoveryServerUri, }; @@ -417,6 +434,12 @@ { RejectUnknownRevocationStatus = RejectUnknownRevocationStatus, IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath, + ValidateServerHostName = ValidateServerHostName switch + { + "true" => true, + "false" => false, + _ => null, + }, }, DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl, DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri, diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/CommandBaseTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/CommandBaseTests.cs index d88f7a76e..bff5d7066 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/CommandBaseTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/CommandBaseTests.cs @@ -38,6 +38,22 @@ public class CommandBaseTests settings.AutoAcceptCertificates.ShouldBeTrue(); } + /// Gitea #816: --validate-host-name maps to the server host-name check, which is off without it. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ValidateHostNameOption_MapsToConnectionSettings(bool flag) + { + var fakeService = new FakeOpcUaClientService(); + var factory = new FakeOpcUaClientServiceFactory(fakeService); + var command = new ConnectCommand(factory) { Url = "opc.tcp://localhost:4840", ValidateHostName = flag }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); + + fakeService.LastConnectionSettings!.ValidateServerHostName.ShouldBe(flag); + } + /// Verifies that encrypt option maps to SignAndEncrypt. [Fact] public async Task SecurityOption_Encrypt_MapsToSignAndEncrypt() diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs index 521b6fbb8..427f70f7e 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs @@ -14,6 +14,8 @@ internal sealed class FakeSessionFactory : ISessionFactory public bool ThrowOnCreate { get; set; } /// Gets the endpoint URL from the last CreateSessionAsync call. public string? LastEndpointUrl { get; private set; } + /// Gets the host-name-validation flag from the last CreateSessionAsync call (#816). + public bool? LastValidateServerHostName { get; private set; } /// /// Optional gate that, when set, blocks until completed. @@ -33,14 +35,16 @@ internal sealed class FakeSessionFactory : ISessionFactory /// The session name. /// The session timeout in milliseconds. /// The user identity. + /// The SDK checkDomain flag (#816). /// Cancellation token. /// A task containing the session adapter. public async Task CreateSessionAsync( ApplicationConfiguration config, EndpointDescription endpoint, string sessionName, - uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct) + uint sessionTimeoutMs, UserIdentity identity, bool validateServerHostName, CancellationToken ct) { CreateCallCount++; LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty; + LastValidateServerHostName = validateServerHostName; // One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a // failover is held) runs freely. diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs index 8c74f90c1..104f20fbf 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs @@ -55,6 +55,25 @@ public class OpcUaClientServiceTests : IDisposable _service.CurrentConnectionInfo.ShouldBe(info); } + /// + /// Gitea #816: the host-name check reaches the session factory (the SDK's checkDomain) — off by default, the + /// pre-#816 behaviour — and survives the per-endpoint settings copy the connect path makes. + /// + [Theory] + [InlineData(null)] + [InlineData(true)] + [InlineData(false)] + public async Task ConnectAsync_threads_ValidateServerHostName_to_the_session(bool? validate) + { + var settings = ValidSettings(); + if (validate is { } v) settings.ValidateServerHostName = v; + + await _service.ConnectAsync(settings, TestContext.Current.CancellationToken); + + _sessionFactory.LastValidateServerHostName.ShouldBe(validate ?? false); + _configFactory.LastSettings!.ValidateServerHostName.ShouldBe(validate ?? false); + } + /// /// Verifies that invalid connection settings fail validation before any OPC UA session is created. /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs index 39b59408f..5e66a574a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; using Opc.Ua; using Opc.Ua.Client; using Shouldly; @@ -20,27 +21,54 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests; /// # or locally / in CI (scripts/ci/integration-fixtures.sh starts the same shape as otopcua-ci-opcplc-nat): /// docker run -d --rm --name otopcua-806-nat --hostname otopcua-806-nat-parity -p 32781:50000 \ /// mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn=50000 --ph=otopcua-806-nat-parity.invalid --autoaccept --ut -/// OPCUA_NAT_ENDPOINT=opc.tcp://127.0.0.1:32781 dotnet test --filter Fixture=opc-plc-nat +/// docker exec otopcua-806-nat bash -c 'cat /app/pki/own/certs/*.der' > /tmp/nat.der # public cert only +/// OPCUA_NAT_ENDPOINT=opc.tcp://127.0.0.1:32781 OPCUA_NAT_SERVER_CERT=/tmp/nat.der dotnet test --filter Fixture=opc-plc-nat /// /// Skipped unless OPCUA_NAT_ENDPOINT is set and accepts TCP. Every test first proves the premise (the /// advertised URL is NOT the configured host:port, and its host does not resolve here), so a fixture that /// advertises a reachable address cannot make these pass vacuously. Before the fix the positive cases failed with /// SocketException: nodename nor servname provided, or not known. +/// Secure cases (#813/#816/#818). Every browse here runs on a THROWAWAY browse PKI (the browser's +/// internal PKI-root seam), never the operator's adminui-browse-pki. The refusal case trusts nothing. The +/// positive and host-name cases trust the fixture's PUBLIC certificate — from OPCUA_NAT_SERVER_CERT (a DER or +/// PEM file; what CI exports after copying it out of its throwaway container) or else the committed +/// Docker/nat/opc-plc-nat-server.der (the shared-host stack's certificate, stable since #818 put its PKI on a +/// volume) — and SKIP when the committed certificate is not the one the server presents, so they can never pass +/// against a different server. /// [Trait("Category", "Integration"), Trait("Fixture", "opc-plc-nat")] -public sealed class NatPublishedEndpointBrowseTests +public sealed class NatPublishedEndpointBrowseTests : IDisposable { private const string EnvVar = "OPCUA_NAT_ENDPOINT"; + private const string CertEnvVar = "OPCUA_NAT_SERVER_CERT"; + private const string CommittedCertFile = "opc-plc-nat-server.der"; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), "otopcua-nat-pki-" + Guid.NewGuid().ToString("N")); private static string? Endpoint => Environment.GetEnvironmentVariable(EnvVar) is { Length: > 0 } e ? e : null; - private static string Json(string endpoint, string policy = "None", string mode = "None") => $$""" - { - "EndpointUrl":"{{endpoint}}", - "SecurityPolicy":"{{policy}}","SecurityMode":"{{mode}}","AuthType":"Anonymous", - "SessionTimeout":"00:01:00","Timeout":"00:00:10","PerEndpointConnectTimeout":"00:00:10" - } - """; + /// + public void Dispose() + { + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort temp cleanup */ } + } + + /// A browser on this test's throwaway PKI. + private OpcUaClientDriverBrowser Browser() => new(_pkiRoot); + + private static string Json(string endpoint, string policy = "None", string mode = "None", bool? validateHostName = null) + { + var certValidation = validateHostName is { } v + ? $$""","CertificateValidation":{"ValidateServerHostName":{{(v ? "true" : "false")}}}""" + : ""; + return $$""" + { + "EndpointUrl":"{{endpoint}}", + "SecurityPolicy":"{{policy}}","SecurityMode":"{{mode}}","AuthType":"Anonymous", + "SessionTimeout":"00:01:00","Timeout":"00:00:10","PerEndpointConnectTimeout":"00:00:10"{{certValidation}} + } + """; + } private static string RequireFixture() { @@ -60,8 +88,11 @@ public sealed class NatPublishedEndpointBrowseTests return endpoint; } - /// The premise: the server advertises a host:port other than the configured one, and that host is unresolvable. - private static async Task AssertAdvertisesUnreachableAddressAsync(string endpoint, CancellationToken ct) + /// + /// The premise: the server advertises a host:port other than the configured one, and that host is unresolvable. + /// Returns the DER certificate the server presents on its secure endpoint. + /// + private static async Task AssertAdvertisesUnreachableAddressAsync(string endpoint, CancellationToken ct) { var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) { @@ -84,6 +115,61 @@ public sealed class NatPublishedEndpointBrowseTests await Should.ThrowAsync(() => Dns.GetHostAddressesAsync(advertised.Host, ct), $"advertised host '{advertised.Host}' must be unresolvable from here, or this test proves nothing"); } + + return endpoints.First(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256).ServerCertificate.ToArray(); + } + + /// + /// Trust the fixture's public certificate in this test's throwaway PKI, or SKIP when no certificate for THIS + /// server is available (see the class remarks). An explicit OPCUA_NAT_SERVER_CERT that does not match + /// FAILS: it names the certificate the operator meant, so a mismatch is a fixture problem worth seeing. + /// + /// The certificate the server presents (from its GetEndpoints answer). + private void TrustFixtureCertificate(byte[] presentedDer) + { + using var presented = X509CertificateLoader.LoadCertificate(presentedDer); + X509Certificate2 trusted; + if (Environment.GetEnvironmentVariable(CertEnvVar) is { Length: > 0 } path) + { + trusted = LoadPublicCertificate(path); + trusted.Thumbprint.ShouldBe(presented.Thumbprint, + $"{CertEnvVar}={path} is not the certificate the NAT fixture presents"); + } + else + { + var committed = Path.Combine(AppContext.BaseDirectory, CommittedCertFile); + if (!File.Exists(committed)) + Assert.Skip($"{CommittedCertFile} is not deployed next to the tests and {CertEnvVar} is unset."); + trusted = LoadPublicCertificate(committed); + if (trusted.Thumbprint != presented.Thumbprint) + Assert.Skip($"the committed {CommittedCertFile} ({trusted.Thumbprint}) is not this fixture's certificate " + + $"({presented.Thumbprint}, expires {presented.NotAfter:yyyy-MM-dd}); set {CertEnvVar} or " + + "refresh the committed copy (see Docker/nat/docker-compose.yml)."); + } + + using (trusted) + { + trusted.HasPrivateKey.ShouldBeFalse("only a PUBLIC certificate is ever trusted from a file"); + var certs = Path.Combine(_pkiRoot, "trusted", "certs"); + Directory.CreateDirectory(certs); + File.WriteAllBytes(Path.Combine(certs, "opc-plc-nat.der"), trusted.RawData); + } + } + + private static X509Certificate2 LoadPublicCertificate(string path) + { + var bytes = File.ReadAllBytes(path); + var text = System.Text.Encoding.ASCII.GetString(bytes); + return text.Contains("-----BEGIN CERTIFICATE-----", StringComparison.Ordinal) + ? X509Certificate2.CreateFromPem(text) + : X509CertificateLoader.LoadCertificate(bytes); + } + + private static StatusCode? FindStatus(Exception? ex) + { + for (; ex is not null; ex = ex.InnerException) + if (ex is ServiceResultException sre) return sre.StatusCode; + return null; } [Fact] @@ -93,7 +179,7 @@ public sealed class NatPublishedEndpointBrowseTests var ct = TestContext.Current.CancellationToken; await AssertAdvertisesUnreachableAddressAsync(endpoint, ct); - await using var session = await new OpcUaClientDriverBrowser().OpenAsync(Json(endpoint), ct); + await using var session = await Browser().OpenAsync(Json(endpoint), ct); var roots = await session.RootAsync(ct); roots.Count.ShouldBeGreaterThan(0); @@ -111,7 +197,7 @@ public sealed class NatPublishedEndpointBrowseTests var ct = TestContext.Current.CancellationToken; // A closed loopback port: the rewrite must not invent a reachable address; the open fails promptly. var sw = System.Diagnostics.Stopwatch.StartNew(); - await Should.ThrowAsync(() => new OpcUaClientDriverBrowser().OpenAsync(Json("opc.tcp://127.0.0.1:1"), ct)); + await Should.ThrowAsync(() => Browser().OpenAsync(Json("opc.tcp://127.0.0.1:1"), ct)); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(30)); } @@ -122,29 +208,59 @@ public sealed class NatPublishedEndpointBrowseTests var ct = TestContext.Current.CancellationToken; await AssertAdvertisesUnreachableAddressAsync(endpoint, ct); - // Basic256Sha256/SignAndEncrypt through the published port. The browse PKI never auto-accepts, so unless the - // operator trusted this fixture's certificate in the browse PKI (then set OPCUA_NAT_SERVER_CERT_TRUSTED=1) the - // open must be refused on the certificate — which proves the session reached the SERVER (no DNS failure on the - // advertised host) and that the rewrite did not relax trust. - var trusted = Environment.GetEnvironmentVariable("OPCUA_NAT_SERVER_CERT_TRUSTED") == "1"; - var json = Json(endpoint, "Basic256Sha256", "SignAndEncrypt"); - if (trusted) - { - await using var session = await new OpcUaClientDriverBrowser().OpenAsync(json, ct); - (await session.RootAsync(ct)).Count.ShouldBeGreaterThan(0); - return; - } - + // Basic256Sha256/SignAndEncrypt through the published port, on a browse PKI that trusts NOTHING (the browse + // never auto-accepts): the open must be refused on the certificate — which proves the session reached the + // SERVER (no DNS failure on the advertised host) and that the rewrite did not relax trust. var ex = await Should.ThrowAsync(async () => { - await using var session = await new OpcUaClientDriverBrowser().OpenAsync(json, ct); + await using var session = await Browser().OpenAsync(Json(endpoint, "Basic256Sha256", "SignAndEncrypt"), ct); }); TestContext.Current.TestOutputHelper?.WriteLine($"secure open refused: {ex.GetType().Name}: {ex.Message}"); ex.ShouldNotBeOfType($"must not fail on DNS for the advertised host: {ex}"); - var sre = ex as ServiceResultException ?? ex.InnerException as ServiceResultException; - sre.ShouldNotBeNull($"expected a certificate refusal, got {ex.GetType().Name}: {ex.Message}"); - sre.StatusCode.ShouldBeOneOf( + var status = FindStatus(ex); + status.ShouldNotBeNull($"expected a certificate refusal, got {ex.GetType().Name}: {ex.Message}"); + status.Value.ShouldBeOneOf( StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadCertificateChainIncomplete); } + + /// + /// #818 — the positive secure case, runnable in CI. #816 — with the host-name check unset (the default) or + /// explicitly off, a trusted certificate is accepted although it names *-nat-private.invalid and the + /// browse dialled the published address: the NAT opt-out. + /// + [Theory] + [InlineData(null)] + [InlineData(false)] + public async Task Secure_browse_with_the_fixture_certificate_trusted_succeeds_without_a_host_name_check(bool? validate) + { + var endpoint = RequireFixture(); + var ct = TestContext.Current.CancellationToken; + TrustFixtureCertificate(await AssertAdvertisesUnreachableAddressAsync(endpoint, ct)); + + await using var session = await Browser().OpenAsync( + Json(endpoint, "Basic256Sha256", "SignAndEncrypt", validate), ct); + (await session.RootAsync(ct)).Count.ShouldBeGreaterThan(0); + } + + /// + /// #816 — with the host-name check on, the SAME trusted certificate is refused, and the host checked is the one + /// the operator configured (the published address), not the server-advertised private name the certificate lists. + /// + [Fact] + public async Task Host_name_validation_checks_the_configured_host_and_refuses_the_NAT_certificate() + { + var endpoint = RequireFixture(); + var ct = TestContext.Current.CancellationToken; + TrustFixtureCertificate(await AssertAdvertisesUnreachableAddressAsync(endpoint, ct)); + + var ex = await Should.ThrowAsync(async () => + { + await using var session = await Browser().OpenAsync( + Json(endpoint, "Basic256Sha256", "SignAndEncrypt", validateHostName: true), ct); + }); + TestContext.Current.TestOutputHelper?.WriteLine($"secure open refused: {ex.GetType().Name}: {ex.Message}"); + FindStatus(ex).ShouldBe(StatusCodes.BadCertificateHostNameInvalid, $"expected a host-name refusal, got {ex}"); + ex.ToString().ShouldContain($"'{new Uri(endpoint).IdnHost}'"); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj index dee7cb7d4..19f51816a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj @@ -24,4 +24,11 @@ + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md index 19817102d..816049ce7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md @@ -10,7 +10,8 @@ here since opc-plc ships pre-containerized. | File | Purpose | |---|---| | [`docker-compose.yml`](docker-compose.yml) | Service definition for `otopcua-opc-plc` — image pin, port map, command flags. | -| [`nat/docker-compose.yml`](nat/docker-compose.yml) | `otopcua-opc-plc-nat` — a SEPARATE stack (`/opt/otopcua-opcuaclient-nat` on the shared host): opc-plc advertising a private `.invalid` host on internal port 50000, published on 50010. For `NatPublishedEndpointBrowseTests` (`OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010`, #806/#813); see the compose file's header. | +| [`nat/docker-compose.yml`](nat/docker-compose.yml) | `otopcua-opc-plc-nat` — a SEPARATE stack (`/opt/otopcua-opcuaclient-nat` on the shared host): opc-plc advertising a private `.invalid` host on internal port 50000, published on 50010. For `NatPublishedEndpointBrowseTests` (`OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010`, #806/#813); see the compose file's header. Its PKI is on a volume (#818). | +| [`nat/opc-plc-nat-server.der`](nat/opc-plc-nat-server.der) | The NAT stack's **public** server certificate (no key), trusted by the positive secure / host-name NAT tests in a throwaway browse PKI. Refresh it when the stack's certificate is rolled (`docker compose down -v`); expires 2027-11-10. | | (this file) | How to run it. | ## Install @@ -34,7 +35,8 @@ docker compose up ``` First run pulls the image (~250 MB). Startup takes ~5-10 seconds; the -healthcheck in the compose file surfaces ready state in `docker ps`. +healthcheck in the compose file surfaces ready state in `docker ps` (a bash `/dev/tcp` probe — +the image has no `netstat`, which kept the old check permanently "unhealthy", #817). To run detached (CI pattern): diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/docker-compose.yml index ccb40f66b..32f2956d5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/docker-compose.yml +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/docker-compose.yml @@ -20,6 +20,16 @@ # ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose up -d' # OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test \ # tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat +# +# #818: the server's PKI (/app/pki — own cert + private key, and the client certs --aa accepted) lives in the +# named volume `opc-plc-nat-pki`, so recreating the container keeps the SAME server certificate. Its PUBLIC half is +# committed next to this file (opc-plc-nat-server.der) and the secure tests trust it in a throwaway browse PKI. The +# private key never leaves the volume. To roll the certificate deliberately (e.g. at expiry, 2027-11-10): +# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose down -v && docker compose up -d' +# ssh dohertj2@10.100.0.35 'docker exec otopcua-opc-plc-nat bash -c "cat /app/pki/own/certs/*.der"' \ +# > tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der +# (until the committed copy is refreshed the positive secure tests SKIP on a thumbprint mismatch — they never pass +# against a certificate other than the committed one). services: opc-plc-nat: image: mcr.microsoft.com/iotedge/opc-plc:2.14.10 @@ -40,11 +50,20 @@ services: # --aa: accept the browser's self-signed client cert. Trust in the OTHER direction (the browser trusting # this server's cert) is deliberately not automatic — that is what the secure test pins. - "--aa" + volumes: + - opc-plc-nat-pki:/app/pki + # The image runs as uid 1654 ("app") and ships no /app/pki, so Docker creates the volume mount point root-owned + # and opc-plc could not write its certificate there. A throwaway test simulator: run it as root rather than add a + # chown init container. + user: "0" healthcheck: - # The image ships no netstat (the sibling ../docker-compose.yml healthcheck is permanently "unhealthy" for that - # reason); bash's /dev/tcp is there. + # The image ships no netstat (the sibling ../docker-compose.yml healthcheck was permanently "unhealthy" for that + # reason until #817 switched it to this probe too); bash's /dev/tcp is there. test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/50000"] interval: 5s timeout: 2s retries: 10 start_period: 10s + +volumes: + opc-plc-nat-pki: diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der new file mode 100644 index 0000000000000000000000000000000000000000..a06fb8e86ba0fd5fb9ebabe7550b2ad49fa58d7c GIT binary patch literal 1043 zcmXqLV&OMvV*0*-nTe5!i7WVQ(x=8LR^lmH2E1&XT5TR}-+37sxmg(uv<$@!MA?`_ zS-6Dx`~#d7-11BFQW8rt^YaY(fog#g%))H`1<3(9$p&)byhdh*hK2?Z5C!C#BXLPG ztBFww*(r>y49rc8{0s(7j9g4jjEoHDcJEGL-zhs~Zs1?Fzehuw;yo^3h)6oB-}UPn zFZ&mxD;eusm4l-kPg+WE<1-3Ar?ULrmslOE8!x6E3jOx~y!7(=(3@V&ZNc&XqJ=^m zTFRfro;)*8DB)*Ov7vS3?LSv@qT zzmd80P*2bC*0x2L^Dj(aE_`~sZKMCQ>SH0SQeMJ+T1)0tXl^)Gb?ZL=>@PBRlQcGb zX1nsFE#j=^;_Lq|*Ekv-VG;;;@O=AY^X)^gKYo^+bdrggk%4h>6Qe0GL<|jhfDs`p z%*gnkg~@=yKo-Q~V-aH!*)T7FS=;=>8QIj9=UHa0N2eMF-7ts-Nh`C27z8z7Rj^QR zi4G}o)r6dU_&|p9gIvPG%*6D?Kmo*81@XlUMA$gA*%(<_*_j#PEGC0ukP1PT9D}Sj zwbG(ID@bCp$}h<;NG?s(1(Lc2ImxUqtVj9muHELDYA&|Uc8clL8|UMls?W}J zNbcBUxj%0D^v~_}4|6+w1TJRzUY8fy+NO3$QCw51^vTEf_0o2dz70G4?7mKNUO2;G z{m0@)X{Cl&`~scJ-IbqiT(P$3j=_}JP$A*&vVRse6^$Fj9GK_bme;dwJ=xkGuzit# zjZ~}4)2RP;zy8>77X2>$@vmRMK*zO{>nhqFIA$Ji5%{xc;*t=vFPm~&J{N^q`uxyU zUv%5EGe~}3mWY3jo{?np+eQ_sPXQ~HBzt~O-O~Ojf0}ZELjUQz$@;gfCVxBU`$Qsq VnNZKQRW>c60_m0CALMYC0RXN#i^%{0 literal 0 HcmV?d00001 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs new file mode 100644 index 000000000..1687d7d06 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs @@ -0,0 +1,108 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// Gitea #816 — does the driver check the server certificate's host name? Against the plain opc-plc fixture, whose +/// certificate names only its container hostname (e.g. DNS:1fc1de3fcb21) while the driver dials the docker +/// host's address, so a host check MUST fail if it runs at all. +/// Before #816 the driver called the SDK's 8-argument DefaultSessionFactory.CreateAsync, which hard-codes +/// checkDomain: false: the mismatch was never noticed. Now certificateValidation.validateServerHostName +/// decides; unset keeps the old behaviour. +/// Each test trusts the server's certificate in a THROWAWAY PKI (the driver's PkiRootOverride) with +/// auto-accept OFF — auto-accept also accepts a host-name mismatch and would hide the check. The certificate is +/// taken from the server's own GetEndpoints answer: what is under test is the host check, not how trust was +/// established. +/// +[Collection(OpcPlcCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "opc-plc")] +public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposable +{ + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), "otopcua-816-" + Guid.NewGuid().ToString("N")); + + /// + public void Dispose() + { + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort temp cleanup */ } + } + + private OpcUaClientDriverOptions SecureOptions(bool? validateHostName) => OpcPlcProfile.BuildOptions(sim.EndpointUrl) with + { + SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + AutoAcceptCertificates = false, + CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = validateHostName }, + }; + + /// Trust the fixture's server certificate (as advertised on its secure endpoint) in the throwaway PKI. + private async Task TrustServerCertificateAsync(CancellationToken ct) + { + var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) + { + ApplicationName = "OtOpcUa #816 trust", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration(), + TransportQuotas = new TransportQuotas(), + ClientConfiguration = new ClientConfiguration(), + DisableHiResClock = true, + }; + using var discovery = await DiscoveryClient.CreateAsync(appConfig, new Uri(sim.EndpointUrl), DiagnosticsMasks.None, ct); + var endpoints = await discovery.GetEndpointsAsync(default, ct); + var secure = endpoints.ToList().First(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256); + var der = secure.ServerCertificate.ToArray(); + der.ShouldNotBeEmpty("the secure endpoint must carry the server certificate"); + + var certs = Path.Combine(_pkiRoot, "trusted", "certs"); + Directory.CreateDirectory(certs); + await File.WriteAllBytesAsync(Path.Combine(certs, "opc-plc.der"), der, ct); + } + + private async Task InitializeAsync(bool? validateHostName, string id, CancellationToken ct) + { + var drv = new OpcUaClientDriver(SecureOptions(validateHostName), driverInstanceId: id) { PkiRootOverride = _pkiRoot }; + try + { + await drv.InitializeAsync("{}", ct); + return drv; + } + catch + { + await drv.DisposeAsync(); + throw; + } + } + + [Theory] + [InlineData(null)] // unset = the pre-#816 behaviour every existing deployment has + [InlineData(false)] // the explicit NAT / IP-addressed opt-out + public async Task Without_host_name_validation_a_trusted_certificate_for_another_host_connects(bool? validate) + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + await TrustServerCertificateAsync(ct); + + await using var drv = await InitializeAsync(validate, $"opcua-816-off-{validate}", ct); + var snapshots = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct); + snapshots[0].StatusCode.ShouldBe(0u, "the secured session must read once the server certificate is trusted"); + } + + [Fact] + public async Task With_host_name_validation_the_certificate_is_checked_against_the_configured_host() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + await TrustServerCertificateAsync(ct); + + var ex = await Should.ThrowAsync(() => InitializeAsync(true, "opcua-816-on", ct)); + TestContext.Current.TestOutputHelper?.WriteLine($"refused: {ex}"); + // The driver's failover aggregate keeps per-endpoint failures as text. The SDK message names the host it + // checked: the CONFIGURED one (the endpoint URL was rewritten to it), not whatever the server advertised. + var configuredHost = new Uri(sim.EndpointUrl).IdnHost; + var text = ex.ToString(); + text.ShouldContain($"The domain '{configuredHost}' is not listed in the server certificate"); + text.ShouldContain("validateServerHostName=false", Case.Sensitive, "the refusal must name the opt-out"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs new file mode 100644 index 000000000..a4c27f1b7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs @@ -0,0 +1,57 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Gitea #816 — certificateValidation.validateServerHostName. The wire behaviour (the SDK's checkDomain against +/// the CONFIGURED host) is pinned against real servers by ServerHostNameValidationTests (driver, plain opc-plc) +/// and NatPublishedEndpointBrowseTests (browser, NAT opc-plc); these pin the option contract. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientServerHostNameOptionTests +{ + [Fact] + public void Unset_resolves_to_off_so_existing_deployments_are_unchanged() + { + var o = new OpcUaClientDriverOptions(); + o.CertificateValidation.ValidateServerHostName.ShouldBeNull(); + o.CertificateValidation.EffectiveValidateServerHostName.ShouldBeFalse(); + OpcUaCertificateValidationOptions.DefaultValidateServerHostName.ShouldBeFalse(); + + OpcUaClientDriverFactoryExtensions.ParseOptions("d", """{"endpointUrl":"opc.tcp://x:1"}""") + .CertificateValidation.EffectiveValidateServerHostName.ShouldBeFalse(); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + public void Factory_json_binds_the_explicit_choice(string json, bool expected) + { + var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", + $$$"""{"endpointUrl":"opc.tcp://x:1","certificateValidation":{"validateServerHostName":{{{json}}}}}"""); + + o.CertificateValidation.ValidateServerHostName.ShouldBe(expected); + o.CertificateValidation.EffectiveValidateServerHostName.ShouldBe(expected); + } + + [Fact] + public void The_effective_value_is_not_serialised_back() + { + var json = System.Text.Json.JsonSerializer.Serialize(new OpcUaCertificateValidationOptions()); + json.ShouldNotContain("Effective"); + } + + [Fact] + public void A_host_name_refusal_names_the_opt_out() + { + var hint = OpcUaClientDriver.HostNameRefusalHint(new ServiceResultException( + StatusCodes.BadCertificateHostNameInvalid, "The domain '10.1.2.3' is not listed in the server certificate.")); + hint.ShouldContain("validateServerHostName=false"); + + OpcUaClientDriver.HostNameRefusalHint(new ServiceResultException(StatusCodes.BadCertificateUntrusted)) + .ShouldBeEmpty(); + OpcUaClientDriver.HostNameRefusalHint(new InvalidOperationException("x")).ShouldBeEmpty(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs index eede6ea95..9940ba49f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -180,6 +180,31 @@ public sealed class OpcUaClientDriverPageFormSerializationTests def.DiscoveryUrl.ShouldBeNull(); } + /// + /// #816: the server host-name check is tri-state. Unset must stay unset through the form (so a later change of + /// the driver default reaches configs nobody chose for), and an explicit opt-in / NAT opt-out must survive. + /// + [Theory] + [InlineData(null)] + [InlineData(true)] + [InlineData(false)] + public void FormModel_RoundTrip_PreservesHostNameValidationTriState(bool? value) + { + var formOpts = new JsonSerializerOptions(_opts) { Converters = { new JsonStringEnumConverter() } }; + var original = new OpcUaClientDriverOptions + { + CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = value }, + }; + + var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]); + back.CertificateValidation.ValidateServerHostName.ShouldBe(value); + + var reparsed = JsonSerializer.Deserialize( + JsonSerializer.Serialize(back, formOpts), formOpts)!; + reparsed.CertificateValidation.ValidateServerHostName.ShouldBe(value); + reparsed.CertificateValidation.EffectiveValidateServerHostName.ShouldBe(value ?? false); + } + /// /// #648: stored DriverConfig JSON from before targetNamespaceKind/unsMappingTable were removed /// must still load into the form (the form's options use UnmappedMemberHandling.Skip) and the legacy From 9742a66a9eabc07e99779c38fe7bb43cd253d635 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:37:25 -0500 Subject: [PATCH 08/16] docs(security): credential-reference namespaces + migration note (#815) docs/security.md gains the canonical table of per-driver namespaces, the five enforcement seams, the out-of-scope cases (appsettings ${secret:}, Sql connectionStringRef) and the migration steps for existing configs. Galaxy, OpcUaClient, Heidenhain, Configuration and driver-specs docs, the Galaxy options XML docs, the drivers rule file and the docker-dev compose comment point at it. docker-dev's env:GALAXY_MXGW_API_KEY stays valid (allowed legacy prefix), so no rig change is needed. Co-Authored-By: Claude --- .claude/rules/drivers.md | 8 +++ docker-dev/docker-compose.yml | 3 +- docs/Configuration.md | 2 +- docs/drivers/Galaxy.md | 8 ++- docs/drivers/Heidenhain.md | 6 +- docs/drivers/OpcUaClient.md | 6 ++ docs/security.md | 61 +++++++++++++++++++ docs/v2/driver-specs.md | 2 +- .../GalaxyDriverOptions.cs | 28 ++++----- 9 files changed, 102 insertions(+), 22 deletions(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 1c2dfd3e1..ba4bf4094 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -319,6 +319,14 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. +- **Credential references are namespaced per driver (#815):** ONE table (`Core.Abstractions.DriverCredentialPolicy`) + + ONE parser/resolver (`CredentialReferences`) for Galaxy (`secret:galaxy/`·`secret:otopcua/galaxy/`·`env:OTOPCUA_GALAXY_`· + `env:GALAXY_MXGW_`), OpcUaClient (`secret:opcuaclient/`·`env:OTOPCUA_OPCUACLIENT_`) and Heidenhain. Enforced at save + (`DriverConfigSaveGate`), deploy (`CredentialReferenceOutOfScope`), Test Connect (`AdminOperationsActor`, before the + probe), browse (`BrowserSessionService`, before the browser) and at resolve time before any store/env read; `file:` is + refused everywhere. A NEW driver that resolves a `secret:`/`env:` field and sends it to an authored endpoint MUST add a + row there and resolve through `CredentialReferences` — never `ISecretResolver.GetAsync` on a raw authored name. + `docs/security.md` §"Credential references are namespaced per driver". - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 42baee8d3..abfc17977 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -679,7 +679,8 @@ services: Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" # Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's - # Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY". + # Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY" (#815: GALAXY_MXGW_* is an allowed + # Galaxy env prefix alongside OTOPCUA_GALAXY_*; any other variable is refused). GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" # Pair-local config cache + REPLICATION ON — the site-a pair is the enablement demo. # site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer. diff --git a/docs/Configuration.md b/docs/Configuration.md index b708fd53a..a9f229797 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -385,7 +385,7 @@ The Galaxy/MxAccess connection settings are **not an `appsettings` section.** Th | Record | Key fields (default) | Meaning | |---|---|---| -| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `env:NAME` / `file:PATH` / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `env:`/`file:` in production. Never store a cleartext key. | +| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `secret:galaxy/` (or `secret:otopcua/galaxy/`) / `env:OTOPCUA_GALAXY_` (or legacy `env:GALAXY_MXGW_`) / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `secret:` in production. Any other `secret:`/`env:` reference and every `file:` reference is refused at save, deploy (`CredentialReferenceOutOfScope`), Test Connect, browse and resolve — #815, see `docs/security.md` §"Credential references are namespaced per driver". Never store a cleartext key. | | `GalaxyMxAccessOptions` (`MxAccess`) | `ClientName`; `PublishingIntervalMs` (`1000`); `WriteUserId` (`0` = anonymous); `EventPumpChannelCapacity` (`50000`) | MXAccess client identity + tuning. `ClientName` **must be unique per OtOpcUa instance** (redundancy pairs enforce this). | | `GalaxyRepositoryOptions` (`Repository`) | `DiscoverPageSize` (`5000`); `WatchDeployEvents` (`true`) | Galaxy Repository browse paging + deploy-event watching. | | `GalaxyReconnectOptions` (`Reconnect`) | `InitialBackoffMs` (`500`); `MaxBackoffMs` (`30000`); `ReplayOnSessionLost` (`true`) | In-driver reconnect-supervisor backoff. | diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 3cc617714..39a97d5b6 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -81,7 +81,7 @@ Project root files: `DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are: -- **`Gateway`** — endpoint, API key secret ref, TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. +- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815, [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. - **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric). - **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`. - **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag). @@ -103,8 +103,10 @@ Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowle not silently downgraded to the fallback. 2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` / `FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous - sessions. The password ref must be `secret:NAME` / `${secret:NAME}` (production), `env:NAME`, - `file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig. + sessions. The password ref must be `secret:galaxy/NAME` / `secret:otopcua/galaxy/NAME` (or the `${secret:…}` + spelling; production), `env:OTOPCUA_GALAXY_NAME` / `env:GALAXY_MXGW_NAME`, or `dev:VALUE`; a bare literal, a + `file:` reference or a reference outside the Galaxy namespace is refused (#815) — the password is sent to the + authored gateway endpoint, so it must not be able to name another subsystem's secret. 3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway health). How the client learns of it (#760): diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index 978088a90..115bb6b35 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -91,7 +91,11 @@ Host at their own machine. Anything outside the namespace is refused by **every* (`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path** (`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the -deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. +deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. Since #815 the parser and resolver are +the shared `CredentialReferences` / `DriverCredentialPolicy` (`Core.Abstractions`) that Galaxy and OpcUaClient use +too, and Test Connect / live browse also refuse an out-of-namespace reference before any probe or browser runs; a +prefix in another casing (`SECRET:`) is refused, and `${secret:heidenhain/}` is accepted as a spelling of +`secret:heidenhain/`. Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 8b4ad31b5..2e2b2b92a 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -81,6 +81,12 @@ for every field (security policy/mode, auth type, session timeout, keep-alive, browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save. +**Credentials (#815).** `Password` / `UserCertificatePassword` are a literal, `secret:opcuaclient/` (the node's +secret store) or `env:OTOPCUA_OPCUACLIENT_`. They are resolved on the node and sent to the configured endpoint, +so any other `secret:`/`env:` reference (and `file:`) is refused at save, deploy (`CredentialReferenceOutOfScope`), Test +Connect, browse and again at session open, before anything is read. Migrate an existing `secret:` by re-creating +the secret as `opcuaclient/`. See [`docs/security.md`](../security.md) §"Credential references are namespaced per driver". + `ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing). diff --git a/docs/security.md b/docs/security.md index be8288f35..20e59cfa4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1144,6 +1144,67 @@ Supporting hygiene, none of which is the control: If a deployment needs the browse surface narrower than "anyone who may author config", the lever is the page policy (and the LDAP group→role mapping behind it), not the browser. +#### Credential references are namespaced per driver (#815) + +The outbound-connect capability above has a sharper edge whenever the config carries a **credential +reference**: a Designer authors both the endpoint *and* the reference, and the node resolves the reference +and sends the value to that endpoint — on Test Connect, on a live browse, and at runtime. Before #815 an +unscoped reference let anyone who may author config exfiltrate any value the admin node could read, with no +deploy: point a Galaxy `Gateway.Endpoint` at their own host, set `ApiKeySecretRef` to +`env:ConnectionStrings__ConfigDb` (or `env:ServerHistorian__ApiKey`, or `file:`), and click Test +Connect / browse; or set an OpcUaClient `Password` to `secret:`. + +Every operator-authored reference is now confined to its driver's namespace — one table, +`DriverCredentialPolicy` (`Core.Abstractions`), one parser/resolver, `CredentialReferences`: + +| Driver | Fields | Allowed references | Literal | +|---|---|---|---| +| `GalaxyMxGateway` | `Gateway.ApiKeySecretRef` | `secret:galaxy/`, `secret:otopcua/galaxy/`, `env:OTOPCUA_GALAXY_`, legacy `env:GALAXY_MXGW_` | allowed (warns), `dev:` | +| `GalaxyMxGateway` | `AlarmAck.FallbackPasswordSecretRef` | same | `dev:` only | +| `OpcUaClient` | `Password`, `UserCertificatePassword` | `secret:opcuaclient/`, `env:OTOPCUA_OPCUACLIENT_` | allowed | +| `Heidenhain` | `InspectPassword`, `DncPassword`, `DataPassword` | `secret:heidenhain/`, `env:OTOPCUA_HEIDENHAIN_` (#803) | refused | + +Rules (all drivers): the prefixes are lower-case and exact (`SECRET:` / `Env:` are refused, never treated +as a literal); `${secret:}` is accepted as a spelling of `secret:`; secret names are +`[A-Za-z0-9._-]` segments with no empty, `.` or `..` segment; the variable suffix is `[A-Z0-9_]+`; **`file:` is +refused everywhere** (Galaxy used to read any file the node could open). A field is matched at any depth of +the DriverConfig / DeviceConfig, case-insensitively, as the drivers bind it. + +Enforced, fail-closed, at every seam — the earlier ones exist for a clear message, the last one is the guarantee: + +1. **AdminUI save** — `DriverConfigSaveGate` (`RawTreeService` driver/device create + update): the config is + never stored. +2. **Deploy gate** — `CredentialReferenceDeployChecks`, code **`CredentialReferenceOutOfScope`** (Heidenhain keeps + `HeidenhainCredentialPersisted`). +3. **Test Connect** — `AdminOperationsActor` refuses before any probe runs (`"Test Connect refused: …"`). +4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens. +5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` (driver + + browser) and `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope + **before** any secret-store or environment read. + +Messages name the field and the allowed forms, never the value. Out of scope by design: `${secret:}` tokens in +`appsettings.json` (host config, not operator-authored), and the `Sql` driver's `connectionStringRef`, which is +already a fixed prefix (`Sql__ConnectionStrings__`) whose value carries its own server — a Designer can +choose *which* provisioned database a ref names, but cannot redirect its credentials to another host. + +**Migration (existing configs).** After the upgrade, a stored config with an out-of-namespace reference fails +its next deploy (`CredentialReferenceOutOfScope`), its Test Connect / browse, and — for a running driver — its +next (re)initialise, which faults the driver with the same message. To migrate: + +- **Galaxy API key** — `env:GALAXY_MXGW_API_KEY` (the docker-dev rig and docs' name) keeps working. Any other + variable: rename it to `OTOPCUA_GALAXY_` on every node that runs the driver *and* the admin nodes + (Test Connect / browse), and set `env:OTOPCUA_GALAXY_`. A store secret already named `galaxy/…` keeps + working; otherwise re-create it as `galaxy//apikey` (`/admin/secrets` or the `secret` CLI). A + `file:` key must move into the store or a namespaced variable. +- **Galaxy fallback ack password** — the documented `secret:otopcua/galaxy/…` name keeps working; `env:` / `file:` + references move as above. +- **OpcUaClient** — re-create `secret:` as `opcuaclient/` (and update the field), or move an + environment value to `OTOPCUA_OPCUACLIENT_`. Literal passwords are unaffected. + +Residual (accepted): within one namespace, a Designer can still point one driver instance at another +instance's secret (e.g. `secret:galaxy//apikey`) — the value then reaches the endpoint the Designer +chose. Per-instance scoping is a possible follow-up. + ### Role grant source Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI. diff --git a/docs/v2/driver-specs.md b/docs/v2/driver-specs.md index 2501b1a8c..59bcc45e4 100644 --- a/docs/v2/driver-specs.md +++ b/docs/v2/driver-specs.md @@ -51,7 +51,7 @@ Per `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs`: { "Gateway": { "Endpoint": "http://localhost:5120", - "ApiKeySecretRef": "secret:galaxy-gw-api-key", + "ApiKeySecretRef": "secret:galaxy/gw/apikey", "UseTls": true, "CaCertificatePath": null, "ConnectTimeoutSeconds": 10, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs index 56fff75ae..bca223edd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs @@ -64,9 +64,11 @@ public sealed record GalaxyDriverOptions( /// /// /// -/// must be a reference — secret:NAME (production), -/// env:NAME, file:PATH or dev:VALUE (rigs only). A bare literal is rejected so a -/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB. +/// must be a Galaxy-namespaced reference (lmxopcua#815) — +/// secret:galaxy/NAME / secret:otopcua/galaxy/NAME (production), env:OTOPCUA_GALAXY_NAME +/// / env:GALAXY_MXGW_NAME, or dev:VALUE (rigs only). A bare literal, file: and any other +/// reference are rejected so a Galaxy password never sits in cleartext in the config DB and the field can +/// never name another subsystem's secret. /// /// /// Galaxy user the fallback acknowledges as; null/empty = no fallback. @@ -84,20 +86,16 @@ public sealed record GalaxyAlarmAckOptions( /// /// Connection details for the MxAccess gateway. is -/// resolved by at InitializeAsync time. Five forms -/// supported, in priority order: +/// resolved by at InitializeAsync time. References are confined +/// to the Galaxy namespace (lmxopcua#815 — the key is sent to the authored Endpoint): /// -/// env:NAME — read from an environment variable (recommended for -/// production; the central config DB holds only the indirection, not the key). -/// file:PATH — read from an ACL'd file outside the repo. -/// secret:NAME — resolved through the shared ZB.MOM.WW.Secrets +/// secret:galaxy/NAME (or secret:otopcua/galaxy/NAME) — the shared ZB.MOM.WW.Secrets /// encrypted store; fail-closed if absent (the production path). -/// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; -/// no startup warning. -/// Anything else — treated as a literal cleartext API key for back-compat. -/// The resolver emits a Warning at startup so an operator who accidentally -/// committed a cleartext key sees it; production should migrate to env: -/// or file:. +/// env:OTOPCUA_GALAXY_NAME (or legacy env:GALAXY_MXGW_NAME) — an environment variable. +/// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; no startup warning. +/// Any other unprefixed string — a literal cleartext API key for back-compat, with a startup +/// Warning. +/// Any other secret:/env: reference, and every file: reference — refused. /// /// // PR 6.5 tuning notes: From 2b0eed1a8f71275d7c9040ffd7175ba141c7394e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:37:41 -0500 Subject: [PATCH 09/16] feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER, ALARM:CLASS=[/GROUP=][/NUMBER=] ( = n | lo..hi; negative numbers allowed). An ALARM: address and the cross-driver alarm object only work together — the factory refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription. - HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath, driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag). FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text, deterministic display order, told-record on the driver instance, recorded only while a handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear. - Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture); Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists. - Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged. - IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic. - AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM: tags at Boolean and seeds/drops the alarm object with the selector; driver form gains "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.) Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530 alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery), Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver. --- .claude/rules/drivers.md | 11 +- .../IAlarmSource.cs | 2 +- .../HeidenhainAddress.cs | 20 +- .../HeidenhainAlarmSelector.cs | 191 +++++++++ .../HeidenhainCatalog.cs | 6 + .../HeidenhainDriverOptions.cs | 7 + .../HeidenhainOptionsParser.cs | 2 +- .../HeidenhainTagDefinitionFactory.cs | 69 +++- .../HeidenhainAlarmProjection.cs | 287 +++++++++++++ .../HeidenhainDeviceRuntime.cs | 49 ++- .../HeidenhainDriver.cs | 107 ++++- .../HeidenhainSession.cs | 25 +- .../Drivers/Forms/HeidenhainDriverForm.razor | 4 + .../Forms/HeidenhainDriverFormModel.cs | 17 +- .../HeidenhainTagConfigEditor.razor | 11 +- .../TagEditors/HeidenhainTagConfigModel.cs | 32 ++ .../Uns/TagEditors/NativeAlarmModel.cs | 2 +- .../DraftValidatorHeidenhainTests.cs | 15 + .../HeidenhainAlarmTests.cs | 384 ++++++++++++++++++ .../Uns/NativeAlarmEditorModelTests.cs | 2 +- .../ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj | 3 + .../HeidenhainNativeAlarmEndToEndTests.cs | 164 ++++++++ 22 files changed, 1379 insertions(+), 31 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainAlarmTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests.cs diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 1c2dfd3e1..e2c058b0e 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -61,7 +61,9 @@ design treated it as an implementation detail. ⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT, -MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an +MTConnect and MQTT raise natively and are unaffected. Heidenhain (#831) raises on the event-driven shape this +recommends: a reconnect whose `R_VR` identity (model / NC / PLC version / options) differs from the previous session — +no timer, no extra request. If that coverage matters later, reach for an event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`, `DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` / @@ -319,6 +321,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. +- **Heidenhain alarms (#831):** an `ALARM:` selector (`*`, `OTHER`, `CLASS=[/GROUP=][/NUMBER=]`, `` = `n` | + `lo..hi`) + the `alarm` object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save + path/CSV/deploy agree; data type fixed `Boolean`). Opt-in per tag, no driver flag (FOCAS #647 precedent); `Alarms/*` + arrays unchanged. Walked on `Polling.AlarmMs` (2 s) by the control's single loop with the SAME `R_RI 27/28` — the + allowlist is unchanged. FOCAS transition rules; identity = class+group+number+TEXT (numbers repeat, `-1`). A failed walk + (refusal/overflow/comms) is quality-only, never a clear. ⚠️ Ack is LOCAL-ONLY (`AcknowledgeAsync` no-op success) — + never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented names or severities. - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 084e54a7d..707a40fa7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// Driver capability for alarm events. Optional — only drivers whose backends expose /// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS /// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect -/// (CONDITION data items). +/// (CONDITION data items), AbCip (ALMD/ALMA), Heidenhain (LSV2 controller alarm list, #831). /// public interface IAlarmSource { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs index 6dcbe5def..4c719b996 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs @@ -30,6 +30,12 @@ public enum HeidenhainAddressKind /// A data-path value read via R_DP (DATA role). DataPath, + + /// + /// An ALARM: selector — a native Part 9 condition driven from the alarm list (R_RI 27/28), never a + /// value (lmxopcua#831). Requires the cross-driver alarm object on the tag. + /// + Alarm, } /// A parsed, validated Heidenhain tag address. @@ -39,13 +45,15 @@ public enum HeidenhainAddressKind /// The PLC area, for . /// The PLC element index (not an absolute address), for . /// The data path as authored, for . +/// The alarm selector, for . public sealed record HeidenhainAddress( HeidenhainAddressKind Kind, string Text, HeidenhainCatalogEntry? Catalog = null, HeidenhainPlcArea? Area = null, long Index = 0, - string? DataPath = null) + string? DataPath = null, + HeidenhainAlarmSelector? AlarmSelector = null) { /// Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header). public const int MaxPayload = 247; @@ -117,6 +125,14 @@ public sealed record HeidenhainAddress( return false; } + if (t.StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal)) + { + if (!HeidenhainAlarmSelector.TryParse(t[HeidenhainAlarmSelector.Prefix.Length..], out var selector, out error)) return false; + address = new HeidenhainAddress(HeidenhainAddressKind.Alarm, HeidenhainAlarmSelector.Prefix + selector.Canonical, + AlarmSelector: selector); + return true; + } + if (t.StartsWith(DataPrefix, StringComparison.Ordinal)) { var path = t[DataPrefix.Length..]; @@ -157,7 +173,7 @@ public sealed record HeidenhainAddress( return true; } - error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding."; + error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding, a DATA:/path binding or an ALARM: selector."; return false; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs new file mode 100644 index 000000000..7fb8c0823 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs @@ -0,0 +1,191 @@ +using System.Globalization; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// Which controller alarms an ALARM: address selects. +public enum HeidenhainAlarmSelectorKind +{ + /// ALARM:* — any active alarm on the tag's control (a device summary condition). + Any, + + /// + /// ALARM:OTHER — active alarms that no selector on the same control matches (the + /// "unmatched" bucket). and other tags do not claim an alarm. + /// + Other, + + /// ALARM:CLASS=7, ALARM:CLASS=7/GROUP=4, ALARM:NUMBER=100..199, … + Filter, +} + +/// An inclusive integer range; == for a single value. +/// Lowest matched value. +/// Highest matched value (inclusive). +public readonly record struct HeidenhainAlarmRange(long Low, long High) +{ + /// Whether lies in the range. + /// The value. + /// True when matched. + public bool Contains(long value) => value >= Low && value <= High; + + /// + public override string ToString() => Low == High + ? Low.ToString(CultureInfo.InvariantCulture) + : $"{Low.ToString(CultureInfo.InvariantCulture)}..{High.ToString(CultureInfo.InvariantCulture)}"; +} + +/// +/// The selector half of an ALARM: Heidenhain address (lmxopcua#831): which entries of the control's +/// active-alarm list (R_RI 27 then 28 — class, group, number, text) drive the ONE Part 9 condition +/// the raw tag is materialised as. +/// +/// +/// Grammar of the text after ALARM: (keys case-insensitive, no whitespace): +/// +/// FormMatches +/// *any active alarm on the control +/// OTHERalarms no filter selector on the control matches +/// CLASS=<r>, GROUP=<r>, NUMBER=<r>, joined by / +/// every named field in its range (each key at most once) +/// +/// A range <r> is n or lo..hi (inclusive; .. rather than - because +/// alarm numbers can be negative — a real iTNC 530 reports -1). CLASS and GROUP are the wire's uint16 +/// fields (0–65535); NUMBER is int32. +/// No semantic names. The meaning of the class/group codes is not documented in the reference +/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed: +/// observe the raw values on Alarms/Classes / Alarms/Groups or in a condition message, then filter. +/// One authority. Backs the AdminUI editor, the deploy inspector and the driver's table build. +/// +/// The selector kind. +/// The class range, or null for any class (filter only). +/// The group range, or null for any group (filter only). +/// The number range, or null for any number (filter only). +public sealed record HeidenhainAlarmSelector( + HeidenhainAlarmSelectorKind Kind, + HeidenhainAlarmRange? Class = null, + HeidenhainAlarmRange? Group = null, + HeidenhainAlarmRange? Number = null) +{ + /// Address prefix of an alarm condition. + public const string Prefix = "ALARM:"; + + /// The * body. + public const string AnyToken = "*"; + + /// The OTHER body. + public const string OtherToken = "OTHER"; + + /// The canonical text after ALARM:. + public string Canonical => Kind switch + { + HeidenhainAlarmSelectorKind.Any => AnyToken, + HeidenhainAlarmSelectorKind.Other => OtherToken, + _ => string.Join("/", new[] + { + Class is { } c ? $"CLASS={c}" : null, + Group is { } g ? $"GROUP={g}" : null, + Number is { } n ? $"NUMBER={n}" : null, + }.Where(s => s is not null)), + }; + + /// + /// Whether this selector matches one alarm on its own terms. + /// answers false here — "unmatched" depends on the other selectors on the control, which the caller evaluates. + /// + /// The alarm's class. + /// The alarm's group. + /// The alarm's number. + /// True when selected. + public bool Matches(int errorClass, int group, int number) => Kind switch + { + HeidenhainAlarmSelectorKind.Any => true, + HeidenhainAlarmSelectorKind.Other => false, + _ => (Class is not { } c || c.Contains(errorClass)) + && (Group is not { } g || g.Contains(group)) + && (Number is not { } n || n.Contains(number)), + }; + + /// Parses the text after ALARM:. Never throws. + /// The selector text. + /// The selector on success. + /// Why it was rejected. + /// True when valid. + public static bool TryParse(string? body, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAlarmSelector? selector, out string? error) + { + selector = null; + error = null; + var t = body?.Trim() ?? ""; + if (t.Length == 0) + { + error = Grammar("an alarm selector is required"); + return false; + } + + if (t == AnyToken) + { + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Any); + return true; + } + + if (string.Equals(t, OtherToken, StringComparison.OrdinalIgnoreCase)) + { + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Other); + return true; + } + + HeidenhainAlarmRange? cls = null, grp = null, num = null; + foreach (var segment in t.Split('/')) + { + var eq = segment.IndexOf('='); + if (eq <= 0) + { + error = Grammar($"'{segment}' is not KEY=VALUE"); + return false; + } + + var key = segment[..eq].ToUpperInvariant(); + var value = segment[(eq + 1)..]; + var (min, max) = key == "NUMBER" ? ((long)int.MinValue, (long)int.MaxValue) : (0L, 65535L); + if (key is not ("CLASS" or "GROUP" or "NUMBER")) + { + error = Grammar($"unknown key '{segment[..eq]}'"); + return false; + } + + if (!TryParseRange(value, min, max, out var range)) + { + error = Grammar($"{key} value '{value}' must be an integer {min}–{max} or lo..hi with lo ≤ hi"); + return false; + } + + ref var slot = ref key == "CLASS" ? ref cls : ref key == "GROUP" ? ref grp : ref num; + if (slot is not null) + { + error = Grammar($"{key} appears twice"); + return false; + } + + slot = range; + } + + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Filter, cls, grp, num); + return true; + } + + private static bool TryParseRange(string text, long min, long max, out HeidenhainAlarmRange range) + { + range = default; + var dots = text.IndexOf("..", StringComparison.Ordinal); + var loText = dots < 0 ? text : text[..dots]; + var hiText = dots < 0 ? text : text[(dots + 2)..]; + if (!long.TryParse(loText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var lo) + || !long.TryParse(hiText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var hi)) + return false; + if (lo < min || hi > max || lo > hi) return false; + range = new HeidenhainAlarmRange(lo, hi); + return true; + } + + private static string Grammar(string why) => + $"Invalid alarm selector: {why}. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=[/GROUP=][/NUMBER=|..]."; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs index 5cef4a304..e76775e47 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs @@ -13,6 +13,9 @@ public enum HeidenhainPollGroup /// Identity, tool, alarms, timers and capability strings (Polling.SlowMs). Slow, + + /// The alarm-list walk behind ALARM: Part 9 conditions (Polling.AlarmMs, lmxopcua#831). + Alarm, } /// One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it. @@ -50,6 +53,9 @@ public static class HeidenhainCatalog /// Feature id reported for the optional DNC login. public const string DncLoginFeature = "DncLogin"; + /// Feature id of the alarm-list walk (R_RI 27/28) — shared by the Alarms/* leaves and ALARM: conditions. + public const string AlarmsFeature = "Alarms"; + /// Feature id reported for the opt-in DATA login. public const string DataLoginFeature = "DataLogin"; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs index 880505ad2..852f4182a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs @@ -107,6 +107,13 @@ public sealed class HeidenhainPollingOptions /// Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms. public int SlowMs { get; init; } = 10000; + /// + /// Alarm-condition group: the alarm-list walk that drives authored ALARM: Part 9 conditions + /// (lmxopcua#831). Default 2000 ms (the FOCAS alarm-poll precedent). Only polled for a control that carries an + /// ALARM: tag; the Alarms/* typed arrays stay on . + /// + public int AlarmMs { get; init; } = 2000; + /// Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64. public int MaxAlarms { get; init; } = 64; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs index 925b4735c..82f94166c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs @@ -110,7 +110,7 @@ public static class HeidenhainOptionsParser var errors = new List(); if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms."); var p = o.Polling; - foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) }) + foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs), ("AlarmMs", p.AlarmMs) }) if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms."); if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256."); var r = o.Reconnect; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs index a3c774701..aa2929acd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs @@ -9,15 +9,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// The parsed address. /// The OPC data type the driver publishes. /// Whether the value is a 1-D array. +/// +/// The cross-driver alarm object, present exactly when is an ALARM: selector +/// (lmxopcua#831); the tag is then a Part 9 condition, never a value. +/// public sealed record HeidenhainTagDefinition( - string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray) + string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray, + HeidenhainTagAlarm? Alarm = null) { /// The poll cadence this tag rides. - public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard - ? Address.Catalog!.Group - : HeidenhainPollGroup.Custom; + public HeidenhainPollGroup Group => Address.Kind switch + { + HeidenhainAddressKind.Standard => Address.Catalog!.Group, + HeidenhainAddressKind.Alarm => HeidenhainPollGroup.Alarm, + _ => HeidenhainPollGroup.Custom, + }; + + /// True for an ALARM: condition tag. + public bool IsAlarmCondition => Address.Kind == HeidenhainAddressKind.Alarm; } +/// The driver's view of a tag's cross-driver alarm object. +/// The authored integer alarm.severity, or null when absent / not an integer. +public sealed record HeidenhainTagAlarm(int? Severity); + /// /// The single authority on a Heidenhain raw tag's TagConfig: consumed by the driver's table build, /// the deploy-time gate () and the AdminUI typed editor's Validate(), so the @@ -29,6 +44,10 @@ public sealed record HeidenhainTagDefinition( /// {"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}. Array leaves /// (Axes/*, Alarms/Classes|Groups|Numbers|Messages) must carry "isArray": true so the /// node's rank matches what the driver publishes; every other address must not. +/// Alarm conditions (lmxopcua#831): {"address": "ALARM:CLASS=7", "dataType": "Boolean", +/// "alarm": {...}}. An ALARM: address and the alarm object only work together — either alone is +/// refused (a condition nothing can raise, or a selector with no value to read). The data type is fixed at +/// Boolean. /// Never a write flag. There is no writable key: the driver is read-only and the deploy /// gate refuses a ReadWrite access level on any Heidenhain tag. /// Data types are parsed by NAME only. A digits-only value (a numerically-serialized enum) is @@ -59,6 +78,7 @@ public static class HeidenhainTagDefinitionFactory string? dataTypeText; bool dataTypePresent; bool? isArray; + HeidenhainTagAlarm? alarm; try { using var doc = JsonDocument.Parse(tagConfig); @@ -95,6 +115,15 @@ public static class HeidenhainTagDefinitionFactory } } + alarm = null; + // Exact-case, exactly as the host's TagConfigIntent reads it: the condition exists iff the host sees it. + if (root.TryGetProperty("alarm", out var a) && a.ValueKind == JsonValueKind.Object) + { + int? severity = a.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.Number + && sev.TryGetInt32(out var sv) ? sv : null; + alarm = new HeidenhainTagAlarm(severity); + } + if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True) { error = "Heidenhain tags are read-only; remove \"writable\": true."; @@ -121,9 +150,34 @@ public static class HeidenhainTagDefinitionFactory declared = parsed; } + if (address.Kind == HeidenhainAddressKind.Alarm && alarm is null) + { + error = $"'{address.Text}' is an alarm selector — enable the tag's native alarm (the 'alarm' object); " + + "an ALARM: tag is a Part 9 condition and has no value to read."; + return false; + } + + if (address.Kind != HeidenhainAddressKind.Alarm && alarm is not null) + { + error = $"The tag carries an 'alarm' object but '{address.Text}' is not an ALARM: selector — nothing could " + + "ever raise the condition. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=[/GROUP=][/NUMBER=|..]."; + return false; + } + DriverDataType type; bool array; - if (address.Kind == HeidenhainAddressKind.Standard) + if (address.Kind == HeidenhainAddressKind.Alarm) + { + if (declared is { } d && d != AlarmConditionDataType) + { + error = $"An ALARM: condition tag is {AlarmConditionDataType}; dataType {d} does not match."; + return false; + } + + type = AlarmConditionDataType; + array = false; + } + else if (address.Kind == HeidenhainAddressKind.Standard) { var entry = address.Catalog!; if (declared is { } d && d != entry.DataType) @@ -172,10 +226,13 @@ public static class HeidenhainTagDefinitionFactory return false; } - definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array); + definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array, alarm); return true; } + /// The fixed Tag.DataType of an ALARM: condition tag (active / inactive). + public const DriverDataType AlarmConditionDataType = DriverDataType.Boolean; + /// /// Deploy-time inspection: empty when accepts the blob (never stricter), /// otherwise one warning explaining the rejection. Never throws. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs new file mode 100644 index 000000000..53e6c7c86 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs @@ -0,0 +1,287 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// An authored ALARM: raw tag the driver drives a Part 9 condition for. +/// The tag's RawPath — the ConditionId, the only key the host routes on. +/// The control (host:port) whose alarm list the tag selects from. +/// Which alarms on that control drive the condition. +/// The authored alarm.severity, or null. +internal sealed record HeidenhainAlarmTag(string RawPath, string HostName, HeidenhainAlarmSelector Selector, int? AuthoredSeverity); + +/// +/// A controller alarm's identity: class, group, number AND text. The number alone is not an identity — a real +/// iTNC 530 reports -1 for (at least) "Run test of cutout channels" — so two different texts under one +/// number are two alarms. +/// +internal readonly record struct HeidenhainAlarmKey(int Class, int Group, int Number, string Message) +{ + public static HeidenhainAlarmKey Of(Lsv2Alarm a) => new(a.ErrorClass, a.Group, a.Number, (a.Message ?? "").Trim()); +} + +/// What one condition last told the server — the diff baseline (re-subscribe / reconnect never re-raise). +internal sealed record HeidenhainToldCondition( + bool Active, + IReadOnlySet Keys, + HeidenhainAlarmKey? Winner, + string Message, + AlarmSeverity Severity, + int? OpcUaSeverity); + +/// +/// Pure selection + transition rules for Heidenhain alarm conditions (lmxopcua#831) — the FOCAS #647 semantics +/// applied to the LSV2 alarm list. +/// +/// +/// +/// Overlap is intended: one alarm drives every selector that matches it (* and +/// CLASS=7). OTHER is what no FILTER selector on the same control claims. +/// Displayed alarm is deterministic — ascending (class, group, number, text), never wire order, +/// so a reordered unchanged list cannot re-raise. +/// Transitions: inactive → active = Raise; a key not in the told set appears, or the +/// displayed alarm leaves while others remain = Retrigger (un-acknowledges: a statement the operator +/// has not seen); a non-displayed member leaves = nothing; nothing matches = Clear. +/// Severity: an authored integer alarm.severity is sent exactly (OpcUaSeverity); +/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no +/// class→severity table is invented. +/// +/// +internal static class HeidenhainAlarmRules +{ + /// The every Heidenhain condition event carries. + public const string AlarmType = "HeidenhainAlarm"; + + /// The alarms one condition matches, in display order (distinct by key). + public static IReadOnlyList Evaluate( + HeidenhainAlarmSelector selector, IReadOnlyList alarms, IReadOnlyCollection deviceSelectors) + { + var filters = selector.Kind == HeidenhainAlarmSelectorKind.Other + ? deviceSelectors.Where(s => s.Kind == HeidenhainAlarmSelectorKind.Filter).ToList() + : null; + return alarms + .Where(a => filters is null + ? selector.Matches(a.ErrorClass, a.Group, a.Number) + : !filters.Any(f => f.Matches(a.ErrorClass, a.Group, a.Number))) + .Select(HeidenhainAlarmKey.Of) + .Distinct() + .OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number) + .ThenBy(k => k.Message, StringComparer.Ordinal) + .ToList(); + } + + /// The event moving the server from to , and the new record. + public static (AlarmEventArgs? Event, HeidenhainToldCondition Told) Transition( + HeidenhainToldCondition? told, IReadOnlyList matched, HeidenhainAlarmTag tag, + IAlarmSubscriptionHandle handle, DateTime nowUtc) + { + var keys = matched.ToHashSet(); + if (matched.Count == 0) + { + if (told is not { Active: true }) + return (null, told ?? new HeidenhainToldCondition(false, keys, null, "", AlarmSeverity.Low, null)); + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}", told.Severity, told.OpcUaSeverity), + told with { Active = false, Keys = keys, Winner = null }); + } + + var winner = matched[0]; + var (bucket, exact) = tag.AuthoredSeverity is { } s ? (BucketOf(s), (int?)s) : (AlarmSeverity.High, null); + var message = MessageOf(matched); + var raised = new HeidenhainToldCondition(true, keys, winner, message, bucket, exact); + if (told is not { Active: true }) + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised); + if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner) + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised); + return (null, told with { Keys = keys }); + } + + /// The nearest bucket of an exact 1..1000 severity. + public static AlarmSeverity BucketOf(int severity) => severity switch + { + < 400 => AlarmSeverity.Low, + < 600 => AlarmSeverity.Medium, + < 800 => AlarmSeverity.High, + _ => AlarmSeverity.Critical, + }; + + /// -1 Run test of cutout channels (class 7, group 4) [+2 more]. + public static string MessageOf(IReadOnlyList matched) + { + var w = matched[0]; + var b = new StringBuilder(); + b.Append(w.Number); + if (w.Message.Length > 0) b.Append(' ').Append(w.Message); + b.Append(" (class ").Append(w.Class).Append(", group ").Append(w.Group).Append(')'); + if (matched.Count > 1) b.Append(" [+").Append(matched.Count - 1).Append(" more]"); + return b.ToString(); + } + + public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc, + AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) => + new(handle, + SourceNodeId: tag.HostName, + ConditionId: tag.RawPath, + AlarmType: AlarmType, + Message: message, + Severity: bucket, + SourceTimestampUtc: nowUtc, + Kind: kind, + OpcUaSeverity: exact, + QualityStatusCode: quality); +} + +/// +/// Drives one Part 9 condition per subscribed ALARM: raw tag from the alarm walks the device loops already +/// perform (lmxopcua#831). There is NO extra connection and NO extra request type: the walk is the same +/// allowlisted R_RI 27/28 the Alarms/* arrays use, issued by the control's single acquisition loop on +/// the Polling.AlarmMs cadence. +/// +/// +/// Lives as long as the driver (not re-created on re-initialise), so the told record survives a +/// reconnect, a same-set re-subscribe and an in-place re-init — re-raising there would un-acknowledge. A changed +/// subscribed set prunes only the RawPaths that left it. +/// A failed walk is not "no alarms". A refusal, a list overflow, a decode error or comms loss keeps +/// every condition's Active/Acked state and annotates its quality once (quality-only event, no /alerts +/// row); the next good walk lifts it to Good before diffing. Only a down session degrades driver health (the +/// runtime's rule) — a refusal never does, or the #527 watchdog would reconnect-loop a production control. +/// Read-only acknowledgement. LSV2 has no acknowledge request in the allowlist, and none is added: +/// is the no-op success the host routes a Part 9 Acknowledge to +/// (the FOCAS / MTConnect posture) — the ack is applied server-side only; the control clears an alarm when its +/// cause is reset. Confirm / shelve / enable are refused by the host for every native condition. +/// Recorded only while a handler is attached (the host detaches while it reconnects), so the re-subscribe +/// that follows still sees the difference. Delivery runs under ; the only production handler +/// is DriverInstanceActor's non-blocking Tell. +/// +internal sealed class HeidenhainAlarmProjection(object sender, Func?> handlers, ILogger logger) +{ + private readonly Lock _lock = new(); + private readonly Dictionary _told = new(StringComparer.Ordinal); + private readonly Dictionary _qualityTold = new(StringComparer.Ordinal); + private readonly Dictionary _failing = new(StringComparer.Ordinal); + private IReadOnlyDictionary _tags = new Dictionary(); + private Subscription? _current; + private long _nextId; + + /// The authored alarm tags (RawPath → tag). Swapped whole on every table build. + public IReadOnlyDictionary Tags + { + get => Volatile.Read(ref _tags); + set => Volatile.Write(ref _tags, value); + } + + /// The live subscription (test seam). + internal Subscription? Current => Volatile.Read(ref _current); + + public IAlarmSubscriptionHandle Subscribe(IReadOnlyList sourceNodeIds) + { + ArgumentNullException.ThrowIfNull(sourceNodeIds); + var refs = sourceNodeIds.Where(r => !string.IsNullOrWhiteSpace(r)).ToHashSet(StringComparer.Ordinal); + var sub = new Subscription(new HeidenhainAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs); + lock (_lock) + { + foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone); + // A (re)subscribe follows a host-side reconnect, where the host re-annotates its conditions; forget which + // ones this projection marked, so a still-failing control re-asserts its quality on the next walk. + _qualityTold.Clear(); + Volatile.Write(ref _current, sub); + } + + return sub.Handle; + } + + public void Unsubscribe(IAlarmSubscriptionHandle handle) + { + lock (_lock) + { + if (Volatile.Read(ref _current) is { } live && ReferenceEquals(live.Handle, handle)) Volatile.Write(ref _current, null); + } + } + + /// One alarm-walk outcome of (Good + list, or a non-Good status). + public void OnWalk(string hostName, uint status, IReadOnlyList? alarms) + { + var now = DateTime.UtcNow; + var tags = Tags; + lock (_lock) + { + if (Volatile.Read(ref _current) is not { } sub) return; + var deviceTags = tags.Values.Where(t => t.HostName == hostName).ToList(); + var subscribed = deviceTags.Where(t => sub.References.Contains(t.RawPath)).ToList(); + if (subscribed.Count == 0) return; + + if (status != HeidenhainStatus.Good || alarms is null) + { + if (!_failing.TryGetValue(hostName, out var was) || was != status) + { + _failing[hostName] = status; + logger.LogWarning( + "Heidenhain {Host}: alarm list unavailable ({Status}); its {Count} alarm condition(s) keep their state and are marked with that quality", + hostName, HeidenhainStatus.State(status), subscribed.Count); + } + } + else if (_failing.Remove(hostName)) + { + logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName); + } + + var h = handlers(); + if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again + + if (status != HeidenhainStatus.Good || alarms is null) + { + foreach (var tag in subscribed) + { + if (_qualityTold.TryGetValue(tag.RawPath, out var q) && q == status) continue; + _qualityTold[tag.RawPath] = status; + Deliver(h, QualityOnly(tag, sub.Handle, status, now)); + } + + return; + } + + var selectors = deviceTags.Select(t => t.Selector).ToList(); + foreach (var tag in subscribed) + { + // Lift a non-Good this projection put there BEFORE the diff, so recovery + a change reads Good then the transition. + if (_qualityTold.Remove(tag.RawPath)) Deliver(h, QualityOnly(tag, sub.Handle, HeidenhainStatus.Good, now)); + _told.TryGetValue(tag.RawPath, out var told); + var (evt, nowTold) = HeidenhainAlarmRules.Transition( + told, HeidenhainAlarmRules.Evaluate(tag.Selector, alarms, selectors), tag, sub.Handle, now); + _told[tag.RawPath] = nowTold; + if (evt is not null) Deliver(h, evt); + } + } + } + + private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now) + { + _told.TryGetValue(tag.RawPath, out var told); + return HeidenhainAlarmRules.Build(tag, handle, now, AlarmTransitionKind.Unspecified, told?.Message ?? "", + told?.Severity ?? AlarmSeverity.Low, told?.OpcUaSeverity, quality); + } + + private void Deliver(EventHandler h, AlarmEventArgs evt) + { + foreach (var registration in h.GetInvocationList()) + { + try { ((EventHandler)registration).Invoke(sender, evt); } + catch (Exception ex) + { + logger.LogWarning(ex, "Heidenhain alarm subscriber threw for {ConditionId}; delivery continues", evt.ConditionId); + } + } + } + + /// The live subscription: its handle and the ConditionIds (RawPaths) it names. + internal sealed record Subscription(HeidenhainAlarmSubscriptionHandle Handle, IReadOnlySet References); +} + +/// Handle returned by . +/// The driver-local subscription number. +public sealed record HeidenhainAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + /// + public string DiagnosticId => $"heidenhain-alarm-sub-{Id}"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs index cdfe9dbed..4950300db 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs @@ -26,6 +26,15 @@ public enum HeidenhainConnectionState /// Builds the client for an endpoint (test seam). internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout); +/// +/// The alarm-walk outcome for a control's ALARM: conditions (lmxopcua#831): Good +/// with the complete list, or a non-Good status (refusal, overflow, comms loss) with null — never "no alarms". +/// +internal delegate void HeidenhainAlarmSink(HeidenhainDeviceRuntime runtime, uint status, IReadOnlyList? alarms); + +/// A session's controller identity, reported once per established session (lmxopcua#831 rediscovery). +internal delegate void HeidenhainIdentitySink(HeidenhainDeviceRuntime runtime, Lsv2Versions identity); + /// /// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction /// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache @@ -54,6 +63,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private readonly Lsv2ClientFactory _clientFactory; private readonly TimeProvider _time; private readonly ILogger _logger; + private readonly HeidenhainAlarmSink? _alarms; + private readonly HeidenhainIdentitySink? _identity; + private readonly IReadOnlyList _alarmTags; private readonly object _publication = new(); private readonly object _lifecycle = new(); private readonly Dictionary _groupGood = new(); @@ -81,7 +93,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable Action stateChanged, Lsv2ClientFactory clientFactory, TimeProvider time, - ILogger logger) + ILogger logger, + HeidenhainAlarmSink? alarms = null, + HeidenhainIdentitySink? identity = null) { _device = device; _options = HeidenhainOptionsParser.Normalize(options); @@ -92,6 +106,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable _clientFactory = clientFactory; _time = time; _logger = logger; + _alarms = alarms; + _identity = identity; + _alarmTags = tags.Where(t => t.IsAlarmCondition).ToList(); _stateChangedUtc = time.GetUtcNow().UtcDateTime; _groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } @@ -220,6 +237,12 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable SetState(HeidenhainConnectionState.Connected, null); _logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model); + if (session.Identity is { } identity && _identity is not null) + { + try { _identity(this, identity); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} identity handler threw", HostName); } + } + await PollAsync(session, generation, ct).ConfigureAwait(false); // Poll returns only on session loss. Retire(generation, "LSV2 communication lost"); @@ -254,6 +277,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable [HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)), [HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)), [HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)), + [HeidenhainPollGroup.Alarm] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.AlarmMs)), }; var start = _time.GetTimestamp(); var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero); @@ -267,7 +291,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable while (!ct.IsCancellationRequested) { var elapsed = _time.GetElapsedTime(start); - foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow }) + foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Alarm, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow }) { if (!due.TryGetValue(group, out var at) || at > elapsed) continue; var tags = _groups[group]; @@ -291,9 +315,19 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable if (generation != _generation || !ReferenceEquals(_session, session)) return false; if (session.CommunicationLost) return false; var good = true; + var alarmsDelivered = false; for (var i = 0; i < tags.Count; i++) { var s = samples[i]; + if (tags[i].IsAlarmCondition) + { + // One alarm walk per batch serves every ALARM: condition of this control (they share the outcome). + if (!alarmsDelivered) DeliverAlarms(s.Status, s.Status == HeidenhainStatus.Good ? s.Value as IReadOnlyList : null); + alarmsDelivered = true; + if (s.Status != HeidenhainStatus.Good) good = false; + continue; + } + _publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now)); if (tags[i].Address.Catalog?.IsCapability == true) continue; // Explicitly disabled optional timers are not partial data. @@ -341,14 +375,25 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private void Invalidate(DateTime now) { + if (_alarmTags.Count > 0) DeliverAlarms(HeidenhainStatus.BadNoCommunication, null); foreach (var tag in _tags) { + if (tag.IsAlarmCondition) continue; _publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true ? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now) : new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now)); } } + /// Hands an alarm-walk outcome to the projection. Called under (ordered with the generation). + private void DeliverAlarms(uint status, IReadOnlyList? alarms) + { + if (_alarms is null) return; + if (status == HeidenhainStatus.Good && alarms is null) status = HeidenhainStatus.BadDecodingError; + try { _alarms(this, status, status == HeidenhainStatus.Good ? alarms : null); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} alarm projection threw", HostName); } + } + private void SetState(HeidenhainConnectionState next, string? error) { HeidenhainConnectionState old; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index 65e9ce831..8f5980ac8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// dedup (subscriptions dedup on value AND status). /// public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, - IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable + IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAlarmSource, IRediscoverable, IAsyncDisposable { /// Most custom (PLC/DATA) bindings one control may carry (reference limit). public const int MaxCustomBindingsPerDevice = 256; @@ -45,6 +45,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi private readonly object _publishLock = new(); private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _subscriptions = new(); + + /// + /// The ALARM: condition projection (lmxopcua#831). Lives as long as the driver — NOT re-created per + /// initialise — so its told record survives a reconnect and an in-place re-init (re-raising would un-ack). + /// + private readonly HeidenhainAlarmProjection _alarms; + + /// + /// Last controller identity seen per control (host:port). Driver-lifetime, so a re-initialise does not + /// forget the baseline; an identity that differs on a later session raises . + /// + private readonly ConcurrentDictionary _identities = new(StringComparer.Ordinal); private HeidenhainDriverOptions _options; private IReadOnlyDictionary _byRawPath = new Dictionary(); private IReadOnlyList _devices = []; @@ -75,6 +87,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi _credentials = credentials; _clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t)); _time = time ?? TimeProvider.System; + _alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger); } /// @@ -89,6 +102,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi /// public event EventHandler? OnHostStatusChanged; + /// + public event EventHandler? OnAlarmEvent; + + /// + /// + /// Raised (advisory — the host shows a "re-browse" chip; nothing is re-materialised) when a control reconnects + /// reporting a different identity (R_VR: model, NC/PLC software version or option string) than the + /// previous session — a software update or option install can change what the control serves (selectors, + /// PLC layout). The first session of a control only records the baseline. No extra traffic: the identity is + /// the R_VR every session already reads at connect. + /// + public event EventHandler? OnRediscoveryNeeded; + /// Parses + validates a config document (the one authority the factory and the driver share). /// The config JSON. /// The options. @@ -138,6 +164,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var devices = _options.Devices ?? []; _skipped.BeginRebuild(_options.RawTags.Count); var perDevice = new Dictionary>(); + var alarmTags = new Dictionary(StringComparer.Ordinal); foreach (var d in devices) perDevice[d] = []; foreach (var raw in _options.RawTags) @@ -156,8 +183,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi } var list = perDevice[device]; - if (def.Address.Kind != HeidenhainAddressKind.Standard - && list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice) + if (def.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath + && list.Count(t => t.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath) >= MaxCustomBindingsPerDevice) { _skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control"); continue; @@ -187,17 +214,27 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var runtime = new HeidenhainDeviceRuntime( dev, _options, tags, (role, ct) => _credentials.ResolveAsync(_options, dev, role, ct), - Publish, OnRuntimeState, _clientFactory, _time, _logger); + Publish, OnRuntimeState, _clientFactory, _time, _logger, + (r, status, alarms) => _alarms.OnWalk(r.HostName, status, alarms), + OnIdentity); runtimes.Add(runtime); foreach (var t in tags) { byPath[t.RawPath] = runtime; + if (t.IsAlarmCondition) + { + // A condition, never a value: it never enters the value cache or a data subscription. + alarmTags[t.RawPath] = new HeidenhainAlarmTag(t.RawPath, runtime.HostName, t.Address.AlarmSelector!, t.Alarm?.Severity); + continue; + } + _cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now); } } _devices = runtimes; _byRawPath = byPath; + _alarms.Tags = alarmTags; } private static HeidenhainDeviceOptions? Route(IEnumerable devices, string deviceName) @@ -270,7 +307,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi for (var i = 0; i < fullReferences.Count; i++) result[i] = _cache.TryGetValue(fullReferences[i], out var s) ? s - : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); + : new DataValueSnapshot(null, Unknown(fullReferences[i]), null, now); return Task.FromResult>(result); } @@ -288,7 +325,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var now = _time.GetUtcNow().UtcDateTime; foreach (var r in sub.References) { - var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); + var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, Unknown(r), null, now); Raise(sub, r, snapshot); } } @@ -297,6 +334,10 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.FromResult(sub); } + /// Status for a reference with no cached value: an ALARM: condition has no value to read. + private uint Unknown(string reference) => + _alarms.Tags.ContainsKey(reference) ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.BadNodeIdUnknown; + /// public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { @@ -411,6 +452,60 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.CompletedTask; } + // ---- IAlarmSource (lmxopcua#831) ---- + + /// + /// + /// are the RawPaths of the alarm-bearing raw tags (the host's alarm set). + /// Collapses to ONE live subscription (the owning actor re-subscribes on every Connected entry). Starts the + /// control loops when nothing else had (an alarm-only driver); the first walk follows within + /// Polling.AlarmMs. + /// + public Task SubscribeAlarmsAsync(IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + var handle = _alarms.Subscribe(sourceNodeIds); + EnsureStarted(); + return Task.FromResult(handle); + } + + /// + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(handle); + _alarms.Unsubscribe(handle); + return Task.CompletedTask; + } + + /// + /// + /// Local-only. LSV2 has no acknowledge request, and the read-only allowlist () + /// is deliberately NOT widened for one: nothing is sent to the control. This is the no-op success the host routes + /// a Part 9 Acknowledge / AddComment to, so the ack is applied (and replicated) server-side — the FOCAS and + /// MTConnect posture. The control clears an alarm itself when its cause is reset. + /// + public Task AcknowledgeAsync(IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(acknowledgements); + return Task.CompletedTask; + } + + /// The alarm projection (test seam). + internal HeidenhainAlarmProjection AlarmProjection => _alarms; + + private void OnIdentity(HeidenhainDeviceRuntime runtime, Lsv2Versions identity) + { + var previous = _identities.GetValueOrDefault(runtime.HostName); + _identities[runtime.HostName] = identity; + if (previous is null || previous == identity) return; + var reason = $"Heidenhain control {runtime.HostName} reconnected with a different identity " + + $"({Describe(previous)} → {Describe(identity)}); re-browse the device and review its tags."; + _logger.LogWarning("{Reason}", reason); + try { OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, null)); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain rediscovery handler threw"); } + + static string Describe(Lsv2Versions v) => $"{v.Model} NC {v.Nc} PLC {v.Plc} options '{v.Options}'"; + } + /// public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs index 46b255b0d..0c2b831ed 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs @@ -187,9 +187,20 @@ internal sealed class HeidenhainSession : IAsyncDisposable return result; } - private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard - ? tag.Address.Catalog!.Feature - : "Custom|" + tag.Address.Text + "|" + tag.DataType; + private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind switch + { + HeidenhainAddressKind.Standard => tag.Address.Catalog!.Feature, + HeidenhainAddressKind.Alarm => HeidenhainCatalog.AlarmsFeature, + _ => "Custom|" + tag.Address.Text + "|" + tag.DataType, + }; + + /// True for a PLC/DATA binding (as opposed to a catalog feature or the alarm walk). + private static bool IsCustom(HeidenhainTagDefinition tag) => + tag.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath; + + /// The catalog feature a non-custom tag is acquired through. + private static string FeatureOf(HeidenhainTagDefinition tag) => + tag.Address.Kind == HeidenhainAddressKind.Alarm ? HeidenhainCatalog.AlarmsFeature : tag.Address.Catalog!.Feature; /// The current capability string for a feature. public string Capability(string feature) @@ -223,8 +234,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable private async Task AcquireAsync(HeidenhainTagDefinition tag, Dictionary wire, CancellationToken ct) { if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication); - var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard; - var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature; + var isCustom = IsCustom(tag); + var feature = isCustom ? tag.Address.Text : FeatureOf(tag); var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature; if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus); try @@ -251,7 +262,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable "Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)), "Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)), "Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)), - "Alarms" => await AlarmsAsync(ct).ConfigureAwait(false), + HeidenhainCatalog.AlarmsFeature => await AlarmsAsync(ct).ConfigureAwait(false), _ => await TimerAsync(feature, ct).ConfigureAwait(false), }; } @@ -419,6 +430,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable private static object? Leaf(HeidenhainTagDefinition tag, object? value) { + // An ALARM: condition receives the whole (immutable copy of the) list; the projection evaluates it. + if (tag.IsAlarmCondition) return value is List alarms ? alarms.ToArray() : null; if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value; var address = tag.Address.Catalog!.Address; var name = address[(address.IndexOf('/') + 1)..]; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor index 96faaa293..4390cb413 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -90,6 +90,10 @@
+
+ + +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs index a5717b33b..8ec2cd114 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs @@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel public int StatusMs { get; set; } = 1000; public int CustomMs { get; set; } = 1000; public int SlowMs { get; set; } = 10000; + public int AlarmMs { get; set; } = 2000; public int MaxAlarms { get; set; } = 64; public bool TimersEnabled { get; set; } public int ReconnectInitialMs { get; set; } = 1000; @@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel StatusMs = o.Polling.StatusMs, CustomMs = o.Polling.CustomMs, SlowMs = o.Polling.SlowMs, + AlarmMs = o.Polling.AlarmMs, MaxAlarms = o.Polling.MaxAlarms, TimersEnabled = o.Polling.TimersEnabled, ReconnectInitialMs = o.Reconnect.InitialDelayMs, @@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel ["StatusMs"] = StatusMs, ["CustomMs"] = CustomMs, ["SlowMs"] = SlowMs, + ["AlarmMs"] = AlarmMs, ["MaxAlarms"] = MaxAlarms, ["TimersEnabled"] = TimersEnabled, }; @@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel return _bag.ToJsonString(JsonOptions); } - /// The driver's own verdict on this config (device endpoints are validated on the device form). + /// + /// The driver's own verdict on this config's ranges (device endpoints are validated on the device form). + /// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting DriverConfigModal + /// shows the save gate's refusal (DriverConfigSaveGate, which scans every password field of the stored + /// document) once and disables Save — reporting it here too printed the same error twice. + /// public string? Validate() { var o = HeidenhainOptionsParser.Parse(ToJson()); var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions { - TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access, + TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + Access = new HeidenhainAccessOptions + { + TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled, + DataEnabled = o.Access.DataEnabled, + }, Polling = o.Polling, Reconnect = o.Reconnect, }); return errors.Count == 0 ? null : string.Join(" ", errors); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor index a5a44cb6c..c20f37718 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor @@ -12,13 +12,15 @@ + placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8 · ALARM:*" /> @foreach (var e in HeidenhainCatalog.Entries) { }
A fixed leaf, PLC:AREA:INDEX (element index into the control's advertised region; AREA one of - @string.Join(", ", HeidenhainAddress.AreaTokens)) or DATA:/path (needs DATA access enabled on the driver). + @string.Join(", ", HeidenhainAddress.AreaTokens)), DATA:/path (needs DATA access enabled on the driver), + or an alarm condition ALARM:* · ALARM:OTHER · + ALARM:CLASS=7[/GROUP=4][/NUMBER=100..199] (enable Native alarm below; acknowledgement is server-side only).
@@ -28,6 +30,11 @@
Fixed by the namespace catalog.
} + else if (_m.IsAlarmAddress) + { + +
A Part 9 condition (active / inactive), not a value.
+ } else { - @foreach (var dt in DataTypes) - { - - } - + @if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated) + { + @* lmxopcua#830: the driver dictates the type for this address — show it, locked, + rather than offer a choice the commit would replace. *@ + + } + else + { + + } +
Set by the address.
+ } + else + { + + @foreach (var dt in DataTypes) + { + + } + + }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index c9d39f804..8ddb93541 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -246,6 +246,13 @@ public static class RawTagCsvMapper return Invalid(rowNumber, cells, $"TagConfig: {configError}"); } + // The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit + // (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK. + if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError) + { + return Invalid(rowNumber, cells, rowError); + } + var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); return new RawCsvReviewRow diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs index 5718c756c..66e3de04f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs @@ -1,22 +1,27 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// -/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the -/// access level is always Read and Tag.DataType follows the address (catalog type, or the binding's -/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the -/// server (RawTreeService) and the deploy gate would refuse. +/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803/#830): for a Heidenhain tag the +/// access level is always Read and Tag.DataType follows the address (catalog type, the binding's declared +/// type, or Boolean for an alarm condition). Used by the tag modal and the manual-entry grid, which LOCK both +/// fields while they are dictated — so the operator can neither pick a value the save path would refuse nor have +/// one silently replaced on commit. The rules themselves live in . /// public static class RawTagRowDefaults { /// True for a driver whose tags can never be writable. /// The driver type. /// Whether the access level is fixed at Read. - public static bool IsReadOnlyDriver(string? driverType) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase); + public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType); + + /// The data type the driver dictates for this TagConfig (lock the field to it), or null. + /// The driver type. + /// The row's TagConfig. + /// The dictated type, or null when the operator chooses. + public static string? DictatedDataType(string? driverType, string? tagConfig) => + RawTagRowRules.DictatedDataType(driverType, tagConfig); /// Applies the driver-dictated access level and data type for a row, when the driver dictates them. /// The driver type. @@ -25,8 +30,7 @@ public static class RawTagRowDefaults /// Sets the row's Tag.DataType. public static void Apply(string? driverType, string? tagConfig, Action setAccess, Action setDataType) { - if (!IsReadOnlyDriver(driverType)) return; - setAccess(TagAccessLevel.Read); - if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType); + if (IsReadOnlyDriver(driverType)) setAccess(TagAccessLevel.Read); + if (DictatedDataType(driverType, tagConfig) is { } dataType) setDataType(dataType); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs new file mode 100644 index 000000000..159e838d3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs @@ -0,0 +1,66 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and +/// Tag.DataType, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path +/// ( create / update / import), the CSV import's staged review grid +/// (), and the manual-entry grid + tag modal (through , +/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused. +/// +/// +/// A driver with row rules registers its functions in ; the call sites never name a driver. +/// The deploy gate applies the same driver functions (e.g. HeidenhainDeployChecks → +/// ), so authoring and deploy cannot disagree. +/// +public static class RawTagRowRules +{ + /// One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null. + /// Whether the row is ReadWrite. + /// The row's Tag.DataType. + /// The row's TagConfig. + public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig); + + /// One driver's dictated data type for a TagConfig, or null when the operator chooses. + /// The row's TagConfig. + public delegate string? DataTypeRule(string? tagConfig); + + private static readonly Dictionary Rules = + new(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true), + }; + + /// The driver types that carry row rules (test seam / documentation). + public static IReadOnlyCollection DriverTypesWithRules => Rules.Keys; + + /// Why a row may not be saved for this driver, or null when it may. + /// The owning driver's type. + /// The row's access level. + /// The row's Tag.DataType. + /// The row's (normalized) TagConfig. + /// The refusal, or null. + public static string? Problem(string? driverType, TagAccessLevel accessLevel, string? tagDataType, string? tagConfig) => + driverType is not null && Rules.TryGetValue(driverType, out var r) + ? r.Row(accessLevel == TagAccessLevel.ReadWrite, tagDataType, tagConfig) + : null; + + /// True for a driver whose tags can never be writable (the access level is fixed at Read). + /// The driver type. + /// Whether the access level is fixed. + public static bool IsReadOnlyDriver(string? driverType) => + driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly; + + /// + /// The Tag.DataType the driver dictates for this TagConfig (so a form must lock the field to it), or + /// null when the operator chooses (any driver without a rule, or a TagConfig that does not resolve yet). + /// + /// The driver type. + /// The TagConfig. + /// The dictated type name, or null. + public static string? DictatedDataType(string? driverType, string? tagConfig) => + driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 148a37a39..6b5a9064d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); /// - /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only - /// and carry the data type the driver publishes — the same rule the deploy gate applies. + /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803/#830) — , the + /// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies. /// private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase) - ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem( - input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig) - : null; + RawTagRowRules.Problem(driverType, input.AccessLevel, input.DataType, tagConfig); /// /// The driver-typed TagConfig gate, applied on the SERVER for every tag-writing path — diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs index d7b59a66d..0c2c5262d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs @@ -53,7 +53,10 @@ public sealed class HeidenhainAuthoringTests { var m = HeidenhainDriverFormModel.FromJson("{}"); m.DncPassword = "hunter2"; - m.Validate()!.ShouldNotContain("hunter2"); + // #830: the credential refusal is the hosting modal's (the save gate), reported once — not the form's too. + var refusal = DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, m.ToJson()); + refusal.ShouldNotBeNull(); + refusal.ShouldNotContain("hunter2"); m.DncPassword = null; m.StatusMs = 10; m.Validate().ShouldNotBeNull(); @@ -66,7 +69,7 @@ public sealed class HeidenhainAuthoringTests d.Port.ShouldBe(19000); d.Validate().ShouldBeNull(); d.DataPassword = "plain"; - d.Validate().ShouldNotBeNull(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, d.ToJson()).ShouldNotBeNull(); // #830: the modal's gate d.DataPassword = "env:OTOPCUA_HEIDENHAIN_DATA"; var json = d.ToJson(); JsonNode.Parse(json)!["Note"]!.GetValue().ShouldBe("keep"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs new file mode 100644 index 000000000..b5a48a91a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs @@ -0,0 +1,176 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// lmxopcua#830 authoring UX: (a) the CSV review grid previews the SAME row rule the commit applies, (b) the +/// driver-dictated data type is a lock, never a silent rewrite, (c) a credential refusal is reported once. +/// Plus the #831 ALARM: authoring seams (editor seeding, native-alarm editor visibility). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainAuthoringUxTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private const string Header = "Name,DataType,AccessLevel,TagConfigJson"; + + private static string Csv(params string[] rows) => Header + "\n" + string.Join("\n", rows); + + private static string Json(string json) => "\"" + json.Replace("\"", "\"\"") + "\""; + + // ---- (a) CSV preview == commit ---- + + [Fact] + public void Csv_preview_flags_ReadWrite_and_data_type_mismatch_rows_like_the_commit() + { + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, Csv( + $"ok,Int32,Read,{exec}", + $"rw,Int32,ReadWrite,{exec}", + $"dt,Double,Read,{exec}", + $"feed,Int32,Read,{Json("""{"address":"Overrides/Feed"}""")}")); + + result.FatalError.ShouldBeNull(); + var rows = result.Rows; + rows[0].Ok.ShouldBeTrue(rows[0].Error); + rows[1].Error!.ShouldContain("read-only"); + rows[2].Error!.ShouldContain("Int32"); + rows[3].Error!.ShouldContain("Double"); // a Float64 leaf publishes Double — picked as Int32 it used to preview OK + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public async Task Csv_preview_and_ImportTags_agree_row_for_row() + { + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var csv = Csv($"ok,Int32,Read,{exec}", $"rw,Int32,ReadWrite,{exec}", $"dt,String,Read,{exec}", + $"plc,Int64,Read,{Json("""{"address":"PLC:DWORD:0","dataType":"Int64"}""")}"); + var preview = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, csv); + + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct); + string deviceId; + using (var db = RawTreeTestDb.CreateNamed(name)) deviceId = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId).DeviceId; + + // Feed EVERY parsed row (the invalid ones too, rebuilt from their cells) to the commit and compare verdicts. + foreach (var row in preview.Rows) + { + var dataType = row.Cells[1]; + var access = Enum.Parse(row.Cells[2]); + var input = new RawTagInput(row.Cells[0], dataType, access, false, null, row.Cells[3]); + var outcome = await svc.ImportTagsAsync(deviceId, [new RawTagImportRow(null, input)], Ct); + (outcome.Errors.Count == 0).ShouldBe(row.Ok, $"{row.Name}: preview {row.Error ?? "OK"} vs commit {string.Join("; ", outcome.Errors)}"); + } + } + + [Fact] + public void Row_rules_are_driver_dispatched_not_named_at_call_sites() + { + RawTagRowRules.DriverTypesWithRules.ShouldContain(DriverTypeNames.Heidenhain); + RawTagRowRules.Problem(DriverTypeNames.Modbus, TagAccessLevel.ReadWrite, "Float", "{}").ShouldBeNull(); + RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.ReadWrite, "Int32", """{"address":"Status/ExecutionState"}""") + .ShouldBe(HeidenhainTagRules.DescribeRowProblem(true, "Int32", """{"address":"Status/ExecutionState"}""")); + RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.Heidenhain).ShouldBeTrue(); + RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.S7).ShouldBeFalse(); + } + + // ---- (b) dictated data type is a lock ---- + + [Theory] + [InlineData("""{"address":"Axes/Positions","isArray":true}""", "Double")] + [InlineData("""{"address":"PLC:WORD:3","dataType":"Int16"}""", "Int16")] + [InlineData("""{"address":"ALARM:*","alarm":{}}""", "Boolean")] + public void The_dictated_data_type_is_what_the_grid_and_modal_lock_to(string config, string expected) + { + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, config).ShouldBe(expected); + // ...and the locked value is exactly what the save path accepts — nothing to rewrite on commit. + RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.Read, expected, config).ShouldBeNull(); + } + + [Fact] + public void An_unresolved_config_or_another_driver_leaves_the_data_type_to_the_operator() + { + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, "{}").ShouldBeNull(); + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Modbus, """{"address":"Axes/Positions","isArray":true}""").ShouldBeNull(); + } + + // ---- (c) credential error shown once ---- + + [Fact] + public void A_literal_password_is_reported_by_the_modal_save_gate_only_not_also_by_the_form() + { + var driver = HeidenhainDriverFormModel.FromJson("{}"); + driver.InspectPassword = "hunter2"; + var json = driver.ToJson(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, json).ShouldNotBeNull(); // the modal's one notice + Save disabled + driver.Validate().ShouldBeNull(); // the form no longer repeats it + driver.StatusMs = 10; + driver.Validate()!.ShouldContain("StatusMs"); // non-credential problems still show in the form + + var device = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7"}"""); + device.DncPassword = "hunter2"; + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, device.ToJson()).ShouldNotBeNull(); + device.Validate().ShouldBeNull(); + HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host still required in the form + } + + // ---- #831 authoring seams ---- + + [Fact] + public void Picking_an_alarm_selector_seeds_the_alarm_object_and_leaving_it_drops_it() + { + var m = HeidenhainTagConfigModel.FromJson("{}"); + m.Address = "ALARM:CLASS=7"; + var json = m.ToJson(); + m.Validate().ShouldBeNull(); + TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull(); + NativeAlarmModel.FromJson(json).IsAlarm.ShouldBeTrue(); + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out _).ShouldBeTrue(); + def!.Alarm!.Severity.ShouldBe(HeidenhainTagConfigModel.DefaultAlarmSeverity); + + // An authored severity is never overwritten by a later edit. + var edited = NativeAlarmModel.Edit(json, a => a.Severity = 950); + var again = HeidenhainTagConfigModel.FromJson(edited); + again.Address = "ALARM:*"; + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", again.ToJson(), out def, out _).ShouldBeTrue(); + def!.Alarm!.Severity.ShouldBe(950); + + again.Address = "Status/ExecutionState"; + var plain = again.ToJson(); + NativeAlarmModel.FromJson(plain).IsAlarm.ShouldBeFalse(); + TagConfigValidator.Validate(DriverTypeNames.Heidenhain, plain).ShouldBeNull(); + } + + [Fact] + public void Heidenhain_offers_the_native_alarm_editor_and_a_csv_alarm_row_imports() + { + NativeAlarmModel.SupportsNativeAlarms(DriverTypeNames.Heidenhain).ShouldBeTrue(); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, + "Name,DataType,AccessLevel,TagConfigJson,Alarm.AlarmType,Alarm.Severity\n" + + $"Any,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},AlarmCondition,800\n" + + $"NoAlarm,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},,\n"); + result.Rows[0].Ok.ShouldBeTrue(result.Rows[0].Error); + result.Rows[1].Ok.ShouldBeFalse(); // a selector without the alarm object is refused in the preview + } + + [Fact] + public void The_driver_form_round_trips_the_alarm_cadence() + { + var m = HeidenhainDriverFormModel.FromJson("{}"); + m.AlarmMs.ShouldBe(2000); + m.AlarmMs = 500; + var o = HeidenhainOptionsParser.Parse(m.ToJson()); + o.Polling.AlarmMs.ShouldBe(500); + m.AlarmMs = 50; + m.Validate()!.ShouldContain("AlarmMs"); + } +} From 8e1e73f93c2ba4be2fc91c0cc99f960d6392b6a4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:39:06 -0500 Subject: [PATCH 11/16] fix(acl): per-event visibility for direct folder event items; generation-checked native wiring (#826) Event items on an equipment / raw device folder were fed by the SDK's MonitoredNode2 fan-out with no ACL decision, so after a deploy moved a scripted alarm (or dropped the reference projecting a native alarm into an equipment) the OLD folder's subscribers kept receiving it - EventId included - until the rebuild landed, indefinitely after a failed apply. - OtOpcUaNodeManager re-implements INodeManager3 so ValidateEventRolePermissions (the SDK's per-event, per-item hook; base not virtual) adds IsNotifierEventVisibleTo: live condition instance, notifier still a legitimate notifier of it, and the #586/#808 Server-object rule (IsConditionVisibleTo over ConditionVisibilityScopes). - The SDK memoizes that verdict per (item, event type, SourceNode): InvalidateFolderEventVerdicts runs on the authority/authorizer setters, RevalidateMonitoredItems (deploy + timer) and CompleteAddressSpaceApply. - ConditionRefresh on direct items goes through RefreshDirectItems (base loop + the same decision). - New generation field TagReferencingEquipment (RawPath -> EquipmentIds, from the artifact's UnsTagReferences via AclScopePathResolver.ResolveTagRawPath): a native alarm's wired equipment folder only counts (folder delivery AND Server-object scopes) while the current generation still references it. - Secondary #808 edge: UNS variables wired UNS->Raw Organizes by AddReference are tracked and always placed as reference variables (an Area named like its equipment's id no longer narrows them). Tests: UnsFolderScopeWireTests (moved alarm on a folder item + ConditionRefresh + rebuild; area named like equipment id), NativeAlarmFolderEventAclWireTests (dropped reference: folder + Server object + refresh + restore), DeploymentArtifactScopePathTests (projection). Rig/event tests now supply tag references. --- .claude/rules/data-plane-auth.md | 30 ++- docs/security.md | 30 ++- .../Authorization/AclScopePathResolver.cs | 11 + .../Authorization/DataPlaneAuthority.cs | 59 ++++- .../OtOpcUaNodeManager.cs | 249 +++++++++++++++++- .../Drivers/DeploymentArtifact.cs | 37 ++- .../OpcUa/OpcUaPublishActor.cs | 3 +- .../AclEventSubscriptionEnforcementTests.cs | 14 +- .../NativeAlarmFolderEventAclWireTests.cs | 117 ++++++++ .../ServerEventRig.cs | 12 +- .../UnsFolderScopeWireTests.cs | 111 +++++++- .../DeploymentArtifactScopePathTests.cs | 25 ++ .../OpcUaPublishActorDeferredWriteTests.cs | 5 +- 13 files changed, 666 insertions(+), 37 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index feb04ccf3..221add473 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -121,11 +121,12 @@ Two things to know before relying on any of this: same-named narrow grant — except a 4-segment (reference-shaped) id, which keeps its split. ⇒ narrow grantees do NOT receive NodeDeleted for removed UNS folders / vtags / conditions (by decision; they re-browse). Identifiers containing NUL are denied before the cache (resolved-scope keys are NUL-prefixed — a client could otherwise - poison a real folder's cached verdict). ⚠️ The folder-owned rule keys on "first id segment == nearest slash-free - live UNS folder ancestor's id", which can only narrow (an Area named exactly like its equipment's id loses that - variable's Tag-grant match). ⚠️ `ConditionVisibilityScopes` returns the condition ALONE for a scripted condition, - so a moved alarm can't reach its old owner via the Server object; direct per-folder event items still ride the - SDK fan-out of the folder it hangs under (follow-up). A UNS node with NO live hierarchy-folder ancestor (rig-only + poison a real folder's cached verdict). The folder-owned rule keys on "first id segment == nearest slash-free + live UNS folder ancestor's id", EXCEPT for a variable tracked in `_unsReferenceVariables` (the source of a + UNS→Raw `Organizes` edge from `AddReference`), which is always a reference variable (#826 — an Area named exactly + like its equipment's id used to make its references folder-owned). ⚠️ `ConditionVisibilityScopes` returns the + condition ALONE for a scripted condition, so a moved alarm can't reach its old owner via the Server object — and + since #826 not via a direct folder item either (below). A UNS node with NO live hierarchy-folder ancestor (rig-only shape) keeps the plain split. Unplaceable ⇒ denied for every grant incl. Cluster (a behaviour change for Cluster keys only in the stale window / corrupt topology). Tests: `UnsFolderScopeWireTests` (opaque ids, incl. one fed by `DeploymentArtifact.ParseDataPlaneAuthority`); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`) @@ -175,6 +176,25 @@ Two things to know before relying on any of this: **Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other event type fails closed. Auditing is off by default (never configured in this repo). + **Direct (per-folder) event items are filtered per event too (Gitea #826).** The SDK's `MonitoredNode2` fan-out + asks `INodeManager3.ValidateEventRolePermissions` per (event, item) on its own delivery task; the base method is + NOT virtual, so `OtOpcUaNodeManager` **re-implements `INodeManager3`** and adds `IsNotifierEventVisibleTo`: the + event's condition (snapshot handle, or an audit's SourceNode) must be the LIVE instance, the item's notifier must + still be a legitimate notifier of it (an ancestor, or a wired folder the CURRENT generation still projects its + RawPath into — `DataPlaneAuthoritySnapshot.TagReferencingEquipment`, from the artifact's `UnsTagReferences` via + `AclScopePathResolver.ResolveTagRawPath`), and `IsConditionVisibleTo(item, ConditionVisibilityScopes(c))` — the + Server-object rule, whose wired folders are ALSO generation-filtered now. ConditionRefresh on a direct item no + longer calls the base (it uses the non-virtual base check, which bypasses the re-implementation): `RefreshDirectItems` + mirrors the base loop plus the same decision. ⚠️ **The SDK memoizes that verdict per `(item, eventType, + SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one + `MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change: + the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and + `CompleteAddressSpaceApply`. Without it a cached allow survives the deploy (caught by the native wire test's + restore leg / `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ A hand-built + authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under + enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests: + `UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`, + `NativeAlarmFolderEventAclWireTests`. - **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`, `NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node, diff --git a/docs/security.md b/docs/security.md index be8288f35..00b3c2b77 100644 --- a/docs/security.md +++ b/docs/security.md @@ -600,9 +600,13 @@ and Write under old and new names until the address space re-points them. An id folder / virtual tag / condition, a probe, a NodeDeleted model change) is decided only by a `Cluster` or `uns` `Namespace` grant — so narrow grantees do not receive NodeDeleted for removed opaque-id nodes, and a removed id can never match an unrelated narrow grant that shares its name; a four-segment (reference-shaped) id keeps its split. -Identifiers containing NUL are refused. Server-object (`i=2253`) delivery of a +Identifiers containing NUL are refused. A UNS variable the reference pass wired to its raw node (the `Organizes` +edge) is placed as a reference variable whatever its id spells (Gitea #826 — the id rule alone made an equipment's +references folder-owned when an Area was named exactly like that equipment's id, losing their `Tag`-grant match). +Server-object (`i=2253`) delivery of a **scripted** condition is now decided on the condition alone (its folders add nothing in steady state and would -leak a moved alarm to its old owner). So a key holding only an `Equipment` grant can navigate to its equipment, +leak a moved alarm to its old owner). Direct event items on a folder get the same per-event decision (Gitea #826, +below). So a key holding only an `Equipment` grant can navigate to its equipment, read its alarm children, receive its scripted alarm events through the Server object and Acknowledge / Confirm / AddComment with the EventId it received. Fail-closed throughout: an unplaceable node is denied under every grant. | HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** | @@ -709,6 +713,28 @@ Behaviours worth knowing before enabling it: condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock. + **Direct (per-folder) event items are filtered per event too** (Gitea #826). An item on an equipment or raw + device folder is fed by the SDK's own per-notifier fan-out (`MonitoredNode2`), which the #586 dispatcher never + touches — so after a deployment moved a scripted alarm (or dropped the reference projecting a native alarm into + an equipment), the OLD folder's subscribers kept receiving it, EventId included, until the rebuild landed, and + indefinitely after a failed apply (commands were already denied). With the flag **on**, each event to such an + item now also needs, against the CURRENT state: + - the event's condition to be the **live** instance (a torn-down one is withheld); + - the folder to still be a legitimate notifier of it — a node it hangs beneath, or a folder it is wired into + **that the current generation still projects its raw tag into** (`DataPlaneAuthoritySnapshot.TagReferencingEquipment`, + `RawPath → EquipmentIds`, decoded from the artifact's `UnsTagReferences` by the same resolver as the grants); + - and the condition to pass exactly the Server-object rule above for the item's session. + + The generation check also narrows the **Server-object** rule: a wired folder whose reference the generation + dropped no longer admits the native alarm there either. **`ConditionRefresh`** on a folder item is filtered the + same way. The hook is `INodeManager3.ValidateEventRolePermissions`, which the node manager re-implements (the + base method is not virtual); the SDK memoizes that verdict per `(item, event type, SourceNode)`, so the node + manager invalidates it on every new generation or authorizer, on each `AclRevalidationTimer` tick, and at the + end of every apply. Withheld events are not replayed and the client is not told; restoring the reference or + grant resumes the feed on the next event. ⚠️ A native alarm reaches an equipment folder's items under + enforcement only while the installed generation carries that reference — a hand-built authority (tests) must + supply `tagReferencingEquipment`. Inert with the flag off. + **Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the `AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs index ea473172d..086d2a719 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclScopePathResolver.cs @@ -231,6 +231,17 @@ public sealed class AclScopePathResolver } } + /// + /// The RawPath of raw tag — the identifier its raw NodeId (and a native alarm on it, as + /// ConditionId) carries — or null when its chain does not resolve (Gitea #826). + /// + /// The raw tag's logical id. + /// The RawPath, or null. + public string? ResolveTagRawPath(string tagId) => + !string.IsNullOrEmpty(tagId) && _tags.TryGetValue(tagId, out var tag) + ? _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) + : null; + private IReadOnlyList ResolveTag(string tagId) { var paths = new List(); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs index e6aa7e761..57a7f20fc 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -37,7 +37,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyDictionary> grantsByPrincipal, IReadOnlyDictionary equipmentScopePaths, IReadOnlyDictionary scriptedAlarmOwners, - IReadOnlyDictionary unsFolderScopes) + IReadOnlyDictionary unsFolderScopes, + IReadOnlyDictionary> tagReferencingEquipment) { ClusterId = clusterId; RevisionHash = revisionHash; @@ -51,6 +52,7 @@ public sealed class DataPlaneAuthoritySnapshot EquipmentScopePaths = equipmentScopePaths; ScriptedAlarmOwners = scriptedAlarmOwners; UnsFolderScopes = unsFolderScopes; + TagReferencingEquipment = tagReferencingEquipment; } private readonly IReadOnlyDictionary _keysById; @@ -160,6 +162,31 @@ public sealed class DataPlaneAuthoritySnapshot /// public IReadOnlyDictionary UnsFolderScopes { get; } + /// + /// RawPath → the EquipmentIds whose UnsTagReference projects that raw tag, as THIS generation deploys + /// them (Gitea #826). Keyed Ordinal (a RawPath is a NodeId identifier); the id sets are + /// OrdinalIgnoreCase (identifier rule). + /// + /// + /// A native alarm materialises once at its raw tag and is wired as an extra event notifier into every + /// referencing equipment's folder. The generation is installed BEFORE its address space is applied, and an + /// apply that throws keeps the old one, so a dropped reference leaves the alarm wired into an equipment folder + /// this generation no longer projects it into. The node manager admits a wired folder only while this map + /// agrees. An entry confers nothing; an absent RawPath means no equipment references it. + /// + public IReadOnlyDictionary> TagReferencingEquipment { get; } + + /// Whether this generation projects the raw tag at into + /// (Gitea #826). + /// The raw tag's RawPath (a native condition's ConditionId). + /// The equipment (its folder's s= identifier). + /// when a reference of this generation connects the two. + public bool IsTagReferencedBy(string rawPath, string equipmentId) => + !string.IsNullOrEmpty(rawPath) + && !string.IsNullOrEmpty(equipmentId) + && TagReferencingEquipment.TryGetValue(rawPath, out var equipment) + && equipment.Contains(equipmentId); + /// /// The ACL path WITHIN the UNS realm (realm segment stripped) of the UNS folder , /// provided the live folder's ancestry is exactly the one this generation @@ -279,6 +306,8 @@ public sealed class DataPlaneAuthoritySnapshot /// (see ); null ⇒ none. /// Every UNS folder's path + id chain (see ); /// null ⇒ none, which denies every UNS folder-scoped node under enforcement. + /// RawPath → EquipmentIds (see ); + /// null ⇒ none, so no native alarm reaches an equipment folder's event items under enforcement. /// The built snapshot. public static DataPlaneAuthoritySnapshot Create( string? clusterId, @@ -290,7 +319,8 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyList? droppedGrants = null, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { ArgumentNullException.ThrowIfNull(revisionHash); ArgumentNullException.ThrowIfNull(keys); @@ -359,9 +389,21 @@ public sealed class DataPlaneAuthoritySnapshot } } + var referencing = new Dictionary>(StringComparer.Ordinal); + if (tagReferencingEquipment is not null) + { + foreach (var (rawPath, equipmentIds) in tagReferencingEquipment) + { + if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue; + // Re-keyed under the identifier comparer, as the maps above are. + referencing[rawPath] = new HashSet( + equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); + } + } + return new DataPlaneAuthoritySnapshot( clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen, - equipment, owners, folders); + equipment, owners, folders, referencing); } } @@ -446,6 +488,9 @@ public interface IDataPlaneAuthoritySink /// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement. /// Every UNS Area/Line/Equipment folder's path + id chain from the same /// artifact (Gitea #808). Null ⇒ none, which denies every UNS folder-scoped node under enforcement. + /// RawPath → EquipmentIds from the same artifact's + /// UnsTagReferences (Gitea #826). Null ⇒ none, so no native alarm reaches an equipment folder's event items + /// under enforcement. /// The installed snapshot. DataPlaneAuthoritySnapshot Apply( string? clusterId, @@ -455,7 +500,8 @@ public interface IDataPlaneAuthoritySink IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null); + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null); } /// @@ -506,7 +552,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { ArgumentNullException.ThrowIfNull(revisionHash); @@ -540,7 +587,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl published = DataPlaneAuthoritySnapshot.Create( clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths, - scriptedAlarmOwners, unsFolderScopes); + scriptedAlarmOwners, unsFolderScopes, tagReferencingEquipment); Volatile.Write(ref _current, published); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 4b13d3931..525d06976 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -39,7 +39,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// the legacy EquipmentNodeWalker server-side integration was retired in favour of the /// (composer → applier → sink → node-manager) chain. /// -public sealed class OtOpcUaNodeManager : CustomNodeManager2 +// INodeManager3 is RE-IMPLEMENTED (Gitea #826): the SDK's per-folder event delivery (MonitoredNode2) asks +// INodeManager3.ValidateEventRolePermissions for every event and item, and the base implementation is not virtual, so +// re-implementing the interface is the only way to put the per-event ACL decision on that path. Every other member +// maps to the inherited implementation exactly as before. +public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { /// v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are /// registered with the SDK so nodes can be minted in either. is @@ -57,6 +61,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri; private readonly ConcurrentDictionary _variables = new(); + + /// + /// UNS variables PROVEN to be UNS reference variables — the source of a forward Organizes edge into the raw + /// namespace (), which only the applier's reference pass wires. Gitea #826: the + /// reference-vs-folder-owned decision in no longer rests on the id rule alone + /// (an Area named exactly like its equipment's id made that equipment's references look folder-owned). Holds the + /// instance, so a stale entry for a replaced node never matches; cleared with . + /// + private readonly ConcurrentDictionary _unsReferenceVariables = new(); private readonly ConcurrentDictionary _folders = new(); private readonly ConcurrentDictionary _alarmConditions = new(); /// H6a: the subset of node ids materialised as NATIVE @@ -567,6 +580,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // walking its own dead item map on each deploy. previous.Changed -= OnAuthorityChanged; next.Changed += OnAuthorityChanged; + InvalidateFolderEventVerdicts(); } } @@ -641,6 +655,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // which only ever DENIES narrow grants, so a miss would read as working enforcement). if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject; _authorizer = value; + InvalidateFolderEventVerdicts(); } } @@ -3604,6 +3619,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false); if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false); + + // Gitea #826: a UNS variable organizing a raw node IS a UNS reference variable — recorded so the ACL + // placement can say so explicitly instead of inferring it from the id's first segment. + if (sourceRealm == AddressSpaceRealm.Uns && targetRealm == AddressSpaceRealm.Raw + && refType == ReferenceTypeIds.Organizes + && source is BaseDataVariableState variable + && _variables.TryGetValue(sourceKey, out var live) && ReferenceEquals(live, variable)) + { + _unsReferenceVariables[sourceKey] = variable; + } } } @@ -3851,6 +3876,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId); _variables.Clear(); + _unsReferenceVariables.Clear(); // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. _historizedTagnames.Clear(); _arrayOnScalarRefused.Clear(); @@ -3898,6 +3924,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public void CompleteAddressSpaceApply() { + // Gitea #826: the apply moved conditions, wiring and folders, and the SDK memoizes each folder item's per-event + // verdict per (item, event type, condition) until told otherwise — forget them before the restored reports + // below go out, so the next event on every folder item is decided against the address space as applied. + InvalidateFolderEventVerdicts(); int reported = 0, discarded; lock (Lock) { @@ -3938,6 +3968,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + _unsReferenceVariables.TryRemove(key, out _); // Conservatively invalidate every outstanding history cursor on topology removal. Interlocked.Increment(ref _historyContinuationEpoch); UnindexChild(key); @@ -4105,6 +4136,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return; } + _unsReferenceVariables.TryRemove(key, out _); if (_variables.TryRemove(key, out var v)) { UnindexChild(key); @@ -4301,6 +4333,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // (s=Area/Line/Equipment/EffectiveName): authorized at its split path, but only while that path is where the // current generation places the equipment it hangs under (review of #808 — a moved or renamed equipment's // variables keep their OLD-name ids until re-pointed, and the old names' grants must not keep them). + // Gitea #826: a variable the reference pass wired to its raw node is a reference variable whatever its id + // spells — so an Area named exactly like its equipment's id no longer turns that equipment's references + // folder-owned (which only ever narrowed: it lost their Tag-grant match). Untracked nodes keep the id rule. + if (node is BaseDataVariableState v + && _unsReferenceVariables.TryGetValue(key, out var reference) && ReferenceEquals(reference, v)) + { + return UnsAclSubject.ReferenceVariable(chain); + } + return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString) ? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning)) : UnsAclSubject.ReferenceVariable(chain); @@ -5478,13 +5519,174 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring) && ReferenceEquals(wiring.Alarm, condition)) { - scopes.AddRange(wiring.Folders.Select(f => f.NodeId)); + // Gitea #826: only a folder THIS generation still projects the alarm's raw tag into. A dropped + // reference leaves the wiring in place until the rebuild lands (or indefinitely after a failed apply), + // and that folder's grant must stop admitting the alarm the moment the generation drops it. + var authority = DataPlaneAuthority.Current; + foreach (var folder in wiring.Folders) + { + if (IsWiredFolderCurrent(authority, condition, folder)) scopes.Add(folder.NodeId); + } } } return scopes; } + /// + /// Whether , wired as an extra notifier of the native , is + /// one the current generation still projects the condition's raw tag into (Gitea #826). + /// + /// The current generation. + /// A native condition (ConditionId = RawPath). + /// A folder wired for it. + /// when a reference of the generation connects them. + /// Only UNS equipment folders are ever wired; anything else is not a current wiring (fail closed). + private bool IsWiredFolderCurrent(DataPlaneAuthoritySnapshot authority, NodeState condition, FolderState folder) => + folder.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns) + && folder.NodeId.TryGetValue(out string equipmentId) + && condition.NodeId.TryGetValue(out string rawPath) + && authority.IsTagReferencedBy(rawPath, equipmentId); + + /// + /// The per-event decision for an event item on one of this manager's own notifiers (an equipment folder, a raw + /// device folder) — the SDK fan-out path the Server-object filtering (#586) does not reach (Gitea #826). + /// + /// The event item; its manager handle names the notifier it is attached to. + /// The event as the SDK delivers it (a condition snapshot, or a cloned audit event). + /// when the item's session may receive it through that notifier. + /// + /// + /// Three conditions, all against the CURRENT state: the event's condition is the LIVE instance (a torn-down + /// one denies); the notifier is still a legitimate notifier of it — the condition itself, a node it hangs + /// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is + /// visible to the session under exactly the Server-object rule ( over + /// ), so a moved scripted alarm is withheld from its old folder just + /// as it is from the Server object. + /// + /// + /// An event that names no live condition of this manager is withheld (fail closed): only condition events + /// and the condition's own method audits reach these notifiers. + /// + /// + /// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes + /// Lock (via GetNodeMetadata); the scope resolution here takes it the same way. + /// + /// + private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e) + { + if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false; + if (EventConditionOf(e) is not { } condition) return false; + if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) || !ReferenceEquals(live, condition)) + return false; + if (!IsLiveNotifierOf(live, notifier)) return false; + return IsConditionVisibleTo(item, ConditionVisibilityScopes(live)); + } + + /// The condition an event delivered through a notifier is about, or null. + /// The event: a condition's snapshot, the condition itself, or an event whose SourceNode names one. + /// The condition state, or null. + private ConditionState? EventConditionOf(IFilterTarget e) + { + var node = ConditionOf(e); + if (node is ConditionState condition) return condition; + + // A condition's Part 9 method audit (cloned by the SDK on its way through a notifier): its SourceNode is the + // condition. + return node is BaseEventState { SourceNode.Value: { IsNull: false } source } + && OwnsNamespace(source) + && _alarmConditions.TryGetValue(MapKey(source), out var named) + ? named + : null; + } + + /// Whether is, right now, a node the condition's events legitimately bubble + /// through (Gitea #826). + /// The live condition. + /// The node the event item is attached to. + /// for the condition itself, a node above it, or a wired folder the current + /// generation still projects it into. + private bool IsLiveNotifierOf(AlarmConditionState condition, NodeState notifier) + { + if (ReferenceEquals(notifier, condition)) return true; + + lock (Lock) + { + for (var ancestor = (condition as BaseInstanceState)?.Parent; + ancestor is not null; + ancestor = (ancestor as BaseInstanceState)?.Parent) + { + if (ReferenceEquals(ancestor, notifier)) return true; + } + + return notifier is FolderState folder + && _alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring) + && ReferenceEquals(wiring.Alarm, condition) + && wiring.Folders.Contains(folder) + && IsWiredFolderCurrent(DataPlaneAuthority.Current, condition, folder); + } + } + + /// + /// Validates whether an event item may receive an event: the SDK's role-permission check, and — with enforcement + /// on, for an item on one of this manager's own nodes — (Gitea #826). + /// + /// The event item. + /// The event. + /// Good to deliver; otherwise the veto. + /// + /// + /// The SDK's MonitoredNode2 asks this, per event and per item, before it queues an event to an item + /// attached to one of this manager's notifiers. The Server-object path (#586) and ConditionRefresh decide + /// the same question themselves, so a folder's subscribers stop receiving a moved alarm (or a native alarm + /// whose reference was dropped) the moment the generation says so, not when the rebuild lands. + /// + /// + /// ⚠️ The SDK MEMOIZES the verdict per (item, event type, SourceNode) until the monitored node's + /// permission generation moves, so must run whenever the inputs + /// change: a new authority generation or authorizer, the revalidation tick, and the end of every apply. A + /// condition's SourceNode is the condition itself, so a verdict is never shared between two conditions. + /// + /// + ServiceResult INodeManager3.ValidateEventRolePermissions(IEventMonitoredItem monitoredItem, IFilterTarget filterTarget) + { + var result = ValidateEventRolePermissions(monitoredItem, filterTarget); + if (ServiceResult.IsBad(result) || !AclEnforced || !OwnsNamespace(monitoredItem.NodeId)) return result; + return IsNotifierEventVisibleTo(monitoredItem, filterTarget) ? result : FolderEventDenied; + } + + /// The verdict a withheld folder event carries back to the SDK (never reaches the client). + private static readonly ServiceResult FolderEventDenied = new(StatusCodes.BadUserAccessDenied); + + /// + /// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826), + /// so the next event on each is decided afresh by . + /// + /// + /// MonitoredNode2.InvalidatePermissionCacheForSession is the SDK's only public lever: it advances that + /// node's permission generation (a verdict computed concurrently under the old one is recomputed, not stored) + /// and drops the session's entries. Cost is one call per (notifier, session) pair — never per condition. The + /// node list is copied under Lock; the invalidation itself takes no lock of ours. + /// + private void InvalidateFolderEventVerdicts() + { + if (_root is null) return; + + List nodes; + lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList(); + + foreach (var node in nodes) + { + var sessions = new HashSet(); + foreach (var item in node.EventMonitoredItems.Values) + { + if (item?.Session?.Id is { IsNull: false } id) sessions.Add(id); + } + + foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session); + } + } + /// Whether 's session may see a condition with the given scopes. /// The Server-object event item. /// From . @@ -5530,10 +5732,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 (item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item); } - if (direct.Count > 0) base.ConditionRefresh(context, direct); + var systemContext = SystemContext.Copy(context); + if (direct.Count > 0) RefreshDirectItems(systemContext, direct); if (serverItems.Count == 0) return ServiceResult.Good; - var systemContext = SystemContext.Copy(context); List notifiers; lock (Lock) notifiers = RootNotifiers?.ToList() ?? []; @@ -5562,6 +5764,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return ServiceResult.Good; } + /// + /// ConditionRefresh for event items on this manager's own notifiers, under enforcement (Gitea #826): the SDK + /// base's loop exactly — an item no longer attached (stood down by revalidation, or tombstoned) is not + /// refreshed — plus the per-event decision live delivery makes (), so a + /// refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold. + /// + /// The refresh's system context. + /// The direct (non-Server-object) items being refreshed. + private void RefreshDirectItems(ServerSystemContext systemContext, List items) + { + foreach (var item in items) + { + NodeState? notifier; + lock (Lock) + { + if (!MonitoredItems.ContainsKey(item.Id)) continue; + notifier = (item.ManagerHandle as NodeHandle)?.Node; + } + + if (notifier is null) continue; + + var events = new List(); + notifier.ConditionRefresh(systemContext, events, true); + foreach (var e in events) + { + if (!IsNotifierEventVisibleTo(item, e)) continue; + if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue; + item.QueueEvent(e); + } + } + } + /// The condition a refresh event describes, or null when it names none (withheld: fail closed). /// A refresh event. /// The condition node. @@ -5663,7 +5897,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public void RevalidateMonitoredItems() { - if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return; + if (!AclEnforced) return; + + // Gitea #826: both triggers (a deploy, the clock) also reach the per-event decision on folder event items, whose + // verdicts the SDK memoizes — a deploy that moves an alarm must not keep serving its old folder's allow. + InvalidateFolderEventVerdicts(); + if (_liveItems.IsEmpty && _eventItems.IsEmpty) return; var now = UtcNow; var stoodDown = 0; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index d4c6e9d33..d287bb9ef 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -93,6 +93,16 @@ public sealed record DataPlaneAuthorityProjection( public IReadOnlyDictionary UnsFolderScopes { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// + /// RawPath → EquipmentIds from the artifact's UnsTagReferences (Gitea #826): which equipment + /// folders this generation projects each raw tag into — the folders a native alarm on that tag may be + /// delivered through. RawPaths come from the SAME as + /// , so they equal the native conditions' ConditionIds by construction. Empty + /// when the artifact carries no grants. + /// + public IReadOnlyDictionary> TagReferencingEquipment { get; init; } = + new Dictionary>(StringComparer.Ordinal); + /// The projection of an absent or unparseable artifact: no cluster, no keys, no grants. public static readonly DataPlaneAuthorityProjection Empty = new(null, [], [], new Dictionary>()); @@ -938,7 +948,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable grants.Add(acl); } - var (scopePaths, equipmentScopePaths, unsFolderScopes) = BuildAclScopePaths(root, grants); + var (scopePaths, equipmentScopePaths, unsFolderScopes, tagReferencingEquipment) = BuildAclScopePaths(root, grants); // Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of // the grant count: it is a topology fact, cheap, and an absent map only ever denies. var scriptedAlarmOwners = new Dictionary(StringComparer.Ordinal); @@ -955,6 +965,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable EquipmentScopePaths = equipmentScopePaths, ScriptedAlarmOwners = scriptedAlarmOwners, UnsFolderScopes = unsFolderScopes, + TagReferencingEquipment = tagReferencingEquipment, }; } @@ -964,10 +975,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable /// path and the RawPath that forms a node's NodeId cannot drift apart. /// /// The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup, - /// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver. + /// Gitea #797), every UNS folder's path + id chain (Gitea #808) and every referenced raw tag's referencing + /// equipment (Gitea #826) from the same resolver. private static (IReadOnlyDictionary> ScopePaths, IReadOnlyDictionary EquipmentScopePaths, - IReadOnlyDictionary UnsFolderScopes) BuildAclScopePaths( + IReadOnlyDictionary UnsFolderScopes, + IReadOnlyDictionary> TagReferencingEquipment) BuildAclScopePaths( JsonElement root, IReadOnlyList grants) { @@ -975,7 +988,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable { return (new Dictionary>(), new Dictionary(StringComparer.OrdinalIgnoreCase), - new Dictionary(StringComparer.OrdinalIgnoreCase)); + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary>(StringComparer.Ordinal)); } var folders = new Dictionary(StringComparer.Ordinal); @@ -1077,7 +1091,20 @@ internal sealed class ParsedDeploymentArtifact : IDisposable if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path; } - return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment)); + // Which equipment folders each raw tag is projected into (Gitea #826) — the folders a native alarm on that tag + // may legitimately be delivered through. Keyed on the RawPath the SAME resolver builds, which is the native + // condition's ConditionId. + var referencing = new Dictionary>(StringComparer.Ordinal); + foreach (var reference in references) + { + if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue; + if (!referencing.TryGetValue(rawPath, out var set)) + referencing[rawPath] = set = new HashSet(StringComparer.OrdinalIgnoreCase); + set.Add(reference.EquipmentId); + } + + return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment), + referencing.ToDictionary(kv => kv.Key, kv => (IReadOnlySet)kv.Value, StringComparer.Ordinal)); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 2165144ef..f90d229c4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -1426,7 +1426,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes)); var snapshot = _dataPlaneAuthority.Apply( projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths, - projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes); + projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes, + projection.TagReferencingEquipment); _log.Info( "OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs index f1e046d24..79f3be8eb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs @@ -88,7 +88,7 @@ public sealed class AclEventSubscriptionEnforcementTests // The next deployment carries only EqB's grant. state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); rig.AttachedEventItems.ShouldBe(1); rig.Transition("t2", active: false); @@ -100,7 +100,7 @@ public sealed class AclEventSubscriptionEnforcementTests // A deployment that restores the grant brings the feed back on its own. state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); rig.AttachedEventItems.ShouldBe(2); rig.Transition("t3", active: true); @@ -143,7 +143,7 @@ public sealed class AclEventSubscriptionEnforcementTests rig.AttachedEventItems.ShouldBe(2); state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); rig.AttachedEventItems.ShouldBe(1); subscription.RemoveItem(onA); @@ -153,7 +153,7 @@ public sealed class AclEventSubscriptionEnforcementTests // A restoring deployment must not re-attach the deleted item. The client could never see that // happen — it has deleted the item — so the server-side fan-out is what is asserted. state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out"); rig.Transition("t2", active: false); @@ -410,7 +410,7 @@ public sealed class AclEventSubscriptionEnforcementTests await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1")); state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); rig.Transition("t2", active: false); await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); @@ -418,7 +418,7 @@ public sealed class AclEventSubscriptionEnforcementTests events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment"); state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); rig.Transition("t3", active: true); await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3")); @@ -1049,7 +1049,7 @@ public sealed class AclEventSubscriptionEnforcementTests { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", keys, grants, ScopePaths(), - ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes()); + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences()); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs new file mode 100644 index 000000000..0636d6ece --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmFolderEventAclWireTests.cs @@ -0,0 +1,117 @@ +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Gitea #826, native variant, over the wire: a native alarm materialises once at its raw tag and is wired as an +/// extra event notifier into every referencing equipment folder. A deployment that drops one equipment's reference +/// installs its generation BEFORE the rebuild that unwires the folder (and an apply that fails keeps the old +/// wiring), so the folder's subscribers used to keep receiving the alarm — EventId included — through the SDK's +/// per-folder fan-out, which no ACL decision reached, and through the Server object, whose scopes admitted every +/// wired folder. Both now ask the CURRENT generation whether it still projects the raw tag into that equipment. +/// +public sealed class NativeAlarmFolderEventAclWireTests +{ + private const string EqA = ServerEventRig.EqA; + private const string EqB = ServerEventRig.EqB; + + /// + /// Anonymous holds EqA, the key holds EqB, the alarm is wired into both. The generation drops EqA's reference + /// with the wiring still in place: EqA's folder item and the anonymous Server-object item stop receiving the + /// alarm (live AND ConditionRefresh), while the key's EqB folder and Server-object items receive the same events + /// (positive control). Restoring the reference restores EqA's feed with no client action — the SDK's memoized + /// per-event verdict is invalidated by the generation change. + /// + [Fact] + public async Task A_native_alarm_stops_reaching_a_folder_whose_reference_the_generation_dropped() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826Native", encrypted: false, ct); + rig.KeyRoles = []; + rig.MaterialiseNative(); + NodeAcl[] grants = [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly), KeyEquipmentGrant(EqB)]; + var state = rig.Enforce(true, [ServerEventRig.Key()], grants); + + using var anonymous = await rig.ConnectAsync(anonymous: true, ct); + using var keyed = await rig.ConnectAsync(anonymous: false, ct); + + var anonEvents = new RecordedEvents(); + var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct); + var onA = RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA)); + var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server); + await anonSub.ApplyChangesAsync(ct); + onA.Status.Error.ShouldBeNull(); + + var keyEvents = new RecordedEvents(); + var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct); + var onB = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqB)); + var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server); + await keySub.ApplyChangesAsync(ct); + onB.Status.Error.ShouldBeNull(); + + // Baseline: every item receives the alarm while both references are deployed. + rig.Project(native: true, State("t1")); + await RecordedEvents.WaitUntilAsync(() => + anonEvents.Has(onA, "t1") && anonEvents.Has(anonServer, "t1") + && keyEvents.Has(onB, "t1") && keyEvents.Has(keyServer, "t1")); + + // The next generation drops EqA's reference; the address space still wires the alarm into EqA. + Deploy(state, "rev-2", grants, EqB); + + rig.Project(native: true, State("t2")); + await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(onB, "t2") && keyEvents.Has(keyServer, "t2")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + anonEvents.Has(onA, "t2").ShouldBeFalse("the folder the generation dropped must not receive the alarm"); + anonEvents.Has(anonServer, "t2").ShouldBeFalse("nor may that folder's grant admit it through the Server object"); + + // ConditionRefresh on the stale folder respects the same rule; the still-referenced folder replays it. + await RefreshAsync(anonymous, anonSub, ct); + await RefreshAsync(keyed, keySub, ct); + await RecordedEvents.WaitUntilAsync(() => + anonEvents.HasType(onA, ObjectTypeIds.RefreshEndEventType) + && keyEvents.ConditionEvents(onB, "t2").Count == 2); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + anonEvents.Has(onA, "t2").ShouldBeFalse("a refresh must not replay the alarm to the dropped folder"); + anonEvents.Has(anonServer, "t2").ShouldBeFalse(); + + // Restoring the reference restores the feed on the next event — no client action, no stale cached deny. + Deploy(state, "rev-3", grants, EqA, EqB); + rig.Project(native: true, State("t3")); + await RecordedEvents.WaitUntilAsync(() => + anonEvents.Has(onA, "t3") && anonEvents.Has(anonServer, "t3") && keyEvents.Has(onB, "t3")); + + await anonymous.CloseAsync(ct); + await keyed.CloseAsync(ct); + } + + private static AlarmConditionSnapshot State(string message) => + new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message); + + private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) => + state.Apply( + ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), + new Dictionary> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() }); + + private static NodeAcl KeyEquipmentGrant(string equipment) => new() + { + NodeAclId = $"acl-key-{equipment}", + ClusterId = ServerEventRig.Cluster, + Principal = ServerEventRig.KeyPrincipal, + ScopeKind = NodeAclScopeKind.Equipment, + ScopeId = $"eq-{equipment}", + PermissionFlags = NodePermissions.ReadOnly, + }; + + private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) => + await session.CallAsync( + ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs index d3a4af131..44ae8279e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -127,7 +127,8 @@ internal sealed class ServerEventRig : IAsyncDisposable { var state = new DataPlaneAuthorityState(); state.Apply( - Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes()); + Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes(), + TagReferences()); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System); @@ -163,7 +164,7 @@ internal sealed class ServerEventRig : IAsyncDisposable PermissionFlags = permissions, }; - private static IReadOnlyDictionary> ScopePaths() => + public static IReadOnlyDictionary> ScopePaths() => new Dictionary> { [new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])], @@ -195,6 +196,13 @@ internal sealed class ServerEventRig : IAsyncDisposable [EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [EqB]), }; + /// + /// The rig's deployed UNS references (Gitea #826): its native alarm's raw tag is projected into both equipment — + /// the folders wires it into, so per-folder delivery of it is current. + /// + public static IReadOnlyDictionary> TagReferences() => + new Dictionary> { [RawAlarmPath] = new HashSet { EqA, EqB } }; + /// The rig's deployed scripted-alarm owners: its one scripted alarm belongs to . public static IReadOnlyDictionary ScriptedAlarmOwners() => new Dictionary { [ScriptedAlarmId] = EqA }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs index 0b004a00d..437123188 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/UnsFolderScopeWireTests.cs @@ -210,6 +210,65 @@ public sealed class UnsFolderScopeWireTests await session.CloseAsync(ct); } + /// + /// Gitea #826 — the same stale window seen by a DIRECT event item on the old owner's equipment folder, which the + /// SDK feeds through its per-folder fan-out rather than the Server object. The old owner's folder subscription + /// receives neither the moved alarm's live events nor its ConditionRefresh replay (EventId included), while its + /// correctly placed alarm keeps arriving on the same item (positive control). After the rebuild re-materialises + /// the alarm under its deployed owner, the new owner's folder subscription receives it. + /// + [Fact] + public async Task A_moved_alarm_stops_reaching_its_old_folders_subscribers_until_the_rebuild_places_it() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns826FolderMoved", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + // The key holds the OLD owner (Pump); anonymous holds the NEW one (Fan). The generation already moved Alarm. + Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan), + EquipmentGrant(Pump, Operator), AnonymousGrant(NodeAclScopeKind.Equipment, Fan)); + + using var oldOwner = await rig.ConnectAsync(anonymous: false, ct); + using var newOwner = await rig.ConnectAsync(anonymous: true, ct); + var oldEvents = new RecordedEvents(); + var oldSubscription = await RecordedEvents.SubscribeAsync(oldOwner, oldEvents, ct); + var pumpFolder = RecordedEvents.AddEventItem(oldSubscription, Uns(oldOwner, Pump)); + await oldSubscription.ApplyChangesAsync(ct); + pumpFolder.Status.Error.ShouldBeNull(); + var newEvents = new RecordedEvents(); + var newSubscription = await RecordedEvents.SubscribeAsync(newOwner, newEvents, ct); + var fanFolder = RecordedEvents.AddEventItem(newSubscription, Uns(newOwner, Fan)); + await newSubscription.ApplyChangesAsync(ct); + fanFolder.Status.Error.ShouldBeNull(); + + var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); + Project(rig, Alarm, pre with { Message = "moved" }); // reported first: the control arrives after its verdict + Project(rig, PumpLo, pre with { Message = "control" }); + await RecordedEvents.WaitUntilAsync(() => oldEvents.Has(pumpFolder, "control")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("the moved alarm must not reach its old owner's folder item"); + + // ConditionRefresh on the folder item replays the placed alarm and withholds the moved one. + await RefreshAsync(oldOwner, oldSubscription, ct); + await RecordedEvents.WaitUntilAsync(() => + oldEvents.HasType(pumpFolder, ObjectTypeIds.RefreshEndEventType) + && oldEvents.ConditionEvents(pumpFolder, "control").Count == 2); + oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("a refresh must not replay the moved alarm to the old folder"); + + // The rebuild places it under its deployed owner. + ((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); + rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); + rig.Nm.CompleteAddressSpaceApply(); + Project(rig, Alarm, pre with { Message = "placed" }); + Project(rig, PumpLo, pre with { Message = "control-2" }); + await RecordedEvents.WaitUntilAsync(() => + newEvents.Has(fanFolder, "placed") && oldEvents.Has(pumpFolder, "control-2")); + oldEvents.Has(pumpFolder, "placed").ShouldBeFalse(); + + await oldOwner.CloseAsync(ct); + await newOwner.CloseAsync(ct); + } + /// /// An EQUIPMENT moved to another line by the generation while its folder still hangs under the old line: the live /// folder ancestry disagrees with the generation's, so the folder, everything it owns, its scripted alarm (read @@ -294,6 +353,34 @@ public sealed class UnsFolderScopeWireTests await session.CloseAsync(ct); } + /// + /// Gitea #826 (secondary edge of #808): an Area NAMED exactly like one of its equipment's opaque ids. That + /// equipment's reference variable (s=<AreaName>/Line/Equipment/Name) then starts with the + /// equipment's own id, which the id rule alone read as folder-owned — losing its Tag-grant match. The variable + /// is now known to be a reference by the Organizes edge the reference pass wires to its raw node, so the Tag + /// grant reaches it; the equipment folder itself stays denied to that grant (control). + /// + [Fact] + public async Task A_reference_variable_under_an_area_named_like_its_equipment_id_keeps_its_tag_grant() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Uns826AreaNamedLikeId", encrypted: false, ct); + rig.KeyRoles = []; + Topology(rig, pumpLine: Line1); + var raw = rig.EnsureRawVariable("flow", "Double", writable: false); + var reference = $"{Pump}/Line1/Pump/Flow"; // the Area's NAME is Pump's id + rig.Sink.EnsureVariable(reference, Pump, "Flow", "Double", writable: false, AddressSpaceRealm.Uns); + rig.Sink.AddReference(reference, AddressSpaceRealm.Uns, raw, AddressSpaceRealm.Raw); + Install(rig, pumpLine: Line1, Deployed(), areaName: Pump, tagArea: Pump, TagGrant()); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + (await ReadAsync(session, Uns(session, reference), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good); + (await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + (await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied); + + await session.CloseAsync(ct); + } + /// /// Review of #808 (cache poisoning): the resolved-scope cache keys are NUL-prefixed, so a client that first /// Reads the nonexistent s="\0Area1\0Line1\0Pump" — the exact cache key of the real Pump folder — must @@ -481,13 +568,19 @@ public sealed class UnsFolderScopeWireTests /// has not caught up with when it differs from the live Area1). private static void Install( ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, string areaName, - params NodeAcl[] grants) + params NodeAcl[] grants) => + Install(rig, pumpLine, deployedOwners, areaName, tagArea: "Area1", grants); + + /// As above, with the Tag grant's UNS projection under the Area NAMED . + private static void Install( + ServerEventRig rig, string pumpLine, IReadOnlyDictionary deployedOwners, string areaName, + string tagArea, params NodeAcl[] grants) { var resolver = Resolver(pumpLine, areaName); var scopePaths = new Dictionary>(resolver.Resolve(grants)) { // A Tag grant's UNS projection (the resolver would derive it from raw rows this rig does not model). - [new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1", "Pump", "Flow"])], + [new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, tagArea, "Line1", "Pump", "Flow"])], }; NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem(); @@ -535,6 +628,20 @@ public sealed class UnsFolderScopeWireTests PermissionFlags = flags, }; + private static NodeAcl AnonymousGrant(NodeAclScopeKind kind, string scopeId) => new() + { + NodeAclId = $"anon-{scopeId}", + ClusterId = ServerEventRig.Cluster, + Principal = OpcUaPrincipals.Anonymous, + ScopeKind = kind, + ScopeId = scopeId, + PermissionFlags = NodePermissions.ReadOnly, + }; + + private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) => + await session.CallAsync( + ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id); + private static List Route(ServerEventRig rig) { var routed = new List(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs index 7f87d136d..b8df2b82e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs @@ -312,4 +312,29 @@ public sealed class DeploymentArtifactScopePathTests projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]); } + + /// + /// Gitea #826 — the projection says which equipment each raw tag is projected into, keyed on the RawPath the + /// same resolver builds (a native alarm's ConditionId), so the node manager can tell a native alarm's + /// still-referenced equipment folders from one whose reference the generation dropped. + /// + [Fact] + public void The_projection_carries_each_raw_tags_referencing_equipment_under_its_raw_path() + { + var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); + + const string rawPath = "Plant/Line1/ModbusDrv/dev1/Fast/Speed"; + projection.TagReferencingEquipment.Keys.ShouldBe([rawPath]); + projection.TagReferencingEquipment[rawPath].ShouldBe(["EQ-mixer"]); + + var snapshot = new DataPlaneAuthorityState().Apply( + "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, + projection.ScriptedAlarmOwners, projection.UnsFolderScopes, projection.TagReferencingEquipment); + snapshot.IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeTrue(); + snapshot.IsTagReferencedBy(rawPath, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers"); + snapshot.IsTagReferencedBy(rawPath, "EQ-other").ShouldBeFalse(); + snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Other", "EQ-mixer").ShouldBeFalse(); + new DataPlaneAuthorityState().Apply("c1", "rev-1", [], [], projection.ScopePaths) + .IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)"); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index b08d5bafd..9179d3152 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -667,12 +667,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase IReadOnlyDictionary> scopePaths, IReadOnlyDictionary? equipmentScopePaths = null, IReadOnlyDictionary? scriptedAlarmOwners = null, - IReadOnlyDictionary? unsFolderScopes = null) + IReadOnlyDictionary? unsFolderScopes = null, + IReadOnlyDictionary>? tagReferencingEquipment = null) { Interlocked.Increment(ref _applies); return new DataPlaneAuthorityState().Apply( clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners, - unsFolderScopes); + unsFolderScopes, tagReferencingEquipment); } } From 9aff90f884565f6f2a6c744cc9d5cc5ef0a51ee6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:53:01 -0500 Subject: [PATCH 12/16] fix(opcuaclient): never pass checkDomain for a None endpoint; document both SDK check sites (#816 review) SDK 2.0.1 checks the server host name in TWO places: Session.OpenAsync (after the chain is trusted) and DefaultSessionFactory.CreateChannelAsync, against the certificate in the GetEndpoints reply and before any security-mode test. opc-plc sends a certificate on its None endpoint, so `connect -u opc.tcp://10.100.0.35:50000 --validate-host-name` was refused on an unauthenticated channel, where a host check proves nothing. All three call sites now pass checkDomain only when the selected endpoint's SecurityMode != None: OpcUaEndpointSelection.CheckDomainFor for the driver (OpenSessionOnEndpointAsync, so the failover sweep too) and the AdminUI browser; the same expression in Client.Shared ConnectToEndpointAsync (shared by failover). New live tests: driver None + flag on vs :50000 reads; browser None + flag on vs the NAT fixture browses; the secure + flag refusals still hold. Docs: name both check sites; a zone-qualified IPv6 host never matches (FindDomain compares the zone too), so link-local needs validateServerHostName=false; the match is loose (first label alone, subject DC= parts count, no wildcards), which caveats the CA recommendation. --- .claude/rules/drivers.md | 7 ++- docs/Client.CLI.md | 2 +- docs/drivers/OpcUaClient.md | 43 ++++++++++++++----- .../CommandBase.cs | 2 +- .../Models/ConnectionSettings.cs | 6 ++- .../OpcUaClientService.cs | 6 ++- .../OpcUaClientDriverBrowser.cs | 6 ++- .../OpcUaClientDriverOptions.cs | 9 ++-- .../OpcUaEndpointSelection.cs | 17 ++++++++ .../OpcUaClientDriver.cs | 6 ++- .../Drivers/Forms/OpcUaClientDriverForm.razor | 2 +- .../OpcUaClientServiceTests.cs | 21 ++++++--- .../NatPublishedEndpointBrowseTests.cs | 15 +++++++ .../ServerHostNameValidationTests.cs | 21 +++++++++ .../OpcUaClientServerHostNameOptionTests.cs | 15 +++++++ 15 files changed, 145 insertions(+), 33 deletions(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index ecdbfcca6..5de204ad0 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -292,8 +292,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `checkDomain: true`; its 8-arg `DefaultSessionFactory.CreateAsync` hard-codes `false`, so driver, AdminUI browser and Client.Shared never checked before #816. Now `certificateValidation.validateServerHostName` (tri-state, unset = off = unchanged; AdminUI keeps it unset) / CLI `--validate-host-name`. It checks the **configured** host (the rewritten - `ConfiguredEndpoint.EndpointUrl`), secured sessions only, and auto-accept accepts a mismatch. Any new session-create - call site must pass the flag — the overload without it silently disables the check. The rewriter keeps an IPv6 + `ConfiguredEndpoint.EndpointUrl`) and auto-accept accepts a mismatch. ⚠️ The SDK checks in TWO places — + `DefaultSessionFactory.CreateChannelAsync` (GetEndpoints cert, BEFORE any security-mode test, so it fired on `None`) + and `Session.OpenAsync` — so pass `checkDomain` only for a non-`None` endpoint (`OpcUaEndpointSelection.CheckDomainFor`). + Any new session-create call site must do that — the overload without the argument silently disables the check. + Zone-qualified IPv6 hosts never match (zone compared); the match is loose (first label alone counts). The rewriter keeps an IPv6 zone index verbatim (#819; .NET `Uri` drops it from every re-serialisation). `docs/drivers/OpcUaClient.md`. - **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 6cb9eaeec..5f71be86b 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -39,7 +39,7 @@ All commands accept these options: | `-P` / `--password` | Password for `UserName` token authentication | | `-S` / `--security` | Transport security mode: `none`, `sign`, `encrypt`, `signandencrypt` (default: `none`) | | `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy | -| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Secured connections only; off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits | +| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Only with `-S sign`/`encrypt` — ignored for `-S none` (the SDK would otherwise check the GetEndpoints certificate even on an unsecured channel); off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits / for a zone-qualified IPv6 host | | `--verbose` | Enable debug-level Serilog console logging (default: warning) | **`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 9ed288728..fa4c18ccc 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -137,18 +137,37 @@ AdminUI browser (`OpcUaClientDriverBrowser.OpenAsync`) and Client.Shared (`Defau forwards `checkDomain: false`. So **no host-name check ever ran**; trust was the trusted-peer / issuer store alone. That is why, in #813, a trusted NAT certificate for `otopcua-opc-plc-nat-private.invalid` browsed fine over an IP. -Where the check lives when enabled: `Session.OpenAsync` → after `CertificateManager.ValidateAsync` accepts the chain, -`if (checkDomain) CertificateValidationExtensions.ValidateDomains(validator, serverCert, session.ConfiguredEndpoint)` -→ `CertificateValidationCore.ValidateDomains` → `CertificateValidationHelpers.FindDomain`, which compares -`ConfiguredEndpoint.EndpointUrl.IdnHost` with the certificate's DNS / IP SANs (IP literals normalised; a `localhost` -/ `127.0.0.1` / `::1` URL is compared as the machine's own host name + FQDN). A miss is `BadCertificateHostNameInvalid` -("The domain '…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook -first. Consequences: +Where the check lives when enabled — **two** places in SDK 2.0.1, both calling +`CertificateValidationExtensions.ValidateDomains` → `CertificateValidationCore.ValidateDomains` → +`CertificateValidationHelpers.FindDomain`: + +1. `DefaultSessionFactory.CreateChannelAsync` — before the channel opens, against the certificate in the + **GetEndpoints reply** (`endpoint.Description.ServerCertificate`), whenever that reply carries one and **before any + security-mode test**. opc-plc sends its certificate on its `None` endpoint too, so `checkDomain: true` refused a + `None` connect (found in review, reproduced against `:50000`). +2. `Session.OpenAsync` — after `CertificateManager.ValidateAsync` accepts the chain the server returned in + CreateSession (only when the channel or the user-token policy needs the server certificate). + +`FindDomain` compares `ConfiguredEndpoint.EndpointUrl.IdnHost` with the names `X509Utils.GetDomainsFromCertificate` +returns (DNS / IP SANs, and the subject's `DC=` parts); IP literals are normalised, and a `localhost` / `127.0.0.1` / +`::1` URL is compared as the machine's own host name + FQDN. A miss is `BadCertificateHostNameInvalid` ("The domain +'…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook first. +Consequences: - **It checks the CONFIGURED host.** `ConfiguredEndpoint.EndpointUrl` is the URL `OpcUaEndpointSelection` / `EndpointSelector` rewrote, so the advertised host the server named itself never takes part. -- **Secured sessions only** — the block runs only when the channel (or the user-token policy) needs the server - certificate; a `None` session validates no certificate at all. +- **Sign / SignAndEncrypt endpoints only — enforced here, not by the SDK.** Because of site 1, all three call sites + pass `checkDomain` only when the selected endpoint's `SecurityMode != None` (`OpcUaEndpointSelection.CheckDomainFor` + for the driver and the browser, the same expression in Client.Shared's `ConnectToEndpointAsync`; both cover the + failover sweep, which goes through the same per-endpoint open). A host-name check on an unauthenticated channel + proves nothing. +- **It is a loose match.** For a DNS host the SDK also accepts a match on the **first label alone**: + `plc1.site-a.corp` passes a certificate whose only SAN is `PLC1` (case-insensitive), and so would + `plc1.site-b.corp`. There is no wildcard support (`*.site-a.corp` matches nothing), and the subject's `DC=` + components count as names. +- **A zone-qualified IPv6 host never passes.** `FindDomain` compares the host *with* its zone (`fe80::1%eth0`; macOS + normalises `%en0` to `%14`) against the certificate's zone-less IP SANs, so a link-local endpoint (#819) needs + `validateServerHostName: false`. The repo does not fork the SDK check to strip the zone. - **`autoAcceptCertificates` accepts a mismatch too** (the driver's auto-accept `AcceptError` handler returns `true` for everything but revocation). The Client CLI always auto-accepts. - Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no @@ -164,7 +183,9 @@ the browser reads the same JSON): | `false` | Explicit opt-out — the NAT / container port-publish / connect-by-an-IP-the-cert-omits case. | When to turn it on: it earns its keep when trust comes from a **CA** (a trusted issuer vouches for every certificate -it signed, so only the host check ties the certificate to *this* server). With the server's own self-signed +it signed, so only the host check ties the certificate to *this* server) — within the limits above: a +first-label-only match means a certificate issued as `PLC1` for one site is accepted for `plc1` at every site, so give +certificates fully-qualified, site-unique names. With the server's own self-signed certificate pinned in the trusted-peer store it adds little — the pin already identifies the server. Client CLI: `--validate-host-name` (`ConnectionSettings.ValidateServerHostName`, default `false`). Pinned by `ServerHostNameValidationTests` (driver vs. the plain opc-plc, whose certificate names only its container id) and @@ -437,7 +458,7 @@ also refuses them explicitly — defence in depth (#277), not a behaviour change |---|---|---| | `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. | | `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. | -| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Secured sessions only; accepted anyway under `autoAcceptCertificates`. `false` = explicit NAT opt-out. See "Server certificate host name" above. | +| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Sign / SignAndEncrypt endpoints only (never passed for `None`); accepted anyway under `autoAcceptCertificates`; loose (first-label) match; never matches a zone-qualified IPv6 host. `false` = explicit NAT / link-local opt-out. See "Server certificate host name" above. | ### Subscription tuning (#273) diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs index ed95be7c6..8bb02aca5 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs @@ -61,7 +61,7 @@ public abstract class CommandBase : ICommand /// Gets a value indicating whether the server certificate must name the host given in (#816). /// [CommandOption("validate-host-name", - Description = "Require the server certificate to list the --url host (secured connections only; off by default — leave off behind NAT)")] + Description = "Require the server certificate to list the --url host (sign/encrypt only, never with --security none; off by default — leave off behind NAT)")] public bool ValidateHostName { get; init; } /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs index 952bda84b..233242dd9 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs @@ -44,8 +44,10 @@ public sealed class ConnectionSettings /// Gitea #816 — require the server certificate to list the host of the URL being connected (the operator's /// host: discovery keeps it in place of the server-advertised one) among its subject-alternative names; /// a mismatch fails the connect with BadCertificateHostNameInvalid. Defaults to false, the - /// behaviour before #816 (the SDK overload used never checked). Secured connections only. Leave it off behind - /// NAT / a port publish / when connecting by an IP the certificate does not name. + /// behaviour before #816 (the SDK overload used never checked). Applied to Sign / SignAndEncrypt endpoints only + /// — never passed for , where SDK 2.0.1 would check the GetEndpoints + /// certificate at channel creation anyway. Leave it off behind NAT / a port publish / when connecting by an IP + /// the certificate does not name / for a zone-qualified IPv6 host (the SDK compares the zone too). /// public bool ValidateServerHostName { get; set; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 012a3c261..8ea477560 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -794,8 +794,12 @@ public sealed class OpcUaClientService : IOpcUaClientService : new UserIdentity(); var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); + // #816: never on a None endpoint — SDK 2.0.1 also checks the host at channel creation against the + // GetEndpoints certificate, before any security-mode test, and on an unauthenticated channel that proves + // nothing (same rule as OpcUaEndpointSelection.CheckDomainFor for the driver and the AdminUI browser). + var checkDomain = settings.ValidateServerHostName && endpoint.SecurityMode != MessageSecurityMode.None; return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity, - settings.ValidateServerHostName, ct); + checkDomain, ct); } catch { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index f80b054bf..cb318298c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -115,8 +115,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser configuredEndpoint, updateBeforeConnect: false, // Gitea #816: the same host-name policy as the deployed driver (the SDK's overload without this - // argument hard-codes false), checked against the configured host the endpoint was rewritten to. - checkDomain: opts.CertificateValidation.EffectiveValidateServerHostName, + // argument hard-codes false), checked against the configured host the endpoint was rewritten to — + // and, like the driver, never on a None endpoint. + checkDomain: OpcUaEndpointSelection.CheckDomainFor( + endpointDesc, opts.CertificateValidation.EffectiveValidateServerHostName), sessionName: "OtOpcUa AdminUI Browse", (uint)opts.SessionTimeout.TotalMilliseconds, identity, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs index ea9824cc0..df2c4ca34 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs @@ -251,9 +251,12 @@ public sealed record OpcUaCertificateValidationOptions /// false = explicit opt-out — the NAT / port-publish / IP-addressed case, where the server's /// certificate names its private host and the reachable address is something else. /// - /// Only applies to secured sessions (a None channel validates no server certificate at all), and - /// accepts a host-name mismatch like any other - /// certificate error. + /// Only applies to Sign / SignAndEncrypt endpoints: the driver and browser never pass the SDK's + /// checkDomain for a None endpoint, because SDK 2.0.1 would otherwise check at channel creation + /// against the GetEndpoints certificate even there. + /// accepts a host-name mismatch like any other certificate error. A zone-qualified IPv6 host + /// ([fe80::1%eth0]) never matches (the SDK compares the host WITH its zone against the certificate's + /// zone-less IP SANs), so a link-local endpoint needs false. /// public bool? ValidateServerHostName { get; init; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs index f29898288..8b2d2fe70 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs @@ -46,6 +46,23 @@ public static class OpcUaEndpointSelection return match; } + /// + /// Gitea #816 — the checkDomain to hand the SDK for : the operator's choice, but + /// never on a endpoint. SDK 2.0.1 checks the host in TWO places — in + /// DefaultSessionFactory.CreateChannelAsync, against the certificate in the GetEndpoints reply, BEFORE any + /// security-mode test (opc-plc sends one even on its None endpoint), and in Session.OpenAsync once the + /// chain is trusted — so passing true on a None session refused it on a certificate the channel never + /// uses. A host-name check on an unauthenticated channel proves nothing. + /// + /// The endpoint the session will open on. + /// The configured (effective) host-name policy. + /// The checkDomain argument. + public static bool CheckDomainFor(EndpointDescription selected, bool validateServerHostName) + { + ArgumentNullException.ThrowIfNull(selected); + return validateServerHostName && selected.SecurityMode != MessageSecurityMode.None; + } + /// Convert a driver to the OPC UA policy URI. /// The driver security policy to map. /// The corresponding OPC UA security policy URI. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index c39baf985..acba83198 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -1061,12 +1061,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // Gitea #816: the SDK's 8-argument CreateAsync hard-codes checkDomain:false, so before #816 the server // certificate's host name was never checked. The check (when enabled) runs against endpoint.EndpointUrl — - // the CONFIGURED host, because SelectMatching rewrote the advertised one. + // the CONFIGURED host, because SelectMatching rewrote the advertised one — and never on a None endpoint + // (CheckDomainFor; the SDK would otherwise check at channel creation even there). var session = await new DefaultSessionFactory(_sdkTelemetry).CreateAsync( appConfig, endpoint, false, // updateBeforeConnect - _options.CertificateValidation.EffectiveValidateServerHostName, // checkDomain (#816) + OpcUaEndpointSelection.CheckDomainFor( // checkDomain (#816) + selected, _options.CertificateValidation.EffectiveValidateServerHostName), _options.SessionName, (uint)_options.SessionTimeout.TotalMilliseconds, identity, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index d080f2dc9..115cb5eb7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -199,7 +199,7 @@ -
Secured sessions only: the server certificate must name the endpoint host you configured.
+
Sign / SignAndEncrypt only (never on None): the server certificate must name the endpoint host you configured. Off for link-local IPv6.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs index 104f20fbf..c021b3966 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs @@ -56,21 +56,28 @@ public class OpcUaClientServiceTests : IDisposable } /// - /// Gitea #816: the host-name check reaches the session factory (the SDK's checkDomain) — off by default, the - /// pre-#816 behaviour — and survives the per-endpoint settings copy the connect path makes. + /// Gitea #816: the host-name check reaches the session factory (the SDK's checkDomain) on a SECURED endpoint — off + /// by default, the pre-#816 behaviour — and survives the per-endpoint settings copy the connect path makes. On a + /// None endpoint it is never passed: SDK 2.0.1 would check the GetEndpoints certificate at channel creation even + /// there (review of #816, reproduced against opc-plc). /// [Theory] - [InlineData(null)] - [InlineData(true)] - [InlineData(false)] - public async Task ConnectAsync_threads_ValidateServerHostName_to_the_session(bool? validate) + [InlineData(SecurityMode.Sign, null, false)] + [InlineData(SecurityMode.Sign, true, true)] + [InlineData(SecurityMode.SignAndEncrypt, true, true)] + [InlineData(SecurityMode.Sign, false, false)] + [InlineData(SecurityMode.None, true, false)] + [InlineData(SecurityMode.None, null, false)] + public async Task ConnectAsync_threads_ValidateServerHostName_to_secured_sessions_only( + SecurityMode mode, bool? validate, bool expectedCheckDomain) { var settings = ValidSettings(); + settings.SecurityMode = mode; if (validate is { } v) settings.ValidateServerHostName = v; await _service.ConnectAsync(settings, TestContext.Current.CancellationToken); - _sessionFactory.LastValidateServerHostName.ShouldBe(validate ?? false); + _sessionFactory.LastValidateServerHostName.ShouldBe(expectedCheckDomain); _configFactory.LastSettings!.ValidateServerHostName.ShouldBe(validate ?? false); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs index 5e66a574a..dcb736d38 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs @@ -190,6 +190,21 @@ public sealed class NatPublishedEndpointBrowseTests : IDisposable children.Count.ShouldBeGreaterThan(0); } + /// + /// Review of #816: with the host-name check ON, an unsecured browse still opens although the NAT certificate (sent + /// in the GetEndpoints reply even for the None endpoint) names another host — the check never runs on None. + /// + [Fact] + public async Task Host_name_validation_does_not_apply_to_a_None_browse() + { + var endpoint = RequireFixture(); + var ct = TestContext.Current.CancellationToken; + await AssertAdvertisesUnreachableAddressAsync(endpoint, ct); + + await using var session = await Browser().OpenAsync(Json(endpoint, validateHostName: true), ct); + (await session.RootAsync(ct)).Count.ShouldBeGreaterThan(0); + } + [Fact] public async Task Unreachable_configured_endpoint_still_fails_cleanly() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs index 1687d7d06..6c84ec382 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs @@ -89,6 +89,27 @@ public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposab snapshots[0].StatusCode.ShouldBe(0u, "the secured session must read once the server certificate is trusted"); } + /// + /// Review of #816 (reproduced live): SDK 2.0.1 also checks the host at channel creation, against the certificate + /// in the GetEndpoints reply — which opc-plc sends on its None endpoint too — before any security-mode test. The + /// driver must not pass checkDomain for a None session, or the flag refuses an unsecured connect. + /// + [Fact] + public async Task With_host_name_validation_on_a_None_session_still_connects() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl) with + { + AutoAcceptCertificates = false, + CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = true }, + }; + + await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-816-none") { PkiRootOverride = _pkiRoot }; + await drv.InitializeAsync("{}", ct); + (await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct))[0].StatusCode.ShouldBe(0u); + } + [Fact] public async Task With_host_name_validation_the_certificate_is_checked_against_the_configured_host() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs index a4c27f1b7..8208ab004 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs @@ -43,6 +43,21 @@ public sealed class OpcUaClientServerHostNameOptionTests json.ShouldNotContain("Effective"); } + /// + /// Review of #816: SDK 2.0.1 also checks the host in DefaultSessionFactory.CreateChannelAsync, against the + /// GetEndpoints certificate and before any security-mode test, so checkDomain is never passed for a None endpoint. + /// + [Theory] + [InlineData(MessageSecurityMode.None, true, false)] + [InlineData(MessageSecurityMode.None, false, false)] + [InlineData(MessageSecurityMode.Sign, true, true)] + [InlineData(MessageSecurityMode.SignAndEncrypt, true, true)] + [InlineData(MessageSecurityMode.SignAndEncrypt, false, false)] + public void CheckDomain_is_passed_only_for_secured_endpoints(MessageSecurityMode mode, bool validate, bool expected) + { + OpcUaEndpointSelection.CheckDomainFor(new EndpointDescription { SecurityMode = mode }, validate).ShouldBe(expected); + } + [Fact] public void A_host_name_refusal_names_the_opt_out() { From 9d91c418472491fbe2788cfaee11e8a6855209da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:54:01 -0500 Subject: [PATCH 13/16] docs: MIN_EXECUTED raise is 448 after the #816 review tests; fixture-label note reflects reality --- .github/workflows/v2-integration.yml | 2 +- CLAUDE.md | 2 +- scripts/ci/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/v2-integration.yml b/.github/workflows/v2-integration.yml index aa0af29eb..039480404 100644 --- a/.github/workflows/v2-integration.yml +++ b/.github/workflows/v2-integration.yml @@ -74,7 +74,7 @@ jobs: - name: assert not-all-skipped if: always() env: - # #816/#818 added 6 executing OpcUaClient tests; raise to 446 once a hosted run confirms them + # #816/#818 added 8 executing OpcUaClient tests; raise to 448 once a hosted run confirms them # (scripts/ci/README.md §"Fixture-backed integration"). MIN_EXECUTED: "440" run: bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx diff --git a/CLAUDE.md b/CLAUDE.md index 8d6e834a9..89375e172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ central SQL Server. > **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28). -> ⚠️ **The `project: lmxopcua` label is aspirational, not universal.** This note used to claim every fixture compose file carries it. As of 2026-07-24 only the **MQTT** fixture actually does (joined 2026-11-10 by the OpcUaClient port-NAT fixture, `Docker/nat/`, #813) — `grep -rn "labels:" tests --include=*.yml` matches `Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` and nothing else. So `docker ps --filter label=project=lmxopcua` returns the MQTT fixture alone, not the fleet. Add the label when you touch an older fixture; until then enumerate by container name. +> ⚠️ **The `project: lmxopcua` label is not universal.** As of 2026-11-11 it is carried by the MQTT, Modbus, FOCAS, MTConnect, Sql (SQL Server/Postgres/MySQL/Oracle), QuestDB historian and OpcUaClient port-NAT fixtures (`grep -rln lmxopcua tests --include=docker-compose.yml`); older stacks (e.g. AB CIP, S7, the plain opc-plc) do not, so `docker ps --filter label=project=lmxopcua` is not the whole fleet. Add the label when you touch an older fixture; until then enumerate by container name. Docker workloads run on a shared Linux host at **`10.100.0.35`** — not on this VM. Stacks live at `/opt/otopcua-/` on the host and carry the `project=lmxopcua` label so they're discoverable via `docker ps --filter label=project=lmxopcua`. diff --git a/scripts/ci/README.md b/scripts/ci/README.md index a1393437b..567fb2ff4 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -80,7 +80,7 @@ Neither touches `v2-ci.yml`; each has its own concurrency group (`lmxopcua-v2-in now exports the NAT container's public certificate as `OPCUA_NAT_SERVER_CERT`). Locally (2026-11-11, shared host) the two OpcUaClient projects went 13 → 19 executed with 0 skipped, and `assert-not-all-skipped.sh` counted them. Once a hosted run's TRX shows `Driver.OpcUaClient.trx` executed=8 and `Driver.OpcUaClient.Browser.trx` executed=11, - raise `MIN_EXECUTED` in `v2-integration.yml` **440 → 446**. If the NAT three show as skipped there, the certificate + raise `MIN_EXECUTED` in `v2-integration.yml` **440 → 448** (and `v2-e2e.yml` 35 → 39). If the NAT three show as skipped there, the certificate hand-off (`docker exec … /app/pki/own/certs/*.der`) did not work on the runner — fix that, do not lower the bump. - **`v2-e2e.yml`** (manual + weekly cron only; `timeout-minutes: 90`). `probe-e2e-fixtures.sh` TCP-probes 10.100.0.35 ports 5020/1102/50000; if any is closed every later step is skipped and the run is green with a From 97991e047609c3d025bd0caf3e0e1180dd9590b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:54:32 -0500 Subject: [PATCH 14/16] test(heidenhain): wait for the whole outage-invalidation pass before asserting on it (pre-existing race) Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values failed 1/40 on base 5bbe1e3c (and 1/29 on this branch): "Capabilities/Identity should be CommunicationLost but was Supported". RetireAll -> Invalidate publishes every tag in authored order under one lock, capability LAST, and the recorder sees each publish as it happens; the test asserted the capability the instant the five value tags read BadNoCommunication, i.e. before the pass's final publish. Now the wait includes the capability's CommunicationLost (a presence the pass must produce) and additionally asserts no value went Good again during the outage. Second latent race fixed in the same test: Degraded holds through Backoff AND Connecting, but the host status is Stopped only in Backoff (Unknown while a reconnect attempt is in flight), so the test now waits for Degraded && Stopped instead of sampling whichever phase it hit. After the fix: 79/80 filtered runs passed and 10/10 full-suite runs; the one non-pass was a vstest "failed to connect to testhost after 90 s" host-startup timeout (the test never ran), not a test result. --- .../HeidenhainDriverTests.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs index 4ef3030e4..c69b67a22 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs @@ -151,13 +151,26 @@ public sealed class HeidenhainDriverTests sim.GoOffline(); // EVERY remote leaf — fast status, slow identity/alarms and custom PLC — goes Bad at once. - await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication)); - rec.Last(tags[5].RawPath)!.Value.ShouldBe("CommunicationLost"); + // The invalidation is ONE pass over the device's tags (in authored order, capability last), delivered to this + // thread's recorder as it runs — so wait for the WHOLE pass to land (the capability's CommunicationLost is + // its last publication) before asserting on any of it. Asserting the capability the instant the five value + // tags read Bad raced the pass's final publish (1/40 on 5bbe1e3c: "Supported"). + await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication) + && rec.Last(tags[5].RawPath)?.Value is "CommunicationLost"); rec.Last(tags[5].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.Good); + // ...and it is one pass: no remote value published Good again after its outage invalidation. + foreach (var t in tags.Take(5)) + { + var seq = rec.For(t.RawPath).Select(s => s.StatusCode).ToList(); + seq.Skip(seq.LastIndexOf(HeidenhainStatus.BadNoCommunication)).ShouldAllBe(s => s == HeidenhainStatus.BadNoCommunication); + } + (await d.ReadAsync([tags[2].RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication); - await WaitUntil(() => d.GetHealth().State == DriverState.Degraded); + // Degraded holds through every reconnect attempt (Backoff AND Connecting carry the last error), but the host + // status reads Stopped only in Backoff and Unknown while a reconnect attempt is in flight — so wait for the + // Backoff phase rather than sample whichever phase the loop happens to be in. + await WaitUntil(() => d.GetHealth().State == DriverState.Degraded && d.GetHostStatuses().Single().State == HostState.Stopped); d.GetHealth().LastError.ShouldNotBeNullOrEmpty(); - d.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped); hostEvents.ShouldContain(e => e.NewState == HostState.Stopped); var frozen = d.GetHealth().LastSuccessfulRead; await Task.Delay(500, Ct); From 4563940792e5c59b2eb5ac75f26808ef539b91ef Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 02:07:33 -0500 Subject: [PATCH 15/16] fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815) User decision: save, Test Connect, live browse and the deploy gate keep refusing out-of-namespace credential references, but an ALREADY-DEPLOYED driver must not fault at the upgrade restart or (Galaxy) at the next gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef. - LegacyCredentialReferenceTracker (Core.Abstractions): the one, deprecated, call-site-chosen compatibility path. Running Galaxy (InitializeAsync, ReopenAsync, ack fallback) and OpcUaClient (session open) pass it and keep the pre-#815 semantics (case-insensitive prefixes, Galaxy file:, OpcUaClient literal pass-through) with a rate-limited Warning (driver/field/allowed form, never the value) and a credential-compat: diagnostics entry (both drivers now implement IDriverDiagnostics). Browsers and probes pass none and stay hard-fail; Heidenhain has none. - Deploy-gate message names the migration. - Upgrade preflight: CredentialReferencePreflightService (admin nodes, once after ApplicationStarted, ConfigDb only, resolves nothing) logs a Warning per Draft row / Deployed-artifact finding; the /raw banner shows them plus Running drivers reporting warn-only use (driver-health diagnostics). - docs/security.md: exact replacement table (incl. the old form default env:MX_API_KEY, file:, un-namespaced secret:, casing changes), the ReopenAsync reconnect behaviour, the warn-only window and its removal. Co-Authored-By: Claude --- .claude/rules/drivers.md | 5 + docs/drivers/Galaxy.md | 2 +- docs/drivers/OpcUaClient.md | 3 +- docs/security.md | 61 +++-- .../CredentialReferenceDeployChecks.cs | 14 +- .../LegacyCredentialReferenceTracker.cs | 82 +++++++ .../GalaxySecretRef.cs | 62 +++++- .../GalaxyDriver.cs | 21 +- .../Runtime/GalaxyAckCredentialResolver.cs | 34 ++- .../OpcUaUserIdentityFactory.cs | 39 +++- .../OpcUaClientDriver.cs | 14 +- .../OpcUaClientSecretResolution.cs | 6 +- .../Components/Pages/Raw/GlobalRaw.razor | 2 + .../Raw/CredentialReferenceBanner.razor | 85 +++++++ .../CredentialReferencePreflight.cs | 209 ++++++++++++++++++ .../EndpointRouteBuilderExtensions.cs | 4 + .../DraftValidatorCredentialReferenceTests.cs | 1 + .../GalaxyDriverBrowserTests.cs | 17 ++ .../GalaxyDriverLegacyCredentialTests.cs | 146 ++++++++++++ .../OpcUaClientSecretResolutionTests.cs | 29 +++ .../CredentialReferencePreflightTests.cs | 177 +++++++++++++++ ...dminOperationsActorCredentialScopeTests.cs | 1 + 22 files changed, 980 insertions(+), 34 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index ba4bf4094..9456c6b89 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -326,6 +326,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa probe), browse (`BrowserSessionService`, before the browser) and at resolve time before any store/env read; `file:` is refused everywhere. A NEW driver that resolves a `secret:`/`env:` field and sends it to an authored endpoint MUST add a row there and resolve through `CredentialReferences` — never `ISecretResolver.GetAsync` on a raw authored name. + ⚠️ **Deprecated warn-only window:** a RUNNING Galaxy (init, every `ReopenAsync` gateway reconnect, ack fallback) or + OpcUaClient (session open) driver passes its `LegacyCredentialReferenceTracker` and so still resolves a pre-#815 + out-of-namespace ref with the OLD semantics + a rate-limited Warning + a `credential-compat:` diagnostics entry; + browsers/probes pass none (hard-fail), Heidenhain never has one. Chosen by call site — do not add a global flag, and do + not hand the tracker to an admin-node path. Upgrade preflight: `CredentialReferencePreflightService` + the `/raw` banner. `docs/security.md` §"Credential references are namespaced per driver". - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 39a97d5b6..b768aaed8 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -81,7 +81,7 @@ Project root files: `DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are: -- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815, [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. +- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815 (an already-deployed driver keeps resolving a pre-#815 ref on init and every gateway reconnect in deprecated warn-only mode until its next deploy), [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. - **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric). - **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`. - **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag). diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 2e2b2b92a..0e69a6442 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -84,7 +84,8 @@ factory, the Test-Connect probe and the browse picker (unknown keys are skipped) **Credentials (#815).** `Password` / `UserCertificatePassword` are a literal, `secret:opcuaclient/` (the node's secret store) or `env:OTOPCUA_OPCUACLIENT_`. They are resolved on the node and sent to the configured endpoint, so any other `secret:`/`env:` reference (and `file:`) is refused at save, deploy (`CredentialReferenceOutOfScope`), Test -Connect, browse and again at session open, before anything is read. Migrate an existing `secret:` by re-creating +Connect, browse and (for the AdminUI browser) again before anything is read; an already-deployed driver's session open +keeps the pre-#815 semantics in deprecated warn-only mode until its next deploy. Migrate an existing `secret:` by re-creating the secret as `opcuaclient/`. See [`docs/security.md`](../security.md) §"Credential references are namespaced per driver". `ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten diff --git a/docs/security.md b/docs/security.md index 20e59cfa4..e8b491025 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1178,28 +1178,59 @@ Enforced, fail-closed, at every seam — the earlier ones exist for a clear mess `HeidenhainCredentialPersisted`). 3. **Test Connect** — `AdminOperationsActor` refuses before any probe runs (`"Test Connect refused: …"`). 4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens. -5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` (driver + - browser) and `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope - **before** any secret-store or environment read. +5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` and + `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope **before** any + secret-store or environment read. This is hard-fail on every admin-node path (the Galaxy and OpcUaClient + AdminUI browsers, every Test Connect probe) and everywhere for Heidenhain. The one exception is the deprecated + warn-only compatibility path for already-deployed drivers, below. Messages name the field and the allowed forms, never the value. Out of scope by design: `${secret:}` tokens in `appsettings.json` (host config, not operator-authored), and the `Sql` driver's `connectionStringRef`, which is already a fixed prefix (`Sql__ConnectionStrings__`) whose value carries its own server — a Designer can choose *which* provisioned database a ref names, but cannot redirect its credentials to another host. -**Migration (existing configs).** After the upgrade, a stored config with an out-of-namespace reference fails -its next deploy (`CredentialReferenceOutOfScope`), its Test Connect / browse, and — for a running driver — its -next (re)initialise, which faults the driver with the same message. To migrate: +**Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the upgrade +preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message names the +migration), and refused by Test Connect / live browse. Values most likely to be in real config databases, with +the exact replacement (create the new secret / variable first — on every node that runs the driver **and** on +the admin nodes, which run Test Connect and browse — then edit the field): -- **Galaxy API key** — `env:GALAXY_MXGW_API_KEY` (the docker-dev rig and docs' name) keeps working. Any other - variable: rename it to `OTOPCUA_GALAXY_` on every node that runs the driver *and* the admin nodes - (Test Connect / browse), and set `env:OTOPCUA_GALAXY_`. A store secret already named `galaxy/…` keeps - working; otherwise re-create it as `galaxy//apikey` (`/admin/secrets` or the `secret` CLI). A - `file:` key must move into the store or a namespaced variable. -- **Galaxy fallback ack password** — the documented `secret:otopcua/galaxy/…` name keeps working; `env:` / `file:` - references move as above. -- **OpcUaClient** — re-create `secret:` as `opcuaclient/` (and update the field), or move an - environment value to `OTOPCUA_OPCUACLIENT_`. Literal passwords are unaffected. +| Old value | Why it is refused | Replace with | +|---|---|---| +| Galaxy `env:MX_API_KEY` — the **old AdminUI Galaxy form default** (pre-#815 `GalaxyDriverForm.razor`), so the likeliest value in a real DB | variable outside the namespace | rename the variable to `OTOPCUA_GALAXY_API_KEY` and set `env:OTOPCUA_GALAXY_API_KEY` (or store it and use `secret:galaxy//apikey`) | +| Galaxy `env:` (e.g. `env:MY_API_KEY`) | same | variable `OTOPCUA_GALAXY_` → `env:OTOPCUA_GALAXY_`. `env:GALAXY_MXGW_API_KEY` (docker-dev, parity docs) needs **no** change | +| Galaxy `file:` — `docs/Configuration.md` used to recommend it for production | `file:` is refused everywhere | put the file's contents in the store as `galaxy//apikey` → `secret:galaxy//apikey` (or a variable `OTOPCUA_GALAXY_`) | +| Galaxy `secret:` not under `galaxy/` (e.g. the old `docs/v2/driver-specs.md` example `secret:galaxy-gw-api-key`) | secret outside the namespace | re-create it as `galaxy/` (e.g. `galaxy/gw/apikey`) → `secret:galaxy/gw/apikey`. Names already under `galaxy/` or `otopcua/galaxy/` need no change | +| Galaxy `Env:X`, `SECRET:x`, `File:x`, … (any non-lower-case prefix) — **pre-#815 resolved these case-insensitively** | prefixes are now exact | the lower-case namespaced form from the rows above | +| Galaxy `AlarmAck.FallbackPasswordSecretRef` = `env:` / `file:` / `secret:` outside the namespace | same | as above; the documented `secret:otopcua/galaxy/` needs no change | +| OpcUaClient `Password` / `UserCertificatePassword` = `secret:` not under `opcuaclient/` (e.g. `secret:opcua/plant/pw`) | secret outside the namespace | re-create the secret as `opcuaclient/` → `secret:opcuaclient/` | +| OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/` | +| OpcUaClient **literal** password that happens to start with `env:`, `file:`, `secret:`/`${secret:` in any casing (e.g. a real password `Env:Prod1`) — pre-#815 sent it verbatim | it now parses as a reference | store it: `secret:opcuaclient/` (a literal that does not start with one of those prefixes is unaffected) | + +**Warn-only compatibility for already-deployed drivers (⚠️ deprecated — a later release fails closed).** Refusing +at resolve time would fault running plant drivers at the upgrade restart — and, for Galaxy, at **every gateway +reconnect**: `GalaxyDriver.ReopenAsync` (the `ReconnectSupervisor` callback) rebuilds the client options through +`BuildClientOptionsAsync`, which re-resolves `Gateway.ApiKeySecretRef` each time, so a running driver would fault +at its next reconnect, not at a deploy. So, for one compatibility window, the running-driver call sites — Galaxy +`InitializeAsync` / `ReopenAsync` / the ack-fallback resolver, and OpcUaClient session open — resolve an +out-of-namespace reference with the **old** semantics (Galaxy: case-insensitive `env:`/`file:`/`dev:`/`secret:`, +literal otherwise; OpcUaClient: case-insensitive `secret:`, literal otherwise) and log a Warning naming the +driver, field and allowed form (never the value), at most once per field per hour. The use also rides the +driver's diagnostics bag (`credential-compat:`) to `/hosts` and to the `/raw` credential banner. This is +chosen **by call site**, not by a flag (`LegacyCredentialReferenceTracker`): the AdminUI browsers and every probe +never get the tracker and stay hard-fail, and Heidenhain (new driver, no legacy configs) is hard-fail everywhere. +It cannot be used to exfiltrate: save, Test Connect, browse and the deploy gate all refuse such a reference, so it +can only be the reference an administrator deployed **before** the upgrade, and it lives only until that driver's +next successful deploy (which requires migrating it). The hard-fail release deletes the tracker, the per-driver +`Legacy…` resolve methods and their `legacy` parameters — nothing else changes. + +**Upgrade preflight.** On an admin node, `CredentialReferencePreflightService` runs once, 15 s after +`ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only). It scans +every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft**) and the latest +deployment artifact (**Deployed** — what the running drivers were given) and logs one Warning per finding: +driver, device, field, allowed form, never the value. The same list, plus running drivers that reported a +warn-only resolution (**Running**, from driver-health diagnostics, fleet-wide), is shown as a banner on `/raw`, +with a Re-check button. Residual (accepted): within one namespace, a Designer can still point one driver instance at another instance's secret (e.g. `secret:galaxy//apikey`) — the value then reaches the endpoint the Designer diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs index 5badc1b7f..6b62bf087 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs @@ -20,6 +20,16 @@ public static class CredentialReferenceDeployChecks /// A credential field holding a reference outside the driver's namespace (or a refused literal). public const string Code = "CredentialReferenceOutOfScope"; + /// + /// Appended to every error: a config deployed before #815 is still RUNNING in warn-only compatibility mode, and + /// this deploy is where it stops — so the message names the migration, not just the rule. + /// + public const string MigrationHint = + " Migrate it before deploying: create the value under the allowed name (secret store via /admin/secrets or the " + + "secret CLI, or the environment variable on every node that runs this driver AND the admin nodes) and point the " + + "field at it. A driver deployed before this upgrade keeps resolving the old reference (warn-only, deprecated) " + + "only until this deploy succeeds. See docs/security.md §\"Credential references are namespaced per driver\"."; + /// Runs the gate. /// The draft. /// The errors. @@ -32,12 +42,12 @@ public static class CredentialReferenceDeployChecks if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue; foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig)) - errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}", driver.DriverInstanceId)); + errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId)); foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))) { foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig)) - errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}", device.DeviceId)); + errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId)); } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs new file mode 100644 index 000000000..4138e552f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential +/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy +/// InitializeAsync/ReopenAsync + ack fallback, OpcUaClient session open) passes its instance's tracker +/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of +/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass +/// and stay hard-fail. +/// +/// +/// +/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every +/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed +/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs +/// only until its next deploy (which the gate refuses until it is migrated). +/// +/// +/// Removing it (the planned hard-fail release): delete this type, the per-driver Legacy… resolve +/// methods it gates and the legacy parameters; every caller then takes the enforce branch it already +/// has. Nothing else changes. +/// +/// +/// Each use is reported through at most once per per field +/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as so the +/// driver's bag carries it to the AdminUI (/hosts and the /raw +/// credential banner). Messages name the driver, field and allowed forms — never the value. +/// +/// +/// The driver's scope (for the allowed forms). +/// The driver instance (for messages). +/// Sink for a rate-limited warning line. +/// Clock (tests); null ⇒ . +public sealed class LegacyCredentialReferenceTracker( + CredentialScope scope, string driverInstanceId, Action warn, Func? clock = null) +{ + /// The key prefix (one entry per field). + public const string DiagnosticsKeyPrefix = "credential-compat:"; + + /// Minimum spacing between two warnings for the same field. + public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1); + + private readonly object _gate = new(); + private readonly Dictionary _lastWarned = new(StringComparer.Ordinal); + private readonly SortedDictionary _diagnostics = new(StringComparer.Ordinal); + + /// The scope this tracker reports against. + public CredentialScope Scope => scope; + + /// The current legacy uses, keyed credential-compat:<field>. + public IReadOnlyDictionary Diagnostics + { + get + { + lock (_gate) return new Dictionary(_diagnostics, StringComparer.Ordinal); + } + } + + /// Records that was resolved through the legacy path (warns, rate-limited). + /// The field label. + public void Report(string field) + { + var forms = $"secret:{scope.SecretNamespaces[0]} or env:{scope.EnvVariablePrefixes[0]}"; + var now = (clock ?? (() => DateTime.UtcNow))(); + bool log; + lock (_gate) + { + _diagnostics[DiagnosticsKeyPrefix + field] = + $"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}"; + log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval; + if (log) _lastWarned[field] = now; + } + + if (log) + { + warn($"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} " + + $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only " + + $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate " + + $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\"."); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs index 5e685ad6d..7af820c19 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs @@ -36,19 +36,32 @@ public static class GalaxySecretRef /// Optional logger for warning on cleartext keys. /// Cancellation token for the async secret: resolution. /// Environment reader (tests); null ⇒ the process environment. + /// + /// ⚠️ Deprecated #815 compatibility: the RUNNING driver passes its tracker, so a pre-#815 out-of-namespace + /// reference is resolved with the old semantics and warned about instead of failing. The AdminUI browser + /// passes null (hard-fail). See . + /// /// The resolved API-key string. public static async Task ResolveApiKeyAsync( string secretRef, ISecretResolver resolver, ILogger? logger = null, CancellationToken ct = default, - Func? environment = null) + Func? environment = null, + LegacyCredentialReferenceTracker? legacy = null) { ArgumentException.ThrowIfNullOrEmpty(secretRef); ArgumentNullException.ThrowIfNull(resolver); var scope = DriverCredentialPolicy.GalaxyScope; - if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal) + var kind = CredentialReferences.Parse(secretRef, scope, out _); + if (kind == CredentialReferenceKind.OutOfScope && legacy is not null) + { + legacy.Report("Gateway.ApiKeySecretRef"); + return await LegacyResolveApiKeyAsync(secretRef, resolver, logger, ct, environment).ConfigureAwait(false); + } + + if (kind == CredentialReferenceKind.Literal) { if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)) { @@ -74,4 +87,49 @@ public static class GalaxySecretRef environment, ct).ConfigureAwait(false); } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim — case-insensitive env: / file: / dev: / secret:, anything else a + /// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. + /// + private static async Task LegacyResolveApiKeyAsync( + string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func? environment) + { + if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + { + var name = secretRef[4..]; + var value = (environment ?? Environment.GetEnvironmentVariable)(name); + return !string.IsNullOrEmpty(value) + ? value + : throw new InvalidOperationException( + $"Galaxy.Gateway.ApiKeySecretRef resolves to env var '{name}', but it is unset."); + } + + if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + var path = secretRef[5..]; + if (!File.Exists(path)) + throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef points at '{path}', which doesn't exist."); + var contents = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim(); + return !string.IsNullOrEmpty(contents) + ? contents + : throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef file '{path}' is empty."); + } + + if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return secretRef[4..]; + + if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) + { + var name = secretRef["secret:".Length..]; + var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); + return !string.IsNullOrEmpty(value) + ? value + : throw new InvalidOperationException( + $"Galaxy.Gateway.ApiKeySecretRef resolves secret '{name}', but it is absent from the store (fail-closed)."); + } + + logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key."); + return secretRef; + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 72e5b4a70..d1be1bb04 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -38,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// /// public sealed class GalaxyDriver - : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDisposable, IAsyncDisposable + : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable { + // ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815 + // out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the + // ack fallback password) with the old semantics, warning rate-limited and surfacing it on the diagnostics bag. + // The AdminUI browser and the probe never get a tracker, so they stay hard-fail. + private readonly LegacyCredentialReferenceTracker _legacyCredentials; + + /// + IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; + /// Authored tags this driver could not dial — a TagConfig with no usable /// attributeRef has no MXAccess address to bind. private readonly SkippedTagLedger _skippedTags = new(); @@ -342,6 +351,8 @@ public sealed class GalaxyDriver _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; + _legacyCredentials = new LegacyCredentialReferenceTracker( + DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message)); _hierarchySource = hierarchySource; _dataReader = dataReader; _dataWriter = dataWriter; @@ -349,7 +360,7 @@ public sealed class GalaxyDriver _alarmAcknowledger = alarmAcknowledger; _alarmFeed = alarmFeed; _ackCredentialResolver = ackCredentialResolver - ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger); + ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials); // Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags // are authored (live-browse path), leaving every translation an identity no-op. @@ -1098,7 +1109,9 @@ public sealed class GalaxyDriver } } - private async Task BuildClientOptionsAsync( + // Internal: the ONE client-options build InitializeAsync, ReopenAsync (every gateway reconnect) and the + // repository/alarm clients share — the #815 warn-only tests drive it directly to prove a reconnect re-resolves. + internal async Task BuildClientOptionsAsync( GalaxyGatewayOptions gw, CancellationToken cancellationToken) { // Resolve the API-key ref BEFORE the object initializer — the secret: arm is @@ -1108,7 +1121,7 @@ public sealed class GalaxyDriver // (GalaxySecretRef) so the runtime driver and the AdminUI browser share one // implementation; the secret: arm resolves through the shared ISecretResolver. var apiKey = await GalaxySecretRef - .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken) + .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials) .ConfigureAwait(false); return new MxGatewayClientOptions { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs index 77b2ad751..6dc58a0a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs @@ -22,6 +22,7 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver private readonly ISecretResolver _secrets; private readonly ILogger _logger; private readonly Func? _environment; + private readonly LegacyCredentialReferenceTracker? _legacy; /// Initializes a new instance of the class. /// The owning driver instance id (for messages). @@ -29,11 +30,13 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver /// The shared secret resolver. /// Logger; never receives a secret. /// Environment reader (tests); null ⇒ the process environment. + /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. public GalaxyAckCredentialResolver( string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger, - Func? environment = null) + Func? environment = null, LegacyCredentialReferenceTracker? legacy = null) { _environment = environment; + _legacy = legacy; _driverInstanceId = driverInstanceId; _options = options ?? new GalaxyAlarmAckOptions(); _secrets = secrets ?? throw new ArgumentNullException(nameof(secrets)); @@ -156,6 +159,13 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver ///
internal async Task ResolvePasswordRefAsync(string reference, CancellationToken ct) { + if (_legacy is not null + && CredentialReferences.Parse(reference, DriverCredentialPolicy.GalaxyScope, out _) == CredentialReferenceKind.OutOfScope) + { + _legacy.Report("AlarmAck.FallbackPasswordSecretRef"); + return await LegacyResolvePasswordRefAsync(reference, ct).ConfigureAwait(false); + } + var resolved = await CredentialReferences.ResolveOrNullAsync( reference, DriverCredentialPolicy.GalaxyScope, @@ -169,6 +179,28 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver : resolved; } + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. + /// + private async Task LegacyResolvePasswordRefAsync(string reference, CancellationToken ct) + { + if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}')) + reference = "secret:" + reference["${secret:".Length..^1]; + if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) + return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false); + if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) + return (_environment ?? Environment.GetEnvironmentVariable)(reference[4..]); + if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + var path = reference[5..]; + return File.Exists(path) ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') : null; + } + + if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return reference[4..]; + throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/dev: reference, not a literal."); + } + // Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal. internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index f1433c253..e3e111f90 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -115,17 +115,22 @@ public static class OpcUaUserIdentityFactory /// The shared secret resolver, or null when none is available. /// Cancellation token. /// Environment reader (tests); null ⇒ the process environment. + /// + /// ⚠️ Deprecated #815 compatibility: the RUNNING driver's session open passes its tracker, so a pre-#815 + /// out-of-namespace value keeps the old semantics (a case-insensitive secret: resolves, anything else is + /// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail). + /// /// An options copy whose credential fields carry resolved plaintext. public static async Task ResolveSecretRefsAsync( OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct, - Func? environment = null) + Func? environment = null, LegacyCredentialReferenceTracker? legacy = null) { ArgumentNullException.ThrowIfNull(options); - var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment) + var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy) .ConfigureAwait(false); var certPassword = await ResolveFieldAsync( - options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment) + options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy) .ConfigureAwait(false); if (ReferenceEquals(password, options.Password) @@ -139,12 +144,18 @@ public static class OpcUaUserIdentityFactory private static async Task ResolveFieldAsync( string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct, - Func? environment) + Func? environment, LegacyCredentialReferenceTracker? legacy) { // A literal (or blank) passes through verbatim, reference-equal — the author already knows it. var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _); if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value; + if (kind == CredentialReferenceKind.OutOfScope && legacy is not null) + { + legacy.Report(fieldName); + return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false); + } + // lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and // env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read. return await CredentialReferences.ResolveAsync( @@ -156,4 +167,24 @@ public static class OpcUaUserIdentityFactory environment, ct).ConfigureAwait(false); } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim — a case-insensitive secret: resolves (fail-closed), anything else passes through as a + /// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver. + /// + private static async Task LegacyResolveFieldAsync( + string value, string fieldName, ISecretResolver? resolver, CancellationToken ct) + { + if (!value.StartsWith(CredentialReferences.SecretPrefix, StringComparison.OrdinalIgnoreCase)) return value; + var name = value[CredentialReferences.SecretPrefix.Length..]; + if (resolver is null) + throw new InvalidOperationException( + $"OpcUaClientDriverOptions.{fieldName} references secret '{name}', but no secret store is available here (fail-closed)."); + var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); + return !string.IsNullOrEmpty(resolved) + ? resolved + : throw new InvalidOperationException( + $"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed)."); + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4d1820985..150a131e1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,8 +29,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// back on its own recovers in place. /// /// -public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable +public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, IDisposable, IAsyncDisposable { + // ⚠️ lmxopcua#815 warn-only compatibility (deprecated): the session open of this RUNNING driver keeps + // resolving a pre-#815 out-of-namespace credential with the old semantics, warning rate-limited and surfacing + // it on the diagnostics bag. The AdminUI browser never gets a tracker, so it stays hard-fail. + private readonly LegacyCredentialReferenceTracker _legacyCredentials; + + /// + IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; + /// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node /// to dial. This driver's skip path was the quietest of the twelve: NamespaceMap /// drops such an entry with a bare continue and not even a log line. @@ -82,6 +90,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _diag = new OpcUaClientDiagnostics(driverInstanceId); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; + _legacyCredentials = new LegacyCredentialReferenceTracker( + DriverCredentialPolicy.OpcUaClientScope, driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message)); _sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger); PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval; } @@ -554,7 +564,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a // single resolve covers both paths. var resolvedOptions = await OpcUaClientSecretResolution - .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false); + .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false); var identity = BuildUserIdentity(resolvedOptions); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs index bda9a35dd..8e5eb290d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs @@ -31,16 +31,18 @@ internal static class OpcUaClientSecretResolution /// The raw driver options (credential fields may carry secret: refs). /// The shared secret resolver used by the secret: arm. /// Cancellation token for the async secret resolution. + /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. /// An options copy whose credential fields carry resolved plaintext. /// /// A secret: ref names a secret that is absent/tombstoned in the store (fail-closed). /// internal static Task ResolveSecretRefsAsync( - OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct) + OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct, + Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(resolver); // One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session). - return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct); + return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor index 7ef8633ac..07fd06670 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -23,6 +23,8 @@ Changes apply on the next deployment. + +
Project tree diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor new file mode 100644 index 000000000..90a3fedf1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor @@ -0,0 +1,85 @@ +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs +@using ZB.MOM.WW.OtOpcUa.Configuration +@inject CredentialReferencePreflightState State +@inject IDriverStatusSnapshotStore DriverStatus +@inject IServiceProvider Services + +@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their + driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the + deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. *@ + +@if (_findings.Count > 0 || _error is not null) +{ + +} + +@code { + private const int MaxRows = 25; + private IReadOnlyList _findings = []; + private string? _error; + private bool _busy; + + protected override void OnInitialized() => Compose(State.Current); + + private void Compose(CredentialReferencePreflightState.Snapshot snapshot) + { + _error = snapshot.Error; + _findings = snapshot.Findings + .Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll())) + .ToList(); + } + + private async Task RecheckAsync() + { + var factory = Services.GetService(typeof(IDbContextFactory)) as IDbContextFactory; + if (factory is null) return; + _busy = true; + try + { + var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None); + var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null); + State.Set(snapshot); + Compose(snapshot); + } + catch (Exception) + { + _error = "The credential-reference preflight could not read the config DB."; + } + finally + { + _busy = false; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs new file mode 100644 index 000000000..a87f361f7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs @@ -0,0 +1,209 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Deployments; + +/// Where a credential-reference finding came from. +public enum CredentialFindingSource +{ + /// A stored DriverConfig / DeviceConfig row (what the next deploy will gate). + Draft, + + /// The latest deployment artifact (what the running drivers were given). + Deployed, + + /// A running driver that reported resolving it through the #815 warn-only compatibility path. + Running, +} + +/// One out-of-namespace credential reference (never carries the value). +/// Where it was found. +/// The driver type. +/// The driver's display name (or instance id for a running driver). +/// The driver instance id. +/// The device, when the reference sits in a DeviceConfig. +/// The field and the allowed forms. +public sealed record CredentialReferenceFinding( + CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem); + +/// +/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential +/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that +/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode). +/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment. +/// +/// The config DB. +public sealed class CredentialReferencePreflight(IDbContextFactory dbFactory) +{ + /// Scans the draft rows and the latest deployment artifact. + /// Cancellation. + /// The findings. + public async Task> ScanAsync(CancellationToken ct) + { + await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var drivers = await db.DriverInstances.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); + var ruled = drivers.Where(d => DriverCredentialPolicy.TryGet(d.DriverType, out _)).ToList(); + var ids = ruled.Select(d => d.DriverInstanceId).ToHashSet(StringComparer.Ordinal); + var devices = ids.Count == 0 + ? [] + : await db.Devices.AsNoTracking().Where(d => ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false); + var latestBlob = await db.Deployments.AsNoTracking() + .Where(d => d.ArtifactBlob.Length > 0) + .OrderByDescending(d => d.CreatedAtUtc) + .Select(d => d.ArtifactBlob) + .FirstOrDefaultAsync(ct).ConfigureAwait(false); + var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob); + return Scan(ruled, devices, deployed); + } + + /// The pure scan (tests drive it directly). + /// Stored driver rows. + /// Stored device rows. + /// The latest artifact's driver specs. + /// The findings. + public static IReadOnlyList Scan( + IEnumerable drivers, IEnumerable devices, IEnumerable deployed) + { + var findings = new List(); + var driverList = drivers.ToList(); + var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal); + foreach (var d in driverList) + { + foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig)) + findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p)); + } + + foreach (var dev in devices) + { + if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue; + foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig)) + findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p)); + } + + foreach (var s in deployed) + { + foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig)) + findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p)); + } + + return findings; + } + + /// + /// Running drivers that reported a warn-only legacy resolution on their diagnostics bag + /// (), fleet-wide via the driver-health feed. + /// + /// The driver-health snapshots. + /// The findings. + public static IReadOnlyList RunningFindings(IEnumerable snapshots) => + snapshots + .Where(s => s.Diagnostics is not null) + .SelectMany(s => s.Diagnostics! + .Where(kv => kv.Key.StartsWith(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix, StringComparison.Ordinal)) + .Select(kv => new CredentialReferenceFinding( + CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null, + $"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}"))) + .ToList(); +} + +/// The last preflight result, shown by the AdminUI /raw credential banner. +public sealed class CredentialReferencePreflightState +{ + private volatile Snapshot _current = new([], null, null); + + /// An immutable result. + /// The findings. + /// When the scan ran (null = not yet). + /// A scan failure, if any. + public sealed record Snapshot(IReadOnlyList Findings, DateTime? ScannedAtUtc, string? Error); + + /// The current result. + public Snapshot Current => _current; + + /// Replaces the result. + /// The new result. + public void Set(Snapshot snapshot) => _current = snapshot; +} + +/// +/// Runs once after host start on an admin node and logs one Warning per +/// finding (driver, device, field, allowed forms — never a value). Scheduled after ApplicationStarted plus a +/// short delay, off every Akka starter; it needs only the ConfigDb, never ISecretResolver. +/// +/// Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped). +/// Host lifetime. +/// Where the result is kept for the AdminUI. +/// Logger. +public sealed class CredentialReferencePreflightService( + IServiceProvider services, + IHostApplicationLifetime lifetime, + CredentialReferencePreflightState state, + ILogger logger) : BackgroundService +{ + /// Delay after ApplicationStarted before the scan (tests set it to zero). + public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (lifetime.ApplicationStarted.Register(() => started.TrySetResult())) + using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken))) + { + try + { + await started.Task.ConfigureAwait(false); + if (StartDelay > TimeSpan.Zero) await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + } + + await RunOnceAsync(stoppingToken).ConfigureAwait(false); + } + + /// One scan + log pass (internal so tests drive it directly). + /// Cancellation. + /// The findings. + internal async Task> RunOnceAsync(CancellationToken ct) + { + var dbFactory = services.GetService>(); + if (dbFactory is null) + { + logger.LogDebug("Credential-reference preflight skipped: no ConfigDb on this node."); + return []; + } + + try + { + var findings = await new CredentialReferencePreflight(dbFactory).ScanAsync(ct).ConfigureAwait(false); + state.Set(new(findings, DateTime.UtcNow, null)); + foreach (var f in findings) + { + logger.LogWarning( + "Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} " + + "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. " + + "See docs/security.md \"Credential references are namespaced per driver\".", + f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem); + } + + if (findings.Count == 0) + logger.LogInformation("Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced."); + return findings; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB."); + state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB.")); + return []; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index f085754c5..faa797df2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions // Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md services.AddSingleton(); services.AddHostedService(); + // lmxopcua#815 upgrade preflight: one ConfigDb scan after start (no secret resolution), WARN per + // out-of-namespace credential reference, shown by the /raw credential banner. + services.AddSingleton(); + services.AddHostedService(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs index a682e54aa..68dc92637 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs @@ -32,6 +32,7 @@ public sealed class DraftValidatorCredentialReferenceTests e.Context.ShouldBe("drv"); e.Message.ShouldContain("secret:"); e.Message.ShouldContain("lmxopcua#815"); + e.Message.ShouldContain("Migrate it before deploying"); e.Message.ShouldNotContain("ConnectionStrings__ConfigDb"); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs index 5ba065861..e0deaaa77 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs @@ -54,6 +54,23 @@ public sealed class GalaxyDriverBrowserTests ex.Message.ShouldContain("ClientName"); } + /// + /// lmxopcua#815: the browser runs on the admin node against an operator-chosen endpoint with no deploy, so it + /// never gets the running driver's warn-only compatibility — a legacy ref (the old form default + /// env:MX_API_KEY, a file: path, another subsystem's secret) is refused before any connect. + /// + [Theory] + [InlineData("env:MX_API_KEY")] + [InlineData("file:appsettings.json")] + [InlineData("secret:otopcua/historian/api-key")] + public async Task OpenAsync_refuses_an_out_of_namespace_api_key_ref_with_no_warn_only_fallback(string reference) + { + var json = "{\"Gateway\":{\"Endpoint\":\"http://127.0.0.1:1\",\"ApiKeySecretRef\":\"" + reference + + "\"},\"MxAccess\":{\"ClientName\":\"X\"},\"Repository\":{},\"Reconnect\":{}}"; + await Should.ThrowAsync( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); + } + /// A JSON literal that deserializes to null must fail fast with a /// "deserialized to null" message — never a downstream NRE. [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs new file mode 100644 index 000000000..ce42c5796 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; + +/// +/// lmxopcua#815 warn-only compatibility (deprecated): an ALREADY-DEPLOYED Galaxy driver whose config predates the +/// namespaces — e.g. the old AdminUI form default env:MX_API_KEY — keeps resolving it on init and on every +/// gateway reconnect (ReopenAsync → BuildClientOptionsAsync), warning rate-limited and surfacing it +/// on the diagnostics bag; while the admin-node paths (browser) stay hard-fail. +/// +public sealed class GalaxyDriverLegacyCredentialTests +{ + private const string LegacyVariable = "MX_API_KEY_815_TEST"; + + private static GalaxyDriverOptions Opts(string apiKeyRef, string? ackRef = null) => new( + new GalaxyGatewayOptions("https://mxgw.test:5001", apiKeyRef), + new GalaxyMxAccessOptions("Compat815"), + new GalaxyRepositoryOptions(WatchDeployEvents: false), + new GalaxyReconnectOptions()) + { + AlarmAck = ackRef is null ? new GalaxyAlarmAckOptions() : new GalaxyAlarmAckOptions("ack-svc", null, ackRef), + }; + + [Fact] + public async Task A_running_driver_with_a_legacy_env_ref_resolves_on_init_and_every_reconnect_and_warns_once() + { + Environment.SetEnvironmentVariable(LegacyVariable, "the-real-key"); + try + { + var logger = new CaptureLogger(); + var driver = new GalaxyDriver("gx-legacy", Opts($"env:{LegacyVariable}"), new NullResolver(), logger); + var ct = TestContext.Current.CancellationToken; + + // InitializeAsync and each ReopenAsync (gateway reconnect) build the client options through this one method. + (await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key"); + (await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key"); + (await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key"); + + var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("lmxopcua#815")).ToList(); + warnings.Count.ShouldBe(1); // rate-limited: one per field per hour, not one per reconnect + warnings[0].Message.ShouldContain("Gateway.ApiKeySecretRef"); + warnings[0].Message.ShouldContain("env:OTOPCUA_GALAXY_"); + warnings[0].Message.ShouldNotContain("the-real-key"); + + var diagnostics = ((IDriverDiagnostics)driver).GetDiagnostics(); + diagnostics.Keys.ShouldContain(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"); + diagnostics.Values.ShouldAllBe(v => !v.Contains("the-real-key")); + DriverDiagnostics.Bound(diagnostics).Values.ShouldAllBe(v => v.Length <= DriverDiagnostics.MaxValueLength); + } + finally + { + Environment.SetEnvironmentVariable(LegacyVariable, null); + } + } + + [Fact] + public async Task A_running_driver_keeps_the_old_case_insensitive_and_file_semantics() + { + var path = Path.Combine(Path.GetTempPath(), $"gx-815-{Guid.NewGuid():N}.txt"); + File.WriteAllText(path, " key-from-file \n"); + try + { + var driver = new GalaxyDriver("gx-legacy-file", Opts($"file:{path}"), new NullResolver()); + (await driver.BuildClientOptionsAsync(Opts($"file:{path}").Gateway, TestContext.Current.CancellationToken)) + .ApiKey.ShouldBe("key-from-file"); + ((IDriverDiagnostics)driver).GetDiagnostics().ShouldNotBeEmpty(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task A_namespaced_ref_resolves_without_a_compatibility_warning() + { + var logger = new CaptureLogger(); + var driver = new GalaxyDriver("gx-ok", Opts("dev:k"), new NullResolver(), logger); + (await driver.BuildClientOptionsAsync(Opts("dev:k").Gateway, TestContext.Current.CancellationToken)).ApiKey.ShouldBe("k"); + logger.Entries.ShouldNotContain(e => e.Message.Contains("lmxopcua#815")); + ((IDriverDiagnostics)driver).GetDiagnostics().ShouldBeEmpty(); + } + + /// The same legacy ref through the admin-node browser path (no tracker) is refused before any read. + [Fact] + public async Task The_same_ref_without_a_tracker_is_refused() + { + var reads = 0; + await Should.ThrowAsync(() => GalaxySecretRef.ResolveApiKeyAsync( + "env:MX_API_KEY", new NullResolver(), ct: TestContext.Current.CancellationToken, + environment: _ => { reads++; return "the-real-key"; })); + reads.ShouldBe(0); + } + + [Fact] + public async Task The_running_ack_fallback_keeps_a_legacy_ref_and_warns() + { + var warnings = new List(); + var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add); + var resolver = new GalaxyAckCredentialResolver( + "gx", new GalaxyAlarmAckOptions("ack-svc", null, "env:LEGACY_ACK_PW"), new NullResolver(), new CaptureLogger(), + n => n == "LEGACY_ACK_PW" ? "pw" : null, tracker); + + (await resolver.ResolveAsync(null, TestContext.Current.CancellationToken)).Credential.ShouldNotBeNull().Password.ShouldBe("pw"); + warnings.ShouldHaveSingleItem().ShouldContain("AlarmAck.FallbackPasswordSecretRef"); + } + + [Fact] + public void The_tracker_re_warns_after_the_interval() + { + var now = new DateTime(2026, 11, 11, 0, 0, 0, DateTimeKind.Utc); + var warnings = new List(); + var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add, () => now); + tracker.Report("F"); + tracker.Report("F"); + now += LegacyCredentialReferenceTracker.WarnInterval; + tracker.Report("F"); + tracker.Report("G"); + warnings.Count.ShouldBe(3); + } + + private sealed class NullResolver : ISecretResolver + { + public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); + } + + private sealed class CaptureLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs index d319072e3..f52bac365 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs @@ -124,6 +124,35 @@ public sealed class OpcUaClientSecretResolutionTests resolved.Password.ShouldBe("pw"); } + /// + /// lmxopcua#815 warn-only compatibility (deprecated): the RUNNING driver's session open keeps the old semantics + /// for an out-of-namespace value — a case-insensitive secret: resolves, anything else is a literal — and + /// warns once; the browser (no tracker) refuses the same value (see the theory above). + /// + [Fact] + public async Task The_running_driver_keeps_old_semantics_for_a_legacy_value_and_warns() + { + var warnings = new List(); + var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add); + var legacyOptions = new OpcUaClientDriverOptions + { + Password = $"SECRET:{KnownPwName}", // pre-#815: case-insensitive secret: resolved + UserCertificatePassword = "env:NOT_A_REF", // pre-#815: only secret: resolved — a literal password + }; + + for (var i = 0; i < 3; i++) + { + var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( + legacyOptions, FakeResolver(), CancellationToken.None, tracker); + resolved.Password.ShouldBe(KnownPwValue); + resolved.UserCertificatePassword.ShouldBe("env:NOT_A_REF"); + } + + warnings.Count.ShouldBe(2); // one per field, rate-limited across the three session opens + warnings.ShouldAllBe(w => !w.Contains(KnownPwValue)); + tracker.Diagnostics.Count.ShouldBe(2); + } + private sealed class CountingResolver : ISecretResolver { public int Calls { get; private set; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs new file mode 100644 index 000000000..dbc05518c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs @@ -0,0 +1,177 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments; +using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments; + +/// +/// lmxopcua#815 upgrade preflight: stored (draft) and deployed configs with out-of-namespace credential references +/// are listed and logged — driver, device, field, allowed form — and never the value; nothing is resolved. +/// +[Trait("Category", "Unit")] +public sealed class CredentialReferencePreflightTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private const string LegacyGalaxy = """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"env:MX_API_KEY"}}"""; + + private static string SeedLegacy() + { + var name = RawTreeTestDb.SeedFresh(); + using var db = RawTreeTestDb.CreateNamed(name); + // Written straight to the DB, as a pre-#815 build would have — the save gate now refuses it. + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "gx-old", ClusterId = RawTreeTestDb.ClusterId, Name = "Galaxy main", + DriverType = DriverTypeNames.Galaxy, DriverConfig = LegacyGalaxy, + }); + db.Devices.Add(new Device + { + DeviceId = "ouc-dev", DriverInstanceId = "gx-old", Name = "gw1", + DeviceConfig = """{"AlarmAck":{"FallbackPasswordSecretRef":"file:C:\\keys\\ack.txt"}}""", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "mb", ClusterId = RawTreeTestDb.ClusterId, Name = "modbus", + DriverType = DriverTypeNames.Modbus, DriverConfig = """{"Password":"env:ConnectionStrings__ConfigDb"}""", + }); + var artifact = """ + {"DriverInstances":[{"DriverInstanceId":"ouc","Name":"Upstream","DriverType":"OpcUaClient", + "DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]} + """; + db.Deployments.Add(new Deployment { RevisionHash = "abc", CreatedBy = "t", ArtifactBlob = Encoding.UTF8.GetBytes(artifact) }); + db.SaveChanges(); + return name; + } + + [Fact] + public async Task Scan_lists_draft_rows_devices_and_the_deployed_artifact_without_values() + { + var findings = await new CredentialReferencePreflight(RawTreeTestDb.Factory(SeedLegacy())).ScanAsync(Ct); + + findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DriverName == "Galaxy main" + && f.DeviceName == null && f.Problem.Contains("Gateway.ApiKeySecretRef") + && f.Problem.Contains("env:OTOPCUA_GALAXY_")); + findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DeviceName == "gw1" + && f.Problem.Contains("AlarmAck.FallbackPasswordSecretRef")); + findings.ShouldContain(f => f.Source == CredentialFindingSource.Deployed && f.DriverName == "Upstream" + && f.Problem.Contains("secret:opcuaclient/")); + findings.ShouldNotContain(f => f.DriverInstanceId == "mb"); // not a credential-resolving driver + findings.ShouldAllBe(f => !f.Problem.Contains("MX_API_KEY") && !f.Problem.Contains("plant/opcua-pw") + && !f.Problem.Contains("ack.txt")); + } + + [Fact] + public async Task A_clean_database_has_no_findings() + => (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty(); + + [Fact] + public async Task The_boot_pass_logs_one_warning_per_finding_and_publishes_the_state() + { + var services = new ServiceCollection() + .AddSingleton(RawTreeTestDb.Factory(SeedLegacy())) + .BuildServiceProvider(); + var state = new CredentialReferencePreflightState(); + var logger = new CaptureLogger(); + var svc = new CredentialReferencePreflightService(services, new FakeLifetime(), state, logger) { StartDelay = TimeSpan.Zero }; + + var findings = await svc.RunOnceAsync(Ct); + + findings.Count.ShouldBe(3); + state.Current.Findings.Count.ShouldBe(3); + state.Current.ScannedAtUtc.ShouldNotBeNull(); + var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList(); + warnings.Count.ShouldBe(3); + warnings.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY")); + warnings.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef")); + } + + [Fact] + public async Task A_node_without_a_config_db_skips_quietly() + { + var svc = new CredentialReferencePreflightService( + new ServiceCollection().BuildServiceProvider(), new FakeLifetime(), new CredentialReferencePreflightState(), new CaptureLogger()); + (await svc.RunOnceAsync(Ct)).ShouldBeEmpty(); + } + + [Fact] + public async Task The_hosted_service_waits_for_ApplicationStarted_before_scanning() + { + var services = new ServiceCollection().AddSingleton(RawTreeTestDb.Factory(SeedLegacy())).BuildServiceProvider(); + var lifetime = new FakeLifetime(); + var state = new CredentialReferencePreflightState(); + var svc = new CredentialReferencePreflightService(services, lifetime, state, new CaptureLogger()) { StartDelay = TimeSpan.Zero }; + + await svc.StartAsync(Ct); + await Task.Delay(100, Ct); + state.Current.ScannedAtUtc.ShouldBeNull(); // nothing before the host has started + + lifetime.Start(); + var deadline = DateTime.UtcNow.AddSeconds(10); + while (state.Current.ScannedAtUtc is null && DateTime.UtcNow < deadline) await Task.Delay(20, Ct); + state.Current.Findings.Count.ShouldBe(3); + await svc.StopAsync(Ct); + } + + [Fact] + public void Running_drivers_reporting_warn_only_resolution_are_surfaced_from_driver_health() + { + var snapshots = new[] + { + new DriverHealthChanged("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow, + Diagnostics: new Dictionary + { + [LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated …", + ["s7.pdu"] = "480", + }), + new DriverHealthChanged("c", "mb", "Healthy", null, null, 0, DateTime.UtcNow), + }; + + var running = CredentialReferencePreflight.RunningFindings(snapshots); + + running.ShouldHaveSingleItem().ShouldSatisfyAllConditions( + f => f.Source.ShouldBe(CredentialFindingSource.Running), + f => f.DriverInstanceId.ShouldBe("gx-old"), + f => f.Problem.ShouldStartWith("Gateway.ApiKeySecretRef")); + } + + private sealed class FakeLifetime : IHostApplicationLifetime + { + private readonly CancellationTokenSource _started = new(); + + public CancellationToken ApplicationStarted => _started.Token; + + public CancellationToken ApplicationStopping => CancellationToken.None; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void Start() => _started.Cancel(); + + public void StopApplication() + { + } + } + + private sealed class CaptureLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs index 0303f9991..86780d72d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs @@ -17,6 +17,7 @@ public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActor { [Theory] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")] + [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")] [InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")] [InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")] From 489664d105f5bbb25be15953f410883e2c518a12 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 02:25:24 -0500 Subject: [PATCH 16/16] fix(acl): folder event verdict invalidation reaches orphaned items, decides on the live instance, skips ACL-off; alarm-bearing reference map (#826 review) 1. Orphaned subscriptions (session closed without deleting subs) have Session == null, so the public MonitoredNode2.InvalidatePermissionCacheForSession never reached their items: a cached allow survived a deploy, the moved alarm's events were queued and delivered on TransferSubscriptions. Each notifier's cache is now cleared whole through the SDK's private MonitoredNode2.InvalidatePermissionCaches (reflection, guard-tested; fallback = per-session drop + Remove/Add of session-less items). 2. The verdict is decided on the condition LIVE under the event's NodeId, not the event's own instance: the SDK key has no instance identity, so a replaced instance's late event cached a deny its successor inherited until the next invalidation. 3. Invalidation is skipped while enforcement is off (forced across an on/off authorizer transition) and is one O(cache) clear per node instead of a whole-cache scan per item. 4. TagReferencingEquipment carries alarm-bearing raw tags only (TagConfigIntent.Alarm); the snapshot adopts an OrdinalIgnoreCase set instead of copying it again. Tests (each fails with its fix reverted): FolderEventVerdictInvalidationTests (orphan + deploy + transfer; replaced instance's late event; ACL-off skip; SDK private-method guard), DeploymentArtifactScopePathTests (alarm-bearing projection). --- .claude/rules/data-plane-auth.md | 13 +- docs/security.md | 7 +- .../Authorization/DataPlaneAuthority.cs | 13 +- .../OtOpcUaNodeManager.cs | 88 ++++++-- .../Drivers/DeploymentArtifact.cs | 22 +- .../FolderEventVerdictInvalidationTests.cs | 188 ++++++++++++++++++ .../DeploymentArtifactScopePathTests.cs | 61 ++++-- 7 files changed, 355 insertions(+), 37 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 221add473..ae4301f88 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -189,8 +189,17 @@ Two things to know before relying on any of this: SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one `MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change: the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and - `CompleteAddressSpaceApply`. Without it a cached allow survives the deploy (caught by the native wire test's - restore leg / `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ A hand-built + `CompleteAddressSpaceApply` — and is SKIPPED while enforcement is off (the authorizer setter forces it across an + on/off transition). Without it a cached allow survives the deploy (caught by the native wire test's restore leg / + `t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ (review) It clears each node's + cache WHOLE through the SDK's private `MonitoredNode2.InvalidatePermissionCaches` (reflection, pinned by + `FolderEventVerdictInvalidationTests.The_sdks_node_wide…`): the public `InvalidatePermissionCacheForSession` + misses ORPHANED subscriptions (session closed without deleting subs ⇒ `Session` null), which then queued a moved + alarm's events and delivered them on `TransferSubscriptions`, and rescanned the cache per item. Fallback if the SDK + drops it: per-session drop + `Remove`/`Add` of each session-less item. ⚠️ The verdict is decided on the LIVE + instance holding the event's condition NodeId, never the event's own instance — the SDK key has no instance + identity, so denying a replaced instance's late event cached a deny the new instance inherited. + `TagReferencingEquipment` holds ALARM-BEARING tags only (`TagConfigIntent.Alarm`). ⚠️ A hand-built authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests: `UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`, diff --git a/docs/security.md b/docs/security.md index 00b3c2b77..30fe71689 100644 --- a/docs/security.md +++ b/docs/security.md @@ -729,8 +729,11 @@ Behaviours worth knowing before enabling it: dropped no longer admits the native alarm there either. **`ConditionRefresh`** on a folder item is filtered the same way. The hook is `INodeManager3.ValidateEventRolePermissions`, which the node manager re-implements (the base method is not virtual); the SDK memoizes that verdict per `(item, event type, SourceNode)`, so the node - manager invalidates it on every new generation or authorizer, on each `AclRevalidationTimer` tick, and at the - end of every apply. Withheld events are not replayed and the client is not told; restoring the reference or + manager clears each notifier's cache whole (sessions and orphaned, session-less subscriptions alike — an orphaned + subscription must not queue a moved alarm's events for a later `TransferSubscriptions`) on every new generation or + authorizer, on each `AclRevalidationTimer` tick, and at the end of every apply, and not at all while enforcement is + off. A verdict is decided on the condition currently live under the event's NodeId, so a replaced instance's late + event cannot leave a deny behind for its successor. Only alarm-bearing raw tags are carried in the reference map. Withheld events are not replayed and the client is not told; restoring the reference or grant resumes the feed on the next event. ⚠️ A native alarm reaches an equipment folder's items under enforcement only while the installed generation carries that reference — a hand-built authority (tests) must supply `tagReferencingEquipment`. Inert with the flag off. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs index 57a7f20fc..dc4a03bd2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -163,7 +163,7 @@ public sealed class DataPlaneAuthoritySnapshot public IReadOnlyDictionary UnsFolderScopes { get; } /// - /// RawPath → the EquipmentIds whose UnsTagReference projects that raw tag, as THIS generation deploys + /// RawPath → the EquipmentIds whose UnsTagReference projects that ALARM-BEARING raw tag, as THIS generation deploys /// them (Gitea #826). Keyed Ordinal (a RawPath is a NodeId identifier); the id sets are /// OrdinalIgnoreCase (identifier rule). /// @@ -395,9 +395,14 @@ public sealed class DataPlaneAuthoritySnapshot foreach (var (rawPath, equipmentIds) in tagReferencingEquipment) { if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue; - // Re-keyed under the identifier comparer, as the maps above are. - referencing[rawPath] = new HashSet( - equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); + // Re-keyed under the identifier comparer, as the maps above are — but a set already built under it + // (what DeploymentArtifact produces) is adopted rather than copied a second time. + referencing[rawPath] = equipmentIds is HashSet set + && ReferenceEquals(set.Comparer, StringComparer.OrdinalIgnoreCase) + && !set.Any(string.IsNullOrWhiteSpace) + ? set + : new HashSet( + equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 525d06976..62489d97c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -654,8 +654,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // there is no second wiring that could silently not run (an unattached resolver splits opaque ids, // which only ever DENIES narrow grants, so a miss would read as working enforcement). if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject; + var wasEnforced = AclEnforced; _authorizer = value; - InvalidateFolderEventVerdicts(); + // Across an on/off transition the cached verdicts were decided under the other mode. + InvalidateFolderEventVerdicts(force: wasEnforced || AclEnforced); } } @@ -5557,8 +5559,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// when the item's session may receive it through that notifier. /// /// - /// Three conditions, all against the CURRENT state: the event's condition is the LIVE instance (a torn-down - /// one denies); the notifier is still a legitimate notifier of it — the condition itself, a node it hangs + /// Three conditions, all against the CURRENT state: a live condition holds the event's condition NodeId; + /// the notifier is still a legitimate notifier of THAT live instance — the condition itself, a node it hangs /// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is /// visible to the session under exactly the Server-object rule ( over /// ), so a moved scripted alarm is withheld from its old folder just @@ -5569,6 +5571,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// and the condition's own method audits reach these notifiers. /// /// + /// ⚠️ Decided on the live instance, never on the event's own (review of #826). The SDK caches the + /// verdict per (item, event type, SourceNode) — no instance identity — so a verdict must be a + /// function of the condition's NodeId and the current state alone. Denying an event because it came from a + /// REPLACED instance (a surgical remove + re-materialise under the same NodeId, the old instance's last + /// event still in the SDK's queue) cached a deny the new instance then inherited until the next + /// invalidation. A late event from a replaced instance is therefore delivered exactly when the live one's + /// would be. + /// + /// /// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes /// Lock (via GetNodeMetadata); the scope resolution here takes it the same way. /// @@ -5577,8 +5588,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false; if (EventConditionOf(e) is not { } condition) return false; - if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) || !ReferenceEquals(live, condition)) - return false; + if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live)) return false; if (!IsLiveNotifierOf(live, notifier)) return false; return IsConditionVisibleTo(item, ConditionVisibilityScopes(live)); } @@ -5662,31 +5672,85 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826), /// so the next event on each is decided afresh by . /// + /// Run even while enforcement is off — the authorizer setter's on/off transition, where the + /// cached verdicts were decided under the other mode. /// - /// MonitoredNode2.InvalidatePermissionCacheForSession is the SDK's only public lever: it advances that - /// node's permission generation (a verdict computed concurrently under the old one is recomputed, not stored) - /// and drops the session's entries. Cost is one call per (notifier, session) pair — never per condition. The - /// node list is copied under Lock; the invalidation itself takes no lock of ours. + /// + /// Node-wide, session-agnostic. Each notifier's cache is cleared whole through the SDK's own private + /// MonitoredNode2.InvalidatePermissionCaches (what the SDK runs when namespace default permissions + /// change, and on Rebind): it advances the node's permission generation — a verdict computed + /// concurrently under the old one is recomputed, never stored — and clears the cache in one O(cache) pass. + /// The public InvalidatePermissionCacheForSession is NOT sufficient (review of #826): it reaches only + /// items whose Session is set, and an ORPHANED subscription's items (session closed without deleting + /// its subscriptions) have none — they kept a stale allow through a deploy, queued the moved alarm's events + /// and delivered them on TransferSubscriptions. It also rescanned the whole cache once per item + /// (items² × conditions per folder). + /// + /// + /// The private method is resolved once by reflection against the pinned SDK and pinned by + /// FolderEventVerdictInvalidationGuardTests. Should a future SDK drop it, this falls back to the + /// supported route: the per-session drop for items with a session, and a detach + re-attach of each + /// session-less item on its node (Remove drops that item's entries), which can miss an event racing + /// the swap — the reason it is only the fallback. + /// + /// + /// Skipped while enforcement is off ( aside): the verdicts are then the SDK's own + /// role check, which nothing here changes. The node list is copied under Lock; the invalidation + /// takes no lock of ours. + /// /// - private void InvalidateFolderEventVerdicts() + private void InvalidateFolderEventVerdicts(bool force = false) { - if (_root is null) return; + if (_root is null || (!force && !AclEnforced)) return; List nodes; lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList(); foreach (var node in nodes) { + Interlocked.Increment(ref _folderEventVerdictInvalidations); + if (InvalidateNodePermissionCaches is { } invalidate) + { + invalidate(node); + continue; + } + var sessions = new HashSet(); + var sessionless = new List(); foreach (var item in node.EventMonitoredItems.Values) { - if (item?.Session?.Id is { IsNull: false } id) sessions.Add(id); + if (item is null) continue; + if (item.Session?.Id is { IsNull: false } id) sessions.Add(id); + else sessionless.Add(item); } foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session); + foreach (var item in sessionless) + { + node.Remove(item); + node.Add(item); + } } } + /// Notifier caches has invalidated since start. Test + /// observability (it must not run while enforcement is off). + internal long FolderEventVerdictInvalidations => Interlocked.Read(ref _folderEventVerdictInvalidations); + + private long _folderEventVerdictInvalidations; + + /// + /// The SDK's private node-wide cache invalidation (MonitoredNode2.InvalidatePermissionCaches), or null + /// when the SDK no longer has it — see . + /// + internal static readonly Action? InvalidateNodePermissionCaches = + typeof(MonitoredNode2).GetMethod( + "InvalidatePermissionCaches", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + Type.EmptyTypes) is { ReturnType: var returns } method && returns == typeof(void) + ? method.CreateDelegate>() + : null; + /// Whether 's session may see a condition with the given scopes. /// The Server-object event item. /// From . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index d287bb9ef..882d3d408 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -95,7 +95,7 @@ public sealed record DataPlaneAuthorityProjection( /// /// RawPath → EquipmentIds from the artifact's UnsTagReferences (Gitea #826): which equipment - /// folders this generation projects each raw tag into — the folders a native alarm on that tag may be + /// folders this generation projects each ALARM-BEARING raw tag (TagConfig.alarm) into — the folders a native alarm on that tag may be /// delivered through. RawPaths come from the SAME as /// , so they equal the native conditions' ConditionIds by construction. Empty /// when the artifact carries no grants. @@ -1027,12 +1027,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable } var tags = new Dictionary(StringComparer.Ordinal); + // TagConfig kept unparsed: only a REFERENCED tag's is parsed below (Gitea #826 — alarm-bearing ones only). + var tagConfigs = new Dictionary(StringComparer.Ordinal); foreach (var el in EnumerateArray(root, "Tags")) { var id = ReadString(el, "TagId"); var deviceId = ReadString(el, "DeviceId"); if (id is null || deviceId is null) continue; tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id); + tagConfigs[id] = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String + ? tcEl.GetString() + : null; } var areas = new Dictionary(StringComparer.Ordinal); @@ -1091,12 +1096,21 @@ internal sealed class ParsedDeploymentArtifact : IDisposable if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path; } - // Which equipment folders each raw tag is projected into (Gitea #826) — the folders a native alarm on that tag - // may legitimately be delivered through. Keyed on the RawPath the SAME resolver builds, which is the native - // condition's ConditionId. + // Which equipment folders each ALARM-BEARING raw tag is projected into (Gitea #826) — the folders a native alarm + // on that tag may legitimately be delivered through; only native conditions are ever looked up, so plain value + // tags are left out. Keyed on the RawPath the SAME resolver builds, which is the native condition's ConditionId. + // Alarm-bearing is decided by the SAME TagConfigIntent the composer materialises native conditions from. + var alarmBearing = new Dictionary(StringComparer.Ordinal); var referencing = new Dictionary>(StringComparer.Ordinal); foreach (var reference in references) { + if (!alarmBearing.TryGetValue(reference.TagId, out var isAlarm)) + { + isAlarm = TagConfigIntent.Parse(tagConfigs.GetValueOrDefault(reference.TagId)).Alarm is not null; + alarmBearing[reference.TagId] = isAlarm; + } + + if (!isAlarm) continue; if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue; if (!referencing.TryGetValue(rawPath, out var set)) referencing[rawPath] = set = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs new file mode 100644 index 000000000..37fe86526 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs @@ -0,0 +1,188 @@ +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Review of Gitea #826: the SDK memoizes each folder event item's per-event verdict per +/// (item, event type, SourceNode), so the per-event decision is only as fresh as its invalidation. These pin +/// the three ways it went stale or cost too much: an ORPHANED subscription (no session to invalidate by), a stale +/// DENY left by a replaced instance's late event, and invalidation running with enforcement off. +/// +public sealed class FolderEventVerdictInvalidationTests +{ + private const string EqA = ServerEventRig.EqA; + private const string EqB = ServerEventRig.EqB; + + /// + /// A subscription orphaned by its session (closed without deleting its subscriptions) keeps its items on the + /// folder fan-out with NO session. A deployment that drops EqA's reference must still reach those items: the + /// native alarm's events raised while orphaned are not queued to the EqA item, so a TransferSubscriptions to a + /// new session delivers them only through EqB (positive control on the same subscription and event). + /// + [Fact] + public async Task An_orphaned_folder_item_does_not_keep_a_stale_allow_across_a_deploy_and_a_transfer() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826Orphan", encrypted: false, ct); + rig.KeyRoles = []; + rig.MaterialiseNative(); + NodeAcl[] grants = [KeyEquipmentGrant(EqA), KeyEquipmentGrant(EqB)]; + var state = rig.Enforce(true, [ServerEventRig.Key()], grants); + + using var first = await rig.ConnectAsync(anonymous: false, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(first, events, ct); + var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqA)); + var onB = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqB)); + await subscription.ApplyChangesAsync(ct); + onA.Status.Error.ShouldBeNull(); + + // Seed the SDK's cache with an ALLOW for the EqA item. + rig.Project(native: true, State("t1")); + await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "t1") && events.Has(onB, "t1")); + + // Orphan the subscription: a raw CloseSession with deleteSubscriptions=false, so the subscription and its items + // stay on the server with no session. The raw service leaves the client session OBJECT alive (Session.CloseAsync + // would dispose it), as a reconnecting client's is, so the high-level transfer can take the subscription over. + var orphaned = new SubscriptionCollection(first.Subscriptions); + await ((ISessionClient)first).CloseSessionAsync(null, deleteSubscriptions: false, ct); + + // The deployment drops EqA's reference; the alarm reports while the subscription has no session. + Deploy(state, "rev-2", grants, EqB); + rig.Project(native: true, State("t2")); + + using var second = await rig.ConnectAsync(anonymous: false, ct); + (await second.TransferSubscriptionsAsync(orphaned, sendInitialValues: false, ct)).ShouldBeTrue(); + + rig.Project(native: true, State("t3")); + await RecordedEvents.WaitUntilAsync(() => events.Has(onB, "t2") && events.Has(onB, "t3")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + events.Has(onA, "t2").ShouldBeFalse("an event raised while orphaned must not be queued on a stale allow"); + events.Has(onA, "t3").ShouldBeFalse(); + + await second.CloseAsync(ct); + } + + /// + /// A surgical replace of a scripted condition under the SAME NodeId (remove + re-materialise, the folder item + /// survives), with the old instance's last event still waiting in the SDK's per-folder queue when the apply's + /// invalidation runs. That late event must not leave a cached DENY the new instance inherits: the new + /// instance's events keep reaching the folder subscriber. + /// + [Fact] + public async Task A_replaced_instances_late_event_does_not_leave_a_cached_deny_for_the_new_instance() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826Replace", encrypted: false, ct); + rig.KeyRoles = []; + rig.MaterialiseScripted(); + rig.MaterialiseNative(); // also wired into EqA: its event is the deterministic blocker below + rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); + await subscription.ApplyChangesAsync(ct); + onA.Status.Error.ShouldBeNull(); + + rig.Project(native: false, State("s1")); + await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s1")); + + // Holding the node-manager lock parks EqA's SDK delivery task: the native event "n1" is queued first and its + // verdict (a cache miss after the first invalidation) needs the lock in the inherited role check — its node is + // never removed, so that check always takes it. The OLD scripted instance's "s2", queued behind it on the same + // single-reader queue, is therefore decided only after the replace and the apply's invalidation. + lock (rig.Nm.Lock) + { + rig.Nm.CompleteAddressSpaceApply(); // forget the cached allows, so both verdicts must be computed + rig.Project(native: true, State("n1")); + rig.Project(native: false, State("s2")); + ((ISurgicalAddressSpaceSink)rig.Sink) + .RemoveAlarmConditionNode(ServerEventRig.ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue(); + rig.MaterialiseScripted(); + rig.Nm.CompleteAddressSpaceApply(); + } + + rig.Project(native: false, State("s3")); + // The new instance's event arrives (a cached deny would withhold it until the next invalidation), and so does + // the old instance's late one — decided on the live instance, exactly as the live one's would be. + await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s3") && events.Has(onA, "s2")); + + await session.CloseAsync(ct); + } + + /// + /// Review of #826 (perf): with enforcement OFF the folder verdicts are the SDK's own role check, so nothing + /// invalidates them — not the revalidation tick, not a deploy, not the end of an apply. Positive control: the + /// same calls invalidate once enforcement is on. + /// + [Fact] + public async Task Folder_verdicts_are_not_invalidated_while_enforcement_is_off() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("Folder826AclOff", encrypted: false, ct); + rig.MaterialiseNative(); + var state = rig.Enforce(false, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); + await subscription.ApplyChangesAsync(ct); + + var before = rig.Nm.FolderEventVerdictInvalidations; + rig.Nm.RevalidateMonitoredItems(); + rig.Nm.CompleteAddressSpaceApply(); + state.Apply(ServerEventRig.Cluster, "rev-off-2", [], [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)], + ServerEventRig.ScopePaths()); + rig.Nm.FolderEventVerdictInvalidations.ShouldBe(before, "enforcement off: nothing to invalidate"); + + rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); + var enforced = rig.Nm.FolderEventVerdictInvalidations; + enforced.ShouldBeGreaterThan(before, "switching enforcement on invalidates the verdicts decided while off"); + rig.Nm.CompleteAddressSpaceApply(); + rig.Nm.FolderEventVerdictInvalidations.ShouldBeGreaterThan(enforced); + + await session.CloseAsync(ct); + } + + /// + /// The node-wide invalidation rests on a PRIVATE method of the pinned SDK (MonitoredNode2.InvalidatePermissionCaches). + /// If an SDK upgrade drops it the node manager silently falls back to the weaker supported route — this fails + /// first, so the upgrade is looked at. + /// + [Fact] + public void The_sdks_node_wide_permission_cache_invalidation_is_still_resolvable() + { + OtOpcUaNodeManager.InvalidateNodePermissionCaches.ShouldNotBeNull( + "MonitoredNode2.InvalidatePermissionCaches() is gone — re-check InvalidateFolderEventVerdicts against the new SDK"); + } + + private static AlarmConditionSnapshot State(string message) => + new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message); + + private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) => + state.Apply( + ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(), + ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), + new Dictionary> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() }); + + private static NodeAcl KeyEquipmentGrant(string equipment) => new() + { + NodeAclId = $"acl-key-{equipment}", + ClusterId = ServerEventRig.Cluster, + Principal = ServerEventRig.KeyPrincipal, + ScopeKind = NodeAclScopeKind.Equipment, + ScopeId = $"eq-{equipment}", + PermissionFlags = NodePermissions.ReadOnly, + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs index b8df2b82e..f659051ba 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScopePathTests.cs @@ -314,27 +314,62 @@ public sealed class DeploymentArtifactScopePathTests } /// - /// Gitea #826 — the projection says which equipment each raw tag is projected into, keyed on the RawPath the - /// same resolver builds (a native alarm's ConditionId), so the node manager can tell a native alarm's - /// still-referenced equipment folders from one whose reference the generation dropped. + /// Gitea #826 — the projection says which equipment each ALARM-BEARING raw tag is projected into, keyed on the + /// RawPath the same resolver builds (a native alarm's ConditionId), so the node manager can tell a native + /// alarm's still-referenced equipment folders from one whose reference the generation dropped. Plain value tags + /// are never looked up and are left out (review: memory). /// [Fact] - public void The_projection_carries_each_raw_tags_referencing_equipment_under_its_raw_path() + public void The_projection_carries_each_alarm_bearing_raw_tags_referencing_equipment_under_its_raw_path() { - var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); + var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(AlarmArtifact), NodeId); - const string rawPath = "Plant/Line1/ModbusDrv/dev1/Fast/Speed"; - projection.TagReferencingEquipment.Keys.ShouldBe([rawPath]); - projection.TagReferencingEquipment[rawPath].ShouldBe(["EQ-mixer"]); + const string hot = "Plant/Line1/ModbusDrv/dev1/Fast/Hot"; + projection.TagReferencingEquipment.Keys.ShouldBe([hot], "the plain Speed tag is not alarm-bearing"); + projection.TagReferencingEquipment[hot].Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "EQ-oven"]); var snapshot = new DataPlaneAuthorityState().Apply( "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes, projection.TagReferencingEquipment); - snapshot.IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeTrue(); - snapshot.IsTagReferencedBy(rawPath, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers"); - snapshot.IsTagReferencedBy(rawPath, "EQ-other").ShouldBeFalse(); - snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Other", "EQ-mixer").ShouldBeFalse(); + snapshot.IsTagReferencedBy(hot, "EQ-oven").ShouldBeTrue(); + snapshot.IsTagReferencedBy(hot, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers"); + snapshot.IsTagReferencedBy(hot, "EQ-other").ShouldBeFalse(); + snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Speed", "EQ-mixer").ShouldBeFalse(); new DataPlaneAuthorityState().Apply("c1", "rev-1", [], [], projection.ScopePaths) - .IsTagReferencedBy(rawPath, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)"); + .IsTagReferencedBy(hot, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)"); } + + // The fixture's topology plus an alarm-bearing Hot tag referenced by two equipment; Speed stays a plain value. + private const string AlarmArtifact = """ + { + "RawFolders": [ + {"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"}, + {"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"} + ], + "DriverInstances": [ + {"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"} + ], + "Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}], + "TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}], + "Tags": [ + {"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}, + {"TagId":"TAG-hot","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Hot", + "TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\"}}"} + ], + "UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}], + "UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}], + "Equipment": [ + {"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}, + {"EquipmentId":"EQ-oven","UnsLineId":"UL-1","Name":"Oven"} + ], + "UnsTagReferences": [ + {"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed"}, + {"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-hot"}, + {"UnsTagReferenceId":"REF-3","EquipmentId":"EQ-oven","TagId":"TAG-hot"} + ], + "NodeAcls": [ + {"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16} + ] + } + """; }