/* * 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.Linq; using System.Net; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using ProtonVPN.Common.Core.Extensions; using ProtonVPN.Common.Core.Networking; using ProtonVPN.Configurations.Contracts; using ProtonVPN.Dns.Contracts; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.DnsLogs; namespace ProtonVPN.Dns.Resolvers; public abstract class DnsResolverBase { protected ILogger Logger { get; } protected TimeSpan DnsResolveTimeout { get; } private readonly TimeSpan _defaultDnsTimeToLive; protected DnsResolverBase(IConfiguration config, ILogger logger) { Logger = logger; DnsResolveTimeout = config.DnsResolveTimeout; _defaultDnsTimeToLive = config.DefaultDnsTimeToLive; } public async Task ResolveAsync(string host, CancellationToken cancellationToken) { DnsResponse dnsResponse = null; if (cancellationToken.IsCancellationRequested) { Logger.Error($"DNS resolver called with cancelled token for host '{host}'."); } else if (string.IsNullOrEmpty(host)) { Logger.Error($"DNS resolver called for empty host '{host}'."); } else { Logger.Info($"Attempting to resolve host '{host}'."); dnsResponse = await StartTasksAndWaitAnySuccessAsync(host, cancellationToken); } LogResult(host, dnsResponse); return dnsResponse; } protected abstract Task StartTasksAndWaitAnySuccessAsync(string host, CancellationToken cancellationToken); private void LogResult(string host, DnsResponse dnsResponse) { if (IsNullOrEmpty(dnsResponse)) { Logger.Error($"Failed to resolve host '{host}'."); } else { Logger.Info($"Successfully resolved host '{host}'."); } } protected abstract bool IsNullOrEmpty(DnsResponse dnsResponse); // This method receives the DNS request tasks as funcs to be able to use with them a cancellation token it can cancel. // When there is a task that successfully responds, this method can cancel immediately all other pending tasks. // When there is a timeout or an unexpected error, this method can cancel immediately all pending tasks. protected async Task WaitAnySuccessfulResolveAsync( IList>> resolveFuncs, TimeSpan timeout, CancellationToken cancellationToken) { CancellationTokenSource timeoutCancellationTokenSource = new(timeout); CancellationTokenSource childCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, timeoutCancellationTokenSource.Token); IList> resolveTasks = resolveFuncs .Select(resolveTask => resolveTask(childCancellationTokenSource.Token)).ToList(); return await TryWaitAnySuccessfulResolveAsync(resolveTasks, childCancellationTokenSource); } private async Task TryWaitAnySuccessfulResolveAsync(IList> resolveTasks, CancellationTokenSource cancellationTokenSource) { DnsResponse dnsResponse = null; while (resolveTasks.Any()) { try { Task completedTask = await Task.WhenAny(resolveTasks); resolveTasks.Remove(completedTask); dnsResponse = await completedTask; if (!IsNullOrEmpty(dnsResponse)) { break; } if (cancellationTokenSource.Token.IsCancellationRequested) { LogOperationCancelled("Task cancelled after a DNS resolve task completed without response."); break; } } catch (Exception e) { if (e.IsOrAnyInnerIsOfExceptionType()) { LogOperationCancelled("Task cancelled when waiting for DNS resolve tasks."); } else { Logger.Error("Unexpected error in DNS resolve task wait.", e); } break; } } resolveTasks.ForEach(t => t.FireAndForget()); cancellationTokenSource.Cancel(); return dnsResponse; } protected void LogOperationCancelled(string message, [CallerFilePath] string sourceFilePath = "", [CallerMemberName] string sourceMemberName = "", [CallerLineNumber] int sourceLineNumber = 0) { Logger.Info(message, sourceFilePath: sourceFilePath, sourceMemberName: sourceMemberName, sourceLineNumber: sourceLineNumber); } protected DnsResponse CreateDnsResponseWithIpAddresses(string host, int? timeToLiveInSeconds, IList systemTypeIpAddresses) { if (systemTypeIpAddresses.IsNullOrEmpty()) { Logger.Error("Cannot create DNS response entity because no IP addresses were provided."); return null; } List ipAddresses = systemTypeIpAddresses.Select(ia => new IpAddress(ia)).ToList(); foreach (IpAddress ipAddress in ipAddresses.ToList()) { if (!ipAddress.IsPublicIp()) { Logger.Warn($"Resolved {ipAddress.ToString()} is not a public IP address, removing from the list."); ipAddresses.Remove(ipAddress); } } if (ipAddresses.Count == 0) { Logger.Error("Cannot create DNS response entity because all resolved IP addresses were not public IP addresses."); return null; } DnsResponse dnsResponse = new(host, GetTimeToLiveOrDefault(timeToLiveInSeconds), ipAddresses); Logger.Info($"Created DNS response entity for host '{dnsResponse.Host}' with " + $"{dnsResponse.IpAddresses.Count} IP addresses and an expiration date in UTC of " + $"{dnsResponse.ExpirationDateTimeUtc} based on a TTL of {dnsResponse.TimeToLive}."); Logger.Debug($"IP addresses: [{string.Join(",", dnsResponse.IpAddresses.Select(ia => ia.ToString()))}]."); return dnsResponse; } protected TimeSpan GetTimeToLiveOrDefault(int? timeToLiveInSeconds) { return timeToLiveInSeconds is null or 0 ? _defaultDnsTimeToLive.AddJitter(0.25) : TimeSpan.FromSeconds(timeToLiveInSeconds.Value); } protected DnsResponse CreateDnsResponseWithAlternativeHosts(string host, int? timeToLiveInSeconds, IList alternativeHosts) { if (alternativeHosts.IsNullOrEmpty()) { Logger.Error("Cannot create DNS response entity because no alternative hosts were provided."); return null; } DnsResponse dnsResponse = new(host, GetTimeToLiveOrDefault(timeToLiveInSeconds), alternativeHosts); Logger.Info($"Created DNS response entity for host '{dnsResponse.Host}' with " + $"{dnsResponse.AlternativeHosts.Count} alternative hosts and an expiration date in UTC of " + $"{dnsResponse.ExpirationDateTimeUtc} based on a TTL of {dnsResponse.TimeToLive}."); Logger.Debug( $"Alternative hosts: [{string.Join(",", dnsResponse.AlternativeHosts.Select(ia => ia.ToString()))}]."); return dnsResponse; } }