From 9a52679f867b7f6e0fd846e2a0e386e20b333dfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 07:36:02 +0700 Subject: [PATCH 1/2] fix(diagnostics): distinguish same-NIC IPv4 aliases and contextualize late BRCB anomalies --- Services/DiagnosticReportBuilder.cs | 96 ++++++++++-- .../ContextAwareIec61850DiagnosisTests.cs | 143 ++++++++++++++++++ 2 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 9d687470a..bcd9e6d94 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -12,7 +12,7 @@ namespace ArIED61850Tester.Services; internal static class DiagnosticReportBuilder { private sealed record TcpProbeResult(string DeviceId, string Endpoint, string Result, string Detail); - private sealed record AdapterIpv4(string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); + private sealed record AdapterIpv4(string InterfaceId, string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); private sealed record RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note); public static string BuildEmergency( @@ -323,6 +323,8 @@ static string Safe(string value) $"sqWithGIreason={stream.SequenceWithGiReason}, " + $"sqWithBufOvfl={stream.SequenceWithOverflow}; " + "EntryID difference/identical is not an event-loss or harmless-replay conclusion."); + if (stream.Buffered && stream.Findings > 0) + builder.AppendLine($" BRCB context : {ExplainBrcbContinuity(stream)}"); if (stream.AnomalousFrames > 0) builder.AppendLine($" RCB follow-up : receivedAfterLastFinding={stream.FramesAfterLastFinding}, " + $"firstFrameUtc={stream.FirstFrameReceivedAtUtc?.ToString("O") ?? "-"}, " + @@ -384,6 +386,41 @@ static string Safe(string value) builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}"); } + /// + /// IEC 61850-aware interpretation of already observed RCB metadata: + /// temporal context and GI reason are correlations, never proof of lost + /// buffered entries, a server reset, or that a sequence gap is harmless. + /// No extra MMS traffic, raw event retention or timer. + /// + internal static string ExplainBrcbContinuity(Iec61850ReportContinuityStreamSnapshot stream) + { + ArgumentNullException.ThrowIfNull(stream); + var withoutProvenGi = Math.Max(0, stream.SequenceDiscontinuities - stream.SequenceWithGiReason); + var context = $"sequenceDiscontinuities={stream.SequenceDiscontinuities}, " + + $"GI-tagged={stream.SequenceWithGiReason}, withoutConfirmedGI={withoutProvenGi}, " + + $"BufOvflAdvertised={stream.Overflow}"; + + if (stream.ActivationReturnedAtUtc is { } activation && + stream.LastAnomalyReceivedAtUtc is { } anomaly && + anomaly >= activation) + { + var delta = anomaly - activation; + var elapsed = delta.TotalMilliseconds; + if (double.IsFinite(elapsed)) + { + context += $", lastAnomalyAfterStartMs={elapsed:0}"; + if (delta >= TimeSpan.FromSeconds(5)) + context += "; late recurrence observed beyond 5s of RCB activation (NOT startup-only)"; + } + } + else + { + context += "; timing=UNKNOWN (insufficient RCB lifecycle evidence)"; + } + + return context + "; received-frame order only, not TCP wire order, event-loss or GI causality proof"; + } + private static string Verdict(bool? value) => value is true ? "YES" : value is false ? "NO" : "UNKNOWN"; @@ -457,8 +494,10 @@ private static RouteAnalysis AnalyzeRoute(string targetText) return new RouteAnalysis("unavailable", "unavailable", "unavailable", "Route analysis currently supports IPv4 endpoints."); var adapters = GetActiveIpv4Adapters(); - var matching = adapters + var matchingAddresses = adapters .Where(item => IsInSameSubnet(item.Address, target, item.PrefixLength)) + .ToArray(); + var matching = matchingAddresses .Select(item => $"{item.Name}={item.Address}/{item.PrefixLength}") .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -486,19 +525,12 @@ private static RouteAnalysis AnalyzeRoute(string targetText) source = $"unavailable ({ex.GetType().Name}: {ex.Message})"; } - string note; - if (matching.Length == 0) - { - note = $"No active IPv4 adapter is in the same subnet as {target}. Check the PC adapter address, prefix length, VLAN, and static route."; - } - else if (matching.Length == 1) - { - note = $"{target} is directly reachable only through {matching[0]}. Ensure the IED is physically connected to that network."; - } - else - { - note = $"Multiple adapters match {target}; Windows route priority can select the wrong interface. Disable unused adapters or configure interface metrics/static routes."; - } + // Do not confuse multiple IP aliases on ONE KM-TEST interface with + // competing network adapters. Exact local IP means same-host endpoint; + // neither condition warrants telling operators to disable interfaces. + var note = ExplainIpv4Route(target, + matchingAddresses.Select(item => (item.InterfaceId, item.Name)).ToArray(), + adapters.Any(item => item.Address.Equals(target))); return new RouteAnalysis( source, @@ -507,6 +539,39 @@ private static RouteAnalysis AnalyzeRoute(string targetText) note); } + /// + /// Pure, testable interface-aware route advice. The interface ID, not the + /// number of matching IPv4 addresses, defines routing ambiguity. This is + /// descriptive only: the actual OS routing table remains authoritative. + /// + internal static string ExplainIpv4Route( + IPAddress target, + IReadOnlyList<(string InterfaceId, string AdapterName)> matchedAddresses, + bool targetAssignedLocally) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(matchedAddresses); + var distinctInterfaces = matchedAddresses + .GroupBy(item => item.InterfaceId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First().AdapterName) + .ToArray(); + if (targetAssignedLocally) + return $"Target {target} is assigned to this PC on " + + $"{(distinctInterfaces.Length == 0 ? "a local interface" : string.Join(", ", distinctInterfaces))}; " + + "this can be a same-host IED simulator. Do not infer a competing NIC from IPv4 aliases. " + + "Check the local MMS listener and actual OS route if connection fails."; + if (distinctInterfaces.Length == 0) + return $"No directly matching IPv4 subnet for {target}; a gateway or static route can still provide connectivity. " + + "Check the Windows routing table rather than assuming the endpoint is unreachable."; + if (distinctInterfaces.Length == 1) + return $"One network interface ({distinctInterfaces[0]}) matches {target}, across " + + $"{matchedAddresses.Count} IPv4 address(es). IP aliases are not competing adapters; " + + "confirm actual OS routing and physical/virtual IED connection if needed."; + return $"{distinctInterfaces.Length} distinct network interfaces match {target} " + + $"({string.Join(", ", distinctInterfaces)}); Windows interface metrics/routing table may select " + + "a different path. Check the OS route before changing adapter configuration."; + } + private static AdapterIpv4[] GetActiveIpv4Adapters() { try @@ -517,6 +582,7 @@ private static AdapterIpv4[] GetActiveIpv4Adapters() .SelectMany(adapter => adapter.GetIPProperties().UnicastAddresses .Where(address => address.Address.AddressFamily == AddressFamily.InterNetwork) .Select(address => new AdapterIpv4( + adapter.Id, adapter.Name, adapter.NetworkInterfaceType, address.Address, diff --git a/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs new file mode 100644 index 000000000..2c256ae77 --- /dev/null +++ b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Text; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Deterministic IEC-aware decisions: no live NIC, IED, sockets or simulator. +/// Hardware/network routing and physical SOE completeness are separate tests. +/// +public sealed class ContextAwareIec61850DiagnosisTests +{ + private static readonly IPAddress Bcuge = IPAddress.Parse("192.16.1.33"); + + [Fact] + public void SamePhysicalInterfaceWithFiveAliases_DoesNotClaimFiveCompetingNics() + { + var addresses = Enumerable.Range(1, 5) + .Select(_ => (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test")).ToArray(); + var description = DiagnosticReportBuilder.ExplainIpv4Route(Bcuge, addresses, false); + Assert.Contains("One network interface (KM_Test)", description); + Assert.Contains("5 IPv4 address(es)", description); + Assert.Contains("not competing adapters", description); + Assert.DoesNotContain("distinct network interfaces", description); + } + + [Fact] + public void DeviceIpIsLocalSimulatorEndpoint_DoesNotRecommendDisablingInterfaces() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, + new[] { (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test") }, + targetAssignedLocally: true); + Assert.Contains("assigned to this PC", description); + Assert.Contains("same-host IED simulator", description); + Assert.DoesNotContain("Disable", description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("physically connected", description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DifferentInterfacesWithSameFriendlyName_AreStillARealRoutingAmbiguity() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, + new[] + { + (InterfaceId: "guid-1", AdapterName: "Ethernet"), + (InterfaceId: "guid-2", AdapterName: "Ethernet"), + (InterfaceId: "GUID-1", AdapterName: "Ethernet") + }, + targetAssignedLocally: false); + Assert.Contains("2 distinct network interfaces", description); + Assert.Contains("routing table", description); + } + + [Fact] + public void NoDirectSubnetMatch_DoesNotClaimGatewayRoutingImpossible() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, Array.Empty<(string InterfaceId, string AdapterName)>(), false); + Assert.Contains("gateway or static route can still provide connectivity", description); + Assert.DoesNotContain("unreachable", description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BrcbLateRecurrence_IsNotWronglyExplainedAsStartupOnlyOrGiReset() + { + var activation = new DateTimeOffset(2026, 10, 12, 0, 26, 4, TimeSpan.Zero); + var stream = new Iec61850ReportContinuityStreamSnapshot + { + Rcb = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101", + Buffered = true, + Frames = 55, + Sequenced = 55, + Findings = 22, + SequenceDiscontinuities = 21, + SequenceWithGiReason = 1, + Overflow = 1, + ActivationReturnedAtUtc = activation, + LastAnomalyReceivedAtUtc = activation.AddSeconds(48.575) + }; + var output = DiagnosticReportBuilder.ExplainBrcbContinuity(stream); + Assert.Contains("GI-tagged=1, withoutConfirmedGI=20", output); + Assert.Contains("BufOvflAdvertised=1", output); + Assert.Contains("lastAnomalyAfterStartMs=48575", output); + Assert.Contains("late recurrence observed beyond 5s", output); + Assert.Contains("not TCP wire order", output); + Assert.DoesNotContain("events were lost", output); + + // Must also surface in ordinary user diagnostic, not only in helpers. + var builder = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(builder, + new Iec61850ReportContinuitySnapshot + { + Frames = 55, Sequenced = 55, Findings = 22, Streams = new[] { stream } + }); + Assert.Contains("BRCB context :", builder.ToString()); + Assert.Contains("NOT startup-only", builder.ToString()); + } + + [Fact] + public void StartupOnlyObservedFrames_AreNotPromotedToCausalCertainty() + { + var activation = DateTimeOffset.UtcNow; + var stream = new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + Findings = 2, + SequenceDiscontinuities = 2, + SequenceWithGiReason = 0, + ActivationReturnedAtUtc = activation, + LastAnomalyReceivedAtUtc = activation.AddMilliseconds(250) + }; + var description = DiagnosticReportBuilder.ExplainBrcbContinuity(stream); + Assert.Contains("lastAnomalyAfterStartMs=250", description); + Assert.DoesNotContain("late recurrence", description); + Assert.Contains("not TCP wire order", description); + Assert.Contains("withoutConfirmedGI=2", description); + } + + [Fact] + public void UnknownOrReversedTimestamps_DoNotFabricateLateRecurrence() + { + var now = DateTimeOffset.UtcNow; + var noTiming = DiagnosticReportBuilder.ExplainBrcbContinuity( + new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + SequenceDiscontinuities = 1 + }); + Assert.Contains("timing=UNKNOWN", noTiming); + var reversed = DiagnosticReportBuilder.ExplainBrcbContinuity( + new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + SequenceDiscontinuities = 1, + ActivationReturnedAtUtc = now, + LastAnomalyReceivedAtUtc = now.AddSeconds(-1) + }); + Assert.Contains("timing=UNKNOWN", reversed); + Assert.DoesNotContain("late recurrence", reversed); + } +} From 14399369abae30d55ec04a9dec051b5679a59e38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 07:40:21 +0700 Subject: [PATCH 2/2] fix(diagnostics): avoid unreachable wording when gateway route is unverified --- Services/DiagnosticReportBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index bcd9e6d94..336332cdd 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -562,7 +562,7 @@ internal static string ExplainIpv4Route( "Check the local MMS listener and actual OS route if connection fails."; if (distinctInterfaces.Length == 0) return $"No directly matching IPv4 subnet for {target}; a gateway or static route can still provide connectivity. " + - "Check the Windows routing table rather than assuming the endpoint is unreachable."; + "Check the Windows routing table before drawing a connectivity conclusion."; if (distinctInterfaces.Length == 1) return $"One network interface ({distinctInterfaces[0]}) matches {target}, across " + $"{matchedAddresses.Count} IPv4 address(es). IP aliases are not competing adapters; " +