/*
* Copyright (c) 2024 Proton AG
*
* This file is part of ProtonVPN.
*
* ProtonVPN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonVPN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonVPN. If not, see .
*/
using System.Net;
using System.Net.Sockets;
using ProtonVPN.Common.Core.Networking;
using ProtonVPN.Configurations.Contracts;
using ProtonVPN.Dns.Contracts;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.NetworkLogs;
using ProtonVPN.NetworkTimeProtocols.Contracts;
namespace ProtonVPN.NetworkTimeProtocols;
// NTP implementation can be read on RFC 2030: https://datatracker.ietf.org/doc/html/rfc2030
public class NtpClient : INtpClient
{
private const int UDP_PORT = 123;
private const int SOCKET_TIMEOUT_IN_MILLISECONDS = 3000;
/// Bytes 40-43 (32-bit uint) - Seconds since 1900-01-01 00:00:00 UTC
private const byte RESPONSE_SECONDS_FIRST_BYTE = 40;
/// Bytes 44-47 (32-bit uint) - Fractional seconds
private const byte RESPONSE_FRACTIONAL_SECONDS_FIRST_BYTE = 44;
///
/// 0x1B = 00 011 011
/// [Leap Indicator] 0 - No warning of impending leap seconds
/// [Version Number] 3 - Version 3 (IPv4 only)
/// [Mode] 3 - Client (In unicast and anycast the client sets this to 3 in the request and the server to 4 (server) in the reply)
///
private const byte REQUEST_FIRST_BYTE_CONTENT = 0x1B;
private readonly ILogger _logger;
private readonly IDnsManager _dnsManager;
private readonly string _serverUrlIdnHost;
public NtpClient(ILogger logger, IDnsManager dnsManager, IConfiguration config)
{
_logger = logger;
_dnsManager = dnsManager;
_serverUrlIdnHost = config.NtpServerUrl;
}
public async Task GetNetworkUtcTimeAsync(CancellationToken cancellationToken)
{
try
{
IPEndPoint ipEndPoint = await GetNtpServerIPEndPointAsync(cancellationToken);
if (ipEndPoint is null)
{
_logger.Error("Failed to get the NTP server IP address.");
return null;
}
byte[] ntpData = CreateNtpRequestData();
using (Socket socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
{
socket.Connect(ipEndPoint);
socket.ReceiveTimeout = SOCKET_TIMEOUT_IN_MILLISECONDS;
socket.Send(ntpData);
socket.Receive(ntpData);
socket.Close();
}
return GetUtcTime(ntpData);
}
catch (SocketException e)
{
_logger.Error("Failed to get the network time", e);
return null;
}
}
private async Task GetNtpServerIPEndPointAsync(CancellationToken cancellationToken)
{
IList ipAddresses = await _dnsManager.GetAsync(_serverUrlIdnHost, cancellationToken);
return ipAddresses.Count == 0 ? null : new(ipAddresses.First().GetSystemType(), UDP_PORT);
}
private byte[] CreateNtpRequestData()
{
byte[] ntpData = new byte[48];
ntpData[0] = REQUEST_FIRST_BYTE_CONTENT;
return ntpData;
}
private DateTime GetUtcTime(byte[] ntpData)
{
long timestampInMilliseconds = GetTimestampInMilliseconds(ntpData);
DateTime utcTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(timestampInMilliseconds);
return HandleNtpRollover(utcTime);
}
private long GetTimestampInMilliseconds(byte[] ntpData)
{
ulong intPart = BitConverter.ToUInt32(ntpData, RESPONSE_SECONDS_FIRST_BYTE);
ulong fractionalPart = BitConverter.ToUInt32(ntpData, RESPONSE_FRACTIONAL_SECONDS_FIRST_BYTE);
intPart = SwapEndian(intPart);
fractionalPart = SwapEndian(fractionalPart);
ulong timestampInMilliseconds = (intPart * 1000) + (fractionalPart * 1000 / 0x100000000L);
return (long)timestampInMilliseconds;
}
private uint SwapEndian(ulong x)
{
return (uint)(((x & 0x000000ff) << 24) +
((x & 0x0000ff00) << 8) +
((x & 0x00ff0000) >> 8) +
((x & 0xff000000) >> 24));
}
/// The NTP 64-bit timestamp will roll over on 7 February 2036.
/// After this date, timestamps will appear to reset to 1900.
private DateTime HandleNtpRollover(DateTime utcTime)
{
if (utcTime.Year < 2024)
{
utcTime = utcTime.AddYears(136); // 136 years = 2 ^ 32 seconds
}
return utcTime;
}
}