diff --git a/docs/oracle-dns-protocol.md b/docs/oracle-dns-protocol.md new file mode 100644 index 000000000..3baa3e373 --- /dev/null +++ b/docs/oracle-dns-protocol.md @@ -0,0 +1,125 @@ +# Oracle DNS Protocol + +The Oracle plugin resolves RFC 4501 `dns:` URIs through a DNS-over-HTTPS (DoH) gateway. This lets oracle nodes read authoritative DNS data (TXT for DKIM/SPF/DIDs, CERT/TLSA, etc.) without sending plaintext DNS queries. + +> **When should I use it?** +> Whenever you need DNS data on-chain and want the request to stay encrypted end-to-end. + +## Enable and configure + +1. Install or build the `OracleService` plugin and copy `OracleService.json` next to the plugin binary. +2. Add the `Dns` section (defaults shown): + +```jsonc +{ + "PluginConfiguration": { + // ... + "Dns": { + "EndPoint": "https://cloudflare-dns.com/dns-query", + "Timeout": 5000 + } + } +} +``` + +- `EndPoint` must point to a DoH resolver that supports [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) with `application/dns-message` format. +- `Timeout` is the maximum milliseconds the oracle will wait for a DoH response before returning `OracleResponseCode.Timeout`. + +> You can run your own DoH gateway and point the oracle to it if you need custom trust anchors or strict egress controls. + +### RFC 8484 Compliance + +This implementation uses the standard `application/dns-message` content type as defined in RFC 8484. DNS queries are sent as POST requests with binary DNS wire format (RFC 1035). Compatible DoH endpoints include: + +| Provider | Endpoint | +|----------|----------| +| Cloudflare | `https://cloudflare-dns.com/dns-query` | +| Google | `https://dns.google/dns-query` | +| Quad9 | `https://dns.quad9.net/dns-query` | + +Any RFC 8484-compliant DoH server should work with this oracle protocol. + +## RFC 4501 URI format + +``` +dns:[//authority/]domain[?CLASS=class;TYPE=type] +``` + +- `domain` is the DNS owner name (relative or absolute). Percent-encoding and escaped dots (`%5c.`) follow RFC 4501 rules. +- `domain` must not include additional path segments; only the owner name belongs here. +- `authority` is the optional DoH server to use for this query (RFC 4501). When specified, the oracle connects to `https://{authority}/dns-query`. If omitted, the configured `EndPoint` is used. +- `CLASS` is optional and case-insensitive. Only `IN` (`1`) is supported; other classes are rejected. +- `TYPE` is optional and case-insensitive. Use mnemonics (`TXT`, `TLSA`, `CERT`, `A`, `AAAA`, …) or numeric values. Defaults to `A` per RFC 4501. + +Query parameters can be separated by `;` (RFC style) or `&`. + +Examples: + +- `dns:1alhai._domainkey.icloud.com?TYPE=TXT` — DKIM TXT record. +- `dns:simon.example.org?TYPE=CERT` — CERT RDATA is returned as-is (type, key tag, algorithm, base64). +- `dns://dns.google/ftp.example.org?TYPE=A` — uses Google's DoH server (`https://dns.google/dns-query`) instead of the configured endpoint. +- `dns://cloudflare-dns.com/example.org?TYPE=TXT` — uses Cloudflare's DoH server for this specific query. + +## Response schema + +Successful queries return a NeoVM-serialized **Struct** (use `StdLib.Deserialize(result)` in contracts). + +Struct schema: + +- `Envelope` (Struct, 3 items): `[Name, Type, Answers]` +- `Answer` (Struct, 4 items): `[Name, Type, Ttl, Data]` + +Notes: + +- `Answers` normalizes record types and names, sorts records by name/type/data, and sets `Ttl` to `0` so all oracle nodes serialize the same payload. +- CERT records are returned verbatim in `Answer[3]` (type, key tag, algorithm, base64 payload). Contracts can parse the certificate themselves if needed. +- If the DoH server responds with NXDOMAIN, the oracle returns `OracleResponseCode.NotFound`. +- Results exceeding `OracleResponse.MaxResultSize` yield `OracleResponseCode.ResponseTooLarge`. +- Oracle `filter` is not supported for DNS responses in struct mode; pass an empty filter string. + +## Contract usage example + +```csharp +public static void RequestAppleDkim() +{ + const string url = "dns:1alhai._domainkey.icloud.com?TYPE=TXT"; + Oracle.Request(url, "", nameof(OnOracleCallback), Runtime.CallingScriptHash, 5_00000000); +} + +public static void OnOracleCallback(string url, byte[] userData, int code, byte[] result) +{ + if (code != (int)OracleResponseCode.Success) throw new Exception("Oracle query failed"); + + // Envelope = [Name, Type, Answers] + var envelope = (object[])StdLib.Deserialize(result); + var answers = (object[])envelope[2]; + + // Answer = [Name, Type, Ttl, Data] + var first = (object[])answers[0]; + Storage.Put(Storage.CurrentContext, "dkim", (string)first[3]); +} +``` + +Tips: + +1. Always set `TYPE` when you need anything other than an A record. +2. Budget enough `gasForResponse` to cover payload size (TXT records are often kilobytes). +3. Validate or fingerprint returned DNS data before trusting it. +4. DNS oracle responses do not support the oracle `filter`; request the record type you need and parse `Answers` in-contract. + +## Manual testing + +Use the same resolver the oracle will contact to inspect responses: + +```bash +printf 'bR4BAAABAAAAAAAABjFhbGhhaQpfZG9tYWlua2V5BmljbG91ZANjb20AAAEAAQ==' | \ + base64 -d | \ + curl -s \ + -X POST \ + -H 'accept: application/dns-message' \ + -H 'content-type: application/dns-message' \ + --data-binary @- \ + 'https://cloudflare-dns.com/dns-query' +``` + +Compare the DNS answer content with `Answer[3]` returned by your contract callback (after `StdLib.Deserialize`). diff --git a/plugins/OracleService/OracleService.cs b/plugins/OracleService/OracleService.cs index b1b56fffd..fa6dee0d6 100644 --- a/plugins/OracleService/OracleService.cs +++ b/plugins/OracleService/OracleService.cs @@ -32,6 +32,7 @@ using Neo.Wallets; using Serilog; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Text; namespace Neo.Plugins.OracleService; @@ -152,6 +153,7 @@ public Task Start(Wallet? wallet) this.wallet = wallet; protocols["https"] = new OracleHttpsProtocol(); + protocols["dns"] = new OracleDnsProtocol(); protocols["neofs"] = new OracleNeoFSProtocol(wallet, oracles); status = OracleStatus.Running; timer = new Timer(OnTimer, null, RefreshIntervalMilliSeconds, Timeout.Infinite); @@ -301,8 +303,20 @@ private async Task ProcessRequestAsync(DataCache snapshot, OracleRequest req) (OracleResponseCode code, string? data) = await ProcessUrlAsync(req.Url); + bool dnsStackOutput = Uri.TryCreate(req.Url, UriKind.Absolute, out Uri? requestUri) + && requestUri.Scheme.Equals("dns", StringComparison.OrdinalIgnoreCase); + byte[]? dnsStackBytes = null; + if (code == OracleResponseCode.Success && dnsStackOutput) + { + if (!TryDecodeDnsStackItemPayload(data, out dnsStackBytes)) + { + code = OracleResponseCode.Error; + PluginLogger?.Warning("Invalid DNS stack item payload: {OriginalTxid}", req.OriginalTxid); + } + } + PluginLogger?.Information("Process oracle request end: {OriginalTxid} <{Url}>, responseCode:{ResponseCode}, response:{Response}", - req.OriginalTxid, req.Url, code, data); + req.OriginalTxid, req.Url, code, FormatResponseForLog(code, data, dnsStackOutput)); var oracleNodes = NativeContract.RoleManagement.GetDesignatedByRole(snapshot, Role.Oracle, height); foreach (var (requestId, request) in NativeContract.Oracle.GetRequestsByUrl(snapshot, req.Url)) @@ -312,7 +326,20 @@ private async Task ProcessRequestAsync(DataCache snapshot, OracleRequest req) { try { - result = Filter(data!, request.Filter); + if (dnsStackOutput) + { + if (!string.IsNullOrEmpty(request.Filter)) + throw new InvalidOperationException("Filter is not supported for dns: requests."); + if (dnsStackBytes is null) + throw new InvalidOperationException("Missing DNS stack item payload."); + if (dnsStackBytes.Length > OracleResponse.MaxResultSize) + throw new InvalidOperationException("DNS stack item payload exceeds oracle maximum result size."); + result = dnsStackBytes; + } + else + { + result = Filter(data ?? string.Empty, request.Filter); + } } catch (Exception ex) { @@ -542,6 +569,41 @@ public static byte[] Filter(string input, string? filterArgs) return afterObjects.ToByteArray(false); } + internal static string? FormatResponseForLog(OracleResponseCode code, string? data, bool dnsStackOutput) + { + if (code != OracleResponseCode.Success) + return data; + + if (dnsStackOutput) + return string.IsNullOrEmpty(data) ? "" : $""; + + if (string.IsNullOrEmpty(data)) + return data; + + const int maxLen = 2048; + return data.Length <= maxLen ? data : data[..maxLen] + "..."; + } + + internal static bool TryDecodeDnsStackItemPayload(string? payload, [NotNullWhen(true)] out byte[]? result) + { + if (payload is null) + { + result = null; + return false; + } + + try + { + result = Convert.FromBase64String(payload); + return true; + } + catch (Exception) + { + result = null; + return false; + } + } + private bool CheckTxSign(DataCache snapshot, Transaction tx, ConcurrentDictionary OracleSigns) { uint height = NativeContract.Ledger.CurrentIndex(snapshot) + 1; diff --git a/plugins/OracleService/OracleService.json b/plugins/OracleService/OracleService.json index 49bf1153b..c9db00ef7 100644 --- a/plugins/OracleService/OracleService.json +++ b/plugins/OracleService/OracleService.json @@ -14,6 +14,10 @@ "EndPoint": "http://127.0.0.1:8080", "Timeout": 15000 }, + "Dns": { + "EndPoint": "https://cloudflare-dns.com/dns-query", + "Timeout": 5000 + }, "AutoStart": false }, "Dependency": [ diff --git a/plugins/OracleService/OracleSettings.cs b/plugins/OracleService/OracleSettings.cs index c60b6d315..297781996 100644 --- a/plugins/OracleService/OracleSettings.cs +++ b/plugins/OracleService/OracleSettings.cs @@ -36,6 +36,19 @@ public NeoFSSettings(IConfigurationSection section) } } +class DnsSettings +{ + public Uri EndPoint { get; } + public TimeSpan Timeout { get; } + + public DnsSettings(IConfigurationSection section) + { + string endpoint = section.GetValue("EndPoint", "https://cloudflare-dns.com/dns-query"); + EndPoint = new Uri(endpoint, UriKind.Absolute); + Timeout = TimeSpan.FromMilliseconds(section.GetValue("Timeout", 5000)); + } +} + class OracleSettings : IPluginSettings { public uint Network { get; } @@ -46,6 +59,7 @@ class OracleSettings : IPluginSettings public string[] AllowedContentTypes { get; } public HttpsSettings Https { get; } public NeoFSSettings NeoFS { get; } + public DnsSettings Dns { get; } public bool AutoStart { get; } public static OracleSettings Default { get; private set; } = null!; @@ -65,6 +79,7 @@ private OracleSettings(IConfigurationSection section) AllowedContentTypes = AllowedContentTypes.Concat("application/json").ToArray(); Https = new HttpsSettings(section.GetSection("Https")); NeoFS = new NeoFSSettings(section.GetSection("NeoFS")); + Dns = new DnsSettings(section.GetSection("Dns")); AutoStart = section.GetValue("AutoStart", false); } diff --git a/plugins/OracleService/Protocols/OracleDnsProtocol.cs b/plugins/OracleService/Protocols/OracleDnsProtocol.cs new file mode 100644 index 000000000..d7640f7a5 --- /dev/null +++ b/plugins/OracleService/Protocols/OracleDnsProtocol.cs @@ -0,0 +1,807 @@ +// Copyright (C) 2015-2026 The Neo Project. +// +// OracleDnsProtocol.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Neo.Network.P2P.Payloads; +using Neo.VM; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.IO.Pipelines; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using VmArray = Neo.VM.Types.Array; +using VmByteString = Neo.VM.Types.ByteString; +using VmInteger = Neo.VM.Types.Integer; +using VmStruct = Neo.VM.Types.Struct; + +namespace Neo.Plugins.OracleService.Protocols; + +/// +/// DNS oracle protocol implementing RFC 8484 (DNS over HTTPS) with application/dns-message format. +/// +class OracleDnsProtocol : IOracleProtocol +{ + private const int DnsHeaderSize = 12; + private static readonly MediaTypeHeaderValue DnsMessageMediaType = new("application/dns-message"); + private sealed class ResponseTooLargeException : Exception { } + + /// + /// Represents a parsed DNS resource record from wire format (RFC 1035). + /// + private sealed class DnsResourceRecord + { + public required string Name { get; set; } + public ushort Type { get; set; } + public ushort Class { get; set; } + public uint Ttl { get; set; } + public required byte[] RData { get; set; } + } + + /// + /// Represents a parsed DNS response message (RFC 1035). + /// + private sealed class DnsMessage + { + public ushort Id { get; set; } + public ushort Flags { get; set; } + public int ResponseCode => Flags & 0x0F; + public List Answers { get; } = []; + } + + private sealed class ResultAnswer + { + public required string Name { get; set; } + public required string Type { get; set; } + public uint Ttl { get; set; } + public required string Data { get; set; } + } + + private static readonly IReadOnlyDictionary RecordTypeLookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["A"] = 1, + ["NS"] = 2, + ["CNAME"] = 5, + ["SOA"] = 6, + ["MX"] = 15, + ["TXT"] = 16, + ["AAAA"] = 28, + ["SRV"] = 33, + ["CERT"] = 37, + ["DNSKEY"] = 48, + ["TLSA"] = 52, + }; + + private static readonly IReadOnlyDictionary ReverseRecordTypeLookup = + RecordTypeLookup.ToDictionary(p => p.Value, p => p.Key); + + private readonly HttpClient client; + private readonly object syncRoot = new(); + private bool configured; + private Uri? endpoint; + private TimeSpan timeout; + + public OracleDnsProtocol(HttpMessageHandler? handler = null) + { + // Do not allow automatic redirects; resolver endpoints must be explicitly allowed. + client = handler is null ? new HttpClient(CreateDefaultHandler()) : new HttpClient(handler); + CustomAttributeData attribute = Assembly.GetExecutingAssembly().CustomAttributes.First(p => p.AttributeType == typeof(AssemblyInformationalVersionAttribute)); + string version = attribute.ConstructorArguments[0].Value as string ?? "unknown"; + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NeoOracleService", version)); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/dns-message")); + } + + public void Configure() + { + EnsureConfigured(force: true); + } + + public void Dispose() + { + client.Dispose(); + } + + public async Task<(OracleResponseCode, string?)> ProcessAsync(Uri uri, CancellationToken cancellation) + { + EnsureConfigured(); + + string queryName; + NameValueCollection query; + Uri resolverEndpoint; + try + { + query = ParseQueryString(uri.Query); + queryName = BuildQueryName(uri); + ValidateClass(query); + resolverEndpoint = GetResolverEndpoint(uri); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + int recordType; + string recordTypeLabel; + try + { + recordType = ParseRecordType(query); + recordTypeLabel = GetRecordTypeLabel(recordType); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + Utility.Log(nameof(OracleDnsProtocol), LogLevel.Debug, $"Request: {queryName} ({recordTypeLabel}) via {resolverEndpoint.Host}"); + + DnsMessage dnsResponse; + try + { + dnsResponse = await ResolveAsync(queryName, (ushort)recordType, resolverEndpoint, cancellation); + } + catch (TaskCanceledException) + { + return (OracleResponseCode.Timeout, null); + } + catch (ResponseTooLargeException) + { + return (OracleResponseCode.ResponseTooLarge, null); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + // RCODE 3 = NXDOMAIN + if (dnsResponse.ResponseCode == 3) + return (OracleResponseCode.NotFound, null); + + if (dnsResponse.ResponseCode != 0) + return (OracleResponseCode.Error, $"DNS error (RCODE {dnsResponse.ResponseCode})"); + + if (dnsResponse.Answers.Count == 0) + return (OracleResponseCode.NotFound, null); + + ResultAnswer[] answers; + try + { + answers = dnsResponse.Answers + .Select(a => new ResultAnswer + { + Name = a.Name.TrimEnd('.'), + Type = GetRecordTypeLabel(a.Type), + Ttl = 0, + Data = FormatRData(a.Type, a.RData) + }) + .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(a => a.Type, StringComparer.Ordinal) + .ThenBy(a => a.Data, StringComparer.Ordinal) + .ToArray(); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + byte[] stackBytes = SerializeStackItemEnvelope(queryName, recordTypeLabel, answers); + if (stackBytes.Length > OracleResponse.MaxResultSize) + return (OracleResponseCode.ResponseTooLarge, null); + + return (OracleResponseCode.Success, Convert.ToBase64String(stackBytes)); + } + + private static byte[] SerializeStackItemEnvelope(string name, string recordType, IEnumerable answers) + { + var envelope = new VmStruct + { + new VmByteString(Encoding.UTF8.GetBytes(name ?? string.Empty)), + new VmByteString(Encoding.UTF8.GetBytes(recordType ?? string.Empty)) + }; + + var answerArray = new VmArray(); + foreach (var answer in answers ?? Enumerable.Empty()) + { + var item = new VmStruct + { + new VmByteString(Encoding.UTF8.GetBytes(answer?.Name ?? string.Empty)), + new VmByteString(Encoding.UTF8.GetBytes(answer?.Type ?? string.Empty)), + new VmInteger((long)(answer?.Ttl ?? 0)), + new VmByteString(Encoding.UTF8.GetBytes(answer?.Data ?? string.Empty)) + }; + answerArray.Add(item); + } + + envelope.Add(answerArray); + + return Neo.SmartContract.BinarySerializer.Serialize(envelope, ExecutionEngineLimits.Default with + { + MaxItemSize = (uint)OracleResponse.MaxResultSize + }); + } + + /// + /// Sends a DNS query using RFC 8484 POST method with application/dns-message format. + /// + private async Task ResolveAsync(string name, ushort type, Uri resolverEndpoint, CancellationToken cancellation) + { + byte[] queryMessage = BuildDnsQuery(name, type); + ushort queryId = BinaryPrimitives.ReadUInt16BigEndian(queryMessage.AsSpan(0, 2)); + + using ByteArrayContent content = new(queryMessage); + content.Headers.ContentType = DnsMessageMediaType; + + using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + timeoutSource.CancelAfter(timeout); + CancellationToken requestCancellation = timeoutSource.Token; + + await EnsureEndpointAllowed(resolverEndpoint, requestCancellation); + + using HttpRequestMessage request = new(HttpMethod.Post, resolverEndpoint) + { + Content = content, + Version = HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher + }; + + using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"DoH endpoint returned {(int)response.StatusCode} ({response.StatusCode})"); + + if (response.Content.Headers.ContentLength.HasValue && response.Content.Headers.ContentLength > OracleResponse.MaxResultSize) + throw new ResponseTooLargeException(); + + using Stream stream = await response.Content.ReadAsStreamAsync(requestCancellation); + byte[] responseData = await ReadResponseContentAsync(stream, requestCancellation); + return ParseDnsResponse(responseData, queryId, name, type); + } + + private static async Task ReadResponseContentAsync(Stream stream, CancellationToken cancellation) + { + PipeReader reader = PipeReader.Create(stream); + try + { + using MemoryStream buffer = new(); + while (true) + { + ReadResult result = await reader.ReadAsync(cancellation); + ReadOnlySequence sequence = result.Buffer; + + if (buffer.Length + sequence.Length > OracleResponse.MaxResultSize) + throw new ResponseTooLargeException(); + + foreach (ReadOnlyMemory segment in sequence) + buffer.Write(segment.Span); + + reader.AdvanceTo(sequence.End); + + if (result.IsCompleted) + return buffer.ToArray(); + } + } + finally + { + await reader.CompleteAsync(); + } + } + + private async Task EnsureEndpointAllowed(Uri resolverEndpoint, CancellationToken cancellation) + { + if (OracleSettings.Default.AllowPrivateHost) + return; + + _ = await ResolveEndpointAddressesAsync(resolverEndpoint.Host, cancellation); + } + + private static HttpMessageHandler CreateDefaultHandler() + { + return new SocketsHttpHandler + { + AllowAutoRedirect = false, + ConnectCallback = ConnectToAllowedEndpointAsync + }; + } + + private static async ValueTask ConnectToAllowedEndpointAsync(SocketsHttpConnectionContext context, CancellationToken cancellation) + { + IPAddress[] addresses = await ResolveEndpointAddressesAsync(context.DnsEndPoint.Host, cancellation); + SocketException? lastSocketException = null; + + foreach (IPAddress address in addresses) + { + Socket socket = new(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), cancellation); + return new NetworkStream(socket, ownsSocket: true); + } + catch (SocketException ex) + { + socket.Dispose(); + lastSocketException = ex; + } + } + + throw lastSocketException ?? new SocketException((int)SocketError.HostUnreachable); + } + + internal static async Task ResolveEndpointAddressesAsync(string host, CancellationToken cancellation) + { + if (!OracleSettings.Default.AllowPrivateHost && IsPrivateEndpoint(host)) + throw new InvalidOperationException("Private resolver endpoints are not allowed."); + + IPAddress[] addresses; + try + { + addresses = IPAddress.TryParse(host, out IPAddress? address) + ? [address] + : await Dns.GetHostAddressesAsync(host, cancellation); + } + catch (SocketException ex) + { + throw new InvalidOperationException($"Failed to resolve resolver endpoint: {ex.Message}", ex); + } + + if (addresses.Length == 0) + throw new InvalidOperationException("Failed to resolve resolver endpoint."); + + return ValidateEndpointAddresses(addresses); + } + + internal static IPAddress[] ValidateEndpointAddresses(IPAddress[] addresses) + { + if (!OracleSettings.Default.AllowPrivateHost && addresses.Any(p => p.IsInternal())) + throw new InvalidOperationException("Private resolver endpoints are not allowed."); + + return addresses; + } + + private static bool IsPrivateEndpoint(string host) + { + if (string.IsNullOrWhiteSpace(host)) + return false; + + host = host.TrimEnd('.'); + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + if (IPAddress.TryParse(host, out IPAddress? address)) + return address.IsInternal(); + + return false; + } + + /// + /// Gets the DoH resolver endpoint from the URI authority or falls back to the configured default. + /// Per RFC 4501, the authority component specifies the DNS server to query. + /// + private Uri GetResolverEndpoint(Uri uri) + { + // Check if URI has an authority (e.g., dns://resolver.example.com/domain) + if (!string.IsNullOrEmpty(uri.Host)) + { + // Build DoH endpoint from the authority + // Default to HTTPS and /dns-query path per RFC 8484 + UriBuilder builder = new() + { + Scheme = "https", + Host = uri.Host, + Path = "/dns-query" + }; + + if (uri.Port > 0 && uri.Port != 443) + builder.Port = uri.Port; + + return builder.Uri; + } + + // Fall back to configured endpoint + return endpoint ?? throw new InvalidOperationException("DNS settings are not loaded."); + } + + /// + /// Builds a DNS query message in wire format (RFC 1035). + /// + internal static byte[] BuildDnsQuery(string name, ushort type) + { + // Estimate size: header (12) + name (name.Length + 2 for length bytes + 1 for null) + type (2) + class (2) + List message = new(DnsHeaderSize + name.Length + 5 + 4); + + // Header section (12 bytes) + // ID: random identifier + ushort id = (ushort)Random.Shared.Next(0, 65536); + message.Add((byte)(id >> 8)); + message.Add((byte)(id & 0xFF)); + + // Flags: standard query, recursion desired (0x0100) + message.Add(0x01); + message.Add(0x00); + + // QDCOUNT: 1 question + message.Add(0x00); + message.Add(0x01); + + // ANCOUNT, NSCOUNT, ARCOUNT: 0 + message.AddRange(new byte[6]); + + // Question section + // QNAME: domain name in label format + EncodeDnsName(message, name); + + // QTYPE + message.Add((byte)(type >> 8)); + message.Add((byte)(type & 0xFF)); + + // QCLASS: IN (1) + message.Add(0x00); + message.Add(0x01); + + return [.. message]; + } + + /// + /// Encodes a domain name in DNS wire format (RFC 1035 section 4.1.2). + /// + private static void EncodeDnsName(List buffer, string name) + { + if (string.IsNullOrEmpty(name) || name == ".") + { + buffer.Add(0x00); + return; + } + + string[] labels = name.TrimEnd('.').Split('.'); + int wireLength = 1; // Root label. + foreach (string label in labels) + { + if (label.Length == 0) + throw new FormatException("DNS name contains an empty label."); + if (label.Length > 63) + throw new FormatException($"DNS label exceeds 63 characters: {label}"); + byte[] labelBytes = Encoding.ASCII.GetBytes(label); + wireLength += labelBytes.Length + 1; + if (wireLength > 255) + throw new FormatException("DNS name exceeds 255 octets."); + buffer.Add((byte)labelBytes.Length); + buffer.AddRange(labelBytes); + } + buffer.Add(0x00); // Root label + } + + /// + /// Parses a DNS response message from wire format (RFC 1035). + /// + private static DnsMessage ParseDnsResponse(byte[] data, ushort expectedId, string expectedName, ushort expectedType) + { + if (data is null || data.Length < DnsHeaderSize) + throw new FormatException("DNS response too short."); + + DnsMessage message = new() + { + Id = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(0, 2)), + Flags = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(2, 2)) + }; + + if (message.Id != expectedId) + throw new FormatException("DNS response ID does not match the query."); + + ushort qdCount = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(4, 2)); + ushort anCount = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(6, 2)); + + int offset = DnsHeaderSize; + + if (qdCount != 1) + throw new FormatException("DNS response must contain exactly one question."); + + (string questionName, int questionOffset) = DecodeDnsName(data, offset); + offset = questionOffset; + if (offset + 4 > data.Length) + throw new FormatException("DNS response truncated in question section."); + + ushort questionType = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(offset, 2)); + ushort questionClass = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(offset + 2, 2)); + offset += 4; + + if (!questionName.TrimEnd('.').Equals(expectedName.TrimEnd('.'), StringComparison.OrdinalIgnoreCase) + || questionType != expectedType + || questionClass != 1) + throw new FormatException("DNS response question does not match the query."); + + // Parse answer section + for (int i = 0; i < anCount; i++) + { + (string name, int newOffset) = DecodeDnsName(data, offset); + offset = newOffset; + + if (offset + 10 > data.Length) + throw new FormatException("DNS response truncated in answer section."); + + ushort type = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(offset, 2)); + ushort cls = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(offset + 2, 2)); + uint ttl = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset + 4, 4)); + ushort rdLength = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(offset + 8, 2)); + offset += 10; + + if (offset + rdLength > data.Length) + throw new FormatException("DNS response truncated in RDATA."); + + byte[] rdata = new byte[rdLength]; + Array.Copy(data, offset, rdata, 0, rdLength); + offset += rdLength; + + message.Answers.Add(new DnsResourceRecord + { + Name = name, + Type = type, + Class = cls, + Ttl = ttl, + RData = rdata + }); + } + + return message; + } + + /// + /// Decodes a DNS name from wire format, handling compression (RFC 1035 section 4.1.4). + /// + private static (string Name, int NewOffset) DecodeDnsName(byte[] data, int offset) + { + StringBuilder name = new(); + int originalOffset = offset; + bool jumped = false; + int jumpCount = 0; + int wireLength = 1; + const int maxJumps = 128; // Prevent infinite loops + + while (offset < data.Length) + { + byte length = data[offset]; + + if (length == 0) + { + offset++; + break; + } + + // Check for compression pointer (top 2 bits set) + if ((length & 0xC0) == 0xC0) + { + if (offset + 1 >= data.Length) + throw new FormatException("DNS name compression pointer truncated."); + + if (++jumpCount > maxJumps) + throw new FormatException("DNS name compression loop detected."); + + int pointer = ((length & 0x3F) << 8) | data[offset + 1]; + if (pointer >= offset) + throw new FormatException("DNS name compression pointer must point backwards."); + if (!jumped) + { + originalOffset = offset + 2; + jumped = true; + } + offset = pointer; + continue; + } + + if ((length & 0xC0) != 0) + throw new FormatException("DNS label has invalid length bits."); + + offset++; + if (offset + length > data.Length) + throw new FormatException("DNS label extends beyond message."); + if (length > 63) + throw new FormatException("DNS label exceeds 63 octets."); + wireLength += length + 1; + if (wireLength > 255) + throw new FormatException("DNS name exceeds 255 octets."); + + if (name.Length > 0) + name.Append('.'); + + name.Append(Encoding.ASCII.GetString(data, offset, length)); + offset += length; + } + + return (name.ToString(), jumped ? originalOffset : offset); + } + + /// + /// Skips over a DNS name in wire format. + /// + private static int SkipDnsName(byte[] data, int offset) + { + while (offset < data.Length) + { + byte length = data[offset]; + + if (length == 0) + return offset + 1; + + // Compression pointer + if ((length & 0xC0) == 0xC0) + return offset + 2; + + offset += 1 + length; + } + + throw new FormatException("DNS name extends beyond message."); + } + + /// + /// Formats RDATA based on record type for human-readable output. + /// + private static string FormatRData(ushort type, byte[] rdata) + { + if (rdata is null || rdata.Length == 0) + return string.Empty; + + return type switch + { + 1 when rdata.Length == 4 => new IPAddress(rdata).ToString(), // A + 28 when rdata.Length == 16 => new IPAddress(rdata).ToString(), // AAAA + 16 => FormatTxtRecord(rdata), // TXT + 37 => FormatCertRecord(rdata), // CERT + _ => Convert.ToBase64String(rdata) + }; + } + + /// + /// Formats a TXT record (RFC 1035 section 3.3.14). + /// + private static string FormatTxtRecord(byte[] rdata) + { + StringBuilder result = new(); + int offset = 0; + + while (offset < rdata.Length) + { + int length = rdata[offset++]; + if (offset + length > rdata.Length) + throw new FormatException("TXT record string is truncated."); + + if (result.Length > 0) + result.Append(' '); + + result.Append('"'); + result.Append(Encoding.UTF8.GetString(rdata, offset, length)); + result.Append('"'); + offset += length; + } + + return result.ToString(); + } + + /// + /// Formats a CERT record (RFC 4398). + /// + private static string FormatCertRecord(byte[] rdata) + { + if (rdata.Length < 5) + return Convert.ToBase64String(rdata); + + ushort certType = BinaryPrimitives.ReadUInt16BigEndian(rdata.AsSpan(0, 2)); + ushort keyTag = BinaryPrimitives.ReadUInt16BigEndian(rdata.AsSpan(2, 2)); + byte algorithm = rdata[4]; + byte[] certData = rdata[5..]; + + return $"{certType} {keyTag} {algorithm} {Convert.ToBase64String(certData)}"; + } + + private void EnsureConfigured(bool force = false) + { + if (configured && !force) + return; + lock (syncRoot) + { + if (configured && !force) + return; + var dnsSettings = OracleSettings.Default?.Dns ?? throw new InvalidOperationException("DNS settings are not loaded."); + endpoint = dnsSettings.EndPoint; + timeout = dnsSettings.Timeout; + configured = true; + } + } + + internal static string BuildQueryName(Uri uri) + { + string dnsName = NormalizeDnsName(uri.GetComponents(UriComponents.Path, UriFormat.Unescaped)); + if (string.IsNullOrEmpty(dnsName)) + throw new FormatException("dns: URI must include a dnsname."); + + return dnsName; + } + + private static NameValueCollection ParseQueryString(string query) + { + string normalized = string.IsNullOrEmpty(query) + ? string.Empty + : query.TrimStart('?').Replace(';', '&'); + return HttpUtility.ParseQueryString(normalized); + } + + private static string? GetQueryValue(NameValueCollection? query, string key) + { + if (query is null) + return null; + foreach (string? existing in query.AllKeys) + { + if (existing is null) + continue; + if (existing.Equals(key, StringComparison.OrdinalIgnoreCase)) + return query[existing]; + } + return null; + } + + private static void ValidateClass(NameValueCollection query) + { + string? classRaw = GetQueryValue(query, "class"); + if (string.IsNullOrWhiteSpace(classRaw)) + return; + classRaw = classRaw.Trim(); + if (classRaw.Equals("IN", StringComparison.OrdinalIgnoreCase) || classRaw == "1") + return; + throw new FormatException($"Unsupported DNS class '{classRaw}', only IN is supported."); + } + + private static string NormalizeDnsName(string? value) + { + string? normalized = NormalizeLabel(value?.Trim('/')); + if (string.IsNullOrEmpty(normalized)) + throw new FormatException("dns: URI must include a dnsname."); + if (normalized.Contains('/')) + throw new FormatException("dnsname must not contain path segments."); + return normalized; + } + + private static string? NormalizeLabel(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + return Uri.UnescapeDataString(value).Trim().Trim('.'); + } + + private static int ParseRecordType(NameValueCollection query) + { + string? typeRaw = GetQueryValue(query, "type"); + if (string.IsNullOrWhiteSpace(typeRaw)) + return RecordTypeLookup["A"]; + typeRaw = typeRaw.Trim(); + if (int.TryParse(typeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int numeric)) + { + if (numeric < 0 || numeric > ushort.MaxValue) + throw new FormatException($"Unsupported DNS record type '{typeRaw}'"); + return numeric; + } + if (RecordTypeLookup.TryGetValue(typeRaw, out int mapped)) + return mapped; + throw new FormatException($"Unsupported DNS record type '{typeRaw}'"); + } + + private static string GetRecordTypeLabel(int type) + { + if (ReverseRecordTypeLookup.TryGetValue(type, out string? label)) + return label; + return type.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/tests/Neo.Plugins.OracleService.Tests/UT_OracleDnsProtocol.cs b/tests/Neo.Plugins.OracleService.Tests/UT_OracleDnsProtocol.cs new file mode 100644 index 000000000..8508a948d --- /dev/null +++ b/tests/Neo.Plugins.OracleService.Tests/UT_OracleDnsProtocol.cs @@ -0,0 +1,1346 @@ +// Copyright (C) 2015-2026 The Neo Project. +// +// UT_OracleDnsProtocol.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Neo.Network.P2P.Payloads; +using Neo.Plugins.OracleService.Protocols; +using Neo.VM; +using Neo.VM.Types; +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using VmArray = Neo.VM.Types.Array; +using VmStruct = Neo.VM.Types.Struct; + +namespace Neo.Plugins.OracleService.Tests; + +[TestClass] +public class UT_OracleDnsProtocol +{ + [TestInitialize] + public void Setup() + { + LoadSettings(); + } + + private static VmStruct DeserializeEnvelope(string? payload) + { + Assert.IsNotNull(payload); + byte[] bytes = Convert.FromBase64String(payload!); + StackItem item = Neo.SmartContract.BinarySerializer.Deserialize(bytes, ExecutionEngineLimits.Default with + { + MaxItemSize = (uint)OracleResponse.MaxResultSize + }); + + Assert.IsInstanceOfType(item); + return (VmStruct)item; + } + + private static (string Name, string Type, VmArray Answers) ParseEnvelope(string? payload) + { + VmStruct envelope = DeserializeEnvelope(payload); + Assert.AreEqual(3, envelope.Count); + Assert.IsInstanceOfType(envelope[2]); + return (envelope[0].GetString()!, envelope[1].GetString()!, (VmArray)envelope[2]); + } + + private static VmStruct ParseAnswer(StackItem item) + { + Assert.IsInstanceOfType(item); + VmStruct answer = (VmStruct)item; + Assert.AreEqual(4, answer.Count); + return answer; + } + + [TestMethod] + public void BuildQueryName_ParsesDnsUri() + { + var uri = new Uri("dns:simon.example.org?TYPE=TXT"); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("simon.example.org", name); + } + + [TestMethod] + public void BuildQueryName_RespectsAuthoritySyntax() + { + var uri = new Uri("dns://resolver.example/ftp.example.org?TYPE=TXT"); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("ftp.example.org", name); + } + + [TestMethod] + public void BuildQueryName_ThrowsWithoutDnsName() + { + var uri = new Uri("dns://resolver.example/"); + Assert.ThrowsExactly(() => OracleDnsProtocol.BuildQueryName(uri)); + } + + [TestMethod] + public void BuildQueryName_RejectsPathSegments() + { + var uri = new Uri("dns:example.com/extra"); + Assert.ThrowsExactly(() => OracleDnsProtocol.BuildQueryName(uri)); + } + + [TestMethod] + public async Task ProcessAsync_RejectsUnsupportedClass() + { + using var protocol = new OracleDnsProtocol(new StubHandler(_ => throw new InvalidOperationException("Should not send when class is invalid"))); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?CLASS=CH"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "class"); + } + + [TestMethod] + public async Task ProcessAsync_AllowsClassIn() + { + byte[] dnsResponse = BuildDnsResponse("example.com", 16, 120, Encoding.ASCII.GetBytes("\x05hello")); + var handler = new StubHandler(request => + { + Assert.AreEqual(HttpMethod.Post, request.Method); + Assert.AreEqual("application/dns-message", request.Content!.Headers.ContentType!.MediaType); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?CLASS=IN;TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, _, answers) = ParseEnvelope(payload); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("TXT", answer0[1].GetString()); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsStackItemEnvelope() + { + byte[] dnsResponse = BuildDnsResponse("example.com", 16, 120, Encoding.ASCII.GetBytes("\x05hello")); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=TXT"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + Assert.IsNotNull(payload); + + var (name, type, answers) = ParseEnvelope(payload); + Assert.AreEqual("example.com", name); + Assert.AreEqual("TXT", type); + Assert.AreEqual(1, answers.Count); + + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("example.com", answer0[0].GetString()); + Assert.AreEqual("TXT", answer0[1].GetString()); + Assert.AreEqual(0, (int)answer0[2].GetInteger()); + Assert.AreEqual("\"hello\"", answer0[3].GetString()); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsTooLargeForOversizedResponse() + { + // Create a response that will exceed MaxResultSize when serialized. + // Generate many TXT records to make the final result payload too large. + List response = new(); + + // Header (12 bytes) + response.AddRange(new byte[] { 0x00, 0x01 }); // ID + response.AddRange(new byte[] { 0x81, 0x80 }); // Flags + response.AddRange(new byte[] { 0x00, 0x01 }); // QDCOUNT: 1 + const int answerCount = 32; + response.AddRange(new byte[] { 0x00, 0x20 }); // ANCOUNT: 32 answers + response.AddRange(new byte[] { 0x00, 0x00 }); // NSCOUNT: 0 + response.AddRange(new byte[] { 0x00, 0x00 }); // ARCOUNT: 0 + + // Question section + EncodeDnsName(response, "big.example.com"); + response.AddRange(new byte[] { 0x00, 0x10 }); // TYPE TXT + response.AddRange(new byte[] { 0x00, 0x01 }); // CLASS IN + + // Add many answer records with large TXT data to exceed OracleResponse.MaxResultSize. + string largeText = new('A', 4000); + byte[] txtRdata = BuildTxtRdata(largeText); + for (int i = 0; i < answerCount; i++) + { + EncodeDnsName(response, "big.example.com"); + response.AddRange(new byte[] { 0x00, 0x10 }); // TYPE TXT + response.AddRange(new byte[] { 0x00, 0x01 }); // CLASS IN + response.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x3C }); // TTL: 60 + response.Add((byte)(txtRdata.Length >> 8)); + response.Add((byte)(txtRdata.Length & 0xFF)); + response.AddRange(txtRdata); + } + + byte[] dnsResponse = [.. response]; + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:big.example.com?TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.ResponseTooLarge, code); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsNotFoundForNxDomain() + { + // Build NXDOMAIN response (RCODE = 3) + byte[] dnsResponse = BuildDnsResponseWithRcode("example.com", 1, 3); + var handler = new StubHandler(request => + { + Assert.AreEqual(HttpMethod.Post, request.Method); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.NotFound, code); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task ProcessAsync_OmitsCertificateWhenNotRequested() + { + byte[] txtRdata = BuildTxtRdata("hello"); + byte[] dnsResponse = BuildDnsResponse("plain.example.com", 16, 120, txtRdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:plain.example.com?TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, _, answers) = ParseEnvelope(payload); + Assert.AreEqual(1, answers.Count); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("plain.example.com", answer0[0].GetString()); + Assert.AreEqual("TXT", answer0[1].GetString()); + } + + [TestMethod] + public async Task ProcessAsync_ParsesDkimTxtRecord() + { + const string dkimData = "k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp1+6V9wVDqveufqdpypuXn7Z1xXHrp236UMtO4Zwzp1KimG1HjMATkUMlzUxr87hcPLZ9eczsQnUnxE27XGr0C+MEY0S8NxVkg4CSkiUbSSjMBDuNIQP5CKEM5Qn2ATqNnS/xPbbGr3HdWu3UwG+329xNXO/SuKD5d/mswHxZ34rnOG0r8QwMCKaRZ3eLaxhUJW6QcgO5Kb/6VQwWi4KFOeFHrgb3R04QLbTjaCj1eO0MJdHj7FVGHvXZHzVvzJeY9q24apqYh6gMPkTFogyXv3gZH/BqhGlymM4T/6QAEyy6AdZkGouVp21Hb+Jseb3CidRubc4QZAlWTMwVzKhI6+wIDAQAB"; + byte[] txtRdata = BuildTxtRdata(dkimData); + byte[] dnsResponse = BuildDnsResponse("1alhai._domainkey.icloud.com", 16, 299, txtRdata); + + var handler = new StubHandler(request => + { + Assert.IsTrue(request.Headers.Accept.Any(h => h.MediaType == "application/dns-message")); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:1alhai._domainkey.icloud.com?TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + var (name, type, answers) = ParseEnvelope(payload); + Assert.AreEqual("1alhai._domainkey.icloud.com", name); + Assert.AreEqual("TXT", type); + Assert.AreEqual(1, answers.Count); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("TXT", answer0[1].GetString()); + StringAssert.Contains(answer0[3].GetString(), "k=rsa"); + } + + [TestMethod] + public void BuildDnsQuery_CreatesValidWireFormat() + { + byte[] query = OracleDnsProtocol.BuildDnsQuery("example.com", 16); + + // Verify header - query should have at least 12 bytes for header + Assert.IsGreaterThanOrEqualTo(12, query.Length, $"Query length {query.Length} should be >= 12"); + + // Flags should be 0x0100 (standard query, recursion desired) + Assert.AreEqual(0x01, query[2]); + Assert.AreEqual(0x00, query[3]); + + // QDCOUNT should be 1 + Assert.AreEqual(0x00, query[4]); + Assert.AreEqual(0x01, query[5]); + + // Verify question section contains the domain name + // After header (12 bytes), we should have: 7example3com0 (encoded name) + Assert.AreEqual(7, query[12]); // length of "example" + Assert.AreEqual((byte)'e', query[13]); + Assert.AreEqual((byte)'x', query[14]); + } + + #region DNS Wire Format Tests + + [TestMethod] + public void BuildDnsQuery_EncodesSubdomainsCorrectly() + { + byte[] query = OracleDnsProtocol.BuildDnsQuery("sub.domain.example.com", 1); + + // Verify the encoded name: 3sub6domain7example3com0 + int offset = 12; // After header + Assert.AreEqual(3, query[offset]); // "sub" length + Assert.AreEqual((byte)'s', query[offset + 1]); + Assert.AreEqual(6, query[offset + 4]); // "domain" length + Assert.AreEqual(7, query[offset + 11]); // "example" length + Assert.AreEqual(3, query[offset + 19]); // "com" length + } + + [TestMethod] + public void BuildDnsQuery_HandlesTrailingDot() + { + byte[] query1 = OracleDnsProtocol.BuildDnsQuery("example.com", 1); + byte[] query2 = OracleDnsProtocol.BuildDnsQuery("example.com.", 1); + + // Both should produce the same encoded name (compare first 2 bytes which are random ID, then rest should match) + Assert.HasCount(query1.Length, query2); + CollectionAssert.AreEqual(query1[2..], query2[2..]); + } + + [TestMethod] + public void BuildDnsQuery_AllowsMaximumLengthWireName() + { + string maxName = $"{new string('a', 63)}.{new string('b', 63)}.{new string('c', 63)}.{new string('d', 61)}"; + byte[] query = OracleDnsProtocol.BuildDnsQuery(maxName, 1); + + Assert.HasCount(12 + 255 + 4, query); + } + + [TestMethod] + public void BuildDnsQuery_RejectsOverlongWireName() + { + string tooLongName = $"{new string('a', 63)}.{new string('b', 63)}.{new string('c', 63)}.{new string('d', 63)}"; + + Assert.ThrowsExactly(() => OracleDnsProtocol.BuildDnsQuery(tooLongName, 1)); + } + + [TestMethod] + public void BuildDnsQuery_RejectsEmptyLabels() + { + Assert.ThrowsExactly(() => OracleDnsProtocol.BuildDnsQuery("example..com", 1)); + } + + [TestMethod] + public void BuildDnsQuery_SetsCorrectRecordType() + { + // Test A record (type 1) + byte[] queryA = OracleDnsProtocol.BuildDnsQuery("example.com", 1); + int typeOffset = 12 + 13; // header + encoded name length for "example.com" + Assert.AreEqual(0x00, queryA[typeOffset]); + Assert.AreEqual(0x01, queryA[typeOffset + 1]); + + // Test AAAA record (type 28) + byte[] queryAAAA = OracleDnsProtocol.BuildDnsQuery("example.com", 28); + Assert.AreEqual(0x00, queryAAAA[typeOffset]); + Assert.AreEqual(0x1C, queryAAAA[typeOffset + 1]); + + // Test TXT record (type 16) + byte[] queryTXT = OracleDnsProtocol.BuildDnsQuery("example.com", 16); + Assert.AreEqual(0x00, queryTXT[typeOffset]); + Assert.AreEqual(0x10, queryTXT[typeOffset + 1]); + } + + #endregion + + #region A Record Tests + + [TestMethod] + public async Task ProcessAsync_ParsesARecord() + { + // A record: 192.168.1.1 + byte[] rdata = [192, 168, 1, 1]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, rdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, type, answers) = ParseEnvelope(payload); + Assert.AreEqual("A", type); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("192.168.1.1", answer0[3].GetString()); + } + + [TestMethod] + public async Task ProcessAsync_DefaultsToARecord() + { + byte[] rdata = [10, 0, 0, 1]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, rdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + // No TYPE specified - should default to A + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, type, _) = ParseEnvelope(payload); + Assert.AreEqual("A", type); + } + + #endregion + + #region AAAA Record Tests + + [TestMethod] + public async Task ProcessAsync_ParsesAAAARecord() + { + // AAAA record: 2001:db8::1 + byte[] rdata = [0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + byte[] dnsResponse = BuildDnsResponse("example.com", 28, 300, rdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=AAAA"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, type, answers) = ParseEnvelope(payload); + Assert.AreEqual("AAAA", type); + VmStruct answer0 = ParseAnswer(answers[0]); + string ipv6 = answer0[3].GetString()!; + Assert.IsTrue(ipv6.Contains("2001:db8", StringComparison.OrdinalIgnoreCase)); + } + + #endregion + + #region CERT Record Tests + + [TestMethod] + public async Task ProcessAsync_PreservesCertRdata() + { + // CERT RDATA: type(2) + keyTag(2) + algorithm(1) + certificate bytes + byte[] certBytes = Encoding.ASCII.GetBytes("cert-payload"); + List certRdata = new(); + certRdata.AddRange(new byte[] { 0x00, 0x01 }); // PKIX type + certRdata.AddRange(new byte[] { 0x00, 0x00 }); // key tag + certRdata.Add(0x00); // algorithm + certRdata.AddRange(certBytes); + + byte[] dnsResponse = BuildDnsResponse("cert.example.com", 37, 60, [.. certRdata]); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:cert.example.com?TYPE=CERT"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, _, answers) = ParseEnvelope(payload); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("CERT", answer0[1].GetString()); + string data = answer0[3].GetString()!; + StringAssert.Contains(data, Convert.ToBase64String(certBytes)); + } + + #endregion + + #region DNS Name Compression Tests + + [TestMethod] + public async Task ProcessAsync_HandlesNameCompression() + { + // Build response with compression pointer in answer section + List response = new(); + + // Header + response.AddRange(new byte[] { 0x00, 0x01 }); // ID + response.AddRange(new byte[] { 0x81, 0x80 }); // Flags + response.AddRange(new byte[] { 0x00, 0x01 }); // QDCOUNT: 1 + response.AddRange(new byte[] { 0x00, 0x01 }); // ANCOUNT: 1 + response.AddRange(new byte[] { 0x00, 0x00 }); // NSCOUNT: 0 + response.AddRange(new byte[] { 0x00, 0x00 }); // ARCOUNT: 0 + + // Question section - name starts at offset 12 + EncodeDnsName(response, "example.com"); + response.AddRange(new byte[] { 0x00, 0x01 }); // TYPE A + response.AddRange(new byte[] { 0x00, 0x01 }); // CLASS IN + + // Answer section with compression pointer to offset 12 + response.AddRange(new byte[] { 0xC0, 0x0C }); // Compression pointer to offset 12 + response.AddRange(new byte[] { 0x00, 0x01 }); // TYPE A + response.AddRange(new byte[] { 0x00, 0x01 }); // CLASS IN + response.AddRange(new byte[] { 0x00, 0x00, 0x01, 0x2C }); // TTL: 300 + response.AddRange(new byte[] { 0x00, 0x04 }); // RDLENGTH: 4 + response.AddRange(new byte[] { 0x08, 0x08, 0x08, 0x08 }); // 8.8.8.8 + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([.. response]) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, _, answers) = ParseEnvelope(payload); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual("example.com", answer0[0].GetString()); + Assert.AreEqual("8.8.8.8", answer0[3].GetString()); + } + + #endregion + + #region DNS Response Validation Tests + + [TestMethod] + public async Task ProcessAsync_RejectsMismatchedResponseId() + { + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, [1, 2, 3, 4]); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }, echoDnsQueryId: false); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "ID"); + } + + [TestMethod] + public async Task ProcessAsync_RejectsMismatchedQuestion() + { + byte[] dnsResponse = BuildDnsResponse("other.example.com", 1, 300, [1, 2, 3, 4]); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "question"); + } + + [TestMethod] + public async Task ProcessAsync_RejectsForwardCompressionPointer() + { + List response = new(); + response.AddRange(new byte[] { 0x00, 0x01 }); // ID + response.AddRange(new byte[] { 0x81, 0x80 }); // Flags + response.AddRange(new byte[] { 0x00, 0x01 }); // QDCOUNT + response.AddRange(new byte[] { 0x00, 0x01 }); // ANCOUNT + response.AddRange(new byte[] { 0x00, 0x00 }); // NSCOUNT + response.AddRange(new byte[] { 0x00, 0x00 }); // ARCOUNT + EncodeDnsName(response, "example.com"); + response.AddRange(new byte[] { 0x00, 0x01, 0x00, 0x01 }); + response.AddRange(new byte[] { 0xC0, 0x20 }); // Forward pointer. + response.AddRange(new byte[] { 0x00, 0x01, 0x00, 0x01 }); + response.AddRange(new byte[] { 0x00, 0x00, 0x01, 0x2C }); + response.AddRange(new byte[] { 0x00, 0x04, 0x08, 0x08, 0x08, 0x08 }); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([.. response]) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "backwards"); + } + + [TestMethod] + public async Task ProcessAsync_RejectsTruncatedTxtRecord() + { + byte[] dnsResponse = BuildDnsResponse("example.com", 16, 300, [0x05, (byte)'h', (byte)'i']); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=TXT"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "truncated"); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + public async Task ProcessAsync_ReturnsErrorForHttpFailure() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "500"); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsTimeoutOnCancellation() + { + var handler = new StubHandler(_ => throw new TaskCanceledException()); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Timeout, code); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsErrorForServFail() + { + // SERVFAIL = RCODE 2 + byte[] dnsResponse = BuildDnsResponseWithRcode("example.com", 1, 2); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "RCODE 2"); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsNotFoundForEmptyAnswer() + { + // NOERROR but no answers + byte[] dnsResponse = BuildDnsResponseWithRcode("example.com", 1, 0); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.NotFound, code); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task ProcessAsync_RejectsPrivateResolverHost() + { + // Should reject before sending any request + var handler = new StubHandler(_ => throw new InvalidOperationException("Should not send")); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns://127.0.0.1/example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "Private resolver"); + } + + [TestMethod] + public async Task ProcessAsync_RejectsLocalhostResolverSuffix() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("Should not send")); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns://sub.localhost/example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "Private resolver"); + } + + [TestMethod] + public void ValidateEndpointAddresses_RejectsAnyResolvedPrivateAddress() + { + IPAddress[] addresses = + [ + IPAddress.Parse("1.1.1.1"), + IPAddress.Parse("10.0.0.10") + ]; + + Assert.ThrowsExactly(() => OracleDnsProtocol.ValidateEndpointAddresses(addresses)); + } + + [TestMethod] + public async Task ProcessAsync_RejectsUnsupportedRecordType() + { + using var protocol = new OracleDnsProtocol(new StubHandler(_ => throw new InvalidOperationException("Should not send"))); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=INVALID"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "INVALID"); + } + + #endregion + + #region Record Type Parsing Tests + + [TestMethod] + public async Task ProcessAsync_AcceptsNumericRecordType() + { + byte[] rdata = [127, 0, 0, 1]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, rdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + // Use numeric type 1 instead of "A" + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=1"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, type, _) = ParseEnvelope(payload); + Assert.AreEqual("A", type); + } + + [TestMethod] + public async Task ProcessAsync_AcceptsCaseInsensitiveRecordType() + { + byte[] txtRdata = BuildTxtRdata("test"); + byte[] dnsResponse = BuildDnsResponse("example.com", 16, 300, txtRdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=txt"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + } + + [TestMethod] + public async Task ProcessAsync_RejectsOutOfRangeNumericRecordType() + { + using var protocol = new OracleDnsProtocol(new StubHandler(_ => throw new InvalidOperationException("Should not send"))); + (OracleResponseCode code, string? message) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=70000"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "70000"); + } + + #endregion + + #region Query Parameter Tests + + [TestMethod] + public async Task ProcessAsync_AcceptsClass1AsIN() + { + byte[] rdata = [1, 2, 3, 4]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, rdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, _) = await protocol.ProcessAsync(new Uri("dns:example.com?CLASS=1"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + } + + [TestMethod] + public async Task ProcessAsync_AcceptsSemicolonSeparator() + { + byte[] txtRdata = BuildTxtRdata("test"); + byte[] dnsResponse = BuildDnsResponse("example.com", 16, 300, txtRdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + // RFC 4501 uses semicolon as separator + (OracleResponseCode code, _) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=TXT;CLASS=IN"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + } + + [TestMethod] + public async Task ProcessAsync_AcceptsAmpersandSeparator() + { + byte[] txtRdata = BuildTxtRdata("test"); + byte[] dnsResponse = BuildDnsResponse("example.com", 16, 300, txtRdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, _) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=TXT&CLASS=IN"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + } + + #endregion + + #region Multiple Answers Tests + + [TestMethod] + public async Task ProcessAsync_HandlesMultipleAnswers() + { + // Build response with multiple A records + List response = new(); + + // Header + response.AddRange(new byte[] { 0x00, 0x01 }); // ID + response.AddRange(new byte[] { 0x81, 0x80 }); // Flags + response.AddRange(new byte[] { 0x00, 0x01 }); // QDCOUNT: 1 + response.AddRange(new byte[] { 0x00, 0x03 }); // ANCOUNT: 3 + response.AddRange(new byte[] { 0x00, 0x00 }); // NSCOUNT: 0 + response.AddRange(new byte[] { 0x00, 0x00 }); // ARCOUNT: 0 + + // Question section + EncodeDnsName(response, "example.com"); + response.AddRange(new byte[] { 0x00, 0x01 }); // TYPE A + response.AddRange(new byte[] { 0x00, 0x01 }); // CLASS IN + + // Three answer records + byte[][] ips = [[1, 1, 1, 1], [8, 8, 8, 8], [9, 9, 9, 9]]; + foreach (byte[] ip in ips) + { + EncodeDnsName(response, "example.com"); + response.AddRange(new byte[] { 0x00, 0x01 }); // TYPE A + response.AddRange(new byte[] { 0x00, 0x01 }); // CLASS IN + response.AddRange(new byte[] { 0x00, 0x00, 0x01, 0x2C }); // TTL: 300 + response.AddRange(new byte[] { 0x00, 0x04 }); // RDLENGTH: 4 + response.AddRange(ip); + } + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([.. response]) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, _, answers) = ParseEnvelope(payload); + Assert.AreEqual(3, answers.Count); + Assert.AreEqual("1.1.1.1", ParseAnswer(answers[0])[3].GetString()); + Assert.AreEqual("8.8.8.8", ParseAnswer(answers[1])[3].GetString()); + Assert.AreEqual("9.9.9.9", ParseAnswer(answers[2])[3].GetString()); + } + + #endregion + + #region TTL Tests + + [TestMethod] + public async Task ProcessAsync_NormalizesTtlValue() + { + byte[] rdata = [1, 2, 3, 4]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 86400, rdata); + + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync(new Uri("dns:example.com?TYPE=A"), CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + var (_, _, answers) = ParseEnvelope(payload); + VmStruct answer0 = ParseAnswer(answers[0]); + Assert.AreEqual(0u, (uint)answer0[2].GetInteger()); + } + + #endregion + + #region URI Parsing Edge Cases + + [TestMethod] + public void BuildQueryName_HandlesPercentEncoding() + { + // %2e is encoded dot + var uri = new Uri("dns:example%2ecom"); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("example.com", name); + } + + [TestMethod] + public void BuildQueryName_TrimsTrailingDots() + { + var uri = new Uri("dns:example.com."); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("example.com", name); + } + + #endregion + + #region User-Specified Authority Tests + + [TestMethod] + public async Task ProcessAsync_UsesAuthorityFromUri() + { + byte[] rdata = [1, 2, 3, 4]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, rdata); + + Uri? capturedUri = null; + var handler = new StubHandler(request => + { + capturedUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }; + }); + using var protocol = new OracleDnsProtocol(handler); + + // Use authority syntax: dns://example.com/domain + (OracleResponseCode code, _) = await protocol.ProcessAsync( + new Uri("dns://example.com/example.com?TYPE=A"), + CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + Assert.IsNotNull(capturedUri); + Assert.AreEqual("example.com", capturedUri!.Host); + Assert.AreEqual("/dns-query", capturedUri.AbsolutePath); + Assert.AreEqual("https", capturedUri.Scheme); + } + + [TestMethod] + public async Task ProcessAsync_FallsBackToConfiguredEndpoint() + { + byte[] rdata = [1, 2, 3, 4]; + byte[] dnsResponse = BuildDnsResponse("example.com", 1, 300, rdata); + + Uri? capturedUri = null; + var handler = new StubHandler(request => + { + capturedUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(dnsResponse) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/dns-message") } + } + }; + }); + using var protocol = new OracleDnsProtocol(handler); + + // No authority - should use configured endpoint + (OracleResponseCode code, _) = await protocol.ProcessAsync( + new Uri("dns:example.com?TYPE=A"), + CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + Assert.IsNotNull(capturedUri); + // Should use the configured endpoint from LoadSettings() + Assert.AreEqual("example.com", capturedUri!.Host); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_UserSpecifiedAuthority_GoogleDoH() + { + if (!ShouldRunLiveDoHTests()) return; + LoadSettingsWithEndpoint("https://cloudflare-dns.com/dns-query"); // Configure Cloudflare as default + using var protocol = new OracleDnsProtocol(); + + // But use Google via authority + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync( + new Uri("dns://dns.google/google.com?TYPE=A"), + CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + Assert.IsNotNull(payload); + var (name, _, _) = ParseEnvelope(payload); + Assert.AreEqual("google.com", name); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_UserSpecifiedAuthority_CloudflareDoH() + { + if (!ShouldRunLiveDoHTests()) return; + LoadSettingsWithEndpoint("https://dns.google/dns-query"); // Configure Google as default + using var protocol = new OracleDnsProtocol(); + + // But use Cloudflare via authority + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync( + new Uri("dns://cloudflare-dns.com/cloudflare.com?TYPE=A"), + CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code); + Assert.IsNotNull(payload); + var (name, _, _) = ParseEnvelope(payload); + Assert.AreEqual("cloudflare.com", name); + } + + #endregion + + #region Real DoH Integration Tests (RFC 8484) + + /// + /// Integration test against Cloudflare DoH (https://cloudflare-dns.com/dns-query). + /// Verifies RFC 8484 application/dns-message format works with real service. + /// + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_CloudflareDoH_ResolvesARecord() + { + await TestRealDoHEndpoint("https://cloudflare-dns.com/dns-query", "cloudflare.com", "A"); + } + + /// + /// Integration test against Google DoH (https://dns.google/dns-query). + /// Verifies RFC 8484 application/dns-message format works with real service. + /// + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_GoogleDoH_ResolvesARecord() + { + await TestRealDoHEndpoint("https://dns.google/dns-query", "google.com", "A"); + } + + /// + /// Integration test against Quad9 DoH (https://dns.quad9.net/dns-query). + /// Verifies RFC 8484 application/dns-message format works with real service. + /// + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_Quad9DoH_ResolvesARecord() + { + await TestRealDoHEndpoint("https://dns.quad9.net/dns-query", "quad9.net", "A"); + } + + /// + /// Integration test for TXT record resolution. + /// + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_CloudflareDoH_ResolvesTxtRecord() + { + await TestRealDoHEndpoint("https://cloudflare-dns.com/dns-query", "cloudflare.com", "TXT"); + } + + /// + /// Integration test for AAAA (IPv6) record resolution. + /// + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_GoogleDoH_ResolvesAAAARecord() + { + await TestRealDoHEndpoint("https://dns.google/dns-query", "google.com", "AAAA"); + } + + /// + /// Integration test for NXDOMAIN response. + /// + [TestMethod] + [TestCategory("Integration")] + public async Task Integration_CloudflareDoH_ReturnsNotFoundForNxDomain() + { + if (!ShouldRunLiveDoHTests()) return; + LoadSettingsWithEndpoint("https://cloudflare-dns.com/dns-query"); + using var protocol = new OracleDnsProtocol(); + (OracleResponseCode code, _) = await protocol.ProcessAsync( + new Uri("dns:this-domain-does-not-exist-12345.invalid"), + CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.NotFound, code); + } + + private static async Task TestRealDoHEndpoint(string endpoint, string domain, string recordType) + { + if (!ShouldRunLiveDoHTests()) return; + LoadSettingsWithEndpoint(endpoint); + using var protocol = new OracleDnsProtocol(); + (OracleResponseCode code, string? payload) = await protocol.ProcessAsync( + new Uri($"dns:{domain}?TYPE={recordType}"), + CancellationToken.None); + + Assert.AreEqual(OracleResponseCode.Success, code, $"Failed to resolve {domain} via {endpoint}"); + Assert.IsNotNull(payload); + + var (name, type, answers) = ParseEnvelope(payload); + Assert.AreEqual(domain, name); + Assert.AreEqual(recordType, type); + Assert.IsGreaterThan(0, answers.Count, $"Expected at least one answer for {domain}"); + } + + private static bool ShouldRunLiveDoHTests() + { + return string.Equals(Environment.GetEnvironmentVariable("NEO_RUN_LIVE_DOH_TESTS"), "1", StringComparison.Ordinal); + } + + private static void LoadSettingsWithEndpoint(string dnsEndpoint) + { + var values = new Dictionary + { + ["PluginConfiguration:Network"] = "5195086", + ["PluginConfiguration:Nodes:0"] = "http://127.0.0.1:20332", + ["PluginConfiguration:AllowedContentTypes:0"] = "application/json", + ["PluginConfiguration:Https:Timeout"] = "5000", + ["PluginConfiguration:NeoFS:EndPoint"] = "http://127.0.0.1:8080", + ["PluginConfiguration:NeoFS:Timeout"] = "15000", + ["PluginConfiguration:Dns:EndPoint"] = dnsEndpoint, + ["PluginConfiguration:Dns:Timeout"] = "10000" + }; + IConfigurationSection section = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build() + .GetSection("PluginConfiguration"); + OracleSettings.Load(section); + } + + #endregion + + private static void LoadSettings() + { + var values = new Dictionary + { + ["PluginConfiguration:Network"] = "5195086", + ["PluginConfiguration:Nodes:0"] = "http://127.0.0.1:20332", + ["PluginConfiguration:AllowedContentTypes:0"] = "application/json", + ["PluginConfiguration:Https:Timeout"] = "5000", + ["PluginConfiguration:NeoFS:EndPoint"] = "http://127.0.0.1:8080", + ["PluginConfiguration:NeoFS:Timeout"] = "15000", + ["PluginConfiguration:Dns:EndPoint"] = "https://example.com/dns-query", + ["PluginConfiguration:Dns:Timeout"] = "3000" + }; + IConfigurationSection section = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build() + .GetSection("PluginConfiguration"); + OracleSettings.Load(section); + } + + /// + /// Builds a DNS response message in wire format (RFC 1035). + /// + private static byte[] BuildDnsResponse(string name, ushort type, uint ttl, byte[] rdata) + { + List response = new(); + + // Header (12 bytes) + response.AddRange(new byte[] { 0x00, 0x01 }); // ID + response.AddRange(new byte[] { 0x81, 0x80 }); // Flags: response, recursion desired, recursion available + response.AddRange(new byte[] { 0x00, 0x01 }); // QDCOUNT: 1 + response.AddRange(new byte[] { 0x00, 0x01 }); // ANCOUNT: 1 + response.AddRange(new byte[] { 0x00, 0x00 }); // NSCOUNT: 0 + response.AddRange(new byte[] { 0x00, 0x00 }); // ARCOUNT: 0 + + // Question section + EncodeDnsName(response, name); + response.Add((byte)(type >> 8)); + response.Add((byte)(type & 0xFF)); + response.Add(0x00); + response.Add(0x01); // CLASS IN + + // Answer section + EncodeDnsName(response, name); + response.Add((byte)(type >> 8)); + response.Add((byte)(type & 0xFF)); + response.Add(0x00); + response.Add(0x01); // CLASS IN + + // TTL (4 bytes) + response.Add((byte)(ttl >> 24)); + response.Add((byte)(ttl >> 16)); + response.Add((byte)(ttl >> 8)); + response.Add((byte)(ttl & 0xFF)); + + // RDLENGTH and RDATA + response.Add((byte)(rdata.Length >> 8)); + response.Add((byte)(rdata.Length & 0xFF)); + response.AddRange(rdata); + + return [.. response]; + } + + /// + /// Builds a DNS response with a specific RCODE (no answers). + /// + private static byte[] BuildDnsResponseWithRcode(string name, ushort type, int rcode) + { + List response = new(); + + // Header (12 bytes) + response.AddRange(new byte[] { 0x00, 0x01 }); // ID + response.Add(0x81); // QR=1, Opcode=0, AA=0, TC=0, RD=1 + response.Add((byte)(0x80 | (rcode & 0x0F))); // RA=1, Z=0, RCODE + response.AddRange(new byte[] { 0x00, 0x01 }); // QDCOUNT: 1 + response.AddRange(new byte[] { 0x00, 0x00 }); // ANCOUNT: 0 + response.AddRange(new byte[] { 0x00, 0x00 }); // NSCOUNT: 0 + response.AddRange(new byte[] { 0x00, 0x00 }); // ARCOUNT: 0 + + // Question section + EncodeDnsName(response, name); + response.Add((byte)(type >> 8)); + response.Add((byte)(type & 0xFF)); + response.Add(0x00); + response.Add(0x01); // CLASS IN + + return [.. response]; + } + + /// + /// Encodes a domain name in DNS wire format. + /// + private static void EncodeDnsName(List buffer, string name) + { + string[] labels = name.TrimEnd('.').Split('.'); + foreach (string label in labels) + { + byte[] labelBytes = Encoding.ASCII.GetBytes(label); + buffer.Add((byte)labelBytes.Length); + buffer.AddRange(labelBytes); + } + buffer.Add(0x00); // Root label + } + + /// + /// Builds TXT record RDATA (length-prefixed strings). + /// + private static byte[] BuildTxtRdata(string text) + { + List rdata = new(); + byte[] textBytes = Encoding.UTF8.GetBytes(text); + + // TXT records are split into 255-byte chunks + int offset = 0; + while (offset < textBytes.Length) + { + int chunkLength = Math.Min(255, textBytes.Length - offset); + rdata.Add((byte)chunkLength); + rdata.AddRange(textBytes.Skip(offset).Take(chunkLength)); + offset += chunkLength; + } + + return [.. rdata]; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func responder; + private readonly bool echoDnsQueryId; + + public StubHandler(Func responder, bool echoDnsQueryId = true) + { + this.responder = responder; + this.echoDnsQueryId = echoDnsQueryId; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response = responder(request); + if (!echoDnsQueryId || request.Content is null || response.Content is null) + return response; + + if (request.Content.Headers.ContentType?.MediaType != "application/dns-message" + || response.Content.Headers.ContentType?.MediaType != "application/dns-message") + return response; + + byte[] query = await request.Content.ReadAsByteArrayAsync(cancellationToken); + byte[] responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (query.Length < 2 || responseBytes.Length < 2) + return response; + + responseBytes[0] = query[0]; + responseBytes[1] = query[1]; + + ByteArrayContent content = new(responseBytes); + foreach (var header in response.Content.Headers) + content.Headers.TryAddWithoutValidation(header.Key, header.Value); + response.Content = content; + return response; + } + } +} diff --git a/tests/Neo.Plugins.OracleService.Tests/UT_OracleService.cs b/tests/Neo.Plugins.OracleService.Tests/UT_OracleService.cs index 3fe45f61c..fa2986ecf 100644 --- a/tests/Neo.Plugins.OracleService.Tests/UT_OracleService.cs +++ b/tests/Neo.Plugins.OracleService.Tests/UT_OracleService.cs @@ -44,6 +44,18 @@ public void TestFilter() OracleService.Filter(json, "$.Manufacturers[1].Products[0]").ToStrictUtf8String()); } + [TestMethod] + public void FormatResponseForLog_UsesErrorPayloadForDnsStackFailures() + { + Assert.AreEqual("invalid-base64", OracleService.FormatResponseForLog(OracleResponseCode.Error, "invalid-base64", true)); + } + + [TestMethod] + public void FormatResponseForLog_RedactsSuccessfulDnsStackPayload() + { + Assert.AreEqual("", OracleService.FormatResponseForLog(OracleResponseCode.Success, "ABCDEFGH", true)); + } + [TestMethod] public void TestCreateOracleResponseTx() {