diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs new file mode 100644 index 00000000..dd8e3e45 --- /dev/null +++ b/MainWindow.ClockSync.cs @@ -0,0 +1,169 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Net; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private readonly SntpClockService _sntpClockService = new(); + private readonly SemaphoreSlim _clockSyncIntegrationGate = new(1, 1); + private readonly HashSet _clockSyncObservedClients = new(StringComparer.OrdinalIgnoreCase); + private string _lastClockSyncStatus = string.Empty; + private bool _clockSyncLifecycleAttached; + + private void InitializeClockSyncLifecycle() + { + if (_clockSyncLifecycleAttached) + return; + + _clockSyncLifecycleAttached = true; + Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; + foreach (var device in Devices) + AttachClockSyncDevice(device); + + _sntpClockService.StatusChanged += ClockSyncService_StatusChanged; + _sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved; + Closed += ClockSyncMainWindow_Closed; + } + + private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (var item in e.OldItems.OfType()) + item.PropertyChanged -= ClockSyncDevice_PropertyChanged; + } + + if (e.NewItems != null) + { + foreach (var item in e.NewItems.OfType()) + AttachClockSyncDevice(item); + } + } + + private void AttachClockSyncDevice(Iec61850MonitorDevice device) + { + device.PropertyChanged -= ClockSyncDevice_PropertyChanged; + device.PropertyChanged += ClockSyncDevice_PropertyChanged; + + if (device.IsConnected) + ScheduleClockSyncReconcile(device); + } + + private void ClockSyncDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(Iec61850MonitorDevice.IsConnected) || + sender is not Iec61850MonitorDevice device || + !device.IsConnected) + return; + + ScheduleClockSyncReconcile(device); + } + + private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => ScheduleClockSyncReconcile(device))); + return; + } + + _ = EnsureClockSyncForDeviceAsync(device); + } + + private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device) + { + if (!IPAddress.TryParse(device.IpAddress, out var iedAddress) || + iedAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + return; + + await _clockSyncIntegrationGate.WaitAsync(); + try + { + await _sntpClockService.EnsureStartedAsync(iedAddress, _applicationCancellation.Token); + _sntpClockService.RequestImmediateBroadcast(); + } + catch (OperationCanceledException) when (_applicationCancellation.IsCancellationRequested) + { + } + catch (Exception ex) + { + AddLog("WARN", "Clock Sync", + $"{device.Name}: IEC 61850 remains connected, but ARSAS SNTP could not start: {ex.Message}"); + } + finally + { + _clockSyncIntegrationGate.Release(); + } + } + + private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot) + { + void Publish() + { + var status = $"{snapshot.State}|{snapshot.Detail}"; + if (status.Equals(_lastClockSyncStatus, StringComparison.Ordinal)) + return; + + _lastClockSyncStatus = status; + var level = snapshot.State switch + { + SntpClockServiceState.Serving => "INFO", + SntpClockServiceState.Starting => "INFO", + SntpClockServiceState.Stopped => "INFO", + _ => "WARN" + }; + AddLog(level, "Clock Sync", snapshot.Detail); + } + + if (Dispatcher.CheckAccess()) + Publish(); + else + Dispatcher.BeginInvoke(new Action(Publish)); + } + + private void ClockSyncService_ClientRequestObserved(SntpClientObservation observation) + { + var key = observation.Address.ToString(); + + 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; + + var device = Devices.FirstOrDefault(item => + 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."); + } + + if (Dispatcher.CheckAccess()) + Publish(); + else + Dispatcher.BeginInvoke(new Action(Publish)); + } + + private async void ClockSyncMainWindow_Closed(object? sender, EventArgs e) + { + try + { + Devices.CollectionChanged -= ClockSyncDevices_CollectionChanged; + foreach (var device in Devices) + device.PropertyChanged -= ClockSyncDevice_PropertyChanged; + + _sntpClockService.StatusChanged -= ClockSyncService_StatusChanged; + _sntpClockService.ClientRequestObserved -= ClockSyncService_ClientRequestObserved; + await _sntpClockService.DisposeAsync(); + } + catch + { + // Application shutdown must never be blocked by a commissioning helper service. + } + } +} diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index eb78e831..0a16cb8d 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -23,6 +23,7 @@ public partial class MainWindow protected override void OnInitialized(EventArgs e) { base.OnInitialized(e); + InitializeClockSyncLifecycle(); Dispatcher.BeginInvoke(new Action(InstallFirstRunTestingChoices), DispatcherPriority.Loaded); } diff --git a/Services/SntpClockService.cs b/Services/SntpClockService.cs new file mode 100644 index 00000000..ab42880f --- /dev/null +++ b/Services/SntpClockService.cs @@ -0,0 +1,396 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace ArIED61850Tester.Services; + +public enum SntpClockServiceState +{ + Stopped, + Starting, + Serving, + PortUnavailable, + Faulted +} + +public sealed record SntpClientObservation( + IPAddress Address, + DateTimeOffset LastRequestUtc, + int RequestCount, + byte Version); + +public sealed record SntpClockServiceSnapshot( + SntpClockServiceState State, + string Detail, + SntpNetworkBinding? Binding, + DateTimeOffset? LastBroadcastUtc, + int ObservedClientCount, + bool ClockHealthy); + +/// +/// 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. +/// +public sealed class SntpClockService : IAsyncDisposable +{ + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + private readonly ConcurrentDictionary _clients = new(StringComparer.OrdinalIgnoreCase); + private readonly SntpClockHealthMonitor _clockHealth = new(); + private readonly SntpServerProfile _profile; + private readonly TimeSpan _broadcastInterval; + private UdpClient? _udp; + private CancellationTokenSource? _serviceCancellation; + private Task? _receiveTask; + private Task? _broadcastTask; + private SntpNetworkBinding? _binding; + private DateTimeOffset? _lastBroadcastUtc; + private SntpClockServiceState _state = SntpClockServiceState.Stopped; + private string _detail = "SNTP clock service is stopped."; + private int _broadcastPulseRequested; + + public SntpClockService( + SntpServerProfile? profile = null, + TimeSpan? broadcastInterval = null) + { + _profile = profile ?? new SntpServerProfile(); + _broadcastInterval = NormalizeBroadcastInterval(broadcastInterval ?? TimeSpan.FromSeconds(64)); + } + + public event Action? StatusChanged; + public event Action? ClientRequestObserved; + + public SntpClockServiceSnapshot Snapshot + => new( + _state, + _detail, + _binding, + _lastBroadcastUtc, + _clients.Count, + _clockHealth.Sample().IsHealthy); + + public IReadOnlyCollection ObservedClients + => _clients.Values.OrderBy(item => item.Address.ToString(), StringComparer.OrdinalIgnoreCase).ToArray(); + + public async Task EnsureStartedAsync(IPAddress iedAddress, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(iedAddress); + + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var requestedBinding = SntpNetworkRouteResolver.ResolveForRemote(iedAddress); + if (_udp != null && _binding != null) + { + if (_binding.LocalAddress.Equals(requestedBinding.LocalAddress)) + { + RequestImmediateBroadcast(); + return; + } + + 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."); + return; + } + + await StartCoreAsync(requestedBinding, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + public void RequestImmediateBroadcast() + => Interlocked.Exchange(ref _broadcastPulseRequested, 1); + + public async Task StopAsync() + { + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + var cancellation = _serviceCancellation; + _serviceCancellation = null; + if (cancellation != null) + { + try { cancellation.Cancel(); } catch { } + } + + try { _udp?.Dispose(); } catch { } + _udp = null; + + var tasks = new[] { _receiveTask, _broadcastTask }.Where(task => task != null).Cast().ToArray(); + _receiveTask = null; + _broadcastTask = null; + if (tasks.Length > 0) + { + try { await Task.WhenAll(tasks).ConfigureAwait(false); } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch { } + } + + cancellation?.Dispose(); + _binding = null; + _lastBroadcastUtc = null; + SetState(SntpClockServiceState.Stopped, "SNTP clock service is stopped."); + } + finally + { + _lifecycleGate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + _lifecycleGate.Dispose(); + } + + private Task StartCoreAsync(SntpNetworkBinding binding, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + SetState(SntpClockServiceState.Starting, $"Preparing SNTP on {binding.Summary}."); + + 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. + udp.Client.ExclusiveAddressUse = true; + udp.EnableBroadcast = true; + udp.Client.Bind(new IPEndPoint(binding.LocalAddress, 123)); + } + 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; + } + catch (Exception ex) + { + udp.Dispose(); + SetState(SntpClockServiceState.Faulted, $"Could not start SNTP on {binding.LocalAddress}: {ex.Message}"); + return Task.CompletedTask; + } + + _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 ReceiveLoopAsync(UdpClient udp, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + UdpReceiveResult received; + try + { + received = await udp.ReceiveAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { break; } + catch (SocketException ex) + { + if (!cancellationToken.IsCancellationRequested) + SetState(SntpClockServiceState.Faulted, $"SNTP receive failed: {ex.SocketErrorCode}."); + break; + } + + var receiveUtc = DateTimeOffset.UtcNow; + 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."); + continue; + } + + try + { + await udp.SendAsync(reply, reply.Length, received.RemoteEndPoint).ConfigureAwait(false); + } + 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, + 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 + { + if (Interlocked.Exchange(ref _broadcastPulseRequested, 0) == 1 || + _lastBroadcastUtc == null || + DateTimeOffset.UtcNow - _lastBroadcastUtc >= _broadcastInterval) + { + var health = _clockHealth.Sample(); + if (health.IsHealthy) + { + var now = DateTimeOffset.UtcNow; + var packet = SntpPacket.BuildBroadcast( + now, + _profile with { ReferenceUtc = health.ReferenceUtc }, + synchronized: true); + await udp.SendAsync(packet, packet.Length, destination).ConfigureAwait(false); + _lastBroadcastUtc = now; + 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}"); + } + } + + if (!await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + break; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { break; } + catch (SocketException ex) + { + if (!cancellationToken.IsCancellationRequested) + SetState(SntpClockServiceState.Faulted, $"SNTP broadcast failed: {ex.SocketErrorCode}."); + break; + } + } + } + + private void SetState(SntpClockServiceState state, string detail) + { + _state = state; + _detail = detail; + PublishStatus(); + } + + private void PublishStatus() + => StatusChanged?.Invoke(Snapshot); + + private static TimeSpan NormalizeBroadcastInterval(TimeSpan interval) + => interval < TimeSpan.FromSeconds(64) ? TimeSpan.FromSeconds(64) : interval; + + private sealed class SntpClockHealthMonitor + { + private readonly object _sync = new(); + private DateTimeOffset _baselineUtc; + private long _baselineTimestamp; + private DateTimeOffset _referenceUtc; + + public void Reset() + { + lock (_sync) + { + _baselineUtc = DateTimeOffset.UtcNow; + _baselineTimestamp = Stopwatch.GetTimestamp(); + _referenceUtc = _baselineUtc; + } + } + + public ClockHealthSample Sample(DateTimeOffset? nowOverride = null) + { + lock (_sync) + { + var now = (nowOverride ?? DateTimeOffset.UtcNow).ToUniversalTime(); + if (_baselineTimestamp == 0) + { + _baselineUtc = now; + _baselineTimestamp = Stopwatch.GetTimestamp(); + _referenceUtc = now; + } + + if (now.Year is < 2020 or > 2100) + return new ClockHealthSample(false, now, _referenceUtc, $"system UTC year {now.Year} is outside the commissioning safety window"); + + var elapsed = Stopwatch.GetElapsedTime(_baselineTimestamp); + var expected = _baselineUtc + elapsed; + var jump = (now - expected).Duration(); + + if (jump > TimeSpan.FromSeconds(2)) + { + // Reject one packet after a large wall-clock step, then re-baseline. + _baselineUtc = now; + _baselineTimestamp = Stopwatch.GetTimestamp(); + _referenceUtc = now; + return new ClockHealthSample(false, now, _referenceUtc, $"system clock stepped by {jump.TotalMilliseconds:N0} ms"); + } + + return new ClockHealthSample( + true, + now, + _referenceUtc, + "Windows UTC is monotonic and sane; synchronized SNTP is advertised as a local commissioning source, not as GPS/PTP traceability."); + } + } + } + + private sealed record ClockHealthSample( + bool IsHealthy, + DateTimeOffset UtcNow, + DateTimeOffset ReferenceUtc, + string Detail); +} diff --git a/Services/SntpNetworkRouteResolver.cs b/Services/SntpNetworkRouteResolver.cs new file mode 100644 index 00000000..49b95d45 --- /dev/null +++ b/Services/SntpNetworkRouteResolver.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace ArIED61850Tester.Services; + +public sealed record SntpNetworkBinding( + IPAddress LocalAddress, + IPAddress SubnetMask, + IPAddress? DirectedBroadcast, + string InterfaceName, + string InterfaceId) +{ + public string Summary => DirectedBroadcast == null + ? $"{InterfaceName} • {LocalAddress}" + : $"{InterfaceName} • {LocalAddress} → {DirectedBroadcast}"; +} + +/// +/// Selects the Windows IPv4 interface that routes to an IED and derives its directed broadcast address. +/// +public static class SntpNetworkRouteResolver +{ + public static SntpNetworkBinding ResolveForRemote(IPAddress remoteAddress) + { + ArgumentNullException.ThrowIfNull(remoteAddress); + if (remoteAddress.AddressFamily != AddressFamily.InterNetwork) + throw new NotSupportedException("ARSAS SNTP P0 currently serves IPv4 station-bus endpoints."); + + var interfaces = GetIpv4Candidates(); + + // Ask Windows which local address it would route to the IED. UDP Connect does not + // transmit a datagram; it only selects a route/local endpoint for this socket. + try + { + using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + probe.Connect(new IPEndPoint(remoteAddress, 123)); + if (probe.LocalEndPoint is IPEndPoint localEndPoint) + { + var routed = interfaces.FirstOrDefault(candidate => candidate.Address.Equals(localEndPoint.Address)); + if (routed != null) + return Build(routed); + } + } + catch (SocketException) + { + // Continue with a deterministic same-subnet fallback below. + } + + foreach (var candidate in interfaces) + { + if (IsSameSubnet(candidate.Address, remoteAddress, candidate.Mask)) + return Build(candidate); + } + + throw new InvalidOperationException($"Windows could not resolve an active IPv4 station-bus route to {remoteAddress}."); + } + + public static IPAddress? ComputeDirectedBroadcast(IPAddress address, IPAddress mask) + { + var addressBytes = address.GetAddressBytes(); + var maskBytes = mask.GetAddressBytes(); + if (addressBytes.Length != 4 || maskBytes.Length != 4) + throw new NotSupportedException("Directed broadcast calculation currently supports IPv4 only."); + + var hostBits = 0; + var result = new byte[4]; + for (var i = 0; i < 4; i++) + { + hostBits += 8 - CountBits(maskBytes[i]); + result[i] = (byte)(addressBytes[i] | ~maskBytes[i]); + } + + // /31 and /32 have no useful directed broadcast target for this workflow. + return hostBits < 2 ? null : new IPAddress(result); + } + + public static bool IsSameSubnet(IPAddress first, IPAddress second, IPAddress mask) + { + var firstBytes = first.GetAddressBytes(); + var secondBytes = second.GetAddressBytes(); + var maskBytes = mask.GetAddressBytes(); + if (firstBytes.Length != 4 || secondBytes.Length != 4 || maskBytes.Length != 4) + return false; + + for (var i = 0; i < 4; i++) + { + if ((firstBytes[i] & maskBytes[i]) != (secondBytes[i] & maskBytes[i])) + return false; + } + + return true; + } + + private static List GetIpv4Candidates() + { + var candidates = new List(); + foreach (var networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up || + networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) + continue; + + foreach (var unicast in networkInterface.GetIPProperties().UnicastAddresses) + { + if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || + unicast.IPv4Mask == null || + IPAddress.Any.Equals(unicast.Address) || + IPAddress.Loopback.Equals(unicast.Address)) + continue; + + candidates.Add(new Ipv4Candidate( + unicast.Address, + unicast.IPv4Mask, + networkInterface.Name, + networkInterface.Id)); + } + } + + if (candidates.Count == 0) + throw new InvalidOperationException("No active IPv4 station-bus network adapter is available."); + + return candidates; + } + + private static SntpNetworkBinding Build(Ipv4Candidate candidate) + => new( + candidate.Address, + candidate.Mask, + ComputeDirectedBroadcast(candidate.Address, candidate.Mask), + candidate.InterfaceName, + candidate.InterfaceId); + + private static int CountBits(byte value) + { + var count = 0; + while (value != 0) + { + count += value & 1; + value >>= 1; + } + + return count; + } + + private sealed record Ipv4Candidate( + IPAddress Address, + IPAddress Mask, + string InterfaceName, + string InterfaceId); +} diff --git a/Services/SntpPacket.cs b/Services/SntpPacket.cs new file mode 100644 index 00000000..0aae4c6f --- /dev/null +++ b/Services/SntpPacket.cs @@ -0,0 +1,189 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ArIED61850Tester.Services; + +/// +/// Minimal, auditable SNTPv4 packet codec implemented from RFC 4330 / RFC 5905 wire semantics. +/// No third-party NTP source code is used. +/// +public static class SntpPacket +{ + public const int MinimumLength = 48; + private static readonly DateTimeOffset NtpEpoch = new(1900, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static bool TryReadClientRequest(ReadOnlySpan packet, out SntpClientRequest request) + { + request = default; + if (packet.Length < MinimumLength) + return false; + + var leapIndicator = (byte)((packet[0] >> 6) & 0x03); + var version = (byte)((packet[0] >> 3) & 0x07); + var mode = (byte)(packet[0] & 0x07); + if (mode != 3 || version is < 3 or > 4) + return false; + + request = new SntpClientRequest( + Version: version, + PollExponent: unchecked((sbyte)packet[2]), + LeapIndicator: leapIndicator, + TransmitTimestampRaw: packet.Slice(40, 8).ToArray()); + return true; + } + + public static byte[] BuildServerReply( + ReadOnlySpan requestPacket, + DateTimeOffset receiveUtc, + DateTimeOffset transmitUtc, + SntpServerProfile profile, + bool synchronized = true) + { + if (!TryReadClientRequest(requestPacket, out var request)) + throw new ArgumentException("Packet is not a supported SNTP client request.", nameof(requestPacket)); + + var response = new byte[MinimumLength]; + var version = Math.Min(request.Version, (byte)4); + var leap = synchronized ? profile.LeapIndicator : (byte)3; + response[0] = (byte)((leap << 6) | (version << 3) | 4); + response[1] = synchronized ? profile.Stratum : (byte)0; + response[2] = unchecked((byte)request.PollExponent); // RFC 4330: copy request Poll field intact. + response[3] = unchecked((byte)profile.PrecisionExponent); + + // Originate is always the client's Transmit timestamp, copied bit-for-bit. + request.TransmitTimestampRaw.CopyTo(response, 24); + + if (!synchronized) + { + // RFC 4330 server start/unsynchronized state: stratum 0, INIT, all timestamps + // zero except Originate when replying to a client request. + WriteReferenceId(response.AsSpan(12, 4), "INIT"); + return response; + } + + WriteSignedFixed16_16(response.AsSpan(4, 4), profile.RootDelay); + WriteUnsignedFixed16_16(response.AsSpan(8, 4), profile.RootDispersion); + WriteReferenceId(response.AsSpan(12, 4), profile.ReferenceId); + WriteTimestamp(response.AsSpan(16, 8), profile.ReferenceUtc == default ? transmitUtc : profile.ReferenceUtc); + WriteTimestamp(response.AsSpan(32, 8), receiveUtc); + WriteTimestamp(response.AsSpan(40, 8), transmitUtc); + return response; + } + + public static byte[] BuildBroadcast( + DateTimeOffset transmitUtc, + SntpServerProfile profile, + bool synchronized = true) + { + var response = new byte[MinimumLength]; + var leap = synchronized ? profile.LeapIndicator : (byte)3; + response[0] = (byte)((leap << 6) | (4 << 3) | 5); + response[1] = synchronized ? profile.Stratum : (byte)0; + response[2] = unchecked((byte)profile.PollExponent); + response[3] = unchecked((byte)profile.PrecisionExponent); + + if (!synchronized) + { + WriteReferenceId(response.AsSpan(12, 4), "INIT"); + return response; + } + + WriteSignedFixed16_16(response.AsSpan(4, 4), profile.RootDelay); + WriteUnsignedFixed16_16(response.AsSpan(8, 4), profile.RootDispersion); + WriteReferenceId(response.AsSpan(12, 4), profile.ReferenceId); + WriteTimestamp(response.AsSpan(16, 8), profile.ReferenceUtc == default ? transmitUtc : profile.ReferenceUtc); + WriteTimestamp(response.AsSpan(40, 8), transmitUtc); + return response; + } + + public static DateTimeOffset ReadTimestamp(ReadOnlySpan timestamp, DateTimeOffset? eraHint = null) + { + if (timestamp.Length < 8) + throw new ArgumentException("NTP timestamp requires 8 bytes.", nameof(timestamp)); + + var seconds32 = BinaryPrimitives.ReadUInt32BigEndian(timestamp[..4]); + var fraction = BinaryPrimitives.ReadUInt32BigEndian(timestamp.Slice(4, 4)); + + // Era 0 covers 1900-2036. Around the 2036 rollover, choose the era nearest the hint. + var hint = eraHint ?? DateTimeOffset.UtcNow; + var hintSeconds = (hint - NtpEpoch).Ticks / TimeSpan.TicksPerSecond; + var era = Math.Max(0L, (hintSeconds + (1L << 31)) >> 32); + var seconds = (era << 32) | seconds32; + if (era > 0) + { + var previous = seconds - (1L << 32); + if (Math.Abs(previous - hintSeconds) < Math.Abs(seconds - hintSeconds)) + seconds = previous; + } + + var fractionalTicks = (long)((fraction * (ulong)TimeSpan.TicksPerSecond) >> 32); + return NtpEpoch.AddTicks(seconds * TimeSpan.TicksPerSecond + fractionalTicks); + } + + public static void WriteTimestamp(Span destination, DateTimeOffset utc) + { + if (destination.Length < 8) + throw new ArgumentException("NTP timestamp requires 8 bytes.", nameof(destination)); + + utc = utc.ToUniversalTime(); + var ticksSinceEpoch = (utc - NtpEpoch).Ticks; + if (ticksSinceEpoch < 0) + throw new ArgumentOutOfRangeException(nameof(utc), "SNTP timestamp predates 1900-01-01 UTC."); + + var seconds = (ulong)(ticksSinceEpoch / TimeSpan.TicksPerSecond); + var remainderTicks = (ulong)(ticksSinceEpoch % TimeSpan.TicksPerSecond); + var fraction = (uint)((remainderTicks << 32) / (ulong)TimeSpan.TicksPerSecond); + + BinaryPrimitives.WriteUInt32BigEndian(destination[..4], unchecked((uint)seconds)); + BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(4, 4), fraction); + } + + private static void WriteReferenceId(Span destination, string referenceId) + { + destination.Clear(); + var bytes = Encoding.ASCII.GetBytes(string.IsNullOrWhiteSpace(referenceId) ? "LOCL" : referenceId.Trim()); + bytes.AsSpan(0, Math.Min(4, bytes.Length)).CopyTo(destination); + } + + private static void WriteSignedFixed16_16(Span destination, TimeSpan value) + { + var seconds = value.TotalSeconds; + var scaled = (long)Math.Round(seconds * 65536d, MidpointRounding.AwayFromZero); + scaled = Math.Clamp(scaled, int.MinValue, int.MaxValue); + BinaryPrimitives.WriteInt32BigEndian(destination, (int)scaled); + } + + private static void WriteUnsignedFixed16_16(Span destination, TimeSpan value) + { + var seconds = Math.Max(0d, value.TotalSeconds); + var scaled = (ulong)Math.Round(seconds * 65536d, MidpointRounding.AwayFromZero); + BinaryPrimitives.WriteUInt32BigEndian(destination, (uint)Math.Min(scaled, uint.MaxValue)); + } +} + +public readonly record struct SntpClientRequest( + byte Version, + sbyte PollExponent, + byte LeapIndicator, + byte[] TransmitTimestampRaw); + +public sealed record SntpServerProfile +{ + /// + /// SIPROTEC compatibility advertisement used by ARSAS commissioning Clock Sync. + /// Field experience with SIPROTEC requires a trusted-looking low stratum; stratum 2 + /// is deliberately used instead of stratum 1 so ARSAS does not claim to be a primary + /// GPS/PTP/atomic reference. ReferenceId remains LOCL and diagnostics state that the + /// laptop clock is a local commissioning source, not a traceable grandmaster. + /// + public const byte SiprotecCompatibilityStratum = 2; + + public byte Stratum { get; init; } = SiprotecCompatibilityStratum; + public byte LeapIndicator { get; init; } + public sbyte PollExponent { get; init; } = 6; + public sbyte PrecisionExponent { get; init; } = -10; + public TimeSpan RootDelay { get; init; } = TimeSpan.Zero; + public TimeSpan RootDispersion { get; init; } = TimeSpan.FromMilliseconds(50); + public string ReferenceId { get; init; } = "LOCL"; + public DateTimeOffset ReferenceUtc { get; init; } +} diff --git a/docs/SNTP_CLOCK_SYNC.md b/docs/SNTP_CLOCK_SYNC.md new file mode 100644 index 00000000..597ecf27 --- /dev/null +++ b/docs/SNTP_CLOCK_SYNC.md @@ -0,0 +1,57 @@ +# ARSAS Clock Sync — SNTP P0 + +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. + +## P0 behavior + +- Starts automatically after the first IPv4 IED reaches `IsConnected = true`. +- 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. +- 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 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. +- 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. + +## 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. + +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. + +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. + +A later phase can add explicit Windows upstream-source verification and/or PTP monitoring without changing the SNTP packet engine. + +## UDP/123 ownership + +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. + +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. + +## 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. + +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. + +## Validation + +`SntpPacketTests` covers: + +- SNTP client request recognition; +- version/poll field copy behavior; +- mode-4 reply semantics; +- originate timestamp echo; +- SIPROTEC compatibility stratum 2 on unicast and broadcast packets; +- mode-5 broadcast semantics; +- RFC-style unsynchronized response fields; +- directed broadcast calculation; +- NTP timestamp round-trip accuracy. diff --git a/tests/ARSAS.Tests/SntpPacketTests.cs b/tests/ARSAS.Tests/SntpPacketTests.cs new file mode 100644 index 00000000..e9d8e2ca --- /dev/null +++ b/tests/ARSAS.Tests/SntpPacketTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SntpPacketTests +{ + [Fact] + public void ClientRequest_IsRecognized_AndReplyCopiesVersionPollAndOriginateTimestamp() + { + var request = new byte[SntpPacket.MinimumLength]; + request[0] = (byte)((4 << 3) | 3); + request[2] = 9; + var clientTransmit = new DateTimeOffset(2026, 8, 13, 2, 3, 4, 567, TimeSpan.Zero); + SntpPacket.WriteTimestamp(request.AsSpan(40, 8), clientTransmit); + + Assert.True(SntpPacket.TryReadClientRequest(request, out var parsed)); + Assert.Equal((byte)4, parsed.Version); + Assert.Equal((sbyte)9, parsed.PollExponent); + + var receive = clientTransmit.AddMilliseconds(2); + var transmit = receive.AddMilliseconds(1); + var reply = SntpPacket.BuildServerReply(request, receive, transmit, new SntpServerProfile()); + + Assert.Equal(4, reply[0] & 0x07); + Assert.Equal(4, (reply[0] >> 3) & 0x07); + Assert.Equal(SntpServerProfile.SiprotecCompatibilityStratum, reply[1]); + Assert.Equal(9, unchecked((sbyte)reply[2])); + Assert.Equal(request.AsSpan(40, 8).ToArray(), reply.AsSpan(24, 8).ToArray()); + Assert.InRange((SntpPacket.ReadTimestamp(reply.AsSpan(32, 8), receive) - receive).Duration(), TimeSpan.Zero, TimeSpan.FromTicks(2)); + Assert.InRange((SntpPacket.ReadTimestamp(reply.AsSpan(40, 8), transmit) - transmit).Duration(), TimeSpan.Zero, TimeSpan.FromTicks(2)); + } + + [Fact] + public void Broadcast_IsMode5_AndUsesSiprotecCompatibilityStratum() + { + var now = new DateTimeOffset(2026, 8, 13, 2, 3, 4, TimeSpan.Zero); + var packet = SntpPacket.BuildBroadcast(now, new SntpServerProfile()); + + Assert.Equal(SntpPacket.MinimumLength, packet.Length); + Assert.Equal(5, packet[0] & 0x07); + Assert.Equal(4, (packet[0] >> 3) & 0x07); + Assert.Equal((byte)2, SntpServerProfile.SiprotecCompatibilityStratum); + Assert.Equal(SntpServerProfile.SiprotecCompatibilityStratum, packet[1]); + Assert.Equal(6, unchecked((sbyte)packet[2])); + Assert.Equal("LOCL", System.Text.Encoding.ASCII.GetString(packet, 12, 4)); + Assert.InRange((SntpPacket.ReadTimestamp(packet.AsSpan(40, 8), now) - now).Duration(), TimeSpan.Zero, TimeSpan.FromTicks(2)); + } + + [Fact] + public void UnsynchronizedReply_UsesLeapAlarmStratumZeroAndZeroServerTimestamps() + { + var request = new byte[SntpPacket.MinimumLength]; + request[0] = (byte)((4 << 3) | 3); + request[2] = 7; + var clientTransmit = DateTimeOffset.UtcNow; + SntpPacket.WriteTimestamp(request.AsSpan(40, 8), clientTransmit); + + var reply = SntpPacket.BuildServerReply( + request, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + new SntpServerProfile(), + synchronized: false); + + Assert.Equal(3, (reply[0] >> 6) & 0x03); + Assert.Equal(0, reply[1]); + Assert.Equal(7, unchecked((sbyte)reply[2])); + Assert.Equal("INIT", System.Text.Encoding.ASCII.GetString(reply, 12, 4)); + Assert.All(reply.AsSpan(16, 8).ToArray(), value => Assert.Equal(0, value)); + Assert.Equal(request.AsSpan(40, 8).ToArray(), reply.AsSpan(24, 8).ToArray()); + Assert.All(reply.AsSpan(32, 8).ToArray(), value => Assert.Equal(0, value)); + Assert.All(reply.AsSpan(40, 8).ToArray(), value => Assert.Equal(0, value)); + } + + [Fact] + public void DirectedBroadcast_IsCalculatedFromMask() + { + var broadcast = SntpNetworkRouteResolver.ComputeDirectedBroadcast( + IPAddress.Parse("192.168.10.42"), + IPAddress.Parse("255.255.255.0")); + + Assert.Equal(IPAddress.Parse("192.168.10.255"), broadcast); + } + + [Fact] + public void DirectedBroadcast_IsSuppressedForPointToPointPrefixes() + { + Assert.Null(SntpNetworkRouteResolver.ComputeDirectedBroadcast( + IPAddress.Parse("10.0.0.1"), + IPAddress.Parse("255.255.255.254"))); + } + + [Fact] + public void TimestampRoundTrip_PreservesSubMillisecondTime() + { + var expected = new DateTimeOffset(2026, 8, 13, 2, 3, 4, 123, TimeSpan.Zero).AddTicks(4567); + Span wire = stackalloc byte[8]; + + SntpPacket.WriteTimestamp(wire, expected); + var actual = SntpPacket.ReadTimestamp(wire, expected); + + Assert.InRange((actual - expected).Duration(), TimeSpan.Zero, TimeSpan.FromTicks(2)); + } +}