From 2463d2b4b759e6e732f818f81d9762829dc4ae7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:01:46 +0700 Subject: [PATCH 01/13] Add raw SNTP Ethernet frame codec --- Services/SntpEthernetFrameCodec.cs | 284 +++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 Services/SntpEthernetFrameCodec.cs diff --git a/Services/SntpEthernetFrameCodec.cs b/Services/SntpEthernetFrameCodec.cs new file mode 100644 index 00000000..77f9a464 --- /dev/null +++ b/Services/SntpEthernetFrameCodec.cs @@ -0,0 +1,284 @@ +using System.Buffers.Binary; +using System.Net; + +namespace ArIED61850Tester.Services; + +public readonly record struct SntpRawClientFrame( + byte[] SourceMac, + byte[] DestinationMac, + IPAddress SourceAddress, + IPAddress DestinationAddress, + ushort SourcePort, + ushort DestinationPort, + ushort? VlanTci, + ushort? VlanEtherType, + byte[] Payload); + +/// +/// Minimal Ethernet/IPv4/UDP codec used by the raw Npcap SNTP fallback. +/// It intentionally understands only the traffic needed by commissioning NTP: +/// Ethernet (optionally one VLAN tag), IPv4 without fragmentation, UDP and SNTP Mode 3. +/// +public static class SntpEthernetFrameCodec +{ + private const ushort EtherTypeIpv4 = 0x0800; + private const ushort EtherTypeDot1Q = 0x8100; + private const ushort EtherTypeDot1Ad = 0x88A8; + private const byte IpProtocolUdp = 17; + private const int Ipv4HeaderLength = 20; + private const int UdpHeaderLength = 8; + + public static bool TryParseClientRequest( + ReadOnlySpan frame, + IPAddress localAddress, + out SntpRawClientFrame request) + { + request = default; + ArgumentNullException.ThrowIfNull(localAddress); + + if (localAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork || frame.Length < 14) + return false; + + var sourceMac = frame.Slice(6, 6).ToArray(); + var destinationMac = frame.Slice(0, 6).ToArray(); + var etherType = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(12, 2)); + var networkOffset = 14; + ushort? vlanTci = null; + ushort? vlanEtherType = null; + + if (etherType is EtherTypeDot1Q or EtherTypeDot1Ad) + { + if (frame.Length < 18) + return false; + + vlanEtherType = etherType; + vlanTci = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(14, 2)); + etherType = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(16, 2)); + networkOffset = 18; + } + + if (etherType != EtherTypeIpv4 || frame.Length < networkOffset + Ipv4HeaderLength) + return false; + + var versionAndHeaderLength = frame[networkOffset]; + if ((versionAndHeaderLength >> 4) != 4) + return false; + + var ipHeaderLength = (versionAndHeaderLength & 0x0F) * 4; + if (ipHeaderLength < Ipv4HeaderLength || frame.Length < networkOffset + ipHeaderLength + UdpHeaderLength) + return false; + + var totalLength = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(networkOffset + 2, 2)); + if (totalLength < ipHeaderLength + UdpHeaderLength || frame.Length < networkOffset + totalLength) + return false; + + var fragmentField = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(networkOffset + 6, 2)); + if ((fragmentField & 0x3FFF) != 0) + return false; + + if (frame[networkOffset + 9] != IpProtocolUdp) + return false; + + var sourceAddress = new IPAddress(frame.Slice(networkOffset + 12, 4)); + var destinationAddress = new IPAddress(frame.Slice(networkOffset + 16, 4)); + var udpOffset = networkOffset + ipHeaderLength; + var sourcePort = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(udpOffset, 2)); + var destinationPort = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(udpOffset + 2, 2)); + var udpLength = BinaryPrimitives.ReadUInt16BigEndian(frame.Slice(udpOffset + 4, 2)); + + if (destinationPort != 123 || udpLength < UdpHeaderLength || udpOffset + udpLength > networkOffset + totalLength) + return false; + + if (!destinationAddress.Equals(localAddress) && + !destinationAddress.Equals(IPAddress.Broadcast) && + !IsSubnetBroadcastFor(localAddress, destinationAddress)) + return false; + + var payload = frame.Slice(udpOffset + UdpHeaderLength, udpLength - UdpHeaderLength).ToArray(); + if (!SntpPacket.TryReadClientRequest(payload, out _)) + return false; + + request = new SntpRawClientFrame( + sourceMac, + destinationMac, + sourceAddress, + destinationAddress, + sourcePort, + destinationPort, + vlanTci, + vlanEtherType, + payload); + return true; + } + + public static byte[] BuildServerReply( + in SntpRawClientFrame request, + ReadOnlySpan localMac, + IPAddress localAddress, + ReadOnlySpan sntpPayload, + ushort identification = 0) + { + ValidateMac(localMac); + ValidateIpv4(localAddress); + if (request.SourceMac is not { Length: 6 }) + throw new ArgumentException("Raw SNTP request must contain a six-byte source MAC.", nameof(request)); + + return BuildIpv4UdpFrame( + destinationMac: request.SourceMac, + sourceMac: localMac, + sourceAddress: localAddress, + destinationAddress: request.SourceAddress, + sourcePort: 123, + destinationPort: request.SourcePort, + payload: sntpPayload, + vlanTci: request.VlanTci, + vlanEtherType: request.VlanEtherType, + identification: identification); + } + + public static byte[] BuildBroadcast( + ReadOnlySpan localMac, + IPAddress localAddress, + IPAddress directedBroadcast, + ReadOnlySpan sntpPayload, + ushort identification = 0) + { + ValidateMac(localMac); + ValidateIpv4(localAddress); + ValidateIpv4(directedBroadcast); + + Span destinationMac = stackalloc byte[6]; + destinationMac.Fill(0xFF); + return BuildIpv4UdpFrame( + destinationMac, + localMac, + localAddress, + directedBroadcast, + 123, + 123, + sntpPayload, + null, + null, + identification); + } + + private static byte[] BuildIpv4UdpFrame( + ReadOnlySpan destinationMac, + ReadOnlySpan sourceMac, + IPAddress sourceAddress, + IPAddress destinationAddress, + ushort sourcePort, + ushort destinationPort, + ReadOnlySpan payload, + ushort? vlanTci, + ushort? vlanEtherType, + ushort identification) + { + ValidateMac(destinationMac); + ValidateMac(sourceMac); + ValidateIpv4(sourceAddress); + ValidateIpv4(destinationAddress); + + var hasVlan = vlanTci.HasValue; + var ethernetLength = hasVlan ? 18 : 14; + var udpLength = checked(UdpHeaderLength + payload.Length); + var ipLength = checked(Ipv4HeaderLength + udpLength); + if (udpLength > ushort.MaxValue || ipLength > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(payload), "SNTP Ethernet payload is too large for IPv4/UDP."); + + var result = new byte[ethernetLength + ipLength]; + destinationMac.CopyTo(result.AsSpan(0, 6)); + sourceMac.CopyTo(result.AsSpan(6, 6)); + + if (hasVlan) + { + BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(12, 2), vlanEtherType ?? EtherTypeDot1Q); + BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(14, 2), vlanTci!.Value); + BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(16, 2), EtherTypeIpv4); + } + else + { + BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(12, 2), EtherTypeIpv4); + } + + var ip = result.AsSpan(ethernetLength, Ipv4HeaderLength); + ip.Clear(); + ip[0] = 0x45; + BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(2, 2), checked((ushort)ipLength)); + BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(4, 2), identification); + ip[8] = 64; + ip[9] = IpProtocolUdp; + sourceAddress.GetAddressBytes().AsSpan().CopyTo(ip.Slice(12, 4)); + destinationAddress.GetAddressBytes().AsSpan().CopyTo(ip.Slice(16, 4)); + BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(10, 2), ComputeInternetChecksum(ip)); + + var udp = result.AsSpan(ethernetLength + Ipv4HeaderLength, udpLength); + udp.Clear(); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(0, 2), sourcePort); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(2, 2), destinationPort); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(4, 2), checked((ushort)udpLength)); + payload.CopyTo(udp.Slice(UdpHeaderLength)); + + var udpChecksum = ComputeUdpChecksum(sourceAddress, destinationAddress, udp); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(6, 2), udpChecksum == 0 ? (ushort)0xFFFF : udpChecksum); + return result; + } + + private static ushort ComputeUdpChecksum(IPAddress sourceAddress, IPAddress destinationAddress, ReadOnlySpan udp) + { + var pseudo = new byte[12 + udp.Length]; + sourceAddress.GetAddressBytes().AsSpan().CopyTo(pseudo.AsSpan(0, 4)); + destinationAddress.GetAddressBytes().AsSpan().CopyTo(pseudo.AsSpan(4, 4)); + pseudo[9] = IpProtocolUdp; + BinaryPrimitives.WriteUInt16BigEndian(pseudo.AsSpan(10, 2), checked((ushort)udp.Length)); + udp.CopyTo(pseudo.AsSpan(12)); + pseudo[18] = 0; + pseudo[19] = 0; + return ComputeInternetChecksum(pseudo); + } + + internal static ushort ComputeInternetChecksum(ReadOnlySpan data) + { + uint sum = 0; + var index = 0; + while (index + 1 < data.Length) + { + sum += BinaryPrimitives.ReadUInt16BigEndian(data.Slice(index, 2)); + index += 2; + } + + if (index < data.Length) + sum += (uint)data[index] << 8; + + while ((sum >> 16) != 0) + sum = (sum & 0xFFFF) + (sum >> 16); + + return unchecked((ushort)~sum); + } + + private static bool IsSubnetBroadcastFor(IPAddress localAddress, IPAddress destinationAddress) + { + var local = localAddress.GetAddressBytes(); + var destination = destinationAddress.GetAddressBytes(); + if (local.Length != 4 || destination.Length != 4) + return false; + + // Avoid guessing the Windows prefix here. This check only permits an IPv4 address + // ending in .255 as a compatibility path; exact directed-broadcast validation is + // performed by SntpNetworkRouteResolver before ARSAS transmits its own broadcasts. + return destination[3] == 0xFF && destination[0] == local[0] && destination[1] == local[1]; + } + + private static void ValidateMac(ReadOnlySpan mac) + { + if (mac.Length != 6) + throw new ArgumentException("Ethernet MAC address must contain exactly six bytes.", nameof(mac)); + } + + private static void ValidateIpv4(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + throw new NotSupportedException("Raw SNTP transport currently supports IPv4 only."); + } +} From 7dc20d15519395e7046fd318e4537237cec7ff2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:02:16 +0700 Subject: [PATCH 02/13] Add Npcap raw SNTP transport --- Services/SntpRawNpcapTransport.cs | 179 ++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 Services/SntpRawNpcapTransport.cs diff --git a/Services/SntpRawNpcapTransport.cs b/Services/SntpRawNpcapTransport.cs new file mode 100644 index 00000000..9d9ce3d1 --- /dev/null +++ b/Services/SntpRawNpcapTransport.cs @@ -0,0 +1,179 @@ +using System.Net; +using System.Net.NetworkInformation; +using AR.Iec61850.Transports; +using AR.Iec61850.Transports.Npcap; + +namespace ArIED61850Tester.Services; + +/// +/// Raw Ethernet SNTP fallback for Windows hosts where UDP/123 is already owned by W32Time +/// or another service. It never changes the Windows Time service and never claims that a +/// transmitted packet proves the IED synchronized its clock. +/// +public sealed class SntpRawNpcapTransport : IAsyncDisposable +{ + private readonly SntpNetworkBinding _binding; + private readonly byte[] _localMac; + private readonly string _adapterSelector; + private NpcapProcessBusDuplexTransport? _transport; + private CancellationTokenSource? _cancellation; + private Task? _captureTask; + private Func? _requestHandler; + private int _identification; + + public SntpRawNpcapTransport(SntpNetworkBinding binding) + { + _binding = binding ?? throw new ArgumentNullException(nameof(binding)); + (_adapterSelector, _localMac) = ResolveNpcapAdapter(binding); + } + + public string AdapterSelector => _adapterSelector; + + public Task StartAsync( + Func requestHandler, + CancellationToken applicationCancellation = default) + { + ArgumentNullException.ThrowIfNull(requestHandler); + if (_transport != null) + throw new InvalidOperationException("Raw SNTP Npcap transport is already running."); + + _requestHandler = requestHandler; + _transport = new NpcapProcessBusDuplexTransport(_adapterSelector); + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(applicationCancellation); + _captureTask = CaptureLoopAsync(_transport, _cancellation.Token); + return Task.CompletedTask; + } + + public async Task SendReplyAsync( + in SntpRawClientFrame request, + ReadOnlyMemory sntpPayload, + CancellationToken cancellationToken = default) + { + var transport = _transport ?? throw new InvalidOperationException("Raw SNTP transport is not running."); + var frame = SntpEthernetFrameCodec.BuildServerReply( + request, + _localMac, + _binding.LocalAddress, + sntpPayload.Span, + NextIdentification()); + await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); + } + + public async Task SendBroadcastAsync( + IPAddress directedBroadcast, + ReadOnlyMemory sntpPayload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(directedBroadcast); + var transport = _transport ?? throw new InvalidOperationException("Raw SNTP transport is not running."); + var frame = SntpEthernetFrameCodec.BuildBroadcast( + _localMac, + _binding.LocalAddress, + directedBroadcast, + sntpPayload.Span, + NextIdentification()); + await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); + } + + private async Task CaptureLoopAsync( + NpcapProcessBusDuplexTransport transport, + CancellationToken cancellationToken) + { + var options = new ProcessBusCaptureOptions + { + Filter = "udp dst port 123", + ReadTimeoutMilliseconds = 250, + BufferCapacity = 1024 + }; + + try + { + await foreach (var captured in transport.CaptureAsync(options, cancellationToken).ConfigureAwait(false)) + { + if (!SntpEthernetFrameCodec.TryParseClientRequest(captured.Frame, _binding.LocalAddress, out var request)) + continue; + + var handler = _requestHandler; + if (handler != null) + await handler(request, captured.Timestamp.ToUniversalTime(), cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private ushort NextIdentification() + => unchecked((ushort)Interlocked.Increment(ref _identification)); + + private static (string Selector, byte[] LocalMac) ResolveNpcapAdapter(SntpNetworkBinding binding) + { + var windowsAdapter = NetworkInterface.GetAllNetworkInterfaces() + .FirstOrDefault(adapter => adapter.Id.Equals(binding.InterfaceId, StringComparison.OrdinalIgnoreCase)); + if (windowsAdapter == null) + throw new InvalidOperationException($"Windows network adapter '{binding.InterfaceName}' ({binding.InterfaceId}) is no longer available."); + + var localMac = windowsAdapter.GetPhysicalAddress().GetAddressBytes(); + if (localMac.Length != 6) + throw new InvalidOperationException($"Station-bus adapter '{binding.InterfaceName}' does not expose a six-byte Ethernet MAC address."); + + var adapters = NpcapAdapterCatalog.ListAdapters(); + if (adapters.Count == 0) + throw new InvalidOperationException("Npcap is not installed or no capture adapters are available."); + + var normalizedId = Normalize(binding.InterfaceId); + var byId = adapters.FirstOrDefault(adapter => Normalize(adapter.Name).Contains(normalizedId, StringComparison.OrdinalIgnoreCase)); + if (byId != null) + return (byId.Name, localMac); + + var normalizedMac = Convert.ToHexString(localMac); + var byMac = adapters + .Where(adapter => Normalize(adapter.MacAddress?.ToString()).Equals(normalizedMac, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (byMac.Length == 1) + return (byMac[0].Name, localMac); + + throw new InvalidOperationException( + $"Npcap could not map the Windows station-bus adapter '{binding.InterfaceName}' ({binding.LocalAddress}) to a capture device."); + } + + private static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + Span buffer = stackalloc char[value.Length]; + var length = 0; + foreach (var character in value) + { + if (char.IsAsciiHexDigit(character)) + buffer[length++] = char.ToUpperInvariant(character); + } + + return new string(buffer[..length]); + } + + public async ValueTask DisposeAsync() + { + var cancellation = _cancellation; + _cancellation = null; + if (cancellation != null) + { + try { cancellation.Cancel(); } catch { } + } + + var captureTask = _captureTask; + _captureTask = null; + if (captureTask != null) + { + try { await captureTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + } + + try { _transport?.Dispose(); } catch { } + _transport = null; + _requestHandler = null; + cancellation?.Dispose(); + } +} From f855901dc552121d1b766ad11e899759139969c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:03:41 +0700 Subject: [PATCH 03/13] Fallback SNTP to raw Npcap when UDP 123 is busy --- Services/SntpClockService.cs | 314 +++++++++++++++++++++++++++-------- 1 file changed, 241 insertions(+), 73 deletions(-) diff --git a/Services/SntpClockService.cs b/Services/SntpClockService.cs index ab42880f..33bd6136 100644 --- a/Services/SntpClockService.cs +++ b/Services/SntpClockService.cs @@ -14,29 +14,50 @@ public enum SntpClockServiceState Faulted } +public enum SntpClockTransportMode +{ + None, + UdpSocket, + NpcapRaw +} + public sealed record SntpClientObservation( IPAddress Address, DateTimeOffset LastRequestUtc, int RequestCount, byte Version); +public sealed record SntpReplyObservation( + IPAddress Address, + DateTimeOffset SentUtc, + long ReplyCount, + byte Version, + SntpClockTransportMode TransportMode); + public sealed record SntpClockServiceSnapshot( SntpClockServiceState State, string Detail, SntpNetworkBinding? Binding, DateTimeOffset? LastBroadcastUtc, int ObservedClientCount, - bool ClockHealthy); + bool ClockHealthy, + SntpClockTransportMode TransportMode, + long BroadcastCount, + long ClientRequestCount, + long ReplyCount, + DateTimeOffset? LastRequestUtc, + DateTimeOffset? LastReplyUtc); /// /// Lightweight SNTPv4 commissioning clock for ARSAS. /// -/// Design goals: -/// - clean-room wire implementation from RFC semantics; -/// - one UDP/123 service bound only to the station-bus interface chosen by Windows routing; -/// - Mode 4 unicast replies plus Mode 5 directed broadcasts; -/// - no dependency on MMS/GOOSE/SV protocol code; -/// - fail-open for IEC 61850: any SNTP problem is diagnostic only and never breaks an IED association. +/// Preferred path is a normal UDP/123 socket bound to the station-bus interface. When +/// Windows Time or another service already owns UDP/123, ARSAS falls back to a raw Npcap +/// Ethernet transport on the same adapter. ARSAS never stops or reconfigures W32Time. +/// +/// Evidence is deliberately split into broadcast sent, client request observed and +/// Mode-4 reply sent. None of those signals alone is presented as proof that the IED has +/// synchronized its internal clock. /// public sealed class SntpClockService : IAsyncDisposable { @@ -46,14 +67,21 @@ public sealed class SntpClockService : IAsyncDisposable private readonly SntpServerProfile _profile; private readonly TimeSpan _broadcastInterval; private UdpClient? _udp; + private SntpRawNpcapTransport? _rawTransport; private CancellationTokenSource? _serviceCancellation; private Task? _receiveTask; private Task? _broadcastTask; private SntpNetworkBinding? _binding; private DateTimeOffset? _lastBroadcastUtc; + private DateTimeOffset? _lastRequestUtc; + private DateTimeOffset? _lastReplyUtc; private SntpClockServiceState _state = SntpClockServiceState.Stopped; + private SntpClockTransportMode _transportMode = SntpClockTransportMode.None; private string _detail = "SNTP clock service is stopped."; private int _broadcastPulseRequested; + private long _broadcastCount; + private long _clientRequestCount; + private long _replyCount; public SntpClockService( SntpServerProfile? profile = null, @@ -65,6 +93,7 @@ public SntpClockService( public event Action? StatusChanged; public event Action? ClientRequestObserved; + public event Action? ReplySent; public SntpClockServiceSnapshot Snapshot => new( @@ -73,7 +102,13 @@ public SntpClockServiceSnapshot Snapshot _binding, _lastBroadcastUtc, _clients.Count, - _clockHealth.Sample().IsHealthy); + _clockHealth.Sample().IsHealthy, + _transportMode, + Interlocked.Read(ref _broadcastCount), + Interlocked.Read(ref _clientRequestCount), + Interlocked.Read(ref _replyCount), + _lastRequestUtc, + _lastReplyUtc); public IReadOnlyCollection ObservedClients => _clients.Values.OrderBy(item => item.Address.ToString(), StringComparer.OrdinalIgnoreCase).ToArray(); @@ -86,7 +121,7 @@ public async Task EnsureStartedAsync(IPAddress iedAddress, CancellationToken can try { var requestedBinding = SntpNetworkRouteResolver.ResolveForRemote(iedAddress); - if (_udp != null && _binding != null) + if ((_udp != null || _rawTransport != null) && _binding != null) { if (_binding.LocalAddress.Equals(requestedBinding.LocalAddress)) { @@ -96,7 +131,7 @@ public async Task EnsureStartedAsync(IPAddress iedAddress, CancellationToken can SetState( SntpClockServiceState.Serving, - $"SNTP remains bound to {_binding.Summary}. IED {iedAddress} routes through {requestedBinding.LocalAddress}; multi-NIC serving is deferred to the raw/Npcap transport phase."); + $"SNTP remains bound to {_binding.Summary}. IED {iedAddress} routes through {requestedBinding.LocalAddress}; one station-bus clock transport is active at a time."); return; } @@ -137,9 +172,19 @@ public async Task StopAsync() catch { } } + if (_rawTransport != null) + { + try { await _rawTransport.DisposeAsync().ConfigureAwait(false); } catch { } + _rawTransport = null; + } + cancellation?.Dispose(); _binding = null; _lastBroadcastUtc = null; + _lastRequestUtc = null; + _lastReplyUtc = null; + _transportMode = SntpClockTransportMode.None; + ResetEvidenceCounters(); SetState(SntpClockServiceState.Stopped, "SNTP clock service is stopped."); } finally @@ -154,51 +199,90 @@ public async ValueTask DisposeAsync() _lifecycleGate.Dispose(); } - private Task StartCoreAsync(SntpNetworkBinding binding, CancellationToken cancellationToken) + private async Task StartCoreAsync(SntpNetworkBinding binding, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); SetState(SntpClockServiceState.Starting, $"Preparing SNTP on {binding.Summary}."); + ResetEvidenceCounters(); + _clients.Clear(); + _clockHealth.Reset(); + _binding = binding; + + var serviceCancellation = new CancellationTokenSource(); + _serviceCancellation = serviceCancellation; + var udp = new UdpClient(AddressFamily.InterNetwork); try { - // Do not co-bind UDP/123. If Windows Time or another server owns it, fail clearly - // rather than risk nondeterministic packet delivery between two NTP listeners. + // Prefer the ordinary Windows socket path when it is actually available. + // Never co-bind or stop W32Time: a bind conflict falls through to Npcap RAW. udp.Client.ExclusiveAddressUse = true; udp.EnableBroadcast = true; udp.Client.Bind(new IPEndPoint(binding.LocalAddress, 123)); + + _udp = udp; + _transportMode = SntpClockTransportMode.UdpSocket; + SetState( + SntpClockServiceState.Serving, + binding.DirectedBroadcast == null + ? $"SNTP UDP server active on {binding.LocalAddress}:123 with SIPROTEC compatibility stratum {_profile.Stratum}. No usable directed broadcast is available." + : $"SNTP UDP server active on {binding.LocalAddress}:123 with SIPROTEC compatibility stratum {_profile.Stratum}; Mode 5 broadcast targets {binding.DirectedBroadcast}:123."); + + _receiveTask = ReceiveLoopAsync(udp, serviceCancellation.Token); + _broadcastTask = BroadcastLoopAsync(binding, serviceCancellation.Token); + RequestImmediateBroadcast(); + return; } catch (SocketException ex) { udp.Dispose(); - SetState( - SntpClockServiceState.PortUnavailable, - $"UDP/123 is unavailable on {binding.LocalAddress} ({ex.SocketErrorCode}). Windows Time or another NTP service may already own the port."); - return Task.CompletedTask; + _udp = null; + await StartRawFallbackAsync(binding, ex.SocketErrorCode.ToString(), serviceCancellation, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { udp.Dispose(); - SetState(SntpClockServiceState.Faulted, $"Could not start SNTP on {binding.LocalAddress}: {ex.Message}"); - return Task.CompletedTask; + _udp = null; + await StartRawFallbackAsync(binding, ex.Message, serviceCancellation, cancellationToken).ConfigureAwait(false); } + } - _binding = binding; - _udp = udp; - _clients.Clear(); - _clockHealth.Reset(); - _serviceCancellation = new CancellationTokenSource(); - - SetState( - SntpClockServiceState.Serving, - binding.DirectedBroadcast == null - ? $"SNTP unicast server active on {binding.LocalAddress}:123 with SIPROTEC compatibility stratum {_profile.Stratum}. This subnet has no usable directed broadcast address." - : $"SNTP server active on {binding.LocalAddress}:123 with SIPROTEC compatibility stratum {_profile.Stratum}; Mode 5 broadcast targets {binding.DirectedBroadcast}:123."); - - _receiveTask = ReceiveLoopAsync(udp, _serviceCancellation.Token); - _broadcastTask = BroadcastLoopAsync(udp, binding, _serviceCancellation.Token); - RequestImmediateBroadcast(); - return Task.CompletedTask; + private async Task StartRawFallbackAsync( + SntpNetworkBinding binding, + string udpFailure, + CancellationTokenSource serviceCancellation, + CancellationToken startCancellation) + { + startCancellation.ThrowIfCancellationRequested(); + try + { + var raw = new SntpRawNpcapTransport(binding); + await raw.StartAsync(HandleRawClientRequestAsync, serviceCancellation.Token).ConfigureAwait(false); + _rawTransport = raw; + _transportMode = SntpClockTransportMode.NpcapRaw; + + SetState( + SntpClockServiceState.Serving, + binding.DirectedBroadcast == null + ? $"UDP/123 unavailable ({udpFailure}); Npcap RAW SNTP fallback active on {binding.InterfaceName} / {binding.LocalAddress}. Windows Time was left unchanged." + : $"UDP/123 unavailable ({udpFailure}); Npcap RAW SNTP fallback active on {binding.InterfaceName} / {binding.LocalAddress}. Mode 5 broadcast targets {binding.DirectedBroadcast}:123. Windows Time was left unchanged."); + + _broadcastTask = BroadcastLoopAsync(binding, serviceCancellation.Token); + RequestImmediateBroadcast(); + } + catch (Exception rawException) + { + try { serviceCancellation.Cancel(); } catch { } + serviceCancellation.Dispose(); + if (ReferenceEquals(_serviceCancellation, serviceCancellation)) + _serviceCancellation = null; + _binding = null; + _transportMode = SntpClockTransportMode.None; + SetState( + SntpClockServiceState.PortUnavailable, + $"UDP/123 unavailable ({udpFailure}) and Npcap RAW fallback could not start: {rawException.Message}. IEC 61850 remains unaffected."); + } } private async Task ReceiveLoopAsync(UdpClient udp, CancellationToken cancellationToken) @@ -223,62 +307,84 @@ private async Task ReceiveLoopAsync(UdpClient udp, CancellationToken cancellatio if (!SntpPacket.TryReadClientRequest(received.Buffer, out var request)) continue; - var health = _clockHealth.Sample(receiveUtc); - var transmitUtc = DateTimeOffset.UtcNow; - byte[] reply; - try - { - reply = SntpPacket.BuildServerReply( - received.Buffer, - receiveUtc, - transmitUtc, - _profile with { ReferenceUtc = health.ReferenceUtc }, - health.IsHealthy); - } - catch (ArgumentOutOfRangeException) - { - SetState(SntpClockServiceState.Serving, - "SNTP request was ignored because the Windows UTC value is outside the NTP timestamp range."); + RecordClientRequest(received.RemoteEndPoint.Address, receiveUtc, request.Version); + var reply = BuildReplyOrNull(received.Buffer, receiveUtc, out var transmitUtc); + if (reply == null) continue; - } try { await udp.SendAsync(reply, reply.Length, received.RemoteEndPoint).ConfigureAwait(false); + RecordReply(received.RemoteEndPoint.Address, transmitUtc, request.Version); } catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { break; } catch (SocketException ex) { if (!cancellationToken.IsCancellationRequested) SetState(SntpClockServiceState.Faulted, $"SNTP reply to {received.RemoteEndPoint.Address} failed: {ex.SocketErrorCode}."); - continue; } - - var key = received.RemoteEndPoint.Address.ToString(); - var observation = _clients.AddOrUpdate( - key, - _ => new SntpClientObservation(received.RemoteEndPoint.Address, transmitUtc, 1, request.Version), - (_, previous) => previous with - { - LastRequestUtc = transmitUtc, - RequestCount = previous.RequestCount + 1, - Version = request.Version - }); - ClientRequestObserved?.Invoke(observation); } } - private async Task BroadcastLoopAsync( - UdpClient udp, - SntpNetworkBinding binding, + private async Task HandleRawClientRequestAsync( + SntpRawClientFrame rawRequest, + DateTimeOffset receiveUtc, CancellationToken cancellationToken) + { + if (!SntpPacket.TryReadClientRequest(rawRequest.Payload, out var request)) + return; + + RecordClientRequest(rawRequest.SourceAddress, receiveUtc, request.Version); + var reply = BuildReplyOrNull(rawRequest.Payload, receiveUtc, out var transmitUtc); + if (reply == null) + return; + + var raw = _rawTransport; + if (raw == null) + return; + + try + { + await raw.SendReplyAsync(rawRequest, reply, cancellationToken).ConfigureAwait(false); + RecordReply(rawRequest.SourceAddress, transmitUtc, request.Version); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + SetState(SntpClockServiceState.Faulted, $"RAW SNTP reply to {rawRequest.SourceAddress} failed: {ex.Message}"); + } + } + + private byte[]? BuildReplyOrNull(ReadOnlySpan requestPacket, DateTimeOffset receiveUtc, out DateTimeOffset transmitUtc) + { + var health = _clockHealth.Sample(receiveUtc); + transmitUtc = DateTimeOffset.UtcNow; + try + { + return SntpPacket.BuildServerReply( + requestPacket, + receiveUtc, + transmitUtc, + _profile with { ReferenceUtc = health.ReferenceUtc }, + health.IsHealthy); + } + catch (ArgumentOutOfRangeException) + { + SetState( + SntpClockServiceState.Serving, + "SNTP request was ignored because the Windows UTC value is outside the NTP timestamp range."); + return null; + } + } + + private async Task BroadcastLoopAsync(SntpNetworkBinding binding, CancellationToken cancellationToken) { if (binding.DirectedBroadcast == null) return; - var destination = new IPEndPoint(binding.DirectedBroadcast, 123); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); - while (!cancellationToken.IsCancellationRequested) { try @@ -295,15 +401,17 @@ private async Task BroadcastLoopAsync( now, _profile with { ReferenceUtc = health.ReferenceUtc }, synchronized: true); - await udp.SendAsync(packet, packet.Length, destination).ConfigureAwait(false); + + await SendBroadcastPacketAsync(binding.DirectedBroadcast, packet, cancellationToken).ConfigureAwait(false); _lastBroadcastUtc = now; + Interlocked.Increment(ref _broadcastCount); PublishStatus(); } else { SetState( SntpClockServiceState.Serving, - $"SNTP is listening on {binding.LocalAddress}:123, but this broadcast was suppressed because the Windows clock health check failed: {health.Detail}"); + $"SNTP broadcast suppressed because the Windows clock health check failed: {health.Detail}"); } } @@ -318,9 +426,70 @@ private async Task BroadcastLoopAsync( SetState(SntpClockServiceState.Faulted, $"SNTP broadcast failed: {ex.SocketErrorCode}."); break; } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + SetState(SntpClockServiceState.Faulted, $"SNTP broadcast failed: {ex.Message}"); + break; + } } } + private async Task SendBroadcastPacketAsync( + IPAddress directedBroadcast, + byte[] packet, + CancellationToken cancellationToken) + { + if (_transportMode == SntpClockTransportMode.UdpSocket && _udp != null) + { + var destination = new IPEndPoint(directedBroadcast, 123); + await _udp.SendAsync(packet, packet.Length, destination).ConfigureAwait(false); + return; + } + + if (_transportMode == SntpClockTransportMode.NpcapRaw && _rawTransport != null) + { + await _rawTransport.SendBroadcastAsync(directedBroadcast, packet, cancellationToken).ConfigureAwait(false); + return; + } + + throw new InvalidOperationException("No active SNTP transport is available for broadcast."); + } + + private void RecordClientRequest(IPAddress address, DateTimeOffset requestUtc, byte version) + { + _lastRequestUtc = requestUtc; + Interlocked.Increment(ref _clientRequestCount); + var key = address.ToString(); + var observation = _clients.AddOrUpdate( + key, + _ => new SntpClientObservation(address, requestUtc, 1, version), + (_, previous) => previous with + { + LastRequestUtc = requestUtc, + RequestCount = previous.RequestCount + 1, + Version = version + }); + ClientRequestObserved?.Invoke(observation); + PublishStatus(); + } + + private void RecordReply(IPAddress address, DateTimeOffset sentUtc, byte version) + { + _lastReplyUtc = sentUtc; + var replyCount = Interlocked.Increment(ref _replyCount); + ReplySent?.Invoke(new SntpReplyObservation(address, sentUtc, replyCount, version, _transportMode)); + PublishStatus(); + } + + private void ResetEvidenceCounters() + { + Interlocked.Exchange(ref _broadcastCount, 0); + Interlocked.Exchange(ref _clientRequestCount, 0); + Interlocked.Exchange(ref _replyCount, 0); + Interlocked.Exchange(ref _broadcastPulseRequested, 0); + } + private void SetState(SntpClockServiceState state, string detail) { _state = state; @@ -372,7 +541,6 @@ public ClockHealthSample Sample(DateTimeOffset? nowOverride = null) if (jump > TimeSpan.FromSeconds(2)) { - // Reject one packet after a large wall-clock step, then re-baseline. _baselineUtc = now; _baselineTimestamp = Stopwatch.GetTimestamp(); _referenceUtc = now; From 6b38b4e0fbf82530bd86f261712d994f0a12ddc8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:04:20 +0700 Subject: [PATCH 04/13] Expose honest Clock Sync wire evidence to FAT --- MainWindow.ClockSync.cs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index dd8e3e45..aff2da34 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -11,9 +11,13 @@ public partial class MainWindow private readonly SntpClockService _sntpClockService = new(); private readonly SemaphoreSlim _clockSyncIntegrationGate = new(1, 1); private readonly HashSet _clockSyncObservedClients = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _clockSyncRepliedClients = new(StringComparer.OrdinalIgnoreCase); private string _lastClockSyncStatus = string.Empty; private bool _clockSyncLifecycleAttached; + internal event Action? ClockSyncSnapshotChanged; + internal SntpClockServiceSnapshot ClockSyncSnapshot => _sntpClockService.Snapshot; + private void InitializeClockSyncLifecycle() { if (_clockSyncLifecycleAttached) @@ -26,6 +30,7 @@ private void InitializeClockSyncLifecycle() _sntpClockService.StatusChanged += ClockSyncService_StatusChanged; _sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved; + _sntpClockService.ReplySent += ClockSyncService_ReplySent; Closed += ClockSyncMainWindow_Closed; } @@ -104,7 +109,11 @@ private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot) { void Publish() { - var status = $"{snapshot.State}|{snapshot.Detail}"; + // FAT telemetry must receive every evidence-counter change even when the textual + // service detail did not change. The live log remains deduplicated separately. + ClockSyncSnapshotChanged?.Invoke(snapshot); + + var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}"; if (status.Equals(_lastClockSyncStatus, StringComparison.Ordinal)) return; @@ -131,8 +140,6 @@ private void ClockSyncService_ClientRequestObserved(SntpClientObservation observ void Publish() { - // A request from the same client can occur indefinitely. Keep the live log quiet: - // first observation proves the client is using ARSAS; counters remain in the service snapshot. if (!_clockSyncObservedClients.Add(key)) return; @@ -140,7 +147,30 @@ void Publish() item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); var name = device?.Name ?? key; AddLog("INFO", "Clock Sync", - $"{name} ({key}) requested SNTPv{observation.Version}; ARSAS returned a Mode 4 reply from the station-bus interface."); + $"{name} ({key}) sent an SNTPv{observation.Version} client request to ARSAS. Request observed; synchronization is not yet proven."); + } + + if (Dispatcher.CheckAccess()) + Publish(); + else + Dispatcher.BeginInvoke(new Action(Publish)); + } + + private void ClockSyncService_ReplySent(SntpReplyObservation observation) + { + var key = observation.Address.ToString(); + + void Publish() + { + if (!_clockSyncRepliedClients.Add(key)) + return; + + var device = Devices.FirstOrDefault(item => + item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); + var name = device?.Name ?? key; + var transport = observation.TransportMode == SntpClockTransportMode.NpcapRaw ? "Npcap RAW" : "UDP"; + AddLog("INFO", "Clock Sync", + $"{name} ({key}) received an ARSAS SNTP Mode 4 reply via {transport}. Reply sent; relay clock synchronization remains unproven until device evidence confirms it."); } if (Dispatcher.CheckAccess()) @@ -159,6 +189,7 @@ private async void ClockSyncMainWindow_Closed(object? sender, EventArgs e) _sntpClockService.StatusChanged -= ClockSyncService_StatusChanged; _sntpClockService.ClientRequestObserved -= ClockSyncService_ClientRequestObserved; + _sntpClockService.ReplySent -= ClockSyncService_ReplySent; await _sntpClockService.DisposeAsync(); } catch From 7dec5ee2e459e12b0b69f2f5090fcf5d9f231b47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:04:38 +0700 Subject: [PATCH 05/13] Reset Clock Sync request and reply evidence on toggle --- MainWindow.ClockSyncToggle.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index b6a22226..d9ed36b9 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -26,6 +26,7 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) { await _sntpClockService.StopAsync(); _clockSyncObservedClients.Clear(); + _clockSyncRepliedClients.Clear(); AddLog("INFO", "Clock Sync", "Clock Sync disabled from the FAT workspace. IEC 61850 monitoring remains active."); } @@ -48,6 +49,6 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) AttachClockSyncDevice(device); AddLog("INFO", "Clock Sync", - "Clock Sync enabled from the FAT workspace. ARSAS will advertise laptop time to connected IPv4 IEDs using the SIPROTEC-compatible SNTP profile."); + "Clock Sync enabled from the FAT workspace. ARSAS will serve connected IPv4 IEDs using normal UDP/123 when available and an Npcap RAW fallback when Windows already owns that port. Windows Time is never stopped or reconfigured."); } } From 15d161f0447c54fe8cb0b09c96ded2ff6f974334 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:05:35 +0700 Subject: [PATCH 06/13] Show Clock Sync transport and wire evidence in FAT --- IoListTestingWindow.ClockSyncUx.cs | 108 ++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/IoListTestingWindow.ClockSyncUx.cs b/IoListTestingWindow.ClockSyncUx.cs index 17486929..9a22b81f 100644 --- a/IoListTestingWindow.ClockSyncUx.cs +++ b/IoListTestingWindow.ClockSyncUx.cs @@ -1,18 +1,22 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Media; +using ArIED61850Tester.Services; namespace ArIED61850Tester; public partial class IoListTestingWindow { private CheckBox? _clockSyncCheckBox; + private TextBlock? _clockSyncEvidenceText; + private MainWindow? _clockSyncSnapshotOwner; private bool _clockSyncCheckBoxRefreshing; protected override void OnInitialized(EventArgs e) { base.OnInitialized(e); Loaded += ClockSyncUx_Loaded; + Closed += ClockSyncUx_Closed; } private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) @@ -20,6 +24,7 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) if (_clockSyncCheckBox != null) { RefreshClockSyncCheckBox(); + AttachClockSyncSnapshotOwner(); return; } @@ -35,20 +40,111 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) Name = "ClockSyncEnabledCheckBox", Content = "Clock Sync", VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 12, 0), + Margin = new Thickness(0, 0, 8, 0), Padding = new Thickness(2, 0, 2, 0), FontSize = 11.2, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("Ink") as Brush ?? Brushes.DimGray, Focusable = false, - ToolTip = "SNTP laptop → IED. Checked: ARSAS serves and broadcasts laptop time using the SIPROTEC compatibility profile (stratum 2). Unchecked: ARSAS stops its SNTP service. IEC 61850 monitoring is unaffected." + ToolTip = "SNTP laptop → IED. Checked: ARSAS serves laptop time using normal UDP/123 when available, with an Npcap RAW fallback when Windows already owns UDP/123. Unchecked: ARSAS stops only its Clock Sync service. IEC 61850 remains unaffected." + }; + + var evidence = new TextBlock + { + Name = "ClockSyncEvidenceTextBlock", + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 12, 0), + FontSize = 10.4, + FontWeight = FontWeights.Medium, + Foreground = TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray, + Text = "Clock: waiting" }; _clockSyncCheckBox = checkBox; + _clockSyncEvidenceText = evidence; RefreshClockSyncCheckBox(); checkBox.Checked += ClockSyncCheckBox_Changed; checkBox.Unchecked += ClockSyncCheckBox_Changed; actionPanel.Children.Insert(previewIndex + 1, checkBox); + actionPanel.Children.Insert(previewIndex + 2, evidence); + AttachClockSyncSnapshotOwner(); + } + + private void AttachClockSyncSnapshotOwner() + { + if (Owner is not MainWindow mainWindow) + return; + + if (!ReferenceEquals(_clockSyncSnapshotOwner, mainWindow)) + { + if (_clockSyncSnapshotOwner != null) + _clockSyncSnapshotOwner.ClockSyncSnapshotChanged -= ClockSyncSnapshotChanged; + + _clockSyncSnapshotOwner = mainWindow; + mainWindow.ClockSyncSnapshotChanged += ClockSyncSnapshotChanged; + } + + RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot); + } + + private void ClockSyncSnapshotChanged(SntpClockServiceSnapshot snapshot) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => RefreshClockSyncEvidence(snapshot))); + return; + } + + RefreshClockSyncEvidence(snapshot); + } + + private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot) + { + if (_clockSyncEvidenceText == null) + return; + + var transport = snapshot.TransportMode switch + { + SntpClockTransportMode.NpcapRaw => "RAW", + SntpClockTransportMode.UdpSocket => "UDP", + _ => "—" + }; + + _clockSyncEvidenceText.Text = snapshot.State switch + { + SntpClockServiceState.Serving => + $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven", + SntpClockServiceState.Starting => "Clock: starting…", + SntpClockServiceState.Stopped => "Clock: off", + SntpClockServiceState.PortUnavailable => "Clock: unavailable", + SntpClockServiceState.Faulted => "Clock: fault", + _ => $"Clock: {snapshot.State}" + }; + + _clockSyncEvidenceText.ToolTip = BuildClockSyncEvidenceToolTip(snapshot, transport); + _clockSyncEvidenceText.Foreground = snapshot.State switch + { + SntpClockServiceState.PortUnavailable or SntpClockServiceState.Faulted => Brushes.DarkOrange, + SntpClockServiceState.Serving when snapshot.ReplyCount > 0 => Brushes.SeaGreen, + _ => TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray + }; + } + + private static string BuildClockSyncEvidenceToolTip(SntpClockServiceSnapshot snapshot, string transport) + { + var binding = snapshot.Binding == null ? "—" : snapshot.Binding.Summary; + var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; + var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; + var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; + return $"Clock Sync evidence\n" + + $"State: {snapshot.State}\n" + + $"Transport: {transport}\n" + + $"Binding: {binding}\n" + + $"Broadcast sent: {snapshot.BroadcastCount} (last {lastBroadcast})\n" + + $"Client request seen: {snapshot.ClientRequestCount} (last {lastRequest})\n" + + $"Mode 4 reply sent: {snapshot.ReplyCount} (last {lastReply})\n\n" + + "These counters prove packet activity only. ARSAS does not claim that the relay clock is synchronized without device-side evidence.\n\n" + + snapshot.Detail; } private void RefreshClockSyncCheckBox() @@ -80,10 +176,18 @@ private async void ClockSyncCheckBox_Changed(object sender, RoutedEventArgs e) { await mainWindow.SetClockSyncEnabledAsync(requested); RefreshClockSyncCheckBox(); + RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot); } finally { _clockSyncCheckBox.IsEnabled = true; } } + + private void ClockSyncUx_Closed(object? sender, EventArgs e) + { + if (_clockSyncSnapshotOwner != null) + _clockSyncSnapshotOwner.ClockSyncSnapshotChanged -= ClockSyncSnapshotChanged; + _clockSyncSnapshotOwner = null; + } } From b818972df642bc5e220b20b2baba0ec2b0b37a08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:06:31 +0700 Subject: [PATCH 07/13] Add raw SNTP frame regression tests --- .../SntpEthernetFrameCodecTests.cs | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs diff --git a/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs b/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs new file mode 100644 index 00000000..fde565a7 --- /dev/null +++ b/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs @@ -0,0 +1,192 @@ +using System.Buffers.Binary; +using System.Net; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SntpEthernetFrameCodecTests +{ + private static readonly byte[] LaptopMac = [0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0x01]; + private static readonly byte[] RelayMac = [0x00, 0x0E, 0x8C, 0x11, 0x22, 0x33]; + private static readonly IPAddress LaptopIp = IPAddress.Parse("192.168.81.100"); + private static readonly IPAddress RelayIp = IPAddress.Parse("192.168.81.70"); + + [Fact] + public void Mode3RawFrame_IsParsed_AndMode4ReplySwapsEndpointsWithValidChecksums() + { + var requestPayload = BuildMode3Request(); + var ethernet = BuildClientFrame(requestPayload, sourcePort: 49152); + + Assert.True(SntpEthernetFrameCodec.TryParseClientRequest(ethernet, LaptopIp, out var parsed)); + Assert.Equal(RelayMac, parsed.SourceMac); + Assert.Equal(RelayIp, parsed.SourceAddress); + Assert.Equal(LaptopIp, parsed.DestinationAddress); + Assert.Equal((ushort)49152, parsed.SourcePort); + Assert.Equal((ushort)123, parsed.DestinationPort); + + var receive = new DateTimeOffset(2026, 8, 13, 5, 40, 0, TimeSpan.Zero); + var transmit = receive.AddMilliseconds(1); + var ntpReply = SntpPacket.BuildServerReply(requestPayload, receive, transmit, new SntpServerProfile()); + var reply = SntpEthernetFrameCodec.BuildServerReply(parsed, LaptopMac, LaptopIp, ntpReply, 0x1234); + + Assert.Equal(RelayMac, reply.AsSpan(0, 6).ToArray()); + Assert.Equal(LaptopMac, reply.AsSpan(6, 6).ToArray()); + Assert.Equal((ushort)0x0800, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(12, 2))); + + const int ipOffset = 14; + Assert.Equal(LaptopIp, new IPAddress(reply.AsSpan(ipOffset + 12, 4))); + Assert.Equal(RelayIp, new IPAddress(reply.AsSpan(ipOffset + 16, 4))); + Assert.Equal((ushort)0x1234, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(ipOffset + 4, 2))); + Assert.Equal((ushort)0, InternetChecksum(reply.AsSpan(ipOffset, 20))); + + const int udpOffset = ipOffset + 20; + Assert.Equal((ushort)123, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(udpOffset, 2))); + Assert.Equal((ushort)49152, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(udpOffset + 2, 2))); + Assert.True(UdpChecksumIsValid(reply, ipOffset, udpOffset)); + + var payload = reply.AsSpan(udpOffset + 8, SntpPacket.MinimumLength); + Assert.Equal(4, payload[0] & 0x07); + Assert.Equal(SntpServerProfile.SiprotecCompatibilityStratum, payload[1]); + Assert.Equal(requestPayload.AsSpan(40, 8).ToArray(), payload.Slice(24, 8).ToArray()); + } + + [Fact] + public void VlanTaggedMode3_PreservesTagInRawReply() + { + const ushort vlanTci = 0xA064; + var requestPayload = BuildMode3Request(); + var ethernet = BuildClientFrame(requestPayload, sourcePort: 123, vlanTci: vlanTci); + + Assert.True(SntpEthernetFrameCodec.TryParseClientRequest(ethernet, LaptopIp, out var parsed)); + Assert.Equal(vlanTci, parsed.VlanTci); + Assert.Equal((ushort)0x8100, parsed.VlanEtherType); + + var replyPayload = SntpPacket.BuildServerReply( + requestPayload, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + new SntpServerProfile()); + var reply = SntpEthernetFrameCodec.BuildServerReply(parsed, LaptopMac, LaptopIp, replyPayload); + + Assert.Equal((ushort)0x8100, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(12, 2))); + Assert.Equal(vlanTci, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(14, 2))); + Assert.Equal((ushort)0x0800, BinaryPrimitives.ReadUInt16BigEndian(reply.AsSpan(16, 2))); + } + + [Fact] + public void BroadcastFrame_UsesEthernetBroadcastDirectedIpv4AndMode5() + { + var now = new DateTimeOffset(2026, 8, 13, 5, 45, 0, TimeSpan.Zero); + var ntp = SntpPacket.BuildBroadcast(now, new SntpServerProfile()); + var frame = SntpEthernetFrameCodec.BuildBroadcast( + LaptopMac, + LaptopIp, + IPAddress.Parse("192.168.81.255"), + ntp, + 7); + + Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, frame.AsSpan(0, 6).ToArray()); + Assert.Equal(LaptopMac, frame.AsSpan(6, 6).ToArray()); + Assert.Equal(IPAddress.Parse("192.168.81.255"), new IPAddress(frame.AsSpan(30, 4))); + Assert.Equal((ushort)123, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(34, 2))); + Assert.Equal((ushort)123, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(36, 2))); + Assert.Equal(5, frame[42] & 0x07); + Assert.Equal((byte)2, frame[43]); + Assert.Equal((ushort)0, InternetChecksum(frame.AsSpan(14, 20))); + Assert.True(UdpChecksumIsValid(frame, 14, 34)); + } + + [Fact] + public void NonMode3OrWrongDestinationPort_IsRejected() + { + var request = BuildMode3Request(); + request[0] = (byte)((4 << 3) | 4); + Assert.False(SntpEthernetFrameCodec.TryParseClientRequest(BuildClientFrame(request), LaptopIp, out _)); + + request[0] = (byte)((4 << 3) | 3); + var wrongPort = BuildClientFrame(request, destinationPort: 124); + Assert.False(SntpEthernetFrameCodec.TryParseClientRequest(wrongPort, LaptopIp, out _)); + } + + private static byte[] BuildMode3Request() + { + var request = new byte[SntpPacket.MinimumLength]; + request[0] = (byte)((4 << 3) | 3); + request[2] = 6; + SntpPacket.WriteTimestamp( + request.AsSpan(40, 8), + new DateTimeOffset(2026, 8, 13, 5, 30, 0, 125, TimeSpan.Zero)); + return request; + } + + private static byte[] BuildClientFrame( + byte[] ntpPayload, + ushort sourcePort = 123, + ushort destinationPort = 123, + ushort? vlanTci = null) + { + var ethernetLength = vlanTci.HasValue ? 18 : 14; + var udpLength = 8 + ntpPayload.Length; + var ipLength = 20 + udpLength; + var frame = new byte[ethernetLength + ipLength]; + LaptopMac.CopyTo(frame, 0); + RelayMac.CopyTo(frame, 6); + + if (vlanTci.HasValue) + { + BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(12, 2), 0x8100); + BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(14, 2), vlanTci.Value); + BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(16, 2), 0x0800); + } + else + { + BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(12, 2), 0x0800); + } + + var ip = frame.AsSpan(ethernetLength, 20); + ip[0] = 0x45; + BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(2, 2), (ushort)ipLength); + ip[8] = 64; + ip[9] = 17; + RelayIp.GetAddressBytes().CopyTo(ip.Slice(12, 4)); + LaptopIp.GetAddressBytes().CopyTo(ip.Slice(16, 4)); + BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(10, 2), InternetChecksum(ip)); + + var udp = frame.AsSpan(ethernetLength + 20, udpLength); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(0, 2), sourcePort); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(2, 2), destinationPort); + BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(4, 2), (ushort)udpLength); + ntpPayload.CopyTo(udp.Slice(8)); + return frame; + } + + private static bool UdpChecksumIsValid(byte[] frame, int ipOffset, int udpOffset) + { + var udpLength = BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(udpOffset + 4, 2)); + var pseudo = new byte[12 + udpLength]; + frame.AsSpan(ipOffset + 12, 8).CopyTo(pseudo.AsSpan(0, 8)); + pseudo[9] = 17; + BinaryPrimitives.WriteUInt16BigEndian(pseudo.AsSpan(10, 2), udpLength); + frame.AsSpan(udpOffset, udpLength).CopyTo(pseudo.AsSpan(12)); + return InternetChecksum(pseudo) == 0; + } + + private static ushort InternetChecksum(ReadOnlySpan data) + { + uint sum = 0; + var index = 0; + while (index + 1 < data.Length) + { + sum += BinaryPrimitives.ReadUInt16BigEndian(data.Slice(index, 2)); + index += 2; + } + + if (index < data.Length) + sum += (uint)data[index] << 8; + + while ((sum >> 16) != 0) + sum = (sum & 0xFFFF) + (sum >> 16); + + return unchecked((ushort)~sum); + } +} From ff4e1afb084f5bb3553f4fa066683fdca4c59723 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:07:15 +0700 Subject: [PATCH 08/13] Fix raw SNTP regression test span copies --- tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs b/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs index fde565a7..49702643 100644 --- a/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs +++ b/tests/ARSAS.Tests/SntpEthernetFrameCodecTests.cs @@ -148,15 +148,15 @@ private static byte[] BuildClientFrame( BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(2, 2), (ushort)ipLength); ip[8] = 64; ip[9] = 17; - RelayIp.GetAddressBytes().CopyTo(ip.Slice(12, 4)); - LaptopIp.GetAddressBytes().CopyTo(ip.Slice(16, 4)); + RelayIp.GetAddressBytes().AsSpan().CopyTo(ip.Slice(12, 4)); + LaptopIp.GetAddressBytes().AsSpan().CopyTo(ip.Slice(16, 4)); BinaryPrimitives.WriteUInt16BigEndian(ip.Slice(10, 2), InternetChecksum(ip)); var udp = frame.AsSpan(ethernetLength + 20, udpLength); BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(0, 2), sourcePort); BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(2, 2), destinationPort); BinaryPrimitives.WriteUInt16BigEndian(udp.Slice(4, 2), (ushort)udpLength); - ntpPayload.CopyTo(udp.Slice(8)); + ntpPayload.AsSpan().CopyTo(udp.Slice(8)); return frame; } From abdcf42ea087a0e116c809f8e36ae9bce5422fa0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:08:59 +0700 Subject: [PATCH 09/13] Harden raw SNTP transport lifecycle and fault reporting --- Services/SntpRawNpcapTransport.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Services/SntpRawNpcapTransport.cs b/Services/SntpRawNpcapTransport.cs index 9d9ce3d1..624a7253 100644 --- a/Services/SntpRawNpcapTransport.cs +++ b/Services/SntpRawNpcapTransport.cs @@ -27,6 +27,7 @@ public SntpRawNpcapTransport(SntpNetworkBinding binding) (_adapterSelector, _localMac) = ResolveNpcapAdapter(binding); } + public event Action? Faulted; public string AdapterSelector => _adapterSelector; public Task StartAsync( @@ -45,7 +46,7 @@ public Task StartAsync( } public async Task SendReplyAsync( - in SntpRawClientFrame request, + SntpRawClientFrame request, ReadOnlyMemory sntpPayload, CancellationToken cancellationToken = default) { @@ -101,6 +102,13 @@ private async Task CaptureLoopAsync( catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + Faulted?.Invoke(ex); + } } private ushort NextIdentification() @@ -122,9 +130,13 @@ private static (string Selector, byte[] LocalMac) ResolveNpcapAdapter(SntpNetwor throw new InvalidOperationException("Npcap is not installed or no capture adapters are available."); var normalizedId = Normalize(binding.InterfaceId); - var byId = adapters.FirstOrDefault(adapter => Normalize(adapter.Name).Contains(normalizedId, StringComparison.OrdinalIgnoreCase)); - if (byId != null) - return (byId.Name, localMac); + if (!string.IsNullOrEmpty(normalizedId)) + { + var byId = adapters.FirstOrDefault(adapter => + Normalize(adapter.Name).Contains(normalizedId, StringComparison.OrdinalIgnoreCase)); + if (byId != null) + return (byId.Name, localMac); + } var normalizedMac = Convert.ToHexString(localMac); var byMac = adapters From 22e1f860c63bd2d4a2ab85207509e057cca3a388 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:11:44 +0700 Subject: [PATCH 10/13] Explain Npcap requirement for Clock Sync raw fallback --- installer/ArIED61850.iss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer/ArIED61850.iss b/installer/ArIED61850.iss index 4b43baeb..5245b517 100644 --- a/installer/ArIED61850.iss +++ b/installer/ArIED61850.iss @@ -91,7 +91,7 @@ begin begin SuppressibleMsgBox( 'ARSAS is installed and its MMS/SCL features are ready.' + #13#10 + #13#10 + - 'Npcap was not detected. Install Npcap separately before using the GOOSE Subscriber. ' + + 'Npcap was not detected. Install Npcap separately before using the GOOSE Subscriber or the Clock Sync RAW fallback that keeps SNTP working when Windows already owns UDP/123. ' + 'Keep WinPcap API compatibility enabled when required by your engineering workstation policy.', mbInformation, MB_OK, From f2bc0c49317682ef3d7804b67d5e34d8537a63cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:12:11 +0700 Subject: [PATCH 11/13] Document raw SNTP fallback and honest FAT evidence --- docs/SNTP_CLOCK_SYNC.md | 74 ++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/docs/SNTP_CLOCK_SYNC.md b/docs/SNTP_CLOCK_SYNC.md index 597ecf27..4ad3d263 100644 --- a/docs/SNTP_CLOCK_SYNC.md +++ b/docs/SNTP_CLOCK_SYNC.md @@ -1,44 +1,69 @@ -# ARSAS Clock Sync — SNTP P0 +# ARSAS Clock Sync — SNTP commissioning service -ARSAS includes a small clean-room SNTPv4 commissioning service for station-bus work. The implementation is written specifically for ARSAS from the protocol behavior described by RFC 4330 and RFC 5905; it does not embed or copy a third-party GPL NTP implementation. +ARSAS includes a small clean-room SNTPv4 commissioning service for station-bus work. The implementation is written specifically for ARSAS from the protocol behavior described by RFC 4330 and RFC 5905; it does not embed or copy a third-party NTP implementation. -## P0 behavior +## Current behavior -- Starts automatically after the first IPv4 IED reaches `IsConnected = true`. +- Starts automatically after the first IPv4 IED reaches `IsConnected = true` while the FAT `Clock Sync` checkbox is enabled. - Uses the Windows route to that IED to select the station-bus IPv4 interface. -- Binds only that local interface on UDP/123. -- Replies to SNTPv3/v4 client requests with server mode 4. +- Prefers a normal UDP/123 socket bound only to that local interface. +- If Windows Time or another process already owns UDP/123, automatically falls back to raw Ethernet capture/injection through Npcap on the same station-bus adapter. +- Never stops, restarts, or reconfigures Windows Time. +- Replies to SNTPv3/v4 client Mode 3 requests with server Mode 4. - Copies the client Version, Poll, and Transmit timestamp into the reply fields required by SNTP server semantics. -- Sends an immediate SNTPv4 mode-5 directed broadcast, then repeats every 64 seconds by default. +- Sends an immediate SNTPv4 Mode 5 directed broadcast, then repeats every 64 seconds by default when a usable directed-broadcast address exists. - Sends another immediate broadcast when a newly connected IED is observed. -- Records client request observations by source IP so ARSAS can distinguish `request observed` from merely `broadcast advertised`. -- Advertises synchronized commissioning packets with SIPROTEC compatibility `stratum 2` and reference ID `LOCL`. This is intentionally below the SIPROTEC questionable-stratum boundary used in Siemens time-quality handling, while avoiding a false `stratum 1` primary-reference claim. +- Separately records `broadcast sent`, `client request seen`, and `Mode 4 reply sent` evidence. +- Advertises synchronized commissioning packets with SIPROTEC compatibility `stratum 2` and reference ID `LOCL`. - Performs a wall-clock sanity/step check. A large time step suppresses broadcast and makes that instant's unicast reply RFC-style unsynchronized (`LI=3`, `stratum=0`, `INIT`, server timestamps zero). - Never fails an IEC 61850 association when SNTP cannot start. +## Evidence semantics + +FAT Clock Sync telemetry intentionally distinguishes packet activity from actual relay synchronization: + +- `B` / Broadcast: ARSAS transmitted a Mode 5 broadcast. +- `Req`: ARSAS observed a client Mode 3 request from an IED. +- `Reply`: ARSAS successfully transmitted a Mode 4 response. +- `sync not proven`: none of the three counters alone proves that the relay accepted the source or adjusted its internal clock. + +A broadcast without a client request may still be valid when the relay is explicitly configured for broadcast NTP, but ARSAS does not treat it as an acknowledgement. For unicast SNTP, the strongest wire-level evidence is a Mode 3 request followed by a Mode 4 reply. Device-side time-quality or clock evidence is still required before declaring the relay synchronized. + ## SIPROTEC compatibility stratum -SIPROTEC devices evaluate the quality of the SNTP server in addition to timestamp fields. Field commissioning has shown that a conservative high-stratum local source can be rejected or remain marked unsynchronized on SIPROTEC installations. ARSAS therefore forces its current commissioning profile to `stratum 2` for both mode-4 unicast replies and mode-5 broadcasts. +Field commissioning has shown that a conservative high-stratum local source can be rejected or remain marked unsynchronized on some SIPROTEC installations. ARSAS therefore uses `stratum 2` for both Mode 4 replies and Mode 5 broadcasts. -The value is named in code as `SntpServerProfile.SiprotecCompatibilityStratum` and is protected by regression tests. It is not used to claim that the Windows laptop is physically traceable to a stratum-1 GNSS/PTP/atomic source. `LOCL` remains the reference ID and ARSAS diagnostics describe the source as a local commissioning clock. +The value is named in code as `SntpServerProfile.SiprotecCompatibilityStratum` and is protected by regression tests. It does not claim that the Windows laptop is physically traceable to a stratum-1 GNSS/PTP/atomic source. `LOCL` remains the reference ID and ARSAS diagnostics describe the laptop as a local commissioning source. If the Windows clock fails the ARSAS clock-health guard, synchronized stratum is not advertised: the affected unicast response becomes unsynchronized (`LI=3`, `stratum=0`, `INIT`) and broadcast is suppressed. ## Accuracy and trust boundary -P0 intentionally does not claim UTC traceability. The Windows system clock is treated as a temporary commissioning reference. ARSAS checks for gross time sanity and sudden wall-clock steps, but it does not claim the laptop is equivalent to GPS, IRIG-B, PTP, or an IEC/IEEE 61850-9-3 grandmaster. +ARSAS intentionally does not claim UTC traceability. The Windows system clock is treated as a temporary commissioning reference. ARSAS checks for gross time sanity and sudden wall-clock steps, but it does not claim the laptop is equivalent to GPS, IRIG-B, PTP, or an IEC/IEEE 61850-9-3 grandmaster. + +A later phase can add explicit Windows upstream-source verification and/or device-side time-quality correlation without changing the SNTP packet engine. -A later phase can add explicit Windows upstream-source verification and/or PTP monitoring without changing the SNTP packet engine. +## UDP/123 ownership and Npcap RAW fallback -## UDP/123 ownership +Windows Time and other NTP software may already own UDP/123. ARSAS first attempts exclusive ownership of UDP/123 on the selected station-bus address. If that succeeds, normal Windows UDP sockets are used. -Windows Time and other NTP software may already own UDP/123. ARSAS does **not** stop or reconfigure those services automatically. ARSAS requests exclusive ownership of UDP/123 on the selected station-bus address; if bind fails, Clock Sync reports `PortUnavailable` and all MMS/GOOSE/SV behavior continues normally. +If the bind fails, ARSAS leaves the existing Windows service untouched and attempts an Npcap RAW fallback on the same adapter. The fallback: -A raw/Npcap transport can be added later as a separate phase for advanced multi-NIC/coexistence scenarios. P0 intentionally keeps the time service isolated and auditable. +- captures Ethernet frames matching `udp dst port 123`; +- accepts only supported IPv4 SNTP Mode 3 client requests addressed to the station-bus laptop; +- preserves a single incoming 802.1Q/802.1ad VLAN tag on the Mode 4 reply; +- builds Ethernet, IPv4 and UDP headers directly; +- calculates IPv4 and UDP checksums; +- replies directly to the request source MAC/IP/UDP port; +- injects Mode 5 broadcasts with Ethernet broadcast MAC and the route-derived directed-broadcast IPv4 address. + +If both the normal socket path and Npcap fallback are unavailable, Clock Sync reports `PortUnavailable`; MMS/GOOSE/SV/FAT IEC 61850 behavior remains fail-open. + +Npcap is therefore optional for the normal UDP path but required for the RAW fallback. The Windows installer warns when Npcap is not detected; it does not silently install drivers or modify Windows Time/firewall policy. ## Network scope -P0 serves the first station-bus interface selected by Windows routing. IEDs on that subnet can use either unicast SNTP (configure the ARSAS laptop IP as server) or mode-5 broadcast if their vendor configuration supports broadcast client mode. +ARSAS serves the first station-bus interface selected by Windows routing. IEDs on that subnet can use unicast SNTP by configuring the ARSAS laptop station-bus IP as the server, or Mode 5 broadcast when the relay configuration supports broadcast-client operation. If another connected IED routes through a different local IPv4 interface, ARSAS reports that the existing clock service remains on the original station-bus binding rather than silently moving the clock source. @@ -48,10 +73,19 @@ If another connected IED routes through a different local IPv4 interface, ARSAS - SNTP client request recognition; - version/poll field copy behavior; -- mode-4 reply semantics; +- Mode 4 reply semantics; - originate timestamp echo; - SIPROTEC compatibility stratum 2 on unicast and broadcast packets; -- mode-5 broadcast semantics; +- Mode 5 broadcast semantics; - RFC-style unsynchronized response fields; -- directed broadcast calculation; +- directed-broadcast calculation; - NTP timestamp round-trip accuracy. + +`SntpEthernetFrameCodecTests` additionally covers: + +- raw Ethernet Mode 3 recognition; +- MAC/IP/UDP endpoint swapping for Mode 4 replies; +- valid IPv4 and UDP checksums; +- preservation of a single VLAN tag; +- raw Mode 5 Ethernet/directed-broadcast construction; +- rejection of non-Mode-3 or wrong-destination-port traffic. From 1c6ae61b22287c05383ad6f6882b33e0e8baa75f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:13:23 +0700 Subject: [PATCH 12/13] Surface Npcap runtime faults in Clock Sync telemetry --- Services/SntpClockService.cs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/Services/SntpClockService.cs b/Services/SntpClockService.cs index 33bd6136..834146f5 100644 --- a/Services/SntpClockService.cs +++ b/Services/SntpClockService.cs @@ -172,10 +172,12 @@ public async Task StopAsync() catch { } } - if (_rawTransport != null) + var raw = _rawTransport; + _rawTransport = null; + if (raw != null) { - try { await _rawTransport.DisposeAsync().ConfigureAwait(false); } catch { } - _rawTransport = null; + raw.Faulted -= RawTransport_Faulted; + try { await raw.DisposeAsync().ConfigureAwait(false); } catch { } } cancellation?.Dispose(); @@ -255,11 +257,13 @@ private async Task StartRawFallbackAsync( CancellationToken startCancellation) { startCancellation.ThrowIfCancellationRequested(); + SntpRawNpcapTransport? raw = null; try { - var raw = new SntpRawNpcapTransport(binding); - await raw.StartAsync(HandleRawClientRequestAsync, serviceCancellation.Token).ConfigureAwait(false); + raw = new SntpRawNpcapTransport(binding); + raw.Faulted += RawTransport_Faulted; _rawTransport = raw; + await raw.StartAsync(HandleRawClientRequestAsync, serviceCancellation.Token).ConfigureAwait(false); _transportMode = SntpClockTransportMode.NpcapRaw; SetState( @@ -273,6 +277,14 @@ private async Task StartRawFallbackAsync( } catch (Exception rawException) { + if (raw != null) + { + raw.Faulted -= RawTransport_Faulted; + if (ReferenceEquals(_rawTransport, raw)) + _rawTransport = null; + try { await raw.DisposeAsync().ConfigureAwait(false); } catch { } + } + try { serviceCancellation.Cancel(); } catch { } serviceCancellation.Dispose(); if (ReferenceEquals(_serviceCancellation, serviceCancellation)) @@ -285,6 +297,16 @@ private async Task StartRawFallbackAsync( } } + private void RawTransport_Faulted(Exception exception) + { + if (_serviceCancellation?.IsCancellationRequested != false) + return; + + SetState( + SntpClockServiceState.Faulted, + $"Npcap RAW SNTP capture failed: {exception.Message}. IEC 61850 remains unaffected."); + } + private async Task ReceiveLoopAsync(UdpClient udp, CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) From 45afd3203bfbbd5cf1ef0f1ec415ae00025a057e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 13:15:58 +0700 Subject: [PATCH 13/13] Fix Npcap captured-frame span conversion --- Services/SntpRawNpcapTransport.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/SntpRawNpcapTransport.cs b/Services/SntpRawNpcapTransport.cs index 624a7253..c85046cf 100644 --- a/Services/SntpRawNpcapTransport.cs +++ b/Services/SntpRawNpcapTransport.cs @@ -91,7 +91,7 @@ private async Task CaptureLoopAsync( { await foreach (var captured in transport.CaptureAsync(options, cancellationToken).ConfigureAwait(false)) { - if (!SntpEthernetFrameCodec.TryParseClientRequest(captured.Frame, _binding.LocalAddress, out var request)) + if (!SntpEthernetFrameCodec.TryParseClientRequest(captured.Frame.Span, _binding.LocalAddress, out var request)) continue; var handler = _requestHandler;