/* * 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.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ProtonVPN.Client.Settings.Contracts; using ProtonVPN.Common.Core.Extensions; using ProtonVPN.Common.Core.Networking; using ProtonVPN.Configurations.Contracts; using ProtonVPN.Dns.Caching; using ProtonVPN.Dns.Contracts; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.DnsLogs; namespace ProtonVPN.Dns; public abstract class ARecordDnsManagerBase { protected TimeSpan FailedDnsRequestTimeout { get; } protected TimeSpan NewCacheTimeToLiveOnResolveError { get; } protected ILogger Logger { get; } protected IDnsCacheManager DnsCacheManager { get; } private readonly ISettings _settings; private readonly SemaphoreSlim _semaphore = new(1, 1); private readonly ConcurrentDictionary _failedRequestsCache = new(); protected ARecordDnsManagerBase(ISettings settings, IConfiguration config, ILogger logger, IDnsCacheManager dnsCacheManager) { FailedDnsRequestTimeout = config.FailedDnsRequestTimeout; NewCacheTimeToLiveOnResolveError = config.NewCacheTimeToLiveOnResolveError; Logger = logger; DnsCacheManager = dnsCacheManager; _settings = settings; } public async Task> GetAsync(string host, CancellationToken cancellationToken) { IList ipAddresses = GetFreshIpAddressesFromCache(host); if (ipAddresses.Count == 0) { ipAddresses = await ResolveOrGetFromCacheAsync(host, cancellationToken); } return ipAddresses; } private async Task> ResolveOrGetFromCacheAsync(string host, CancellationToken cancellationToken) { try { await _semaphore.WaitAsync(cancellationToken); } catch { Logger.Warn($"DNS resolve of host '{host}' was cancelled while waiting."); return new List(); } IList ipAddresses; try { ipAddresses = GetFreshIpAddressesFromCache(host); if (ipAddresses.Count == 0) { if (_failedRequestsCache.TryGetValue(host, out DateTime timeoutEndDateUtc) && timeoutEndDateUtc > DateTime.UtcNow) { Logger.Debug($"Skipping DNS resolve of host '{host}' because its under timeout."); ipAddresses = GetIpAddressesFromCache(host); } else { Logger.Info($"No fresh IP addresses for host '{host}' were found in the cache. Triggering a refresh."); ipAddresses = await ResolveHostAsync(host, cancellationToken); if (ipAddresses.Count == 0) { ipAddresses = await GetIpAddressesFromCacheAndSetNewTtlAsync(host); } } } else { Logger.Debug($"Locked re-check for a fresh DNS cache of host '{host}' was successful."); } } finally { _semaphore.Release(); } return ipAddresses; } private IList GetFreshIpAddressesFromCache(string host) { IList ipAddresses = new List(); DateTime currentDateTimeUtc = DateTime.UtcNow; if (_settings.DnsCache.TryGetValueIfDictionaryIsNotNull(host, out DnsResponse dnsResponse) && dnsResponse.ExpirationDateTimeUtc > currentDateTimeUtc) { ipAddresses = dnsResponse.IpAddresses; } return ipAddresses; } private IList GetIpAddressesFromCache(string host) { IList ipAddresses = new List(); if (_settings.DnsCache.TryGetValueIfDictionaryIsNotNull(host, out DnsResponse dnsResponse)) { ipAddresses = dnsResponse.IpAddresses; } return ipAddresses ?? new List(); } private async Task> GetIpAddressesFromCacheAndSetNewTtlAsync(string host) { IList ipAddresses = GetIpAddressesFromCache(host); if (ipAddresses.Any()) { DnsResponse newDnsResponse = await DnsCacheManager.UpdateAsync(host, SetDatesAndTimeToLiveFactory); Logger.Info($"Returning cached IP addresses for host '{host}'. " + $"New TTL of {newDnsResponse.TimeToLive} resulting in a " + $"new expiration date of {newDnsResponse.ExpirationDateTimeUtc}."); } else { DateTime timeoutEndDateUtc = DateTime.UtcNow + FailedDnsRequestTimeout; _failedRequestsCache.AddOrUpdate(host, timeoutEndDateUtc, (_, _) => timeoutEndDateUtc); Logger.Warn($"No cached IP addresses exist for host '{host}'. " + $"Next resolve can only be made after '{timeoutEndDateUtc}'."); } return ipAddresses; } private DnsResponse SetDatesAndTimeToLiveFactory(DnsResponse dnsResponse) { dnsResponse.SetDatesAndTimeToLive(NewCacheTimeToLiveOnResolveError); return dnsResponse; } protected abstract Task> ResolveHostAsync(string host, CancellationToken cancellationToken); public async Task> ResolveWithoutCacheAsync(string host, CancellationToken cancellationToken) { try { await _semaphore.WaitAsync(cancellationToken); } catch { Logger.Warn($"DNS resolve of host '{host}' was cancelled while waiting."); return new List(); } IList ipAddresses = new List(); try { if (_failedRequestsCache.TryGetValue(host, out DateTime timeoutEndDateUtc) && timeoutEndDateUtc > DateTime.UtcNow) { Logger.Debug($"Skipping forced DNS resolve of host '{host}' because its under timeout."); } else { Logger.Info($"Starting resolve of IP addresses for host '{host}'."); ipAddresses = await ResolveHostAsync(host, cancellationToken); } } finally { _semaphore.Release(); } return ipAddresses; } public IList GetFromCache(string host) { ConcurrentDictionary dnsCache = _settings.DnsCache; IList ipAddresses = new List(); if (dnsCache.TryGetValueIfDictionaryIsNotNull(host, out DnsResponse dnsResponse)) { ipAddresses = dnsResponse.IpAddresses; } return ipAddresses; } }