From 21787a2514953665f6cc90f4f284e9851a0f2c6b Mon Sep 17 00:00:00 2001 From: rzikm Date: Wed, 3 Jun 2026 15:17:50 +0200 Subject: [PATCH 01/10] Add System.Net.Dns resolver API surface and Windows implementation Implements the API approved in dotnet/runtime#19443: * New Dns.Resolve*[Async] static methods for A/AAAA/SRV/MX/TXT/CNAME/PTR/NS records. * New DnsResolver / DnsResolverOptions for instance-based resolution with optional custom DNS servers. * Record types AddressRecord, SrvRecord, MxRecord, TxtRecord, CNameRecord, PtrRecord, NsRecord. * DnsResponseCode enum and DnsResult envelope carrying ResponseCode, Records, and NegativeCacheTtl. Windows implementation uses DnsQueryEx (DNS_QUERY_REQUEST v1 by default; DNS_QUERY_REQUEST3 when the caller supplies non-default ports on Windows 11 build 22000+, throwing PlatformNotSupportedException otherwise). Non-Windows platforms get PlatformNotSupportedException stubs pending follow-up implementations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interop/Windows/Dnsapi/Interop.DnsApi.cs | 71 +++ .../Windows/Dnsapi/Interop.DnsTypes.cs | 195 ++++++++ .../src/Interop/Windows/Interop.Libraries.cs | 1 + .../ref/System.Net.NameResolution.cs | 125 ++++++ .../src/Resources/Strings.resx | 3 + .../src/System.Net.NameResolution.csproj | 15 + .../src/System/Net/Dns.Resolve.cs | 73 +++ .../src/System/Net/Dns.cs | 2 +- .../src/System/Net/DnsRecords.cs | 116 +++++ .../src/System/Net/DnsResolver.Unsupported.cs | 35 ++ .../src/System/Net/DnsResolver.Windows.cs | 327 ++++++++++++++ .../System/Net/DnsResolver.WindowsAsync.cs | 419 ++++++++++++++++++ .../src/System/Net/DnsResolver.cs | 163 +++++++ .../src/System/Net/DnsResolverOptions.cs | 18 + .../src/System/Net/DnsResponseCode.cs | 23 + .../src/System/Net/DnsResult.cs | 40 ++ 16 files changed, 1625 insertions(+), 1 deletion(-) create mode 100644 src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsApi.cs create mode 100644 src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/Dns.Resolve.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsRecords.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverOptions.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResponseCode.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResult.cs diff --git a/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsApi.cs b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsApi.cs new file mode 100644 index 00000000000000..ce4e396ff9a8f3 --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsApi.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +internal static partial class Interop +{ + internal static partial class Dnsapi + { + // ---- Query types we use ---- + internal const ushort DNS_TYPE_A = 0x0001; + internal const ushort DNS_TYPE_NS = 0x0002; + internal const ushort DNS_TYPE_CNAME = 0x0005; + internal const ushort DNS_TYPE_SOA = 0x0006; + internal const ushort DNS_TYPE_PTR = 0x000c; + internal const ushort DNS_TYPE_MX = 0x000f; + internal const ushort DNS_TYPE_TEXT = 0x0010; + internal const ushort DNS_TYPE_AAAA = 0x001c; + internal const ushort DNS_TYPE_SRV = 0x0021; + + // ---- DnsQueryEx return codes / Win32 error codes ---- + internal const int DNS_REQUEST_PENDING = 9506; + internal const int ERROR_SUCCESS = 0; + internal const int DNS_INFO_NO_RECORDS = 9501; + internal const int DNS_ERROR_RCODE_FORMAT_ERROR = 9001; + internal const int DNS_ERROR_RCODE_SERVER_FAILURE = 9002; + internal const int DNS_ERROR_RCODE_NAME_ERROR = 9003; + internal const int DNS_ERROR_RCODE_NOT_IMPLEMENTED = 9004; + internal const int DNS_ERROR_RCODE_REFUSED = 9005; + + // ---- DnsQueryEx options ---- + internal const ulong DNS_QUERY_STANDARD = 0x00000000; + internal const ulong DNS_QUERY_RETURN_MESSAGE = 0x00020000; + + // ---- Query request versions ---- + internal const uint DNS_QUERY_REQUEST_VERSION1 = 0x1; + internal const uint DNS_QUERY_REQUEST_VERSION3 = 0x3; + + // ---- DNS_ADDR address family marker — addresses are stored in SOCKADDR form ---- + internal const ushort AF_INET = 2; + internal const ushort AF_INET6 = 23; + + // ---- DNS_CUSTOM_SERVER server types ---- + internal const uint DNS_CUSTOM_SERVER_TYPE_UDP = 0x1; + internal const uint DNS_CUSTOM_SERVER_TYPE_DOH = 0x2; + + // ---- DNS_CUSTOM_SERVER usage flags ---- + internal const ulong DNS_CUSTOM_SERVER_UDP_FALLBACK = 0x1; + + // ---- DnsFreeType for DnsFree ---- + internal const int DnsFreeFlat = 0; + internal const int DnsFreeRecordList = 1; + internal const int DnsFreeParsedMessageFields = 2; + + internal delegate void DnsQueryCompletionRoutine(IntPtr pQueryContext, IntPtr pQueryResults); + + [LibraryImport(Libraries.Dnsapi, EntryPoint = "DnsQueryEx")] + internal static unsafe partial int DnsQueryEx( + DNS_QUERY_REQUEST* pQueryRequest, + DNS_QUERY_RESULT* pQueryResults, + DNS_QUERY_CANCEL* pCancelHandle); + + [LibraryImport(Libraries.Dnsapi, EntryPoint = "DnsCancelQuery")] + internal static unsafe partial int DnsCancelQuery(DNS_QUERY_CANCEL* pCancelHandle); + + [LibraryImport(Libraries.Dnsapi, EntryPoint = "DnsFree")] + internal static partial void DnsFree(IntPtr pData, int freeType); + } +} diff --git a/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs new file mode 100644 index 00000000000000..cf92e3e0307a29 --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs @@ -0,0 +1,195 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + internal static partial class Dnsapi + { + // DNS_QUERY_REQUEST (v1) — Win8 / Server 2012+ + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_QUERY_REQUEST + { + public uint Version; + public IntPtr QueryName; // PCWSTR + public ushort QueryType; + public ulong QueryOptions; + public DNS_ADDR_ARRAY* pDnsServerList; + public uint InterfaceIndex; + public IntPtr pQueryCompletionCallback; // PDNS_QUERY_COMPLETION_ROUTINE + public IntPtr pQueryContext; + } + + // DNS_QUERY_REQUEST3 — Win11 Build 22000+ + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_QUERY_REQUEST3 + { + public uint Version; + public IntPtr QueryName; + public ushort QueryType; + public ulong QueryOptions; + public DNS_ADDR_ARRAY* pDnsServerList; + public uint InterfaceIndex; + public IntPtr pQueryCompletionCallback; + public IntPtr pQueryContext; + public uint cCustomServers; + public DNS_CUSTOM_SERVER* pCustomServers; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_QUERY_RESULT + { + public uint Version; + public int QueryStatus; + public ulong QueryOptions; + public IntPtr pQueryRecords; // DNS_RECORD* + public IntPtr Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_QUERY_CANCEL + { + public fixed byte Reserved[32]; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_ADDR + { + // SOCKET_ADDRESS-like: 32 bytes of SOCKADDR_STORAGE-ish + extras. + // DnsApi documents this struct as 64 bytes total with the first 32 + // being the SOCKADDR (IPv4/IPv6 SOCKADDR fits within). + public fixed byte MaxSa[32]; + public uint DnsAddrUserDword0; + public uint DnsAddrUserDword1; + public uint DnsAddrUserDword2; + public uint DnsAddrUserDword3; + public uint DnsAddrUserDword4; + public uint DnsAddrUserDword5; + public uint DnsAddrUserDword6; + public uint DnsAddrUserDword7; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_ADDR_ARRAY + { + public uint MaxCount; + public uint AddrCount; + public uint Tag; + public ushort Family; + public ushort WordReserved; + public uint Flags; + public uint MatchFlag; + public uint Reserved1; + public uint Reserved2; + // followed by AddrCount entries of DNS_ADDR + // (we allocate the trailing array contiguously) + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_CUSTOM_SERVER + { + public uint dwServerType; // DNS_CUSTOM_SERVER_TYPE_* + public ulong ullFlags; + public IntPtr pwszTemplate; // PCWSTR (DoH only) + public fixed byte ServerAddr[32]; // SOCKADDR + } + + // ---- DNS_RECORD (variable layout: header + Data union) ---- + // We declare the fixed header layout and read the data area as a byte blob, + // re-interpreting per record type. The Data field begins at offset 24 on 32-bit + // and at offset 24 on 64-bit pointer layouts as documented; we use a + // Sequential struct with explicit Next/pName pointers. + [StructLayout(LayoutKind.Sequential)] + internal struct DNS_RECORD_HEADER + { + public IntPtr pNext; // DNS_RECORD* + public IntPtr pName; // PCWSTR + public ushort wType; + public ushort wDataLength; // not always reliable; use type to interpret + public uint Flags; // contains Section in the low bits + public uint dwTtl; + public uint dwReserved; + // followed by Data union + } + + // ---- Section field within DNS_RECORD.Flags ---- + // The Section is the lowest 2 bits of the DW_FLAGS field. + internal const uint DNSREC_SECTION_MASK = 0x3; + internal const uint DNSREC_QUESTION = 0; + internal const uint DNSREC_ANSWER = 1; + internal const uint DNSREC_AUTHORITY = 2; + internal const uint DNSREC_ADDITIONAL = 3; + + // ---- Data unions ---- + [StructLayout(LayoutKind.Sequential)] + internal struct DNS_A_DATA + { + public uint IpAddress; // network byte order + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_AAAA_DATA + { + public fixed byte Ip6Address[16]; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct DNS_PTR_DATA + { + public IntPtr pNameHost; // PCWSTR + } + + // Same shape as DNS_PTR_DATA — Windows uses DNS_PTR_DATA for NS/CNAME too, + // but typed aliases keep call sites self-documenting. +#pragma warning disable CS0649 // fields populated via native marshalling + internal struct DNS_CNAME_DATA + { + public IntPtr pNameHost; + } + + internal struct DNS_NS_DATA + { + public IntPtr pNameHost; + } +#pragma warning restore CS0649 + + [StructLayout(LayoutKind.Sequential)] + internal struct DNS_MX_DATA + { + public IntPtr pNameExchange; // PCWSTR + public ushort wPreference; + public ushort Pad; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct DNS_SRV_DATA + { + public IntPtr pNameTarget; // PCWSTR + public ushort wPriority; + public ushort wWeight; + public ushort wPort; + public ushort Pad; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct DNS_TXT_DATA + { + public uint dwStringCount; + // followed by dwStringCount entries of PCWSTR + } + + [StructLayout(LayoutKind.Sequential)] + internal struct DNS_SOA_DATA + { + public IntPtr pNamePrimaryServer; // PCWSTR + public IntPtr pNameAdministrator; // PCWSTR + public uint dwSerialNo; + public uint dwRefresh; + public uint dwRetry; + public uint dwExpire; + public uint dwDefaultTtl; + } + } +} diff --git a/src/libraries/Common/src/Interop/Windows/Interop.Libraries.cs b/src/libraries/Common/src/Interop/Windows/Interop.Libraries.cs index a9a3a2fe167edc..af66c1f796edc0 100644 --- a/src/libraries/Common/src/Interop/Windows/Interop.Libraries.cs +++ b/src/libraries/Common/src/Interop/Windows/Interop.Libraries.cs @@ -12,6 +12,7 @@ internal static partial class Libraries internal const string Credui = "credui.dll"; internal const string Crypt32 = "crypt32.dll"; internal const string CryptUI = "cryptui.dll"; + internal const string Dnsapi = "dnsapi.dll"; internal const string Dsrole = "dsrole.dll"; internal const string Gdi32 = "gdi32.dll"; internal const string HttpApi = "httpapi.dll"; diff --git a/src/libraries/System.Net.NameResolution/ref/System.Net.NameResolution.cs b/src/libraries/System.Net.NameResolution/ref/System.Net.NameResolution.cs index 9a35c4275aa8df..a77da0600bc3fb 100644 --- a/src/libraries/System.Net.NameResolution/ref/System.Net.NameResolution.cs +++ b/src/libraries/System.Net.NameResolution/ref/System.Net.NameResolution.cs @@ -42,6 +42,24 @@ public static partial class Dns public static string GetHostName() { throw null; } [System.ObsoleteAttribute("Resolve has been deprecated. Use GetHostEntry instead.")] public static System.Net.IPHostEntry Resolve(string hostName) { throw null; } + public static System.Net.DnsResult ResolveAddresses(string name) { throw null; } + public static System.Net.DnsResult ResolveAddresses(string name, System.Net.Sockets.AddressFamily addressFamily) { throw null; } + public static System.Threading.Tasks.Task> ResolveAddressesAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task> ResolveAddressesAsync(string name, System.Net.Sockets.AddressFamily addressFamily, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Net.DnsResult ResolveSrv(string name) { throw null; } + public static System.Threading.Tasks.Task> ResolveSrvAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Net.DnsResult ResolveMx(string name) { throw null; } + public static System.Threading.Tasks.Task> ResolveMxAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Net.DnsResult ResolveTxt(string name) { throw null; } + public static System.Threading.Tasks.Task> ResolveTxtAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Net.DnsResult ResolveCName(string name) { throw null; } + public static System.Threading.Tasks.Task> ResolveCNameAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Net.DnsResult ResolvePtr(string name) { throw null; } + public static System.Net.DnsResult ResolvePtr(System.Net.IPAddress address) { throw null; } + public static System.Threading.Tasks.Task> ResolvePtrAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task> ResolvePtrAsync(System.Net.IPAddress address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Net.DnsResult ResolveNs(string name) { throw null; } + public static System.Threading.Tasks.Task> ResolveNsAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } public partial class IPHostEntry { @@ -50,4 +68,111 @@ public IPHostEntry() { } public string[] Aliases { get { throw null; } set { } } public string HostName { get { throw null; } set { } } } + public sealed partial class DnsResolver : System.IAsyncDisposable, System.IDisposable + { + public DnsResolver() { } + public DnsResolver(System.Net.DnsResolverOptions options) { } + public System.Net.DnsResult ResolveAddresses(string name) { throw null; } + public System.Net.DnsResult ResolveAddresses(string name, System.Net.Sockets.AddressFamily addressFamily) { throw null; } + public System.Net.DnsResult ResolveSrv(string name) { throw null; } + public System.Net.DnsResult ResolveMx(string name) { throw null; } + public System.Net.DnsResult ResolveTxt(string name) { throw null; } + public System.Net.DnsResult ResolveCName(string name) { throw null; } + public System.Net.DnsResult ResolvePtr(string name) { throw null; } + public System.Net.DnsResult ResolveNs(string name) { throw null; } + public System.Threading.Tasks.Task> ResolveAddressesAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolveAddressesAsync(string name, System.Net.Sockets.AddressFamily addressFamily, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolveSrvAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolveMxAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolveTxtAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolveCNameAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolvePtrAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolvePtrAsync(System.Net.IPAddress address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task> ResolveNsAsync(string name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public void Dispose() { } + public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + } + public sealed partial class DnsResolverOptions + { + public DnsResolverOptions() { } + public System.Collections.Generic.IList Servers { get { throw null; } set { } } + } + public readonly partial struct DnsResult + { + private readonly T _dummyT; + private readonly object _dummy; + private readonly int _dummyPrimitive; + [System.CLSCompliantAttribute(false)] + public System.Net.DnsResponseCode ResponseCode { get { throw null; } } + public System.Collections.Generic.IReadOnlyList Records { get { throw null; } } + public System.TimeSpan NegativeCacheTtl { get { throw null; } } + } + [System.CLSCompliantAttribute(false)] + public enum DnsResponseCode : ushort + { + NoError = 0, + FormatError = 1, + ServerFailure = 2, + NxDomain = 3, + NotImplemented = 4, + Refused = 5, + } + public readonly partial struct AddressRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public System.Net.IPAddress Address { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + } + public readonly partial struct SrvRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public string Target { get { throw null; } } + [System.CLSCompliantAttribute(false)] + public ushort Port { get { throw null; } } + [System.CLSCompliantAttribute(false)] + public ushort Priority { get { throw null; } } + [System.CLSCompliantAttribute(false)] + public ushort Weight { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + public System.Collections.Generic.IReadOnlyList Addresses { get { throw null; } } + } + public readonly partial struct MxRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public string Exchange { get { throw null; } } + [System.CLSCompliantAttribute(false)] + public ushort Preference { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + } + public readonly partial struct TxtRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public System.Collections.Generic.IReadOnlyList Values { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + } + public readonly partial struct CNameRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public string CanonicalName { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + } + public readonly partial struct PtrRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public string Name { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + } + public readonly partial struct NsRecord + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public string Name { get { throw null; } } + public System.TimeSpan Ttl { get { throw null; } } + } } diff --git a/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx b/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx index ed124526d86fbe..de6f24bdacdd75 100644 --- a/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx +++ b/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx @@ -75,4 +75,7 @@ System.Net.NameResolution is not supported on this platform. + + Specifying a DNS server port other than 53 requires Windows 11 Build 22000 or later. + \ No newline at end of file diff --git a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj index 47439bf44a3f63..89da5be9e94bf0 100644 --- a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj +++ b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj @@ -15,6 +15,12 @@ + + + + + + @@ -37,6 +43,13 @@ + + + + + @@ -76,6 +89,7 @@ + + + s_defaultResolver ??= new DnsResolver(); + + public static DnsResult ResolveAddresses(string name) + => DefaultResolver.ResolveAddresses(name); + + public static DnsResult ResolveAddresses(string name, AddressFamily addressFamily) + => DefaultResolver.ResolveAddresses(name, addressFamily); + + public static Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveAddressesAsync(name, cancellationToken); + + public static Task> ResolveAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveAddressesAsync(name, addressFamily, cancellationToken); + + public static DnsResult ResolveSrv(string name) + => DefaultResolver.ResolveSrv(name); + + public static Task> ResolveSrvAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveSrvAsync(name, cancellationToken); + + public static DnsResult ResolveMx(string name) + => DefaultResolver.ResolveMx(name); + + public static Task> ResolveMxAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveMxAsync(name, cancellationToken); + + public static DnsResult ResolveTxt(string name) + => DefaultResolver.ResolveTxt(name); + + public static Task> ResolveTxtAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveTxtAsync(name, cancellationToken); + + public static DnsResult ResolveCName(string name) + => DefaultResolver.ResolveCName(name); + + public static Task> ResolveCNameAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveCNameAsync(name, cancellationToken); + + public static DnsResult ResolvePtr(string name) + => DefaultResolver.ResolvePtr(name); + + public static DnsResult ResolvePtr(IPAddress address) + => DefaultResolver.ResolvePtrAsync(address).GetAwaiter().GetResult(); + + public static Task> ResolvePtrAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolvePtrAsync(name, cancellationToken); + + public static Task> ResolvePtrAsync(IPAddress address, CancellationToken cancellationToken = default) + => DefaultResolver.ResolvePtrAsync(address, cancellationToken); + + public static DnsResult ResolveNs(string name) + => DefaultResolver.ResolveNs(name); + + public static Task> ResolveNsAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveNsAsync(name, cancellationToken); + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs index fdac620bbf15df..a46d27f0d2eac2 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs @@ -13,7 +13,7 @@ namespace System.Net { /// Provides simple domain name resolution functionality. - public static class Dns + public static partial class Dns { /// Gets the host name of the local machine. public static string GetHostName() diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecords.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecords.cs new file mode 100644 index 00000000000000..dd31abed2b1e27 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecords.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace System.Net +{ + /// An A or AAAA record resolved from DNS, with TTL. + public readonly struct AddressRecord + { + public IPAddress Address { get; } + public TimeSpan Ttl { get; } + + internal AddressRecord(IPAddress address, TimeSpan ttl) + { + Address = address; + Ttl = ttl; + } + } + + /// An SRV record (RFC 2782) with optional inlined address records from the additional section. + public readonly struct SrvRecord + { + private readonly IReadOnlyList? _addresses; + + public string Target { get; } + [CLSCompliant(false)] + public ushort Port { get; } + [CLSCompliant(false)] + public ushort Priority { get; } + [CLSCompliant(false)] + public ushort Weight { get; } + public TimeSpan Ttl { get; } + public IReadOnlyList Addresses => _addresses ?? Array.Empty(); + + internal SrvRecord(string target, ushort port, ushort priority, ushort weight, TimeSpan ttl, IReadOnlyList? addresses) + { + Target = target; + Port = port; + Priority = priority; + Weight = weight; + Ttl = ttl; + _addresses = addresses; + } + } + + /// An MX record (RFC 1035 §3.3.9). + public readonly struct MxRecord + { + public string Exchange { get; } + [CLSCompliant(false)] + public ushort Preference { get; } + public TimeSpan Ttl { get; } + + internal MxRecord(string exchange, ushort preference, TimeSpan ttl) + { + Exchange = exchange; + Preference = preference; + Ttl = ttl; + } + } + + /// A TXT record (RFC 1035 §3.3.14). One record may carry multiple character-strings. + public readonly struct TxtRecord + { + private readonly IReadOnlyList? _values; + + public IReadOnlyList Values => _values ?? Array.Empty(); + public TimeSpan Ttl { get; } + + internal TxtRecord(IReadOnlyList values, TimeSpan ttl) + { + _values = values; + Ttl = ttl; + } + } + + /// A CNAME record (RFC 1035 §3.3.1). + public readonly struct CNameRecord + { + public string CanonicalName { get; } + public TimeSpan Ttl { get; } + + internal CNameRecord(string canonicalName, TimeSpan ttl) + { + CanonicalName = canonicalName; + Ttl = ttl; + } + } + + /// A PTR record (RFC 1035 §3.3.12). + public readonly struct PtrRecord + { + public string Name { get; } + public TimeSpan Ttl { get; } + + internal PtrRecord(string name, TimeSpan ttl) + { + Name = name; + Ttl = ttl; + } + } + + /// An NS record (RFC 1035 §3.3.11). + public readonly struct NsRecord + { + public string Name { get; } + public TimeSpan Ttl { get; } + + internal NsRecord(string name, TimeSpan ttl) + { + Name = name; + Ttl = ttl; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs new file mode 100644 index 00000000000000..0e3e4543ae763c --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable CA1822 // Members do not access instance data and can be marked as static — but we keep them as instance to match the partial signatures on platforms that do implement DNS. + +namespace System.Net +{ + public sealed partial class DnsResolver + { + private Task> ResolveAddressesCoreAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private Task> ResolveSrvCoreAsync(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private Task> ResolveMxCoreAsync(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private Task> ResolveTxtCoreAsync(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private Task> ResolveCNameCoreAsync(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private Task> ResolvePtrCoreAsync(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private Task> ResolveNsCoreAsync(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs new file mode 100644 index 00000000000000..f7b8727f39e370 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs @@ -0,0 +1,327 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + public sealed partial class DnsResolver + { + // ---- Public Resolve*Core methods (called from cross-platform DnsResolver) ---- + + private async Task> ResolveAddressesCoreAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken) + { + if (addressFamily == AddressFamily.Unspecified) + { + // Issue A and AAAA in parallel; merge results. + Task> aTask = QueryAddressesAsync(name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); + Task> aaaaTask = QueryAddressesAsync(name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); + DnsResult aRes = await aTask.ConfigureAwait(false); + DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + + ushort qtype = addressFamily switch + { + AddressFamily.InterNetwork => Interop.Dnsapi.DNS_TYPE_A, + AddressFamily.InterNetworkV6 => Interop.Dnsapi.DNS_TYPE_AAAA, + _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + }; + return await QueryAddressesAsync(name, qtype, cancellationToken).ConfigureAwait(false); + } + + private async Task> ResolveSrvCoreAsync(string name, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = await DnsQueryExAsync(name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); + try + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + // Gather additional-section A/AAAA records by name so we can attach them. + Dictionary>? glue = null; + ParseAdditionalAddresses(raw.RecordsHead, ref glue); + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SRV && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + Interop.Dnsapi.DNS_SRV_DATA data = Marshal.PtrToStructure(dataPtr); + string target = PtrToString(data.pNameTarget) ?? string.Empty; + IReadOnlyList? attached = null; + if (glue != null && glue.TryGetValue(target, out List? list)) + { + attached = list; + } + records.Add(new SrvRecord(target, data.wPort, data.wPriority, data.wWeight, TimeSpan.FromSeconds(hdr.dwTtl), attached)); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + finally + { + raw.Dispose(); + } + } + + private Task> ResolveMxCoreAsync(string name, CancellationToken cancellationToken) + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_MX_DATA data = Marshal.PtrToStructure(dataPtr); + return new MxRecord(PtrToString(data.pNameExchange) ?? string.Empty, data.wPreference, TimeSpan.FromSeconds(hdr.dwTtl)); + }); + + private Task> ResolveCNameCoreAsync(string name, CancellationToken cancellationToken) + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_CNAME_DATA data = Marshal.PtrToStructure(dataPtr); + return new CNameRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }); + + private Task> ResolvePtrCoreAsync(string name, CancellationToken cancellationToken) + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_PTR_DATA data = Marshal.PtrToStructure(dataPtr); + return new PtrRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }); + + private Task> ResolveNsCoreAsync(string name, CancellationToken cancellationToken) + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_NS_DATA data = Marshal.PtrToStructure(dataPtr); + return new NsRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }); + + private async Task> ResolveTxtCoreAsync(string name, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = await DnsQueryExAsync(name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); + try + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_TEXT && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + // DNS_TXT_DATA: uint dwStringCount; followed by array of PCWSTR. + uint count = (uint)Marshal.ReadInt32(dataPtr); + int ptrSize = IntPtr.Size; + IntPtr stringsPtr = dataPtr + sizeof(uint); + if (ptrSize > sizeof(uint)) + { + // Round up to pointer alignment. + long aligned = ((long)stringsPtr + (ptrSize - 1)) & ~(long)(ptrSize - 1); + stringsPtr = checked((nint)aligned); + } + string[] values = new string[count]; + for (int i = 0; i < count; i++) + { + IntPtr strPtr = Marshal.ReadIntPtr(stringsPtr, i * ptrSize); + values[i] = PtrToString(strPtr) ?? string.Empty; + } + records.Add(new TxtRecord(values, TimeSpan.FromSeconds(hdr.dwTtl))); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + finally + { + raw.Dispose(); + } + } + + // ---- Helpers for address parsing ---- + + private async Task> QueryAddressesAsync(string name, ushort qtype, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = await DnsQueryExAsync(name, qtype, cancellationToken).ConfigureAwait(false); + try + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) + { + records.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); + } + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + finally + { + raw.Dispose(); + } + } + + private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) + { + if (a.Records.Count > 0 || b.Records.Count > 0) + { + AddressRecord[] merged = new AddressRecord[a.Records.Count + b.Records.Count]; + int idx = 0; + for (int i = 0; i < a.Records.Count; i++) merged[idx++] = a.Records[i]; + for (int i = 0; i < b.Records.Count; i++) merged[idx++] = b.Records[i]; + return new DnsResult(DnsResponseCode.NoError, merged, TimeSpan.Zero); + } + + DnsResponseCode chosenRc = a.ResponseCode == DnsResponseCode.NxDomain || b.ResponseCode == DnsResponseCode.NxDomain + ? DnsResponseCode.NxDomain + : (a.ResponseCode != DnsResponseCode.NoError ? a.ResponseCode : b.ResponseCode); + TimeSpan negTtl = a.NegativeCacheTtl > TimeSpan.Zero ? a.NegativeCacheTtl : b.NegativeCacheTtl; + return new DnsResult(chosenRc, null, negTtl); + } + + private static bool TryParseAddress(ushort recordType, IntPtr dataPtr, out IPAddress? address) + { + if (recordType == Interop.Dnsapi.DNS_TYPE_A) + { + uint ip = (uint)Marshal.ReadInt32(dataPtr); + address = new IPAddress(ip); + return true; + } + if (recordType == Interop.Dnsapi.DNS_TYPE_AAAA) + { + byte[] bytes = new byte[16]; + Marshal.Copy(dataPtr, bytes, 0, 16); + address = new IPAddress(bytes); + return true; + } + address = null; + return false; + } + + private static void ParseAdditionalAddresses(IntPtr head, ref Dictionary>? glue) + { + for (IntPtr cur = head; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + bool isAddress = hdr.wType == Interop.Dnsapi.DNS_TYPE_A || hdr.wType == Interop.Dnsapi.DNS_TYPE_AAAA; + if (section == Interop.Dnsapi.DNSREC_ADDITIONAL && isAddress) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) + { + string name = PtrToString(hdr.pName) ?? string.Empty; + glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!glue.TryGetValue(name, out List? list)) + { + list = new List(); + glue[name] = list; + } + list.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); + } + } + cur = hdr.pNext; + } + } + + // ---- Generic single-record-type parser ---- + + private async Task> QuerySimpleAsync(string name, ushort qtype, CancellationToken cancellationToken, + Func selector) + { + DnsQueryRawResult raw = await DnsQueryExAsync(name, qtype, cancellationToken).ConfigureAwait(false); + try + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + records.Add(selector(hdr, dataPtr)); + } + cur = hdr.pNext; + } + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + finally + { + raw.Dispose(); + } + } + + // ---- Core DnsQueryEx async wrapper ---- + + private unsafe Task DnsQueryExAsync(string name, ushort queryType, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + DnsQueryAsyncState state = new DnsQueryAsyncState(_options.Servers, name, queryType, cancellationToken); + return state.StartAsync(); + } + + private static unsafe string? PtrToString(IntPtr p) => + p == IntPtr.Zero ? null : Marshal.PtrToStringUni(p); + + // ---- Raw query result returned by the low-level helper ---- + + private readonly struct DnsQueryRawResult : IDisposable + { + public DnsResponseCode ResponseCode { get; } + public IntPtr RecordsHead { get; } + public TimeSpan NegativeCacheTtl { get; } + + public DnsQueryRawResult(DnsResponseCode responseCode, IntPtr recordsHead, TimeSpan negativeCacheTtl) + { + ResponseCode = responseCode; + RecordsHead = recordsHead; + NegativeCacheTtl = negativeCacheTtl; + } + + public void Dispose() + { + if (RecordsHead != IntPtr.Zero) + { + Interop.Dnsapi.DnsFree(RecordsHead, Interop.Dnsapi.DnsFreeRecordList); + } + } + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs new file mode 100644 index 00000000000000..07eec69db4308a --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs @@ -0,0 +1,419 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + public sealed partial class DnsResolver + { + // Win11 build 22000 introduced DNS_QUERY_REQUEST3 (with pCustomServers). + private static readonly bool s_supportsCustomServers = + OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000); + + // Cached callback so we don't allocate a new delegate per query. + private static readonly Interop.Dnsapi.DnsQueryCompletionRoutine s_completionCallback = QueryCompletionCallback; + private static readonly IntPtr s_completionCallbackPtr = + Marshal.GetFunctionPointerForDelegate(s_completionCallback); + + /// + /// Holds the unmanaged state for a single DnsQueryEx invocation, including + /// the request/result/cancel structures, the pinned query name, and the + /// completion TaskCompletionSource. + /// + private sealed unsafe class DnsQueryAsyncState + { + private readonly TaskCompletionSource _tcs = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly string _name; + private readonly ushort _queryType; + private readonly CancellationToken _cancellationToken; + private readonly IList _servers; + + private GCHandle _selfHandle; + private IntPtr _namePtr; + private IntPtr _requestPtr; + private IntPtr _resultPtr; + private IntPtr _cancelPtr; + private IntPtr _serverListPtr; // DNS_ADDR_ARRAY (v1) buffer + private IntPtr _customServersPtr; // DNS_CUSTOM_SERVER[] (v3) buffer + private CancellationTokenRegistration _ctReg; + private int _completed; // 0 = pending, 1 = completed (callback or sync) + + public DnsQueryAsyncState(IList servers, string name, ushort queryType, CancellationToken cancellationToken) + { + _servers = servers; + _name = name; + _queryType = queryType; + _cancellationToken = cancellationToken; + } + + public Task StartAsync() + { + bool needsV3 = false; + if (_servers is { Count: > 0 }) + { + foreach (IPEndPoint ep in _servers) + { + if (ep.Port != 0 && ep.Port != 53) + { + needsV3 = true; + break; + } + } + } + + if (needsV3 && !s_supportsCustomServers) + { + throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); + } + + bool useV3 = needsV3 && s_supportsCustomServers; + + try + { + _namePtr = Marshal.StringToHGlobalUni(_name); + _resultPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); + NativeMemory.Clear((void*)_resultPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); + Interop.Dnsapi.DNS_QUERY_RESULT* result = (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr; + result->Version = useV3 ? Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION3 : Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + + _cancelPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); + NativeMemory.Clear((void*)_cancelPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); + + _selfHandle = GCHandle.Alloc(this); + + int status; + if (useV3) + { + _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST3)); + NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST3)); + Interop.Dnsapi.DNS_QUERY_REQUEST3* req = (Interop.Dnsapi.DNS_QUERY_REQUEST3*)_requestPtr; + req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION3; + req->QueryName = _namePtr; + req->QueryType = _queryType; + req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; + req->InterfaceIndex = 0; + req->pQueryCompletionCallback = s_completionCallbackPtr; + req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); + + BuildCustomServers(_servers, out _customServersPtr, out uint count); + req->cCustomServers = count; + req->pCustomServers = (Interop.Dnsapi.DNS_CUSTOM_SERVER*)_customServersPtr; + + status = Interop.Dnsapi.DnsQueryEx( + (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, + (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, + (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); + } + else + { + _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); + NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); + Interop.Dnsapi.DNS_QUERY_REQUEST* req = (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr; + req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + req->QueryName = _namePtr; + req->QueryType = _queryType; + req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; + req->InterfaceIndex = 0; + req->pQueryCompletionCallback = s_completionCallbackPtr; + req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); + + if (_servers is { Count: > 0 }) + { + BuildAddrArray(_servers, out _serverListPtr); + req->pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)_serverListPtr; + } + + status = Interop.Dnsapi.DnsQueryEx( + (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, + (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, + (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); + } + + if (status == Interop.Dnsapi.DNS_REQUEST_PENDING) + { + // Async. Register cancellation; the callback will free resources and complete the TCS. + if (_cancellationToken.CanBeCanceled) + { + _ctReg = _cancellationToken.UnsafeRegister(static (s, _) => + { + DnsQueryAsyncState st = (DnsQueryAsyncState)s!; + st.CancelAndAbort(); + }, this); + } + } + else + { + // Synchronous completion. The callback was NOT invoked; we complete inline. + CompleteFromResult(status); + } + } + catch + { + FreeAll(); + throw; + } + + return _tcs.Task; + } + + private void CancelAndAbort() + { + if (_cancelPtr != IntPtr.Zero) + { + Interop.Dnsapi.DnsCancelQuery((Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); + } + } + + /// + /// Invoked from either the native callback or the sync completion path. + /// Parses the QueryStatus and pQueryRecords from the result struct, + /// completes the TCS, and frees state. + /// + internal void CompleteFromResult(int status) + { + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + + try + { + _ctReg.Dispose(); + + Interop.Dnsapi.DNS_QUERY_RESULT result = Marshal.PtrToStructure(_resultPtr); + IntPtr records = result.pQueryRecords; + + if (_cancellationToken.IsCancellationRequested) + { + if (records != IntPtr.Zero) + { + Interop.Dnsapi.DnsFree(records, Interop.Dnsapi.DnsFreeRecordList); + } + _tcs.TrySetCanceled(_cancellationToken); + return; + } + + DnsResponseCode rc = MapWindowsErrorToResponseCode(status); + + // For NXDOMAIN/NODATA, try to extract the negative-cache TTL from the + // SOA in the authority section if it's present in the record list. + TimeSpan negativeTtl = TimeSpan.Zero; + if (rc != DnsResponseCode.NoError || records == IntPtr.Zero) + { + negativeTtl = ExtractNegativeCacheTtl(records); + } + + _tcs.TrySetResult(new DnsQueryRawResult(rc, records, negativeTtl)); + } + catch (Exception ex) + { + _tcs.TrySetException(ex); + } + finally + { + FreeAll(); + } + } + + private void FreeAll() + { + if (_namePtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_namePtr); + _namePtr = IntPtr.Zero; + } + if (_requestPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_requestPtr); + _requestPtr = IntPtr.Zero; + } + if (_resultPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_resultPtr); + _resultPtr = IntPtr.Zero; + } + if (_cancelPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_cancelPtr); + _cancelPtr = IntPtr.Zero; + } + if (_serverListPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_serverListPtr); + _serverListPtr = IntPtr.Zero; + } + if (_customServersPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_customServersPtr); + _customServersPtr = IntPtr.Zero; + } + if (_selfHandle.IsAllocated) + { + _selfHandle.Free(); + } + } + } + + // Native callback. Marshaled to a function pointer once at startup. + // We use a managed delegate (no UnmanagedCallersOnly) because callers + // currently pass it via Marshal.GetFunctionPointerForDelegate. + private static void QueryCompletionCallback(IntPtr pQueryContext, IntPtr pQueryResults) + { + try + { + GCHandle handle = GCHandle.FromIntPtr(pQueryContext); + DnsQueryAsyncState? state = handle.Target as DnsQueryAsyncState; + if (state == null) + { + return; + } + + // pQueryResults points to the same DNS_QUERY_RESULT we passed in. + unsafe + { + Interop.Dnsapi.DNS_QUERY_RESULT* res = (Interop.Dnsapi.DNS_QUERY_RESULT*)pQueryResults; + state.CompleteFromResult(res->QueryStatus); + } + } + catch + { + // Swallow — never allow exceptions to propagate into native code. + } + } + + private static unsafe void BuildAddrArray(IList servers, out IntPtr arrayPtr) + { + int count = servers.Count; + int headerSize = sizeof(Interop.Dnsapi.DNS_ADDR_ARRAY); + int addrSize = sizeof(Interop.Dnsapi.DNS_ADDR); + int totalSize = headerSize + addrSize * count; + + arrayPtr = Marshal.AllocHGlobal(totalSize); + NativeMemory.Clear((void*)arrayPtr, (nuint)totalSize); + + Interop.Dnsapi.DNS_ADDR_ARRAY* arr = (Interop.Dnsapi.DNS_ADDR_ARRAY*)arrayPtr; + arr->MaxCount = (uint)count; + arr->AddrCount = (uint)count; + arr->Family = (ushort)(servers[0].AddressFamily == AddressFamily.InterNetwork ? Interop.Dnsapi.AF_INET : Interop.Dnsapi.AF_INET6); + + byte* addrBase = (byte*)arrayPtr + headerSize; + for (int i = 0; i < count; i++) + { + IPEndPoint ep = servers[i]; + byte* sa = addrBase + (i * addrSize); + WriteSockAddr(sa, ep); + } + } + + private static unsafe void BuildCustomServers(IList servers, out IntPtr arrayPtr, out uint count) + { + if (servers is null or { Count: 0 }) + { + arrayPtr = IntPtr.Zero; + count = 0; + return; + } + + int n = servers.Count; + int entrySize = sizeof(Interop.Dnsapi.DNS_CUSTOM_SERVER); + arrayPtr = Marshal.AllocHGlobal(entrySize * n); + NativeMemory.Clear((void*)arrayPtr, (nuint)(entrySize * n)); + + Interop.Dnsapi.DNS_CUSTOM_SERVER* arr = (Interop.Dnsapi.DNS_CUSTOM_SERVER*)arrayPtr; + for (int i = 0; i < n; i++) + { + IPEndPoint ep = servers[i]; + arr[i].dwServerType = Interop.Dnsapi.DNS_CUSTOM_SERVER_TYPE_UDP; + arr[i].ullFlags = 0; + arr[i].pwszTemplate = IntPtr.Zero; + WriteSockAddr((byte*)&arr[i].ServerAddr[0], ep); + } + count = (uint)n; + } + + // Writes a SOCKADDR_IN or SOCKADDR_IN6 representation into the destination buffer. + // The buffer must be at least 28 bytes (sizeof sockaddr_in6). + private static unsafe void WriteSockAddr(byte* dest, IPEndPoint ep) + { + int port = ep.Port == 0 ? 53 : ep.Port; + if (ep.AddressFamily == AddressFamily.InterNetwork) + { + // sockaddr_in: ushort family, ushort port (net order), uint addr, 8 bytes zero + *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET; + dest[2] = (byte)(port >> 8); + dest[3] = (byte)(port & 0xff); + Span addrBytes = stackalloc byte[4]; + ep.Address.TryWriteBytes(addrBytes, out _); + dest[4] = addrBytes[0]; + dest[5] = addrBytes[1]; + dest[6] = addrBytes[2]; + dest[7] = addrBytes[3]; + // dest[8..15] left zero + } + else if (ep.AddressFamily == AddressFamily.InterNetworkV6) + { + // sockaddr_in6: ushort family, ushort port, uint flowinfo, 16-byte addr, uint scope_id + *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET6; + dest[2] = (byte)(port >> 8); + dest[3] = (byte)(port & 0xff); + // flowinfo (dest[4..7]) left zero + Span addrBytes = stackalloc byte[16]; + ep.Address.TryWriteBytes(addrBytes, out _); + for (int i = 0; i < 16; i++) + { + dest[8 + i] = addrBytes[i]; + } + // scope_id (dest[24..27]) + uint scopeId = (uint)ep.Address.ScopeId; + dest[24] = (byte)(scopeId & 0xff); + dest[25] = (byte)((scopeId >> 8) & 0xff); + dest[26] = (byte)((scopeId >> 16) & 0xff); + dest[27] = (byte)((scopeId >> 24) & 0xff); + } + else + { + throw new ArgumentException(SR.net_invalid_ip_addr); + } + } + + private static DnsResponseCode MapWindowsErrorToResponseCode(int status) => + status switch + { + Interop.Dnsapi.ERROR_SUCCESS => DnsResponseCode.NoError, + Interop.Dnsapi.DNS_INFO_NO_RECORDS => DnsResponseCode.NoError, // NODATA: name exists but no records of requested type + Interop.Dnsapi.DNS_ERROR_RCODE_NAME_ERROR => DnsResponseCode.NxDomain, + Interop.Dnsapi.DNS_ERROR_RCODE_FORMAT_ERROR => DnsResponseCode.FormatError, + Interop.Dnsapi.DNS_ERROR_RCODE_SERVER_FAILURE => DnsResponseCode.ServerFailure, + Interop.Dnsapi.DNS_ERROR_RCODE_NOT_IMPLEMENTED => DnsResponseCode.NotImplemented, + Interop.Dnsapi.DNS_ERROR_RCODE_REFUSED => DnsResponseCode.Refused, + _ => DnsResponseCode.ServerFailure, + }; + + private static TimeSpan ExtractNegativeCacheTtl(IntPtr head) + { + // Walk the record list looking for an SOA in the authority section. + for (IntPtr cur = head; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SOA && section == Interop.Dnsapi.DNSREC_AUTHORITY) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + Interop.Dnsapi.DNS_SOA_DATA soa = Marshal.PtrToStructure(dataPtr); + // RFC 2308 §5: negative cache TTL = min(SOA TTL, SOA MINIMUM) + uint negTtl = Math.Min(hdr.dwTtl, soa.dwDefaultTtl); + return TimeSpan.FromSeconds(negTtl); + } + cur = hdr.pNext; + } + return TimeSpan.Zero; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs new file mode 100644 index 00000000000000..01e78c216e08cc --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + /// + /// Resolves DNS records, optionally using a caller-specified set of DNS servers. + /// + /// + /// When constructed without options, or with empty , + /// the resolver uses the system-configured DNS servers. + /// + public sealed partial class DnsResolver : IAsyncDisposable, IDisposable + { + private readonly DnsResolverOptions _options; + private bool _disposed; + + public DnsResolver() : this(new DnsResolverOptions()) { } + + public DnsResolver(DnsResolverOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + } + + public DnsResult ResolveAddresses(string name) + => ResolveAddressesAsync(name).GetAwaiter().GetResult(); + + public DnsResult ResolveAddresses(string name, AddressFamily addressFamily) + => ResolveAddressesAsync(name, addressFamily).GetAwaiter().GetResult(); + + public DnsResult ResolveSrv(string name) + => ResolveSrvAsync(name).GetAwaiter().GetResult(); + + public DnsResult ResolveMx(string name) + => ResolveMxAsync(name).GetAwaiter().GetResult(); + + public DnsResult ResolveTxt(string name) + => ResolveTxtAsync(name).GetAwaiter().GetResult(); + + public DnsResult ResolveCName(string name) + => ResolveCNameAsync(name).GetAwaiter().GetResult(); + + public DnsResult ResolvePtr(string name) + => ResolvePtrAsync(name).GetAwaiter().GetResult(); + + public DnsResult ResolveNs(string name) + => ResolveNsAsync(name).GetAwaiter().GetResult(); + + public Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) + => ResolveAddressesAsync(name, AddressFamily.Unspecified, cancellationToken); + + public Task> ResolveAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveAddressesCoreAsync(name, addressFamily, cancellationToken); + } + + public Task> ResolveSrvAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveSrvCoreAsync(name, cancellationToken); + } + + public Task> ResolveMxAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveMxCoreAsync(name, cancellationToken); + } + + public Task> ResolveTxtAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveTxtCoreAsync(name, cancellationToken); + } + + public Task> ResolveCNameAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveCNameCoreAsync(name, cancellationToken); + } + + public Task> ResolvePtrAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolvePtrCoreAsync(name, cancellationToken); + } + + public Task> ResolvePtrAsync(IPAddress address, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolvePtrCoreAsync(BuildArpaName(address), cancellationToken); + } + + public Task> ResolveNsAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveNsCoreAsync(name, cancellationToken); + } + + public void Dispose() => _disposed = true; + + public ValueTask DisposeAsync() + { + _disposed = true; + return ValueTask.CompletedTask; + } + + private static void ValidateName(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + } + + /// + /// Builds the reverse-lookup .arpa domain name for an IPv4 or IPv6 address. + /// + internal static unsafe string BuildArpaName(IPAddress address) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + Span bytes = stackalloc byte[4]; + address.TryWriteBytes(bytes, out _); + return string.Create(System.Globalization.CultureInfo.InvariantCulture, $"{bytes[3]}.{bytes[2]}.{bytes[1]}.{bytes[0]}.in-addr.arpa"); + } + else if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + Span bytes = stackalloc byte[16]; + address.TryWriteBytes(bytes, out _); + Span chars = stackalloc char[32 * 2 + 9]; + int pos = 0; + for (int i = 15; i >= 0; i--) + { + byte b = bytes[i]; + chars[pos++] = ToHex(b & 0xF); + chars[pos++] = '.'; + chars[pos++] = ToHex(b >> 4); + chars[pos++] = '.'; + } + "ip6.arpa".AsSpan().CopyTo(chars.Slice(pos)); + pos += "ip6.arpa".Length; + return new string(chars.Slice(0, pos)); + + static char ToHex(int n) => (char)(n < 10 ? '0' + n : 'a' + (n - 10)); + } + else + { + throw new ArgumentException(SR.net_invalid_ip_addr, nameof(address)); + } + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverOptions.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverOptions.cs new file mode 100644 index 00000000000000..d9bb6c9dffbf6b --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverOptions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace System.Net +{ + /// + /// Options controlling DNS resolution performed by . + /// + public sealed class DnsResolverOptions + { + /// + /// DNS servers to query. When empty, the system-configured DNS servers are used. + /// + public IList Servers { get; set; } = new List(); + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResponseCode.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResponseCode.cs new file mode 100644 index 00000000000000..9b2b99f3d91e56 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResponseCode.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Net +{ + /// DNS response codes as defined in RFC 1035 (and updates). + [CLSCompliant(false)] + public enum DnsResponseCode : ushort + { + /// No error condition. + NoError = 0, + /// The name server was unable to interpret the query. + FormatError = 1, + /// The name server was unable to process this query due to a problem with the name server. + ServerFailure = 2, + /// The domain name referenced in the query does not exist (NXDOMAIN). + NxDomain = 3, + /// The name server does not support the requested kind of query. + NotImplemented = 4, + /// The name server refuses to perform the specified operation for policy reasons. + Refused = 5, + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResult.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResult.cs new file mode 100644 index 00000000000000..702445b4166d65 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResult.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace System.Net +{ + /// + /// Carries the result of a DNS resolution operation, including the response + /// code, the parsed records, and (for negative responses) the negative-cache TTL. + /// + public readonly struct DnsResult + { + private readonly IReadOnlyList? _records; + + /// The DNS response code returned by the server. + [CLSCompliant(false)] + public DnsResponseCode ResponseCode { get; } + + /// + /// The records returned by the server. Empty on error or NODATA responses. + /// + public IReadOnlyList Records => _records ?? Array.Empty(); + + /// + /// For negative responses (NXDOMAIN/NODATA), the TTL for which the negative + /// answer may be cached (derived from the SOA minimum TTL in the authority + /// section, per RFC 2308 §5). if not applicable + /// or unavailable. + /// + public TimeSpan NegativeCacheTtl { get; } + + internal DnsResult(DnsResponseCode responseCode, IReadOnlyList? records, TimeSpan negativeCacheTtl) + { + ResponseCode = responseCode; + _records = records; + NegativeCacheTtl = negativeCacheTtl; + } + } +} From 77dd68ceab76e7db05f59e32b440f0cbaa45ac06 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Wed, 3 Jun 2026 15:45:11 +0200 Subject: [PATCH 02/10] Add DnsResolver tests Adds argument-validation tests and Windows-only OuterLoop network tests for the new DnsResolver / Dns.Resolve* APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/FunctionalTests/DnsResolverTest.cs | 228 ++++++++++++++++++ ...Net.NameResolution.Functional.Tests.csproj | 1 + 2 files changed, 229 insertions(+) create mode 100644 src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs new file mode 100644 index 00000000000000..8307518af9ee3e --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -0,0 +1,228 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace System.Net.NameResolution.Tests +{ + // Tests for the new DnsResolver / Dns.Resolve* APIs. + // Network tests are individually marked with [OuterLoop]. + public class DnsResolverTest + { + private const string TestHost = "microsoft.com"; + private const string TestSrv = "_sip._tls.microsoft.com"; // SRV record for SIP discovery + private const string TestMxHost = "microsoft.com"; + private const string TestTxtHost = "microsoft.com"; + private const string TestCNameHost = "www.microsoft.com"; + private const string TestNsHost = "microsoft.com"; + private const string NonExistentHost = "this-name-definitely-does-not-exist.dotnet-test.invalid"; + + // ---- Cross-platform argument-validation tests ---- + + [Fact] + public void DnsResolver_Construct_NullOptions_Throws() + { + Assert.Throws(() => new DnsResolver(null!)); + } + + [Fact] + public void DnsResolver_Construct_DefaultOptions_DoesNotThrow() + { + using DnsResolver r = new DnsResolver(); + Assert.NotNull(r); + } + + [Fact] + public async Task DnsResolver_NullName_Throws() + { + using DnsResolver r = new DnsResolver(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(null!)); + await Assert.ThrowsAsync(() => r.ResolveSrvAsync(null!)); + await Assert.ThrowsAsync(() => r.ResolveMxAsync(null!)); + await Assert.ThrowsAsync(() => r.ResolveTxtAsync(null!)); + await Assert.ThrowsAsync(() => r.ResolveCNameAsync(null!)); + await Assert.ThrowsAsync(() => r.ResolvePtrAsync((string)null!)); + await Assert.ThrowsAsync(() => r.ResolvePtrAsync((IPAddress)null!)); + await Assert.ThrowsAsync(() => r.ResolveNsAsync(null!)); + } + + [Fact] + public async Task DnsResolver_EmptyName_Throws() + { + using DnsResolver r = new DnsResolver(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(string.Empty)); + } + + [Fact] + public async Task DnsResolver_Disposed_Throws() + { + DnsResolver r = new DnsResolver(); + r.Dispose(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost)); + await Assert.ThrowsAsync(() => r.ResolveSrvAsync(TestSrv)); + await Assert.ThrowsAsync(() => r.ResolveMxAsync(TestMxHost)); + } + + [Fact] + public async Task DnsResolver_DisposeAsync_ThrowsOnUse() + { + DnsResolver r = new DnsResolver(); + await r.DisposeAsync(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost)); + } + + [Fact] + public async Task DnsResolver_PreCanceledToken_ReturnsCanceled() + { + using DnsResolver r = new DnsResolver(); + CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost, cts.Token)); + } + + // ---- Windows network tests (require outbound DNS) ---- + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveAddresses_KnownName_ReturnsRecords() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveAddressesAsync(TestHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (AddressRecord rec in result.Records) + { + Assert.NotNull(rec.Address); + Assert.True(rec.Address.AddressFamily == AddressFamily.InterNetwork || rec.Address.AddressFamily == AddressFamily.InterNetworkV6); + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveAddresses_IPv4Only_ReturnsOnlyIPv4() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveAddressesAsync(TestHost, AddressFamily.InterNetwork); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + foreach (AddressRecord rec in result.Records) + { + Assert.Equal(AddressFamily.InterNetwork, rec.Address.AddressFamily); + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveAddresses_NonExistent_ReturnsNxDomain() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveAddressesAsync(NonExistentHost); + Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); + Assert.Empty(result.Records); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveMx_KnownName_ReturnsRecords() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveMxAsync(TestMxHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (MxRecord rec in result.Records) + { + Assert.False(string.IsNullOrEmpty(rec.Exchange)); + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveTxt_KnownName_ReturnsRecords() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveTxtAsync(TestTxtHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (TxtRecord rec in result.Records) + { + Assert.NotEmpty(rec.Values); + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveCName_KnownName_ReturnsRecord() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveCNameAsync(TestCNameHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + // CNAME may or may not exist for the target; at minimum the call should succeed. + if (result.Records.Count > 0) + { + Assert.False(string.IsNullOrEmpty(result.Records[0].CanonicalName)); + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolveNs_KnownName_ReturnsRecords() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolveNsAsync(TestNsHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (NsRecord rec in result.Records) + { + Assert.False(string.IsNullOrEmpty(rec.Name)); + } + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolvePtr_ByIPAddress_ReturnsRecord() + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await r.ResolvePtrAsync(IPAddress.Parse("8.8.8.8")); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + Assert.False(string.IsNullOrEmpty(result.Records[0].Name)); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task Static_Dns_ResolveAddressesAsync_Works() + { + DnsResult result = await Dns.ResolveAddressesAsync(TestHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task DnsResolver_CustomServer_Port53_Works() + { + DnsResolverOptions opts = new DnsResolverOptions + { + Servers = { new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53) } + }; + using DnsResolver r = new DnsResolver(opts); + DnsResult result = await r.ResolveAddressesAsync(TestHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + } + + // ---- Reverse-arpa name building (covers both IPv4 and IPv6 paths used by ResolvePtr(IPAddress)) ---- + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [OuterLoop] + public async Task ResolvePtr_IPv6Address_DoesNotThrow() + { + using DnsResolver r = new DnsResolver(); + // Google public DNS IPv6 — call shouldn't throw, even if no PTR record exists. + DnsResult result = await r.ResolvePtrAsync(IPAddress.Parse("2001:4860:4860::8888")); + Assert.True(result.ResponseCode == DnsResponseCode.NoError || result.ResponseCode == DnsResponseCode.NxDomain); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj index 9741c993ccdba5..bc260f9daf3b20 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj @@ -9,6 +9,7 @@ + From f0ef4af53d4a1361570b9d102da29484ee2899df Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 4 Jun 2026 18:24:17 +0200 Subject: [PATCH 03/10] Add loopback DNS test suite and restrict DnsQueryEx custom servers to port 53 DnsQueryEx only contacts custom DNS servers on the standard port 53 and requires the sockaddr port field to be 0; any non-zero port (even 53) is rejected with ERROR_INVALID_PARAMETER. Simplify the Windows resolver to always use the v1 DNS_QUERY_REQUEST path, write sockaddr port 0, and throw PlatformNotSupportedException for server endpoints requesting a non-default port. Remove the now-unused v3 custom-server code path. Add an in-process loopback DNS server (bound to 127.0.0.1:53, skipped when the port is unavailable) and a comprehensive behavioral test suite covering address/SRV/MX/TXT/CNAME/PTR/NS resolution, NXDOMAIN vs NODATA, TTLs, SRV additional-address glue, port-0 acceptance, and in-flight cancellation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Windows/Dnsapi/Interop.DnsTypes.cs | 2 + .../System/Net/DnsResolver.WindowsAsync.cs | 129 ++--- .../DnsResolverLoopbackTest.cs | 443 ++++++++++++++++++ .../tests/FunctionalTests/DnsResolverTest.cs | 12 + .../FunctionalTests/DnsResponseBuilder.cs | 243 ++++++++++ .../FunctionalTests/LoopbackDnsServer.cs | 275 +++++++++++ ...Net.NameResolution.Functional.Tests.csproj | 3 + 7 files changed, 1010 insertions(+), 97 deletions(-) create mode 100644 src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResponseBuilder.cs create mode 100644 src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs diff --git a/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs index cf92e3e0307a29..db3519b1c5ef8b 100644 --- a/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs +++ b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs @@ -34,6 +34,8 @@ internal unsafe struct DNS_QUERY_REQUEST3 public uint InterfaceIndex; public IntPtr pQueryCompletionCallback; public IntPtr pQueryContext; + public int IsNetworkQueryRequired; // BOOL + public uint RequiredNetworkIndex; public uint cCustomServers; public DNS_CUSTOM_SERVER* pCustomServers; } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs index 07eec69db4308a..86e68a2f26c6ad 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs @@ -12,10 +12,6 @@ namespace System.Net { public sealed partial class DnsResolver { - // Win11 build 22000 introduced DNS_QUERY_REQUEST3 (with pCustomServers). - private static readonly bool s_supportsCustomServers = - OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000); - // Cached callback so we don't allocate a new delegate per query. private static readonly Interop.Dnsapi.DnsQueryCompletionRoutine s_completionCallback = QueryCompletionCallback; private static readonly IntPtr s_completionCallbackPtr = @@ -40,8 +36,7 @@ private sealed unsafe class DnsQueryAsyncState private IntPtr _requestPtr; private IntPtr _resultPtr; private IntPtr _cancelPtr; - private IntPtr _serverListPtr; // DNS_ADDR_ARRAY (v1) buffer - private IntPtr _customServersPtr; // DNS_CUSTOM_SERVER[] (v3) buffer + private IntPtr _serverListPtr; // DNS_ADDR_ARRAY buffer private CancellationTokenRegistration _ctReg; private int _completed; // 0 = pending, 1 = completed (callback or sync) @@ -55,86 +50,56 @@ public DnsQueryAsyncState(IList servers, string name, ushort queryTy public Task StartAsync() { - bool needsV3 = false; + // DnsQueryEx only supports DNS servers reachable on the standard port 53. + // The sockaddr port field passed to the API must be 0 (the API always + // queries port 53); supplying any non-zero port - even 53 itself - results + // in ERROR_INVALID_PARAMETER. We therefore reject any server endpoint that + // requests a non-default port, since it cannot be honored on Windows. if (_servers is { Count: > 0 }) { foreach (IPEndPoint ep in _servers) { if (ep.Port != 0 && ep.Port != 53) { - needsV3 = true; - break; + throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); } } } - if (needsV3 && !s_supportsCustomServers) - { - throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); - } - - bool useV3 = needsV3 && s_supportsCustomServers; - try { _namePtr = Marshal.StringToHGlobalUni(_name); _resultPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); NativeMemory.Clear((void*)_resultPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); Interop.Dnsapi.DNS_QUERY_RESULT* result = (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr; - result->Version = useV3 ? Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION3 : Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + result->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; _cancelPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); NativeMemory.Clear((void*)_cancelPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); _selfHandle = GCHandle.Alloc(this); - int status; - if (useV3) + _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); + NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); + Interop.Dnsapi.DNS_QUERY_REQUEST* req = (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr; + req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + req->QueryName = _namePtr; + req->QueryType = _queryType; + req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; + req->InterfaceIndex = 0; + req->pQueryCompletionCallback = s_completionCallbackPtr; + req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); + + if (_servers is { Count: > 0 }) { - _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST3)); - NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST3)); - Interop.Dnsapi.DNS_QUERY_REQUEST3* req = (Interop.Dnsapi.DNS_QUERY_REQUEST3*)_requestPtr; - req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION3; - req->QueryName = _namePtr; - req->QueryType = _queryType; - req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; - req->InterfaceIndex = 0; - req->pQueryCompletionCallback = s_completionCallbackPtr; - req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); - - BuildCustomServers(_servers, out _customServersPtr, out uint count); - req->cCustomServers = count; - req->pCustomServers = (Interop.Dnsapi.DNS_CUSTOM_SERVER*)_customServersPtr; - - status = Interop.Dnsapi.DnsQueryEx( - (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, - (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, - (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); + BuildAddrArray(_servers, out _serverListPtr); + req->pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)_serverListPtr; } - else - { - _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); - NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); - Interop.Dnsapi.DNS_QUERY_REQUEST* req = (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr; - req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; - req->QueryName = _namePtr; - req->QueryType = _queryType; - req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; - req->InterfaceIndex = 0; - req->pQueryCompletionCallback = s_completionCallbackPtr; - req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); - - if (_servers is { Count: > 0 }) - { - BuildAddrArray(_servers, out _serverListPtr); - req->pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)_serverListPtr; - } - status = Interop.Dnsapi.DnsQueryEx( - (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, - (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, - (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); - } + int status = Interop.Dnsapi.DnsQueryEx( + (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, + (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, + (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); if (status == Interop.Dnsapi.DNS_REQUEST_PENDING) { @@ -249,11 +214,6 @@ private void FreeAll() Marshal.FreeHGlobal(_serverListPtr); _serverListPtr = IntPtr.Zero; } - if (_customServersPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(_customServersPtr); - _customServersPtr = IntPtr.Zero; - } if (_selfHandle.IsAllocated) { _selfHandle.Free(); @@ -312,43 +272,19 @@ private static unsafe void BuildAddrArray(IList servers, out IntPtr } } - private static unsafe void BuildCustomServers(IList servers, out IntPtr arrayPtr, out uint count) - { - if (servers is null or { Count: 0 }) - { - arrayPtr = IntPtr.Zero; - count = 0; - return; - } - - int n = servers.Count; - int entrySize = sizeof(Interop.Dnsapi.DNS_CUSTOM_SERVER); - arrayPtr = Marshal.AllocHGlobal(entrySize * n); - NativeMemory.Clear((void*)arrayPtr, (nuint)(entrySize * n)); - - Interop.Dnsapi.DNS_CUSTOM_SERVER* arr = (Interop.Dnsapi.DNS_CUSTOM_SERVER*)arrayPtr; - for (int i = 0; i < n; i++) - { - IPEndPoint ep = servers[i]; - arr[i].dwServerType = Interop.Dnsapi.DNS_CUSTOM_SERVER_TYPE_UDP; - arr[i].ullFlags = 0; - arr[i].pwszTemplate = IntPtr.Zero; - WriteSockAddr((byte*)&arr[i].ServerAddr[0], ep); - } - count = (uint)n; - } - // Writes a SOCKADDR_IN or SOCKADDR_IN6 representation into the destination buffer. // The buffer must be at least 28 bytes (sizeof sockaddr_in6). private static unsafe void WriteSockAddr(byte* dest, IPEndPoint ep) { - int port = ep.Port == 0 ? 53 : ep.Port; + // DnsQueryEx always queries DNS servers on port 53 and requires the sockaddr + // port field to be left as 0. Supplying a non-zero port (even 53) is rejected + // with ERROR_INVALID_PARAMETER. Non-default ports are validated and rejected + // earlier in StartAsync, so the port is always written as 0 here. if (ep.AddressFamily == AddressFamily.InterNetwork) { // sockaddr_in: ushort family, ushort port (net order), uint addr, 8 bytes zero *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET; - dest[2] = (byte)(port >> 8); - dest[3] = (byte)(port & 0xff); + // dest[2..3] (port) left zero Span addrBytes = stackalloc byte[4]; ep.Address.TryWriteBytes(addrBytes, out _); dest[4] = addrBytes[0]; @@ -361,8 +297,7 @@ private static unsafe void WriteSockAddr(byte* dest, IPEndPoint ep) { // sockaddr_in6: ushort family, ushort port, uint flowinfo, 16-byte addr, uint scope_id *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET6; - dest[2] = (byte)(port >> 8); - dest[3] = (byte)(port & 0xff); + // dest[2..3] (port) left zero // flowinfo (dest[4..7]) left zero Span addrBytes = stackalloc byte[16]; ep.Address.TryWriteBytes(addrBytes, out _); diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs new file mode 100644 index 00000000000000..e713e60bdda6dc --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -0,0 +1,443 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace System.Net.NameResolution.Tests +{ + // Deterministic DnsResolver tests driven by an in-process loopback DNS server. + // + // On Windows, DnsQueryEx only ever contacts custom DNS servers on the standard + // port 53 (the sockaddr port field must be 0), so the loopback server binds port 53. + // When that port is unavailable (e.g. a local DNS service is already running) the + // tests are skipped via SkipTestException rather than failing. Because the single + // machine-wide port 53 is shared, these tests run sequentially (see the collection). + // + // These tests cover the record-parsing and response-handling behavior that the + // OuterLoop tests in DnsResolverTest.cs cannot exercise deterministically. + [Collection(nameof(DnsLoopbackTestCollection))] + public class DnsResolverLoopbackTest + { + public static bool IsSupported => PlatformDetection.IsWindows; + + private static DnsResolver CreateResolver(LoopbackDnsServer server) + => new DnsResolver(new DnsResolverOptions { Servers = { server.EndPoint } }); + + // Generates a unique multi-label name so neither the OS resolver cache nor a + // previous test run can satisfy the query without reaching the loopback server. + private static string UniqueName(string label) => $"{label}-{Guid.NewGuid():N}.test"; + + // ---- Address resolution ---- + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("host"); + server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); + server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); + + DnsResult result = await resolver.ResolveAddressesAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Equal(2, result.Records.Count); + Assert.Contains(result.Records, a => a.Address.ToString() == "10.0.0.1"); + Assert.Contains(result.Records, a => a.Address.ToString() == "fd00::1"); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("v4"); + server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 2 }, ttl: 300)); + server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); + + DnsResult result = await resolver.ResolveAddressesAsync(name); + + // A succeeds, AAAA returns NXDOMAIN — overall is success because we got addresses. + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + AddressRecord record = Assert.Single(result.Records); + Assert.Equal("10.0.0.2", record.Address.ToString()); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("v6"); + server.AddResponse(name, DnsRecordType.A, b => b.ResponseCode(DnsResponseCode.NxDomain)); + server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); + + DnsResult result = await resolver.ResolveAddressesAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + AddressRecord record = Assert.Single(result.Records); + Assert.Equal("fd00::1", record.Address.ToString()); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("famv4"); + server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 192, 0, 2, 7 }, ttl: 200)); + + DnsResult result = await resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + AddressRecord record = Assert.Single(result.Records); + Assert.Equal("192.0.2.7", record.Address.ToString()); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_HasTtl() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("ttl"); + server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); + server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); + + DnsResult result = await resolver.ResolveAddressesAsync(name); + + AddressRecord record = Assert.Single(result.Records); + // The TTL we sent (120s) should be preserved (custom-server queries bypass the OS cache). + Assert.True(record.Ttl > TimeSpan.Zero && record.Ttl <= TimeSpan.FromSeconds(120), + $"Unexpected TTL: {record.Ttl}"); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("missing"); + byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 120); + server.AddResponse(name, DnsRecordType.A, b => b + .ResponseCode(DnsResponseCode.NxDomain) + .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 120)); + server.AddResponse(name, DnsRecordType.AAAA, b => b + .ResponseCode(DnsResponseCode.NxDomain) + .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 120)); + + DnsResult result = await resolver.ResolveAddressesAsync(name); + + Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); + Assert.Empty(result.Records); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("nodata"); + byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 30); + server.AddResponse(name, DnsRecordType.A, b => b + .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); + server.AddResponse(name, DnsRecordType.AAAA, b => b + .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); + + // The name exists but has no A/AAAA records → NODATA for both queries. + DnsResult result = await resolver.ResolveAddressesAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Empty(result.Records); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string nodataName = UniqueName("nodata"); + byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 30); + server.AddResponse(nodataName, DnsRecordType.A, b => b + .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); + server.AddResponse(nodataName, DnsRecordType.AAAA, b => b + .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); + + string missingName = UniqueName("missing"); + byte[] nxSoaRdata = DnsResponseBuilder.BuildSoaRdata("test", 120); + server.AddResponse(missingName, DnsRecordType.A, b => b + .ResponseCode(DnsResponseCode.NxDomain) + .Authority("test", DnsRecordType.SOA, nxSoaRdata, ttl: 120)); + server.AddResponse(missingName, DnsRecordType.AAAA, b => b + .ResponseCode(DnsResponseCode.NxDomain) + .Authority("test", DnsRecordType.SOA, nxSoaRdata, ttl: 120)); + + DnsResult nodata = await resolver.ResolveAddressesAsync(nodataName); + Assert.Equal(DnsResponseCode.NoError, nodata.ResponseCode); + Assert.Empty(nodata.Records); + + DnsResult nxdomain = await resolver.ResolveAddressesAsync(missingName); + Assert.Equal(DnsResponseCode.NxDomain, nxdomain.ResponseCode); + Assert.Empty(nxdomain.Records); + + Assert.NotEqual(nodata.ResponseCode, nxdomain.ResponseCode); + } + + // ---- SRV ---- + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveSrv_ReturnsRecords() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = $"_http._tcp.{UniqueName("svc")}"; + server.AddResponse(name, DnsRecordType.SRV, b => b + .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 8080, "node1.test"), ttl: 120) + .Answer(DnsResponseBuilder.BuildSrvRdata(20, 50, 8081, "node2.test"), ttl: 120)); + + DnsResult result = await resolver.ResolveSrvAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Equal(2, result.Records.Count); + + SrvRecord s1 = Assert.Single(result.Records, s => s.Target == "node1.test"); + Assert.Equal((ushort)8080, s1.Port); + Assert.Equal((ushort)10, s1.Priority); + Assert.Equal((ushort)100, s1.Weight); + + SrvRecord s2 = Assert.Single(result.Records, s => s.Target == "node2.test"); + Assert.Equal((ushort)8081, s2.Port); + Assert.Equal((ushort)20, s2.Priority); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveSrv_IncludesAdditionalAddresses() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = $"_http._tcp.{UniqueName("svc")}"; + server.AddResponse(name, DnsRecordType.SRV, b => b + .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 8080, "node1.test"), ttl: 120) + .Answer(DnsResponseBuilder.BuildSrvRdata(20, 50, 8081, "node2.test"), ttl: 120) + .Additional("node1.test", DnsRecordType.A, new byte[] { 10, 0, 0, 10 }, ttl: 120) + .Additional("node2.test", DnsRecordType.A, new byte[] { 10, 0, 0, 11 }, ttl: 120) + .Additional("node2.test", DnsRecordType.AAAA, IPAddress.Parse("fd00::11").GetAddressBytes(), ttl: 120)); + + DnsResult result = await resolver.ResolveSrvAsync(name); + + SrvRecord s1 = Assert.Single(result.Records, s => s.Target == "node1.test"); + Assert.NotNull(s1.Addresses); + AddressRecord s1Addr = Assert.Single(s1.Addresses); + Assert.Equal("10.0.0.10", s1Addr.Address.ToString()); + + SrvRecord s2 = Assert.Single(result.Records, s => s.Target == "node2.test"); + Assert.NotNull(s2.Addresses); + Assert.Equal(2, s2.Addresses.Count); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveSrv_NoAdditionalAddresses() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = $"_noadd._tcp.{UniqueName("svc")}"; + server.AddResponse(name, DnsRecordType.SRV, b => b + .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 9090, "noaddr.test"), ttl: 60)); + + DnsResult result = await resolver.ResolveSrvAsync(name); + + SrvRecord record = Assert.Single(result.Records); + Assert.Equal("noaddr.test", record.Target); + Assert.Empty(record.Addresses); + } + + // ---- MX / TXT / CNAME / PTR / NS ---- + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveMx_ReturnsRecords() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("mx"); + server.AddResponse(name, DnsRecordType.MX, b => b + .Answer(DnsResponseBuilder.BuildMxRdata(10, "mail1.test"), ttl: 120) + .Answer(DnsResponseBuilder.BuildMxRdata(20, "mail2.test"), ttl: 120)); + + DnsResult result = await resolver.ResolveMxAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Equal(2, result.Records.Count); + + MxRecord m1 = Assert.Single(result.Records, m => m.Exchange == "mail1.test"); + Assert.Equal((ushort)10, m1.Preference); + Assert.Single(result.Records, m => m.Exchange == "mail2.test" && m.Preference == 20); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveTxt_ReturnsValues() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("txt"); + server.AddResponse(name, DnsRecordType.TXT, b => b + .Answer(DnsResponseBuilder.BuildTxtRdata("v=spf1 -all"), ttl: 120) + .Answer(DnsResponseBuilder.BuildTxtRdata("part1", "part2"), ttl: 120)); + + DnsResult result = await resolver.ResolveTxtAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Equal(2, result.Records.Count); + Assert.Contains(result.Records, t => t.Values.Count == 1 && t.Values[0] == "v=spf1 -all"); + Assert.Contains(result.Records, t => t.Values.Count == 2 && t.Values[0] == "part1" && t.Values[1] == "part2"); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveCName_ReturnsCanonicalName() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("alias"); + server.AddResponse(name, DnsRecordType.CNAME, b => b + .Answer(DnsResponseBuilder.EncodeName("canonical.test"), ttl: 120)); + + DnsResult result = await resolver.ResolveCNameAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + CNameRecord record = Assert.Single(result.Records); + Assert.Equal("canonical.test", record.CanonicalName); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolvePtr_ReturnsName() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = $"1.0.0.10.in-addr.{UniqueName("arpa")}"; + server.AddResponse(name, DnsRecordType.PTR, b => b + .Answer(DnsResponseBuilder.EncodeName("host.test"), ttl: 120)); + + DnsResult result = await resolver.ResolvePtrAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + PtrRecord record = Assert.Single(result.Records); + Assert.Equal("host.test", record.Name); + } + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveNs_ReturnsRecords() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("ns"); + server.AddResponse(name, DnsRecordType.NS, b => b + .Answer(DnsResponseBuilder.EncodeName("ns1.test"), ttl: 120) + .Answer(DnsResponseBuilder.EncodeName("ns2.test"), ttl: 120)); + + DnsResult result = await resolver.ResolveNsAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Equal(2, result.Records.Count); + Assert.Contains(result.Records, n => n.Name == "ns1.test"); + Assert.Contains(result.Records, n => n.Name == "ns2.test"); + } + + // ---- Custom server endpoint handling ---- + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task CustomServer_DefaultPortZero_IsAccepted() + { + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + // Port 0 means "use the default DNS port"; DnsQueryEx always queries port 53. + using DnsResolver resolver = new DnsResolver(new DnsResolverOptions + { + Servers = { new IPEndPoint(IPAddress.Loopback, 0) } + }); + + string name = UniqueName("port0"); + server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 5 }, ttl: 120)); + server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); + + DnsResult result = await resolver.ResolveAddressesAsync(name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + AddressRecord record = Assert.Single(result.Records); + Assert.Equal("10.0.0.5", record.Address.ToString()); + } + + // ---- Cancellation while a query is in flight ---- + + [ConditionalFact(nameof(IsSupported))] + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + public async Task ResolveAddresses_CancellationInFlight_Throws() + { + using SemaphoreSlim queryReceived = new(0, 1); + using ManualResetEventSlim serverCanContinue = new(false); + using CancellationTokenSource cts = new(); + + await using LoopbackDnsServer server = LoopbackDnsServer.Start(); + using DnsResolver resolver = CreateResolver(server); + + string name = UniqueName("cancel"); + server.AddRawResponse(name, DnsRecordType.A, queryId => + { + queryReceived.Release(); + // Hold the response until the test cancels and signals us to continue. + serverCanContinue.Wait(TimeSpan.FromSeconds(30)); + return DnsResponseBuilder.For(queryId, DnsResponseBuilder.EncodeName(name), DnsRecordType.A) + .Answer(new byte[] { 10, 0, 0, 1 }, ttl: 60) + .Build(); + }); + + // Query a single family so exactly one (blocked) UDP query is issued. + Task resolveTask = resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork, cts.Token); + + Assert.True(await queryReceived.WaitAsync(TimeSpan.FromSeconds(10))); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => resolveTask); + + serverCanContinue.Set(); + } + } + + // The loopback DNS server binds the single machine-wide port 53, so all tests that + // use it must run sequentially. Placing them in this collection disables parallel + // execution between the test classes that opt into it. + [CollectionDefinition(nameof(DnsLoopbackTestCollection), DisableParallelization = true)] + public sealed class DnsLoopbackTestCollection { } +} diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs index 8307518af9ee3e..b47c117e78c893 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -213,6 +213,18 @@ public async Task DnsResolver_CustomServer_Port53_Works() Assert.NotEmpty(result.Records); } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + public async Task DnsResolver_CustomServer_NonStandardPort_ThrowsPlatformNotSupported() + { + // DnsQueryEx only supports custom DNS servers on the standard port 53. + DnsResolverOptions opts = new DnsResolverOptions + { + Servers = { new IPEndPoint(IPAddress.Loopback, 5353) } + }; + using DnsResolver r = new DnsResolver(opts); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost)); + } + // ---- Reverse-arpa name building (covers both IPv4 and IPv6 paths used by ResolvePtr(IPAddress)) ---- [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResponseBuilder.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResponseBuilder.cs new file mode 100644 index 00000000000000..5d2141d6f7ad59 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResponseBuilder.cs @@ -0,0 +1,243 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; + +namespace System.Net.NameResolution.Tests +{ + // DNS record types used by the loopback test server. Values are the RFC-assigned TYPE codes. + internal enum DnsRecordType : ushort + { + A = 1, + NS = 2, + CNAME = 5, + SOA = 6, + PTR = 12, + MX = 15, + TXT = 16, + AAAA = 28, + SRV = 33, + } + + [Flags] + internal enum DnsHeaderFlags : ushort + { + None = 0, + Truncation = 0x0200, + RecursionDesired = 0x0100, + RecursionAvailable = 0x0080, + } + + /// + /// Fluent builder for constructing raw DNS response byte arrays in tests. + /// Self-contained: does not depend on any production DNS message types. + /// + internal sealed class DnsResponseBuilder + { + private readonly ushort _queryId; + private readonly byte[] _questionName; // wire-encoded question name (may be empty) + private readonly DnsRecordType _questionType; + + private DnsResponseCode _rcode; + private DnsHeaderFlags _extraFlags; + + private List<(byte[]? OwnerName, DnsRecordType Type, uint Ttl, byte[] Rdata)>? _answers; + private List<(byte[]? OwnerName, DnsRecordType Type, uint Ttl, byte[] Rdata)>? _authority; + private List<(byte[]? OwnerName, DnsRecordType Type, uint Ttl, byte[] Rdata)>? _additional; + + private int _questionCountOverride = -1; + private int _answerCountOverride = -1; + private int _authorityCountOverride = -1; + private int _additionalCountOverride = -1; + private bool _skipQuestion; + + private DnsResponseBuilder(ushort queryId, byte[] questionName, DnsRecordType questionType) + { + _queryId = queryId; + _questionName = questionName; + _questionType = questionType; + } + + public static DnsResponseBuilder For(ushort queryId, byte[] questionName, DnsRecordType questionType) + => new DnsResponseBuilder(queryId, questionName, questionType); + + public DnsResponseBuilder ResponseCode(DnsResponseCode rcode) + { + _rcode = rcode; + return this; + } + + public DnsResponseBuilder Truncated() + { + _extraFlags |= DnsHeaderFlags.Truncation; + return this; + } + + public DnsResponseBuilder Answer(byte[] rdata, uint ttl = 300) + => Answer(_questionType, rdata, ttl); + + public DnsResponseBuilder Answer(DnsRecordType type, byte[] rdata, uint ttl = 300) + { + _answers ??= new(); + _answers.Add((null, type, ttl, rdata)); + return this; + } + + public DnsResponseBuilder Answer(string ownerName, DnsRecordType type, byte[] rdata, uint ttl = 300) + { + _answers ??= new(); + _answers.Add((EncodeName(ownerName), type, ttl, rdata)); + return this; + } + + public DnsResponseBuilder Authority(string ownerName, DnsRecordType type, byte[] rdata, uint ttl = 60) + { + _authority ??= new(); + _authority.Add((EncodeName(ownerName), type, ttl, rdata)); + return this; + } + + public DnsResponseBuilder Additional(string ownerName, DnsRecordType type, byte[] rdata, uint ttl = 300) + { + _additional ??= new(); + _additional.Add((EncodeName(ownerName), type, ttl, rdata)); + return this; + } + + public DnsResponseBuilder OverrideQuestionCount(ushort count) { _questionCountOverride = count; return this; } + public DnsResponseBuilder OverrideAnswerCount(ushort count) { _answerCountOverride = count; return this; } + public DnsResponseBuilder OverrideAuthorityCount(ushort count) { _authorityCountOverride = count; return this; } + public DnsResponseBuilder OverrideAdditionalCount(ushort count) { _additionalCountOverride = count; return this; } + public DnsResponseBuilder SkipQuestion() { _skipQuestion = true; return this; } + + public byte[] Build() + { + int answerCount = _answers?.Count ?? 0; + int authorityCount = _authority?.Count ?? 0; + int additionalCount = _additional?.Count ?? 0; + bool writeQuestion = !_skipQuestion && _questionName.Length > 0; + + byte[] buf = new byte[4096]; + int offset = 0; + + // Header + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset), _queryId); + ushort flags = (ushort)(0x8000 // QR (response) + | (ushort)(DnsHeaderFlags.RecursionDesired | DnsHeaderFlags.RecursionAvailable | _extraFlags) + | ((ushort)_rcode & 0xF)); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 2), flags); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 4), (ushort)(_questionCountOverride >= 0 ? _questionCountOverride : (writeQuestion ? 1 : 0))); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 6), (ushort)(_answerCountOverride >= 0 ? _answerCountOverride : answerCount)); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 8), (ushort)(_authorityCountOverride >= 0 ? _authorityCountOverride : authorityCount)); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 10), (ushort)(_additionalCountOverride >= 0 ? _additionalCountOverride : additionalCount)); + offset += 12; + + if (writeQuestion) + { + _questionName.CopyTo(buf.AsSpan(offset)); + offset += _questionName.Length; + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset), (ushort)_questionType); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 2), 1); // class IN + offset += 4; + } + + WriteSection(buf, ref offset, _answers); + WriteSection(buf, ref offset, _authority); + WriteSection(buf, ref offset, _additional); + + return buf[..offset]; + } + + private void WriteSection(byte[] buf, ref int offset, + List<(byte[]? OwnerName, DnsRecordType Type, uint Ttl, byte[] Rdata)>? records) + { + if (records is null) + { + return; + } + + foreach ((byte[]? ownerName, DnsRecordType type, uint ttl, byte[] rdata) in records) + { + byte[] name = ownerName ?? _questionName; + name.CopyTo(buf.AsSpan(offset)); + offset += name.Length; + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset), (ushort)type); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 2), 1); // class IN + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(offset + 4), ttl); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(offset + 8), (ushort)rdata.Length); + offset += 10; + rdata.CopyTo(buf.AsSpan(offset)); + offset += rdata.Length; + } + } + + internal static byte[] EncodeName(string name) + { + if (string.IsNullOrEmpty(name) || name == ".") + { + return new byte[] { 0 }; + } + + List bytes = new(); + foreach (string label in name.Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + byte[] labelBytes = Encoding.ASCII.GetBytes(label); + bytes.Add((byte)labelBytes.Length); + bytes.AddRange(labelBytes); + } + bytes.Add(0); + return bytes.ToArray(); + } + + internal static byte[] BuildSoaRdata(string soaName, uint minTtl) + { + byte[] mname = EncodeName("ns." + soaName); + byte[] rname = EncodeName("admin." + soaName); + byte[] rdata = new byte[mname.Length + rname.Length + 20]; + mname.CopyTo(rdata, 0); + rname.CopyTo(rdata, mname.Length); + int fixedStart = mname.Length + rname.Length; + BinaryPrimitives.WriteUInt32BigEndian(rdata.AsSpan(fixedStart), 2024010101); // serial + BinaryPrimitives.WriteUInt32BigEndian(rdata.AsSpan(fixedStart + 4), 3600); // refresh + BinaryPrimitives.WriteUInt32BigEndian(rdata.AsSpan(fixedStart + 8), 900); // retry + BinaryPrimitives.WriteUInt32BigEndian(rdata.AsSpan(fixedStart + 12), 604800); // expire + BinaryPrimitives.WriteUInt32BigEndian(rdata.AsSpan(fixedStart + 16), minTtl); // minimum + return rdata; + } + + internal static byte[] BuildSrvRdata(ushort priority, ushort weight, ushort port, string target) + { + byte[] targetBytes = EncodeName(target); + byte[] rdata = new byte[6 + targetBytes.Length]; + BinaryPrimitives.WriteUInt16BigEndian(rdata, priority); + BinaryPrimitives.WriteUInt16BigEndian(rdata.AsSpan(2), weight); + BinaryPrimitives.WriteUInt16BigEndian(rdata.AsSpan(4), port); + targetBytes.CopyTo(rdata, 6); + return rdata; + } + + internal static byte[] BuildMxRdata(ushort preference, string exchange) + { + byte[] exchangeBytes = EncodeName(exchange); + byte[] rdata = new byte[2 + exchangeBytes.Length]; + BinaryPrimitives.WriteUInt16BigEndian(rdata, preference); + exchangeBytes.CopyTo(rdata, 2); + return rdata; + } + + // A single character-string (length-prefixed) TXT value. + internal static byte[] BuildTxtRdata(params string[] values) + { + List rdata = new(); + foreach (string value in values) + { + byte[] valueBytes = Encoding.ASCII.GetBytes(value); + rdata.Add((byte)valueBytes.Length); + rdata.AddRange(valueBytes); + } + return rdata.ToArray(); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs new file mode 100644 index 00000000000000..5d399f66b2464c --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs @@ -0,0 +1,275 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.XUnitExtensions; + +namespace System.Net.NameResolution.Tests +{ + /// + /// A minimal in-process DNS server for testing. Listens on the loopback DNS port (53) + /// and responds with preconfigured answers based on the query name and type. + /// Self-contained: does not depend on any production DNS message types. + /// + /// + /// Windows' DnsQueryEx only ever contacts custom DNS servers on the standard + /// port 53 (the sockaddr port field must be 0), so the loopback server must bind 53. + /// Binding a privileged-looking low port does not require elevation on Windows, but + /// the port may already be in use (e.g. a local DNS service), in which case + /// throws so the test is skipped + /// rather than failed. + /// + internal sealed class LoopbackDnsServer : IAsyncDisposable + { + // DnsQueryEx always queries DNS servers on the standard port 53. + public const int DnsPort = 53; + + private readonly UdpClient _udp; + private readonly TcpListener _tcp; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _udpListenTask; + private readonly Task _tcpListenTask; + private readonly Dictionary<(string Name, DnsRecordType Type), ResponseBuilder> _responses = new(); + private int _requestCount; + + public IPEndPoint EndPoint { get; } + + public int RequestCount => _requestCount; + + public int TcpRequestCount { get; private set; } + + private LoopbackDnsServer(UdpClient udp, TcpListener tcp, IPEndPoint endPoint) + { + _udp = udp; + _tcp = tcp; + EndPoint = endPoint; + _udpListenTask = ListenUdpAsync(_cts.Token); + _tcpListenTask = ListenTcpAsync(_cts.Token); + } + + public static LoopbackDnsServer Start() + { + UdpClient udp; + try + { + udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, DnsPort)); + } + catch (SocketException ex) + { + throw new SkipTestException( + $"Unable to bind loopback DNS port {DnsPort}; another DNS server may be running ({ex.SocketErrorCode})."); + } + + IPEndPoint ep = (IPEndPoint)udp.Client.LocalEndPoint!; + TcpListener tcp = new(IPAddress.Loopback, ep.Port); + try + { + tcp.Start(); + } + catch (SocketException ex) + { + udp.Dispose(); + throw new SkipTestException( + $"Unable to bind loopback DNS TCP port {DnsPort}; another DNS server may be running ({ex.SocketErrorCode})."); + } + + return new LoopbackDnsServer(udp, tcp, ep); + } + + public void AddResponse(string name, DnsRecordType type, Func configure) + { + _responses[(name.ToLowerInvariant(), type)] = (queryId, qName, _) => + configure(DnsResponseBuilder.For(queryId, qName, type)).Build(); + } + + public void AddRawResponse(string name, DnsRecordType type, Func rawFactory) + { + _responses[(name.ToLowerInvariant(), type)] = (queryId, _, _) => rawFactory(queryId); + } + + public delegate byte[] ResponseBuilder(ushort queryId, byte[] questionName, bool isTcp); + + private async Task ListenUdpAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + UdpReceiveResult result = await _udp.ReceiveAsync(ct); + Interlocked.Increment(ref _requestCount); + + byte[] response = ProcessQuery(result.Buffer); + if (response.Length > 0) + { + await _udp.SendAsync(response, result.RemoteEndPoint, ct); + } + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + } + + private async Task ListenTcpAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + TcpClient client = await _tcp.AcceptTcpClientAsync(ct); + _ = HandleTcpClientAsync(client, ct); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + } + + private async Task HandleTcpClientAsync(TcpClient client, CancellationToken ct) + { + try + { + using (client) + { + NetworkStream stream = client.GetStream(); + + byte[] lengthBuf = new byte[2]; + if (!await ReadExactlyAsync(stream, lengthBuf, ct)) + { + return; + } + + int queryLength = BinaryPrimitives.ReadUInt16BigEndian(lengthBuf); + byte[] query = new byte[queryLength]; + if (!await ReadExactlyAsync(stream, query, ct)) + { + return; + } + + Interlocked.Increment(ref _requestCount); + TcpRequestCount++; + + byte[] response = ProcessQuery(query, isTcp: true); + if (response.Length > 0) + { + byte[] responseLengthBuf = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(responseLengthBuf, (ushort)response.Length); + await stream.WriteAsync(responseLengthBuf, ct); + await stream.WriteAsync(response, ct); + } + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (IOException) { } + } + + private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken ct) + { + int read = 0; + while (read < buffer.Length) + { + int n = await stream.ReadAsync(buffer.AsMemory(read), ct); + if (n == 0) + { + return false; + } + read += n; + } + return true; + } + + private byte[] ProcessQuery(byte[] query, bool isTcp = false) + { + if (query.Length < 12) + { + return []; + } + + ushort queryId = BinaryPrimitives.ReadUInt16BigEndian(query); + ushort qdCount = BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(4)); + + if (qdCount < 1) + { + return DnsResponseBuilder.For(queryId, [], 0) + .ResponseCode(DnsResponseCode.FormatError) + .SkipQuestion() + .Build(); + } + + int pos = 12; + int nameStart = pos; + + while (pos < query.Length) + { + byte b = query[pos]; + if (b == 0) { pos++; break; } + if ((b & 0xC0) == 0xC0) { pos += 2; break; } + pos += 1 + b; + } + + byte[] questionName = query[nameStart..pos]; + + if (pos + 4 > query.Length) + { + return DnsResponseBuilder.For(queryId, questionName, 0) + .ResponseCode(DnsResponseCode.FormatError) + .Build(); + } + + DnsRecordType qType = (DnsRecordType)BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(pos)); + string nameStr = DecodeName(query, nameStart); + + if (_responses.TryGetValue((nameStr.ToLowerInvariant(), qType), out ResponseBuilder? builder)) + { + return builder(queryId, questionName, isTcp); + } + + // Default: NXDOMAIN + return DnsResponseBuilder.For(queryId, questionName, qType) + .ResponseCode(DnsResponseCode.NxDomain) + .Build(); + } + + private static string DecodeName(byte[] message, int offset) + { + StringBuilder sb = new(); + int pos = offset; + while (pos < message.Length) + { + byte len = message[pos]; + if (len == 0) + { + break; + } + if ((len & 0xC0) == 0xC0) + { + pos = ((len & 0x3F) << 8) | message[pos + 1]; + continue; + } + pos++; + if (sb.Length > 0) + { + sb.Append('.'); + } + sb.Append(Encoding.ASCII.GetString(message, pos, len)); + pos += len; + } + return sb.ToString(); + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + _udp.Dispose(); + _tcp.Stop(); + try { await _udpListenTask; } catch { } + try { await _tcpListenTask; } catch { } + _cts.Dispose(); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj index bc260f9daf3b20..02d5851e2c32f5 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/System.Net.NameResolution.Functional.Tests.csproj @@ -10,6 +10,9 @@ + + + From a589fadb1690d7a35ee657453c14e2fed20ea296 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 13:36:41 +0200 Subject: [PATCH 04/10] Polish tests --- .../DnsResolverLoopbackTest.cs | 244 +++++++----------- .../tests/FunctionalTests/DnsResolverTest.cs | 7 +- .../FunctionalTests/LoopbackDnsServer.cs | 83 +++--- 3 files changed, 159 insertions(+), 175 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index e713e60bdda6dc..0f240ff92777db 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -8,6 +8,27 @@ namespace System.Net.NameResolution.Tests { + public class WindowsLoopbackServer : IAsyncDisposable + { + private LoopbackDnsServer _server; + + public WindowsLoopbackServer() + { + _server = LoopbackDnsServer.Start(); + } + + internal LoopbackDnsServer Server => _server; + + public async ValueTask DisposeAsync() + { + if (_server != null) + { + await _server.DisposeAsync(); + _server = null; + } + } + } + // Deterministic DnsResolver tests driven by an in-process loopback DNS server. // // On Windows, DnsQueryEx only ever contacts custom DNS servers on the standard @@ -18,11 +39,11 @@ namespace System.Net.NameResolution.Tests // // These tests cover the record-parsing and response-handling behavior that the // OuterLoop tests in DnsResolverTest.cs cannot exercise deterministically. - [Collection(nameof(DnsLoopbackTestCollection))] - public class DnsResolverLoopbackTest + [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Collection(nameof(DisableParallelization))] + [PlatformSpecific(TestPlatforms.Windows)] + public class DnsResolverLoopbackTest : IClassFixture { - public static bool IsSupported => PlatformDetection.IsWindows; - private static DnsResolver CreateResolver(LoopbackDnsServer server) => new DnsResolver(new DnsResolverOptions { Servers = { server.EndPoint } }); @@ -30,20 +51,27 @@ private static DnsResolver CreateResolver(LoopbackDnsServer server) // previous test run can satisfy the query without reaching the loopback server. private static string UniqueName(string label) => $"{label}-{Guid.NewGuid():N}.test"; + LoopbackDnsServer _server; + DnsResolver? _resolver; + + public DnsResolverLoopbackTest(WindowsLoopbackServer fixture) + { + _server = fixture.Server; + _server.ClearResponses(); + } + + internal DnsResolver Resolver => _resolver ??= CreateResolver(_server); + // ---- Address resolution ---- - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("host"); - server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); - server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); + _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); + _server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await Resolver.ResolveAddressesAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -51,18 +79,14 @@ public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6() Assert.Contains(result.Records, a => a.Address.ToString() == "fd00::1"); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("v4"); - server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 2 }, ttl: 300)); - server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); + _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 2 }, ttl: 300)); + _server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await Resolver.ResolveAddressesAsync(name); // A succeeds, AAAA returns NXDOMAIN — overall is success because we got addresses. Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); @@ -70,53 +94,41 @@ public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4() Assert.Equal("10.0.0.2", record.Address.ToString()); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("v6"); - server.AddResponse(name, DnsRecordType.A, b => b.ResponseCode(DnsResponseCode.NxDomain)); - server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); + _server.AddResponse(name, DnsRecordType.A, b => b.ResponseCode(DnsResponseCode.NxDomain)); + _server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await Resolver.ResolveAddressesAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); AddressRecord record = Assert.Single(result.Records); Assert.Equal("fd00::1", record.Address.ToString()); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("famv4"); - server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 192, 0, 2, 7 }, ttl: 200)); + _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 192, 0, 2, 7 }, ttl: 200)); - DnsResult result = await resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork); + DnsResult result = await Resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); AddressRecord record = Assert.Single(result.Records); Assert.Equal("192.0.2.7", record.Address.ToString()); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_HasTtl() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("ttl"); - server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); - server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); + _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); + _server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await Resolver.ResolveAddressesAsync(name); AddressRecord record = Assert.Single(result.Records); // The TTL we sent (120s) should be preserved (custom-server queries bypass the OS cache). @@ -124,77 +136,65 @@ public async Task ResolveAddresses_HasTtl() $"Unexpected TTL: {record.Ttl}"); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("missing"); byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 120); - server.AddResponse(name, DnsRecordType.A, b => b + _server.AddResponse(name, DnsRecordType.A, b => b .ResponseCode(DnsResponseCode.NxDomain) .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 120)); - server.AddResponse(name, DnsRecordType.AAAA, b => b + _server.AddResponse(name, DnsRecordType.AAAA, b => b .ResponseCode(DnsResponseCode.NxDomain) .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 120)); - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await Resolver.ResolveAddressesAsync(name); Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); Assert.Empty(result.Records); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("nodata"); byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 30); - server.AddResponse(name, DnsRecordType.A, b => b + _server.AddResponse(name, DnsRecordType.A, b => b .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); - server.AddResponse(name, DnsRecordType.AAAA, b => b + _server.AddResponse(name, DnsRecordType.AAAA, b => b .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); // The name exists but has no A/AAAA records → NODATA for both queries. - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await Resolver.ResolveAddressesAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Empty(result.Records); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string nodataName = UniqueName("nodata"); byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 30); - server.AddResponse(nodataName, DnsRecordType.A, b => b + _server.AddResponse(nodataName, DnsRecordType.A, b => b .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); - server.AddResponse(nodataName, DnsRecordType.AAAA, b => b + _server.AddResponse(nodataName, DnsRecordType.AAAA, b => b .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); string missingName = UniqueName("missing"); byte[] nxSoaRdata = DnsResponseBuilder.BuildSoaRdata("test", 120); - server.AddResponse(missingName, DnsRecordType.A, b => b + _server.AddResponse(missingName, DnsRecordType.A, b => b .ResponseCode(DnsResponseCode.NxDomain) .Authority("test", DnsRecordType.SOA, nxSoaRdata, ttl: 120)); - server.AddResponse(missingName, DnsRecordType.AAAA, b => b + _server.AddResponse(missingName, DnsRecordType.AAAA, b => b .ResponseCode(DnsResponseCode.NxDomain) .Authority("test", DnsRecordType.SOA, nxSoaRdata, ttl: 120)); - DnsResult nodata = await resolver.ResolveAddressesAsync(nodataName); + DnsResult nodata = await Resolver.ResolveAddressesAsync(nodataName); Assert.Equal(DnsResponseCode.NoError, nodata.ResponseCode); Assert.Empty(nodata.Records); - DnsResult nxdomain = await resolver.ResolveAddressesAsync(missingName); + DnsResult nxdomain = await Resolver.ResolveAddressesAsync(missingName); Assert.Equal(DnsResponseCode.NxDomain, nxdomain.ResponseCode); Assert.Empty(nxdomain.Records); @@ -203,19 +203,15 @@ public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable() // ---- SRV ---- - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveSrv_ReturnsRecords() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = $"_http._tcp.{UniqueName("svc")}"; - server.AddResponse(name, DnsRecordType.SRV, b => b + _server.AddResponse(name, DnsRecordType.SRV, b => b .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 8080, "node1.test"), ttl: 120) .Answer(DnsResponseBuilder.BuildSrvRdata(20, 50, 8081, "node2.test"), ttl: 120)); - DnsResult result = await resolver.ResolveSrvAsync(name); + DnsResult result = await Resolver.ResolveSrvAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -230,22 +226,18 @@ public async Task ResolveSrv_ReturnsRecords() Assert.Equal((ushort)20, s2.Priority); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveSrv_IncludesAdditionalAddresses() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = $"_http._tcp.{UniqueName("svc")}"; - server.AddResponse(name, DnsRecordType.SRV, b => b + _server.AddResponse(name, DnsRecordType.SRV, b => b .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 8080, "node1.test"), ttl: 120) .Answer(DnsResponseBuilder.BuildSrvRdata(20, 50, 8081, "node2.test"), ttl: 120) .Additional("node1.test", DnsRecordType.A, new byte[] { 10, 0, 0, 10 }, ttl: 120) .Additional("node2.test", DnsRecordType.A, new byte[] { 10, 0, 0, 11 }, ttl: 120) .Additional("node2.test", DnsRecordType.AAAA, IPAddress.Parse("fd00::11").GetAddressBytes(), ttl: 120)); - DnsResult result = await resolver.ResolveSrvAsync(name); + DnsResult result = await Resolver.ResolveSrvAsync(name); SrvRecord s1 = Assert.Single(result.Records, s => s.Target == "node1.test"); Assert.NotNull(s1.Addresses); @@ -257,18 +249,14 @@ public async Task ResolveSrv_IncludesAdditionalAddresses() Assert.Equal(2, s2.Addresses.Count); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveSrv_NoAdditionalAddresses() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = $"_noadd._tcp.{UniqueName("svc")}"; - server.AddResponse(name, DnsRecordType.SRV, b => b + _server.AddResponse(name, DnsRecordType.SRV, b => b .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 9090, "noaddr.test"), ttl: 60)); - DnsResult result = await resolver.ResolveSrvAsync(name); + DnsResult result = await Resolver.ResolveSrvAsync(name); SrvRecord record = Assert.Single(result.Records); Assert.Equal("noaddr.test", record.Target); @@ -277,19 +265,15 @@ public async Task ResolveSrv_NoAdditionalAddresses() // ---- MX / TXT / CNAME / PTR / NS ---- - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveMx_ReturnsRecords() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("mx"); - server.AddResponse(name, DnsRecordType.MX, b => b + _server.AddResponse(name, DnsRecordType.MX, b => b .Answer(DnsResponseBuilder.BuildMxRdata(10, "mail1.test"), ttl: 120) .Answer(DnsResponseBuilder.BuildMxRdata(20, "mail2.test"), ttl: 120)); - DnsResult result = await resolver.ResolveMxAsync(name); + DnsResult result = await Resolver.ResolveMxAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -299,19 +283,15 @@ public async Task ResolveMx_ReturnsRecords() Assert.Single(result.Records, m => m.Exchange == "mail2.test" && m.Preference == 20); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveTxt_ReturnsValues() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("txt"); - server.AddResponse(name, DnsRecordType.TXT, b => b + _server.AddResponse(name, DnsRecordType.TXT, b => b .Answer(DnsResponseBuilder.BuildTxtRdata("v=spf1 -all"), ttl: 120) .Answer(DnsResponseBuilder.BuildTxtRdata("part1", "part2"), ttl: 120)); - DnsResult result = await resolver.ResolveTxtAsync(name); + DnsResult result = await Resolver.ResolveTxtAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -319,55 +299,43 @@ public async Task ResolveTxt_ReturnsValues() Assert.Contains(result.Records, t => t.Values.Count == 2 && t.Values[0] == "part1" && t.Values[1] == "part2"); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveCName_ReturnsCanonicalName() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("alias"); - server.AddResponse(name, DnsRecordType.CNAME, b => b + _server.AddResponse(name, DnsRecordType.CNAME, b => b .Answer(DnsResponseBuilder.EncodeName("canonical.test"), ttl: 120)); - DnsResult result = await resolver.ResolveCNameAsync(name); + DnsResult result = await Resolver.ResolveCNameAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); CNameRecord record = Assert.Single(result.Records); Assert.Equal("canonical.test", record.CanonicalName); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolvePtr_ReturnsName() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = $"1.0.0.10.in-addr.{UniqueName("arpa")}"; - server.AddResponse(name, DnsRecordType.PTR, b => b + _server.AddResponse(name, DnsRecordType.PTR, b => b .Answer(DnsResponseBuilder.EncodeName("host.test"), ttl: 120)); - DnsResult result = await resolver.ResolvePtrAsync(name); + DnsResult result = await Resolver.ResolvePtrAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); PtrRecord record = Assert.Single(result.Records); Assert.Equal("host.test", record.Name); } - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveNs_ReturnsRecords() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("ns"); - server.AddResponse(name, DnsRecordType.NS, b => b + _server.AddResponse(name, DnsRecordType.NS, b => b .Answer(DnsResponseBuilder.EncodeName("ns1.test"), ttl: 120) .Answer(DnsResponseBuilder.EncodeName("ns2.test"), ttl: 120)); - DnsResult result = await resolver.ResolveNsAsync(name); + DnsResult result = await Resolver.ResolveNsAsync(name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -377,11 +345,9 @@ public async Task ResolveNs_ReturnsRecords() // ---- Custom server endpoint handling ---- - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task CustomServer_DefaultPortZero_IsAccepted() { - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); // Port 0 means "use the default DNS port"; DnsQueryEx always queries port 53. using DnsResolver resolver = new DnsResolver(new DnsResolverOptions { @@ -389,8 +355,8 @@ public async Task CustomServer_DefaultPortZero_IsAccepted() }); string name = UniqueName("port0"); - server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 5 }, ttl: 120)); - server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); + _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 5 }, ttl: 120)); + _server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); DnsResult result = await resolver.ResolveAddressesAsync(name); @@ -401,19 +367,15 @@ public async Task CustomServer_DefaultPortZero_IsAccepted() // ---- Cancellation while a query is in flight ---- - [ConditionalFact(nameof(IsSupported))] - [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] + [Fact] public async Task ResolveAddresses_CancellationInFlight_Throws() { using SemaphoreSlim queryReceived = new(0, 1); using ManualResetEventSlim serverCanContinue = new(false); using CancellationTokenSource cts = new(); - await using LoopbackDnsServer server = LoopbackDnsServer.Start(); - using DnsResolver resolver = CreateResolver(server); - string name = UniqueName("cancel"); - server.AddRawResponse(name, DnsRecordType.A, queryId => + _server.AddRawResponse(name, DnsRecordType.A, queryId => { queryReceived.Release(); // Hold the response until the test cancels and signals us to continue. @@ -424,7 +386,7 @@ public async Task ResolveAddresses_CancellationInFlight_Throws() }); // Query a single family so exactly one (blocked) UDP query is issued. - Task resolveTask = resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork, cts.Token); + Task resolveTask = Resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork, cts.Token); Assert.True(await queryReceived.WaitAsync(TimeSpan.FromSeconds(10))); cts.Cancel(); @@ -434,10 +396,4 @@ public async Task ResolveAddresses_CancellationInFlight_Throws() serverCanContinue.Set(); } } - - // The loopback DNS server binds the single machine-wide port 53, so all tests that - // use it must run sequentially. Placing them in this collection disables parallel - // execution between the test classes that opt into it. - [CollectionDefinition(nameof(DnsLoopbackTestCollection), DisableParallelization = true)] - public sealed class DnsLoopbackTestCollection { } } diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs index b47c117e78c893..32e6bdf1aea582 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using System.Linq; using Xunit; namespace System.Net.NameResolution.Tests @@ -203,9 +204,13 @@ public async Task Static_Dns_ResolveAddressesAsync_Works() [OuterLoop] public async Task DnsResolver_CustomServer_Port53_Works() { + IPAddress dnsAddress = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces() + .SelectMany(ni => ni.GetIPProperties().DnsAddresses) + .FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork); + DnsResolverOptions opts = new DnsResolverOptions { - Servers = { new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53) } + Servers = { new IPEndPoint(dnsAddress, 53) } }; using DnsResolver r = new DnsResolver(opts); DnsResult result = await r.ResolveAddressesAsync(TestHost); diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs index 5d399f66b2464c..e871a80f76ec99 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers.Binary; +using System.Diagnostics; using System.Collections.Generic; -using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; @@ -27,11 +27,10 @@ namespace System.Net.NameResolution.Tests /// internal sealed class LoopbackDnsServer : IAsyncDisposable { - // DnsQueryEx always queries DNS servers on the standard port 53. public const int DnsPort = 53; - private readonly UdpClient _udp; - private readonly TcpListener _tcp; + private readonly Socket _udp; + private readonly Socket _tcp; private readonly CancellationTokenSource _cts = new(); private readonly Task _udpListenTask; private readonly Task _tcpListenTask; @@ -44,7 +43,7 @@ internal sealed class LoopbackDnsServer : IAsyncDisposable public int TcpRequestCount { get; private set; } - private LoopbackDnsServer(UdpClient udp, TcpListener tcp, IPEndPoint endPoint) + private LoopbackDnsServer(Socket udp, Socket tcp, IPEndPoint endPoint) { _udp = udp; _tcp = tcp; @@ -55,26 +54,35 @@ private LoopbackDnsServer(UdpClient udp, TcpListener tcp, IPEndPoint endPoint) public static LoopbackDnsServer Start() { - UdpClient udp; + Socket udp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + if (OperatingSystem.IsWindows()) + { + // Disable ICMP "port unreachable" surfacing as WSAECONNRESET on ReceiveFrom + const int SIO_UDP_CONNRESET = -1744830452; + udp.IOControl(SIO_UDP_CONNRESET, new byte[] { 0 }, null); + } try { - udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, DnsPort)); + udp.Bind(new IPEndPoint(IPAddress.Loopback, DnsPort)); } catch (SocketException ex) { + udp.Dispose(); throw new SkipTestException( $"Unable to bind loopback DNS port {DnsPort}; another DNS server may be running ({ex.SocketErrorCode})."); } - IPEndPoint ep = (IPEndPoint)udp.Client.LocalEndPoint!; - TcpListener tcp = new(IPAddress.Loopback, ep.Port); + IPEndPoint ep = (IPEndPoint)udp.LocalEndPoint!; + Socket tcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); try { - tcp.Start(); + tcp.Bind(new IPEndPoint(IPAddress.Loopback, ep.Port)); + tcp.Listen(); } catch (SocketException ex) { udp.Dispose(); + tcp.Dispose(); throw new SkipTestException( $"Unable to bind loopback DNS TCP port {DnsPort}; another DNS server may be running ({ex.SocketErrorCode})."); } @@ -82,6 +90,8 @@ public static LoopbackDnsServer Start() return new LoopbackDnsServer(udp, tcp, ep); } + public void ClearResponses() => _responses.Clear(); + public void AddResponse(string name, DnsRecordType type, Func configure) { _responses[(name.ToLowerInvariant(), type)] = (queryId, qName, _) => @@ -101,18 +111,29 @@ private async Task ListenUdpAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { - UdpReceiveResult result = await _udp.ReceiveAsync(ct); - Interlocked.Increment(ref _requestCount); - - byte[] response = ProcessQuery(result.Buffer); - if (response.Length > 0) + byte[] buffer = new byte[4096]; + SocketReceiveFromResult result = await _udp.ReceiveFromAsync( + buffer, SocketFlags.None, new IPEndPoint(IPAddress.Loopback, 0), ct); + byte[] query = buffer[..result.ReceivedBytes]; + EndPoint remote = result.RemoteEndPoint; + _ = Task.Run(async () => { - await _udp.SendAsync(response, result.RemoteEndPoint, ct); - } + Interlocked.Increment(ref _requestCount); + + byte[] response = ProcessQuery(query); + if (response.Length > 0) + { + await _udp.SendToAsync(response, SocketFlags.None, remote, ct); + } + }); } } catch (OperationCanceledException) { } catch (ObjectDisposedException) { } + catch (Exception ex) + { + Debug.Fail($"Unexpected exception in UDP listener: {ex}"); + } } private async Task ListenTcpAsync(CancellationToken ct) @@ -121,31 +142,33 @@ private async Task ListenTcpAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { - TcpClient client = await _tcp.AcceptTcpClientAsync(ct); - _ = HandleTcpClientAsync(client, ct); + Socket client = await _tcp.AcceptAsync(ct); + _ = Task.Run(() => HandleTcpClientAsync(client, ct)); } } catch (OperationCanceledException) { } catch (ObjectDisposedException) { } + catch (Exception ex) + { + Debug.Fail($"Unexpected exception in TCP listener: {ex}"); + } } - private async Task HandleTcpClientAsync(TcpClient client, CancellationToken ct) + private async Task HandleTcpClientAsync(Socket client, CancellationToken ct) { try { using (client) { - NetworkStream stream = client.GetStream(); - byte[] lengthBuf = new byte[2]; - if (!await ReadExactlyAsync(stream, lengthBuf, ct)) + if (!await ReadExactlyAsync(client, lengthBuf, ct)) { return; } int queryLength = BinaryPrimitives.ReadUInt16BigEndian(lengthBuf); byte[] query = new byte[queryLength]; - if (!await ReadExactlyAsync(stream, query, ct)) + if (!await ReadExactlyAsync(client, query, ct)) { return; } @@ -158,22 +181,22 @@ private async Task HandleTcpClientAsync(TcpClient client, CancellationToken ct) { byte[] responseLengthBuf = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(responseLengthBuf, (ushort)response.Length); - await stream.WriteAsync(responseLengthBuf, ct); - await stream.WriteAsync(response, ct); + await client.SendAsync(responseLengthBuf, SocketFlags.None, ct); + await client.SendAsync(response, SocketFlags.None, ct); } } } catch (OperationCanceledException) { } catch (ObjectDisposedException) { } - catch (IOException) { } + catch (SocketException) { } } - private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken ct) + private static async Task ReadExactlyAsync(Socket socket, byte[] buffer, CancellationToken ct) { int read = 0; while (read < buffer.Length) { - int n = await stream.ReadAsync(buffer.AsMemory(read), ct); + int n = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, ct); if (n == 0) { return false; @@ -266,7 +289,7 @@ public async ValueTask DisposeAsync() { _cts.Cancel(); _udp.Dispose(); - _tcp.Stop(); + _tcp.Dispose(); try { await _udpListenTask; } catch { } try { await _tcpListenTask; } catch { } _cts.Dispose(); From 73acd3c9db98e30483b9aaa9d101279999078545 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 13:58:54 +0200 Subject: [PATCH 05/10] Add synchronous DnsResolver methods using synchronous DnsQueryEx The synchronous Resolve* methods previously blocked on the async path via GetAwaiter().GetResult(). DnsQueryEx executes synchronously when no completion callback is supplied, so call it directly instead of going through the async state machine. Record-list parsing is factored into shared helpers reused by both the sync and async paths, and the loopback behavioral tests are parameterized over both APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/Dns.Resolve.cs | 5 +- .../src/System/Net/DnsResolver.Unsupported.cs | 21 + .../src/System/Net/DnsResolver.Windows.cs | 372 ++++++++++++------ .../System/Net/DnsResolver.WindowsAsync.cs | 105 ++++- .../src/System/Net/DnsResolver.cs | 44 ++- .../DnsResolverLoopbackTest.cs | 166 +++++--- .../tests/FunctionalTests/DnsResolverTest.cs | 18 + 7 files changed, 528 insertions(+), 203 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.Resolve.cs b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.Resolve.cs index d96bf318a81f1d..eaa2b55eac439e 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.Resolve.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.Resolve.cs @@ -56,7 +56,10 @@ public static DnsResult ResolvePtr(string name) => DefaultResolver.ResolvePtr(name); public static DnsResult ResolvePtr(IPAddress address) - => DefaultResolver.ResolvePtrAsync(address).GetAwaiter().GetResult(); + { + ArgumentNullException.ThrowIfNull(address); + return DefaultResolver.ResolvePtr(DnsResolver.BuildArpaName(address)); + } public static Task> ResolvePtrAsync(string name, CancellationToken cancellationToken = default) => DefaultResolver.ResolvePtrAsync(name, cancellationToken); diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs index 0e3e4543ae763c..8cf6b276e0551b 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs @@ -31,5 +31,26 @@ private Task> ResolvePtrCoreAsync(string name, Cancellation private Task> ResolveNsCoreAsync(string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); + + private DnsResult ResolveAddressesCore(string name, AddressFamily addressFamily, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private DnsResult ResolveSrvCore(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private DnsResult ResolveMxCore(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private DnsResult ResolveTxtCore(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private DnsResult ResolveCNameCore(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private DnsResult ResolvePtrCore(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + private DnsResult ResolveNsCore(string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); } } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs index f7b8727f39e370..b6e16b1eaec498 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs @@ -13,7 +13,7 @@ namespace System.Net { public sealed partial class DnsResolver { - // ---- Public Resolve*Core methods (called from cross-platform DnsResolver) ---- + // ---- Asynchronous Resolve*Core methods (called from cross-platform DnsResolver) ---- private async Task> ResolveAddressesCoreAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken) { @@ -27,12 +27,7 @@ private async Task> ResolveAddressesCoreAsync(string na return MergeAddressResults(aRes, aaaaRes); } - ushort qtype = addressFamily switch - { - AddressFamily.InterNetwork => Interop.Dnsapi.DNS_TYPE_A, - AddressFamily.InterNetworkV6 => Interop.Dnsapi.DNS_TYPE_AAAA, - _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), - }; + ushort qtype = AddressFamilyToQueryType(addressFamily); return await QueryAddressesAsync(name, qtype, cancellationToken).ConfigureAwait(false); } @@ -41,36 +36,7 @@ private async Task> ResolveSrvCoreAsync(string name, Cancel DnsQueryRawResult raw = await DnsQueryExAsync(name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); try { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } - - // Gather additional-section A/AAAA records by name so we can attach them. - Dictionary>? glue = null; - ParseAdditionalAddresses(raw.RecordsHead, ref glue); - - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SRV && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - Interop.Dnsapi.DNS_SRV_DATA data = Marshal.PtrToStructure(dataPtr); - string target = PtrToString(data.pNameTarget) ?? string.Empty; - IReadOnlyList? attached = null; - if (glue != null && glue.TryGetValue(target, out List? list)) - { - attached = list; - } - records.Add(new SrvRecord(target, data.wPort, data.wPriority, data.wWeight, TimeSpan.FromSeconds(hdr.dwTtl), attached)); - } - cur = hdr.pNext; - } - - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + return ParseSrv(raw); } finally { @@ -79,73 +45,76 @@ private async Task> ResolveSrvCoreAsync(string name, Cancel } private Task> ResolveMxCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_MX_DATA data = Marshal.PtrToStructure(dataPtr); - return new MxRecord(PtrToString(data.pNameExchange) ?? string.Empty, data.wPreference, TimeSpan.FromSeconds(hdr.dwTtl)); - }); + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); private Task> ResolveCNameCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_CNAME_DATA data = Marshal.PtrToStructure(dataPtr); - return new CNameRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); - }); + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); private Task> ResolvePtrCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_PTR_DATA data = Marshal.PtrToStructure(dataPtr); - return new PtrRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); - }); + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); private Task> ResolveNsCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_NS_DATA data = Marshal.PtrToStructure(dataPtr); - return new NsRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); - }); + => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); private async Task> ResolveTxtCoreAsync(string name, CancellationToken cancellationToken) { DnsQueryRawResult raw = await DnsQueryExAsync(name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); try { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } + return ParseTxt(raw); + } + finally + { + raw.Dispose(); + } + } - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == Interop.Dnsapi.DNS_TYPE_TEXT && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - // DNS_TXT_DATA: uint dwStringCount; followed by array of PCWSTR. - uint count = (uint)Marshal.ReadInt32(dataPtr); - int ptrSize = IntPtr.Size; - IntPtr stringsPtr = dataPtr + sizeof(uint); - if (ptrSize > sizeof(uint)) - { - // Round up to pointer alignment. - long aligned = ((long)stringsPtr + (ptrSize - 1)) & ~(long)(ptrSize - 1); - stringsPtr = checked((nint)aligned); - } - string[] values = new string[count]; - for (int i = 0; i < count; i++) - { - IntPtr strPtr = Marshal.ReadIntPtr(stringsPtr, i * ptrSize); - values[i] = PtrToString(strPtr) ?? string.Empty; - } - records.Add(new TxtRecord(values, TimeSpan.FromSeconds(hdr.dwTtl))); - } - cur = hdr.pNext; - } + // ---- Synchronous Resolve*Core methods (called from cross-platform DnsResolver) ---- - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + private DnsResult ResolveAddressesCore(string name, AddressFamily addressFamily, CancellationToken cancellationToken) + { + if (addressFamily == AddressFamily.Unspecified) + { + DnsResult aRes = QueryAddresses(name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); + DnsResult aaaaRes = QueryAddresses(name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); + return MergeAddressResults(aRes, aaaaRes); + } + + ushort qtype = AddressFamilyToQueryType(addressFamily); + return QueryAddresses(name, qtype, cancellationToken); + } + + private DnsResult ResolveSrvCore(string name, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = DnsQueryExSync(name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken); + try + { + return ParseSrv(raw); + } + finally + { + raw.Dispose(); + } + } + + private DnsResult ResolveMxCore(string name, CancellationToken cancellationToken) + => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); + + private DnsResult ResolveCNameCore(string name, CancellationToken cancellationToken) + => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); + + private DnsResult ResolvePtrCore(string name, CancellationToken cancellationToken) + => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); + + private DnsResult ResolveNsCore(string name, CancellationToken cancellationToken) + => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); + + private DnsResult ResolveTxtCore(string name, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = DnsQueryExSync(name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken); + try + { + return ParseTxt(raw); } finally { @@ -153,40 +122,191 @@ private async Task> ResolveTxtCoreAsync(string name, Cancel } } - // ---- Helpers for address parsing ---- + // ---- Per-record-type selectors (shared by sync and async paths) ---- + + private static readonly Func s_parseMx = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_MX_DATA data = Marshal.PtrToStructure(dataPtr); + return new MxRecord(PtrToString(data.pNameExchange) ?? string.Empty, data.wPreference, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static readonly Func s_parseCName = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_CNAME_DATA data = Marshal.PtrToStructure(dataPtr); + return new CNameRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static readonly Func s_parsePtr = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_PTR_DATA data = Marshal.PtrToStructure(dataPtr); + return new PtrRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static readonly Func s_parseNs = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_NS_DATA data = Marshal.PtrToStructure(dataPtr); + return new NsRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static ushort AddressFamilyToQueryType(AddressFamily addressFamily) => + addressFamily switch + { + AddressFamily.InterNetwork => Interop.Dnsapi.DNS_TYPE_A, + AddressFamily.InterNetworkV6 => Interop.Dnsapi.DNS_TYPE_AAAA, + _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + }; + + // ---- Address query helpers ---- private async Task> QueryAddressesAsync(string name, ushort qtype, CancellationToken cancellationToken) { DnsQueryRawResult raw = await DnsQueryExAsync(name, qtype, cancellationToken).ConfigureAwait(false); try { - if (raw.ResponseCode != DnsResponseCode.NoError) + return ParseAddresses(raw, qtype); + } + finally + { + raw.Dispose(); + } + } + + private DnsResult QueryAddresses(string name, ushort qtype, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = DnsQueryExSync(name, qtype, cancellationToken); + try + { + return ParseAddresses(raw, qtype); + } + finally + { + raw.Dispose(); + } + } + + // ---- Record-list parsers (sync/async agnostic) ---- + + private static DnsResult ParseAddresses(DnsQueryRawResult raw, ushort qtype) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + IntPtr dataPtr = cur + Marshal.SizeOf(); + if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) + { + records.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); + } } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + private static DnsResult ParseSrv(DnsQueryRawResult raw) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + // Gather additional-section A/AAAA records by name so we can attach them. + Dictionary>? glue = null; + ParseAdditionalAddresses(raw.RecordsHead, ref glue); + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SRV && section == Interop.Dnsapi.DNSREC_ANSWER) { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) + IntPtr dataPtr = cur + Marshal.SizeOf(); + Interop.Dnsapi.DNS_SRV_DATA data = Marshal.PtrToStructure(dataPtr); + string target = PtrToString(data.pNameTarget) ?? string.Empty; + IReadOnlyList? attached = null; + if (glue != null && glue.TryGetValue(target, out List? list)) { - IntPtr dataPtr = cur + Marshal.SizeOf(); - if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) - { - records.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); - } + attached = list; } - cur = hdr.pNext; + records.Add(new SrvRecord(target, data.wPort, data.wPriority, data.wWeight, TimeSpan.FromSeconds(hdr.dwTtl), attached)); } + cur = hdr.pNext; + } - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static DnsResult ParseTxt(DnsQueryRawResult raw) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); } - finally + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) { - raw.Dispose(); + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_TEXT && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + // DNS_TXT_DATA: uint dwStringCount; followed by array of PCWSTR. + uint count = (uint)Marshal.ReadInt32(dataPtr); + int ptrSize = IntPtr.Size; + IntPtr stringsPtr = dataPtr + sizeof(uint); + if (ptrSize > sizeof(uint)) + { + // Round up to pointer alignment. + long aligned = ((long)stringsPtr + (ptrSize - 1)) & ~(long)(ptrSize - 1); + stringsPtr = checked((nint)aligned); + } + string[] values = new string[count]; + for (int i = 0; i < count; i++) + { + IntPtr strPtr = Marshal.ReadIntPtr(stringsPtr, i * ptrSize); + values[i] = PtrToString(strPtr) ?? string.Empty; + } + records.Add(new TxtRecord(values, TimeSpan.FromSeconds(hdr.dwTtl))); + } + cur = hdr.pNext; } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static DnsResult ParseSimple(DnsQueryRawResult raw, ushort qtype, + Func selector) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + records.Add(selector(hdr, dataPtr)); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); } private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) @@ -252,7 +372,7 @@ private static void ParseAdditionalAddresses(IntPtr head, ref Dictionary> QuerySimpleAsync(string name, ushort qtype, CancellationToken cancellationToken, Func selector) @@ -260,23 +380,21 @@ private async Task> QuerySimpleAsync(string name, us DnsQueryRawResult raw = await DnsQueryExAsync(name, qtype, cancellationToken).ConfigureAwait(false); try { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - records.Add(selector(hdr, dataPtr)); - } - cur = hdr.pNext; - } - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + return ParseSimple(raw, qtype, selector); + } + finally + { + raw.Dispose(); + } + } + + private DnsResult QuerySimple(string name, ushort qtype, CancellationToken cancellationToken, + Func selector) + { + DnsQueryRawResult raw = DnsQueryExSync(name, qtype, cancellationToken); + try + { + return ParseSimple(raw, qtype, selector); } finally { @@ -284,7 +402,7 @@ private async Task> QuerySimpleAsync(string name, us } } - // ---- Core DnsQueryEx async wrapper ---- + // ---- Core DnsQueryEx wrappers ---- private unsafe Task DnsQueryExAsync(string name, ushort queryType, CancellationToken cancellationToken) { @@ -300,7 +418,7 @@ private unsafe Task DnsQueryExAsync(string name, ushort query private static unsafe string? PtrToString(IntPtr p) => p == IntPtr.Zero ? null : Marshal.PtrToStringUni(p); - // ---- Raw query result returned by the low-level helper ---- + // ---- Raw query result returned by the low-level helpers ---- private readonly struct DnsQueryRawResult : IDisposable { diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs index 86e68a2f26c6ad..326e14a783754b 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs @@ -50,21 +50,7 @@ public DnsQueryAsyncState(IList servers, string name, ushort queryTy public Task StartAsync() { - // DnsQueryEx only supports DNS servers reachable on the standard port 53. - // The sockaddr port field passed to the API must be 0 (the API always - // queries port 53); supplying any non-zero port - even 53 itself - results - // in ERROR_INVALID_PARAMETER. We therefore reject any server endpoint that - // requests a non-default port, since it cannot be honored on Windows. - if (_servers is { Count: > 0 }) - { - foreach (IPEndPoint ep in _servers) - { - if (ep.Port != 0 && ep.Port != 53) - { - throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); - } - } - } + ValidateServerPorts(_servers); try { @@ -248,6 +234,95 @@ private static void QueryCompletionCallback(IntPtr pQueryContext, IntPtr pQueryR } } + // DnsQueryEx only supports DNS servers reachable on the standard port 53. + // The sockaddr port field passed to the API must be 0 (the API always + // queries port 53); supplying any non-zero port - even 53 itself - results + // in ERROR_INVALID_PARAMETER. We therefore reject any server endpoint that + // requests a non-default port, since it cannot be honored on Windows. + private static void ValidateServerPorts(IList servers) + { + if (servers is { Count: > 0 }) + { + foreach (IPEndPoint ep in servers) + { + if (ep.Port != 0 && ep.Port != 53) + { + throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); + } + } + } + } + + // Synchronous DnsQueryEx invocation. By omitting the completion callback the + // API executes the query inline on the calling thread and returns the result + // directly, so no GCHandle / TaskCompletionSource bookkeeping is required. + private unsafe DnsQueryRawResult DnsQueryExSync(string name, ushort queryType, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + ValidateServerPorts(_options.Servers); + + IntPtr namePtr = IntPtr.Zero; + IntPtr serverListPtr = IntPtr.Zero; + try + { + namePtr = Marshal.StringToHGlobalUni(name); + + Interop.Dnsapi.DNS_QUERY_RESULT result = default; + result.Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + + Interop.Dnsapi.DNS_QUERY_REQUEST request = default; + request.Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + request.QueryName = namePtr; + request.QueryType = queryType; + request.QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; + // No completion callback => synchronous execution. + + if (_options.Servers is { Count: > 0 }) + { + BuildAddrArray(_options.Servers, out serverListPtr); + request.pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)serverListPtr; + } + + // A null cancel handle is valid for synchronous queries. + int status = Interop.Dnsapi.DnsQueryEx(&request, &result, null); + + IntPtr records = result.pQueryRecords; + + if (cancellationToken.IsCancellationRequested) + { + if (records != IntPtr.Zero) + { + Interop.Dnsapi.DnsFree(records, Interop.Dnsapi.DnsFreeRecordList); + } + throw new OperationCanceledException(cancellationToken); + } + + DnsResponseCode rc = MapWindowsErrorToResponseCode(status); + + // For NXDOMAIN/NODATA, try to extract the negative-cache TTL from the + // SOA in the authority section if it's present in the record list. + TimeSpan negativeTtl = TimeSpan.Zero; + if (rc != DnsResponseCode.NoError || records == IntPtr.Zero) + { + negativeTtl = ExtractNegativeCacheTtl(records); + } + + return new DnsQueryRawResult(rc, records, negativeTtl); + } + finally + { + if (namePtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(namePtr); + } + if (serverListPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(serverListPtr); + } + } + } + private static unsafe void BuildAddrArray(IList servers, out IntPtr arrayPtr) { int count = servers.Count; diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs index 01e78c216e08cc..f47a53ebfd5123 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -28,28 +28,56 @@ public DnsResolver(DnsResolverOptions options) } public DnsResult ResolveAddresses(string name) - => ResolveAddressesAsync(name).GetAwaiter().GetResult(); + => ResolveAddresses(name, AddressFamily.Unspecified); public DnsResult ResolveAddresses(string name, AddressFamily addressFamily) - => ResolveAddressesAsync(name, addressFamily).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveAddressesCore(name, addressFamily, default); + } public DnsResult ResolveSrv(string name) - => ResolveSrvAsync(name).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveSrvCore(name, default); + } public DnsResult ResolveMx(string name) - => ResolveMxAsync(name).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveMxCore(name, default); + } public DnsResult ResolveTxt(string name) - => ResolveTxtAsync(name).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveTxtCore(name, default); + } public DnsResult ResolveCName(string name) - => ResolveCNameAsync(name).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveCNameCore(name, default); + } public DnsResult ResolvePtr(string name) - => ResolvePtrAsync(name).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolvePtrCore(name, default); + } public DnsResult ResolveNs(string name) - => ResolveNsAsync(name).GetAwaiter().GetResult(); + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveNsCore(name, default); + } public Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) => ResolveAddressesAsync(name, AddressFamily.Unspecified, cancellationToken); diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index 0f240ff92777db..353df8fc6f167a 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -37,6 +37,9 @@ public async ValueTask DisposeAsync() // tests are skipped via SkipTestException rather than failing. Because the single // machine-wide port 53 is shared, these tests run sequentially (see the collection). // + // Each behavioral test is parameterized over the synchronous and asynchronous APIs + // so both code paths are exercised against the same loopback responses. + // // These tests cover the record-parsing and response-handling behavior that the // OuterLoop tests in DnsResolverTest.cs cannot exercise deterministically. [OuterLoop("Binds the loopback DNS port 53 and issues real DnsQueryEx calls.")] @@ -62,16 +65,43 @@ public DnsResolverLoopbackTest(WindowsLoopbackServer fixture) internal DnsResolver Resolver => _resolver ??= CreateResolver(_server); + // ---- Sync/async dispatch helpers ---- + // The synchronous overloads execute inline on the calling thread; the results + // are wrapped in a completed Task so each test can await a single helper. + + private static async Task> ResolveAddresses(bool async, DnsResolver resolver, string name, AddressFamily addressFamily = AddressFamily.Unspecified) + => async ? await resolver.ResolveAddressesAsync(name, addressFamily) : resolver.ResolveAddresses(name, addressFamily); + + private static async Task> ResolveSrv(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveSrvAsync(name) : resolver.ResolveSrv(name); + + private static async Task> ResolveMx(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveMxAsync(name) : resolver.ResolveMx(name); + + private static async Task> ResolveTxt(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveTxtAsync(name) : resolver.ResolveTxt(name); + + private static async Task> ResolveCName(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveCNameAsync(name) : resolver.ResolveCName(name); + + private static async Task> ResolvePtr(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolvePtrAsync(name) : resolver.ResolvePtr(name); + + private static async Task> ResolveNs(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveNsAsync(name) : resolver.ResolveNs(name); + // ---- Address resolution ---- - [Fact] - public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6(bool async) { string name = UniqueName("host"); _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); _server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); - DnsResult result = await Resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -79,14 +109,16 @@ public async Task ResolveAddresses_Unspecified_ReturnsBothV4AndV6() Assert.Contains(result.Records, a => a.Address.ToString() == "fd00::1"); } - [Fact] - public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4(bool async) { string name = UniqueName("v4"); _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 2 }, ttl: 300)); _server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); - DnsResult result = await Resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, Resolver, name); // A succeeds, AAAA returns NXDOMAIN — overall is success because we got addresses. Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); @@ -94,41 +126,47 @@ public async Task ResolveAddresses_IPv4Only_ReturnsOnlyV4() Assert.Equal("10.0.0.2", record.Address.ToString()); } - [Fact] - public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_IPv6Only_ReturnsOnlyV6(bool async) { string name = UniqueName("v6"); _server.AddResponse(name, DnsRecordType.A, b => b.ResponseCode(DnsResponseCode.NxDomain)); _server.AddResponse(name, DnsRecordType.AAAA, b => b.Answer(IPAddress.Parse("fd00::1").GetAddressBytes(), ttl: 60)); - DnsResult result = await Resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); AddressRecord record = Assert.Single(result.Records); Assert.Equal("fd00::1", record.Address.ToString()); } - [Fact] - public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_AddressFamilyV4_QueriesOnlyA(bool async) { string name = UniqueName("famv4"); _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 192, 0, 2, 7 }, ttl: 200)); - DnsResult result = await Resolver.ResolveAddressesAsync(name, AddressFamily.InterNetwork); + DnsResult result = await ResolveAddresses(async, Resolver, name, AddressFamily.InterNetwork); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); AddressRecord record = Assert.Single(result.Records); Assert.Equal("192.0.2.7", record.Address.ToString()); } - [Fact] - public async Task ResolveAddresses_HasTtl() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_HasTtl(bool async) { string name = UniqueName("ttl"); _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 1 }, ttl: 120)); _server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); - DnsResult result = await Resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, Resolver, name); AddressRecord record = Assert.Single(result.Records); // The TTL we sent (120s) should be preserved (custom-server queries bypass the OS cache). @@ -136,8 +174,10 @@ public async Task ResolveAddresses_HasTtl() $"Unexpected TTL: {record.Ttl}"); } - [Fact] - public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain(bool async) { string name = UniqueName("missing"); byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 120); @@ -148,14 +188,16 @@ public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain() .ResponseCode(DnsResponseCode.NxDomain) .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 120)); - DnsResult result = await Resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, Resolver, name); Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); Assert.Empty(result.Records); } - [Fact] - public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords(bool async) { string name = UniqueName("nodata"); byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 30); @@ -165,14 +207,16 @@ public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords() .Authority("test", DnsRecordType.SOA, soaRdata, ttl: 30)); // The name exists but has no A/AAAA records → NODATA for both queries. - DnsResult result = await Resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Empty(result.Records); } - [Fact] - public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable(bool async) { string nodataName = UniqueName("nodata"); byte[] soaRdata = DnsResponseBuilder.BuildSoaRdata("test", 30); @@ -190,11 +234,11 @@ public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable() .ResponseCode(DnsResponseCode.NxDomain) .Authority("test", DnsRecordType.SOA, nxSoaRdata, ttl: 120)); - DnsResult nodata = await Resolver.ResolveAddressesAsync(nodataName); + DnsResult nodata = await ResolveAddresses(async, Resolver, nodataName); Assert.Equal(DnsResponseCode.NoError, nodata.ResponseCode); Assert.Empty(nodata.Records); - DnsResult nxdomain = await Resolver.ResolveAddressesAsync(missingName); + DnsResult nxdomain = await ResolveAddresses(async, Resolver, missingName); Assert.Equal(DnsResponseCode.NxDomain, nxdomain.ResponseCode); Assert.Empty(nxdomain.Records); @@ -203,15 +247,17 @@ public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable() // ---- SRV ---- - [Fact] - public async Task ResolveSrv_ReturnsRecords() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveSrv_ReturnsRecords(bool async) { string name = $"_http._tcp.{UniqueName("svc")}"; _server.AddResponse(name, DnsRecordType.SRV, b => b .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 8080, "node1.test"), ttl: 120) .Answer(DnsResponseBuilder.BuildSrvRdata(20, 50, 8081, "node2.test"), ttl: 120)); - DnsResult result = await Resolver.ResolveSrvAsync(name); + DnsResult result = await ResolveSrv(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -226,8 +272,10 @@ public async Task ResolveSrv_ReturnsRecords() Assert.Equal((ushort)20, s2.Priority); } - [Fact] - public async Task ResolveSrv_IncludesAdditionalAddresses() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveSrv_IncludesAdditionalAddresses(bool async) { string name = $"_http._tcp.{UniqueName("svc")}"; _server.AddResponse(name, DnsRecordType.SRV, b => b @@ -237,7 +285,7 @@ public async Task ResolveSrv_IncludesAdditionalAddresses() .Additional("node2.test", DnsRecordType.A, new byte[] { 10, 0, 0, 11 }, ttl: 120) .Additional("node2.test", DnsRecordType.AAAA, IPAddress.Parse("fd00::11").GetAddressBytes(), ttl: 120)); - DnsResult result = await Resolver.ResolveSrvAsync(name); + DnsResult result = await ResolveSrv(async, Resolver, name); SrvRecord s1 = Assert.Single(result.Records, s => s.Target == "node1.test"); Assert.NotNull(s1.Addresses); @@ -249,14 +297,16 @@ public async Task ResolveSrv_IncludesAdditionalAddresses() Assert.Equal(2, s2.Addresses.Count); } - [Fact] - public async Task ResolveSrv_NoAdditionalAddresses() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveSrv_NoAdditionalAddresses(bool async) { string name = $"_noadd._tcp.{UniqueName("svc")}"; _server.AddResponse(name, DnsRecordType.SRV, b => b .Answer(DnsResponseBuilder.BuildSrvRdata(10, 100, 9090, "noaddr.test"), ttl: 60)); - DnsResult result = await Resolver.ResolveSrvAsync(name); + DnsResult result = await ResolveSrv(async, Resolver, name); SrvRecord record = Assert.Single(result.Records); Assert.Equal("noaddr.test", record.Target); @@ -265,15 +315,17 @@ public async Task ResolveSrv_NoAdditionalAddresses() // ---- MX / TXT / CNAME / PTR / NS ---- - [Fact] - public async Task ResolveMx_ReturnsRecords() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveMx_ReturnsRecords(bool async) { string name = UniqueName("mx"); _server.AddResponse(name, DnsRecordType.MX, b => b .Answer(DnsResponseBuilder.BuildMxRdata(10, "mail1.test"), ttl: 120) .Answer(DnsResponseBuilder.BuildMxRdata(20, "mail2.test"), ttl: 120)); - DnsResult result = await Resolver.ResolveMxAsync(name); + DnsResult result = await ResolveMx(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -283,15 +335,17 @@ public async Task ResolveMx_ReturnsRecords() Assert.Single(result.Records, m => m.Exchange == "mail2.test" && m.Preference == 20); } - [Fact] - public async Task ResolveTxt_ReturnsValues() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveTxt_ReturnsValues(bool async) { string name = UniqueName("txt"); _server.AddResponse(name, DnsRecordType.TXT, b => b .Answer(DnsResponseBuilder.BuildTxtRdata("v=spf1 -all"), ttl: 120) .Answer(DnsResponseBuilder.BuildTxtRdata("part1", "part2"), ttl: 120)); - DnsResult result = await Resolver.ResolveTxtAsync(name); + DnsResult result = await ResolveTxt(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -299,43 +353,49 @@ public async Task ResolveTxt_ReturnsValues() Assert.Contains(result.Records, t => t.Values.Count == 2 && t.Values[0] == "part1" && t.Values[1] == "part2"); } - [Fact] - public async Task ResolveCName_ReturnsCanonicalName() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveCName_ReturnsCanonicalName(bool async) { string name = UniqueName("alias"); _server.AddResponse(name, DnsRecordType.CNAME, b => b .Answer(DnsResponseBuilder.EncodeName("canonical.test"), ttl: 120)); - DnsResult result = await Resolver.ResolveCNameAsync(name); + DnsResult result = await ResolveCName(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); CNameRecord record = Assert.Single(result.Records); Assert.Equal("canonical.test", record.CanonicalName); } - [Fact] - public async Task ResolvePtr_ReturnsName() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolvePtr_ReturnsName(bool async) { string name = $"1.0.0.10.in-addr.{UniqueName("arpa")}"; _server.AddResponse(name, DnsRecordType.PTR, b => b .Answer(DnsResponseBuilder.EncodeName("host.test"), ttl: 120)); - DnsResult result = await Resolver.ResolvePtrAsync(name); + DnsResult result = await ResolvePtr(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); PtrRecord record = Assert.Single(result.Records); Assert.Equal("host.test", record.Name); } - [Fact] - public async Task ResolveNs_ReturnsRecords() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveNs_ReturnsRecords(bool async) { string name = UniqueName("ns"); _server.AddResponse(name, DnsRecordType.NS, b => b .Answer(DnsResponseBuilder.EncodeName("ns1.test"), ttl: 120) .Answer(DnsResponseBuilder.EncodeName("ns2.test"), ttl: 120)); - DnsResult result = await Resolver.ResolveNsAsync(name); + DnsResult result = await ResolveNs(async, Resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); Assert.Equal(2, result.Records.Count); @@ -345,8 +405,10 @@ public async Task ResolveNs_ReturnsRecords() // ---- Custom server endpoint handling ---- - [Fact] - public async Task CustomServer_DefaultPortZero_IsAccepted() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CustomServer_DefaultPortZero_IsAccepted(bool async) { // Port 0 means "use the default DNS port"; DnsQueryEx always queries port 53. using DnsResolver resolver = new DnsResolver(new DnsResolverOptions @@ -358,7 +420,7 @@ public async Task CustomServer_DefaultPortZero_IsAccepted() _server.AddResponse(name, DnsRecordType.A, b => b.Answer(new byte[] { 10, 0, 0, 5 }, ttl: 120)); _server.AddResponse(name, DnsRecordType.AAAA, b => b.ResponseCode(DnsResponseCode.NxDomain)); - DnsResult result = await resolver.ResolveAddressesAsync(name); + DnsResult result = await ResolveAddresses(async, resolver, name); Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); AddressRecord record = Assert.Single(result.Records); diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs index 32e6bdf1aea582..be1f04eacb88a0 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -50,11 +50,25 @@ public async Task DnsResolver_NullName_Throws() await Assert.ThrowsAsync(() => r.ResolveNsAsync(null!)); } + [Fact] + public void DnsResolver_NullName_Throws_Sync() + { + using DnsResolver r = new DnsResolver(); + Assert.Throws(() => r.ResolveAddresses(null!)); + Assert.Throws(() => r.ResolveSrv(null!)); + Assert.Throws(() => r.ResolveMx(null!)); + Assert.Throws(() => r.ResolveTxt(null!)); + Assert.Throws(() => r.ResolveCName(null!)); + Assert.Throws(() => r.ResolvePtr((string)null!)); + Assert.Throws(() => r.ResolveNs(null!)); + } + [Fact] public async Task DnsResolver_EmptyName_Throws() { using DnsResolver r = new DnsResolver(); await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(string.Empty)); + Assert.Throws(() => r.ResolveAddresses(string.Empty)); } [Fact] @@ -65,6 +79,9 @@ public async Task DnsResolver_Disposed_Throws() await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost)); await Assert.ThrowsAsync(() => r.ResolveSrvAsync(TestSrv)); await Assert.ThrowsAsync(() => r.ResolveMxAsync(TestMxHost)); + Assert.Throws(() => r.ResolveAddresses(TestHost)); + Assert.Throws(() => r.ResolveSrv(TestSrv)); + Assert.Throws(() => r.ResolveMx(TestMxHost)); } [Fact] @@ -228,6 +245,7 @@ public async Task DnsResolver_CustomServer_NonStandardPort_ThrowsPlatformNotSupp }; using DnsResolver r = new DnsResolver(opts); await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost)); + Assert.Throws(() => r.ResolveAddresses(TestHost)); } // ---- Reverse-arpa name building (covers both IPv4 and IPv6 paths used by ResolvePtr(IPAddress)) ---- From c375219421aafe39f6a0e74e4f9fc54ea728a720 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 14:15:26 +0200 Subject: [PATCH 06/10] Unify sync/async DnsResolver core methods into single bool-async methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsResolver.Unsupported.cs | 35 +--- .../src/System/Net/DnsResolver.Windows.cs | 173 ++++++------------ .../src/System/Net/DnsResolver.cs | 30 +-- 3 files changed, 79 insertions(+), 159 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs index 8cf6b276e0551b..5c7ff5d963634f 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs @@ -11,46 +11,25 @@ namespace System.Net { public sealed partial class DnsResolver { - private Task> ResolveAddressesCoreAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken) + private Task> ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); - private Task> ResolveSrvCoreAsync(string name, CancellationToken cancellationToken) + private Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); - private Task> ResolveMxCoreAsync(string name, CancellationToken cancellationToken) + private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); - private Task> ResolveTxtCoreAsync(string name, CancellationToken cancellationToken) + private Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); - private Task> ResolveCNameCoreAsync(string name, CancellationToken cancellationToken) + private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); - private Task> ResolvePtrCoreAsync(string name, CancellationToken cancellationToken) + private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); - private Task> ResolveNsCoreAsync(string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolveAddressesCore(string name, AddressFamily addressFamily, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolveSrvCore(string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolveMxCore(string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolveTxtCore(string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolveCNameCore(string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolvePtrCore(string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private DnsResult ResolveNsCore(string name, CancellationToken cancellationToken) + private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) => throw new PlatformNotSupportedException(); } } diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs index b6e16b1eaec498..35346a408038ef 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs @@ -13,80 +13,43 @@ namespace System.Net { public sealed partial class DnsResolver { - // ---- Asynchronous Resolve*Core methods (called from cross-platform DnsResolver) ---- - - private async Task> ResolveAddressesCoreAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken) + // ---- Resolve*Core methods (called from cross-platform DnsResolver) ---- + // + // Each method takes a `bool async` flag controlling whether the underlying + // DnsQueryEx call is issued asynchronously (via the completion-callback state + // machine) or synchronously (inline on the calling thread). When async is + // false the returned Task is already completed, so the synchronous public + // entry points can safely unwrap it without blocking a thread. + + private async Task> ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) { if (addressFamily == AddressFamily.Unspecified) { - // Issue A and AAAA in parallel; merge results. - Task> aTask = QueryAddressesAsync(name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); - Task> aaaaTask = QueryAddressesAsync(name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); - DnsResult aRes = await aTask.ConfigureAwait(false); - DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); - return MergeAddressResults(aRes, aaaaRes); - } - - ushort qtype = AddressFamilyToQueryType(addressFamily); - return await QueryAddressesAsync(name, qtype, cancellationToken).ConfigureAwait(false); - } - - private async Task> ResolveSrvCoreAsync(string name, CancellationToken cancellationToken) - { - DnsQueryRawResult raw = await DnsQueryExAsync(name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); - try - { - return ParseSrv(raw); - } - finally - { - raw.Dispose(); - } - } - - private Task> ResolveMxCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); - - private Task> ResolveCNameCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); - - private Task> ResolvePtrCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); - - private Task> ResolveNsCoreAsync(string name, CancellationToken cancellationToken) - => QuerySimpleAsync(name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); - - private async Task> ResolveTxtCoreAsync(string name, CancellationToken cancellationToken) - { - DnsQueryRawResult raw = await DnsQueryExAsync(name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); - try - { - return ParseTxt(raw); - } - finally - { - raw.Dispose(); - } - } - - // ---- Synchronous Resolve*Core methods (called from cross-platform DnsResolver) ---- - - private DnsResult ResolveAddressesCore(string name, AddressFamily addressFamily, CancellationToken cancellationToken) - { - if (addressFamily == AddressFamily.Unspecified) - { - DnsResult aRes = QueryAddresses(name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); - DnsResult aaaaRes = QueryAddresses(name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); - return MergeAddressResults(aRes, aaaaRes); + if (async) + { + // Issue A and AAAA in parallel; merge results. + Task> aTask = QueryAddresses(async: true, name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); + Task> aaaaTask = QueryAddresses(async: true, name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); + DnsResult aRes = await aTask.ConfigureAwait(false); + DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + else + { + // Synchronous: query A then AAAA sequentially. + DnsResult aRes = await QueryAddresses(async: false, name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken).ConfigureAwait(false); + DnsResult aaaaRes = await QueryAddresses(async: false, name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken).ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } } ushort qtype = AddressFamilyToQueryType(addressFamily); - return QueryAddresses(name, qtype, cancellationToken); + return await QueryAddresses(async, name, qtype, cancellationToken).ConfigureAwait(false); } - private DnsResult ResolveSrvCore(string name, CancellationToken cancellationToken) + private async Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) { - DnsQueryRawResult raw = DnsQueryExSync(name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken); + DnsQueryRawResult raw = await DnsQueryEx(async, name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); try { return ParseSrv(raw); @@ -97,21 +60,21 @@ private DnsResult ResolveSrvCore(string name, CancellationToken cance } } - private DnsResult ResolveMxCore(string name, CancellationToken cancellationToken) - => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); + private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) + => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); - private DnsResult ResolveCNameCore(string name, CancellationToken cancellationToken) - => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); + private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) + => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); - private DnsResult ResolvePtrCore(string name, CancellationToken cancellationToken) - => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); + private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) + => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); - private DnsResult ResolveNsCore(string name, CancellationToken cancellationToken) - => QuerySimple(name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); + private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) + => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); - private DnsResult ResolveTxtCore(string name, CancellationToken cancellationToken) + private async Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) { - DnsQueryRawResult raw = DnsQueryExSync(name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken); + DnsQueryRawResult raw = await DnsQueryEx(async, name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); try { return ParseTxt(raw); @@ -122,7 +85,7 @@ private DnsResult ResolveTxtCore(string name, CancellationToken cance } } - // ---- Per-record-type selectors (shared by sync and async paths) ---- + // ---- Per-record-type selectors (shared by all record types) ---- private static readonly Func s_parseMx = static (hdr, dataPtr) => { @@ -156,11 +119,11 @@ private static ushort AddressFamilyToQueryType(AddressFamily addressFamily) => _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), }; - // ---- Address query helpers ---- + // ---- Query wrappers (issue the query, then parse the record list) ---- - private async Task> QueryAddressesAsync(string name, ushort qtype, CancellationToken cancellationToken) + private async Task> QueryAddresses(bool async, string name, ushort qtype, CancellationToken cancellationToken) { - DnsQueryRawResult raw = await DnsQueryExAsync(name, qtype, cancellationToken).ConfigureAwait(false); + DnsQueryRawResult raw = await DnsQueryEx(async, name, qtype, cancellationToken).ConfigureAwait(false); try { return ParseAddresses(raw, qtype); @@ -171,12 +134,13 @@ private async Task> QueryAddressesAsync(string name, us } } - private DnsResult QueryAddresses(string name, ushort qtype, CancellationToken cancellationToken) + private async Task> QuerySimple(bool async, string name, ushort qtype, CancellationToken cancellationToken, + Func selector) { - DnsQueryRawResult raw = DnsQueryExSync(name, qtype, cancellationToken); + DnsQueryRawResult raw = await DnsQueryEx(async, name, qtype, cancellationToken).ConfigureAwait(false); try { - return ParseAddresses(raw, qtype); + return ParseSimple(raw, qtype, selector); } finally { @@ -184,7 +148,7 @@ private DnsResult QueryAddresses(string name, ushort qtype, Cance } } - // ---- Record-list parsers (sync/async agnostic) ---- + // ---- Record-list parsers ---- private static DnsResult ParseAddresses(DnsQueryRawResult raw, ushort qtype) { @@ -372,47 +336,24 @@ private static void ParseAdditionalAddresses(IntPtr head, ref Dictionary> QuerySimpleAsync(string name, ushort qtype, CancellationToken cancellationToken, - Func selector) - { - DnsQueryRawResult raw = await DnsQueryExAsync(name, qtype, cancellationToken).ConfigureAwait(false); - try - { - return ParseSimple(raw, qtype, selector); - } - finally - { - raw.Dispose(); - } - } + // ---- Core DnsQueryEx wrapper ---- - private DnsResult QuerySimple(string name, ushort qtype, CancellationToken cancellationToken, - Func selector) + private unsafe Task DnsQueryEx(bool async, string name, ushort queryType, CancellationToken cancellationToken) { - DnsQueryRawResult raw = DnsQueryExSync(name, qtype, cancellationToken); - try - { - return ParseSimple(raw, qtype, selector); - } - finally + if (cancellationToken.IsCancellationRequested) { - raw.Dispose(); + return Task.FromCanceled(cancellationToken); } - } - // ---- Core DnsQueryEx wrappers ---- - - private unsafe Task DnsQueryExAsync(string name, ushort queryType, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) + if (async) { - return Task.FromCanceled(cancellationToken); + DnsQueryAsyncState state = new DnsQueryAsyncState(_options.Servers, name, queryType, cancellationToken); + return state.StartAsync(); } - DnsQueryAsyncState state = new DnsQueryAsyncState(_options.Servers, name, queryType, cancellationToken); - return state.StartAsync(); + // Synchronous: the result is produced inline, so the returned Task is + // already completed and the sync entry points unwrap it without blocking. + return Task.FromResult(DnsQueryExSync(name, queryType, cancellationToken)); } private static unsafe string? PtrToString(IntPtr p) => diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs index f47a53ebfd5123..f290a5609803d0 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -34,49 +34,49 @@ public DnsResult ResolveAddresses(string name, AddressFamily addr { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveAddressesCore(name, addressFamily, default); + return ResolveAddressesCore(async: false, name, addressFamily, default).GetAwaiter().GetResult(); } public DnsResult ResolveSrv(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveSrvCore(name, default); + return ResolveSrvCore(async: false, name, default).GetAwaiter().GetResult(); } public DnsResult ResolveMx(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveMxCore(name, default); + return ResolveMxCore(async: false, name, default).GetAwaiter().GetResult(); } public DnsResult ResolveTxt(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveTxtCore(name, default); + return ResolveTxtCore(async: false, name, default).GetAwaiter().GetResult(); } public DnsResult ResolveCName(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveCNameCore(name, default); + return ResolveCNameCore(async: false, name, default).GetAwaiter().GetResult(); } public DnsResult ResolvePtr(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolvePtrCore(name, default); + return ResolvePtrCore(async: false, name, default).GetAwaiter().GetResult(); } public DnsResult ResolveNs(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveNsCore(name, default); + return ResolveNsCore(async: false, name, default).GetAwaiter().GetResult(); } public Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) @@ -86,56 +86,56 @@ public Task> ResolveAddressesAsync(string name, Address { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveAddressesCoreAsync(name, addressFamily, cancellationToken); + return ResolveAddressesCore(async: true, name, addressFamily, cancellationToken); } public Task> ResolveSrvAsync(string name, CancellationToken cancellationToken = default) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveSrvCoreAsync(name, cancellationToken); + return ResolveSrvCore(async: true, name, cancellationToken); } public Task> ResolveMxAsync(string name, CancellationToken cancellationToken = default) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveMxCoreAsync(name, cancellationToken); + return ResolveMxCore(async: true, name, cancellationToken); } public Task> ResolveTxtAsync(string name, CancellationToken cancellationToken = default) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveTxtCoreAsync(name, cancellationToken); + return ResolveTxtCore(async: true, name, cancellationToken); } public Task> ResolveCNameAsync(string name, CancellationToken cancellationToken = default) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveCNameCoreAsync(name, cancellationToken); + return ResolveCNameCore(async: true, name, cancellationToken); } public Task> ResolvePtrAsync(string name, CancellationToken cancellationToken = default) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolvePtrCoreAsync(name, cancellationToken); + return ResolvePtrCore(async: true, name, cancellationToken); } public Task> ResolvePtrAsync(IPAddress address, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(address); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolvePtrCoreAsync(BuildArpaName(address), cancellationToken); + return ResolvePtrCore(async: true, BuildArpaName(address), cancellationToken); } public Task> ResolveNsAsync(string name, CancellationToken cancellationToken = default) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveNsCoreAsync(name, cancellationToken); + return ResolveNsCore(async: true, name, cancellationToken); } public void Dispose() => _disposed = true; From 8196da616106499b8858050ff1eaccd274083704 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 15:11:58 +0200 Subject: [PATCH 07/10] Assert sync DnsResolver core tasks complete synchronously Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsResolver.cs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs index f290a5609803d0..29da9b9260d38c 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -34,49 +35,63 @@ public DnsResult ResolveAddresses(string name, AddressFamily addr { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveAddressesCore(async: false, name, addressFamily, default).GetAwaiter().GetResult(); + Task> task = ResolveAddressesCore(async: false, name, addressFamily, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public DnsResult ResolveSrv(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveSrvCore(async: false, name, default).GetAwaiter().GetResult(); + Task> task = ResolveSrvCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public DnsResult ResolveMx(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveMxCore(async: false, name, default).GetAwaiter().GetResult(); + Task> task = ResolveMxCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public DnsResult ResolveTxt(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveTxtCore(async: false, name, default).GetAwaiter().GetResult(); + Task> task = ResolveTxtCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public DnsResult ResolveCName(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveCNameCore(async: false, name, default).GetAwaiter().GetResult(); + Task> task = ResolveCNameCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public DnsResult ResolvePtr(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolvePtrCore(async: false, name, default).GetAwaiter().GetResult(); + Task> task = ResolvePtrCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public DnsResult ResolveNs(string name) { ValidateName(name); ObjectDisposedException.ThrowIf(_disposed, this); - return ResolveNsCore(async: false, name, default).GetAwaiter().GetResult(); + Task> task = ResolveNsCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); } public Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) From 522c3968be9ba134bd184ea7ee1fb41b244fded1 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 17:26:02 +0200 Subject: [PATCH 08/10] Refactor Windows DnsResolver logic into DnsResolverPal (PAL pattern) Move all platform-specific logic from DnsResolver.Windows.cs and DnsResolver.WindowsAsync.cs into a new DnsResolverPal.Windows.cs static PAL class, mirroring the existing NameResolutionPal pattern. The cross-platform Resolve*Core methods remain on DnsResolver and delegate to the PAL, providing a seam for future instrumentation and telemetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System.Net.NameResolution.csproj | 7 +- .../src/System/Net/DnsResolver.Unsupported.cs | 35 - .../src/System/Net/DnsResolver.Windows.cs | 386 --------- .../System/Net/DnsResolver.WindowsAsync.cs | 429 ---------- .../src/System/Net/DnsResolver.cs | 30 + .../System/Net/DnsResolverPal.Unsupported.cs | 34 + .../src/System/Net/DnsResolverPal.Windows.cs | 801 ++++++++++++++++++ 7 files changed, 868 insertions(+), 854 deletions(-) delete mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Unsupported.cs delete mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs delete mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Unsupported.cs create mode 100644 src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs diff --git a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj index 89da5be9e94bf0..4cdb503467afc2 100644 --- a/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj +++ b/src/libraries/System.Net.NameResolution/src/System.Net.NameResolution.csproj @@ -43,8 +43,7 @@ - - + @@ -89,7 +88,7 @@ - + - + > ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - - private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) - => throw new PlatformNotSupportedException(); - } -} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs deleted file mode 100644 index 35346a408038ef..00000000000000 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.Windows.cs +++ /dev/null @@ -1,386 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Buffers.Binary; -using System.Collections.Generic; -using System.Diagnostics; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; - -namespace System.Net -{ - public sealed partial class DnsResolver - { - // ---- Resolve*Core methods (called from cross-platform DnsResolver) ---- - // - // Each method takes a `bool async` flag controlling whether the underlying - // DnsQueryEx call is issued asynchronously (via the completion-callback state - // machine) or synchronously (inline on the calling thread). When async is - // false the returned Task is already completed, so the synchronous public - // entry points can safely unwrap it without blocking a thread. - - private async Task> ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) - { - if (addressFamily == AddressFamily.Unspecified) - { - if (async) - { - // Issue A and AAAA in parallel; merge results. - Task> aTask = QueryAddresses(async: true, name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); - Task> aaaaTask = QueryAddresses(async: true, name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); - DnsResult aRes = await aTask.ConfigureAwait(false); - DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); - return MergeAddressResults(aRes, aaaaRes); - } - else - { - // Synchronous: query A then AAAA sequentially. - DnsResult aRes = await QueryAddresses(async: false, name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken).ConfigureAwait(false); - DnsResult aaaaRes = await QueryAddresses(async: false, name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken).ConfigureAwait(false); - return MergeAddressResults(aRes, aaaaRes); - } - } - - ushort qtype = AddressFamilyToQueryType(addressFamily); - return await QueryAddresses(async, name, qtype, cancellationToken).ConfigureAwait(false); - } - - private async Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) - { - DnsQueryRawResult raw = await DnsQueryEx(async, name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); - try - { - return ParseSrv(raw); - } - finally - { - raw.Dispose(); - } - } - - private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) - => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); - - private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) - => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); - - private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) - => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); - - private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) - => QuerySimple(async, name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); - - private async Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) - { - DnsQueryRawResult raw = await DnsQueryEx(async, name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); - try - { - return ParseTxt(raw); - } - finally - { - raw.Dispose(); - } - } - - // ---- Per-record-type selectors (shared by all record types) ---- - - private static readonly Func s_parseMx = static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_MX_DATA data = Marshal.PtrToStructure(dataPtr); - return new MxRecord(PtrToString(data.pNameExchange) ?? string.Empty, data.wPreference, TimeSpan.FromSeconds(hdr.dwTtl)); - }; - - private static readonly Func s_parseCName = static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_CNAME_DATA data = Marshal.PtrToStructure(dataPtr); - return new CNameRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); - }; - - private static readonly Func s_parsePtr = static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_PTR_DATA data = Marshal.PtrToStructure(dataPtr); - return new PtrRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); - }; - - private static readonly Func s_parseNs = static (hdr, dataPtr) => - { - Interop.Dnsapi.DNS_NS_DATA data = Marshal.PtrToStructure(dataPtr); - return new NsRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); - }; - - private static ushort AddressFamilyToQueryType(AddressFamily addressFamily) => - addressFamily switch - { - AddressFamily.InterNetwork => Interop.Dnsapi.DNS_TYPE_A, - AddressFamily.InterNetworkV6 => Interop.Dnsapi.DNS_TYPE_AAAA, - _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), - }; - - // ---- Query wrappers (issue the query, then parse the record list) ---- - - private async Task> QueryAddresses(bool async, string name, ushort qtype, CancellationToken cancellationToken) - { - DnsQueryRawResult raw = await DnsQueryEx(async, name, qtype, cancellationToken).ConfigureAwait(false); - try - { - return ParseAddresses(raw, qtype); - } - finally - { - raw.Dispose(); - } - } - - private async Task> QuerySimple(bool async, string name, ushort qtype, CancellationToken cancellationToken, - Func selector) - { - DnsQueryRawResult raw = await DnsQueryEx(async, name, qtype, cancellationToken).ConfigureAwait(false); - try - { - return ParseSimple(raw, qtype, selector); - } - finally - { - raw.Dispose(); - } - } - - // ---- Record-list parsers ---- - - private static DnsResult ParseAddresses(DnsQueryRawResult raw, ushort qtype) - { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } - - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) - { - records.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); - } - } - cur = hdr.pNext; - } - - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); - } - - private static DnsResult ParseSrv(DnsQueryRawResult raw) - { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } - - // Gather additional-section A/AAAA records by name so we can attach them. - Dictionary>? glue = null; - ParseAdditionalAddresses(raw.RecordsHead, ref glue); - - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SRV && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - Interop.Dnsapi.DNS_SRV_DATA data = Marshal.PtrToStructure(dataPtr); - string target = PtrToString(data.pNameTarget) ?? string.Empty; - IReadOnlyList? attached = null; - if (glue != null && glue.TryGetValue(target, out List? list)) - { - attached = list; - } - records.Add(new SrvRecord(target, data.wPort, data.wPriority, data.wWeight, TimeSpan.FromSeconds(hdr.dwTtl), attached)); - } - cur = hdr.pNext; - } - - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); - } - - private static DnsResult ParseTxt(DnsQueryRawResult raw) - { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } - - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == Interop.Dnsapi.DNS_TYPE_TEXT && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - // DNS_TXT_DATA: uint dwStringCount; followed by array of PCWSTR. - uint count = (uint)Marshal.ReadInt32(dataPtr); - int ptrSize = IntPtr.Size; - IntPtr stringsPtr = dataPtr + sizeof(uint); - if (ptrSize > sizeof(uint)) - { - // Round up to pointer alignment. - long aligned = ((long)stringsPtr + (ptrSize - 1)) & ~(long)(ptrSize - 1); - stringsPtr = checked((nint)aligned); - } - string[] values = new string[count]; - for (int i = 0; i < count; i++) - { - IntPtr strPtr = Marshal.ReadIntPtr(stringsPtr, i * ptrSize); - values[i] = PtrToString(strPtr) ?? string.Empty; - } - records.Add(new TxtRecord(values, TimeSpan.FromSeconds(hdr.dwTtl))); - } - cur = hdr.pNext; - } - - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); - } - - private static DnsResult ParseSimple(DnsQueryRawResult raw, ushort qtype, - Func selector) - { - if (raw.ResponseCode != DnsResponseCode.NoError) - { - return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); - } - - List records = new(); - for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - records.Add(selector(hdr, dataPtr)); - } - cur = hdr.pNext; - } - - return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); - } - - private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) - { - if (a.Records.Count > 0 || b.Records.Count > 0) - { - AddressRecord[] merged = new AddressRecord[a.Records.Count + b.Records.Count]; - int idx = 0; - for (int i = 0; i < a.Records.Count; i++) merged[idx++] = a.Records[i]; - for (int i = 0; i < b.Records.Count; i++) merged[idx++] = b.Records[i]; - return new DnsResult(DnsResponseCode.NoError, merged, TimeSpan.Zero); - } - - DnsResponseCode chosenRc = a.ResponseCode == DnsResponseCode.NxDomain || b.ResponseCode == DnsResponseCode.NxDomain - ? DnsResponseCode.NxDomain - : (a.ResponseCode != DnsResponseCode.NoError ? a.ResponseCode : b.ResponseCode); - TimeSpan negTtl = a.NegativeCacheTtl > TimeSpan.Zero ? a.NegativeCacheTtl : b.NegativeCacheTtl; - return new DnsResult(chosenRc, null, negTtl); - } - - private static bool TryParseAddress(ushort recordType, IntPtr dataPtr, out IPAddress? address) - { - if (recordType == Interop.Dnsapi.DNS_TYPE_A) - { - uint ip = (uint)Marshal.ReadInt32(dataPtr); - address = new IPAddress(ip); - return true; - } - if (recordType == Interop.Dnsapi.DNS_TYPE_AAAA) - { - byte[] bytes = new byte[16]; - Marshal.Copy(dataPtr, bytes, 0, 16); - address = new IPAddress(bytes); - return true; - } - address = null; - return false; - } - - private static void ParseAdditionalAddresses(IntPtr head, ref Dictionary>? glue) - { - for (IntPtr cur = head; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - bool isAddress = hdr.wType == Interop.Dnsapi.DNS_TYPE_A || hdr.wType == Interop.Dnsapi.DNS_TYPE_AAAA; - if (section == Interop.Dnsapi.DNSREC_ADDITIONAL && isAddress) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) - { - string name = PtrToString(hdr.pName) ?? string.Empty; - glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); - if (!glue.TryGetValue(name, out List? list)) - { - list = new List(); - glue[name] = list; - } - list.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); - } - } - cur = hdr.pNext; - } - } - - // ---- Core DnsQueryEx wrapper ---- - - private unsafe Task DnsQueryEx(bool async, string name, ushort queryType, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - if (async) - { - DnsQueryAsyncState state = new DnsQueryAsyncState(_options.Servers, name, queryType, cancellationToken); - return state.StartAsync(); - } - - // Synchronous: the result is produced inline, so the returned Task is - // already completed and the sync entry points unwrap it without blocking. - return Task.FromResult(DnsQueryExSync(name, queryType, cancellationToken)); - } - - private static unsafe string? PtrToString(IntPtr p) => - p == IntPtr.Zero ? null : Marshal.PtrToStringUni(p); - - // ---- Raw query result returned by the low-level helpers ---- - - private readonly struct DnsQueryRawResult : IDisposable - { - public DnsResponseCode ResponseCode { get; } - public IntPtr RecordsHead { get; } - public TimeSpan NegativeCacheTtl { get; } - - public DnsQueryRawResult(DnsResponseCode responseCode, IntPtr recordsHead, TimeSpan negativeCacheTtl) - { - ResponseCode = responseCode; - RecordsHead = recordsHead; - NegativeCacheTtl = negativeCacheTtl; - } - - public void Dispose() - { - if (RecordsHead != IntPtr.Zero) - { - Interop.Dnsapi.DnsFree(RecordsHead, Interop.Dnsapi.DnsFreeRecordList); - } - } - } - } -} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs deleted file mode 100644 index 326e14a783754b..00000000000000 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.WindowsAsync.cs +++ /dev/null @@ -1,429 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Diagnostics; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; - -namespace System.Net -{ - public sealed partial class DnsResolver - { - // Cached callback so we don't allocate a new delegate per query. - private static readonly Interop.Dnsapi.DnsQueryCompletionRoutine s_completionCallback = QueryCompletionCallback; - private static readonly IntPtr s_completionCallbackPtr = - Marshal.GetFunctionPointerForDelegate(s_completionCallback); - - /// - /// Holds the unmanaged state for a single DnsQueryEx invocation, including - /// the request/result/cancel structures, the pinned query name, and the - /// completion TaskCompletionSource. - /// - private sealed unsafe class DnsQueryAsyncState - { - private readonly TaskCompletionSource _tcs = - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly string _name; - private readonly ushort _queryType; - private readonly CancellationToken _cancellationToken; - private readonly IList _servers; - - private GCHandle _selfHandle; - private IntPtr _namePtr; - private IntPtr _requestPtr; - private IntPtr _resultPtr; - private IntPtr _cancelPtr; - private IntPtr _serverListPtr; // DNS_ADDR_ARRAY buffer - private CancellationTokenRegistration _ctReg; - private int _completed; // 0 = pending, 1 = completed (callback or sync) - - public DnsQueryAsyncState(IList servers, string name, ushort queryType, CancellationToken cancellationToken) - { - _servers = servers; - _name = name; - _queryType = queryType; - _cancellationToken = cancellationToken; - } - - public Task StartAsync() - { - ValidateServerPorts(_servers); - - try - { - _namePtr = Marshal.StringToHGlobalUni(_name); - _resultPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); - NativeMemory.Clear((void*)_resultPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); - Interop.Dnsapi.DNS_QUERY_RESULT* result = (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr; - result->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; - - _cancelPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); - NativeMemory.Clear((void*)_cancelPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); - - _selfHandle = GCHandle.Alloc(this); - - _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); - NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); - Interop.Dnsapi.DNS_QUERY_REQUEST* req = (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr; - req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; - req->QueryName = _namePtr; - req->QueryType = _queryType; - req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; - req->InterfaceIndex = 0; - req->pQueryCompletionCallback = s_completionCallbackPtr; - req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); - - if (_servers is { Count: > 0 }) - { - BuildAddrArray(_servers, out _serverListPtr); - req->pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)_serverListPtr; - } - - int status = Interop.Dnsapi.DnsQueryEx( - (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, - (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, - (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); - - if (status == Interop.Dnsapi.DNS_REQUEST_PENDING) - { - // Async. Register cancellation; the callback will free resources and complete the TCS. - if (_cancellationToken.CanBeCanceled) - { - _ctReg = _cancellationToken.UnsafeRegister(static (s, _) => - { - DnsQueryAsyncState st = (DnsQueryAsyncState)s!; - st.CancelAndAbort(); - }, this); - } - } - else - { - // Synchronous completion. The callback was NOT invoked; we complete inline. - CompleteFromResult(status); - } - } - catch - { - FreeAll(); - throw; - } - - return _tcs.Task; - } - - private void CancelAndAbort() - { - if (_cancelPtr != IntPtr.Zero) - { - Interop.Dnsapi.DnsCancelQuery((Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); - } - } - - /// - /// Invoked from either the native callback or the sync completion path. - /// Parses the QueryStatus and pQueryRecords from the result struct, - /// completes the TCS, and frees state. - /// - internal void CompleteFromResult(int status) - { - if (Interlocked.Exchange(ref _completed, 1) != 0) - { - return; - } - - try - { - _ctReg.Dispose(); - - Interop.Dnsapi.DNS_QUERY_RESULT result = Marshal.PtrToStructure(_resultPtr); - IntPtr records = result.pQueryRecords; - - if (_cancellationToken.IsCancellationRequested) - { - if (records != IntPtr.Zero) - { - Interop.Dnsapi.DnsFree(records, Interop.Dnsapi.DnsFreeRecordList); - } - _tcs.TrySetCanceled(_cancellationToken); - return; - } - - DnsResponseCode rc = MapWindowsErrorToResponseCode(status); - - // For NXDOMAIN/NODATA, try to extract the negative-cache TTL from the - // SOA in the authority section if it's present in the record list. - TimeSpan negativeTtl = TimeSpan.Zero; - if (rc != DnsResponseCode.NoError || records == IntPtr.Zero) - { - negativeTtl = ExtractNegativeCacheTtl(records); - } - - _tcs.TrySetResult(new DnsQueryRawResult(rc, records, negativeTtl)); - } - catch (Exception ex) - { - _tcs.TrySetException(ex); - } - finally - { - FreeAll(); - } - } - - private void FreeAll() - { - if (_namePtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(_namePtr); - _namePtr = IntPtr.Zero; - } - if (_requestPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(_requestPtr); - _requestPtr = IntPtr.Zero; - } - if (_resultPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(_resultPtr); - _resultPtr = IntPtr.Zero; - } - if (_cancelPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(_cancelPtr); - _cancelPtr = IntPtr.Zero; - } - if (_serverListPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(_serverListPtr); - _serverListPtr = IntPtr.Zero; - } - if (_selfHandle.IsAllocated) - { - _selfHandle.Free(); - } - } - } - - // Native callback. Marshaled to a function pointer once at startup. - // We use a managed delegate (no UnmanagedCallersOnly) because callers - // currently pass it via Marshal.GetFunctionPointerForDelegate. - private static void QueryCompletionCallback(IntPtr pQueryContext, IntPtr pQueryResults) - { - try - { - GCHandle handle = GCHandle.FromIntPtr(pQueryContext); - DnsQueryAsyncState? state = handle.Target as DnsQueryAsyncState; - if (state == null) - { - return; - } - - // pQueryResults points to the same DNS_QUERY_RESULT we passed in. - unsafe - { - Interop.Dnsapi.DNS_QUERY_RESULT* res = (Interop.Dnsapi.DNS_QUERY_RESULT*)pQueryResults; - state.CompleteFromResult(res->QueryStatus); - } - } - catch - { - // Swallow — never allow exceptions to propagate into native code. - } - } - - // DnsQueryEx only supports DNS servers reachable on the standard port 53. - // The sockaddr port field passed to the API must be 0 (the API always - // queries port 53); supplying any non-zero port - even 53 itself - results - // in ERROR_INVALID_PARAMETER. We therefore reject any server endpoint that - // requests a non-default port, since it cannot be honored on Windows. - private static void ValidateServerPorts(IList servers) - { - if (servers is { Count: > 0 }) - { - foreach (IPEndPoint ep in servers) - { - if (ep.Port != 0 && ep.Port != 53) - { - throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); - } - } - } - } - - // Synchronous DnsQueryEx invocation. By omitting the completion callback the - // API executes the query inline on the calling thread and returns the result - // directly, so no GCHandle / TaskCompletionSource bookkeeping is required. - private unsafe DnsQueryRawResult DnsQueryExSync(string name, ushort queryType, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - ValidateServerPorts(_options.Servers); - - IntPtr namePtr = IntPtr.Zero; - IntPtr serverListPtr = IntPtr.Zero; - try - { - namePtr = Marshal.StringToHGlobalUni(name); - - Interop.Dnsapi.DNS_QUERY_RESULT result = default; - result.Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; - - Interop.Dnsapi.DNS_QUERY_REQUEST request = default; - request.Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; - request.QueryName = namePtr; - request.QueryType = queryType; - request.QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; - // No completion callback => synchronous execution. - - if (_options.Servers is { Count: > 0 }) - { - BuildAddrArray(_options.Servers, out serverListPtr); - request.pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)serverListPtr; - } - - // A null cancel handle is valid for synchronous queries. - int status = Interop.Dnsapi.DnsQueryEx(&request, &result, null); - - IntPtr records = result.pQueryRecords; - - if (cancellationToken.IsCancellationRequested) - { - if (records != IntPtr.Zero) - { - Interop.Dnsapi.DnsFree(records, Interop.Dnsapi.DnsFreeRecordList); - } - throw new OperationCanceledException(cancellationToken); - } - - DnsResponseCode rc = MapWindowsErrorToResponseCode(status); - - // For NXDOMAIN/NODATA, try to extract the negative-cache TTL from the - // SOA in the authority section if it's present in the record list. - TimeSpan negativeTtl = TimeSpan.Zero; - if (rc != DnsResponseCode.NoError || records == IntPtr.Zero) - { - negativeTtl = ExtractNegativeCacheTtl(records); - } - - return new DnsQueryRawResult(rc, records, negativeTtl); - } - finally - { - if (namePtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(namePtr); - } - if (serverListPtr != IntPtr.Zero) - { - Marshal.FreeHGlobal(serverListPtr); - } - } - } - - private static unsafe void BuildAddrArray(IList servers, out IntPtr arrayPtr) - { - int count = servers.Count; - int headerSize = sizeof(Interop.Dnsapi.DNS_ADDR_ARRAY); - int addrSize = sizeof(Interop.Dnsapi.DNS_ADDR); - int totalSize = headerSize + addrSize * count; - - arrayPtr = Marshal.AllocHGlobal(totalSize); - NativeMemory.Clear((void*)arrayPtr, (nuint)totalSize); - - Interop.Dnsapi.DNS_ADDR_ARRAY* arr = (Interop.Dnsapi.DNS_ADDR_ARRAY*)arrayPtr; - arr->MaxCount = (uint)count; - arr->AddrCount = (uint)count; - arr->Family = (ushort)(servers[0].AddressFamily == AddressFamily.InterNetwork ? Interop.Dnsapi.AF_INET : Interop.Dnsapi.AF_INET6); - - byte* addrBase = (byte*)arrayPtr + headerSize; - for (int i = 0; i < count; i++) - { - IPEndPoint ep = servers[i]; - byte* sa = addrBase + (i * addrSize); - WriteSockAddr(sa, ep); - } - } - - // Writes a SOCKADDR_IN or SOCKADDR_IN6 representation into the destination buffer. - // The buffer must be at least 28 bytes (sizeof sockaddr_in6). - private static unsafe void WriteSockAddr(byte* dest, IPEndPoint ep) - { - // DnsQueryEx always queries DNS servers on port 53 and requires the sockaddr - // port field to be left as 0. Supplying a non-zero port (even 53) is rejected - // with ERROR_INVALID_PARAMETER. Non-default ports are validated and rejected - // earlier in StartAsync, so the port is always written as 0 here. - if (ep.AddressFamily == AddressFamily.InterNetwork) - { - // sockaddr_in: ushort family, ushort port (net order), uint addr, 8 bytes zero - *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET; - // dest[2..3] (port) left zero - Span addrBytes = stackalloc byte[4]; - ep.Address.TryWriteBytes(addrBytes, out _); - dest[4] = addrBytes[0]; - dest[5] = addrBytes[1]; - dest[6] = addrBytes[2]; - dest[7] = addrBytes[3]; - // dest[8..15] left zero - } - else if (ep.AddressFamily == AddressFamily.InterNetworkV6) - { - // sockaddr_in6: ushort family, ushort port, uint flowinfo, 16-byte addr, uint scope_id - *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET6; - // dest[2..3] (port) left zero - // flowinfo (dest[4..7]) left zero - Span addrBytes = stackalloc byte[16]; - ep.Address.TryWriteBytes(addrBytes, out _); - for (int i = 0; i < 16; i++) - { - dest[8 + i] = addrBytes[i]; - } - // scope_id (dest[24..27]) - uint scopeId = (uint)ep.Address.ScopeId; - dest[24] = (byte)(scopeId & 0xff); - dest[25] = (byte)((scopeId >> 8) & 0xff); - dest[26] = (byte)((scopeId >> 16) & 0xff); - dest[27] = (byte)((scopeId >> 24) & 0xff); - } - else - { - throw new ArgumentException(SR.net_invalid_ip_addr); - } - } - - private static DnsResponseCode MapWindowsErrorToResponseCode(int status) => - status switch - { - Interop.Dnsapi.ERROR_SUCCESS => DnsResponseCode.NoError, - Interop.Dnsapi.DNS_INFO_NO_RECORDS => DnsResponseCode.NoError, // NODATA: name exists but no records of requested type - Interop.Dnsapi.DNS_ERROR_RCODE_NAME_ERROR => DnsResponseCode.NxDomain, - Interop.Dnsapi.DNS_ERROR_RCODE_FORMAT_ERROR => DnsResponseCode.FormatError, - Interop.Dnsapi.DNS_ERROR_RCODE_SERVER_FAILURE => DnsResponseCode.ServerFailure, - Interop.Dnsapi.DNS_ERROR_RCODE_NOT_IMPLEMENTED => DnsResponseCode.NotImplemented, - Interop.Dnsapi.DNS_ERROR_RCODE_REFUSED => DnsResponseCode.Refused, - _ => DnsResponseCode.ServerFailure, - }; - - private static TimeSpan ExtractNegativeCacheTtl(IntPtr head) - { - // Walk the record list looking for an SOA in the authority section. - for (IntPtr cur = head; cur != IntPtr.Zero; ) - { - Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); - uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; - if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SOA && section == Interop.Dnsapi.DNSREC_AUTHORITY) - { - IntPtr dataPtr = cur + Marshal.SizeOf(); - Interop.Dnsapi.DNS_SOA_DATA soa = Marshal.PtrToStructure(dataPtr); - // RFC 2308 §5: negative cache TTL = min(SOA TTL, SOA MINIMUM) - uint negTtl = Math.Min(hdr.dwTtl, soa.dwDefaultTtl); - return TimeSpan.FromSeconds(negTtl); - } - cur = hdr.pNext; - } - return TimeSpan.Zero; - } - } -} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs index 29da9b9260d38c..1bf3a0521690fa 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -161,6 +161,36 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } + // ---- Resolve*Core methods ---- + // + // These instance methods are the platform-agnostic seam between the public + // API and the platform abstraction layer (DnsResolverPal). They exist as a + // dedicated layer so that instrumentation and telemetry can be added here + // later without touching either the public surface or the PAL. The `async` + // flag is threaded down to the PAL, which issues the underlying query + // synchronously or asynchronously accordingly. + + private Task> ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) + => DnsResolverPal.ResolveAddresses(_options.Servers, async, name, addressFamily, cancellationToken); + + private Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) + => DnsResolverPal.ResolveSrv(_options.Servers, async, name, cancellationToken); + + private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) + => DnsResolverPal.ResolveMx(_options.Servers, async, name, cancellationToken); + + private Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) + => DnsResolverPal.ResolveTxt(_options.Servers, async, name, cancellationToken); + + private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) + => DnsResolverPal.ResolveCName(_options.Servers, async, name, cancellationToken); + + private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) + => DnsResolverPal.ResolvePtr(_options.Servers, async, name, cancellationToken); + + private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) + => DnsResolverPal.ResolveNs(_options.Servers, async, name, cancellationToken); + private static void ValidateName(string name) { ArgumentException.ThrowIfNullOrEmpty(name); diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Unsupported.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Unsupported.cs new file mode 100644 index 00000000000000..932a91b4822d0f --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Unsupported.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + internal static partial class DnsResolverPal + { + public static Task> ResolveAddresses(IList servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + public static Task> ResolveSrv(IList servers, bool async, string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + public static Task> ResolveMx(IList servers, bool async, string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + public static Task> ResolveTxt(IList servers, bool async, string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + public static Task> ResolveCName(IList servers, bool async, string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + public static Task> ResolvePtr(IList servers, bool async, string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + + public static Task> ResolveNs(IList servers, bool async, string name, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException(); + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs new file mode 100644 index 00000000000000..16264e8ddd314c --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs @@ -0,0 +1,801 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + // Windows DNS resolver implementation backed by the Win32 DnsQueryEx API. + internal static partial class DnsResolverPal + { + // ---- Public PAL entry points (one per record type) ---- + // + // Each method takes a `bool async` flag controlling whether the underlying + // DnsQueryEx call is issued asynchronously (via the completion-callback state + // machine) or synchronously (inline on the calling thread). When async is + // false the returned Task is already completed, so the synchronous public + // entry points can safely unwrap it without blocking a thread. + + public static async Task> ResolveAddresses(IList servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) + { + if (addressFamily == AddressFamily.Unspecified) + { + if (async) + { + // Issue A and AAAA in parallel; merge results. + Task> aTask = QueryAddresses(servers, async: true, name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken); + Task> aaaaTask = QueryAddresses(servers, async: true, name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken); + DnsResult aRes = await aTask.ConfigureAwait(false); + DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + else + { + // Synchronous: query A then AAAA sequentially. + DnsResult aRes = await QueryAddresses(servers, async: false, name, Interop.Dnsapi.DNS_TYPE_A, cancellationToken).ConfigureAwait(false); + DnsResult aaaaRes = await QueryAddresses(servers, async: false, name, Interop.Dnsapi.DNS_TYPE_AAAA, cancellationToken).ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + } + + ushort qtype = AddressFamilyToQueryType(addressFamily); + return await QueryAddresses(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + } + + public static async Task> ResolveSrv(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); + try + { + return ParseSrv(raw); + } + finally + { + raw.Dispose(); + } + } + + public static Task> ResolveMx(IList servers, bool async, string name, CancellationToken cancellationToken) + => QuerySimple(servers, async, name, Interop.Dnsapi.DNS_TYPE_MX, cancellationToken, s_parseMx); + + public static Task> ResolveCName(IList servers, bool async, string name, CancellationToken cancellationToken) + => QuerySimple(servers, async, name, Interop.Dnsapi.DNS_TYPE_CNAME, cancellationToken, s_parseCName); + + public static Task> ResolvePtr(IList servers, bool async, string name, CancellationToken cancellationToken) + => QuerySimple(servers, async, name, Interop.Dnsapi.DNS_TYPE_PTR, cancellationToken, s_parsePtr); + + public static Task> ResolveNs(IList servers, bool async, string name, CancellationToken cancellationToken) + => QuerySimple(servers, async, name, Interop.Dnsapi.DNS_TYPE_NS, cancellationToken, s_parseNs); + + public static async Task> ResolveTxt(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); + try + { + return ParseTxt(raw); + } + finally + { + raw.Dispose(); + } + } + + // ---- Per-record-type selectors (shared by all record types) ---- + + private static readonly Func s_parseMx = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_MX_DATA data = Marshal.PtrToStructure(dataPtr); + return new MxRecord(PtrToString(data.pNameExchange) ?? string.Empty, data.wPreference, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static readonly Func s_parseCName = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_CNAME_DATA data = Marshal.PtrToStructure(dataPtr); + return new CNameRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static readonly Func s_parsePtr = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_PTR_DATA data = Marshal.PtrToStructure(dataPtr); + return new PtrRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static readonly Func s_parseNs = static (hdr, dataPtr) => + { + Interop.Dnsapi.DNS_NS_DATA data = Marshal.PtrToStructure(dataPtr); + return new NsRecord(PtrToString(data.pNameHost) ?? string.Empty, TimeSpan.FromSeconds(hdr.dwTtl)); + }; + + private static ushort AddressFamilyToQueryType(AddressFamily addressFamily) => + addressFamily switch + { + AddressFamily.InterNetwork => Interop.Dnsapi.DNS_TYPE_A, + AddressFamily.InterNetworkV6 => Interop.Dnsapi.DNS_TYPE_AAAA, + _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + }; + + // ---- Query wrappers (issue the query, then parse the record list) ---- + + private static async Task> QueryAddresses(IList servers, bool async, string name, ushort qtype, CancellationToken cancellationToken) + { + DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + try + { + return ParseAddresses(raw, qtype); + } + finally + { + raw.Dispose(); + } + } + + private static async Task> QuerySimple(IList servers, bool async, string name, ushort qtype, CancellationToken cancellationToken, + Func selector) + { + DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + try + { + return ParseSimple(raw, qtype, selector); + } + finally + { + raw.Dispose(); + } + } + + // ---- Record-list parsers ---- + + private static DnsResult ParseAddresses(DnsQueryRawResult raw, ushort qtype) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) + { + records.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); + } + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static DnsResult ParseSrv(DnsQueryRawResult raw) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + // Gather additional-section A/AAAA records by name so we can attach them. + Dictionary>? glue = null; + ParseAdditionalAddresses(raw.RecordsHead, ref glue); + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SRV && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + Interop.Dnsapi.DNS_SRV_DATA data = Marshal.PtrToStructure(dataPtr); + string target = PtrToString(data.pNameTarget) ?? string.Empty; + IReadOnlyList? attached = null; + if (glue != null && glue.TryGetValue(target, out List? list)) + { + attached = list; + } + records.Add(new SrvRecord(target, data.wPort, data.wPriority, data.wWeight, TimeSpan.FromSeconds(hdr.dwTtl), attached)); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static DnsResult ParseTxt(DnsQueryRawResult raw) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_TEXT && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + // DNS_TXT_DATA: uint dwStringCount; followed by array of PCWSTR. + uint count = (uint)Marshal.ReadInt32(dataPtr); + int ptrSize = IntPtr.Size; + IntPtr stringsPtr = dataPtr + sizeof(uint); + if (ptrSize > sizeof(uint)) + { + // Round up to pointer alignment. + long aligned = ((long)stringsPtr + (ptrSize - 1)) & ~(long)(ptrSize - 1); + stringsPtr = checked((nint)aligned); + } + string[] values = new string[count]; + for (int i = 0; i < count; i++) + { + IntPtr strPtr = Marshal.ReadIntPtr(stringsPtr, i * ptrSize); + values[i] = PtrToString(strPtr) ?? string.Empty; + } + records.Add(new TxtRecord(values, TimeSpan.FromSeconds(hdr.dwTtl))); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static DnsResult ParseSimple(DnsQueryRawResult raw, ushort qtype, + Func selector) + { + if (raw.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(raw.ResponseCode, null, raw.NegativeCacheTtl); + } + + List records = new(); + for (IntPtr cur = raw.RecordsHead; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == qtype && section == Interop.Dnsapi.DNSREC_ANSWER) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + records.Add(selector(hdr, dataPtr)); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, TimeSpan.Zero); + } + + private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) + { + if (a.Records.Count > 0 || b.Records.Count > 0) + { + AddressRecord[] merged = new AddressRecord[a.Records.Count + b.Records.Count]; + int idx = 0; + for (int i = 0; i < a.Records.Count; i++) merged[idx++] = a.Records[i]; + for (int i = 0; i < b.Records.Count; i++) merged[idx++] = b.Records[i]; + return new DnsResult(DnsResponseCode.NoError, merged, TimeSpan.Zero); + } + + DnsResponseCode chosenRc = a.ResponseCode == DnsResponseCode.NxDomain || b.ResponseCode == DnsResponseCode.NxDomain + ? DnsResponseCode.NxDomain + : (a.ResponseCode != DnsResponseCode.NoError ? a.ResponseCode : b.ResponseCode); + TimeSpan negTtl = a.NegativeCacheTtl > TimeSpan.Zero ? a.NegativeCacheTtl : b.NegativeCacheTtl; + return new DnsResult(chosenRc, null, negTtl); + } + + private static bool TryParseAddress(ushort recordType, IntPtr dataPtr, out IPAddress? address) + { + if (recordType == Interop.Dnsapi.DNS_TYPE_A) + { + uint ip = (uint)Marshal.ReadInt32(dataPtr); + address = new IPAddress(ip); + return true; + } + if (recordType == Interop.Dnsapi.DNS_TYPE_AAAA) + { + byte[] bytes = new byte[16]; + Marshal.Copy(dataPtr, bytes, 0, 16); + address = new IPAddress(bytes); + return true; + } + address = null; + return false; + } + + private static void ParseAdditionalAddresses(IntPtr head, ref Dictionary>? glue) + { + for (IntPtr cur = head; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + bool isAddress = hdr.wType == Interop.Dnsapi.DNS_TYPE_A || hdr.wType == Interop.Dnsapi.DNS_TYPE_AAAA; + if (section == Interop.Dnsapi.DNSREC_ADDITIONAL && isAddress) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) + { + string name = PtrToString(hdr.pName) ?? string.Empty; + glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!glue.TryGetValue(name, out List? list)) + { + list = new List(); + glue[name] = list; + } + list.Add(new AddressRecord(address!, TimeSpan.FromSeconds(hdr.dwTtl))); + } + } + cur = hdr.pNext; + } + } + + // ---- Core DnsQueryEx wrapper ---- + + private static Task DnsQueryEx(IList servers, bool async, string name, ushort queryType, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (async) + { + DnsQueryAsyncState state = new DnsQueryAsyncState(servers, name, queryType, cancellationToken); + return state.StartAsync(); + } + + // Synchronous: the result is produced inline, so the returned Task is + // already completed and the sync entry points unwrap it without blocking. + return Task.FromResult(DnsQueryExSync(servers, name, queryType, cancellationToken)); + } + + private static unsafe string? PtrToString(IntPtr p) => + p == IntPtr.Zero ? null : Marshal.PtrToStringUni(p); + + // ---- Asynchronous DnsQueryEx state machine ---- + + // Cached callback so we don't allocate a new delegate per query. + private static readonly Interop.Dnsapi.DnsQueryCompletionRoutine s_completionCallback = QueryCompletionCallback; + private static readonly IntPtr s_completionCallbackPtr = + Marshal.GetFunctionPointerForDelegate(s_completionCallback); + + /// + /// Holds the unmanaged state for a single DnsQueryEx invocation, including + /// the request/result/cancel structures, the pinned query name, and the + /// completion TaskCompletionSource. + /// + private sealed unsafe class DnsQueryAsyncState + { + private readonly TaskCompletionSource _tcs = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly string _name; + private readonly ushort _queryType; + private readonly CancellationToken _cancellationToken; + private readonly IList _servers; + + private GCHandle _selfHandle; + private IntPtr _namePtr; + private IntPtr _requestPtr; + private IntPtr _resultPtr; + private IntPtr _cancelPtr; + private IntPtr _serverListPtr; // DNS_ADDR_ARRAY buffer + private CancellationTokenRegistration _ctReg; + private int _completed; // 0 = pending, 1 = completed (callback or sync) + + public DnsQueryAsyncState(IList servers, string name, ushort queryType, CancellationToken cancellationToken) + { + _servers = servers; + _name = name; + _queryType = queryType; + _cancellationToken = cancellationToken; + } + + public Task StartAsync() + { + ValidateServerPorts(_servers); + + try + { + _namePtr = Marshal.StringToHGlobalUni(_name); + _resultPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); + NativeMemory.Clear((void*)_resultPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_RESULT)); + Interop.Dnsapi.DNS_QUERY_RESULT* result = (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr; + result->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + + _cancelPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); + NativeMemory.Clear((void*)_cancelPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_CANCEL)); + + _selfHandle = GCHandle.Alloc(this); + + _requestPtr = Marshal.AllocHGlobal(sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); + NativeMemory.Clear((void*)_requestPtr, (nuint)sizeof(Interop.Dnsapi.DNS_QUERY_REQUEST)); + Interop.Dnsapi.DNS_QUERY_REQUEST* req = (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr; + req->Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + req->QueryName = _namePtr; + req->QueryType = _queryType; + req->QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; + req->InterfaceIndex = 0; + req->pQueryCompletionCallback = s_completionCallbackPtr; + req->pQueryContext = GCHandle.ToIntPtr(_selfHandle); + + if (_servers is { Count: > 0 }) + { + BuildAddrArray(_servers, out _serverListPtr); + req->pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)_serverListPtr; + } + + int status = Interop.Dnsapi.DnsQueryEx( + (Interop.Dnsapi.DNS_QUERY_REQUEST*)_requestPtr, + (Interop.Dnsapi.DNS_QUERY_RESULT*)_resultPtr, + (Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); + + if (status == Interop.Dnsapi.DNS_REQUEST_PENDING) + { + // Async. Register cancellation; the callback will free resources and complete the TCS. + if (_cancellationToken.CanBeCanceled) + { + _ctReg = _cancellationToken.UnsafeRegister(static (s, _) => + { + DnsQueryAsyncState st = (DnsQueryAsyncState)s!; + st.CancelAndAbort(); + }, this); + } + } + else + { + // Synchronous completion. The callback was NOT invoked; we complete inline. + CompleteFromResult(status); + } + } + catch + { + FreeAll(); + throw; + } + + return _tcs.Task; + } + + private void CancelAndAbort() + { + if (_cancelPtr != IntPtr.Zero) + { + Interop.Dnsapi.DnsCancelQuery((Interop.Dnsapi.DNS_QUERY_CANCEL*)_cancelPtr); + } + } + + /// + /// Invoked from either the native callback or the sync completion path. + /// Parses the QueryStatus and pQueryRecords from the result struct, + /// completes the TCS, and frees state. + /// + internal void CompleteFromResult(int status) + { + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + + try + { + _ctReg.Dispose(); + + Interop.Dnsapi.DNS_QUERY_RESULT result = Marshal.PtrToStructure(_resultPtr); + IntPtr records = result.pQueryRecords; + + if (_cancellationToken.IsCancellationRequested) + { + if (records != IntPtr.Zero) + { + Interop.Dnsapi.DnsFree(records, Interop.Dnsapi.DnsFreeRecordList); + } + _tcs.TrySetCanceled(_cancellationToken); + return; + } + + DnsResponseCode rc = MapWindowsErrorToResponseCode(status); + + // For NXDOMAIN/NODATA, try to extract the negative-cache TTL from the + // SOA in the authority section if it's present in the record list. + TimeSpan negativeTtl = TimeSpan.Zero; + if (rc != DnsResponseCode.NoError || records == IntPtr.Zero) + { + negativeTtl = ExtractNegativeCacheTtl(records); + } + + _tcs.TrySetResult(new DnsQueryRawResult(rc, records, negativeTtl)); + } + catch (Exception ex) + { + _tcs.TrySetException(ex); + } + finally + { + FreeAll(); + } + } + + private void FreeAll() + { + if (_namePtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_namePtr); + _namePtr = IntPtr.Zero; + } + if (_requestPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_requestPtr); + _requestPtr = IntPtr.Zero; + } + if (_resultPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_resultPtr); + _resultPtr = IntPtr.Zero; + } + if (_cancelPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_cancelPtr); + _cancelPtr = IntPtr.Zero; + } + if (_serverListPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_serverListPtr); + _serverListPtr = IntPtr.Zero; + } + if (_selfHandle.IsAllocated) + { + _selfHandle.Free(); + } + } + } + + // Native callback. Marshaled to a function pointer once at startup. + // We use a managed delegate (no UnmanagedCallersOnly) because callers + // currently pass it via Marshal.GetFunctionPointerForDelegate. + private static void QueryCompletionCallback(IntPtr pQueryContext, IntPtr pQueryResults) + { + try + { + GCHandle handle = GCHandle.FromIntPtr(pQueryContext); + DnsQueryAsyncState? state = handle.Target as DnsQueryAsyncState; + if (state == null) + { + return; + } + + // pQueryResults points to the same DNS_QUERY_RESULT we passed in. + unsafe + { + Interop.Dnsapi.DNS_QUERY_RESULT* res = (Interop.Dnsapi.DNS_QUERY_RESULT*)pQueryResults; + state.CompleteFromResult(res->QueryStatus); + } + } + catch + { + // Swallow — never allow exceptions to propagate into native code. + } + } + + // DnsQueryEx only supports DNS servers reachable on the standard port 53. + // The sockaddr port field passed to the API must be 0 (the API always + // queries port 53); supplying any non-zero port - even 53 itself - results + // in ERROR_INVALID_PARAMETER. We therefore reject any server endpoint that + // requests a non-default port, since it cannot be honored on Windows. + private static void ValidateServerPorts(IList servers) + { + if (servers is { Count: > 0 }) + { + foreach (IPEndPoint ep in servers) + { + if (ep.Port != 0 && ep.Port != 53) + { + throw new PlatformNotSupportedException(SR.net_dns_custom_port_not_supported); + } + } + } + } + + // Synchronous DnsQueryEx invocation. By omitting the completion callback the + // API executes the query inline on the calling thread and returns the result + // directly, so no GCHandle / TaskCompletionSource bookkeeping is required. + private static unsafe DnsQueryRawResult DnsQueryExSync(IList servers, string name, ushort queryType, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + ValidateServerPorts(servers); + + IntPtr namePtr = IntPtr.Zero; + IntPtr serverListPtr = IntPtr.Zero; + try + { + namePtr = Marshal.StringToHGlobalUni(name); + + Interop.Dnsapi.DNS_QUERY_RESULT result = default; + result.Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + + Interop.Dnsapi.DNS_QUERY_REQUEST request = default; + request.Version = Interop.Dnsapi.DNS_QUERY_REQUEST_VERSION1; + request.QueryName = namePtr; + request.QueryType = queryType; + request.QueryOptions = Interop.Dnsapi.DNS_QUERY_STANDARD; + // No completion callback => synchronous execution. + + if (servers is { Count: > 0 }) + { + BuildAddrArray(servers, out serverListPtr); + request.pDnsServerList = (Interop.Dnsapi.DNS_ADDR_ARRAY*)serverListPtr; + } + + // A null cancel handle is valid for synchronous queries. + int status = Interop.Dnsapi.DnsQueryEx(&request, &result, null); + + IntPtr records = result.pQueryRecords; + + if (cancellationToken.IsCancellationRequested) + { + if (records != IntPtr.Zero) + { + Interop.Dnsapi.DnsFree(records, Interop.Dnsapi.DnsFreeRecordList); + } + throw new OperationCanceledException(cancellationToken); + } + + DnsResponseCode rc = MapWindowsErrorToResponseCode(status); + + // For NXDOMAIN/NODATA, try to extract the negative-cache TTL from the + // SOA in the authority section if it's present in the record list. + TimeSpan negativeTtl = TimeSpan.Zero; + if (rc != DnsResponseCode.NoError || records == IntPtr.Zero) + { + negativeTtl = ExtractNegativeCacheTtl(records); + } + + return new DnsQueryRawResult(rc, records, negativeTtl); + } + finally + { + if (namePtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(namePtr); + } + if (serverListPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(serverListPtr); + } + } + } + + private static unsafe void BuildAddrArray(IList servers, out IntPtr arrayPtr) + { + int count = servers.Count; + int headerSize = sizeof(Interop.Dnsapi.DNS_ADDR_ARRAY); + int addrSize = sizeof(Interop.Dnsapi.DNS_ADDR); + int totalSize = headerSize + addrSize * count; + + arrayPtr = Marshal.AllocHGlobal(totalSize); + NativeMemory.Clear((void*)arrayPtr, (nuint)totalSize); + + Interop.Dnsapi.DNS_ADDR_ARRAY* arr = (Interop.Dnsapi.DNS_ADDR_ARRAY*)arrayPtr; + arr->MaxCount = (uint)count; + arr->AddrCount = (uint)count; + arr->Family = (ushort)(servers[0].AddressFamily == AddressFamily.InterNetwork ? Interop.Dnsapi.AF_INET : Interop.Dnsapi.AF_INET6); + + byte* addrBase = (byte*)arrayPtr + headerSize; + for (int i = 0; i < count; i++) + { + IPEndPoint ep = servers[i]; + byte* sa = addrBase + (i * addrSize); + WriteSockAddr(sa, ep); + } + } + + // Writes a SOCKADDR_IN or SOCKADDR_IN6 representation into the destination buffer. + // The buffer must be at least 28 bytes (sizeof sockaddr_in6). + private static unsafe void WriteSockAddr(byte* dest, IPEndPoint ep) + { + // DnsQueryEx always queries DNS servers on port 53 and requires the sockaddr + // port field to be left as 0. Supplying a non-zero port (even 53) is rejected + // with ERROR_INVALID_PARAMETER. Non-default ports are validated and rejected + // earlier, so the port is always written as 0 here. + if (ep.AddressFamily == AddressFamily.InterNetwork) + { + // sockaddr_in: ushort family, ushort port (net order), uint addr, 8 bytes zero + *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET; + // dest[2..3] (port) left zero + Span addrBytes = stackalloc byte[4]; + ep.Address.TryWriteBytes(addrBytes, out _); + dest[4] = addrBytes[0]; + dest[5] = addrBytes[1]; + dest[6] = addrBytes[2]; + dest[7] = addrBytes[3]; + // dest[8..15] left zero + } + else if (ep.AddressFamily == AddressFamily.InterNetworkV6) + { + // sockaddr_in6: ushort family, ushort port, uint flowinfo, 16-byte addr, uint scope_id + *(ushort*)(dest + 0) = Interop.Dnsapi.AF_INET6; + // dest[2..3] (port) left zero + // flowinfo (dest[4..7]) left zero + Span addrBytes = stackalloc byte[16]; + ep.Address.TryWriteBytes(addrBytes, out _); + for (int i = 0; i < 16; i++) + { + dest[8 + i] = addrBytes[i]; + } + // scope_id (dest[24..27]) + uint scopeId = (uint)ep.Address.ScopeId; + dest[24] = (byte)(scopeId & 0xff); + dest[25] = (byte)((scopeId >> 8) & 0xff); + dest[26] = (byte)((scopeId >> 16) & 0xff); + dest[27] = (byte)((scopeId >> 24) & 0xff); + } + else + { + throw new ArgumentException(SR.net_invalid_ip_addr); + } + } + + private static DnsResponseCode MapWindowsErrorToResponseCode(int status) => + status switch + { + Interop.Dnsapi.ERROR_SUCCESS => DnsResponseCode.NoError, + Interop.Dnsapi.DNS_INFO_NO_RECORDS => DnsResponseCode.NoError, // NODATA: name exists but no records of requested type + Interop.Dnsapi.DNS_ERROR_RCODE_NAME_ERROR => DnsResponseCode.NxDomain, + Interop.Dnsapi.DNS_ERROR_RCODE_FORMAT_ERROR => DnsResponseCode.FormatError, + Interop.Dnsapi.DNS_ERROR_RCODE_SERVER_FAILURE => DnsResponseCode.ServerFailure, + Interop.Dnsapi.DNS_ERROR_RCODE_NOT_IMPLEMENTED => DnsResponseCode.NotImplemented, + Interop.Dnsapi.DNS_ERROR_RCODE_REFUSED => DnsResponseCode.Refused, + _ => DnsResponseCode.ServerFailure, + }; + + private static TimeSpan ExtractNegativeCacheTtl(IntPtr head) + { + // Walk the record list looking for an SOA in the authority section. + for (IntPtr cur = head; cur != IntPtr.Zero; ) + { + Interop.Dnsapi.DNS_RECORD_HEADER hdr = Marshal.PtrToStructure(cur); + uint section = hdr.Flags & Interop.Dnsapi.DNSREC_SECTION_MASK; + if (hdr.wType == Interop.Dnsapi.DNS_TYPE_SOA && section == Interop.Dnsapi.DNSREC_AUTHORITY) + { + IntPtr dataPtr = cur + Marshal.SizeOf(); + Interop.Dnsapi.DNS_SOA_DATA soa = Marshal.PtrToStructure(dataPtr); + // RFC 2308 §5: negative cache TTL = min(SOA TTL, SOA MINIMUM) + uint negTtl = Math.Min(hdr.dwTtl, soa.dwDefaultTtl); + return TimeSpan.FromSeconds(negTtl); + } + cur = hdr.pNext; + } + return TimeSpan.Zero; + } + + // ---- Raw query result returned by the low-level helpers ---- + + private readonly struct DnsQueryRawResult : IDisposable + { + public DnsResponseCode ResponseCode { get; } + public IntPtr RecordsHead { get; } + public TimeSpan NegativeCacheTtl { get; } + + public DnsQueryRawResult(DnsResponseCode responseCode, IntPtr recordsHead, TimeSpan negativeCacheTtl) + { + ResponseCode = responseCode; + RecordsHead = recordsHead; + NegativeCacheTtl = negativeCacheTtl; + } + + public void Dispose() + { + if (RecordsHead != IntPtr.Zero) + { + Interop.Dnsapi.DnsFree(RecordsHead, Interop.Dnsapi.DnsFreeRecordList); + } + } + } + } +} From 6157140048caf5425516c27e4bd70cef64236cb3 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 18:02:48 +0200 Subject: [PATCH 09/10] Add telemetry to DnsResolver Instrument the Resolve*Core seam in DnsResolver with the existing NameResolutionTelemetry infrastructure (EventSource counters, the DnsLookup Activity span, and the dns.lookup.duration metric), matching the static Dns class. When no diagnostics consumer is enabled the PAL task is returned directly, keeping the common path allocation-free and preserving the synchronous-completion invariant the sync Resolve* methods depend on. Extend NameResolutionActivity.Stop to accept a string[] answer so the new record types can populate the dns.answers tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsResolver.cs | 95 ++++++++++++++++--- .../src/System/Net/NameResolutionTelemetry.cs | 1 + 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs index 1bf3a0521690fa..3e91681887ee8d 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Diagnostics; using System.Net.Sockets; using System.Threading; @@ -164,32 +165,102 @@ public ValueTask DisposeAsync() // ---- Resolve*Core methods ---- // // These instance methods are the platform-agnostic seam between the public - // API and the platform abstraction layer (DnsResolverPal). They exist as a - // dedicated layer so that instrumentation and telemetry can be added here - // later without touching either the public surface or the PAL. The `async` - // flag is threaded down to the PAL, which issues the underlying query - // synchronously or asynchronously accordingly. + // API and the platform abstraction layer (DnsResolverPal). They issue the + // underlying query through the PAL (synchronously or asynchronously per the + // `async` flag) and wrap it with telemetry. When no diagnostics consumer is + // enabled, the PAL task is returned directly so the common path stays + // allocation-free and, on the synchronous path, completes inline. private Task> ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) - => DnsResolverPal.ResolveAddresses(_options.Servers, async, name, addressFamily, cancellationToken); + { + Task> task = DnsResolverPal.ResolveAddresses(_options.Servers, async, name, addressFamily, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Address.ToString())) + : task; + } private Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) - => DnsResolverPal.ResolveSrv(_options.Servers, async, name, cancellationToken); + { + Task> task = DnsResolverPal.ResolveSrv(_options.Servers, async, name, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Target)) + : task; + } private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) - => DnsResolverPal.ResolveMx(_options.Servers, async, name, cancellationToken); + { + Task> task = DnsResolverPal.ResolveMx(_options.Servers, async, name, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Exchange)) + : task; + } private Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) - => DnsResolverPal.ResolveTxt(_options.Servers, async, name, cancellationToken); + { + Task> task = DnsResolverPal.ResolveTxt(_options.Servers, async, name, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => + { + List values = new(); + foreach (TxtRecord record in r.Records) + { + values.AddRange(record.Values); + } + return values.ToArray(); + }) + : task; + } private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) - => DnsResolverPal.ResolveCName(_options.Servers, async, name, cancellationToken); + { + Task> task = DnsResolverPal.ResolveCName(_options.Servers, async, name, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.CanonicalName)) + : task; + } private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) - => DnsResolverPal.ResolvePtr(_options.Servers, async, name, cancellationToken); + { + Task> task = DnsResolverPal.ResolvePtr(_options.Servers, async, name, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Name)) + : task; + } private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) - => DnsResolverPal.ResolveNs(_options.Servers, async, name, cancellationToken); + { + Task> task = DnsResolverPal.ResolveNs(_options.Servers, async, name, cancellationToken); + return NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Name)) + : task; + } + + private static async Task> ResolveWithTelemetry(string name, Task> queryTask, Func, string[]> getAnswers) + { + NameResolutionActivity activity = NameResolutionTelemetry.Log.BeforeResolution(name); + try + { + DnsResult result = await queryTask.ConfigureAwait(false); + NameResolutionTelemetry.Log.AfterResolution(name, in activity, getAnswers(result)); + return result; + } + catch (Exception ex) + { + NameResolutionTelemetry.Log.AfterResolution(name, in activity, answer: null, exception: ex); + throw; + } + } + + private static string[] MapAnswers(DnsResult result, Func selector) + { + IReadOnlyList records = result.Records; + string[] answers = new string[records.Count]; + for (int i = 0; i < records.Count; i++) + { + answers[i] = selector(records[i]); + } + return answers; + } private static void ValidateName(string name) { diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/NameResolutionTelemetry.cs b/src/libraries/System.Net.NameResolution/src/System/Net/NameResolutionTelemetry.cs index 59c2b47cc00a3a..1bc519b285579f 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/NameResolutionTelemetry.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/NameResolutionTelemetry.cs @@ -195,6 +195,7 @@ public bool Stop(object? answer, Exception? exception, out TimeSpan duration) string[]? answerValues = answer switch { string h => [h], + string[] values => values, IPAddress[] addresses => GetStringValues(addresses), IPHostEntry entry => GetStringValues(entry.AddressList), _ => null From 6f99d7b1c1f5ee7e46f85a135546d8f143b5b63b Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 11 Jun 2026 18:31:55 +0200 Subject: [PATCH 10/10] Fix DnsResolver telemetry under-measuring synchronous queries The Resolve*Core methods invoked the PAL eagerly and only then wrapped the resulting task with telemetry. On the synchronous path the Windows PAL executes the query while creating the task, so BeforeResolution ran after most of the work was done and the recorded duration excluded the query time. Defer the PAL invocation behind a Func so telemetry brackets the entire query for both sync and async paths. Adds a regression test asserting the recorded dns.lookup.duration covers a delayed server response on both sync and async paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/DnsResolver.cs | 72 +++++++------------ .../DnsResolverLoopbackTest.cs | 58 +++++++++++++++ 2 files changed, 85 insertions(+), 45 deletions(-) diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs index 3e91681887ee8d..483ae143f82fb4 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -169,37 +169,29 @@ public ValueTask DisposeAsync() // underlying query through the PAL (synchronously or asynchronously per the // `async` flag) and wrap it with telemetry. When no diagnostics consumer is // enabled, the PAL task is returned directly so the common path stays - // allocation-free and, on the synchronous path, completes inline. + // allocation-free and, on the synchronous path, completes inline. When + // telemetry is enabled, the PAL call is deferred into ResolveWithTelemetry so + // that the measurement starts before the query runs - on the synchronous path + // the PAL would otherwise execute the entire query before telemetry began. private Task> ResolveAddressesCore(bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolveAddresses(_options.Servers, async, name, addressFamily, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Address.ToString())) - : task; - } + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolveAddresses(_options.Servers, async, name, addressFamily, cancellationToken), static r => MapAnswers(r, static a => a.Address.ToString())) + : DnsResolverPal.ResolveAddresses(_options.Servers, async, name, addressFamily, cancellationToken); private Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolveSrv(_options.Servers, async, name, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Target)) - : task; - } + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolveSrv(_options.Servers, async, name, cancellationToken), static r => MapAnswers(r, static a => a.Target)) + : DnsResolverPal.ResolveSrv(_options.Servers, async, name, cancellationToken); private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolveMx(_options.Servers, async, name, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Exchange)) - : task; - } + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolveMx(_options.Servers, async, name, cancellationToken), static r => MapAnswers(r, static a => a.Exchange)) + : DnsResolverPal.ResolveMx(_options.Servers, async, name, cancellationToken); private Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolveTxt(_options.Servers, async, name, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolveTxt(_options.Servers, async, name, cancellationToken), static r => { List values = new(); foreach (TxtRecord record in r.Records) @@ -208,39 +200,29 @@ private Task> ResolveTxtCore(bool async, string name, Cance } return values.ToArray(); }) - : task; - } + : DnsResolverPal.ResolveTxt(_options.Servers, async, name, cancellationToken); private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolveCName(_options.Servers, async, name, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.CanonicalName)) - : task; - } + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolveCName(_options.Servers, async, name, cancellationToken), static r => MapAnswers(r, static a => a.CanonicalName)) + : DnsResolverPal.ResolveCName(_options.Servers, async, name, cancellationToken); private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolvePtr(_options.Servers, async, name, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Name)) - : task; - } + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolvePtr(_options.Servers, async, name, cancellationToken), static r => MapAnswers(r, static a => a.Name)) + : DnsResolverPal.ResolvePtr(_options.Servers, async, name, cancellationToken); private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) - { - Task> task = DnsResolverPal.ResolveNs(_options.Servers, async, name, cancellationToken); - return NameResolutionTelemetry.AnyDiagnosticsEnabled() - ? ResolveWithTelemetry(name, task, static r => MapAnswers(r, static a => a.Name)) - : task; - } + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, () => DnsResolverPal.ResolveNs(_options.Servers, async, name, cancellationToken), static r => MapAnswers(r, static a => a.Name)) + : DnsResolverPal.ResolveNs(_options.Servers, async, name, cancellationToken); - private static async Task> ResolveWithTelemetry(string name, Task> queryTask, Func, string[]> getAnswers) + private static async Task> ResolveWithTelemetry(string name, Func>> resolve, Func, string[]> getAnswers) { NameResolutionActivity activity = NameResolutionTelemetry.Log.BeforeResolution(name); try { - DnsResult result = await queryTask.ConfigureAwait(false); + DnsResult result = await resolve().ConfigureAwait(false); NameResolutionTelemetry.Log.AfterResolution(name, in activity, getAnswers(result)); return result; } diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs index 353df8fc6f167a..eed201f7612d1c 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -457,5 +460,60 @@ public async Task ResolveAddresses_CancellationInFlight_Throws() serverCanContinue.Set(); } + + // ---- Telemetry ---- + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_RecordsDurationMetric_CoversQueryTime(bool async) + { + TimeSpan delay = TimeSpan.FromMilliseconds(250); + string name = UniqueName("metrics"); + _server.AddRawResponse(name, DnsRecordType.A, queryId => + { + Thread.Sleep(delay); + return DnsResponseBuilder.For(queryId, DnsResponseBuilder.EncodeName(name), DnsRecordType.A) + .Answer(new byte[] { 10, 0, 0, 9 }, ttl: 120) + .Build(); + }); + + List> measurements = new(); + using (MeterListener listener = new()) + { + listener.InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == "System.Net.NameResolution" && instrument.Name == "dns.lookup.duration") + { + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + { + lock (measurements) + { + measurements.Add(new Measurement(measurement, tags)); + } + }); + listener.Start(); + + // A single A query so exactly one lookup is measured. + DnsResult result = await ResolveAddresses(async, Resolver, name, AddressFamily.InterNetwork); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + } + + Measurement[] matching = measurements + .Where(m => m.Tags.ToArray().Any(t => t.Key == "dns.question.name" && (string?)t.Value == name)) + .ToArray(); + + Measurement recorded = Assert.Single(matching); + + // The measured duration must span the actual query, and so must be at least + // the server's artificial response delay - the lookup cannot legitimately + // complete before the server replies. Regression: on the synchronous path + // telemetry used to start only after the PAL had already begun executing the + // query, so the recorded duration was shorter than the server delay. + Assert.True(recorded.Value >= delay.TotalSeconds, $"Expected a lookup duration of at least {delay.TotalSeconds:0.###}s but got {recorded.Value:0.###}s."); + } } }