diff --git a/DnsClientX.Tests/QueryDnsByEndpoint.cs b/DnsClientX.Tests/QueryDnsByEndpoint.cs index 4f931b86..11b1cfc2 100644 --- a/DnsClientX.Tests/QueryDnsByEndpoint.cs +++ b/DnsClientX.Tests/QueryDnsByEndpoint.cs @@ -18,6 +18,7 @@ public class QueryDnsByEndpoint { #endif [Theory] public async Task ShouldWorkForTXT(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; var response = await ClientX.QueryDns("github.com", DnsRecordType.TXT, endpoint); foreach (DnsAnswer answer in response.Answers) { Assert.True(answer.Name == "github.com"); @@ -48,6 +49,7 @@ public async Task ShouldWorkForTXT(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.GoogleQuic)] #endif public async Task ShouldWorkForA(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; var response = await ClientX.QueryDns("evotec.pl", DnsRecordType.A, endpoint); foreach (DnsAnswer answer in response.Answers) { Assert.True(answer.Name == "evotec.pl"); @@ -78,6 +80,7 @@ public async Task ShouldWorkForA(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.GoogleQuic)] #endif public async Task ShouldWorkForPTR(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; var response = await ClientX.QueryDns("1.1.1.1", DnsRecordType.PTR, endpoint); foreach (DnsAnswer answer in response.Answers) { Assert.True(answer.Data == "one.one.one.one"); diff --git a/DnsClientX.Tests/RegexCultureInvariantTests.cs b/DnsClientX.Tests/RegexCultureInvariantTests.cs index 908f97b6..6565329d 100644 --- a/DnsClientX.Tests/RegexCultureInvariantTests.cs +++ b/DnsClientX.Tests/RegexCultureInvariantTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using System.Threading; @@ -40,7 +41,8 @@ public void ConvertData_TlsaRecord_ConsistentAcrossCultures(string culture) { [InlineData("tr-TR")] public void FilterAnswersRegex_ConsistentAcrossCultures(string culture) { var client = new ClientX(); - MethodInfo method = typeof(ClientX).GetMethod("FilterAnswersRegex", BindingFlags.NonPublic | BindingFlags.Instance)!; + MethodInfo method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "FilterAnswersRegex" && m.GetParameters().Length == 3); var answers = new[] { new DnsAnswer { Name = "example.com", @@ -69,7 +71,8 @@ public void FilterAnswersRegex_ConsistentAcrossCultures(string culture) { [InlineData("tr-TR")] public void FilterAnswers_ConsistentAcrossCultures(string culture) { var client = new ClientX(); - MethodInfo method = typeof(ClientX).GetMethod("FilterAnswers", BindingFlags.NonPublic | BindingFlags.Instance)!; + MethodInfo method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "FilterAnswers" && m.GetParameters().Length == 3); var answers = new[] { new DnsAnswer { Name = "example.com", diff --git a/DnsClientX.Tests/ResolveFilterAliasTests.cs b/DnsClientX.Tests/ResolveFilterAliasTests.cs new file mode 100644 index 00000000..63c8ff6d --- /dev/null +++ b/DnsClientX.Tests/ResolveFilterAliasTests.cs @@ -0,0 +1,118 @@ +using System.Threading.Tasks; +using Xunit; + +namespace DnsClientX.Tests { + /// + /// Tests alias handling for ResolveFilter APIs. + /// + public class ResolveFilterAliasTests { + private static DnsResponse CreateResponse(string name) { + return new DnsResponse { + Answers = new[] { + new DnsAnswer { Name = name, Type = DnsRecordType.CNAME, TTL = 60, DataRaw = "alias.example.com" }, + new DnsAnswer { Name = name, Type = DnsRecordType.TXT, TTL = 120, DataRaw = "v=spf1 include:example.com -all\nother=record" } + } + }; + } + + /// + /// Ensures alias answers are kept alongside matching TXT lines when enabled. + /// + [Fact] + public async Task ResolveFilter_IncludeAliases_KeepsCnameAndMatchingTxt() { + using var client = new ClientX(DnsEndpoint.Cloudflare); + client.ResolverOverride = (name, type, ct) => Task.FromResult(CreateResponse(name)); + + var options = new ResolveFilterOptions(true); + var response = await client.ResolveFilter("example.com", DnsRecordType.TXT, "v=spf1", options, retryOnTransient: false); + + Assert.NotNull(response.Answers); + Assert.Equal(2, response.Answers.Length); + Assert.Contains(response.Answers, answer => answer.Type == DnsRecordType.CNAME); + Assert.Contains(response.Answers, answer => answer.Type == DnsRecordType.TXT && answer.Data == "v=spf1 include:example.com -all"); + } + + /// + /// Ensures alias answers are not kept when alias inclusion is disabled. + /// + [Fact] + public async Task ResolveFilter_ExcludeAliases_DropsCname() { + using var client = new ClientX(DnsEndpoint.Cloudflare); + client.ResolverOverride = (name, type, ct) => Task.FromResult(CreateResponse(name)); + + var options = new ResolveFilterOptions(false); + var response = await client.ResolveFilter("example.com", DnsRecordType.TXT, "v=spf1", options, retryOnTransient: false); + + Assert.Single(response.Answers); + Assert.Equal(DnsRecordType.TXT, response.Answers[0].Type); + } + + /// + /// Ensures array-based ResolveFilter returns responses when only aliases match. + /// + [Fact] + public async Task ResolveFilter_Array_IncludeAliases_ReturnsResponseWithOnlyCname() { + using var client = new ClientX(DnsEndpoint.Cloudflare); + client.ResolverOverride = (name, type, ct) => Task.FromResult(CreateResponse(name)); + + var options = new ResolveFilterOptions(true); + var responses = await client.ResolveFilter(new[] { "example.com" }, DnsRecordType.TXT, "nomatch", options, retryOnTransient: false); + + Assert.Single(responses); + Assert.Single(responses[0].Answers); + Assert.Equal(DnsRecordType.CNAME, responses[0].Answers[0].Type); + } + + /// + /// Ensures empty filters still keep alias and requested type answers when enabled. + /// + [Fact] + public async Task ResolveFilter_IncludeAliases_EmptyFilter_ReturnsAliasAndType() { + using var client = new ClientX(DnsEndpoint.Cloudflare); + client.ResolverOverride = (name, type, ct) => Task.FromResult(CreateResponse(name)); + + var options = new ResolveFilterOptions(true); + var response = await client.ResolveFilter("example.com", DnsRecordType.TXT, string.Empty, options, retryOnTransient: false); + + Assert.NotNull(response.Answers); + Assert.Equal(2, response.Answers.Length); + Assert.Contains(response.Answers, answer => answer.Type == DnsRecordType.CNAME); + Assert.Contains(response.Answers, answer => answer.Type == DnsRecordType.TXT); + } + + /// + /// Ensures null filters are treated as empty when alias inclusion is enabled. + /// + [Fact] + public async Task ResolveFilter_IncludeAliases_NullFilter_TreatedAsEmpty() { + using var client = new ClientX(DnsEndpoint.Cloudflare); + client.ResolverOverride = (name, type, ct) => Task.FromResult(CreateResponse(name)); + + var options = new ResolveFilterOptions(true); + string? filter = null; +#pragma warning disable CS8604 // Null is allowed for robustness testing. + var response = await client.ResolveFilter("example.com", DnsRecordType.TXT, filter, options, retryOnTransient: false); +#pragma warning restore CS8604 + + Assert.NotNull(response.Answers); + Assert.Equal(2, response.Answers.Length); + Assert.Contains(response.Answers, answer => answer.Type == DnsRecordType.CNAME); + Assert.Contains(response.Answers, answer => answer.Type == DnsRecordType.TXT); + } + + /// + /// Ensures alias filtering still respects the filter when querying alias types. + /// + [Fact] + public async Task ResolveFilter_IncludeAliases_AliasType_RespectsFilter() { + using var client = new ClientX(DnsEndpoint.Cloudflare); + client.ResolverOverride = (name, type, ct) => Task.FromResult(CreateResponse(name)); + + var options = new ResolveFilterOptions(true); + var response = await client.ResolveFilter("example.com", DnsRecordType.CNAME, "nomatch", options, retryOnTransient: false); + + Assert.NotNull(response.Answers); + Assert.Empty(response.Answers); + } + } +} diff --git a/DnsClientX.Tests/ResolveFilterLineTests.cs b/DnsClientX.Tests/ResolveFilterLineTests.cs index 043fc581..c5035d4b 100644 --- a/DnsClientX.Tests/ResolveFilterLineTests.cs +++ b/DnsClientX.Tests/ResolveFilterLineTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using Xunit; @@ -15,7 +16,8 @@ public class ResolveFilterLineTests { [Fact] public void FilterAnswers_ReturnsMatchingLine() { var client = new ClientX(); - var method = typeof(ClientX).GetMethod("FilterAnswers", BindingFlags.NonPublic | BindingFlags.Instance)!; + var method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "FilterAnswers" && m.GetParameters().Length == 3); var answers = new[] { CreateTxt("line1\nline2") }; var result = (DnsAnswer[])method.Invoke(client, new object[] { answers, "line2", DnsRecordType.TXT })!; Assert.Single(result); @@ -28,7 +30,8 @@ public void FilterAnswers_ReturnsMatchingLine() { [Fact] public void FilterAnswersRegex_ReturnsMatchingLine() { var client = new ClientX(); - var method = typeof(ClientX).GetMethod("FilterAnswersRegex", BindingFlags.NonPublic | BindingFlags.Instance)!; + var method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "FilterAnswersRegex" && m.GetParameters().Length == 3); var answers = new[] { CreateTxt("line1\nline2") }; var result = (DnsAnswer[])method.Invoke(client, new object[] { answers, new Regex("line2$"), DnsRecordType.TXT })!; Assert.Single(result); diff --git a/DnsClientX.Tests/ResolveFilterNullDataTests.cs b/DnsClientX.Tests/ResolveFilterNullDataTests.cs index 2c1579f8..173ace5b 100644 --- a/DnsClientX.Tests/ResolveFilterNullDataTests.cs +++ b/DnsClientX.Tests/ResolveFilterNullDataTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using Xunit; @@ -22,7 +23,8 @@ private static DnsAnswer CreateAnswer(string dataRaw, DnsRecordType type) { [Fact] public void FilterAnswers_ShouldIgnoreEmptyData() { var client = new ClientX(); - MethodInfo method = typeof(ClientX).GetMethod("FilterAnswers", BindingFlags.NonPublic | BindingFlags.Instance)!; + MethodInfo method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "FilterAnswers" && m.GetParameters().Length == 3); var answers = new[] { CreateAnswer(string.Empty, DnsRecordType.A) }; var result = (DnsAnswer[])method.Invoke(client, new object[] { answers, "test", DnsRecordType.A })!; Assert.Empty(result); @@ -34,7 +36,8 @@ public void FilterAnswers_ShouldIgnoreEmptyData() { [Fact] public void FilterAnswersRegex_ShouldIgnoreEmptyData() { var client = new ClientX(); - MethodInfo method = typeof(ClientX).GetMethod("FilterAnswersRegex", BindingFlags.NonPublic | BindingFlags.Instance)!; + MethodInfo method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "FilterAnswersRegex" && m.GetParameters().Length == 3); var answers = new[] { CreateAnswer(string.Empty, DnsRecordType.A) }; var result = (DnsAnswer[])method.Invoke(client, new object[] { answers, new Regex("test", RegexOptions.CultureInvariant), DnsRecordType.A })!; Assert.Empty(result); @@ -46,7 +49,8 @@ public void FilterAnswersRegex_ShouldIgnoreEmptyData() { [Fact] public void HasMatchingAnswers_ShouldReturnFalseForEmptyData() { var client = new ClientX(); - MethodInfo method = typeof(ClientX).GetMethod("HasMatchingAnswers", BindingFlags.NonPublic | BindingFlags.Instance)!; + MethodInfo method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "HasMatchingAnswers" && m.GetParameters().Length == 3); var answers = new[] { CreateAnswer(string.Empty, DnsRecordType.A) }; var result = (bool)method.Invoke(client, new object[] { answers, "test", DnsRecordType.A })!; Assert.False(result); @@ -58,7 +62,8 @@ public void HasMatchingAnswers_ShouldReturnFalseForEmptyData() { [Fact] public void HasMatchingAnswersRegex_ShouldReturnFalseForEmptyData() { var client = new ClientX(); - MethodInfo method = typeof(ClientX).GetMethod("HasMatchingAnswersRegex", BindingFlags.NonPublic | BindingFlags.Instance)!; + MethodInfo method = typeof(ClientX).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(m => m.Name == "HasMatchingAnswersRegex" && m.GetParameters().Length == 3); var answers = new[] { CreateAnswer(string.Empty, DnsRecordType.A) }; var result = (bool)method.Invoke(client, new object[] { answers, new Regex("test", RegexOptions.CultureInvariant), DnsRecordType.A })!; Assert.False(result); diff --git a/DnsClientX.Tests/ResolveFirst.cs b/DnsClientX.Tests/ResolveFirst.cs index 587d08e5..9e3c8d6f 100644 --- a/DnsClientX.Tests/ResolveFirst.cs +++ b/DnsClientX.Tests/ResolveFirst.cs @@ -24,6 +24,7 @@ public class ResolveFirst { [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public async Task ShouldWorkForTXT(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; using var Client = new ClientX(endpoint); var answer = await Client.ResolveFirst("github.com", DnsRecordType.TXT, cancellationToken: CancellationToken.None); Assert.True(answer != null); @@ -53,6 +54,7 @@ public async Task ShouldWorkForTXT(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public async Task ShouldWorkForA(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; using var Client = new ClientX(endpoint); var answer = await Client.ResolveFirst("evotec.pl", DnsRecordType.A, cancellationToken: CancellationToken.None); Assert.True(answer != null); @@ -78,6 +80,7 @@ public async Task ShouldWorkForA(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public void ShouldWorkForTXT_Sync(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; using var Client = new ClientX(endpoint); var answer = Client.ResolveFirstSync("github.com", DnsRecordType.TXT, cancellationToken: CancellationToken.None); Assert.True(answer != null); @@ -104,6 +107,7 @@ public void ShouldWorkForTXT_Sync(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public void ShouldWorkForA_Sync(DnsEndpoint endpoint) { + if (TestSkipHelpers.ShouldSkipEndpoint(endpoint)) return; using var Client = new ClientX(endpoint); var answer = Client.ResolveFirstSync("evotec.pl", DnsRecordType.A, cancellationToken: CancellationToken.None); Assert.True(answer != null); diff --git a/DnsClientX.Tests/ResolveSync.cs b/DnsClientX.Tests/ResolveSync.cs index a2bd6177..b8a3b137 100644 --- a/DnsClientX.Tests/ResolveSync.cs +++ b/DnsClientX.Tests/ResolveSync.cs @@ -24,6 +24,11 @@ private void LogDiagnostics(string message) _output.WriteLine($"[Diagnostic] {message}"); } + private bool ShouldSkipEndpoint(DnsEndpoint endpoint) + { + return TestSkipHelpers.ShouldSkipEndpoint(endpoint, _output); + } + private async Task TryResolveWithDiagnostics(ClientX client, string domain, DnsRecordType recordType, int maxRetries = 3) { LogDiagnostics($"Attempting to resolve {domain} for record type {recordType}"); @@ -98,6 +103,7 @@ private async Task TryResolveWithDiagnostics(ClientX client, string [InlineData(DnsEndpoint.OpenDNSFamily)] public async Task ShouldWorkForTXTSync(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var response = await TryResolveWithDiagnostics(client, "github.com", DnsRecordType.TXT); @@ -127,8 +133,9 @@ public async Task ShouldWorkForTXTSync(DnsEndpoint endpoint) [InlineData(DnsEndpoint.GoogleWireFormatPost)] [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] - public async Task ShouldWorkForFirstSyncTXT(DnsEndpoint endpoint) + public async Task ShouldWorkForFirstSyncTXT(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var response = await TryResolveWithDiagnostics(client, "github.com", DnsRecordType.TXT); @@ -158,6 +165,7 @@ public async Task ShouldWorkForFirstSyncTXT(DnsEndpoint endpoint) [InlineData(DnsEndpoint.OpenDNSFamily)] public async Task ShouldWorkForAllSyncTXT(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var response = await TryResolveWithDiagnostics(client, "github.com", DnsRecordType.TXT); @@ -189,6 +197,7 @@ public async Task ShouldWorkForAllSyncTXT(DnsEndpoint endpoint) [InlineData(DnsEndpoint.OpenDNSFamily)] public async Task ShouldWorkForASync(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var response = await TryResolveWithDiagnostics(client, "evotec.pl", DnsRecordType.A); @@ -219,6 +228,7 @@ public async Task ShouldWorkForASync(DnsEndpoint endpoint) [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public void ShouldWorkForPTRSync(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var response = client.ResolveSync("1.1.1.1", DnsRecordType.PTR); foreach (DnsAnswer answer in response.Answers) { @@ -285,6 +295,7 @@ public void ShouldWorkForMultipleTypesSync(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public void ShouldWorkForFirstSyncA(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var answer = client.ResolveFirstSync("evotec.pl", DnsRecordType.A, cancellationToken: CancellationToken.None); Assert.True(answer != null); @@ -311,6 +322,7 @@ public void ShouldWorkForFirstSyncA(DnsEndpoint endpoint) { [InlineData(DnsEndpoint.OpenDNS)] [InlineData(DnsEndpoint.OpenDNSFamily)] public void ShouldWorkForAllSyncA(DnsEndpoint endpoint) { + if (ShouldSkipEndpoint(endpoint)) return; using var client = new ClientX(endpoint); var answers = client.ResolveAllSync("evotec.pl", DnsRecordType.A); foreach (DnsAnswer answer in answers) { diff --git a/DnsClientX.Tests/TestSkipHelpers.cs b/DnsClientX.Tests/TestSkipHelpers.cs new file mode 100644 index 00000000..38e114cc --- /dev/null +++ b/DnsClientX.Tests/TestSkipHelpers.cs @@ -0,0 +1,64 @@ +using System; +using System.Net; +using Xunit.Abstractions; + +namespace DnsClientX.Tests; + +internal static class TestSkipHelpers +{ + internal static bool ShouldSkipEndpoint(DnsEndpoint endpoint, ITestOutputHelper? output = null) + { + return ShouldSkipSystemTcp(endpoint, output) || ShouldSkipOdoh(endpoint, output); + } + + private static bool ShouldSkipSystemTcp(DnsEndpoint endpoint, ITestOutputHelper? output) + { + if (endpoint != DnsEndpoint.SystemTcp) + { + return false; + } + + var servers = SystemInformation.GetDnsFromActiveNetworkCard(); + if (servers == null || servers.Count == 0) + { + output?.WriteLine("[Diagnostic] System TCP DNS skipped: no active DNS servers detected."); + return true; + } + + var allLoopback = true; + foreach (var server in servers) + { + if (IPAddress.TryParse(server, out var ip) && !IPAddress.IsLoopback(ip)) + { + allLoopback = false; + break; + } + } + + if (allLoopback) + { + output?.WriteLine("[Diagnostic] System TCP DNS skipped: only loopback resolvers detected."); + return true; + } + + return false; + } + + private static bool ShouldSkipOdoh(DnsEndpoint endpoint, ITestOutputHelper? output) + { + if (endpoint != DnsEndpoint.CloudflareOdoh) + { + return false; + } + + var allow = Environment.GetEnvironmentVariable("DNSCLIENTX_RUN_ODOH_TESTS"); + if (string.Equals(allow, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(allow, "true", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + output?.WriteLine("[Diagnostic] Cloudflare ODoH tests skipped unless DNSCLIENTX_RUN_ODOH_TESTS=1."); + return true; + } +} diff --git a/DnsClientX/DnsClientX.ResolveFilter.cs b/DnsClientX/DnsClientX.ResolveFilter.cs index b72a10a8..4151672b 100644 --- a/DnsClientX/DnsClientX.ResolveFilter.cs +++ b/DnsClientX/DnsClientX.ResolveFilter.cs @@ -28,10 +28,30 @@ public partial class ClientX { /// Token used to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the DNS responses that match the filter. public async Task ResolveFilter(string[] names, DnsRecordType type, string filter, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { + return await ResolveFilter(names, type, filter, new ResolveFilterOptions(), requestDnsSec, validateDnsSec, retryOnTransient, maxRetries, retryDelayMs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves multiple domain names for a single DNS record type in parallel using DNS over HTTPS. + /// This method allows you to specify a filter that will be applied to the data of the DNS answers. + /// + /// The domain names to resolve. + /// The type of DNS record to resolve. + /// The filter to apply to the DNS answers data. + /// Options for filtering, including alias inclusion. + /// Whether to request DNSSEC data in the response. + /// Whether to validate DNSSEC data. + /// Whether to retry on transient errors. + /// The maximum number of retries. + /// The delay between retries in milliseconds. + /// Token used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the DNS responses that match the filter. + public async Task ResolveFilter(string[] names, DnsRecordType type, string filter, ResolveFilterOptions options, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { + filter ??= string.Empty; int total = names.Length; DnsResponse[] allResponses; if (EndpointConfiguration.MaxConcurrency is null || EndpointConfiguration.MaxConcurrency <= 0 || EndpointConfiguration.MaxConcurrency >= total) { - var tasksUnbounded = names.Select(name => Resolve(name, type, requestDnsSec, validateDnsSec, false, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)).ToList(); + var tasksUnbounded = names.Select(name => Resolve(name, type, requestDnsSec, validateDnsSec, options.IncludeAliases, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)).ToList(); await Task.WhenAll(tasksUnbounded).ConfigureAwait(false); allResponses = tasksUnbounded.Select(t => t.Result).ToArray(); } else { @@ -44,7 +64,7 @@ public async Task ResolveFilter(string[] names, DnsRecordType typ tasks.Add(Task.Run(async () => { await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - allResponses[idx] = await Resolve(name, type, requestDnsSec, validateDnsSec, false, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); + allResponses[idx] = await Resolve(name, type, requestDnsSec, validateDnsSec, options.IncludeAliases, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); } finally { semaphore.Release(); } @@ -54,9 +74,9 @@ public async Task ResolveFilter(string[] names, DnsRecordType typ } var filteredResponses = allResponses - .Where(response => HasMatchingAnswers(response.Answers ?? Array.Empty(), filter, type)) + .Where(response => HasMatchingAnswers(response.Answers ?? Array.Empty(), filter, type, options.IncludeAliases)) .Select(response => { - response.Answers = FilterAnswers(response.Answers, filter, type); + response.Answers = FilterAnswers(response.Answers, filter, type, options.IncludeAliases); return response; }) .ToArray(); @@ -79,10 +99,29 @@ public async Task ResolveFilter(string[] names, DnsRecordType typ /// Token used to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the DNS responses that match the filter. public async Task ResolveFilter(string[] names, DnsRecordType type, Regex regexFilter, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { + return await ResolveFilter(names, type, regexFilter, new ResolveFilterOptions(), requestDnsSec, validateDnsSec, retryOnTransient, maxRetries, retryDelayMs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves multiple domain names for a single DNS record type in parallel using DNS over HTTPS. + /// This method allows you to specify a regular expression filter that will be applied to the data of the DNS answers. + /// + /// The domain names to resolve. + /// The type of DNS record to resolve. + /// The regular expression filter to apply to the DNS answers data. + /// Options for filtering, including alias inclusion. + /// Whether to request DNSSEC data in the response. + /// Whether to validate DNSSEC data. + /// Whether to retry on transient errors. + /// The maximum number of retries. + /// The delay between retries in milliseconds. + /// Token used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the DNS responses that match the filter. + public async Task ResolveFilter(string[] names, DnsRecordType type, Regex regexFilter, ResolveFilterOptions options, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { int total = names.Length; DnsResponse[] allResponses; if (EndpointConfiguration.MaxConcurrency is null || EndpointConfiguration.MaxConcurrency <= 0 || EndpointConfiguration.MaxConcurrency >= total) { - var tasksUnbounded = names.Select(name => Resolve(name, type, requestDnsSec, validateDnsSec, false, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)).ToList(); + var tasksUnbounded = names.Select(name => Resolve(name, type, requestDnsSec, validateDnsSec, options.IncludeAliases, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)).ToList(); await Task.WhenAll(tasksUnbounded).ConfigureAwait(false); allResponses = tasksUnbounded.Select(t => t.Result).ToArray(); } else { @@ -95,7 +134,7 @@ public async Task ResolveFilter(string[] names, DnsRecordType typ tasks.Add(Task.Run(async () => { await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - allResponses[idx] = await Resolve(name, type, requestDnsSec, validateDnsSec, false, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); + allResponses[idx] = await Resolve(name, type, requestDnsSec, validateDnsSec, options.IncludeAliases, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); } finally { semaphore.Release(); } @@ -105,9 +144,9 @@ public async Task ResolveFilter(string[] names, DnsRecordType typ } var filteredResponses = allResponses - .Where(response => HasMatchingAnswersRegex(response.Answers ?? Array.Empty(), regexFilter, type)) + .Where(response => HasMatchingAnswersRegex(response.Answers ?? Array.Empty(), regexFilter, type, options.IncludeAliases)) .Select(response => { - response.Answers = FilterAnswersRegex(response.Answers, regexFilter, type); + response.Answers = FilterAnswersRegex(response.Answers, regexFilter, type, options.IncludeAliases); return response; }) .ToArray(); @@ -131,10 +170,30 @@ public async Task ResolveFilter(string[] names, DnsRecordType typ /// Token used to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the DNS response that matches the filter. public async Task ResolveFilter(string name, DnsRecordType type, string filter, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { - var response = await Resolve(name, type, requestDnsSec, validateDnsSec, false, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); + return await ResolveFilter(name, type, filter, new ResolveFilterOptions(), requestDnsSec, validateDnsSec, retryOnTransient, maxRetries, retryDelayMs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a single domain name for a single DNS record type using DNS over HTTPS. + /// This method allows you to specify a filter that will be applied to the data of the DNS answers. + /// + /// The domain name to resolve. + /// The type of DNS record to resolve. + /// The filter to apply to the DNS answers data. + /// Options for filtering, including alias inclusion. + /// Whether to request DNSSEC data in the response. + /// Whether to validate DNSSEC data. + /// Whether to retry on transient errors. + /// The maximum number of retries. + /// The delay between retries in milliseconds. + /// Token used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the DNS response that matches the filter. + public async Task ResolveFilter(string name, DnsRecordType type, string filter, ResolveFilterOptions options, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { + filter ??= string.Empty; + var response = await Resolve(name, type, requestDnsSec, validateDnsSec, options.IncludeAliases, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrEmpty(filter) && response.Answers != null) { - response.Answers = FilterAnswers(response.Answers, filter, type); + if (response.Answers != null && (options.IncludeAliases || !string.IsNullOrEmpty(filter))) { + response.Answers = FilterAnswers(response.Answers, filter, type, options.IncludeAliases); } return response; @@ -155,10 +214,29 @@ public async Task ResolveFilter(string name, DnsRecordType type, st /// Token used to cancel the operation. /// A task that represents the asynchronous operation. The task result contains the DNS response that matches the filter. public async Task ResolveFilter(string name, DnsRecordType type, Regex regexFilter, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { - var response = await Resolve(name, type, requestDnsSec, validateDnsSec, false, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); + return await ResolveFilter(name, type, regexFilter, new ResolveFilterOptions(), requestDnsSec, validateDnsSec, retryOnTransient, maxRetries, retryDelayMs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a single domain name for a single DNS record type using DNS over HTTPS. + /// This method allows you to specify a regular expression filter that will be applied to the data of the DNS answers. + /// + /// The domain name to resolve. + /// The type of DNS record to resolve. + /// The regular expression filter to apply to the DNS answers data. + /// Options for filtering, including alias inclusion. + /// Whether to request DNSSEC data in the response. + /// Whether to validate DNSSEC data. + /// Whether to retry on transient errors. + /// The maximum number of retries. + /// The delay between retries in milliseconds. + /// Token used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the DNS response that matches the filter. + public async Task ResolveFilter(string name, DnsRecordType type, Regex regexFilter, ResolveFilterOptions options, bool requestDnsSec = false, bool validateDnsSec = false, bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 100, CancellationToken cancellationToken = default) { + var response = await Resolve(name, type, requestDnsSec, validateDnsSec, options.IncludeAliases, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken).ConfigureAwait(false); if (response.Answers != null) { - response.Answers = FilterAnswersRegex(response.Answers, regexFilter, type); + response.Answers = FilterAnswersRegex(response.Answers, regexFilter, type, options.IncludeAliases); } return response; @@ -172,6 +250,12 @@ public async Task ResolveFilter(string name, DnsRecordType type, Re /// The DNS record type being filtered. /// Filtered array of DNS answers. private DnsAnswer[] FilterAnswers(DnsAnswer[] answers, string filter, DnsRecordType type) { + return FilterAnswers(answers, filter, type, false); + } + + private DnsAnswer[] FilterAnswers(DnsAnswer[] answers, string filter, DnsRecordType type, bool includeAliases) { + filter ??= string.Empty; + var filterLower = filter.ToLowerInvariant(); var filteredAnswers = new List(); foreach (var answer in answers) { @@ -179,10 +263,20 @@ private DnsAnswer[] FilterAnswers(DnsAnswer[] answers, string filter, DnsRecordT continue; } + var isAlias = IsAliasRecordType(answer.Type); + if (includeAliases && isAlias && answer.Type != type) { + filteredAnswers.Add(answer); + continue; + } + + if (answer.Type != type) { + continue; + } + if (type == DnsRecordType.TXT && answer.Type == DnsRecordType.TXT) { - // For TXT records, check if any line contains the filter + // For TXT records, check if any line contains the filter var lines = answer.Data.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - var matchingLines = lines.Where(line => line.ToLowerInvariant().Contains(filter.ToLowerInvariant())).ToArray(); + var matchingLines = lines.Where(line => line.ToLowerInvariant().Contains(filterLower)).ToArray(); if (matchingLines.Length > 0) { // Create a new answer with only the matching lines @@ -198,7 +292,7 @@ private DnsAnswer[] FilterAnswers(DnsAnswer[] answers, string filter, DnsRecordT } } else { // For non-TXT records, use the original logic - if (answer.Data.ToLowerInvariant().Contains(filter.ToLowerInvariant())) { + if (answer.Data.ToLowerInvariant().Contains(filterLower)) { filteredAnswers.Add(answer); } } @@ -215,6 +309,10 @@ private DnsAnswer[] FilterAnswers(DnsAnswer[] answers, string filter, DnsRecordT /// The DNS record type being filtered. /// Filtered array of DNS answers. private DnsAnswer[] FilterAnswersRegex(DnsAnswer[] answers, Regex regexFilter, DnsRecordType type) { + return FilterAnswersRegex(answers, regexFilter, type, false); + } + + private DnsAnswer[] FilterAnswersRegex(DnsAnswer[] answers, Regex regexFilter, DnsRecordType type, bool includeAliases) { var filteredAnswers = new List(); foreach (var answer in answers) { @@ -222,6 +320,16 @@ private DnsAnswer[] FilterAnswersRegex(DnsAnswer[] answers, Regex regexFilter, D continue; } + var isAlias = IsAliasRecordType(answer.Type); + if (includeAliases && isAlias && answer.Type != type) { + filteredAnswers.Add(answer); + continue; + } + + if (answer.Type != type) { + continue; + } + if (type == DnsRecordType.TXT && answer.Type == DnsRecordType.TXT) { // For TXT records, check if any line matches the regex var lines = answer.Data.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); @@ -258,23 +366,38 @@ private DnsAnswer[] FilterAnswersRegex(DnsAnswer[] answers, Regex regexFilter, D /// The DNS record type being filtered. /// True if any answer contains a match. private bool HasMatchingAnswers(DnsAnswer[] answers, string filter, DnsRecordType type) { + return HasMatchingAnswers(answers, filter, type, false); + } + + private bool HasMatchingAnswers(DnsAnswer[] answers, string filter, DnsRecordType type, bool includeAliases) { if (answers == null) { return false; } + filter ??= string.Empty; + var filterLower = filter.ToLowerInvariant(); foreach (var answer in answers) { if (string.IsNullOrEmpty(answer.Data)) { continue; } + var isAlias = IsAliasRecordType(answer.Type); + if (includeAliases && isAlias && answer.Type != type) { + return true; + } + + if (answer.Type != type) { + continue; + } + if (type == DnsRecordType.TXT && answer.Type == DnsRecordType.TXT) { var lines = answer.Data.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - var matchingLines = lines.Where(line => line.ToLowerInvariant().Contains(filter.ToLowerInvariant())).ToArray(); + var matchingLines = lines.Where(line => line.ToLowerInvariant().Contains(filterLower)).ToArray(); if (matchingLines.Length > 0) { return true; } } else { - if (answer.Data.ToLowerInvariant().Contains(filter.ToLowerInvariant())) { + if (answer.Data.ToLowerInvariant().Contains(filterLower)) { return true; } } @@ -290,6 +413,10 @@ private bool HasMatchingAnswers(DnsAnswer[] answers, string filter, DnsRecordTyp /// The DNS record type being filtered. /// True if any answer contains a match. private bool HasMatchingAnswersRegex(DnsAnswer[] answers, Regex regexFilter, DnsRecordType type) { + return HasMatchingAnswersRegex(answers, regexFilter, type, false); + } + + private bool HasMatchingAnswersRegex(DnsAnswer[] answers, Regex regexFilter, DnsRecordType type, bool includeAliases) { if (answers == null) { return false; } @@ -299,6 +426,15 @@ private bool HasMatchingAnswersRegex(DnsAnswer[] answers, Regex regexFilter, Dns continue; } + var isAlias = IsAliasRecordType(answer.Type); + if (includeAliases && isAlias && answer.Type != type) { + return true; + } + + if (answer.Type != type) { + continue; + } + if (type == DnsRecordType.TXT && answer.Type == DnsRecordType.TXT) { var lines = answer.Data.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); var matchingLines = lines.Where(line => regexFilter.IsMatch(line)).ToArray(); @@ -313,5 +449,9 @@ private bool HasMatchingAnswersRegex(DnsAnswer[] answers, Regex regexFilter, Dns } return false; } + + private static bool IsAliasRecordType(DnsRecordType recordType) { + return recordType == DnsRecordType.CNAME || recordType == DnsRecordType.DNAME; + } } } diff --git a/DnsClientX/ResolveFilterOptions.cs b/DnsClientX/ResolveFilterOptions.cs new file mode 100644 index 00000000..42838881 --- /dev/null +++ b/DnsClientX/ResolveFilterOptions.cs @@ -0,0 +1,13 @@ +namespace DnsClientX; + +/// +/// Provides options for +/// and related overloads. +/// +/// +/// +/// var options = new ResolveFilterOptions(IncludeAliases: true); +/// var response = await client.ResolveFilter("example.com", DnsRecordType.TXT, "v=spf1", options); +/// +/// +public readonly record struct ResolveFilterOptions(bool IncludeAliases = false);