/* * 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 ProtonVPN.Api.Contracts; using ProtonVPN.Api.Contracts.Servers; using ProtonVPN.Client.EventMessaging.Contracts; using ProtonVPN.Client.Logic.Servers.Contracts; using ProtonVPN.Client.Logic.Servers.Contracts.Enums; using ProtonVPN.Client.Logic.Servers.Contracts.Extensions; using ProtonVPN.Client.Logic.Servers.Contracts.Messages; using ProtonVPN.Client.Logic.Servers.Contracts.Models; using ProtonVPN.Client.Logic.Servers.Files; using ProtonVPN.Client.Logic.Servers.Loads; using ProtonVPN.Client.Settings.Contracts; using ProtonVPN.Common.Core.Geographical; using ProtonVPN.Configurations.Contracts; using ProtonVPN.EntityMapping.Contracts; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.ApiLogs; using ProtonVPN.Logging.Contracts.Events.AppLogs; namespace ProtonVPN.Client.Logic.Servers.Cache; public class ServersCache : IServersCache { private readonly IApiClient _apiClient; private readonly IEntityMapper _entityMapper; private readonly IServersFileReaderWriter _serversFileReaderWriter; private readonly IEventMessageSender _eventMessageSender; private readonly IConfiguration _config; private readonly ISettings _settings; private readonly ILogger _logger; private readonly IFavoriteServersStorage _favoriteServersStorage; private readonly IServerLoadsCalculator _serverLoadsCalculator; private readonly ReaderWriterLockSlim _lock = new(); private string? _deviceCountryLocation; private sbyte? _userMaxTier; private bool _hasServersRequestFailed; private DateTime _lastFullUpdateUtc = DateTime.MinValue; private DateTime _lastLoadsUpdateUtc = DateTime.MinValue; private IReadOnlyList _originalServers = []; private IReadOnlyList _filteredServers = []; public IReadOnlyList Servers => GetWithReadLock(() => _filteredServers); private IReadOnlyList _freeCountries = []; public IReadOnlyList FreeCountries => GetWithReadLock(() => _freeCountries); private IReadOnlyList _countries = []; public IReadOnlyList Countries => GetWithReadLock(() => _countries); private IReadOnlyList _states = []; public IReadOnlyList States => GetWithReadLock(() => _states); private IReadOnlyList _cities = []; public IReadOnlyList Cities => GetWithReadLock(() => _cities); private IReadOnlyList _gateways = []; public IReadOnlyList Gateways => GetWithReadLock(() => _gateways); private IReadOnlyList _secureCoreCountryPairs = []; public IReadOnlyList SecureCoreCountryPairs => GetWithReadLock(() => _secureCoreCountryPairs); public ServersCache(IApiClient apiClient, IEntityMapper entityMapper, IServersFileReaderWriter serversFileReaderWriter, IEventMessageSender eventMessageSender, IConfiguration config, ISettings settings, ILogger logger, IFavoriteServersStorage favoriteServersLoader, IServerLoadsCalculator serverLoadsCalculator) { _apiClient = apiClient; _entityMapper = entityMapper; _serversFileReaderWriter = serversFileReaderWriter; _eventMessageSender = eventMessageSender; _config = config; _settings = settings; _logger = logger; _favoriteServersStorage = favoriteServersLoader; _serverLoadsCalculator = serverLoadsCalculator; } public bool IsEmpty() { return Servers is null || Servers.Count == 0; } public bool AreAllServersUnderMaintenance() { return Servers.All(s => s.IsUnderMaintenance()); } public bool IsStale() { return _deviceCountryLocation != _settings.DeviceLocation?.CountryCode || _userMaxTier != _settings.VpnPlan.MaxTier; } public bool IsOutdated() { return DateTime.UtcNow - _lastFullUpdateUtc >= _config.ServerUpdateInterval; } public bool IsLoadOutdated() { return DateTime.UtcNow - _lastLoadsUpdateUtc >= _config.MinimumServerLoadUpdateInterval; } public bool HasServersRequestFailed() { return _hasServersRequestFailed; } public bool HasGatewaysAndNoCountries() { return Gateways.Any() && !Countries.Any(); } public bool HasNoServers() { #if DEBUG if (_settings.SkipNoConnectionsPage) { return false; } #endif return IsEmpty() || AreAllServersUnderMaintenance(); } private T GetWithReadLock(Func func) { _lock.EnterReadLock(); try { return func(); } finally { _lock.ExitReadLock(); } } public void LoadFromFileIfEmpty() { if (IsEmpty()) { _logger.Info("Cache is empty, loading servers from file."); ServersFile file = _serversFileReaderWriter.Read(); ProcessServers(file.DeviceCountryLocation, file.UserMaxTier, file.Servers); } } public void Clear() { _lock.EnterWriteLock(); try { _lastFullUpdateUtc = DateTime.MinValue; _lastLoadsUpdateUtc = DateTime.MinValue; _deviceCountryLocation = null; _userMaxTier = null; _originalServers = []; _filteredServers = []; _freeCountries = []; _countries = []; _states = []; _cities = []; _gateways = []; _secureCoreCountryPairs = []; } finally { _lock.ExitWriteLock(); } } public async Task UpdateAsync(CancellationToken cancellationToken) { try { IEnumerable favoriteServerIds = GetFavoriteServerIds(); DeviceLocation? deviceLocation = _settings.DeviceLocation; DateTime utcNow = DateTime.UtcNow; ApiResponseResult response = await _apiClient.GetServersAsync( deviceLocation, favoriteServerIds, cancellationToken); if (response.Success) { _lastFullUpdateUtc = utcNow; _lastLoadsUpdateUtc = utcNow; _hasServersRequestFailed = false; if (response.LastModified.HasValue) { _settings.LogicalsLastModifiedDate = response.LastModified.Value; } if (response.IsNotModified) { _logger.Info("API: Get servers response was not modified since last call, using cached data."); } else { _logger.Info("API: Get servers response was modified since last call, updating cached data."); List servers = _entityMapper.Map(response.Value.Servers); // Handle race condition when new favorite servers are added between API request and response if (response.Value.ResponseMetadata is not null && response.Value.ResponseMetadata.ListIsTruncated && favoriteServerIds.Any()) { IEnumerable favoriteServerIdsUpdated = GetFavoriteServerIds(); IEnumerable preserveIds = favoriteServerIdsUpdated.Except(favoriteServerIds); if (preserveIds.Any()) { servers.AddRange(_originalServers.Where(s => preserveIds.Contains(s.Id))); } } _settings.LastLogicalsStatusId = response.Value.StatusId; bool result = await UpdateBinaryLoadsAsync(servers, cancellationToken); if (!result) { _logger.Warn("Loads were not updated."); return; } string deviceCountryLocation = deviceLocation?.CountryCode ?? string.Empty; sbyte userMaxTier = _settings.VpnPlan.MaxTier; SaveToFile(deviceCountryLocation, userMaxTier, servers); ProcessServers(deviceCountryLocation, userMaxTier, servers); } } else { _hasServersRequestFailed = true; } } catch (Exception e) { _logger.Error("API: Get servers failed", e); _hasServersRequestFailed = true; if (cancellationToken.IsCancellationRequested) { throw; } } } private IEnumerable GetFavoriteServerIds() { return _favoriteServersStorage.Get(); } private async Task GetServerStatusAndLoadFileAsync(string statusId, CancellationToken cancellationToken) { try { ApiResponseResult response = await _apiClient.GetServerLoadsAndStatusBinaryStringAsync(statusId, cancellationToken); return response.Success ? response.Value : null; } catch (Exception e) { _logger.Error("API: Get binary status file failed", e); return null; } } public async Task UpdateLoadsAsync(CancellationToken cancellationToken) { List servers = Servers.ToList(); bool result = await UpdateBinaryLoadsAsync(servers, cancellationToken); if (result) { _lastLoadsUpdateUtc = DateTime.UtcNow; string deviceCountryLocation = _settings.DeviceLocation?.CountryCode ?? string.Empty; sbyte userMaxTier = _settings.VpnPlan.MaxTier; SaveToFile(deviceCountryLocation, userMaxTier, servers); ProcessServers(deviceCountryLocation, userMaxTier, servers); } else { _logger.Warn("Loads were not updated."); } } private async Task UpdateBinaryLoadsAsync(List servers, CancellationToken cancellationToken) { if (_settings.LastLogicalsStatusId is null) { _logger.Warn("Cannot make the API request because LastLogicalsStatusId is null."); return false; } byte[]? statusFile = await GetServerStatusAndLoadFileAsync(_settings.LastLogicalsStatusId, cancellationToken); if (statusFile is null) { _logger.Warn("Cached server data was not updated, because status file is missing."); return false; } bool result = _serverLoadsCalculator.UpdateServerLoads(servers, statusFile, _settings.DeviceLocation); if (!result) { _logger.Warn("Cached server data was not updated, because server status and loads were not updated."); return false; } return true; } private void ProcessServers(string? deviceCountryLocation, sbyte? userMaxTier, IReadOnlyList servers) { SetIpv6Flags(servers); IReadOnlyList freeCountries = GetFreeCountries(servers); IReadOnlyList countries = GetCountries(servers); IReadOnlyList states = GetStates(servers); IReadOnlyList cities = GetCities(servers); IReadOnlyList gateways = GetGateways(servers); IReadOnlyList secureCoreCountryPairs = GetSecureCoreCountryPairs(servers); IReadOnlyList filteredServers = GetFilteredServers(servers); _lock.EnterWriteLock(); try { _deviceCountryLocation = deviceCountryLocation; _userMaxTier = userMaxTier; _originalServers = servers; _filteredServers = filteredServers; _freeCountries = freeCountries; _countries = countries; _states = states; _cities = cities; _gateways = gateways; _secureCoreCountryPairs = secureCoreCountryPairs; } finally { _lock.ExitWriteLock(); } _eventMessageSender.Send(new ServerListChangedMessage()); } private static void SetIpv6Flags(IEnumerable servers) { foreach (Server server in servers) { if (server.Servers is null) { continue; } foreach (PhysicalServer physicalServer in server.Servers) { physicalServer.IsIpv6Supported = server.Features.IsSupported(ServerFeatures.Ipv6); } } } private IReadOnlyList GetFreeCountries(IEnumerable servers) { return servers .Where(s => !string.IsNullOrWhiteSpace(s.ExitCountry) && s.IsFreeNonB2B()) .GroupBy(s => s.ExitCountry) .Select(g => new FreeCountry() { Code = g.Key, IsLocationUnderMaintenance = IsUnderMaintenance(g) }) .ToList(); } private IReadOnlyList GetCountries(IEnumerable servers) { return servers .Where(s => !string.IsNullOrWhiteSpace(s.ExitCountry) && s.IsPaidNonB2B()) .GroupBy(s => s.ExitCountry) .Select(g => new Country() { Code = g.Key, Features = AggregateFeatures(g), IsStandardUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsStandard()), IsP2PUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.P2P)), IsSecureCoreUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.SecureCore)), IsTorUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.Tor)) }) .ToList(); } private ServerFeatures AggregateFeatures(IGrouping servers) { return servers.Aggregate(default(ServerFeatures), (combinedFeatures, s) => combinedFeatures | s.Features); } private bool IsUnderMaintenance(IGrouping servers, Func? filterFunc = null) { return !servers.Any(s => (filterFunc == null || filterFunc(s)) && !s.IsUnderMaintenance()); } private IReadOnlyList GetStates(IReadOnlyList servers) { return servers .Where(s => !string.IsNullOrWhiteSpace(s.ExitCountry) && !string.IsNullOrWhiteSpace(s.State) && s.IsPaidNonB2B()) .GroupBy(s => new { Country = s.ExitCountry, s.State }) .Select(g => new State() { CountryCode = g.Key.Country, Name = g.Key.State, Features = AggregateFeatures(g), IsStandardUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsStandard()), IsP2PUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.P2P)), IsSecureCoreUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.SecureCore)), IsTorUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.Tor)) }) .ToList(); } private IReadOnlyList GetCities(IReadOnlyList servers) { return servers .Where(s => !string.IsNullOrWhiteSpace(s.ExitCountry) && !string.IsNullOrWhiteSpace(s.City) && s.IsPaidNonB2B()) .GroupBy(s => new { Country = s.ExitCountry, s.State, s.City }) .Select(g => new City() { CountryCode = g.Key.Country, StateName = g.Key.State, Name = g.Key.City, Features = AggregateFeatures(g), IsStandardUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsStandard()), IsP2PUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.P2P)), IsSecureCoreUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.SecureCore)), IsTorUnderMaintenance = IsUnderMaintenance(g, s => s.Features.IsSupported(ServerFeatures.Tor)) }) .ToList(); } private IReadOnlyList GetGateways(IReadOnlyList servers) { return servers .Where(s => s.Features.IsB2B() && !string.IsNullOrWhiteSpace(s.GatewayName)) .GroupBy(s => s.GatewayName) .Select(g => new Gateway() { Name = g.Key, IsLocationUnderMaintenance = IsUnderMaintenance(g) }) .ToList(); } private IReadOnlyList GetSecureCoreCountryPairs(IReadOnlyList servers) { return servers .Where(s => s.Features.IsSupported(ServerFeatures.SecureCore) && !string.IsNullOrWhiteSpace(s.EntryCountry) && !string.IsNullOrWhiteSpace(s.ExitCountry)) .GroupBy(s => new { s.EntryCountry, s.ExitCountry }) .Select(g => new SecureCoreCountryPair() { EntryCountry = g.Key.EntryCountry, ExitCountry = g.Key.ExitCountry, IsLocationUnderMaintenance = IsUnderMaintenance(g) }) .ToList(); } private IReadOnlyList GetFilteredServers(IReadOnlyList servers) { ServerTiers maxTier = (ServerTiers)_settings.VpnPlan.MaxTier; List filteredServers = []; foreach (Server server in servers.Where(s => s.IsVisible)) { if (server.Tier <= maxTier) { // Add all the servers the user can access (based on his plan) filteredServers.Add(server); } else if (server.Tier <= ServerTiers.Plus) { // Include all the servers the user cannot access (but without the physical servers) filteredServers.Add(server.CopyWithoutPhysicalServers()); } } return filteredServers; } private void SaveToFile(string? deviceCountryLocation, sbyte? userMaxTier, List servers) { ServersFile serversFile = new() { DeviceCountryLocation = deviceCountryLocation, UserMaxTier = userMaxTier, Servers = servers, }; _serversFileReaderWriter.Save(serversFile); } public void ReprocessServers() { _logger.Info("Reprocessing servers."); ProcessServers(_deviceCountryLocation, _userMaxTier, _originalServers); } public async Task?> LookupAsync(string input) { LoadFromFileIfEmpty(); try { DeviceLocation? deviceLocation = _settings.DeviceLocation; ApiResponseResult response = await _apiClient.GetServerByNameAsync(input, deviceLocation); if (response.Success) { Server server = _entityMapper.Map(response.Value.LogicalServer); List servers = _originalServers.ToList(); Server? alreadyExistingServer = servers.FirstOrDefault(s => s.Id == server.Id); if (alreadyExistingServer is not null) { servers.Remove(alreadyExistingServer); } servers.Add(server); string deviceCountryLocation = deviceLocation?.CountryCode ?? string.Empty; sbyte userMaxTier = _settings.VpnPlan.MaxTier; SaveToFile(deviceCountryLocation, userMaxTier, servers); ProcessServers(deviceCountryLocation, userMaxTier, servers); } else { _logger.Warn($"API: Get server by name returned with Code: {response.ResponseMessage?.StatusCode}, Error: {response.Error}"); } return response; } catch (Exception e) { _logger.Error("API: Get server by name failed", e); } return null; } }