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..fce1cc072d8e04 --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsApi.cs @@ -0,0 +1,70 @@ +// 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 + { + // ---- 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..c656df7e6c6b13 --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/Dnsapi/Interop.DnsTypes.cs @@ -0,0 +1,204 @@ +// 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 int IsNetworkQueryRequired; // BOOL + public uint RequiredNetworkIndex; + 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 union follows the header; because the + // header contains two pointers, its size (and therefore the Data offset) depends on + // the pointer width - 24 bytes on 32-bit and 32 bytes on 64-bit. Callers must use + // Unsafe.SizeOf() rather than a hard-coded offset. + [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 struct DNS_AAAA_DATA + { + public Ip6AddressBytes Ip6Address; + } + + [System.Runtime.CompilerServices.InlineArray(16)] + internal struct Ip6AddressBytes + { + private byte _element0; + } + + [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..4d3b37ae9d506c 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,112 @@ 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 ResolvePtr(System.Net.IPAddress address) { 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..c8a9b5f4fe42c3 100644 --- a/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx +++ b/src/libraries/System.Net.NameResolution/src/Resources/Strings.resx @@ -75,4 +75,16 @@ System.Net.NameResolution is not supported on this platform. - \ No newline at end of file + + Specifying a custom DNS server port is not supported on this platform; only the default DNS port (53) can be used. + + + All DNS server endpoints must belong to the same address family. + + + The DNS server list must not contain null entries. + + + The DNS name '{0}' is not a valid domain name. + + 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..26804896d30e9d 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,12 @@ + + + + @@ -76,6 +88,14 @@ + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000000..03055bba782e26 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.Resolve.cs @@ -0,0 +1,200 @@ +// 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 +{ + public static partial class Dns + { + // Shared DnsResolver instance used by the static Resolve* methods. + // Uses the system-configured DNS servers. + private static DnsResolver? s_defaultResolver; + + private static DnsResolver DefaultResolver => + LazyInitializer.EnsureInitialized(ref s_defaultResolver, static () => new DnsResolver()); + + /// + /// Resolves the IPv4 (A) and IPv6 (AAAA) addresses for the specified host name using the system-configured DNS servers. + /// + /// The host name to resolve. + /// A containing the address records. + /// is or empty. + public static DnsResult ResolveAddresses(string name) + => DefaultResolver.ResolveAddresses(name); + + /// + /// Resolves the addresses of the specified family for the specified host name using the system-configured DNS servers. + /// + /// The host name to resolve. + /// + /// The address family to query. Use for A records, + /// for AAAA records, or + /// for both. + /// + /// A containing the address records. + /// is or empty. + public static DnsResult ResolveAddresses(string name, AddressFamily addressFamily) + => DefaultResolver.ResolveAddresses(name, addressFamily); + + /// + /// Asynchronously resolves the IPv4 (A) and IPv6 (AAAA) addresses for the specified host name using the system-configured DNS servers. + /// + /// The host name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the address records. + /// is or empty. + public static Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveAddressesAsync(name, cancellationToken); + + /// + /// Asynchronously resolves the addresses of the specified family for the specified host name using the system-configured DNS servers. + /// + /// The host name to resolve. + /// + /// The address family to query. Use for A records, + /// for AAAA records, or + /// for both. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the address records. + /// is or empty. + public static Task> ResolveAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveAddressesAsync(name, addressFamily, cancellationToken); + + /// + /// Resolves the service (SRV) records for the specified name using the system-configured DNS servers. + /// + /// The name to resolve, typically in the form _service._protocol.host. + /// A containing the SRV records. + /// is or empty. + public static DnsResult ResolveSrv(string name) + => DefaultResolver.ResolveSrv(name); + + /// + /// Asynchronously resolves the service (SRV) records for the specified name using the system-configured DNS servers. + /// + /// The name to resolve, typically in the form _service._protocol.host. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the SRV records. + /// is or empty. + public static Task> ResolveSrvAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveSrvAsync(name, cancellationToken); + + /// + /// Resolves the mail exchange (MX) records for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A containing the MX records. + /// is or empty. + public static DnsResult ResolveMx(string name) + => DefaultResolver.ResolveMx(name); + + /// + /// Asynchronously resolves the mail exchange (MX) records for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the MX records. + /// is or empty. + public static Task> ResolveMxAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveMxAsync(name, cancellationToken); + + /// + /// Resolves the text (TXT) records for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A containing the TXT records. + /// is or empty. + public static DnsResult ResolveTxt(string name) + => DefaultResolver.ResolveTxt(name); + + /// + /// Asynchronously resolves the text (TXT) records for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the TXT records. + /// is or empty. + public static Task> ResolveTxtAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveTxtAsync(name, cancellationToken); + + /// + /// Resolves the canonical name (CNAME) record for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A containing the CNAME records. + /// is or empty. + public static DnsResult ResolveCName(string name) + => DefaultResolver.ResolveCName(name); + + /// + /// Asynchronously resolves the canonical name (CNAME) record for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the CNAME records. + /// is or empty. + public static Task> ResolveCNameAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolveCNameAsync(name, cancellationToken); + + /// + /// Resolves the pointer (PTR) records for the specified name using the system-configured DNS servers. + /// + /// The name to resolve, typically a reverse-lookup name such as 4.3.2.1.in-addr.arpa. + /// A containing the PTR records. + /// is or empty. + public static DnsResult ResolvePtr(string name) + => DefaultResolver.ResolvePtr(name); + + /// + /// Resolves the pointer (PTR) records for the specified IP address using the system-configured DNS servers. + /// + /// The IP address to perform a reverse lookup for. + /// A containing the PTR records. + /// is . + public static DnsResult ResolvePtr(IPAddress address) + => DefaultResolver.ResolvePtr(address); + + /// + /// Asynchronously resolves the pointer (PTR) records for the specified name using the system-configured DNS servers. + /// + /// The name to resolve, typically a reverse-lookup name such as 4.3.2.1.in-addr.arpa. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the PTR records. + /// is or empty. + public static Task> ResolvePtrAsync(string name, CancellationToken cancellationToken = default) + => DefaultResolver.ResolvePtrAsync(name, cancellationToken); + + /// + /// Asynchronously resolves the pointer (PTR) records for the specified IP address using the system-configured DNS servers. + /// + /// The IP address to perform a reverse lookup for. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the PTR records. + /// is . + public static Task> ResolvePtrAsync(IPAddress address, CancellationToken cancellationToken = default) + => DefaultResolver.ResolvePtrAsync(address, cancellationToken); + + /// + /// Resolves the authoritative name server (NS) records for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A containing the NS records. + /// is or empty. + public static DnsResult ResolveNs(string name) + => DefaultResolver.ResolveNs(name); + + /// + /// Asynchronously resolves the authoritative name server (NS) records for the specified name using the system-configured DNS servers. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the NS records. + /// is or empty. + 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/DnsEncodedName.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs new file mode 100644 index 00000000000000..119212b2a1be72 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsEncodedName.cs @@ -0,0 +1,549 @@ +// 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; +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace System.Net +{ + // Represents a domain name in DNS wire format (RFC 1035 §4.1.4). + // Works for both the read path (responses with compression pointers) and the + // write path (flat encoded names). + internal readonly ref struct DnsEncodedName + { + private static readonly IdnMapping s_idnMapping = new IdnMapping { AllowUnassigned = false, UseStd3AsciiRules = true }; + + // Maximum wire-format size of any valid domain name (including length + // prefixes and the root label terminator). + public const int MaxEncodedLength = 255; + + // The buffer containing the encoded name. For names parsed from responses, + // this is the full message (needed to follow compression pointers). For + // names created via TryEncode, this is the flat encoded buffer. + private readonly ReadOnlySpan _buffer; + + // Offset within _buffer where this name starts. + private readonly int _offset; + + // Whether any label is ACE-encoded (starts with "xn--"), indicating IDN/Punycode. + private readonly bool _isAce; + + // Whether the wire encoding contains compression pointers. + // False for names created via TryEncode (always flat). + private readonly bool _hasPointers; + + internal DnsEncodedName(ReadOnlySpan buffer, int offset, bool isAce, bool hasPointers) + { + _buffer = buffer; + _offset = offset; + _isAce = isAce; + _hasPointers = hasPointers; + } + + // Attempts to parse a DNS name from a wire-format buffer at the given offset. + // Validates that the name is well-formed (valid label lengths, no truncation). + // The buffer is retained by the returned DnsEncodedName to support compression + // pointer resolution. bytesConsumed receives the number of bytes consumed from + // the buffer at offset (not following compression pointers). + public static bool TryParse(ReadOnlySpan buffer, int offset, out DnsEncodedName name, out int bytesConsumed) + { + name = default; + bytesConsumed = 0; + + if (offset < 0 || offset >= buffer.Length) + { + return false; + } + + if (!ValidateName(buffer, offset, out int wireLen, out _, out bool isAce, out bool hasPointers)) + { + return false; + } + + if (!hasPointers) + { + // Non-pointer names: _buffer is sliced to exactly the encoded bytes. + name = new DnsEncodedName(buffer[offset..(offset + wireLen)], 0, isAce, hasPointers: false); + } + else + { + // Pointer names: full message buffer needed for pointer resolution. + name = new DnsEncodedName(buffer, offset, isAce, hasPointers: true); + } + bytesConsumed = wireLen; + return true; + } + + // Validates a domain name and encodes it into wire format. + public static OperationStatus TryEncode( + ReadOnlySpan name, + Span destination, + out DnsEncodedName result, + out int bytesWritten) + { + result = default; + bytesWritten = 0; + + // Handle root name "." or empty string. + if (name.Length == 0 || (name.Length == 1 && name[0] == '.')) + { + if (destination.Length < 1) + { + return OperationStatus.DestinationTooSmall; + } + destination[0] = 0; // root label + bytesWritten = 1; + result = new DnsEncodedName(destination[..1], 0, isAce: false, hasPointers: false); + return OperationStatus.Done; + } + + // If the name contains non-ASCII characters, convert to ACE (Punycode) + // form per RFC 5891 (IDNA 2008) before wire encoding. + string? aceName = null; + if (!Ascii.IsValid(name)) + { + try + { + aceName = s_idnMapping.GetAscii(name.ToString()); + } + catch (ArgumentException) + { + return OperationStatus.InvalidData; + } + name = aceName; + } + + // Strip trailing dot if present (FQDN notation). + if (name[^1] == '.') + { + name = name[..^1]; + } + + // Wire format length: each '.' becomes a length byte, plus one leading + // length byte and trailing root label. + int wireLen = name.Length + 2; + if (wireLen > MaxEncodedLength) + { + return OperationStatus.InvalidData; // name too long + } + if (wireLen > destination.Length) + { + return OperationStatus.DestinationTooSmall; + } + + // Copy the ASCII name at offset 1, so dots land where length prefixes will go. + OperationStatus asciiStatus = Ascii.FromUtf16(name, destination.Slice(1, name.Length), out _); + Debug.Assert(asciiStatus == OperationStatus.Done); + + // Walk through and replace dots with label lengths, validating labels. + Span body = destination.Slice(1, name.Length); + int labelStart = 0; + bool isAce = aceName != null; + while (true) + { + int dotIdx = body[labelStart..].IndexOf((byte)'.'); + int labelLen = dotIdx >= 0 ? dotIdx : body.Length - labelStart; + + Span label = body.Slice(labelStart, labelLen); + if (!IsValidLabel(label)) + { + return OperationStatus.InvalidData; + } + + if (!isAce && labelLen >= 4) + { + isAce = IsAceLabel(label); + } + + // Overwrite the dot (or the leading slot at destination[0]) with the label length. + destination[labelStart] = (byte)labelLen; + + if (dotIdx < 0) + { + break; + } + + labelStart += labelLen + 1; + } + + // Write root (empty) label. + destination[wireLen - 1] = 0; + + bytesWritten = wireLen; + result = new DnsEncodedName(destination[..wireLen], 0, isAce, hasPointers: false); + return OperationStatus.Done; + } + + // Compares this name to a dotted string representation. Case-insensitive. + // Non-ASCII (Unicode) names are converted to ACE form before comparison. + public bool Equals(ReadOnlySpan name) + { + if (!Ascii.IsValid(name)) + { + try + { + name = s_idnMapping.GetAscii(name.ToString()); + } + catch (ArgumentException) + { + return false; + } + } + + // Strip trailing dot from the comparison name. + if (name.Length > 0 && name[^1] == '.') + { + name = name[..^1]; + } + + DnsLabelEnumerator enumerator = EnumerateLabels(); + int nameIdx = 0; + + while (enumerator.MoveNext()) + { + ReadOnlySpan label = enumerator.Current; + + if (nameIdx > 0) + { + // Expect a dot separator. + if (nameIdx >= name.Length || name[nameIdx] != '.') + { + return false; + } + nameIdx++; + } + + if (nameIdx + label.Length > name.Length) + { + return false; + } + + if (!Ascii.EqualsIgnoreCase(label, name.Slice(nameIdx, label.Length))) + { + return false; + } + nameIdx += label.Length; + } + + return nameIdx == name.Length; + } + + // Decodes the domain name into the destination buffer as a dotted string. + // ACE-encoded labels (starting with "xn--") are converted back to Unicode. + public unsafe bool TryDecode(Span destination, out int charsWritten) + { + charsWritten = 0; + + if (!_isAce) + { + // Fast path for non-ACE names: decode directly to destination. + return TryDecodeAscii(destination, out charsWritten); + } + + // For ACE names, the ASCII intermediate may be longer than the final + // Unicode form. Decode to a local buffer first, then convert. + Span ascii = stackalloc char[256]; + if (!TryDecodeAscii(ascii, out int asciiWritten)) + { + return false; + } + + try + { + string unicode = s_idnMapping.GetUnicode(new string(ascii[..asciiWritten])); + if (unicode.Length <= destination.Length) + { + unicode.AsSpan().CopyTo(destination); + charsWritten = unicode.Length; + return true; + } + } + catch (ArgumentException) + { + // IDN conversion failed, fall through to ACE form. + } + + if (asciiWritten <= destination.Length) + { + ascii[..asciiWritten].CopyTo(destination); + charsWritten = asciiWritten; + return true; + } + + return false; + } + + private static bool IsAceLabel(ReadOnlySpan label) + { + return label.Length >= 4 && + Ascii.EqualsIgnoreCase(label[..4], "xn--"u8); + } + + // Enumerates the individual labels of this domain name. + // Follows compression pointers transparently. + public DnsLabelEnumerator EnumerateLabels() => new DnsLabelEnumerator(_buffer, _offset); + + // Copies the flat wire-format encoding of this name to the destination buffer, + // expanding compression pointers if present. + internal bool TryCopyEncodedTo(Span destination, out int bytesWritten) + { + bytesWritten = 0; + + if (!_hasPointers) + { + // Fast path: _buffer is sliced to exactly the encoded bytes starting at _offset. + ReadOnlySpan encoded = _buffer[_offset..]; + if (encoded.Length > destination.Length) + { + return false; + } + + encoded.CopyTo(destination); + bytesWritten = encoded.Length; + return true; + } + + // Slow path: expand compression pointers by copying labels as we go. + // MaxEncodedLength bounds the output, so we won't overrun a properly sized buffer. + foreach (ReadOnlySpan label in EnumerateLabels()) + { + if (bytesWritten + 1 + label.Length > destination.Length) + { + return false; + } + destination[bytesWritten] = (byte)label.Length; + bytesWritten++; + label.CopyTo(destination[bytesWritten..]); + bytesWritten += label.Length; + } + + if (bytesWritten >= destination.Length) + { + return false; + } + destination[bytesWritten] = 0; // root label + bytesWritten++; + + return true; + } + + public override unsafe string ToString() + { + Span chars = stackalloc char[256]; + bool success = TryDecode(chars, out int charsWritten); + Debug.Assert(success); + return new string(chars[..charsWritten]); + } + + // Decodes the domain name as raw ASCII without IDN conversion. + private bool TryDecodeAscii(Span destination, out int charsWritten) + { + charsWritten = 0; + DnsLabelEnumerator enumerator = EnumerateLabels(); + bool first = true; + + while (enumerator.MoveNext()) + { + ReadOnlySpan label = enumerator.Current; + + if (!first) + { + if (charsWritten >= destination.Length) + { + return false; + } + destination[charsWritten] = '.'; + charsWritten++; + } + first = false; + + if (charsWritten + label.Length > destination.Length) + { + return false; + } + + Ascii.ToUtf16(label, destination.Slice(charsWritten, label.Length), out _); + charsWritten += label.Length; + } + + if (charsWritten == 0) + { + // Root name produces "." in dotted form. + if (destination.Length < 1) + { + return false; + } + destination[0] = '.'; + charsWritten = 1; + } + + return true; + } + + // Validates the name and computes the wire-format byte count, the dotted ASCII + // string length, and whether any label is ACE-encoded or uses compression pointers, + // all in a single pass. Returns false if the name is malformed or exceeds RFC 1035 limits. + // When validateContent is false (response parsing), only structural validation is + // performed (label lengths, pointer safety, total length). When true (outbound + // encoding), label content is also validated for LDH compliance. + private static bool ValidateName(ReadOnlySpan buffer, int offset, + out int wireLength, out int formattedLength, out bool isAce, + out bool hasPointers, bool validateContent = false) + { + wireLength = 0; + formattedLength = 0; + isAce = false; + hasPointers = false; + + int pos = offset; + bool foundWireEnd = false; + int hops = 0; + + while (pos < buffer.Length) + { + byte b = buffer[pos]; + + if (b == 0) + { + // Root label — end of name. + if (!foundWireEnd) + { + wireLength = pos + 1 - offset; + } + return true; + } + + if ((b & 0xC0) == 0xC0) + { + // Compression pointer. + if (pos + 1 >= buffer.Length) + { + return false; // truncated pointer + } + + if (!foundWireEnd) + { + wireLength = pos + 2 - offset; + foundWireEnd = true; + hasPointers = true; + } + + int pointer = ((b & 0x3F) << 8) | buffer[pos + 1]; + if (pointer >= pos) + { + return false; // only backwards jumps allowed + } + pos = pointer; + + if (++hops > 16) + { + return false; // too many pointer hops + } + continue; + } + + if ((b & 0xC0) != 0x00) + { + return false; // one of the upper 2 bits is nonzero, invalid per RFC 1035 + } + Debug.Assert(b <= 63); // enforced by condition above + + if (pos + 1 + b > buffer.Length) + { + return false; // label extends past buffer + } + + // Account for dot separator in formatted length. + formattedLength += formattedLength > 0 ? b + 1 : b; + if (formattedLength > 253) + { + return false; // RFC 1035: max 253 characters in dotted form + } + + // Check for ACE label ("xn--" prefix). + ReadOnlySpan label = buffer.Slice(pos + 1, b); + if (!isAce && b >= 4) + { + isAce = IsAceLabel(label); + } + + // Validate label contents when required (outbound encoding). + if (validateContent && !IsValidLabel(label)) + { + return false; + } + + pos += 1 + b; // skip length byte + label + } + + return false; // ran off the end of buffer without finding root label + } + + private static readonly SearchValues s_ldhBytes = + SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"u8); + + // Validates that a label has valid length (1-63), contains only LDH (Letters, + // Digits, Hyphens) characters and underscores (for SRV, DKIM, etc.), and does + // not start or end with a hyphen. + private static bool IsValidLabel(ReadOnlySpan label) + { + return label.Length > 0 && + label.Length <= 63 && + label[0] != (byte)'-' && + label[^1] != (byte)'-' && + label.IndexOfAnyExcept(s_ldhBytes) < 0; + } + } + + // Enumerates labels of a DNS name, following compression pointers. The name must + // have been validated by DnsEncodedName.TryParse or DnsEncodedName.TryEncode before + // enumeration. + internal ref struct DnsLabelEnumerator + { + private readonly ReadOnlySpan _buffer; + private int _pos; + private ReadOnlySpan _current; + + internal DnsLabelEnumerator(ReadOnlySpan buffer, int offset) + { + _buffer = buffer; + _pos = offset; + _current = default; + } + + public readonly ReadOnlySpan Current => _current; + + public bool MoveNext() + { + byte b = _buffer[_pos]; + + while ((b & 0xC0) == 0xC0) + { + // Compression pointer: follow it. + Debug.Assert(_pos + 1 < _buffer.Length, "Truncated compression pointer"); + int pointer = ((b & 0x3F) << 8) | _buffer[_pos + 1]; + Debug.Assert(pointer < _pos, "Forward or self-referencing compression pointer"); + _pos = pointer; + b = _buffer[_pos]; + } + + if (b == 0) + { + // End, root label. + return false; + } + + Debug.Assert(b <= 63, "Invalid label length byte"); + int labelLen = b; + _pos++; + Debug.Assert(_pos + labelLen <= _buffer.Length, "Label extends past buffer"); + _current = _buffer.Slice(_pos, labelLen); + _pos += labelLen; + return true; + } + + public readonly DnsLabelEnumerator GetEnumerator() => this; + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs new file mode 100644 index 00000000000000..1699e690d9755c --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageHeader.cs @@ -0,0 +1,111 @@ +// 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; + +namespace System.Net +{ + // The fixed 12-byte DNS message header (RFC 1035 §4.1.1). + internal struct DnsMessageHeader + { + public ushort Id { get; set; } + public bool IsResponse { get; set; } + public DnsOpCode OpCode { get; set; } + public DnsHeaderFlags Flags { get; set; } + public DnsResponseCode ResponseCode { get; set; } + public ushort QuestionCount { get; set; } + public ushort AnswerCount { get; set; } + public ushort AuthorityCount { get; set; } + public ushort AdditionalCount { get; set; } + + internal const int Size = 12; + + internal bool TryWrite(Span destination) + { + if (destination.Length < Size) + { + return false; + } + + BinaryPrimitives.WriteUInt16BigEndian(destination, Id); + BinaryPrimitives.WriteUInt16BigEndian(destination[2..], EncodeFlagsWord()); + BinaryPrimitives.WriteUInt16BigEndian(destination[4..], QuestionCount); + BinaryPrimitives.WriteUInt16BigEndian(destination[6..], AnswerCount); + BinaryPrimitives.WriteUInt16BigEndian(destination[8..], AuthorityCount); + BinaryPrimitives.WriteUInt16BigEndian(destination[10..], AdditionalCount); + return true; + } + + internal static bool TryRead(ReadOnlySpan source, out DnsMessageHeader header) + { + header = default; + if (source.Length < Size) + { + return false; + } + + ushort id = BinaryPrimitives.ReadUInt16BigEndian(source); + ushort flagsWord = BinaryPrimitives.ReadUInt16BigEndian(source[2..]); + ushort qdCount = BinaryPrimitives.ReadUInt16BigEndian(source[4..]); + ushort anCount = BinaryPrimitives.ReadUInt16BigEndian(source[6..]); + ushort nsCount = BinaryPrimitives.ReadUInt16BigEndian(source[8..]); + ushort arCount = BinaryPrimitives.ReadUInt16BigEndian(source[10..]); + + DecodeFlagsWord(flagsWord, out bool isResponse, out DnsOpCode opCode, + out DnsHeaderFlags flags, out DnsResponseCode responseCode); + + header = new DnsMessageHeader + { + Id = id, + IsResponse = isResponse, + OpCode = opCode, + Flags = flags, + ResponseCode = responseCode, + QuestionCount = qdCount, + AnswerCount = anCount, + AuthorityCount = nsCount, + AdditionalCount = arCount, + }; + return true; + } + + // RFC 1035 §4.1.1 wire format of the flags word (bytes 2-3): + // + // Bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 + // QR | OpCode | AA TC RD RA Z AD CD | RCODE | + // + // DnsHeaderFlags enum values are the wire bit positions shifted right by 4, + // so the enum fits in a byte. Encoding shifts left by 4 to restore wire positions, + // decoding shifts right by 4. The Z bit (wire bit 6) gap is preserved by the shift. + // Wire flag bits: AA(10) TC(9) RD(8) RA(7) AD(5) CD(4) + // Enum bits: AA(6) TC(5) RD(4) RA(3) AD(1) CD(0) + private const int FlagsShift = 4; + private const ushort WireFlagsMask = 0x07F0; // wire bits 10-7 and 5-4 + + private readonly ushort EncodeFlagsWord() + { + ushort word = 0; + + if (IsResponse) + { + word |= 1 << 15; + } + + word |= (ushort)(((int)OpCode & 0xF) << 11); + word |= (ushort)((int)Flags << FlagsShift); + word |= (ushort)((int)ResponseCode & 0xF); + + return word; + } + + private static void DecodeFlagsWord(ushort word, + out bool isResponse, out DnsOpCode opCode, + out DnsHeaderFlags flags, out DnsResponseCode responseCode) + { + isResponse = (word & (1 << 15)) != 0; + opCode = (DnsOpCode)((word >> 11) & 0xF); + responseCode = (DnsResponseCode)(word & 0xF); + flags = (DnsHeaderFlags)((word & WireFlagsMask) >> FlagsShift); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs new file mode 100644 index 00000000000000..37340206dfacaf --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageReader.cs @@ -0,0 +1,158 @@ +// 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; + +namespace System.Net +{ + // A parsed question entry from the question section. + internal readonly ref struct DnsQuestion + { + public DnsEncodedName Name { get; } + public DnsRecordType Type { get; } + public DnsRecordClass Class { get; } + + internal DnsQuestion(DnsEncodedName name, DnsRecordType type, DnsRecordClass @class) + { + Name = name; + Type = type; + Class = @class; + } + } + + // A parsed resource record from any section (answer, authority, additional). + internal readonly ref struct DnsRecord + { + public DnsEncodedName Name { get; } + public DnsRecordType Type { get; } + public DnsRecordClass Class { get; } + public uint TimeToLive { get; } + + // Raw RDATA bytes. + public ReadOnlySpan Data { get; } + + // The full DNS message buffer, for resolving compression pointers in RDATA. + public ReadOnlySpan Message { get; } + + // Offset of Data within Message. + public int DataOffset { get; } + + internal DnsRecord(DnsEncodedName name, DnsRecordType type, DnsRecordClass @class, + uint ttl, ReadOnlySpan data, ReadOnlySpan message, int dataOffset) + { + Name = name; + Type = type; + Class = @class; + TimeToLive = ttl; + Data = data; + Message = message; + DataOffset = dataOffset; + } + } + + // Reads DNS messages from a buffer. Parses sequentially: header, questions, resource records. + internal ref struct DnsMessageReader + { + private readonly ReadOnlySpan _message; + private int _pos; + + public DnsMessageHeader Header { get; } + + private DnsMessageReader(ReadOnlySpan message, DnsMessageHeader header) + { + _message = message; + _pos = DnsMessageHeader.Size; + Header = header; + } + + // Attempts to create a reader over a DNS message. Parses the header eagerly. + // Returns false if the buffer is too small for a valid header. + public static bool TryCreate(ReadOnlySpan message, out DnsMessageReader reader) + { + reader = default; + + if (!DnsMessageHeader.TryRead(message, out DnsMessageHeader header)) + { + return false; + } + + reader = new DnsMessageReader(message, header); + return true; + } + + // Reads the next question from the message. + public bool TryReadQuestion(out DnsQuestion question) + { + question = default; + + if (_pos >= _message.Length) + { + return false; + } + + if (!DnsEncodedName.TryParse(_message, _pos, out DnsEncodedName name, out int nameWireLen)) + { + return false; + } + _pos += nameWireLen; + + // QTYPE (2) + QCLASS (2) = 4 bytes + if (_pos + 4 > _message.Length) + { + return false; + } + + DnsRecordType type = (DnsRecordType)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + DnsRecordClass @class = (DnsRecordClass)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + + question = new DnsQuestion(name, type, @class); + return true; + } + + // Reads the next resource record from the message. + public bool TryReadRecord(out DnsRecord record) + { + record = default; + + if (_pos >= _message.Length) + { + return false; + } + + if (!DnsEncodedName.TryParse(_message, _pos, out DnsEncodedName name, out int nameWireLen)) + { + return false; + } + _pos += nameWireLen; + + // TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = 10 bytes + if (_pos + 10 > _message.Length) + { + return false; + } + + DnsRecordType type = (DnsRecordType)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + DnsRecordClass @class = (DnsRecordClass)BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + uint ttl = BinaryPrimitives.ReadUInt32BigEndian(_message[_pos..]); + _pos += 4; + ushort rdLength = BinaryPrimitives.ReadUInt16BigEndian(_message[_pos..]); + _pos += 2; + + int dataOffset = _pos; + if (dataOffset + rdLength > _message.Length) + { + return false; + } + + ReadOnlySpan data = _message.Slice(dataOffset, rdLength); + _pos += rdLength; + + record = new DnsRecord(name, type, @class, ttl, data, _message, dataOffset); + return true; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs new file mode 100644 index 00000000000000..6c6018efb82770 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsMessageWriter.cs @@ -0,0 +1,62 @@ +// 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; + +namespace System.Net +{ + // Writes DNS query messages into a caller-provided buffer. + // Only supports writing request messages (header + questions). + internal ref struct DnsMessageWriter + { + private readonly Span _destination; + private int _bytesWritten; + + public DnsMessageWriter(Span destination) + { + _destination = destination; + _bytesWritten = 0; + } + + public readonly int BytesWritten => _bytesWritten; + + // Writes the 12-byte message header at the current position. + public bool TryWriteHeader(in DnsMessageHeader header) + { + if (!header.TryWrite(_destination[_bytesWritten..])) + { + return false; + } + _bytesWritten += DnsMessageHeader.Size; + return true; + } + + // Writes a question entry: encoded domain name + type + class. + // Expands compression pointers if present (safe for names from responses). + public bool TryWriteQuestion( + scoped DnsEncodedName name, + DnsRecordType type, + DnsRecordClass @class = DnsRecordClass.Internet) + { + if (!name.TryCopyEncodedTo(_destination[_bytesWritten..], out int nameWritten)) + { + return false; + } + + // type (2) + class (2) + if (_bytesWritten + nameWritten + 4 > _destination.Length) + { + return false; + } + _bytesWritten += nameWritten; + + BinaryPrimitives.WriteUInt16BigEndian(_destination[_bytesWritten..], (ushort)type); + _bytesWritten += 2; + + BinaryPrimitives.WriteUInt16BigEndian(_destination[_bytesWritten..], (ushort)@class); + _bytesWritten += 2; + + return true; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs new file mode 100644 index 00000000000000..5fa1a8c918d217 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecordParsing.cs @@ -0,0 +1,309 @@ +// 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; + +namespace System.Net +{ + // Typed RDATA accessors over parsed DNS records. + + internal readonly ref struct DnsARecordData + { + public ReadOnlySpan AddressBytes { get; } + + internal DnsARecordData(ReadOnlySpan addressBytes) + { + AddressBytes = addressBytes; + } + + public IPAddress ToIPAddress() => new IPAddress(AddressBytes); + } + + internal readonly ref struct DnsAAAARecordData + { + public ReadOnlySpan AddressBytes { get; } + + internal DnsAAAARecordData(ReadOnlySpan addressBytes) + { + AddressBytes = addressBytes; + } + + public IPAddress ToIPAddress() => new IPAddress(AddressBytes); + } + + internal readonly ref struct DnsCNameRecordData + { + public DnsEncodedName CName { get; } + + internal DnsCNameRecordData(DnsEncodedName cname) + { + CName = cname; + } + } + + internal readonly ref struct DnsMxRecordData + { + public ushort Preference { get; } + public DnsEncodedName Exchange { get; } + + internal DnsMxRecordData(ushort preference, DnsEncodedName exchange) + { + Preference = preference; + Exchange = exchange; + } + } + + internal readonly ref struct DnsSrvRecordData + { + public ushort Priority { get; } + public ushort Weight { get; } + public ushort Port { get; } + public DnsEncodedName Target { get; } + + internal DnsSrvRecordData(ushort priority, ushort weight, ushort port, DnsEncodedName target) + { + Priority = priority; + Weight = weight; + Port = port; + Target = target; + } + } + + internal readonly ref struct DnsSoaRecordData + { + public DnsEncodedName PrimaryNameServer { get; } + public DnsEncodedName ResponsibleMailbox { get; } + public uint SerialNumber { get; } + public uint RefreshInterval { get; } + public uint RetryInterval { get; } + public uint ExpireLimit { get; } + public uint MinimumTtl { get; } + + internal DnsSoaRecordData(DnsEncodedName primaryNameServer, DnsEncodedName responsibleMailbox, + uint serialNumber, uint refreshInterval, uint retryInterval, + uint expireLimit, uint minimumTtl) + { + PrimaryNameServer = primaryNameServer; + ResponsibleMailbox = responsibleMailbox; + SerialNumber = serialNumber; + RefreshInterval = refreshInterval; + RetryInterval = retryInterval; + ExpireLimit = expireLimit; + MinimumTtl = minimumTtl; + } + } + + internal readonly ref struct DnsTxtRecordData + { + private readonly ReadOnlySpan _data; + + internal DnsTxtRecordData(ReadOnlySpan data) + { + _data = data; + } + + public DnsTxtEnumerator EnumerateStrings() => new DnsTxtEnumerator(_data); + } + + internal ref struct DnsTxtEnumerator + { + private ReadOnlySpan _remaining; + private ReadOnlySpan _current; + + internal DnsTxtEnumerator(ReadOnlySpan data) + { + _remaining = data; + _current = default; + } + + public readonly ReadOnlySpan Current => _current; + + public bool MoveNext() + { + if (_remaining.Length == 0) + { + return false; + } + + int len = _remaining[0]; + if (1 + len > _remaining.Length) + { + return false; + } + + _current = _remaining.Slice(1, len); + _remaining = _remaining[(1 + len)..]; + return true; + } + + public readonly DnsTxtEnumerator GetEnumerator() => this; + } + + internal readonly ref struct DnsPtrRecordData + { + public DnsEncodedName Name { get; } + + internal DnsPtrRecordData(DnsEncodedName name) + { + Name = name; + } + } + + internal readonly ref struct DnsNsRecordData + { + public DnsEncodedName Name { get; } + + internal DnsNsRecordData(DnsEncodedName name) + { + Name = name; + } + } + + internal static class DnsRecordExtensions + { + public static bool TryParseARecord(this DnsRecord record, out DnsARecordData result) + { + result = default; + if (record.Type != DnsRecordType.A || record.Data.Length != 4) + { + return false; + } + result = new DnsARecordData(record.Data); + return true; + } + + public static bool TryParseAAAARecord(this DnsRecord record, out DnsAAAARecordData result) + { + result = default; + if (record.Type != DnsRecordType.AAAA || record.Data.Length != 16) + { + return false; + } + result = new DnsAAAARecordData(record.Data); + return true; + } + + public static bool TryParseCNameRecord(this DnsRecord record, out DnsCNameRecordData result) + { + result = default; + if (record.Type != DnsRecordType.CNAME || record.Data.Length == 0) + { + return false; + } + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName cname, out _)) + { + return false; + } + result = new DnsCNameRecordData(cname); + return true; + } + + public static bool TryParseMxRecord(this DnsRecord record, out DnsMxRecordData result) + { + result = default; + if (record.Type != DnsRecordType.MX || record.Data.Length < 3) + { + return false; + } + ushort preference = BinaryPrimitives.ReadUInt16BigEndian(record.Data); + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + 2, out DnsEncodedName exchange, out _)) + { + return false; + } + result = new DnsMxRecordData(preference, exchange); + return true; + } + + public static bool TryParseSrvRecord(this DnsRecord record, out DnsSrvRecordData result) + { + result = default; + if (record.Type != DnsRecordType.SRV || record.Data.Length < 7) + { + return false; + } + ushort priority = BinaryPrimitives.ReadUInt16BigEndian(record.Data); + ushort weight = BinaryPrimitives.ReadUInt16BigEndian(record.Data[2..]); + ushort port = BinaryPrimitives.ReadUInt16BigEndian(record.Data[4..]); + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + 6, out DnsEncodedName target, out _)) + { + return false; + } + result = new DnsSrvRecordData(priority, weight, port, target); + return true; + } + + public static bool TryParseSoaRecord(this DnsRecord record, out DnsSoaRecordData result) + { + result = default; + if (record.Type != DnsRecordType.SOA || record.Data.Length < 22) + { + return false; + } + + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName mname, out int mnameLen)) + { + return false; + } + + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset + mnameLen, out DnsEncodedName rname, out int rnameLen)) + { + return false; + } + + ReadOnlySpan fixedData = record.Data[(mnameLen + rnameLen)..]; + if (fixedData.Length < 20) + { + return false; + } + + result = new DnsSoaRecordData(mname, rname, + BinaryPrimitives.ReadUInt32BigEndian(fixedData), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[4..]), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[8..]), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[12..]), + BinaryPrimitives.ReadUInt32BigEndian(fixedData[16..])); + return true; + } + + public static bool TryParseTxtRecord(this DnsRecord record, out DnsTxtRecordData result) + { + result = default; + if (record.Type != DnsRecordType.TXT || record.Data.Length == 0) + { + return false; + } + result = new DnsTxtRecordData(record.Data); + return true; + } + + public static bool TryParsePtrRecord(this DnsRecord record, out DnsPtrRecordData result) + { + result = default; + if (record.Type != DnsRecordType.PTR || record.Data.Length == 0) + { + return false; + } + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName ptr, out _)) + { + return false; + } + result = new DnsPtrRecordData(ptr); + return true; + } + + public static bool TryParseNsRecord(this DnsRecord record, out DnsNsRecordData result) + { + result = default; + if (record.Type != DnsRecordType.NS || record.Data.Length == 0) + { + return false; + } + if (!DnsEncodedName.TryParse(record.Message, record.DataOffset, out DnsEncodedName ns, out _)) + { + return false; + } + result = new DnsNsRecordData(ns); + return true; + } + } +} 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..7a10fc3000c420 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsRecords.cs @@ -0,0 +1,135 @@ +// 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 +{ + /// Represents an A or AAAA record resolved from DNS, including its time-to-live. + public readonly struct AddressRecord + { + /// Gets the resolved IP address. + public IPAddress Address { get; } + /// Gets the time-to-live (TTL) of the record. + public TimeSpan Ttl { get; } + + internal AddressRecord(IPAddress address, TimeSpan ttl) + { + Address = address; + Ttl = ttl; + } + } + + /// Represents an SRV record (RFC 2782), with optional inlined address records from the additional section. + public readonly struct SrvRecord + { + private readonly IReadOnlyList? _addresses; + + /// Gets the domain name of the target host. + public string Target { get; } + /// Gets the port on the target host where the service is found. + [CLSCompliant(false)] + public ushort Port { get; } + /// Gets the priority of the target host. Lower values are preferred. + [CLSCompliant(false)] + public ushort Priority { get; } + /// Gets the relative weight for records with the same priority. + [CLSCompliant(false)] + public ushort Weight { get; } + /// Gets the time-to-live (TTL) of the record. + public TimeSpan Ttl { get; } + /// Gets the address records for the target host that were included in the additional section of the response, if any. + 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; + } + } + + /// Represents an MX (mail exchange) record (RFC 1035 §3.3.9). + public readonly struct MxRecord + { + /// Gets the domain name of the mail exchange host. + public string Exchange { get; } + /// Gets the preference of this mail exchange. Lower values are preferred. + [CLSCompliant(false)] + public ushort Preference { get; } + /// Gets the time-to-live (TTL) of the record. + public TimeSpan Ttl { get; } + + internal MxRecord(string exchange, ushort preference, TimeSpan ttl) + { + Exchange = exchange; + Preference = preference; + Ttl = ttl; + } + } + + /// Represents a TXT record (RFC 1035 §3.3.14). A single record may carry multiple character-strings. + public readonly struct TxtRecord + { + private readonly IReadOnlyList? _values; + + /// Gets the character-strings contained in the record. + public IReadOnlyList Values => _values ?? Array.Empty(); + /// Gets the time-to-live (TTL) of the record. + public TimeSpan Ttl { get; } + + internal TxtRecord(IReadOnlyList values, TimeSpan ttl) + { + _values = values; + Ttl = ttl; + } + } + + /// Represents a CNAME (canonical name) record (RFC 1035 §3.3.1). + public readonly struct CNameRecord + { + /// Gets the canonical name for the queried name. + public string CanonicalName { get; } + /// Gets the time-to-live (TTL) of the record. + public TimeSpan Ttl { get; } + + internal CNameRecord(string canonicalName, TimeSpan ttl) + { + CanonicalName = canonicalName; + Ttl = ttl; + } + } + + /// Represents a PTR (pointer) record (RFC 1035 §3.3.12), typically used for reverse DNS lookups. + public readonly struct PtrRecord + { + /// Gets the domain name the queried name points to. + public string Name { get; } + /// Gets the time-to-live (TTL) of the record. + public TimeSpan Ttl { get; } + + internal PtrRecord(string name, TimeSpan ttl) + { + Name = name; + Ttl = ttl; + } + } + + /// Represents an NS (name server) record (RFC 1035 §3.3.11). + public readonly struct NsRecord + { + /// Gets the domain name of the authoritative name server. + public string Name { get; } + /// Gets the time-to-live (TTL) of the record. + 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.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs new file mode 100644 index 00000000000000..dcf102ca4711a4 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolver.cs @@ -0,0 +1,479 @@ +// 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; +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 IPEndPoint[] _servers; + private bool _disposed; + + /// + /// Initializes a new instance of the class that uses the + /// system-configured DNS servers. + /// + public DnsResolver() : this(new DnsResolverOptions()) { } + + /// + /// Initializes a new instance of the class with the specified options. + /// + /// The options controlling how DNS resolution is performed. + /// is . + public DnsResolver(DnsResolverOptions options) + { + ArgumentNullException.ThrowIfNull(options); + // Capture a defensive snapshot of the configured servers. IPEndPoint is + // mutable, so clone each entry to ensure later mutations of the options + // instance (or the endpoints it holds) don't affect this resolver. + IList servers = options.Servers; + _servers = new IPEndPoint[servers.Count]; + for (int i = 0; i < _servers.Length; i++) + { + IPEndPoint server = servers[i]; + if (server is null) + { + throw new ArgumentException(SR.net_dns_servers_contains_null, $"{nameof(options)}.{nameof(DnsResolverOptions.Servers)}"); + } + _servers[i] = new IPEndPoint(server.Address, server.Port); + } + } + + /// + /// Resolves the IPv4 (A) and IPv6 (AAAA) addresses for the specified host name. + /// + /// The host name to resolve. + /// A containing the address records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveAddresses(string name) + => ResolveAddresses(name, AddressFamily.Unspecified); + + /// + /// Resolves the addresses of the specified family for the specified host name. + /// + /// The host name to resolve. + /// + /// The address family to query. Use for A records, + /// for AAAA records, or + /// for both. + /// + /// A containing the address records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveAddresses(string name, AddressFamily addressFamily) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolveAddressesCore(async: false, name, addressFamily, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the service (SRV) records for the specified name. + /// + /// The name to resolve, typically in the form _service._protocol.host. + /// A containing the SRV records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveSrv(string name) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolveSrvCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the mail exchange (MX) records for the specified name. + /// + /// The domain name to resolve. + /// A containing the MX records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveMx(string name) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolveMxCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the text (TXT) records for the specified name. + /// + /// The domain name to resolve. + /// A containing the TXT records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveTxt(string name) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolveTxtCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the canonical name (CNAME) record for the specified name. + /// + /// The domain name to resolve. + /// A containing the CNAME records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveCName(string name) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolveCNameCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the pointer (PTR) records for the specified name, typically used for reverse DNS lookups. + /// + /// The name to resolve, typically a reverse-lookup name such as 4.3.2.1.in-addr.arpa. + /// A containing the PTR records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolvePtr(string name) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolvePtrCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the pointer (PTR) records for the specified IP address, performing a reverse DNS lookup. + /// + /// The IP address to perform a reverse lookup for. + /// A containing the PTR records. + /// is . + /// The resolver has been disposed. + public DnsResult ResolvePtr(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolvePtrCore(async: false, BuildArpaName(address), default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Resolves the authoritative name server (NS) records for the specified name. + /// + /// The domain name to resolve. + /// A containing the NS records. + /// is or empty. + /// The resolver has been disposed. + public DnsResult ResolveNs(string name) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + Task> task = ResolveNsCore(async: false, name, default); + Debug.Assert(task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + /// + /// Asynchronously resolves the IPv4 (A) and IPv6 (AAAA) addresses for the specified host name. + /// + /// The host name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the address records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveAddressesAsync(string name, CancellationToken cancellationToken = default) + => ResolveAddressesAsync(name, AddressFamily.Unspecified, cancellationToken); + + /// + /// Asynchronously resolves the addresses of the specified family for the specified host name. + /// + /// The host name to resolve. + /// + /// The address family to query. Use for A records, + /// for AAAA records, or + /// for both. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the address records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveAddressesCore(async: true, name, addressFamily, cancellationToken); + } + + /// + /// Asynchronously resolves the service (SRV) records for the specified name. + /// + /// The name to resolve, typically in the form _service._protocol.host. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the SRV records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveSrvAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveSrvCore(async: true, name, cancellationToken); + } + + /// + /// Asynchronously resolves the mail exchange (MX) records for the specified name. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the MX records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveMxAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveMxCore(async: true, name, cancellationToken); + } + + /// + /// Asynchronously resolves the text (TXT) records for the specified name. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the TXT records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveTxtAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveTxtCore(async: true, name, cancellationToken); + } + + /// + /// Asynchronously resolves the canonical name (CNAME) record for the specified name. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the CNAME records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveCNameAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveCNameCore(async: true, name, cancellationToken); + } + + /// + /// Asynchronously resolves the pointer (PTR) records for the specified name, typically used for reverse DNS lookups. + /// + /// The name to resolve, typically a reverse-lookup name such as 4.3.2.1.in-addr.arpa. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the PTR records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolvePtrAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolvePtrCore(async: true, name, cancellationToken); + } + + /// + /// Asynchronously resolves the pointer (PTR) records for the specified IP address, performing a reverse DNS lookup. + /// + /// The IP address to perform a reverse lookup for. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the PTR records. + /// is . + /// The resolver has been disposed. + public Task> ResolvePtrAsync(IPAddress address, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolvePtrCore(async: true, BuildArpaName(address), cancellationToken); + } + + /// + /// Asynchronously resolves the authoritative name server (NS) records for the specified name. + /// + /// The domain name to resolve. + /// A token to cancel the asynchronous operation. + /// A task that completes with a containing the NS records. + /// is or empty. + /// The resolver has been disposed. + public Task> ResolveNsAsync(string name, CancellationToken cancellationToken = default) + { + ValidateName(name); + ObjectDisposedException.ThrowIf(_disposed, this); + return ResolveNsCore(async: true, name, cancellationToken); + } + + /// + public void Dispose() => _disposed = true; + + /// + public ValueTask DisposeAsync() + { + _disposed = true; + 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 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. 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) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, addressFamily, cancellationToken), + static s => DnsResolverPal.ResolveAddresses(s.servers, s.async, s.name, s.addressFamily, s.cancellationToken), + static r => MapAnswers(r, static a => a.Address.ToString())) + : DnsResolverPal.ResolveAddresses(_servers, async, name, addressFamily, cancellationToken); + + private Task> ResolveSrvCore(bool async, string name, CancellationToken cancellationToken) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, cancellationToken), + static s => DnsResolverPal.ResolveSrv(s.servers, s.async, s.name, s.cancellationToken), + static r => MapAnswers(r, static a => a.Target)) + : DnsResolverPal.ResolveSrv(_servers, async, name, cancellationToken); + + private Task> ResolveMxCore(bool async, string name, CancellationToken cancellationToken) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, cancellationToken), + static s => DnsResolverPal.ResolveMx(s.servers, s.async, s.name, s.cancellationToken), + static r => MapAnswers(r, static a => a.Exchange)) + : DnsResolverPal.ResolveMx(_servers, async, name, cancellationToken); + + private Task> ResolveTxtCore(bool async, string name, CancellationToken cancellationToken) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, cancellationToken), + static s => DnsResolverPal.ResolveTxt(s.servers, s.async, s.name, s.cancellationToken), + static r => + { + List values = new(); + foreach (TxtRecord record in r.Records) + { + values.AddRange(record.Values); + } + return values.ToArray(); + }) + : DnsResolverPal.ResolveTxt(_servers, async, name, cancellationToken); + + private Task> ResolveCNameCore(bool async, string name, CancellationToken cancellationToken) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, cancellationToken), + static s => DnsResolverPal.ResolveCName(s.servers, s.async, s.name, s.cancellationToken), + static r => MapAnswers(r, static a => a.CanonicalName)) + : DnsResolverPal.ResolveCName(_servers, async, name, cancellationToken); + + private Task> ResolvePtrCore(bool async, string name, CancellationToken cancellationToken) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, cancellationToken), + static s => DnsResolverPal.ResolvePtr(s.servers, s.async, s.name, s.cancellationToken), + static r => MapAnswers(r, static a => a.Name)) + : DnsResolverPal.ResolvePtr(_servers, async, name, cancellationToken); + + private Task> ResolveNsCore(bool async, string name, CancellationToken cancellationToken) + => NameResolutionTelemetry.AnyDiagnosticsEnabled() + ? ResolveWithTelemetry(name, (servers: _servers, async, name, cancellationToken), + static s => DnsResolverPal.ResolveNs(s.servers, s.async, s.name, s.cancellationToken), + static r => MapAnswers(r, static a => a.Name)) + : DnsResolverPal.ResolveNs(_servers, async, name, cancellationToken); + + private static async Task> ResolveWithTelemetry(string name, TState state, Func>> resolve, Func, string[]> getAnswers) + { + NameResolutionActivity activity = NameResolutionTelemetry.Log.BeforeResolution(name); + try + { + DnsResult result = await resolve(state).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) + { + 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 $"{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[16 * 4]; + int pos = 0; + for (int i = bytes.Length - 1; i >= 0; i--) + { + byte b = bytes[i]; + chars[pos++] = ToHex(b & 0xF); + chars[pos++] = '.'; + chars[pos++] = ToHex(b >> 4); + chars[pos++] = '.'; + } + return string.Concat(chars, "ip6.arpa"); + + 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..2f20f8064a2e41 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverOptions.cs @@ -0,0 +1,29 @@ +// 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 + { + private IList _servers = new List(); + + /// + /// Gets or sets the DNS servers to query. When empty, the system-configured DNS servers are used. + /// + /// The value being set is . + public IList Servers + { + get => _servers; + set + { + ArgumentNullException.ThrowIfNull(value); + _servers = value; + } + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs new file mode 100644 index 00000000000000..1e1d079ceb2c5d --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Managed.cs @@ -0,0 +1,925 @@ +// 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; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Sockets; +using System.Runtime.ExceptionServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net +{ + // Managed stub-resolver implementation of the DNS PAL for Unix platforms. + // + // Builds and parses DNS wire messages and talks to the configured servers over + // UDP (with TCP fallback on truncation) using System.Net.Sockets. When no servers + // are configured, the system servers from /etc/resolv.conf are used. + // + // Each entry point takes a `bool async` flag. When async is false the underlying + // socket operations are issued synchronously (blocking) and the returned Task is + // already completed, so the synchronous public entry points can unwrap it without + // blocking a thread pool thread. + internal static partial class DnsResolverPal + { + // Maximum UDP DNS message size without EDNS0 (RFC 1035 §4.2.1). + private const int MaxUdpResponseSize = 512; + + // Initial buffer size for TCP responses; grown based on the 2-byte length prefix. + private const int InitialTcpBufferSize = 4096; + + // Default per-attempt timeout and retry count (DnsResolverOptions exposes only Servers). + private static readonly TimeSpan s_queryTimeout = TimeSpan.FromSeconds(3); + private const int MaxRetries = 2; + + // ---- Public PAL entry points (one per record type) ---- + + public static async Task> ResolveAddresses(IList servers, bool async, string name, AddressFamily addressFamily, CancellationToken cancellationToken) + { + if (addressFamily == AddressFamily.Unspecified) + { + if (async) + { + Task> aTask = QueryAddresses(servers, async: true, name, DnsRecordType.A, cancellationToken); + Task> aaaaTask = QueryAddresses(servers, async: true, name, DnsRecordType.AAAA, cancellationToken); + DnsResult aRes = await aTask.ConfigureAwait(false); + DnsResult aaaaRes = await aaaaTask.ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + else + { + DnsResult aRes = await QueryAddresses(servers, async: false, name, DnsRecordType.A, cancellationToken).ConfigureAwait(false); + DnsResult aaaaRes = await QueryAddresses(servers, async: false, name, DnsRecordType.AAAA, cancellationToken).ConfigureAwait(false); + return MergeAddressResults(aRes, aaaaRes); + } + } + + DnsRecordType 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) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.SRV, cancellationToken).ConfigureAwait(false); + try + { + return ParseSrv(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveMx(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.MX, cancellationToken).ConfigureAwait(false); + try + { + return ParseMx(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveTxt(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.TXT, cancellationToken).ConfigureAwait(false); + try + { + return ParseTxt(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveCName(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.CNAME, cancellationToken).ConfigureAwait(false); + try + { + return ParseCName(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolvePtr(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.PTR, cancellationToken).ConfigureAwait(false); + try + { + return ParsePtr(response.Span); + } + finally + { + response.Dispose(); + } + } + + public static async Task> ResolveNs(IList servers, bool async, string name, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, DnsRecordType.NS, cancellationToken).ConfigureAwait(false); + try + { + return ParseNs(response.Span); + } + finally + { + response.Dispose(); + } + } + + private static async Task> QueryAddresses(IList servers, bool async, string name, DnsRecordType qtype, CancellationToken cancellationToken) + { + DnsResponse response = await SendQuery(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + try + { + return ParseAddresses(response.Span, qtype); + } + finally + { + response.Dispose(); + } + } + + private static DnsRecordType AddressFamilyToQueryType(AddressFamily addressFamily) => + addressFamily switch + { + AddressFamily.InterNetwork => DnsRecordType.A, + AddressFamily.InterNetworkV6 => DnsRecordType.AAAA, + _ => throw new ArgumentException(SR.net_invalid_ip_addr, nameof(addressFamily)), + }; + + // ---- Response parsers ---- + + private static DnsResult ParseAddresses(ReadOnlySpan response, DnsRecordType qtype) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (qtype == DnsRecordType.A && record.TryParseARecord(out DnsARecordData a)) + { + records.Add(new AddressRecord(a.ToIPAddress(), TimeSpan.FromSeconds(record.TimeToLive))); + } + else if (qtype == DnsRecordType.AAAA && record.TryParseAAAARecord(out DnsAAAARecordData aaaa)) + { + records.Add(new AddressRecord(aaaa.ToIPAddress(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + // NODATA: NoError with no matching records — extract negative TTL from SOA + // in the authority section per RFC 2308 §5. + TimeSpan negTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, negTtl); + } + + private static DnsResult ParseSrv(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + // First pass: collect SRV answers (target names captured eagerly as strings). + List<(string Target, ushort Port, ushort Priority, ushort Weight, uint Ttl)> srvs = new(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseSrvRecord(out DnsSrvRecordData srv)) + { + srvs.Add((srv.Target.ToString(), srv.Port, srv.Priority, srv.Weight, record.TimeToLive)); + } + } + + // Skip the authority section. + SkipRecords(ref reader, header.AuthorityCount); + + // Gather additional-section A/AAAA glue addresses keyed by owner name. + Dictionary>? glue = null; + for (int i = 0; i < header.AdditionalCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + IPAddress? address = null; + if (record.TryParseARecord(out DnsARecordData a)) + { + address = a.ToIPAddress(); + } + else if (record.TryParseAAAARecord(out DnsAAAARecordData aaaa)) + { + address = aaaa.ToIPAddress(); + } + + if (address is not null) + { + string owner = record.Name.ToString(); + glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!glue.TryGetValue(owner, out List? list)) + { + list = new List(); + glue[owner] = list; + } + list.Add(new AddressRecord(address, TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + List records = new List(srvs.Count); + foreach ((string target, ushort port, ushort priority, ushort weight, uint ttl) in srvs) + { + IReadOnlyList? attached = null; + if (glue is not null && glue.TryGetValue(target, out List? list)) + { + attached = list; + } + records.Add(new SrvRecord(target, port, priority, weight, TimeSpan.FromSeconds(ttl), attached)); + } + + TimeSpan negTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, negTtl); + } + + private static DnsResult ParseTxt(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseTxtRecord(out DnsTxtRecordData txt)) + { + List values = new List(); + foreach (ReadOnlySpan str in txt.EnumerateStrings()) + { + values.Add(Encoding.UTF8.GetString(str)); + } + records.Add(new TxtRecord(values, TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan txtNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, txtNegTtl); + } + + private static DnsResult ParseMx(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseMxRecord(out DnsMxRecordData mx)) + { + records.Add(new MxRecord(mx.Exchange.ToString(), mx.Preference, TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan mxNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, mxNegTtl); + } + + private static DnsResult ParseCName(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseCNameRecord(out DnsCNameRecordData cname)) + { + records.Add(new CNameRecord(cname.CName.ToString(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan cnameNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, cnameNegTtl); + } + + private static DnsResult ParsePtr(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParsePtrRecord(out DnsPtrRecordData ptr)) + { + records.Add(new PtrRecord(ptr.Name.ToString(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan ptrNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, ptrNegTtl); + } + + private static DnsResult ParseNs(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + if (header.ResponseCode != DnsResponseCode.NoError) + { + return new DnsResult(header.ResponseCode, null, ExtractNegativeCacheTtl(response)); + } + + SkipQuestions(ref reader); + + List records = new List(); + for (int i = 0; i < header.AnswerCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseNsRecord(out DnsNsRecordData ns)) + { + records.Add(new NsRecord(ns.Name.ToString(), TimeSpan.FromSeconds(record.TimeToLive))); + } + } + + TimeSpan nsNegTtl = records.Count == 0 ? ExtractNegativeCacheTtl(response) : TimeSpan.Zero; + return new DnsResult(DnsResponseCode.NoError, records, nsNegTtl); + } + + 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); + } + + // Per RFC 2308 §5, the negative cache TTL is the minimum of the SOA record TTL + // and the SOA MINIMUM field of the SOA record in the authority section. + private static TimeSpan ExtractNegativeCacheTtl(ReadOnlySpan response) + { + DnsMessageReader reader = CreateReader(response); + DnsMessageHeader header = reader.Header; + + SkipQuestions(ref reader); + SkipRecords(ref reader, header.AnswerCount); + + for (int i = 0; i < header.AuthorityCount; i++) + { + DnsRecord record = ReadRecord(ref reader); + if (record.TryParseSoaRecord(out DnsSoaRecordData soa)) + { + uint negTtl = Math.Min(record.TimeToLive, soa.MinimumTtl); + return TimeSpan.FromSeconds(negTtl); + } + } + + return TimeSpan.Zero; + } + + // ---- Query engine ---- + + private static async Task SendQuery(IList servers, bool async, string name, DnsRecordType qtype, CancellationToken cancellationToken) + { + IReadOnlyList serverList = GetServers(servers); + Debug.Assert(serverList.Count > 0); + + byte[] queryBytes = ArrayPool.Shared.Rent(MaxUdpResponseSize); + try + { + ushort queryId = (ushort)RandomNumberGenerator.GetInt32(ushort.MaxValue + 1); + int queryLength = WriteQuery(queryId, name, qtype, queryBytes); + ReadOnlyMemory query = queryBytes.AsMemory(0, queryLength); + + byte[] responseBuffer = ArrayPool.Shared.Rent(MaxUdpResponseSize); + Exception? lastException = null; + + foreach (IPEndPoint server in serverList) + { + for (int attempt = 0; attempt <= MaxRetries; attempt++) + { + if (cancellationToken.IsCancellationRequested) + { + // Surface pre-flight cancellation as TaskCanceledException to match the + // Windows PAL (which completes via TaskCompletionSource.TrySetCanceled). + ArrayPool.Shared.Return(responseBuffer); + throw new TaskCanceledException(); + } + try + { + int responseLength = async + ? await SendUdpQueryAsync(query, server, responseBuffer, cancellationToken).ConfigureAwait(false) + : SendUdpQuerySync(query, server, responseBuffer); + + ResponseValidation validation = ValidateResponse( + responseBuffer.AsSpan(0, responseLength), queryId, name, qtype, out Exception? validationError); + + if (validation == ResponseValidation.Retry) + { + lastException = validationError; + continue; + } + + if (validation == ResponseValidation.TcpFallback) + { + (byte[]? tcpBuffer, int tcpLength, Exception? tcpError) = async + ? await TryTcpFallbackAsync(query, server, cancellationToken).ConfigureAwait(false) + : TryTcpFallbackSync(query, server); + + if (tcpBuffer is not null) + { + // Validate the TCP response (ID, QR bit, echoed question). + ResponseValidation tcpValidation = ValidateResponse( + tcpBuffer.AsSpan(0, tcpLength), queryId, name, qtype, out Exception? tcpValidationError); + if (tcpValidation != ResponseValidation.Ok) + { + ArrayPool.Shared.Return(tcpBuffer); + lastException = tcpValidationError ?? new InvalidDataException(); + continue; + } + + ArrayPool.Shared.Return(responseBuffer); + return new DnsResponse(tcpBuffer, tcpLength); + } + + lastException = tcpError; + continue; + } + + return new DnsResponse(responseBuffer, responseLength); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + ArrayPool.Shared.Return(responseBuffer); + throw; + } + catch (OperationCanceledException) + { + lastException = new TimeoutException(); + } + catch (SocketException ex) + { + lastException = ex; + } + catch (IOException ex) + { + lastException = ex; + } + } + } + + ArrayPool.Shared.Return(responseBuffer); + + if (lastException is not null) + { + ExceptionDispatchInfo.Throw(lastException); + } + throw new TimeoutException(); + } + finally + { + ArrayPool.Shared.Return(queryBytes); + } + } + + private enum ResponseValidation + { + Ok, + Retry, + TcpFallback, + } + + private static ResponseValidation ValidateResponse( + ReadOnlySpan response, ushort expectedId, string expectedName, DnsRecordType expectedType, + out Exception? error) + { + error = null; + + if (!DnsMessageHeader.TryRead(response, out DnsMessageHeader header)) + { + error = new InvalidDataException(); + return ResponseValidation.Retry; + } + + if (!header.IsResponse || header.Id != expectedId) + { + return ResponseValidation.Retry; + } + + if (!ValidateResponseQuestion(response, header, expectedName, expectedType)) + { + error = new InvalidDataException(); + return ResponseValidation.Retry; + } + + if ((header.Flags & DnsHeaderFlags.Truncation) != 0) + { + return ResponseValidation.TcpFallback; + } + + return ResponseValidation.Ok; + } + + private static bool ValidateResponseQuestion( + ReadOnlySpan response, DnsMessageHeader header, string expectedName, DnsRecordType expectedType) + { + if (header.QuestionCount != 1) + { + return false; + } + + DnsMessageReader.TryCreate(response, out DnsMessageReader reader); + if (!reader.TryReadQuestion(out DnsQuestion question)) + { + return false; + } + + return question.Type == expectedType && question.Name.Equals(expectedName); + } + + private static unsafe int WriteQuery(ushort queryId, string name, DnsRecordType type, Span destination) + { + Span nameBuffer = stackalloc byte[DnsEncodedName.MaxEncodedLength]; + OperationStatus status = DnsEncodedName.TryEncode(name, nameBuffer, out DnsEncodedName encodedName, out _); + if (status == OperationStatus.InvalidData) + { + throw new ArgumentException(SR.Format(SR.net_invalid_dns_name, name), nameof(name)); + } + Debug.Assert(status == OperationStatus.Done); + + DnsMessageWriter writer = new DnsMessageWriter(destination); + bool ok = writer.TryWriteHeader(new DnsMessageHeader { Id = queryId, Flags = DnsHeaderFlags.RecursionDesired, QuestionCount = 1 }); + Debug.Assert(ok); + ok = writer.TryWriteQuestion(encodedName, type); + Debug.Assert(ok); + return writer.BytesWritten; + } + + private static async Task SendUdpQueryAsync( + ReadOnlyMemory query, IPEndPoint server, byte[] responseBuffer, CancellationToken cancellationToken) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_queryTimeout); + + await socket.ConnectAsync(server, timeoutCts.Token).ConfigureAwait(false); + await socket.SendAsync(query, SocketFlags.None, timeoutCts.Token).ConfigureAwait(false); + return await socket.ReceiveAsync(responseBuffer, SocketFlags.None, timeoutCts.Token).ConfigureAwait(false); + } + + private static int SendUdpQuerySync( + ReadOnlyMemory query, IPEndPoint server, byte[] responseBuffer) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + socket.SendTimeout = (int)s_queryTimeout.TotalMilliseconds; + socket.ReceiveTimeout = (int)s_queryTimeout.TotalMilliseconds; + + socket.Connect(server); + socket.Send(query.Span, SocketFlags.None); + return socket.Receive(responseBuffer, SocketFlags.None); + } + + private static async Task<(byte[]? Buffer, int Length, Exception? Error)> TryTcpFallbackAsync( + ReadOnlyMemory query, IPEndPoint server, CancellationToken cancellationToken) + { + try + { + (byte[] buffer, int length) = await SendTcpQueryAsync(query, server, cancellationToken).ConfigureAwait(false); + return (buffer, length, null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return (null, 0, new TimeoutException()); + } + catch (Exception ex) when (ex is SocketException or IOException) + { + return (null, 0, ex); + } + } + + private static (byte[]? Buffer, int Length, Exception? Error) TryTcpFallbackSync( + ReadOnlyMemory query, IPEndPoint server) + { + try + { + (byte[] buffer, int length) = SendTcpQuerySync(query, server); + return (buffer, length, null); + } + catch (Exception ex) when (ex is SocketException or IOException) + { + return (null, 0, ex); + } + } + + private static async Task<(byte[] Buffer, int Length)> SendTcpQueryAsync( + ReadOnlyMemory query, IPEndPoint server, CancellationToken cancellationToken) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(s_queryTimeout); + + await socket.ConnectAsync(server, timeoutCts.Token).ConfigureAwait(false); + + byte[] buffer = ArrayPool.Shared.Rent(InitialTcpBufferSize); + try + { + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)query.Length); + await SendExactAsync(socket, buffer.AsMemory(0, 2), timeoutCts.Token).ConfigureAwait(false); + await SendExactAsync(socket, query, timeoutCts.Token).ConfigureAwait(false); + + await ReceiveExactAsync(socket, buffer.AsMemory(0, 2), timeoutCts.Token).ConfigureAwait(false); + int responseLength = BinaryPrimitives.ReadUInt16BigEndian(buffer); + + if (responseLength > buffer.Length) + { + ArrayPool.Shared.Return(buffer); + buffer = ArrayPool.Shared.Rent(responseLength); + } + + await ReceiveExactAsync(socket, buffer.AsMemory(0, responseLength), timeoutCts.Token).ConfigureAwait(false); + return (buffer, responseLength); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private static (byte[] Buffer, int Length) SendTcpQuerySync( + ReadOnlyMemory query, IPEndPoint server) + { + using Socket socket = new Socket(server.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + socket.SendTimeout = (int)s_queryTimeout.TotalMilliseconds; + socket.ReceiveTimeout = (int)s_queryTimeout.TotalMilliseconds; + + // Connect with explicit timeout to prevent unbounded blocking when + // the server's TCP endpoint is unreachable. + IAsyncResult ar = socket.BeginConnect(server, null, null); + if (!ar.AsyncWaitHandle.WaitOne(s_queryTimeout)) + { + socket.Close(); + throw new SocketException((int)SocketError.TimedOut); + } + socket.EndConnect(ar); + + byte[] buffer = ArrayPool.Shared.Rent(InitialTcpBufferSize); + try + { + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)query.Length); + SendExactSync(socket, buffer.AsSpan(0, 2)); + SendExactSync(socket, query.Span); + + ReceiveExactSync(socket, buffer.AsSpan(0, 2)); + int responseLength = BinaryPrimitives.ReadUInt16BigEndian(buffer); + + if (responseLength > buffer.Length) + { + ArrayPool.Shared.Return(buffer); + buffer = ArrayPool.Shared.Rent(responseLength); + } + + ReceiveExactSync(socket, buffer.AsSpan(0, responseLength)); + return (buffer, responseLength); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + private static async Task ReceiveExactAsync(Socket socket, Memory buffer, CancellationToken cancellationToken) + { + int totalReceived = 0; + while (totalReceived < buffer.Length) + { + int received = await socket.ReceiveAsync(buffer[totalReceived..], SocketFlags.None, cancellationToken).ConfigureAwait(false); + if (received == 0) + { + ThrowMalformedResponse(); + } + totalReceived += received; + } + } + + private static void ReceiveExactSync(Socket socket, Span buffer) + { + int totalReceived = 0; + while (totalReceived < buffer.Length) + { + int received = socket.Receive(buffer.Slice(totalReceived), SocketFlags.None); + if (received == 0) + { + ThrowMalformedResponse(); + } + totalReceived += received; + } + } + + private static async Task SendExactAsync(Socket socket, ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + int totalSent = 0; + while (totalSent < buffer.Length) + { + int sent = await socket.SendAsync(buffer[totalSent..], SocketFlags.None, cancellationToken).ConfigureAwait(false); + if (sent == 0) + { + throw new IOException(); + } + totalSent += sent; + } + } + + private static void SendExactSync(Socket socket, ReadOnlySpan buffer) + { + int totalSent = 0; + while (totalSent < buffer.Length) + { + int sent = socket.Send(buffer.Slice(totalSent), SocketFlags.None); + if (sent == 0) + { + throw new IOException(); + } + totalSent += sent; + } + } + + private static IReadOnlyList GetServers(IList servers) + { + if (servers.Count > 0) + { + // A port of 0 means "use the default DNS port" (53). + // Avoid allocating if all ports are already non-zero. + bool needsNormalization = false; + for (int i = 0; i < servers.Count; i++) + { + if (servers[i].Port == 0) + { + needsNormalization = true; + break; + } + } + + if (!needsNormalization) + { + // The IList may already be an array or List; wrap in a read-only view. + if (servers is IReadOnlyList readOnlyServers) + { + return readOnlyServers; + } + IPEndPoint[] copy = new IPEndPoint[servers.Count]; + servers.CopyTo(copy, 0); + return copy; + } + + IPEndPoint[] resolved = new IPEndPoint[servers.Count]; + for (int i = 0; i < servers.Count; i++) + { + IPEndPoint server = servers[i]; + resolved[i] = server.Port == 0 ? new IPEndPoint(server.Address, ResolvConf.DefaultDnsPort) : server; + } + return resolved; + } + + List systemServers = ResolvConf.GetNameServers(); + if (systemServers.Count > 0) + { + return systemServers; + } + + return new IPEndPoint[] { new IPEndPoint(IPAddress.Loopback, ResolvConf.DefaultDnsPort) }; + } + + // ---- Message reading helpers ---- + + private static DnsMessageReader CreateReader(ReadOnlySpan response) + { + if (!DnsMessageReader.TryCreate(response, out DnsMessageReader reader)) + { + ThrowMalformedResponse(); + } + return reader; + } + + private static void SkipQuestions(ref DnsMessageReader reader) + { + for (int i = 0; i < reader.Header.QuestionCount; i++) + { + if (!reader.TryReadQuestion(out _)) + { + ThrowMalformedResponse(); + } + } + } + + private static DnsRecord ReadRecord(ref DnsMessageReader reader) + { + if (!reader.TryReadRecord(out DnsRecord record)) + { + ThrowMalformedResponse(); + } + return record; + } + + private static void SkipRecords(ref DnsMessageReader reader, int count) + { + for (int i = 0; i < count; i++) + { + if (!reader.TryReadRecord(out _)) + { + ThrowMalformedResponse(); + } + } + } + + [DoesNotReturn] + private static void ThrowMalformedResponse() => + throw new InvalidDataException(); + + // Holds a response message buffer rented from the shared ArrayPool. + private readonly struct DnsResponse : IDisposable + { + private readonly byte[] _buffer; + private readonly int _length; + + public DnsResponse(byte[] buffer, int length) + { + _buffer = buffer; + _length = length; + } + + public ReadOnlySpan Span => _buffer.AsSpan(0, _length); + + public void Dispose() => ArrayPool.Shared.Return(_buffer); + } + } +} 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..b3d5817185ab60 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs @@ -0,0 +1,748 @@ +// 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.CompilerServices; +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) + { + using DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, Interop.Dnsapi.DNS_TYPE_SRV, cancellationToken).ConfigureAwait(false); + return ParseSrv(raw); + } + + 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) + { + using DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, Interop.Dnsapi.DNS_TYPE_TEXT, cancellationToken).ConfigureAwait(false); + return ParseTxt(raw); + } + + // ---- 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) + { + using DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + return ParseAddresses(raw, qtype); + } + + private static async Task> QuerySimple(IList servers, bool async, string name, ushort qtype, CancellationToken cancellationToken, + Func selector) + { + using DnsQueryRawResult raw = await DnsQueryEx(servers, async, name, qtype, cancellationToken).ConfigureAwait(false); + return ParseSimple(raw, qtype, selector); + } + + // ---- 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 + Unsafe.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, raw.NegativeCacheTtl); + } + + 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 + Unsafe.SizeOf(); + Interop.Dnsapi.DNS_SRV_DATA data = Marshal.PtrToStructure(dataPtr); + string target = PtrToString(data.pNameTarget) ?? string.Empty; + List? attached = null; + glue?.TryGetValue(target, out attached); + 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, raw.NegativeCacheTtl); + } + + 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 + Unsafe.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, raw.NegativeCacheTtl); + } + + 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 + Unsafe.SizeOf(); + records.Add(selector(hdr, dataPtr)); + } + cur = hdr.pNext; + } + + return new DnsResult(DnsResponseCode.NoError, records, raw.NegativeCacheTtl); + } + + private static DnsResult MergeAddressResults(DnsResult a, DnsResult b) + { + if (a.Records.Count > 0 || b.Records.Count > 0) + { + AddressRecord[] merged = [.. a.Records, .. b.Records]; + TimeSpan mergedTtl = a.NegativeCacheTtl < b.NegativeCacheTtl ? a.NegativeCacheTtl : b.NegativeCacheTtl; + return new DnsResult(DnsResponseCode.NoError, merged, mergedTtl); + } + + 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) + { + // DNS_A_DATA holds the IPv4 address as a uint already in network byte + // order, which is exactly the layout the IPAddress(long) ctor expects. + Interop.Dnsapi.DNS_A_DATA data = Marshal.PtrToStructure(dataPtr); + address = new IPAddress((long)data.IpAddress); + return true; + } + + if (recordType == Interop.Dnsapi.DNS_TYPE_AAAA) + { + // DNS_AAAA_DATA holds the 16 raw IPv6 address bytes; the InlineArray + // field exposes them as a span without any pointer arithmetic. + Interop.Dnsapi.DNS_AAAA_DATA data = Marshal.PtrToStructure(dataPtr); + address = new IPAddress((ReadOnlySpan)data.Ip6Address); + 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 + Unsafe.SizeOf(); + if (TryParseAddress(hdr.wType, dataPtr, out IPAddress? address)) + { + string name = PtrToString(hdr.pName) ?? string.Empty; + glue ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); + List list = CollectionsMarshal.GetValueRefOrAddDefault(glue, name, out _) ??= new 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 string? PtrToString(IntPtr p) => + p == IntPtr.Zero ? null : Marshal.PtrToStringUni(p); + + // Reinterprets native memory (records and result structures returned by + // DnsQueryEx) as a managed read-only reference. The structures are blittable + // and remain valid until the record list / result is freed, so this avoids the + // marshalling copy of Marshal.PtrToStructure. The single pointer cast is + // confined here so callers stay free of the 'unsafe' keyword. + private static unsafe ref readonly T AsStruct(IntPtr ptr) where T : unmanaged => + ref Unsafe.AsRef((void*)ptr); + + // ---- 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 bool _completed; + + 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 = new GCHandle(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, true)) + { + 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); + + // Extract the negative-cache TTL from an authority-section SOA when present. + // This covers both NXDOMAIN and NODATA (the latter maps to NoError but can + // still carry an authority SOA); the helper returns zero when no SOA is found. + TimeSpan 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.Dispose(); + } + } + } + + // 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 + { + DnsQueryAsyncState state = GCHandle.FromIntPtr(pQueryContext).Target; + + // 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 (Exception ex) + { + // Never allow exceptions to propagate into native code. + Debug.Fail($"Unexpected exception in DnsQueryEx completion callback: {ex}"); + } + } + + // DnsQueryEx always queries DNS servers on the standard port 53 and requires the + // sockaddr port field passed to the API to be left as 0; supplying any other + // non-zero port results in ERROR_INVALID_PARAMETER. We accept either 0 ("use the + // default port") or 53 (the port DnsQueryEx will actually use) and normalize both + // to 0 when building the native server list (see WriteSockAddr). Any other port + // cannot be honored on Windows and is rejected here. + private static void ValidateServerPorts(IList servers) + { + 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); + + // Extract the negative-cache TTL from an authority-section SOA when present. + // This covers both NXDOMAIN and NODATA (the latter maps to NoError but can + // still carry an authority SOA); the helper returns zero when no SOA is found. + TimeSpan 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; + + // DnsQueryEx encodes a single address family for the whole array, so all + // endpoints must share one. Reject mixed IPv4/IPv6 lists up front instead + // of producing an inconsistent DNS_ADDR_ARRAY. + AddressFamily family = servers[0].AddressFamily; + for (int i = 1; i < count; i++) + { + if (servers[i].AddressFamily != family) + { + throw new ArgumentException(SR.net_dns_mixed_address_families, nameof(DnsResolverOptions.Servers)); + } + } + + int headerSize = sizeof(Interop.Dnsapi.DNS_ADDR_ARRAY); + int addrSize = sizeof(Interop.Dnsapi.DNS_ADDR); + int totalSize = checked(headerSize + addrSize * count); + + arrayPtr = Marshal.AllocHGlobal(totalSize); + + // Wrap the unmanaged buffer in a span and populate it without pointer arithmetic. + Span buffer = new Span((void*)arrayPtr, totalSize); + buffer.Clear(); + + ref Interop.Dnsapi.DNS_ADDR_ARRAY arr = ref MemoryMarshal.AsRef(buffer); + arr.MaxCount = (uint)count; + arr.AddrCount = (uint)count; + arr.Family = (ushort)(family == AddressFamily.InterNetwork ? Interop.Dnsapi.AF_INET : Interop.Dnsapi.AF_INET6); + + for (int i = 0; i < count; i++) + { + WriteSockAddr(buffer.Slice(headerSize + (i * addrSize), addrSize), servers[i].Address); + } + } + + // 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 void WriteSockAddr(Span dest, IPAddress address) + { + // DnsQueryEx always queries DNS servers on port 53 and requires the sockaddr + // port field to be left as 0, so we build the SOCKADDR from a port-0 endpoint. + // Taking an IPAddress (rather than the caller's mutable IPEndPoint) also ensures + // we can never accidentally serialize a non-default port. SocketAddressPal lays + // out the platform SOCKADDR (family, port, address, and for IPv6 the flow info + // and scope id), so there's no need to write the bytes by hand. + SocketAddress socketAddress = new IPEndPoint(address, 0).Serialize(); + socketAddress.Buffer.Span.Slice(0, socketAddress.Size).CopyTo(dest); + } + + 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 + Unsafe.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); + } + } + } + } +} 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..42f1b940c0fa0b --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResult.cs @@ -0,0 +1,42 @@ +// 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 +{ + /// + /// Represents the result of a DNS resolution operation, including the response + /// code, the parsed records, and (for negative responses) the negative-cache TTL. + /// + /// The type of the resolved records. + public readonly struct DnsResult + { + private readonly IReadOnlyList? _records; + + /// Gets the DNS response code returned by the server. + [CLSCompliant(false)] + public DnsResponseCode ResponseCode { get; } + + /// + /// Gets the records returned by the server. The list is empty on error or NODATA responses. + /// + public IReadOnlyList Records => _records ?? Array.Empty(); + + /// + /// Gets the duration for which a negative response (NXDOMAIN or NODATA) may be cached. + /// + /// + /// The value is derived from the SOA minimum TTL in the authority section, per RFC 2308 §5. + /// It is when not applicable or unavailable. + /// + public TimeSpan NegativeCacheTtl { get; } + + internal DnsResult(DnsResponseCode responseCode, IReadOnlyList? records, TimeSpan negativeCacheTtl) + { + ResponseCode = responseCode; + _records = records; + NegativeCacheTtl = negativeCacheTtl; + } + } +} diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs new file mode 100644 index 00000000000000..ca45bbab2f3daf --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsWireEnums.cs @@ -0,0 +1,56 @@ +// 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 record TYPE values (RFC 1035 §3.2.2 and updates). + internal enum DnsRecordType : ushort + { + A = 1, + NS = 2, + CNAME = 5, + SOA = 6, + PTR = 12, + MX = 15, + TXT = 16, + AAAA = 28, + SRV = 33, + NAPTR = 35, + OPT = 41, + SVCB = 64, + HTTPS = 65, + } + + // DNS record CLASS values (RFC 1035 §3.2.4). + internal enum DnsRecordClass : ushort + { + Internet = 1, + Chaos = 3, + Hesiod = 4, + Any = 255, + } + + // DNS OPCODE values (RFC 1035 §4.1.1 and updates). + internal enum DnsOpCode : byte + { + Query = 0, + InverseQuery = 1, + Status = 2, + Notify = 4, + Update = 5, + } + + // DNS header flag bits. The enum values are the wire bit positions shifted + // right by 4 so the set fits in a byte; see DnsMessageHeader for the encoding. + [Flags] + internal enum DnsHeaderFlags : byte + { + None = 0, + AuthoritativeAnswer = 1 << 6, // AA — wire bit 10 + Truncation = 1 << 5, // TC — wire bit 9 + RecursionDesired = 1 << 4, // RD — wire bit 8 + RecursionAvailable = 1 << 3, // RA — wire bit 7 + AuthenticData = 1 << 1, // AD — wire bit 5 (RFC 4035) + CheckingDisabled = 1 << 0, // CD — wire bit 4 (RFC 4035) + } +} 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 diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs b/src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs new file mode 100644 index 00000000000000..985c54b696ce8a --- /dev/null +++ b/src/libraries/System.Net.NameResolution/src/System/Net/ResolvConf.cs @@ -0,0 +1,79 @@ +// 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.IO; + +namespace System.Net +{ + // Parses the system DNS server configuration from /etc/resolv.conf (RFC-style + // "nameserver" directives). Used when DnsResolverOptions.Servers is empty. + internal static class ResolvConf + { + private const string ResolvConfPath = "/etc/resolv.conf"; + internal const int DefaultDnsPort = 53; + + public static List GetNameServers() + { + try + { + using StreamReader reader = new StreamReader(ResolvConfPath); + return Parse(reader); + } + catch (IOException) + { + return new List(); + } + catch (UnauthorizedAccessException) + { + return new List(); + } + } + + // Parses "nameserver
" directives from a resolv.conf-formatted stream. + // Lines beginning with '#' or ';' are comments. Any text following the address + // on a nameserver line is ignored. + internal static List Parse(TextReader reader) + { + List servers = new List(); + + string? line; + while ((line = reader.ReadLine()) is not null) + { + ReadOnlySpan span = line.AsSpan().Trim(); + if (span.IsEmpty || span[0] == '#' || span[0] == ';') + { + continue; + } + + const string Directive = "nameserver"; + if (!span.StartsWith(Directive, StringComparison.Ordinal)) + { + continue; + } + + ReadOnlySpan rest = span[Directive.Length..]; + if (rest.IsEmpty || (rest[0] != ' ' && rest[0] != '\t')) + { + continue; + } + + rest = rest.TrimStart(); + + // The address is the first whitespace-delimited token; ignore anything after it. + int ws = rest.IndexOfAny(' ', '\t'); + if (ws >= 0) + { + rest = rest[..ws]; + } + + if (IPAddress.TryParse(rest, out IPAddress? address)) + { + servers.Add(new IPEndPoint(address, DefaultDnsPort)); + } + } + + return servers; + } + } +} 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..13e834fd2a5c69 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverLoopbackTest.cs @@ -0,0 +1,525 @@ +// 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; +using Xunit; + +namespace System.Net.NameResolution.Tests +{ + public sealed class DnsLoopbackServerFixture : IAsyncDisposable + { + private LoopbackDnsServer? _server; + + // Started lazily on first access (from within a test invocation) rather than in + // the constructor. LoopbackDnsServer.Start() throws SkipTestException when port 53 + // is unavailable; that is only honored by the ConditionalFact/ConditionalTheory + // runner, which wraps the test class constructor where Server is first accessed. + // Starting in this fixture constructor would instead surface as a hard failure. + internal LoopbackDnsServer Server => _server ??= LoopbackDnsServer.Start(); + + public async ValueTask DisposeAsync() + { + if (_server is not null) + { + await _server.DisposeAsync(); + } + } + } + + // 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. + // On other platforms the managed resolver targets the server's actual endpoint, so + // the loopback server binds an ephemeral port. When the port is unavailable (e.g. a + // local DNS service is already running) the tests are skipped via SkipTestException + // rather than failing. The tests run sequentially (see the collection) to keep the + // shared loopback server's response table deterministic. + // + // 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 a loopback DNS port and issues real DNS queries.")] + [Collection(nameof(DisableParallelization))] + public class DnsResolverLoopbackTest : IClassFixture + { + 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"; + + LoopbackDnsServer _server; + DnsResolver? _resolver; + + public DnsResolverLoopbackTest(DnsLoopbackServerFixture fixture) + { + _server = fixture.Server; + _server.ClearResponses(); + } + + 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 ---- + + [ConditionalTheory] + [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 ResolveAddresses(async, Resolver, 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"); + } + + [ConditionalTheory] + [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 ResolveAddresses(async, Resolver, 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()); + } + + [ConditionalTheory] + [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 ResolveAddresses(async, Resolver, name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + AddressRecord record = Assert.Single(result.Records); + Assert.Equal("fd00::1", record.Address.ToString()); + } + + [ConditionalTheory] + [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 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()); + } + + [ConditionalTheory] + [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 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). + Assert.True(record.Ttl > TimeSpan.Zero && record.Ttl <= TimeSpan.FromSeconds(120), + $"Unexpected TTL: {record.Ttl}"); + } + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_Nxdomain_ReturnsNxDomain(bool async) + { + 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 ResolveAddresses(async, Resolver, name); + + Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); + Assert.Empty(result.Records); + // The negative-cache TTL should be derived from the authority SOA record (120s). + Assert.True(result.NegativeCacheTtl > TimeSpan.Zero && result.NegativeCacheTtl <= TimeSpan.FromSeconds(120), + $"Unexpected NegativeCacheTtl: {result.NegativeCacheTtl}"); + } + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_NoData_ReturnsNoErrorWithEmptyRecords(bool async) + { + 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 ResolveAddresses(async, Resolver, name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.Empty(result.Records); + // The negative-cache TTL should be derived from the authority SOA record (30s). + Assert.True(result.NegativeCacheTtl > TimeSpan.Zero && result.NegativeCacheTtl <= TimeSpan.FromSeconds(30), + $"Unexpected NegativeCacheTtl: {result.NegativeCacheTtl}"); + } + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_NoData_And_Nxdomain_AreDistinguishable(bool async) + { + 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 ResolveAddresses(async, Resolver, nodataName); + Assert.Equal(DnsResponseCode.NoError, nodata.ResponseCode); + Assert.Empty(nodata.Records); + + DnsResult nxdomain = await ResolveAddresses(async, Resolver, missingName); + Assert.Equal(DnsResponseCode.NxDomain, nxdomain.ResponseCode); + Assert.Empty(nxdomain.Records); + + Assert.NotEqual(nodata.ResponseCode, nxdomain.ResponseCode); + } + + // ---- SRV ---- + + [ConditionalTheory] + [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 ResolveSrv(async, Resolver, 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); + } + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveSrv_IncludesAdditionalAddresses(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) + .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 ResolveSrv(async, Resolver, 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); + } + + [ConditionalTheory] + [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 ResolveSrv(async, Resolver, name); + + SrvRecord record = Assert.Single(result.Records); + Assert.Equal("noaddr.test", record.Target); + Assert.Empty(record.Addresses); + } + + // ---- MX / TXT / CNAME / PTR / NS ---- + + [ConditionalTheory] + [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 ResolveMx(async, Resolver, 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); + } + + [ConditionalTheory] + [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 ResolveTxt(async, Resolver, 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"); + } + + [ConditionalTheory] + [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 ResolveCName(async, Resolver, name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + CNameRecord record = Assert.Single(result.Records); + Assert.Equal("canonical.test", record.CanonicalName); + } + + [ConditionalTheory] + [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 ResolvePtr(async, Resolver, name); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + PtrRecord record = Assert.Single(result.Records); + Assert.Equal("host.test", record.Name); + } + + [ConditionalTheory] + [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 ResolveNs(async, Resolver, 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 ---- + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [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 + { + 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 ResolveAddresses(async, resolver, 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] + public async Task ResolveAddresses_CancellationInFlight_Throws() + { + using SemaphoreSlim queryReceived = new(0, 1); + using ManualResetEventSlim serverCanContinue = new(false); + using CancellationTokenSource cts = new(); + + 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(); + } + + // ---- Telemetry ---- + + [ConditionalTheory] + [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."); + } + } +} 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..706171b078aa68 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -0,0 +1,346 @@ +// 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 System.Linq; +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 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] + 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)); + Assert.Throws(() => r.ResolveAddresses(TestHost)); + Assert.Throws(() => r.ResolveSrv(TestSrv)); + Assert.Throws(() => r.ResolveMx(TestMxHost)); + } + + [Fact] + public async Task DnsResolver_DisposeAsync_ThrowsOnUse() + { + DnsResolver r = new DnsResolver(); + await r.DisposeAsync(); + await Assert.ThrowsAsync(() => r.ResolveAddressesAsync(TestHost)); + } + + // ---- 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> 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> ResolveNs(bool async, DnsResolver resolver, string name) + => async ? await resolver.ResolveNsAsync(name) : resolver.ResolveNs(name); + + private static async Task> ResolvePtr(bool async, DnsResolver resolver, IPAddress address) + => async ? await resolver.ResolvePtrAsync(address) : resolver.ResolvePtr(address); + + private static async Task> Static_ResolveAddresses(bool async, string name) + => async ? await Dns.ResolveAddressesAsync(name) : Dns.ResolveAddresses(name); + + // ---- Windows network tests (require outbound DNS) ---- + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + 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)); + } + + // Regression test for the Windows 10 DnsQueryEx bug where an asynchronous query + // that the OS can satisfy synchronously (for example "localhost") returns + // ERROR_SUCCESS inline and never invokes the registered completion callback. + // If the implementation waited for that callback it would hang forever; the PAL + // must instead detect the synchronous completion (any status other than + // DNS_REQUEST_PENDING) and surface the result directly. + // See https://dblohm7.ca/blog/2022/05/06/dnsqueryex-needs-love/. + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveAddresses_SynchronouslyCompletingQuery_DoesNotHang(bool async) + { + using DnsResolver r = new DnsResolver(); + + // "localhost" can be answered without any network round-trip, which is what + // triggers the synchronous-completion path inside DnsQueryEx. A short timeout + // turns the "callback never fires" hang into a test failure rather than letting + // the run stall. + Task> task = ResolveAddresses(async, r, "localhost"); + DnsResult result = await task.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + // DnsQueryEx performs a pure-DNS lookup, so "localhost" may legitimately yield + // no records (NODATA); if any are returned they must be loopback addresses. + foreach (AddressRecord rec in result.Records) + { + Assert.True(IPAddress.IsLoopback(rec.Address), $"Expected a loopback address but got {rec.Address}."); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveAddresses_KnownName_ReturnsRecords(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveAddresses(async, r, 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); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveAddresses_IPv4Only_ReturnsOnlyIPv4(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveAddresses(async, r, TestHost, AddressFamily.InterNetwork); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + foreach (AddressRecord rec in result.Records) + { + Assert.Equal(AddressFamily.InterNetwork, rec.Address.AddressFamily); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveAddresses_NonExistent_ReturnsNxDomain(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveAddresses(async, r, NonExistentHost); + Assert.Equal(DnsResponseCode.NxDomain, result.ResponseCode); + Assert.Empty(result.Records); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveMx_KnownName_ReturnsRecords(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveMx(async, r, TestMxHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (MxRecord rec in result.Records) + { + Assert.False(string.IsNullOrEmpty(rec.Exchange)); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveTxt_KnownName_ReturnsRecords(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveTxt(async, r, TestTxtHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (TxtRecord rec in result.Records) + { + Assert.NotEmpty(rec.Values); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveCName_KnownName_ReturnsRecord(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveCName(async, r, 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)); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolveNs_KnownName_ReturnsRecords(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolveNs(async, r, TestNsHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + foreach (NsRecord rec in result.Records) + { + Assert.False(string.IsNullOrEmpty(rec.Name)); + } + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolvePtr_ByIPAddress_ReturnsRecord(bool async) + { + using DnsResolver r = new DnsResolver(); + DnsResult result = await ResolvePtr(async, r, IPAddress.Parse("8.8.8.8")); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + Assert.False(string.IsNullOrEmpty(result.Records[0].Name)); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task Static_Dns_ResolveAddresses_Works(bool async) + { + DnsResult result = await Static_ResolveAddresses(async, TestHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + Assert.NotEmpty(result.Records); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task DnsResolver_CustomServer_Port53_Works(bool async) + { + IPAddress? dnsAddress = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces() + .SelectMany(ni => ni.GetIPProperties().DnsAddresses) + .FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork); + + if (dnsAddress is null) + { + // No IPv4 DNS server is configured on this machine; nothing to validate. + return; + } + + DnsResolverOptions opts = new DnsResolverOptions + { + Servers = { new IPEndPoint(dnsAddress, 53) } + }; + using DnsResolver r = new DnsResolver(opts); + DnsResult result = await ResolveAddresses(async, r, TestHost); + Assert.Equal(DnsResponseCode.NoError, result.ResponseCode); + 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)); + Assert.Throws(() => r.ResolveAddresses(TestHost)); + } + + // ---- Reverse-arpa name building (covers both IPv4 and IPv6 paths used by ResolvePtr(IPAddress)) ---- + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))] + [InlineData(false)] + [InlineData(true)] + [OuterLoop] + public async Task ResolvePtr_IPv6Address_DoesNotThrow(bool async) + { + using DnsResolver r = new DnsResolver(); + // Google public DNS IPv6 — call shouldn't throw, even if no PTR record exists. + DnsResult result = await ResolvePtr(async, r, 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/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..dfebbd3b6aa040 --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/LoopbackDnsServer.cs @@ -0,0 +1,304 @@ +// 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.Diagnostics; +using System.Collections.Concurrent; +using System.Collections.Generic; +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 a loopback DNS endpoint + /// 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 binds 53 on + /// Windows. On other platforms the managed resolver targets the server's actual + /// endpoint, so an ephemeral port is used instead. If the port cannot be bound (e.g. + /// a local DNS service already owns it), throws + /// so the test is skipped rather than failed. + /// + internal sealed class LoopbackDnsServer : IAsyncDisposable + { + public const int DnsPort = 53; + + private readonly Socket _udp; + private readonly Socket _tcp; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _udpListenTask; + private readonly Task _tcpListenTask; + private readonly ConcurrentDictionary<(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(Socket udp, Socket tcp, IPEndPoint endPoint) + { + _udp = udp; + _tcp = tcp; + EndPoint = endPoint; + _udpListenTask = ListenUdpAsync(_cts.Token); + _tcpListenTask = ListenTcpAsync(_cts.Token); + } + + public static LoopbackDnsServer Start() + { + 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 + { + // Windows DnsQueryEx only contacts custom servers on port 53, so the + // server must bind 53 there. On other platforms the managed resolver + // targets the server's actual endpoint, so an ephemeral port (0) is used + // to avoid conflicts with any system DNS service. + int port = OperatingSystem.IsWindows() ? DnsPort : 0; + udp.Bind(new IPEndPoint(IPAddress.Loopback, port)); + } + 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.LocalEndPoint!; + Socket tcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + 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})."); + } + + 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, _) => + 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) + { + 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 () => + { + 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) + { + try + { + while (!ct.IsCancellationRequested) + { + 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(Socket client, CancellationToken ct) + { + try + { + using (client) + { + byte[] lengthBuf = new byte[2]; + if (!await ReadExactlyAsync(client, lengthBuf, ct)) + { + return; + } + + int queryLength = BinaryPrimitives.ReadUInt16BigEndian(lengthBuf); + byte[] query = new byte[queryLength]; + if (!await ReadExactlyAsync(client, 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 client.SendAsync(responseLengthBuf, SocketFlags.None, ct); + await client.SendAsync(response, SocketFlags.None, ct); + } + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (SocketException) { } + } + + private static async Task ReadExactlyAsync(Socket socket, byte[] buffer, CancellationToken ct) + { + int read = 0; + while (read < buffer.Length) + { + int n = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, 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.Dispose(); + 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 9741c993ccdba5..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 @@ -9,6 +9,10 @@ + + + + diff --git a/src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs b/src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs new file mode 100644 index 00000000000000..d6d8a226f526fc --- /dev/null +++ b/src/libraries/System.Net.NameResolution/tests/PalTests/ResolvConfTests.cs @@ -0,0 +1,118 @@ +// 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.IO; +using Xunit; + +namespace System.Net.NameResolution.PalTests +{ + public class ResolvConfTests + { + private static List Parse(string contents) + { + using StringReader reader = new StringReader(contents); + return ResolvConf.Parse(reader); + } + + [Fact] + public void Parse_SingleNameserver_ReturnsEndpointWithPort53() + { + List servers = Parse("nameserver 192.0.2.1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + Assert.Equal(53, server.Port); + } + + [Fact] + public void Parse_MultipleNameservers_PreservesOrder() + { + List servers = Parse( + "nameserver 192.0.2.1\n" + + "nameserver 192.0.2.2\n" + + "nameserver 192.0.2.3\n"); + + Assert.Equal(3, servers.Count); + Assert.Equal(IPAddress.Parse("192.0.2.1"), servers[0].Address); + Assert.Equal(IPAddress.Parse("192.0.2.2"), servers[1].Address); + Assert.Equal(IPAddress.Parse("192.0.2.3"), servers[2].Address); + } + + [Fact] + public void Parse_IPv6Nameserver_IsParsed() + { + List servers = Parse("nameserver 2001:db8::1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("2001:db8::1"), server.Address); + Assert.Equal(53, server.Port); + } + + [Theory] + [InlineData("# nameserver 192.0.2.1\n")] + [InlineData("; nameserver 192.0.2.1\n")] + [InlineData("\n \n\t\n")] + [InlineData("search example.com\noptions ndots:2\ndomain example.com\n")] + public void Parse_NonNameserverContent_ReturnsEmpty(string contents) + { + Assert.Empty(Parse(contents)); + } + + [Fact] + public void Parse_IgnoresOtherDirectivesAndComments() + { + List servers = Parse( + "# This is a comment\n" + + "domain example.com\n" + + "search example.com sub.example.com\n" + + "nameserver 192.0.2.10\n" + + "; trailing comment\n" + + "options ndots:1 timeout:2\n" + + "nameserver 192.0.2.20\n"); + + Assert.Equal(2, servers.Count); + Assert.Equal(IPAddress.Parse("192.0.2.10"), servers[0].Address); + Assert.Equal(IPAddress.Parse("192.0.2.20"), servers[1].Address); + } + + [Fact] + public void Parse_TextAfterAddress_IsIgnored() + { + List servers = Parse("nameserver 192.0.2.1 # primary resolver\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + } + + [Fact] + public void Parse_TabSeparatedNameserver_IsParsed() + { + List servers = Parse("nameserver\t192.0.2.1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + } + + [Theory] + [InlineData("nameserver\n")] + [InlineData("nameserver \n")] + [InlineData("nameserver not-an-ip\n")] + [InlineData("nameserverextra 192.0.2.1\n")] + public void Parse_InvalidNameserverLines_AreIgnored(string contents) + { + Assert.Empty(Parse(contents)); + } + + [Fact] + public void Parse_ValidAndInvalidMixed_ReturnsOnlyValid() + { + List servers = Parse( + "nameserver not-an-ip\n" + + "nameserver 192.0.2.1\n"); + + IPEndPoint server = Assert.Single(servers); + Assert.Equal(IPAddress.Parse("192.0.2.1"), server.Address); + } + } +} diff --git a/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj b/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj index 05e55180110b81..9c35e7c971348d 100644 --- a/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj +++ b/src/libraries/System.Net.NameResolution/tests/PalTests/System.Net.NameResolution.Pal.Tests.csproj @@ -108,4 +108,9 @@ + + + +