/*
* Copyright (c) 2026 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.Data;
using ProtonVPN.Client.EventMessaging.Contracts;
using ProtonVPN.Client.Logic.Auth.Contracts;
using ProtonVPN.Client.Logic.Auth.Contracts.Messages;
using ProtonVPN.Client.Logic.Connection.Contracts.Enums;
using ProtonVPN.Client.Logic.Connection.Contracts.GuestHole;
using ProtonVPN.Client.Logic.Connection.Contracts.Messages;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Features;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.FreeServers;
using ProtonVPN.Client.Logic.Connection.Contracts.RequestCreators;
using ProtonVPN.Client.Logic.Connection.Extensions;
using ProtonVPN.Client.Logic.Connection.GuestHole;
using ProtonVPN.Client.Logic.Connection.Statistics;
using ProtonVPN.Client.Logic.Servers.Contracts;
using ProtonVPN.Client.Logic.Servers.Contracts.Models;
using ProtonVPN.Client.Logic.Services.Contracts;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Crypto.Contracts;
using ProtonVPN.EntityMapping.Contracts;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.AppLogs;
using ProtonVPN.Logging.Contracts.Events.ConnectionLogs;
using ProtonVPN.Logging.Contracts.Events.ConnectLogs;
using ProtonVPN.ProcessCommunication.Contracts.Entities.Crypto;
using ProtonVPN.ProcessCommunication.Contracts.Entities.LocalAgent;
using ProtonVPN.ProcessCommunication.Contracts.Entities.Vpn;
using ProtonVPN.StatisticalEvents.Contracts.Dimensions;
using ConnectionDetails = ProtonVPN.Client.Logic.Connection.Contracts.Models.ConnectionDetails;
using IpAddressInfo = ProtonVPN.Common.Core.Vpn.IpAddressInfo;
using VpnProtocol = ProtonVPN.Common.Core.Networking.VpnProtocol;
namespace ProtonVPN.Client.Logic.Connection;
public class ConnectionManager : IInternalConnectionManager, IGuestHoleConnector,
IEventMessageReceiver,
IEventMessageReceiver,
IEventMessageReceiver
{
private readonly TimeSpan _reconnectInterval = TimeSpan.FromMinutes(1);
private readonly Random _random = new();
private readonly ILogger _logger;
private readonly ISettings _settings;
private readonly IVpnServiceCaller _vpnServiceCaller;
private readonly IEventMessageSender _eventMessageSender;
private readonly IEntityMapper _entityMapper;
private readonly IConnectionRequestCreator _connectionRequestCreator;
private readonly IReconnectionRequestCreator _reconnectionRequestCreator;
private readonly IDisconnectionRequestCreator _disconnectionRequestCreator;
private readonly IServersLoader _serversLoader;
private readonly IFavoriteServersStorage _favoriteServersStorage;
private readonly IGuestHoleServersFileStorage _guestHoleServersFileStorage;
private readonly IGuestHoleConnectionRequestCreator _guestHoleConnectionRequestCreator;
private readonly IConnectionStatisticalEventsManager _statisticalEventManager;
private readonly IConnectionKeyManager _connectionKeyManager;
private DateTime _minReconnectionDateUtc = DateTime.MinValue;
private bool _isNetworkBlocked;
private bool _isConnectionStatusHandled;
private bool _isGuestHoleActive;
private VpnStateIpcEntity? _cachedMessage;
private IpAddressInfo? _cachedServerIpAddress;
private VpnStatusIpcEntity? _currentStatus = VpnStatusIpcEntity.Disconnected;
private VpnErrorTypeIpcEntity? _currentError = VpnErrorTypeIpcEntity.None;
public ConnectionStatus ConnectionStatus { get; private set; }
public IConnectionIntent? CurrentConnectionIntent { get; private set; }
public ConnectionDetails? CurrentConnectionDetails { get; private set; }
public bool IsDisconnected => ConnectionStatus == ConnectionStatus.Disconnected;
public bool IsConnecting => ConnectionStatus == ConnectionStatus.Connecting;
public bool IsConnected => ConnectionStatus == ConnectionStatus.Connected;
public bool IsConnectAllowed => _currentError != VpnErrorTypeIpcEntity.BaseFilteringEngineServiceNotRunning;
public bool IsNetworkBlocked => _isNetworkBlocked;
public bool IsTwoFactorError => !IsDisconnected && _currentError.IsTwoFactorError();
public bool IsMobileHotspotError => _currentError == VpnErrorTypeIpcEntity.InterfaceHasForwardingEnabled;
public ConnectionManager(
ILogger logger,
ISettings settings,
IVpnServiceCaller vpnServiceCaller,
IEventMessageSender eventMessageSender,
IEntityMapper entityMapper,
IConnectionRequestCreator connectionRequestCreator,
IReconnectionRequestCreator reconnectionRequestCreator,
IDisconnectionRequestCreator disconnectionRequestCreator,
IServersLoader serversLoader,
IFavoriteServersStorage favoriteServersStorage,
IGuestHoleServersFileStorage guestHoleServersFileStorage,
IGuestHoleConnectionRequestCreator guestHoleConnectionRequestCreator,
IConnectionStatisticalEventsManager statisticalEventManager,
IConnectionKeyManager connectionKeyManager)
{
_logger = logger;
_settings = settings;
_vpnServiceCaller = vpnServiceCaller;
_eventMessageSender = eventMessageSender;
_entityMapper = entityMapper;
_connectionRequestCreator = connectionRequestCreator;
_reconnectionRequestCreator = reconnectionRequestCreator;
_disconnectionRequestCreator = disconnectionRequestCreator;
_serversLoader = serversLoader;
_favoriteServersStorage = favoriteServersStorage;
_guestHoleServersFileStorage = guestHoleServersFileStorage;
_guestHoleConnectionRequestCreator = guestHoleConnectionRequestCreator;
_guestHoleConnectionRequestCreator = guestHoleConnectionRequestCreator;
_statisticalEventManager = statisticalEventManager;
_connectionKeyManager = connectionKeyManager;
}
public async Task ConnectAsync(
VpnTriggerDimension connectionTrigger,
IConnectionIntent? connectionIntent = null)
{
_statisticalEventManager.SetConnectionAttempt(connectionTrigger, ConnectionStatus);
connectionIntent ??= _settings.VpnPlan.IsPaid ? ConnectionIntent.Default : ConnectionIntent.FreeDefault;
connectionIntent = ChangeConnectionIntent(connectionIntent, CreateNewIntentIfUserPlanIsFree);
CurrentConnectionIntent = connectionIntent;
_logger.Info($"[CONNECTION_PROCESS] Connection attempt to: {connectionIntent}. Triggered by {connectionTrigger}.", stackTraceDepth: 2);
ConnectionRequestIpcEntity request = await _connectionRequestCreator.CreateAsync(connectionIntent);
await SendRequestIfValidAsync(request);
}
public async Task ConnectToGuestHoleAsync()
{
IOrderedEnumerable servers = (await _guestHoleServersFileStorage.GetAsync()).OrderBy(_ => _random.Next());
if (!servers.Any())
{
throw new GuestHoleException("No guest hole servers provided.");
}
ConnectionRequestIpcEntity request = await _guestHoleConnectionRequestCreator.CreateAsync(servers);
_logger.Info("Guest hole connection requested.");
await _vpnServiceCaller.ConnectAsync(request);
}
public async Task DisconnectFromGuestHoleAsync()
{
DisconnectionRequestIpcEntity request = _disconnectionRequestCreator.Create(VpnError.NoneKeepEnabledKillSwitch);
await _vpnServiceCaller.DisconnectAsync(request);
}
private IConnectionIntent CreateNewIntentIfUserPlanIsFree(IConnectionIntent connectionIntent)
{
if (_settings.VpnPlan.IsPaid)
{
return connectionIntent;
}
ILocationIntent locationIntent = connectionIntent.Location.IsForPaidUsersOnly
? FreeServerLocationIntent.Default
: connectionIntent.Location;
IFeatureIntent? featureIntent = connectionIntent.Feature is null || connectionIntent.Feature.IsForPaidUsersOnly
? null
: connectionIntent.Feature;
return new ConnectionIntent(locationIntent, featureIntent);
}
private async Task SendRequestIfValidAsync(ConnectionRequestIpcEntity request)
{
VpnError error = request.GetVpnError();
if (error == VpnError.None)
{
await _vpnServiceCaller.ConnectAsync(request);
return true;
}
else
{
_logger.Error($"Failed to connect due to '{error}' error detected.");
await DisconnectAsync(VpnTriggerDimension.Auto);
_eventMessageSender.Send(new ConnectionErrorMessage { VpnError = error });
return false;
}
}
/// True if reconnecting. False if not.
public async Task ReconnectIfNotRecentlyReconnectedAsync()
{
if (DateTime.UtcNow > _minReconnectionDateUtc)
{
return await ReconnectAsync(VpnTriggerDimension.Auto);
}
return false;
}
/// Reconnects if the most recent action was a Connect and not a Disconnect.
/// True if reconnecting. False if not.
public async Task ReconnectAsync(VpnTriggerDimension reconnectionTrigger)
{
// If there is no internet connection or the attempt to reach the guest hole servers fails,
// we should not trigger reconnection logic, since all guest hole servers have already been tried.
if (_isGuestHoleActive)
{
return false;
}
_minReconnectionDateUtc = DateTime.UtcNow + _reconnectInterval;
IConnectionIntent? connectionIntent = CurrentConnectionIntent;
if (connectionIntent is null)
{
await DisconnectAsync(VpnTriggerDimension.Auto);
return false;
}
_statisticalEventManager.SetReconnectionAttempt(reconnectionTrigger, ConnectionStatus);
connectionIntent = ChangeConnectionIntent(connectionIntent, CreateNewIntentIfUserPlanIsFree);
CurrentConnectionIntent = connectionIntent;
_logger.Info($"[CONNECTION_PROCESS] Reconnection attempt to: {connectionIntent}. Triggered by {reconnectionTrigger}.", stackTraceDepth: 1);
ConnectionRequestIpcEntity request = await _reconnectionRequestCreator.CreateAsync(connectionIntent);
return await SendRequestIfValidAsync(request);
}
public async Task DisconnectAsync(VpnTriggerDimension disconnectionTrigger)
{
_statisticalEventManager.SetDisconnectionAttempt(disconnectionTrigger, ConnectionStatus);
_logger.Info($"[CONNECTION_PROCESS] Disconnection attempt. Triggered by {disconnectionTrigger}.", stackTraceDepth: 2);
CurrentConnectionIntent = null;
DisconnectionRequestIpcEntity request = _disconnectionRequestCreator.Create();
await _vpnServiceCaller.DisconnectAsync(request);
}
public async Task HandleAsync(VpnStateIpcEntity message)
{
_cachedMessage = message;
IConnectionIntent connectionIntent = CurrentConnectionIntent ?? ConnectionIntent.Default;
bool isToForceStatusUpdate = _isNetworkBlocked != message.NetworkBlocked || !_isConnectionStatusHandled;
_isConnectionStatusHandled = true;
_isNetworkBlocked = message.NetworkBlocked;
if (!_isGuestHoleActive)
{
if (message.Status is VpnStatusIpcEntity.Pinging or VpnStatusIpcEntity.Connected)
{
VpnProtocol vpnProtocol = _entityMapper.Map(message.VpnProtocol);
Server? server = GetCurrentServer(message, vpnProtocol);
PhysicalServer? physicalServer = server?.Servers.FirstOrDefault(FilterPhysicalServerByVpnState(message, vpnProtocol));
if (server is not null && physicalServer is not null)
{
_favoriteServersStorage.SetCurrentServerId(server.Id);
if (CurrentConnectionDetails is null || !CurrentConnectionDetails.OriginalConnectionIntent.IsSameAs(connectionIntent))
{
CurrentConnectionDetails = new ConnectionDetails(
connectionIntent,
server,
physicalServer,
vpnProtocol,
message.EndpointPort);
}
else
{
CurrentConnectionDetails.UpdateServer(server, physicalServer, vpnProtocol, message.EndpointPort);
}
if (_cachedServerIpAddress is not null)
{
CurrentConnectionDetails.UpdateServerIpAddress(_cachedServerIpAddress.Value);
_cachedServerIpAddress = null;
}
}
else if (server is null)
{
_logger.Error($"The status changed to Connected but the associated Server is null. Error: '{message.Error}' " +
$"NetworkBlocked: '{message.NetworkBlocked}' " +
$"Status: '{message.Status}' EntryIp: '{message.EndpointIp}' Label: '{message.Label}' " +
$"NetworkAdapterType: '{message.OpenVpnAdapterType}' VpnProtocol: '{message.VpnProtocol}'");
// VPNWIN-2105 - Either (1) Reconnect without last server, or (2) Delete this comment
await ReconnectAsync(VpnTriggerDimension.Auto);
}
else // Tier is too low for the connected server
{
await ReconnectIfNotRecentlyReconnectedAsync();
}
}
else if (message.Status == VpnStatusIpcEntity.Disconnected)
{
CurrentConnectionDetails = null;
_favoriteServersStorage.SetCurrentServerId(null);
}
}
if (message.Status != VpnStatusIpcEntity.ActionRequired ||
message.Error.IsTwoFactorError())
{
SetConnectionStatus(message.Status, message.Error, isToForceStatusUpdate);
}
}
private Server? GetCurrentServer(VpnStateIpcEntity state, VpnProtocol vpnProtocol)
{
return _serversLoader.GetServers().FirstOrDefault(s => s.Servers.Any(FilterPhysicalServerByVpnState(state, vpnProtocol)));
}
private Func FilterPhysicalServerByVpnState(VpnStateIpcEntity state, VpnProtocol vpnProtocol)
{
return physicalServer => physicalServer.Label == state.Label
&& (physicalServer.EntryIp == state.EndpointIp ||
(physicalServer.RelayIpByProtocol is not null &&
physicalServer.RelayIpByProtocol.ContainsKey(vpnProtocol) &&
physicalServer.RelayIpByProtocol[vpnProtocol] == state.EndpointIp));
}
private void SetConnectionStatus(
VpnStatusIpcEntity status,
VpnErrorTypeIpcEntity error,
bool forceSendStatusUpdate = false)
{
if (_currentStatus == status && _currentError == error && !forceSendStatusUpdate)
{
return;
}
_currentStatus = status;
_currentError = error;
CurrentConnectionDetails?.UpdateStatus(status);
ConnectionStatus = MapConnectionStatus(status, error);
_eventMessageSender.Send(new ConnectionStatusChangedMessage(ConnectionStatus));
_logger.Info($"[CONNECTION_PROCESS] Status updated to {ConnectionStatus}" +
$"{(_isGuestHoleActive ? " (Guest hole)" : string.Empty)}." +
$"{(IsConnected ? $" Connected to server {CurrentConnectionDetails?.ServerName}" : string.Empty)}");
_statisticalEventManager.OnVpnStateChanged(status, error, CurrentConnectionDetails);
}
private ConnectionStatus MapConnectionStatus(VpnStatusIpcEntity status, VpnErrorTypeIpcEntity error)
{
return status == VpnStatusIpcEntity.ActionRequired && error.IsTwoFactorError()
? ConnectionStatus.Connecting
: _entityMapper.Map(status);
}
public void Receive(ConnectionDetailsIpcEntity message)
{
IpAddressInfo serverIpAddress = _entityMapper.Map(message.ServerIpAddress);
_cachedServerIpAddress = serverIpAddress;
CurrentConnectionDetails?.UpdateServerIpAddress(serverIpAddress);
_eventMessageSender.Send(new ConnectionDetailsChangedMessage
{
ClientCountryCode = message.ClientCountryIsoCode,
ClientIpAddress = message.ClientIpAddress,
ServerIpAddress = serverIpAddress,
});
}
public async void Receive(ConnectionCertificateUpdatedMessage message)
{
AsymmetricKeyPair? clientKeyPair = _connectionKeyManager.GetKeyPairOrNull();
if (message.Certificate is not null && clientKeyPair is not null)
{
await _vpnServiceCaller.UpdateLocalAgentTlsCredentialsAsync(new LocalAgentTlsCredentialsIpcEntity()
{
ConnectionCertificate = new ConnectionCertificateIpcEntity()
{
Pem = message.Certificate.Value.Pem,
ExpirationDateUtc = message.Certificate.Value.ExpirationUtcDate.UtcDateTime,
},
ClientKeyPair = _entityMapper.Map(clientKeyPair),
});
}
}
public async Task InitializeAsync(IConnectionIntent? connectionIntent)
{
CurrentConnectionIntent = connectionIntent;
if (_cachedMessage is not null)
{
await HandleAsync(_cachedMessage);
}
await _vpnServiceCaller.RequestConnectionDetailsAsync();
}
public void Receive(GuestHoleStatusChangedMessage message)
{
_isGuestHoleActive = message.IsActive;
}
private IConnectionIntent ChangeConnectionIntent(IConnectionIntent connectionIntent, Func changeIntentFunc)
{
IConnectionIntent newConnectionIntent = changeIntentFunc(connectionIntent);
if (newConnectionIntent != connectionIntent)
{
_logger.Info($"[CONNECTION_PROCESS] The connection intent is changing from " +
$"{connectionIntent} to {newConnectionIntent}.");
}
return newConnectionIntent;
}
}