/* * 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.Http; using System.Security.Authentication; using System.Threading; using System.Threading.Tasks; using Polly.Timeout; using ProtonVPN.Api.Contracts.Exceptions; using ProtonVPN.Client.EventMessaging.Contracts; using ProtonVPN.Client.Logic.Auth.Contracts.Messages; using ProtonVPN.Client.Logic.Connection.Contracts.Enums; using ProtonVPN.Client.Logic.Connection.Contracts.Messages; using ProtonVPN.Client.Settings.Contracts; using ProtonVPN.Common.Core.Extensions; using ProtonVPN.Common.Core.Networking; using ProtonVPN.Configurations.Contracts; using ProtonVPN.Dns.Contracts; using ProtonVPN.Dns.Contracts.AlternativeRouting; using ProtonVPN.Dns.Contracts.Exceptions; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.ApiLogs; namespace ProtonVPN.Api.Handlers; public class AlternativeHostHandler : DelegatingHandler, IEventMessageReceiver, IEventMessageReceiver, IEventMessageReceiver, IEventMessageReceiver { public const string API_PING_TEST_PATH = "tests/ping"; private const string NO_ALTERNATIVE_HOSTS_ERROR_MESSAGE = "No alternative hosts exist. Alternative routing failed."; private const string ALL_ALTERNATIVE_HOSTS_FAILED_ERROR_MESSAGE = "All alternative hosts failed. Alternative routing failed."; private readonly ILogger _logger; private readonly IDnsManager _dnsManager; private readonly IAlternativeRoutingHostGenerator _alternativeRoutingHostGenerator; private readonly IAlternativeHostsManager _alternativeHostsManager; private readonly ISettings _settings; private readonly string _defaultApiHost; private readonly TimeSpan _alternativeRoutingCheckInterval; private readonly SemaphoreSlim _semaphore = new(1, 1); private bool _isGuestHoleActive; private bool _isDisconnected = true; private bool _isUserLoggedIn; private DateTime? _lastAlternativeRoutingCheckDateUtc; public AlternativeHostHandler( ILogger logger, IDnsManager dnsManager, IAlternativeRoutingHostGenerator alternativeRoutingHostGenerator, IAlternativeHostsManager alternativeHostsManager, ISettings settings, IConfiguration config) { _logger = logger; _dnsManager = dnsManager; _alternativeRoutingHostGenerator = alternativeRoutingHostGenerator; _alternativeHostsManager = alternativeHostsManager; _settings = settings; _defaultApiHost = new Uri(config.Urls.ApiUrl).Host; _alternativeRoutingCheckInterval = config.AlternativeRoutingCheckInterval; } public void Receive(ConnectionStatusChangedMessage message) { _isDisconnected = message.ConnectionStatus == ConnectionStatus.Disconnected; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (IsAlternativeRoutingEnabled()) { if (IsAlternativeRoutingAllowed()) { if (IsLastAlternativeRoutingCheckDateNullOrTooOld()) { bool isApiAvailable = await IsApiAvailableAsync(request, cancellationToken); if (isApiAvailable) { await DisableAlternativeRoutingAsync(); } else { return await SendRequestWithActiveAlternativeHostAsync(request, cancellationToken); } } else { return await SendRequestWithActiveAlternativeHostAsync(request, cancellationToken); } } else { await DisableAlternativeRoutingAsync(); } } return await TrySendRequestAsync(request, cancellationToken); } private bool IsAlternativeRoutingEnabled() { return !_settings.ActiveAlternativeApiBaseUrl.IsNullOrEmpty(); } private async Task SendRequestWithActiveAlternativeHostAsync(HttpRequestMessage request, CancellationToken cancellationToken) { string alternativeHost = _settings.ActiveAlternativeApiBaseUrl; if (!alternativeHost.IsNullOrEmpty()) { try { HttpResponseMessage httpResponseMessage = await SendRequestWithActiveAlternativeHostAsync( alternativeHost, request, cancellationToken); return httpResponseMessage; } catch (Exception ex) { _logger.Error($"Alternative host '{alternativeHost}' failed.", ex); } } await DisableAlternativeRoutingAsync(); return await TrySendRequestAsync(request, cancellationToken); } private async Task SendRequestWithActiveAlternativeHostAsync(string alternativeHost, HttpRequestMessage request, CancellationToken cancellationToken) { IList alternativeHostIpAddresses = await _dnsManager.GetAsync(alternativeHost, cancellationToken); ThrowIfAlternativeHostHasNoIpAddresses(alternativeHost, alternativeHostIpAddresses); foreach (IpAddress alternativeHostIpAddress in alternativeHostIpAddresses) { try { HttpResponseMessage httpResponseMessage = await SendRequestToAlternativeHostAsync( alternativeHostIpAddress, alternativeHost, request, cancellationToken); return httpResponseMessage; } catch (Exception ex) { _logger.Error($"Alternative host '{alternativeHost}' with IP address " + $"'{alternativeHostIpAddress}' failed.", ex); } } throw new AlternativeRoutingException($"Alternative host '{alternativeHost}' failed."); } private bool IsAlternativeRoutingAllowed() { return _isDisconnected && !_isGuestHoleActive && IsAlternativeRoutingSettingEnabled(); } private bool IsAlternativeRoutingSettingEnabled() { return _settings.IsAlternativeRoutingEnabled; } private async Task TrySendRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { HttpResponseMessage httpResponseMessage; try { httpResponseMessage = await SendRequestAsync(request, cancellationToken); } catch (Exception ex) when (IsAlternativeRoutingAllowed() && IsPotentialBlockingException(ex)) { bool isApiAvailable = await IsApiAvailableAsync(request, cancellationToken); if (isApiAvailable) { httpResponseMessage = await SendOriginalRequestAndRetryWithAlternativeRoutingAsync(request, cancellationToken); } else { httpResponseMessage = await SendRequestWithAlternativeRoutingAsync(request, cancellationToken); } } return httpResponseMessage; } private async Task SendOriginalRequestAndRetryWithAlternativeRoutingAsync( HttpRequestMessage request, CancellationToken cancellationToken) { HttpResponseMessage httpResponseMessage; try { httpResponseMessage = await SendRequestAsync(request, cancellationToken); } catch (Exception ex) when (IsAlternativeRoutingAllowed() && IsPotentialBlockingException(ex)) { httpResponseMessage = await SendRequestWithAlternativeRoutingAsync(request, cancellationToken); } return httpResponseMessage; } public bool IsPotentialBlockingException(Exception ex) { return ex is TimeoutException or TimeoutRejectedException or DnsException || ex.GetBaseException() is AuthenticationException; } private bool IsLastAlternativeRoutingCheckDateNullOrTooOld() { return _lastAlternativeRoutingCheckDateUtc is null || _lastAlternativeRoutingCheckDateUtc < (DateTime.UtcNow - _alternativeRoutingCheckInterval); } private async Task IsApiAvailableAsync(HttpRequestMessage request, CancellationToken cancellationToken) { _logger.Info("Checking for API availability."); IList defaultApiIpAddresses = await _dnsManager.ResolveWithoutCacheAsync(_defaultApiHost, cancellationToken); if (defaultApiIpAddresses.IsNullOrEmpty()) { defaultApiIpAddresses = _dnsManager.GetFromCache(_defaultApiHost); if (defaultApiIpAddresses.IsNullOrEmpty()) { await UpdateLastAlternativeRoutingCheckDateAsync(); _logger.Warn("The API is unavailable due to a failure in the DNS step. " + "No IP addresses were able to be resolved or fetched from cache."); return false; } } bool isApiPingSuccessful = await SendApiPingRequestAsync(request, cancellationToken); await UpdateLastAlternativeRoutingCheckDateAsync(); LogApiAvailabilityCheckResult(isApiPingSuccessful); return isApiPingSuccessful; } private void LogApiAvailabilityCheckResult(bool isApiPingSuccessful) { if (isApiPingSuccessful) { _logger.Info("The API availability check was successful."); } else { _logger.Warn("The API is unavailable due to a request failure."); } } private async Task UpdateLastAlternativeRoutingCheckDateAsync() { await _semaphore.WaitAsync(); try { _lastAlternativeRoutingCheckDateUtc = DateTime.UtcNow; } finally { _semaphore.Release(); } } private async Task SendApiPingRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { try { HttpResponseMessage result = await base.SendAsync(CreateApiPingRequest(request), cancellationToken); return result.IsSuccessStatusCode; } catch { return false; } } private HttpRequestMessage CreateApiPingRequest(HttpRequestMessage request) { HttpRequestMessage pingRequest = new(); UriBuilder uriBuilder = new(request.RequestUri) { Host = _defaultApiHost, Path = API_PING_TEST_PATH, Query = string.Empty, }; pingRequest.Headers.Host = _defaultApiHost; pingRequest.RequestUri = uriBuilder.Uri; pingRequest.Method = HttpMethod.Get; return pingRequest; } private async Task DisableAlternativeRoutingAsync() { await _semaphore.WaitAsync(); try { _lastAlternativeRoutingCheckDateUtc = null; _settings.ActiveAlternativeApiBaseUrl = null; } finally { _semaphore.Release(); } } private async Task SendRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return await base.SendAsync(request, cancellationToken); } private async Task SendRequestWithAlternativeRoutingAsync( HttpRequestMessage request, CancellationToken cancellationToken) { string alternativeRoutingHost = _alternativeRoutingHostGenerator.Generate(_isUserLoggedIn ? _settings.UniqueSessionId : null); IList alternativeHosts = await _alternativeHostsManager.GetAsync(alternativeRoutingHost, cancellationToken); ThrowIfNoAlternativeHostsExist(alternativeHosts); HttpResponseMessage httpResponseMessage = null; string chosenAlternativeHost = null; foreach (string alternativeHost in alternativeHosts) { try { httpResponseMessage = await SendRequestWithAlternativeHostAsync(alternativeHost, request, cancellationToken); chosenAlternativeHost = alternativeHost; await EnableAlternativeRoutingAsync(chosenAlternativeHost); break; } catch (Exception ex) { _logger.Error($"Alternative host '{alternativeHost}' failed.", ex); } } ThrowIfAllAlternativeHostsFailed(chosenAlternativeHost); return httpResponseMessage; } private void ThrowIfNoAlternativeHostsExist(IList alternativeHosts) { if (alternativeHosts.IsNullOrEmpty()) { _logger.Error(NO_ALTERNATIVE_HOSTS_ERROR_MESSAGE); throw new AlternativeRoutingException(NO_ALTERNATIVE_HOSTS_ERROR_MESSAGE); } } private void ThrowIfAllAlternativeHostsFailed(string chosenAlternativeHost) { if (chosenAlternativeHost.IsNullOrEmpty()) { _logger.Error(ALL_ALTERNATIVE_HOSTS_FAILED_ERROR_MESSAGE); throw new AlternativeRoutingException(ALL_ALTERNATIVE_HOSTS_FAILED_ERROR_MESSAGE); } } private async Task SendRequestWithAlternativeHostAsync(string alternativeHost, HttpRequestMessage request, CancellationToken cancellationToken) { IList alternativeHostIpAddresses = await _dnsManager.GetAsync(alternativeHost, cancellationToken); ThrowIfAlternativeHostHasNoIpAddresses(alternativeHost, alternativeHostIpAddresses); foreach (IpAddress alternativeHostIpAddress in alternativeHostIpAddresses) { try { HttpResponseMessage httpResponseMessage = await SendRequestToAlternativeHostAsync( alternativeHostIpAddress, alternativeHost, request, cancellationToken); if (httpResponseMessage.IsSuccessStatusCode) { return httpResponseMessage; } } catch (Exception ex) { _logger.Error($"Alternative host '{alternativeHost}' with IP address " + $"'{alternativeHostIpAddress}' failed.", ex); } } throw new AlternativeRoutingException($"Alternative host '{alternativeHost}' failed."); } private async Task EnableAlternativeRoutingAsync(string alternativeHost) { await _semaphore.WaitAsync(); try { _lastAlternativeRoutingCheckDateUtc = DateTime.UtcNow; _settings.ActiveAlternativeApiBaseUrl = alternativeHost; } finally { _semaphore.Release(); } } private void ThrowIfAlternativeHostHasNoIpAddresses(string alternativeHost, IList alternativeHostIpAddresses) { if (alternativeHostIpAddresses.IsNullOrEmpty()) { string errorMessage = $"No IP addresses were found for alternative host '{alternativeHost}'."; _logger.Error(errorMessage); throw new AlternativeRoutingException(errorMessage); } } private async Task SendRequestToAlternativeHostAsync(IpAddress ipAddress, string alternativeHost, HttpRequestMessage request, CancellationToken cancellationToken) { string oldUriHost = request.RequestUri.Host; string oldHeaderHost = request.Headers.Host; SetRequestHost(request, uriHost: ipAddress.ToString(), headerHost: alternativeHost); HttpResponseMessage httpResponseMessage; try { httpResponseMessage = await SendRequestAsync(request, cancellationToken); } finally { SetRequestHost(request, uriHost: oldUriHost, headerHost: oldHeaderHost); } return httpResponseMessage; } private void SetRequestHost(HttpRequestMessage request, string uriHost, string headerHost) { UriBuilder uriBuilder = new(request.RequestUri) { Host = uriHost }; request.Headers.Host = headerHost; request.RequestUri = uriBuilder.Uri; } public void Receive(LoggedInMessage message) { _isUserLoggedIn = true; } public void Receive(LoggedOutMessage message) { _isUserLoggedIn = false; } public void Receive(GuestHoleStatusChangedMessage message) { _isGuestHoleActive = message.IsActive; } }