/*
* Copyright (c) 2023 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;
using System.Collections.Generic;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ProtonVPN.Common.Core.Extensions;
using ProtonVPN.Common.Core.Helpers;
using ProtonVPN.Common.Core.Networking;
using ProtonVPN.Common.Legacy;
using ProtonVPN.Common.Legacy.PortForwarding;
using ProtonVPN.IssueReporting.Contracts;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.ConnectionLogs;
using ProtonVPN.Vpn.Gateways;
using ProtonVPN.Vpn.PortMapping.Messages;
using ProtonVPN.Vpn.PortMapping.Serializers.Common;
using ProtonVPN.Vpn.PortMapping.UdpClients;
namespace ProtonVPN.Vpn.PortMapping;
// Network Address Translation Port Mapping Protocol (NAT-PMP) - RFC 6886: https://datatracker.ietf.org/doc/html/rfc6886
public class PortMappingProtocolClient : IPortMappingProtocolClient
{
private const ushort NAT_PMP_PORT = 5351;
private const ushort MIN_TIMEOUT_MILLISECONDS = 250;
private const ushort MAX_TIMEOUT_MILLISECONDS = 64000;
private const uint REQUESTED_LEASE_TIME_SECONDS = 7200;
private const byte TCP_OPERATION = (byte)TransportProtocol.TCP;
private const byte UDP_OPERATION = (byte)TransportProtocol.UDP;
private readonly ILogger _logger;
private readonly IUdpClientWrapper _udpClientWrapper;
private readonly IMessageSerializerProxy _messageSerializerProxy;
private readonly IGatewayCache _gatewayCache;
private readonly IIssueReporter _issueReporter;
private IPEndPoint? _endpoint;
private HelloReplyMessage? _helloReply;
private TemporaryMappedPort? _mappedPort;
private Lazy _cancellationTokenSource = new(CancelledCancellationTokenSource.Create);
private Lazy _stopCancellationTokenSource = new(CancelledCancellationTokenSource.Create);
private PortForwardingState? _lastState;
private VpnState _vpnState = VpnState.Default;
public event EventHandler>? StateChanged;
public PortMappingProtocolClient(ILogger logger,
IUdpClientWrapper udpClientWrapper,
IMessageSerializerProxy messageSerializerProxy,
IGatewayCache gatewayCache,
IIssueReporter issueReporter)
{
_logger = logger;
_udpClientWrapper = udpClientWrapper;
_messageSerializerProxy = messageSerializerProxy;
_gatewayCache = gatewayCache;
_issueReporter = issueReporter;
}
public async Task StartAsync()
{
if (!_cancellationTokenSource.Value.IsCancellationRequested)
{
_logger.Warn("Can't start port mapping because it is already running.");
return;
}
ChangeState(PortMappingStatus.Starting);
await _stopCancellationTokenSource.Value.CancelAsync();
CancellationToken cancellationToken = GenerateNewCancellationToken();
try
{
InitializeUdpClient();
await SendHelloMessageAsync(cancellationToken);
await SendPortMappingMessagesAsync(cancellationToken);
}
catch (Exception e)
{
_logger.Error("NAT-PMP start failed.", e);
ChangeState(PortMappingStatus.Error);
await _cancellationTokenSource.Value.CancelAsync();
_udpClientWrapper.Stop();
}
}
private CancellationToken GenerateNewCancellationToken()
{
CancellationTokenSource cancellationTokenSource = new();
_cancellationTokenSource = new(cancellationTokenSource);
return cancellationTokenSource.Token;
}
private void ChangeState(PortMappingStatus status)
{
PortForwardingState state = CreatePortForwardingState(status);
_lastState = state;
InvokeState(state);
StringBuilder logMessage = new StringBuilder().Append($"State changed to Status '{state.Status}' at '{state.TimestampUtc}'");
if (state.MappedPort?.MappedPort is not null)
{
TemporaryMappedPort mappedPort = state.MappedPort;
logMessage.Append($", Port pair {mappedPort.MappedPort}, expiring after " +
$"{mappedPort.Lifetime} around {mappedPort.ExpirationDateUtc}");
}
_logger.Info(logMessage.ToString());
}
private void InvokeState(PortForwardingState state)
{
state ??= PortForwardingState.Default;
StateChanged?.Invoke(this, new(state));
}
private PortForwardingState CreatePortForwardingState(PortMappingStatus status)
{
return new()
{
MappedPort = _mappedPort,
Status = status
};
}
private void InitializeUdpClient()
{
IPAddress gatewayIPAddress = _gatewayCache.Get() ?? throw new Exception("The default gateway is missing and NAT-PMP can't start without it.");
_endpoint = new IPEndPoint(gatewayIPAddress, NAT_PMP_PORT);
_udpClientWrapper.Start(_endpoint);
_logger.Info($"Starting NAT-PMP communication with gateway {_endpoint}.");
}
private async Task SendHelloMessageAsync(CancellationToken cancellationToken)
{
ChangeState(PortMappingStatus.HelloCommunication);
HelloQueryMessage query = new();
byte[] serializedQuery = _messageSerializerProxy.ToBytes(query);
byte[] serializedReply = await SendMessageWithTimeoutAsync(serializedQuery, cancellationToken);
_helloReply = _messageSerializerProxy.FromBytes(serializedReply);
}
private async Task SendMessageWithTimeoutAsync(byte[] serializedMessage, CancellationToken cancellationToken)
{
byte[]? serializedReply = null;
Exception exception = new("The serialized reply received is empty.");
for (int timeoutInMilliseconds = MIN_TIMEOUT_MILLISECONDS; timeoutInMilliseconds <= MAX_TIMEOUT_MILLISECONDS; timeoutInMilliseconds *= 2)
{
try
{
_udpClientWrapper.Send(serializedMessage);
serializedReply = await GetReplyOrTimeoutAsync(timeoutInMilliseconds, cancellationToken);
}
catch (Exception e)
{
_logger.Error("An error occurred when sending a message " +
$"or receiving a response ({timeoutInMilliseconds}ms).", e);
exception = e;
if (!cancellationToken.IsCancellationRequested)
{
_udpClientWrapper.Reset();
continue;
}
}
break;
}
if (serializedReply is null)
{
_logger.Error("All retries were used for the current communication. Last exception in annex.", exception);
throw exception;
}
return serializedReply;
}
private async Task GetReplyOrTimeoutAsync(int timeoutInMilliseconds, CancellationToken cancellationToken)
{
Task task = Task.Run(GetReply, cancellationToken);
if (await Task.WhenAny(task, Task.Delay(timeoutInMilliseconds, cancellationToken)) == task)
{
ThrowIfReplyAwaitWasCancelled(cancellationToken);
return await task;
}
task.FireAndForget();
ThrowIfReplyAwaitWasCancelled(cancellationToken);
throw new TimeoutException($"The remote endpoint '{_endpoint}' did not reply to the query in time ({timeoutInMilliseconds}ms).");
}
private void ThrowIfReplyAwaitWasCancelled(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException("The wait for the reply was cancelled.");
}
}
private byte[] GetReply()
{
return _udpClientWrapper.Receive();
}
private async Task SendPortMappingMessagesAsync(CancellationToken cancellationToken, PortMappingQueryMessages? queryMessages = null)
{
ChangeState(PortMappingStatus.PortMappingCommunication);
queryMessages ??= new();
queryMessages.TcpQuery ??= CreateTcpPortMappingQueryMessage();
queryMessages.UdpQuery ??= CreateUdpPortMappingQueryMessage();
PortMappingReplyMessage tcpReply = await SendPortMappingMessageAndRetryIfPortsMismatchAsync(queryMessages.TcpQuery, cancellationToken);
PortMappingReplyMessage udpReply = await SendPortMappingMessageAndRetryIfPortsMismatchAsync(queryMessages.UdpQuery, cancellationToken);
HandlePortMappingResponses(tcpReply: tcpReply, udpReply: udpReply, cancellationToken);
}
private PortMappingQueryMessage CreateTcpPortMappingQueryMessage()
{
return CreatePortMappingQueryMessage(TCP_OPERATION);
}
private PortMappingQueryMessage CreatePortMappingQueryMessage(byte operation)
{
return new()
{
Operation = operation,
RequestedLeaseTimeSecond = REQUESTED_LEASE_TIME_SECONDS
};
}
private PortMappingQueryMessage CreateUdpPortMappingQueryMessage()
{
return CreatePortMappingQueryMessage(UDP_OPERATION);
}
private async Task SendPortMappingMessageAndRetryIfPortsMismatchAsync(
PortMappingQueryMessage queryMessage, CancellationToken cancellationToken)
{
PortMappingReplyMessage reply = await SendPortMappingMessageAsync(queryMessage, cancellationToken);
if (reply is not null && reply.IsSuccess() && reply.InternalPort != reply.ExternalPort &&
(queryMessage.InternalPort != 0 || queryMessage.ExternalPort != 0))
{
queryMessage.InternalPort = 0;
queryMessage.ExternalPort = 0;
return await SendPortMappingMessageAsync(queryMessage, cancellationToken);
}
return reply;
}
private async Task SendPortMappingMessageAsync(PortMappingQueryMessage queryMessage,
CancellationToken cancellationToken)
{
try
{
byte[] serializedQuery = _messageSerializerProxy.ToBytes(queryMessage);
byte[] serializedReply = await SendMessageWithTimeoutAsync(serializedQuery, cancellationToken);
return _messageSerializerProxy.FromBytes(serializedReply);
}
catch (Exception ex)
{
_logger.Error("An exception occurred when sending a NAT-PMP request or receiving the response.", ex);
return null;
}
}
private void HandlePortMappingResponses(PortMappingReplyMessage tcpReply, PortMappingReplyMessage udpReply,
CancellationToken cancellationToken)
{
if (HasRequestFailed(tcpReply) && HasRequestFailed(udpReply))
{
HandlePortMappingUnsuccessfulResponses(tcpReply: tcpReply, udpReply: udpReply);
return;
}
if (HasRequestFailed(tcpReply))
{
_logger.Error($"Port mapping TCP response was not successful. " +
$"[ResultCode: {tcpReply?.ResultCode}, Operation: {tcpReply?.Operation}]");
tcpReply = udpReply;
}
if (HasRequestFailed(udpReply))
{
_logger.Error($"Port mapping UDP response was not successful. " +
$"[ResultCode: {udpReply?.ResultCode}, Operation: {udpReply?.Operation}]");
udpReply = tcpReply;
}
TemporaryMappedPort mappedTcpPort = CreateTemporaryMappedPort(tcpReply);
TemporaryMappedPort mappedUdpPort = CreateTemporaryMappedPort(udpReply);
if (tcpReply.InternalPort != tcpReply.ExternalPort || udpReply.InternalPort != udpReply.ExternalPort)
{
HandlePortMappingMismatchResponse(mappedTcpPort: mappedTcpPort, mappedUdpPort: mappedUdpPort);
return;
}
if (mappedTcpPort.MappedPort != mappedUdpPort.MappedPort)
{
HandlePortMismatchBetweenTcpAndUdp(mappedTcpPort: mappedTcpPort, mappedUdpPort: mappedUdpPort);
}
int portDurationInSeconds = (int)Math.Truncate(tcpReply.LifetimeSeconds / 2.0);
// Both TCP and UDP mapped ports should be the same (Although they might not if the server is not correctly configured),
// TCP is saved because it is the most relevant protocol between the two
SavePortMappingAndScheduleRenewal(mappedTcpPort, portDurationInSeconds, cancellationToken);
}
private void HandlePortMismatchBetweenTcpAndUdp(TemporaryMappedPort mappedTcpPort, TemporaryMappedPort mappedUdpPort)
{
_logger.Error($"The NAT-PMP ports of the TCP and UDP replies do not match. " +
$"The logic will proceed by using the TCP ports. [TCP: {mappedTcpPort.MappedPort}, UDP: {mappedUdpPort.MappedPort}]");
_issueReporter.CaptureMessage("NAT-PMP TCP and UDP ports don't match.",
GenerateDescription($"[NAT-PMP] TCP: {mappedTcpPort.MappedPort}, UDP: {mappedUdpPort.MappedPort}"));
}
private string GenerateDescription(string description)
{
StringBuilder stringBuilder = new();
stringBuilder.AppendLine(description);
foreach (string vpnStateLine in GetVpnState())
{
stringBuilder.AppendLine(vpnStateLine);
}
return stringBuilder.ToString();
}
private IEnumerable GetVpnState()
{
yield return "[VPN State]";
yield return $"Remote IP: {_vpnState.RemoteIp}";
yield return $"Label: {_vpnState.Label}";
yield return $"Endpoint port: {_vpnState.EndpointPort}";
yield return $"Protocol: {_vpnState.VpnProtocol}";
yield return $"Status: {_vpnState.Status}";
yield return $"Error: {_vpnState.Error}";
}
private bool HasRequestFailed(PortMappingReplyMessage reply)
{
return reply is null || !reply.IsSuccess();
}
private void HandlePortMappingUnsuccessfulResponses(PortMappingReplyMessage tcpReply, PortMappingReplyMessage udpReply)
{
_logger.Error($"Port mapping responses were not successful. " +
$"[TCP ResultCode: {tcpReply?.ResultCode}, Operation: {tcpReply?.Operation}]" +
$"[UDP ResultCode: {udpReply?.ResultCode}, Operation: {udpReply?.Operation}]");
SetMappedPort(null);
ChangeState(PortMappingStatus.Error);
}
private void HandlePortMappingMismatchResponse(TemporaryMappedPort mappedTcpPort, TemporaryMappedPort mappedUdpPort)
{
_logger.Error($"Port mapping has an External/Internal port mismatch. " +
$"[TCP {mappedTcpPort.MappedPort}][UDP {mappedUdpPort.MappedPort}]");
_issueReporter.CaptureError("NAT-PMP External/Internal port mismatch.",
GenerateDescription($"[NAT-PMP] TCP: {mappedTcpPort.MappedPort}, UDP: {mappedUdpPort.MappedPort}"));
SetMappedPort(null);
ChangeState(PortMappingStatus.Error);
}
private TemporaryMappedPort CreateTemporaryMappedPort(PortMappingReplyMessage reply)
{
return new()
{
MappedPort = new(internalPort: reply.InternalPort, externalPort: reply.ExternalPort),
Lifetime = TimeSpan.FromSeconds(reply.LifetimeSeconds),
ExpirationDateUtc = DateTime.UtcNow.AddSeconds(reply.LifetimeSeconds)
};
}
private void SavePortMappingAndScheduleRenewal(TemporaryMappedPort mappedPort, int portDurationInSeconds, CancellationToken cancellationToken)
{
SetMappedPort(mappedPort);
SchedulePortMappingRenewalAsync(mappedPort.MappedPort, portDurationInSeconds, cancellationToken).FireAndForget();
if (!cancellationToken.IsCancellationRequested)
{
ChangeState(PortMappingStatus.SleepingUntilRefresh);
}
}
private async Task SchedulePortMappingRenewalAsync(MappedPort mappedPort, int portDurationInSeconds, CancellationToken cancellationToken)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(portDurationInSeconds), cancellationToken);
await RenewPortMappingAsync(mappedPort, cancellationToken);
}
catch (OperationCanceledException)
{
_logger.Info("The scheduled renewal of port mapping was cancelled.");
}
catch (Exception e)
{
_logger.Error("An error occurred on a NAT-PMP scheduled renewal.", e);
}
}
private void SetMappedPort(TemporaryMappedPort mappedPort)
{
_mappedPort = mappedPort;
}
private async Task RenewPortMappingAsync(MappedPort mappedPort, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
_logger.Info("Port mapping renewal ignored due to cancelled process.");
}
else
{
try
{
PortMappingQueryMessage tcpQuery = CreateTcpPortMappingQueryMessage();
tcpQuery.InternalPort = (ushort)mappedPort.InternalPort;
tcpQuery.ExternalPort = (ushort)mappedPort.ExternalPort;
PortMappingQueryMessage udpQuery = CreateUdpPortMappingQueryMessage();
udpQuery.InternalPort = (ushort)mappedPort.InternalPort;
udpQuery.ExternalPort = (ushort)mappedPort.ExternalPort;
PortMappingQueryMessages queryMessages = new() { TcpQuery = tcpQuery, UdpQuery = udpQuery };
_logger.Info($"Port mapping renewal started for pair {mappedPort}.");
await SendPortMappingMessagesAsync(cancellationToken, queryMessages: queryMessages);
}
catch (Exception e)
{
_logger.Error("NAT-PMP renewal failed.", e);
ChangeState(PortMappingStatus.Error);
}
}
}
public async Task StopAsync()
{
if (IsStopPossible())
{
await ExecuteStopAsync();
}
}
private bool IsStopPossible()
{
if (_lastState == null ||
_lastState.Status == PortMappingStatus.Stopped)
{
_logger.Debug("Can't stop port mapping because it is already stopped " +
$"(LastState: {_lastState?.Status}).");
return false;
}
if (!_stopCancellationTokenSource.Value.IsCancellationRequested)
{
_logger.Warn("Can't stop port mapping because it is already stopping.");
return false;
}
return true;
}
private async Task ExecuteStopAsync()
{
_logger.Info("Stopping NAT-PMP.");
await _cancellationTokenSource.Value.CancelAsync();
await DestroyMappedPortAsync();
try
{
_udpClientWrapper.Stop();
ChangeStateToStopped();
await _stopCancellationTokenSource.Value.CancelAsync();
}
catch (Exception e)
{
_logger.Error("Error when stopping the UdpClient and finishing the NAT-PMP stop.", e);
}
}
private void ChangeStateToStopped()
{
SetMappedPort(null);
ChangeState(PortMappingStatus.Stopped);
}
private async Task DestroyMappedPortAsync()
{
MappedPort mappedPort = _mappedPort?.MappedPort;
CancellationToken stopCancellationToken = GenerateNewStopCancellationToken();
try
{
if (mappedPort != null)
{
await SendDestroyPortMappingMessagesAsync(mappedPort, stopCancellationToken);
}
}
catch (Exception e)
{
_logger.Error("Error when destroying port mapping.", e);
}
}
private CancellationToken GenerateNewStopCancellationToken()
{
CancellationTokenSource cancellationTokenSource = new();
_stopCancellationTokenSource = new(cancellationTokenSource);
return cancellationTokenSource.Token;
}
private async Task SendDestroyPortMappingMessagesAsync(MappedPort mappedPort, CancellationToken cancellationToken)
{
ChangeState(PortMappingStatus.DestroyPortMappingCommunication);
_logger.Info($"Requesting to destroy mapped TCP port pair {mappedPort}.");
await SendDestroyPortMappingMessageAsync(CreateDestroyTcpPortMappingQueryMessage(mappedPort), mappedPort, cancellationToken);
_logger.Info($"Requesting to destroy mapped UDP port pair {mappedPort}.");
await SendDestroyPortMappingMessageAsync(CreateDestroyUdpPortMappingQueryMessage(mappedPort), mappedPort, cancellationToken);
}
private PortMappingQueryMessage CreateDestroyTcpPortMappingQueryMessage(MappedPort mappedPort)
{
return CreateDestroyPortMappingQueryMessage(mappedPort, TCP_OPERATION);
}
private PortMappingQueryMessage CreateDestroyPortMappingQueryMessage(MappedPort mappedPort, byte operation)
{
return new()
{
Operation = operation,
RequestedLeaseTimeSecond = 0,
InternalPort = (ushort)mappedPort.InternalPort,
ExternalPort = 0,
};
}
private PortMappingQueryMessage CreateDestroyUdpPortMappingQueryMessage(MappedPort mappedPort)
{
return CreateDestroyPortMappingQueryMessage(mappedPort, UDP_OPERATION);
}
private async Task SendDestroyPortMappingMessageAsync(PortMappingQueryMessage query, MappedPort mappedPort, CancellationToken cancellationToken)
{
byte[] serializedQuery = _messageSerializerProxy.ToBytes(query);
byte[] serializedReply = await SendMessageWithSingleTryAsync(serializedQuery, cancellationToken);
PortMappingReplyMessage reply = _messageSerializerProxy.FromBytes(serializedReply);
if (!HasRequestFailed(reply) && reply.InternalPort == mappedPort.InternalPort &&
reply.ExternalPort == 0 && reply.LifetimeSeconds == 0)
{
_logger.Info($"Successful port mapping destruction. Operation: {reply.Operation}.");
}
else
{
_logger.Error($"Unsuccessful port mapping destruction. ResultCode: {reply.ResultCode}, " +
$"Operation: {reply.Operation}, InternalPort: {reply.InternalPort}, " +
$"ExternalPort: {reply.ExternalPort}, LifetimeSeconds: {reply.LifetimeSeconds}, .");
}
}
private async Task SendMessageWithSingleTryAsync(byte[] serializedMessage, CancellationToken cancellationToken)
{
byte[] serializedReply = null;
Exception exception = new("The serialized reply received is empty.");
try
{
_udpClientWrapper.Send(serializedMessage);
serializedReply = await GetReplyOrTimeoutAsync(MIN_TIMEOUT_MILLISECONDS, cancellationToken);
}
catch (Exception e)
{
_logger.Error("An error occurred when sending a message " +
$"or receiving a response ({MIN_TIMEOUT_MILLISECONDS}ms).", e);
exception = e;
}
if (serializedReply is null)
{
HandleSendMessageWithSingleTryFailed(serializedMessage, exception);
}
return serializedReply;
}
private void HandleSendMessageWithSingleTryFailed(byte[] serializedMessage, Exception exception)
{
try
{
_udpClientWrapper.Send(serializedMessage);
}
catch (Exception e)
{
throw new Exception("An exception occurred when retrying to send the message.", e);
}
throw new Exception("The single try message failed to get a reply. A new message was sent.", exception);
}
public void RepeatState()
{
InvokeState(_lastState);
}
public void SetVpnState(VpnState state)
{
_vpnState = state;
}
}