/* * 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; } }