diff --git a/.github/workflows/qualify-cli.yml b/.github/workflows/qualify-cli.yml new file mode 100644 index 00000000..69d6d94a --- /dev/null +++ b/.github/workflows/qualify-cli.yml @@ -0,0 +1,84 @@ +name: Qualify CLI packages + +on: + push: + branches: [main] + paths-ignore: ['*.md', 'Docs/**', 'Examples/**', '.gitignore'] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + name: CLI (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - { os: windows-latest, runtimes: 'win-x64,win-arm64' } + - { os: ubuntu-latest, runtimes: 'linux-x64,linux-arm64' } + - { os: macos-latest, runtimes: 'osx-x64,osx-arm64' } + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - name: Select the macOS SDK installation root + if: runner.os == 'macOS' + shell: bash + run: | + # PowerForge qualifies SDKs from the standard platform installation roots. + sudo mkdir -p /usr/local/share/dotnet + sudo chown "$(id -un)" /usr/local/share/dotnet + echo 'DOTNET_INSTALL_DIR=/usr/local/share/dotnet' >> "$GITHUB_ENV" + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.x + 10.x + - name: Install shared publish engine + shell: pwsh + run: Install-Module PSPublishModule -MinimumVersion 3.0.151 -Force -Scope CurrentUser -AllowClobber + - name: Publish platform CLI candidates + shell: pwsh + run: | + Import-Module PSPublishModule -MinimumVersion 3.0.151 -ErrorAction Stop + Invoke-DotNetPublish -ConfigPath Build/cli.publish.json -Runtimes ('${{ matrix.runtimes }}'.Split(',')) -NoPublishSign -NoInteractive -ExitCode + - name: Smoke native archive + shell: pwsh + run: | + $architecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() + $platform = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' } + $name = if ($IsWindows) { 'DnsClientX.Cli.exe' } else { 'DnsClientX.Cli' } + foreach ($line in Get-Content 'Artefacts/Cli/SHA256SUMS') { + if ($line -notmatch '^([A-Fa-f0-9]{64}) \*(.+)$') { throw 'Invalid CLI checksum catalog.' } + if ((Get-FileHash -LiteralPath $Matches[2] -Algorithm SHA256).Hash -ne $Matches[1]) { throw 'CLI archive checksum mismatch.' } + } + $archive = Join-Path 'Artefacts/Cli' "DnsClientX-$platform-$architecture.zip" + $root = Join-Path $env:RUNNER_TEMP 'cli-candidate' + Expand-Archive -LiteralPath $archive -DestinationPath $root + $executable = Join-Path $root $name + if (-not (Test-Path -LiteralPath $executable)) { throw 'CLI archive is missing its executable.' } + & $executable --help + if ($LASTEXITCODE) { throw 'Native CLI help failed.' } + $json = & $executable --capabilities --format json + if ($LASTEXITCODE) { throw 'Native CLI capability report failed.' } + $json | ConvertFrom-Json -ErrorAction Stop | Out-Null + Add-Type -Path ./Module/Tests/Fixtures/DnsWorkflowServer.cs + $peer = [DnsWorkflowServer]::new() + try { + $json = & $executable package.test --resolver $peer.Endpoint --format json --explain --trace + if ($LASTEXITCODE) { throw 'Packaged CLI loopback query failed.' } + $report = $json | ConvertFrom-Json -ErrorAction Stop + if ($report.Response.Status -ne 'NoError' -or $report.Response.Answer.Count -ne 1 -or $report.Response.Answer[0].data -ne '192.0.2.10' -or $report.Explanation.Transport -ne 'Udp' -or $report.Explanation.Attempts.Count -ne 1 -or $report.Explanation.Attempts[0].Port -ne $peer.Port) { throw 'Packaged CLI query report is incomplete.' } + } finally { $peer.Dispose() } + - uses: actions/upload-artifact@v7 + with: + name: cli-${{ matrix.os }} + path: | + Artefacts/Cli/*.zip + Artefacts/Cli/manifest.json + Artefacts/Cli/SHA256SUMS + if-no-files-found: error diff --git a/.github/workflows/test-powershell.yml b/.github/workflows/test-powershell.yml index 54bc3927..e600cba3 100644 --- a/.github/workflows/test-powershell.yml +++ b/.github/workflows/test-powershell.yml @@ -2,210 +2,172 @@ name: Test PowerShell on: push: - branches: - - main - paths-ignore: - - '*.md' - - 'Docs/**' - - 'Examples/**' - - '.gitignore' + branches: [main] + paths-ignore: ['*.md', 'Docs/**', 'Examples/**', '.gitignore'] pull_request: - branches: - - main + branches: [main] workflow_dispatch: permissions: contents: read -env: - DOTNET_VERSION: '10.x' - BUILD_CONFIGURATION: 'Debug' - jobs: - refresh-psd1: - name: 'Refresh PSD1' + package: + name: Build unsigned package runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup PowerShell modules + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.x + 10.x + - name: Install shared builder shell: pwsh - run: | - Install-Module PSPublishModule -Force -Scope CurrentUser -AllowClobber - - - name: Refresh module manifest + run: Install-Module PSPublishModule -MinimumVersion 3.0.151 -Force -Scope CurrentUser -AllowClobber + - name: Build through native module entrypoint shell: pwsh run: | - ./Module/Build/Build-Module.ps1 -RunMode Manifest -SignModule:$false - - - name: Upload refreshed manifest + $version = (Import-PowerShellDataFile ./Module/DnsClientX.psd1).ModuleVersion + ./Module/Build/Build-Module.ps1 -RunMode Build -SignModule:$false -UpdateVersions:$false -ModuleVersion $version -SkipInstall + - name: Upload module and NuGet candidates uses: actions/upload-artifact@v7 with: - name: psd1 - path: Module/DnsClientX.psd1 - - test-windows-ps5: - needs: refresh-psd1 - name: 'Windows PowerShell 5.1' - runs-on: windows-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Download manifest - uses: actions/download-artifact@v8 - with: - name: psd1 - path: . - - - name: Setup .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Install PowerShell modules - shell: powershell - run: | - Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" - Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - - - name: Build .NET solution - run: | - dotnet restore DnsClientX.sln - dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore - - - name: Run PowerShell tests - shell: powershell - run: ./Module/DnsClientX.Tests.ps1 - - test-windows-ps7: - needs: refresh-psd1 - name: 'Windows PowerShell 7' - runs-on: windows-latest - timeout-minutes: 10 + name: unsigned-packages + path: | + Artefacts/Packed/*.zip + Artefacts/ProjectBuild/packages/*.nupkg + if-no-files-found: error + + test: + needs: package + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - { name: Windows PowerShell 5.1 package, os: windows-latest, shell: powershell } + - { name: Windows PowerShell 7 package, os: windows-latest, shell: pwsh } + - { name: Linux PowerShell 7 package, os: ubuntu-latest, shell: pwsh } + - { name: macOS PowerShell 7 package, os: macos-latest, shell: pwsh } + runs-on: ${{ matrix.os }} + timeout-minutes: 15 steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Download manifest - uses: actions/download-artifact@v8 - with: - name: psd1 - path: . - - - name: Setup .NET - uses: actions/setup-dotnet@v6 + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Install PowerShell modules + name: unsigned-packages + path: candidate + - name: Install Pester shell: pwsh run: | - Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" - Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - - - name: Build .NET solution - run: | - dotnet restore DnsClientX.sln - dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore - - - name: Run PowerShell tests + $install = { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module Pester -MinimumVersion 5.7.1 -Force -Scope CurrentUser -SkipPublisherCheck -AllowClobber -ErrorAction Stop + } + if ('${{ matrix.shell }}' -eq 'powershell') { + & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $install + if ($LASTEXITCODE) { throw 'Windows PowerShell Pester installation failed.' } + } else { + & $install + } + - name: Import packaged module and run contracts shell: pwsh - run: ./Module/DnsClientX.Tests.ps1 + run: | + $archive = @(Get-ChildItem candidate/Packed -Filter 'DnsClientX-PowerShellModule.*.zip') + if ($archive.Count -ne 1) { throw 'Expected one module archive.' } + $root = Join-Path $env:RUNNER_TEMP 'module-candidate' + Expand-Archive -LiteralPath $archive[0].FullName -DestinationPath $root + $manifest = @(Get-ChildItem $root -Filter DnsClientX.psd1 -Recurse) + if ($manifest.Count -ne 1) { throw 'Expected one packaged module manifest.' } + if ('${{ matrix.shell }}' -eq 'powershell') { + & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml" + if ($LASTEXITCODE) { throw 'Windows PowerShell package validation failed.' } + } else { + ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml" + } + - uses: actions/upload-artifact@v7 + if: always() + with: + name: pester-${{ matrix.os }}-${{ matrix.shell }} + path: ${{ runner.temp }}/pester.xml - test-ubuntu: - needs: refresh-psd1 - name: 'Ubuntu PowerShell 7' + test-minimum: + needs: package + name: Minimum PowerShell 7.4.0 package runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Download manifest - uses: actions/download-artifact@v8 + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 with: - name: psd1 - path: . - - - name: Setup .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Install PowerShell - run: | - curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - - curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list | sudo tee /etc/apt/sources.list.d/microsoft.list - sudo apt-get update - sudo apt-get install -y powershell - - - name: Install PowerShell modules + name: unsigned-packages + path: candidate + - name: Install isolated minimum supported host shell: pwsh run: | - Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" - Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - - - name: Build .NET solution - run: | - dotnet restore DnsClientX.sln - dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore - - - name: Run PowerShell tests + $root = Join-Path $env:RUNNER_TEMP 'powershell-7.4.0' + New-Item -ItemType Directory -Path $root | Out-Null + $archive = Join-Path $env:RUNNER_TEMP 'powershell-7.4.0.tar.gz' + Invoke-WebRequest https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell-7.4.0-linux-x64.tar.gz -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne 'AA82C3E9241503B52DB1827E3FD7FC099DA74F828BA032E9B726E50C72B4BEE5') { throw 'PowerShell download hash mismatch.' } + tar -xzf $archive -C $root + if ($LASTEXITCODE) { throw 'PowerShell extraction failed.' } + chmod +x "$root/pwsh" + if ($LASTEXITCODE) { throw 'PowerShell executable setup failed.' } + Install-Module Pester -MinimumVersion 5.7.1 -Force -Scope CurrentUser -AllowClobber + - name: Import package on minimum host and run contracts shell: pwsh - run: ./Module/DnsClientX.Tests.ps1 - - test-macos: - needs: refresh-psd1 - name: 'macOS PowerShell 7' - runs-on: macos-latest - timeout-minutes: 10 + run: | + $archive = @(Get-ChildItem candidate/Packed -Filter 'DnsClientX-PowerShellModule.*.zip') + if ($archive.Count -ne 1) { throw 'Expected one module archive.' } + $root = Join-Path $env:RUNNER_TEMP 'module-candidate' + Expand-Archive -LiteralPath $archive[0].FullName -DestinationPath $root + $manifest = @(Get-ChildItem $root -Filter DnsClientX.psd1 -Recurse) + if ($manifest.Count -ne 1) { throw 'Expected one packaged module manifest.' } + & "$env:RUNNER_TEMP/powershell-7.4.0/pwsh" -NoProfile -File ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml" + if ($LASTEXITCODE) { throw 'Minimum PowerShell contract validation failed.' } + - uses: actions/upload-artifact@v7 + if: always() + with: + name: pester-powershell-7.4.0 + path: ${{ runner.temp }}/pester.xml + + package-consumer: + needs: package + name: Packed NuGet consumer (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Download manifest - uses: actions/download-artifact@v8 + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 with: - name: psd1 - path: . - - - name: Setup .NET - uses: actions/setup-dotnet@v6 + name: unsigned-packages + path: candidate + - uses: actions/setup-dotnet@v6 with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Install PowerShell - shell: bash - run: | - if command -v pwsh >/dev/null 2>&1; then - pwsh --version - exit 0 - fi - - brew update - brew tap powershell/tap - brew install --cask powershell || brew install powershell/tap/powershell - - - name: Install PowerShell modules + dotnet-version: | + 8.x + 10.x + - name: Consume private candidates with isolated restore shell: pwsh run: | - Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" - Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber - - - name: Build .NET solution - run: | - dotnet restore DnsClientX.sln - dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore - - - name: Run PowerShell tests - shell: pwsh - run: ./Module/DnsClientX.Tests.ps1 - + $feed = (Resolve-Path candidate/ProjectBuild/packages).Path + $core = @(Get-ChildItem $feed -Filter 'DnsClientX.*.nupkg' | Where-Object Name -Match '^DnsClientX\.\d') + $provider = @(Get-ChildItem $feed -Filter 'DnsClientX.DnsSec.EdDsa.*.nupkg') + if ($core.Count -ne 1 -or $provider.Count -ne 1) { throw 'Expected core and optional provider candidates.' } + $version = $core[0].BaseName.Substring('DnsClientX.'.Length) + $providerVersion = $provider[0].BaseName.Substring('DnsClientX.DnsSec.EdDsa.'.Length) + $env:NUGET_PACKAGES = Join-Path $env:RUNNER_TEMP 'candidate-nuget-cache' + $env:DNSCLIENTX_CANDIDATE_FEED = $feed + dotnet restore Build/PackageConsumer/PackageConsumer.csproj --configfile Build/PackageConsumer/NuGet.Config -p:DnsClientXPackageVersion=$version -p:DnsClientXEdDsaPackageVersion=$providerVersion + if ($LASTEXITCODE) { throw 'Packed consumer restore failed.' } + foreach ($framework in @('net8.0', 'net10.0')) { + dotnet run --project Build/PackageConsumer/PackageConsumer.csproj --framework $framework --no-restore -p:DnsClientXPackageVersion=$version -p:DnsClientXEdDsaPackageVersion=$providerVersion + if ($LASTEXITCODE) { throw "Packed consumer failed on $framework." } + } diff --git a/.gitignore b/.gitignore index c2ee5c01..626d0d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -368,6 +368,10 @@ build/* !Build/ !Build/*.ps1 !Build/*.json +!Build/PackageConsumer/ +!Build/PackageConsumer/*.cs +!Build/PackageConsumer/*.csproj +!Build/PackageConsumer/NuGet.Config /.idea/ diff --git a/Build/PackageConsumer/NuGet.Config b/Build/PackageConsumer/NuGet.Config new file mode 100644 index 00000000..ebb505f3 --- /dev/null +++ b/Build/PackageConsumer/NuGet.Config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Build/PackageConsumer/PackageConsumer.csproj b/Build/PackageConsumer/PackageConsumer.csproj new file mode 100644 index 00000000..d2534e69 --- /dev/null +++ b/Build/PackageConsumer/PackageConsumer.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0;net10.0 + enable + enable + false + 2.1.2 + 2.1.1 + + + + + + diff --git a/Build/PackageConsumer/Program.cs b/Build/PackageConsumer/Program.cs new file mode 100644 index 00000000..2555f26e --- /dev/null +++ b/Build/PackageConsumer/Program.cs @@ -0,0 +1,25 @@ +using DnsClientX; +using DnsClientX.DnsSec.EdDsa; + +var response = new DnsResponse { + Answers = [new DnsAnswer { Name = "package.example", Type = DnsRecordType.A, Class = 1, TTL = 9, DataRaw = "192.0.2.1" }] +}; +var record = response.GetTypedAnswers().Single(); +if (record.Name != "package.example" || record.TTL != 9 || record.Value.Address.ToString() != "192.0.2.1") + throw new Exception("Typed metadata contract failed."); +using var client = new ClientX(DnsEndpoint.System); +var verifier = new EdDsaDnsSecSignatureVerifier(); +client.EndpointConfiguration.DnsSecSignatureVerifier = verifier; +if (!client.EndpointConfiguration.SupportedDnsSecAlgorithms.Contains(DnsKeyAlgorithm.ED25519)) throw new Exception("Optional verifier failed to load."); +// RFC 8032 section 7.1, first Ed25519 test vector. This invokes the shipped +// cryptographic dependency rather than merely inspecting its algorithm list. +var publicKey = Convert.FromHexString("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"); +var signature = Convert.FromHexString("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"); +if (!verifier.Verify(DnsKeyAlgorithm.ED25519, publicKey, [], signature)) throw new Exception("Packed provider rejected the Ed25519 vector."); +signature[0] ^= 1; +if (verifier.Verify(DnsKeyAlgorithm.ED25519, publicKey, [], signature)) throw new Exception("Packed provider accepted an invalid signature."); +new DnsResolveOptions { TypedRecords = true }.Validate(); +_ = ClientX.GetResponseCacheStatistics(); +_ = DnsQueryExplanation.Create(response); +Console.WriteLine($"Packed core and optional DNSSEC provider loaded on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}."); diff --git a/Build/Test-PackagedModule.ps1 b/Build/Test-PackagedModule.ps1 new file mode 100644 index 00000000..f7c909f9 --- /dev/null +++ b/Build/Test-PackagedModule.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory)] + [string] $ModulePath, + [string] $ResultsPath +) + +$ErrorActionPreference = 'Stop' +$manifest = Get-Item -LiteralPath $ModulePath +$module = Import-Module $manifest.FullName -Force -PassThru +$required = @('Resolve-Dns', 'Find-DnsService', 'Get-DnsService', 'Invoke-DnsUpdate', 'Test-DnsHealth', 'Compare-DnsResolverSnapshot', 'Get-DnsQueryExplanation', 'Get-DnsCache', 'Clear-DnsCache') +foreach ($name in $required) { + if (-not $module.ExportedCommands.ContainsKey($name)) { throw "Packaged module does not export $name." } +} +$library = [DnsClientX.ClientX].Assembly.Location +if (-not $library.StartsWith($manifest.Directory.FullName + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Module loaded its core outside the package: $library" +} +$data = Import-PowerShellDataFile -LiteralPath $manifest.FullName +foreach ($asset in @($data.FormatsToProcess) + @($data.TypesToProcess)) { + if (-not (Test-Path -LiteralPath (Join-Path $manifest.Directory.FullName $asset))) { throw "Package asset is missing: $asset" } +} +if (@($data.FormatsToProcess).Count -eq 0 -or @($data.TypesToProcess).Count -eq 0) { throw 'Package needs its format and type assets.' } +$tests = Join-Path $manifest.Directory.FullName 'Tests' +New-Item -ItemType Directory -Path $tests -Force | Out-Null +Copy-Item -Path (Join-Path $PSScriptRoot '../Module/Tests/*') -Destination $tests -Recurse -Force +Import-Module Pester -MinimumVersion 5.0 -ErrorAction Stop +$configuration = New-PesterConfiguration +$configuration.Run.Path = $tests +$configuration.Run.PassThru = $true +$configuration.Output.Verbosity = 'Detailed' +if ($ResultsPath) { + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputPath = $ResultsPath +} +$result = Invoke-Pester -Configuration $configuration +if ($result.Result -ne 'Passed' -or $result.FailedCount -gt 0 -or $result.PassedCount -eq 0) { throw "Package tests $($result.Result): $($result.FailedCount) failures, $($result.PassedCount) passes." } diff --git a/Build/cli.publish.json b/Build/cli.publish.json new file mode 100644 index 00000000..1563facd --- /dev/null +++ b/Build/cli.publish.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/powerforge.dotnetpublish.schema.json", + "SchemaVersion": 1, + "DotNet": { + "ProjectRoot": "..", + "Configuration": "Release", + "EnvironmentVariables": { + "NUGET_PACKAGES": { + "FromEnvironmentVariable": "NUGET_PACKAGES", + "Required": false + } + } + }, + "Targets": [ + { + "Name": "DnsClientX", + "ProjectPath": "DnsClientX.Cli/DnsClientX.Cli.csproj", + "Kind": "Cli", + "Publish": { + "Framework": "net8.0", + "Runtimes": ["win-x64", "win-arm64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64"], + "Style": "FrameworkDependent", + "OutputPath": "Artefacts/Cli/{rid}", + "Zip": true, + "ZipNameTemplate": "DnsClientX-{rid}.zip", + "KeepSymbols": false, + "KeepDocs": false + } + } + ], + "Outputs": { + "ManifestJsonPath": "Artefacts/Cli/manifest.json", + "ManifestTextPath": "Artefacts/Cli/manifest.txt", + "ChecksumsPath": "Artefacts/Cli/SHA256SUMS" + } +} diff --git a/DnsClientX.Cli/DnsClientX.Cli.csproj b/DnsClientX.Cli/DnsClientX.Cli.csproj index 6f49e23d..4f859c6a 100644 --- a/DnsClientX.Cli/DnsClientX.Cli.csproj +++ b/DnsClientX.Cli/DnsClientX.Cli.csproj @@ -23,7 +23,7 @@ true false - win-x64;linux-x64;osx-x64 + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 diff --git a/DnsClientX.Cli/Program.Arguments.cs b/DnsClientX.Cli/Program.Arguments.cs new file mode 100644 index 00000000..7a14ab0d --- /dev/null +++ b/DnsClientX.Cli/Program.Arguments.cs @@ -0,0 +1,472 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static bool TryParseArgs(string[] args, out CliOptions? options, out string? errorMessage, out IReadOnlyList? invalidSwitches) { + options = new CliOptions(); + errorMessage = null; + var unknown = new List(); + + for (int i = 0; i < args.Length; i++) { + string arg = args[i]; + if (arg.StartsWith("-", StringComparison.Ordinal)) options.ProvidedSwitches.Add(arg); + switch (arg) { + case var opt when opt.Equals("--profile", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--profile", out string? profile, out errorMessage)) { invalidSwitches = null; options = null; return false; } + options.ProfilePath = profile; + break; + case var opt when opt.Equals("--profile-save", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--profile-save", out string? profileSave, out errorMessage)) { invalidSwitches = null; options = null; return false; } + options.ProfileSavePath = profileSave; + break; + case var opt when opt.Equals("--resolver-compare", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--resolver-compare", out string? baseline, out errorMessage) || !TryReadNext(args, ref i, "--resolver-compare", out string? current, out errorMessage)) { invalidSwitches = null; options = null; return false; } + options.CompareBaselinePath = baseline; + options.CompareCurrentPath = current; + break; + case var opt when opt.Equals("--reference-response", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--reference-response", out string? reference, out errorMessage)) { invalidSwitches = null; options = null; return false; } + options.ReferenceResponsePath = reference; + break; + case var opt when opt.Equals("-t", StringComparison.OrdinalIgnoreCase) || + opt.Equals("--type", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--type", out string? recordTypeValue, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + AddOptionValues(options.RecordTypeInputs, recordTypeValue); + options.RecordTypeSpecified = true; + break; + case var opt when opt.Equals("-e", StringComparison.OrdinalIgnoreCase) || + opt.Equals("--endpoint", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--endpoint", out string? endpointValue, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + AddOptionValues(options.EndpointInputs, endpointValue); + break; + case var opt when opt.Equals("--domain", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--domain", out string? domainValue, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + AddOptionValues(options.DomainInputs, domainValue); + break; + case var opt when opt.Equals("--reverse", StringComparison.OrdinalIgnoreCase) || + opt.Equals("--ptr", StringComparison.OrdinalIgnoreCase): + options.ReverseLookup = true; + break; + case var opt when opt.Equals("--axfr", StringComparison.OrdinalIgnoreCase) || + opt.Equals("--zone-transfer", StringComparison.OrdinalIgnoreCase): + options.ZoneTransfer = true; + break; + case var opt when opt.Equals("--transfer-summary", StringComparison.OrdinalIgnoreCase): + options.TransferSummary = true; + break; + case var opt when opt.Equals("--dnssec", StringComparison.OrdinalIgnoreCase): + options.RequestDnsSec = true; + break; + case var opt when opt.Equals("--validate-dnssec", StringComparison.OrdinalIgnoreCase): + options.ValidateDnsSec = true; + break; + case var opt when opt.Equals("--dnssec-verifier", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--dnssec-verifier", out string? verifierPath, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + if (string.IsNullOrWhiteSpace(verifierPath)) { + errorMessage = "--dnssec-verifier requires a nonblank local DLL path."; + invalidSwitches = null; + options = null; + return false; + } + options.DnsSecVerifierPath = verifierPath; + break; + case var opt when opt.Equals("--wire-post", StringComparison.OrdinalIgnoreCase): + options.WirePost = true; + break; + case var opt when opt.Equals("--nsid", StringComparison.OrdinalIgnoreCase): + options.RequestNsid = true; + break; + case var opt when opt.Equals("--bootstrap", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--bootstrap", out string? bootstrap, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + try { options.BootstrapResolver = EndpointParser.ParseBootstrap(bootstrap!); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { + errorMessage = ex.Message; + invalidSwitches = null; + options = null; + return false; + } + break; + case var opt when opt.Equals("--format", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--format", out string? formatValue, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + if (!TryParseQueryOutputFormat(formatValue, out QueryOutputFormat outputFormat)) { + errorMessage = $"Invalid value for --format: {formatValue}"; + invalidSwitches = null; + options = null; + return false; + } + options.OutputFormat = outputFormat; + break; + case var opt when opt.Equals("--short", StringComparison.OrdinalIgnoreCase): + options.ShortOutput = true; + break; + case var opt when opt.Equals("--txt-concat", StringComparison.OrdinalIgnoreCase): + options.TxtConcatOutput = true; + break; + case var opt when opt.Equals("--question", StringComparison.OrdinalIgnoreCase): + options.ShowQuestionSection = true; + break; + case var opt when opt.Equals("--answer", StringComparison.OrdinalIgnoreCase): + options.ShowAnswerSection = true; + break; + case var opt when opt.Equals("--authority", StringComparison.OrdinalIgnoreCase): + options.ShowAuthoritySection = true; + break; + case var opt when opt.Equals("--additional", StringComparison.OrdinalIgnoreCase): + options.ShowAdditionalSection = true; + break; + case var opt when opt.Equals("--benchmark", StringComparison.OrdinalIgnoreCase): + options.Benchmark = true; + break; + case var opt when opt.Equals("--benchmark-connections", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-connections", out string? connectionMode, out errorMessage) || + (connectionMode != "cold" && connectionMode != "warm") || + !Enum.TryParse(connectionMode, true, out ResolverQueryConnectionMode parsedMode) || + (parsedMode != ResolverQueryConnectionMode.Cold && parsedMode != ResolverQueryConnectionMode.Warm)) { + errorMessage = "--benchmark-connections accepts cold or warm."; + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkConnectionMode = parsedMode; + break; + case var opt when opt.Equals("--benchmark-attempts", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-attempts", out string? benchmarkAttemptsValue, out errorMessage) || + !int.TryParse(benchmarkAttemptsValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkAttempts)) { + errorMessage ??= $"Invalid value for --benchmark-attempts: {benchmarkAttemptsValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (benchmarkAttempts < 1) { + errorMessage = $"--benchmark-attempts must be at least 1: {benchmarkAttempts}"; + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkAttempts = benchmarkAttempts; + break; + case var opt when opt.Equals("--benchmark-timeout", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-timeout", out string? benchmarkTimeoutValue, out errorMessage) || + !int.TryParse(benchmarkTimeoutValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkTimeoutMs)) { + errorMessage ??= $"Invalid value for --benchmark-timeout: {benchmarkTimeoutValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (benchmarkTimeoutMs < 1) { + errorMessage = $"--benchmark-timeout must be at least 1: {benchmarkTimeoutMs}"; + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkTimeoutMs = benchmarkTimeoutMs; + break; + case var opt when opt.Equals("--benchmark-concurrency", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-concurrency", out string? benchmarkConcurrencyValue, out errorMessage) || + !int.TryParse(benchmarkConcurrencyValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkConcurrency)) { + errorMessage ??= $"Invalid value for --benchmark-concurrency: {benchmarkConcurrencyValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (benchmarkConcurrency < 1) { + errorMessage = $"--benchmark-concurrency must be at least 1: {benchmarkConcurrency}"; + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkConcurrency = benchmarkConcurrency; + break; + case var opt when opt.Equals("--benchmark-min-success-percent", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-min-success-percent", out string? benchmarkMinSuccessPercentValue, out errorMessage) || + !int.TryParse(benchmarkMinSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessPercent)) { + errorMessage ??= $"Invalid value for --benchmark-min-success-percent: {benchmarkMinSuccessPercentValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (benchmarkMinSuccessPercent < 1 || benchmarkMinSuccessPercent > 100) { + errorMessage = $"--benchmark-min-success-percent must be between 1 and 100: {benchmarkMinSuccessPercent}"; + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkMinSuccessPercent = benchmarkMinSuccessPercent; + break; + case var opt when opt.Equals("--benchmark-min-successful-candidates", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-min-successful-candidates", out string? benchmarkMinSuccessfulCandidatesValue, out errorMessage) || + !int.TryParse(benchmarkMinSuccessfulCandidatesValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessfulCandidates)) { + errorMessage ??= $"Invalid value for --benchmark-min-successful-candidates: {benchmarkMinSuccessfulCandidatesValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (benchmarkMinSuccessfulCandidates < 1) { + errorMessage = $"--benchmark-min-successful-candidates must be at least 1: {benchmarkMinSuccessfulCandidates}"; + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkMinSuccessfulCandidates = benchmarkMinSuccessfulCandidates; + break; + case var opt when opt.Equals("--benchmark-summary-line", StringComparison.OrdinalIgnoreCase): + options.Benchmark = true; + options.BenchmarkSummaryLine = true; + break; + case var opt when opt.Equals("--benchmark-save", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--benchmark-save", out string? benchmarkSavePath, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + options.Benchmark = true; + options.BenchmarkSavePath = benchmarkSavePath; + break; + case var opt when opt.Equals("--benchmark-summary-only", StringComparison.OrdinalIgnoreCase): + options.Benchmark = true; + options.BenchmarkSummaryOnly = true; + break; + case var opt when opt.Equals("--probe", StringComparison.OrdinalIgnoreCase): + options.Probe = true; + break; + case var opt when opt.Equals("--probe-endpoint", StringComparison.OrdinalIgnoreCase) || opt.Equals("--resolver", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--probe-endpoint", out string? probeEndpoint, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + AddOptionValues(options.ProbeEndpoints, probeEndpoint); + break; + case var opt when opt.Equals("--resolver-file", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--resolver-file", out string? resolverFile, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + AddOptionValues(options.ProbeEndpointFiles, resolverFile); + break; + case var opt when opt.Equals("--resolver-url", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--resolver-url", out string? resolverUrl, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + AddOptionValues(options.ProbeEndpointUrls, resolverUrl); + break; + case var opt when opt.Equals("--resolver-validate", StringComparison.OrdinalIgnoreCase): + options.ResolverValidate = true; + break; + case var opt when opt.Equals("--probe-summary-only", StringComparison.OrdinalIgnoreCase): + options.Probe = true; + options.ProbeSummaryOnly = true; + break; + case var opt when opt.Equals("--probe-summary-line", StringComparison.OrdinalIgnoreCase): + options.Probe = true; + options.ProbeSummaryLine = true; + break; + case var opt when opt.Equals("--resolver-select", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--resolver-select", out string? resolverSelectPath, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + options.ResolverSelectPath = resolverSelectPath; + break; + case var opt when opt.Equals("--resolver-use", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--resolver-use", out string? resolverUsePath, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + options.ResolverUsePath = resolverUsePath; + break; + case var opt when opt.Equals("--probe-save", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--probe-save", out string? probeSavePath, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + options.Probe = true; + options.ProbeSavePath = probeSavePath; + break; + case var opt when opt.Equals("--probe-require-consensus", StringComparison.OrdinalIgnoreCase): + options.Probe = true; + options.ProbeRequireConsensus = true; + break; + case var opt when opt.Equals("--probe-min-consensus", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--probe-min-consensus", out string? minConsensusValue, out errorMessage) || + !int.TryParse(minConsensusValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minConsensusPercent)) { + errorMessage ??= $"Invalid value for --probe-min-consensus: {minConsensusValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (minConsensusPercent < 1 || minConsensusPercent > 100) { + errorMessage = $"--probe-min-consensus must be between 1 and 100: {minConsensusPercent}"; + invalidSwitches = null; + options = null; + return false; + } + options.Probe = true; + options.ProbeMinConsensusPercent = minConsensusPercent; + break; + case var opt when opt.Equals("--probe-min-success", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--probe-min-success", out string? minSuccessValue, out errorMessage) || + !int.TryParse(minSuccessValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessCount)) { + errorMessage ??= $"Invalid value for --probe-min-success: {minSuccessValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (minSuccessCount < 1) { + errorMessage = $"--probe-min-success must be at least 1: {minSuccessCount}"; + invalidSwitches = null; + options = null; + return false; + } + options.Probe = true; + options.ProbeMinSuccessCount = minSuccessCount; + break; + case var opt when opt.Equals("--probe-min-success-percent", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--probe-min-success-percent", out string? minSuccessPercentValue, out errorMessage) || + !int.TryParse(minSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessPercent)) { + errorMessage ??= $"Invalid value for --probe-min-success-percent: {minSuccessPercentValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (minSuccessPercent < 1 || minSuccessPercent > 100) { + errorMessage = $"--probe-min-success-percent must be between 1 and 100: {minSuccessPercent}"; + invalidSwitches = null; + options = null; + return false; + } + options.Probe = true; + options.ProbeMinSuccessPercent = minSuccessPercent; + break; + case var opt when opt.Equals("--explain", StringComparison.OrdinalIgnoreCase): + options.Explain = true; + break; + case var opt when opt.Equals("--trace", StringComparison.OrdinalIgnoreCase): + options.Trace = true; + options.Explain = true; + break; + case var opt when opt.Equals("--capabilities", StringComparison.OrdinalIgnoreCase): + options.ShowCapabilities = true; + break; + case var opt when opt.Equals("--stamp-info", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--stamp-info", out string? stampInfo, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + options.StampInfo = stampInfo; + break; + case var opt when opt.Equals("--update", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--update", out string? zone, out errorMessage) || + !TryReadNext(args, ref i, "--update", out string? updateName, out errorMessage) || + !TryReadNext(args, ref i, "--update", out string? updateTypeValue, out errorMessage) || + !TryReadNext(args, ref i, "--update", out string? updateData, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + if (!Enum.TryParse(updateTypeValue, true, out DnsRecordType updateType)) { + errorMessage = $"Invalid value for --update type: {updateTypeValue}"; + invalidSwitches = null; + options = null; + return false; + } + options.DoUpdate = true; + options.Zone = zone; + options.UpdateName = updateName; + options.RecordType = updateType; + options.UpdateData = updateData; + break; + case var opt when opt.Equals("--ttl", StringComparison.OrdinalIgnoreCase): + if (!TryReadNext(args, ref i, "--ttl", out string? ttlValue, out errorMessage) || + !int.TryParse(ttlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int ttl)) { + errorMessage ??= $"Invalid value for --ttl: {ttlValue}"; + invalidSwitches = null; + options = null; + return false; + } + if (ttl < 0) { + errorMessage = $"--ttl must be zero or greater: {ttl}"; + invalidSwitches = null; + options = null; + return false; + } + options.Ttl = ttl; + break; + default: + if (options.Domain is null && !arg.StartsWith("-", StringComparison.Ordinal)) { + options.Domain = arg; + } else { + unknown.Add(arg); + } + break; + } + } + + if (unknown.Count > 0) { + invalidSwitches = unknown; + options = null; + return false; + } + + if (!FinalizeParsedLists(options, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + + if (!ValidateParsedOptions(options, out errorMessage)) { + invalidSwitches = null; + options = null; + return false; + } + + invalidSwitches = Array.Empty(); + return true; + } + + } +} diff --git a/DnsClientX.Cli/Program.Help.cs b/DnsClientX.Cli/Program.Help.cs new file mode 100644 index 00000000..6f072f90 --- /dev/null +++ b/DnsClientX.Cli/Program.Help.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static void ShowHelp() { + Console.WriteLine("DnsClientX.Cli - simple DNS query tool"); + Console.WriteLine("Usage: DnsClientX.Cli [options] "); + Console.WriteLine(" --resolver Query one explicit resolver directly (UDP/TCP/DoT/DoH/DoQ etc.)"); + Console.WriteLine(" --profile [--benchmark] Run a versioned resolver health profile; settings come from its JSON"); + Console.WriteLine(" --profile-save Save the complete health report JSON"); + Console.WriteLine(" --resolver-compare Compare saved probe/benchmark snapshots"); + Console.WriteLine(" --reference-response Caller-supplied authoritative reference array for comparison"); + Console.WriteLine(" Explicit resolver inputs query directly; add --probe for multiple-candidate probing."); + Console.WriteLine(" --format json emits a complete JSON report; --explain/--trace query JSON includes structured evidence."); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" -t, --type DNS record type (default A, comma-separated with --benchmark)"); + Console.WriteLine(" -e, --endpoint DNS endpoint name (default System, comma-separated with --benchmark)"); + Console.WriteLine(" --domain Domain name (comma-separated with --benchmark)"); + Console.WriteLine(" --dnssec Request DNSSEC records"); + Console.WriteLine(" --validate-dnssec Validate DNSSEC records"); + Console.WriteLine(" --dnssec-verifier Optional DNSSEC provider DLL path (dependencies beside it)"); + Console.WriteLine(" --wire-post Use DNS over HTTPS wire POST (when supported)"); + Console.WriteLine(" --bootstrap Resolve endpoint hostnames through an IP-literal UDP/TCP resolver, e.g. udp@1.1.1.1:53"); + Console.WriteLine(" --nsid Request resolver identity through EDNS (wire transports)"); + Console.WriteLine(" --format Query output format: pretty, json, raw"); + Console.WriteLine(" --short Print answer values only"); + Console.WriteLine(" --txt-concat Flatten TXT output into a single string"); + Console.WriteLine(" --question Show the question section"); + Console.WriteLine(" --answer Show the answer section"); + Console.WriteLine(" --authority Show the authority section"); + Console.WriteLine(" --additional Show the additional section"); + Console.WriteLine(" --reverse Force PTR lookup mode"); + Console.WriteLine(" --axfr Discover authoritative servers and attempt recursive AXFR"); + Console.WriteLine(" --transfer-summary Print discovered authorities and tried transfer targets"); + Console.WriteLine(" --benchmark Benchmark one or more endpoints across repeated queries"); + Console.WriteLine(" --benchmark-attempts Repeat each domain/type combination this many times"); + Console.WriteLine(" --benchmark-timeout Per-query timeout to apply during benchmark runs"); + Console.WriteLine(" --benchmark-concurrency Max concurrent queries across the benchmark run"); + Console.WriteLine(" --benchmark-connections Fresh clients per attempt or retained clients per target (default cold)"); + Console.WriteLine(" --benchmark-min-success-percent Require a minimum overall benchmark success rate"); + Console.WriteLine(" --benchmark-min-successful-candidates Require this many candidates to return at least one success"); + Console.WriteLine(" --benchmark-summary-only Suppress per-candidate benchmark rows and print only the header and summary"); + Console.WriteLine(" --benchmark-summary-line Append one stable BENCHMARK_SUMMARY key=value line for automation"); + Console.WriteLine(" --benchmark-save Persist benchmark scoring and recommendation data as JSON"); + Console.WriteLine(" --probe Probe the selected endpoint profile and related variants"); + Console.WriteLine(" --probe-endpoint Probe a custom endpoint such as tcp@1.1.1.1:53, doq@dns.quad9.net:853, or doh3@https://dns.quad9.net/dns-query"); + Console.WriteLine(" --resolver-file Load custom endpoints from a text file for probe or benchmark"); + Console.WriteLine(" --resolver-url Load custom endpoints from an HTTP or HTTPS URL for probe or benchmark"); + Console.WriteLine(" --resolver-validate Validate resolver endpoint inputs without querying DNS"); + Console.WriteLine(" --probe-summary-only Suppress per-endpoint probe lines and print only the header and summary"); + Console.WriteLine(" --probe-summary-line Append one stable PROBE_SUMMARY key=value line for automation"); + Console.WriteLine(" --probe-save Persist probe scoring and health data as JSON"); + Console.WriteLine(" --resolver-select Load a saved score snapshot and print the recommended resolver"); + Console.WriteLine(" --resolver-use Reuse the recommended resolver from a saved snapshot for query, probe, or benchmark"); + Console.WriteLine(" --probe-require-consensus Fail when successful probe responders disagree"); + Console.WriteLine(" --probe-min-consensus Fail when the top answer set is below the given 1-100 percentage"); + Console.WriteLine(" --probe-min-success Fail when fewer than the given number of probes succeed"); + Console.WriteLine(" --probe-min-success-percent Fail when the probe success rate is below the given 1-100 percentage"); + Console.WriteLine(" --capabilities Print the shared transport capability report"); + Console.WriteLine(" --stamp-info Print parsed DNS stamp details without querying DNS"); + Console.WriteLine(" --explain Print resolver and response diagnostics"); + Console.WriteLine(" --trace Print explain output plus audit details"); + Console.WriteLine(" --update Send dynamic update to one explicit authoritative endpoint"); + Console.WriteLine(" --ttl TTL for update (default 300)"); + Console.WriteLine(); + Console.WriteLine("Available endpoints:"); + foreach (var (ep, desc) in DnsEndpointExtensions.GetAllWithDescriptions()) { + Console.WriteLine($" {ep,-20} {desc}"); + } + } + } +} diff --git a/DnsClientX.Cli/Program.Inputs.cs b/DnsClientX.Cli/Program.Inputs.cs new file mode 100644 index 00000000..844cc374 --- /dev/null +++ b/DnsClientX.Cli/Program.Inputs.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static bool TryReadNext(string[] args, ref int index, string optionName, out string? value, out string? errorMessage) { + if (index + 1 >= args.Length) { + value = null; + errorMessage = $"Missing value for {optionName}"; + return false; + } + + value = args[++index]; + errorMessage = null; + return true; + } + + private static bool TryParseQueryOutputFormat(string? value, out QueryOutputFormat format) { + switch (value?.Trim().ToLowerInvariant()) { + case "pretty": + format = QueryOutputFormat.Pretty; + return true; + case "json": + format = QueryOutputFormat.Json; + return true; + case "raw": + format = QueryOutputFormat.Raw; + return true; + default: + format = default; + return false; + } + } + + private static bool ApplyReverseLookupDefaults(CliOptions options, out string? errorMessage) { + errorMessage = null; + + if (options.ReverseLookup) { + if (options.RecordTypeSpecified && + options.BenchmarkRecordTypes.Any(recordType => recordType != DnsRecordType.PTR)) { + errorMessage = "--reverse cannot be combined with a record type other than PTR."; + return false; + } + + options.RecordType = DnsRecordType.PTR; + if (options.BenchmarkRecordTypes.Count > 0) { + options.BenchmarkRecordTypes.Clear(); + options.BenchmarkRecordTypes.Add(DnsRecordType.PTR); + } + + return true; + } + + if (options.RecordTypeSpecified || options.DoUpdate) { + return true; + } + + string[] effectiveBenchmarkDomains = options.Benchmark ? GetEffectiveBenchmarkDomains(options) : Array.Empty(); + bool shouldUsePtrByDefault = options.Benchmark + ? effectiveBenchmarkDomains.Length > 0 && effectiveBenchmarkDomains.All(IsIpAddressLiteral) + : !string.IsNullOrWhiteSpace(options.Domain) && IsIpAddressLiteral(options.Domain); + + if (shouldUsePtrByDefault) { + options.RecordType = DnsRecordType.PTR; + } + + return true; + } + + private static bool IsIpAddressLiteral(string? value) { + if (string.IsNullOrWhiteSpace(value)) { + return false; + } + + string trimmed = value!.Trim(); + return IPAddress.TryParse(trimmed, out _); + } + + private static void AddOptionValues(ICollection target, string? rawValue) { + foreach (string value in SplitOptionValues(rawValue)) { + target.Add(value); + } + } + + private static IEnumerable SplitOptionValues(string? rawValue) { + if (string.IsNullOrWhiteSpace(rawValue)) { + yield break; + } + + string valueText = rawValue!; + string[] parts = valueText.Split(','); + foreach (string part in parts) { + string value = part.Trim(); + if (!string.IsNullOrWhiteSpace(value)) { + yield return value; + } + } + } + + private static bool FinalizeParsedLists(CliOptions options, out string? errorMessage) { + errorMessage = null; + + if (options.EndpointInputs.Count > 0) { + foreach (string endpointValue in options.EndpointInputs) { + if (!Enum.TryParse(endpointValue, true, out DnsEndpoint endpoint)) { + errorMessage = $"Invalid value for --endpoint: {endpointValue}"; + return false; + } + if (endpoint == DnsEndpoint.Custom) { + errorMessage = "--endpoint Custom is not supported in the CLI. Use explicit resolver endpoint syntax such as --probe-endpoint, --resolver-file, or --resolver-url instead."; + return false; + } + options.BenchmarkEndpoints.Add(endpoint); + } + + options.Endpoint = options.BenchmarkEndpoints[0]; + if (!options.Benchmark && options.BenchmarkEndpoints.Count > 1) { + errorMessage = "--endpoint accepts multiple values only with --benchmark."; + return false; + } + } + + if (options.RecordTypeInputs.Count > 0) { + foreach (string recordTypeValue in options.RecordTypeInputs) { + if (!Enum.TryParse(recordTypeValue, true, out DnsRecordType recordType)) { + errorMessage = $"Invalid value for --type: {recordTypeValue}"; + return false; + } + options.BenchmarkRecordTypes.Add(recordType); + } + + options.RecordType = options.BenchmarkRecordTypes[0]; + if (!options.Benchmark && options.BenchmarkRecordTypes.Count > 1) { + errorMessage = "--type accepts multiple values only with --benchmark."; + return false; + } + } + + if (options.DomainInputs.Count > 0) { + foreach (string domainValue in options.DomainInputs) { + options.BenchmarkDomains.Add(domainValue); + } + + options.Domain = options.BenchmarkDomains[0]; + if (!options.Benchmark && options.BenchmarkDomains.Count > 1) { + errorMessage = "--domain accepts multiple values only with --benchmark."; + return false; + } + } + + return true; + } + + } +} diff --git a/DnsClientX.Cli/Program.Options.cs b/DnsClientX.Cli/Program.Options.cs new file mode 100644 index 00000000..e79036e5 --- /dev/null +++ b/DnsClientX.Cli/Program.Options.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private enum QueryOutputFormat { + Pretty, + Json, + Raw + } + + private sealed class CliOptions { + public HashSet ProvidedSwitches { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public string? ProfilePath { get; set; } + public string? ProfileSavePath { get; set; } + public string? CompareBaselinePath { get; set; } + public string? CompareCurrentPath { get; set; } + public string? ReferenceResponsePath { get; set; } + public string? Domain { get; set; } + public DnsRecordType RecordType { get; set; } = DnsRecordType.A; + public bool RecordTypeSpecified { get; set; } + public DnsEndpoint Endpoint { get; set; } = DnsEndpoint.System; + public QueryOutputFormat OutputFormat { get; set; } = QueryOutputFormat.Pretty; + public bool ShortOutput { get; set; } + public bool TxtConcatOutput { get; set; } + public bool ShowQuestionSection { get; set; } + public bool ShowAnswerSection { get; set; } + public bool ShowAuthoritySection { get; set; } + public bool ShowAdditionalSection { get; set; } + public bool ReverseLookup { get; set; } + public bool ZoneTransfer { get; set; } + public bool TransferSummary { get; set; } + public bool Benchmark { get; set; } + public int BenchmarkAttempts { get; set; } = 3; + public int BenchmarkTimeoutMs { get; set; } = 2000; + public int BenchmarkConcurrency { get; set; } = 4; + public ResolverQueryConnectionMode BenchmarkConnectionMode { get; set; } + public int? BenchmarkMinSuccessPercent { get; set; } + public int? BenchmarkMinSuccessfulCandidates { get; set; } + public bool BenchmarkSummaryOnly { get; set; } + public bool BenchmarkSummaryLine { get; set; } + public List BenchmarkEndpoints { get; } = new List(); + public List BenchmarkDomains { get; } = new List(); + public List BenchmarkRecordTypes { get; } = new List(); + public List EndpointInputs { get; } = new List(); + public List DomainInputs { get; } = new List(); + public List RecordTypeInputs { get; } = new List(); + public List ProbeEndpointFiles { get; } = new List(); + public List ProbeEndpointUrls { get; } = new List(); + public bool RequestDnsSec { get; set; } + public bool ValidateDnsSec { get; set; } + public string? DnsSecVerifierPath { get; set; } + public IDnsSecSignatureVerifier? DnsSecSignatureVerifier { get; set; } + public bool WirePost { get; set; } + public DnsResolverEndpoint? BootstrapResolver { get; set; } + public bool RequestNsid { get; set; } + public bool Probe { get; set; } + public List ProbeEndpoints { get; } = new List(); + public string? ProbeSavePath { get; set; } + public string? ResolverSelectPath { get; set; } + public string? ResolverUsePath { get; set; } + public bool ProbeSummaryOnly { get; set; } + public bool ProbeSummaryLine { get; set; } + public bool ProbeRequireConsensus { get; set; } + public int? ProbeMinConsensusPercent { get; set; } + public int? ProbeMinSuccessCount { get; set; } + public int? ProbeMinSuccessPercent { get; set; } + public string? BenchmarkSavePath { get; set; } + public bool Explain { get; set; } + public bool Trace { get; set; } + public bool ShowCapabilities { get; set; } + public string? StampInfo { get; set; } + public bool ResolverValidate { get; set; } + public bool DoUpdate { get; set; } + public string? Zone { get; set; } + public string? UpdateName { get; set; } + public string? UpdateData { get; set; } + public int Ttl { get; set; } = 300; + + public bool HasExplicitSectionSelection => + ShowQuestionSection || + ShowAnswerSection || + ShowAuthoritySection || + ShowAdditionalSection; + + public bool HasCustomEndpointInputs => + ProbeEndpoints.Count > 0 || + ProbeEndpointFiles.Count > 0 || + ProbeEndpointUrls.Count > 0; + } + + } +} diff --git a/DnsClientX.Cli/Program.Output.cs b/DnsClientX.Cli/Program.Output.cs new file mode 100644 index 00000000..7eebb564 --- /dev/null +++ b/DnsClientX.Cli/Program.Output.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static void WriteQueryResponse(DnsResponse response, CliOptions options, TimeSpan elapsed) { + var presentationOptions = new DnsResponsePresentationOptions { + Mode = GetResponsePresentationMode(options), + TxtConcat = options.TxtConcatOutput, + ShowQuestions = ShouldShowQuestionSection(options, rawDefault: false), + ShowAnswers = ShouldShowAnswerSection(options, rawDefault: false), + ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: false), + ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: false) + }; + + if (presentationOptions.Mode == DnsResponsePresentationMode.Raw) { + presentationOptions.ShowQuestions = ShouldShowQuestionSection(options, rawDefault: true); + presentationOptions.ShowAnswers = ShouldShowAnswerSection(options, rawDefault: true); + presentationOptions.ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: true); + presentationOptions.ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: true); + } + + foreach (string line in DnsResponseTextFormatter.BuildOutputLines(response, presentationOptions, elapsed, FormatDuration)) { + Console.WriteLine(line); + } + } + + private static void WriteZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) { + switch (options.OutputFormat) { + case QueryOutputFormat.Json: + Console.WriteLine(DnsClientXJsonSerializer.Serialize(result)); + return; + default: + WritePrettyZoneTransferResponse(result, options); + return; + } + } + + private static void SaveResolverScoreSnapshot(string path, ResolverScoreSnapshot snapshot) { + ResolverScoreStore.Save(path, snapshot); + } + + private static void WriteResolverSelection(ResolverSelectionResult selection, CliOptions options) { + if (options.OutputFormat == QueryOutputFormat.Json) { + if (options.Explain) { + ResolverScoreSnapshot snapshot = ResolverScoreStore.Load(options.ResolverSelectPath!); + Console.WriteLine(DnsClientXJsonSerializer.Serialize(new { Selection = selection, snapshot.Summary, snapshot.Context })); + } else { + Console.WriteLine(DnsClientXJsonSerializer.Serialize(selection)); + } + return; + } + + WriteHumanLine(selection.Target); + } + + private static void WritePrettyZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) { + WriteHumanLine($"Zone Transfer: {result.Zone}"); + WriteHumanLine($" Selected authority: {result.SelectedAuthority}"); + WriteHumanLine($" Selected server: {result.SelectedServer}:{result.Port}"); + Console.WriteLine($" Chunks: {result.RecordSets.Length}"); + + if (options.TransferSummary) { + WriteHumanLine($" Authorities discovered: {string.Join(", ", result.Authorities)}"); + WriteHumanLine($" Tried servers: {string.Join(", ", result.TriedServers)}"); + } + + Console.WriteLine("Records:"); + foreach (ZoneTransferResult recordSet in result.RecordSets) { + if (recordSet.IsOpening) { + Console.WriteLine(" ;; opening SOA"); + } else if (recordSet.IsClosing) { + Console.WriteLine(" ;; closing SOA"); + } + + foreach (DnsAnswer record in recordSet.Records) { + Console.WriteLine($" {DnsTerminalText.Escape(record.Name)}\t{record.TTL}\tIN\t{record.Type}\t{DnsTerminalText.Escape(record.Data)}"); + } + } + } + + private static DnsResponsePresentationMode GetResponsePresentationMode(CliOptions options) { + if (options.ShortOutput) { + return DnsResponsePresentationMode.Short; + } + + return options.OutputFormat switch { + QueryOutputFormat.Json => DnsResponsePresentationMode.Json, + QueryOutputFormat.Raw => DnsResponsePresentationMode.Raw, + _ => DnsResponsePresentationMode.Pretty + }; + } + + private static bool ShouldShowQuestionSection(CliOptions options, bool rawDefault) { + if (options.HasExplicitSectionSelection) { + return options.ShowQuestionSection; + } + + return rawDefault; + } + + private static bool ShouldShowAnswerSection(CliOptions options, bool rawDefault) { + if (options.HasExplicitSectionSelection) { + return options.ShowAnswerSection; + } + + return !rawDefault || options.OutputFormat == QueryOutputFormat.Raw; + } + + private static bool ShouldShowAuthoritySection(CliOptions options, bool rawDefault) { + if (options.HasExplicitSectionSelection) { + return options.ShowAuthoritySection; + } + + return rawDefault; + } + + private static bool ShouldShowAdditionalSection(CliOptions options, bool rawDefault) { + if (options.HasExplicitSectionSelection) { + return options.ShowAdditionalSection; + } + + return rawDefault; + } + + private static void WriteExplain( + ResolverSingleOperationResult result, + string operation, + string target, + DnsRecordType recordType, + bool requestDnsSec, + bool validateDnsSec, + bool trace, + string? zone = null, + int? ttl = null) { + foreach (string line in ResolverReportTextFormatter.BuildSingleOperationExplainLines( + result, + operation, + target, + recordType, + requestDnsSec, + validateDnsSec, + FormatDuration, + zone, + ttl)) { + Console.WriteLine(line); + } + + if (trace) { + WriteTrace(result); + } + } + + private static void WriteTrace(ResolverSingleOperationResult result) { + foreach (string line in ResolverReportTextFormatter.BuildSingleOperationTraceLines(result, FormatDuration)) { + Console.WriteLine(line); + } + } + + private static string FormatDuration(TimeSpan duration) { + if (duration <= TimeSpan.Zero) { + return "0 ms"; + } + + if (duration.TotalMilliseconds < 1000) { + return $"{duration.TotalMilliseconds:F0} ms"; + } + + return $"{duration.TotalSeconds:F3} s"; + } + + private static void WriteHumanLine(string value) => Console.WriteLine(DnsTerminalText.Escape(value)); + + private static void WriteHumanError(string? value) => Console.Error.WriteLine(DnsTerminalText.Escape(value)); + + } +} diff --git a/DnsClientX.Cli/Program.ProfileOperations.cs b/DnsClientX.Cli/Program.ProfileOperations.cs new file mode 100644 index 00000000..ae5c9411 --- /dev/null +++ b/DnsClientX.Cli/Program.ProfileOperations.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static bool ValidateOperationalMode(CliOptions options, out string? error) { + error = null; + bool profile = options.ProfilePath != null; + bool comparison = options.CompareBaselinePath != null; + if (!profile && !comparison) { + if (options.ProfileSavePath != null || options.ReferenceResponsePath != null) { error = "--profile-save requires --profile; --reference-response requires --resolver-compare."; return false; } + return true; + } + var allowed = new HashSet(profile + ? new[] { "--profile", "--profile-save", "--benchmark", "--format", "--dnssec-verifier" } + : new[] { "--resolver-compare", "--reference-response", "--format" }, StringComparer.OrdinalIgnoreCase); + if (profile && comparison || options.Domain != null || options.OutputFormat == QueryOutputFormat.Raw || options.ProvidedSwitches.Any(s => !allowed.Contains(s))) { + error = "Profile/comparison modes support pretty or JSON output; profile run settings come from the profile. Use --profile path [--benchmark] [--profile-save report.json], or --resolver-compare baseline.json current.json [--reference-response references.json]."; + return false; + } + if (string.IsNullOrWhiteSpace(options.ProfilePath) && profile || comparison && (string.IsNullOrWhiteSpace(options.CompareBaselinePath) || string.IsNullOrWhiteSpace(options.CompareCurrentPath))) { error = "A nonblank profile or snapshot path is required."; return false; } + return true; + } + + private static async Task RunProfileAsync(CliOptions options, CancellationToken token) { + ResolverHealthProfile profile = ResolverHealthProfileStore.Load(options.ProfilePath!); + IDnsSecSignatureVerifier? verifier = string.IsNullOrWhiteSpace(options.DnsSecVerifierPath) ? null : DnsSecSignatureVerifierLoader.Load(options.DnsSecVerifierPath!); + ResolverHealthReport report = await ResolverHealthWorkflow.RunAsync(profile, options.Benchmark, verifier, CreateBuiltInOverride(), CreateExplicitOverride(), token).ConfigureAwait(false); + string json = DnsClientXJsonSerializer.Serialize(report); + if (options.ProfileSavePath != null) { + File.WriteAllText(Path.GetFullPath(options.ProfileSavePath), json); + } + if (options.OutputFormat == QueryOutputFormat.Json) { + Console.WriteLine(json); + } else { + WriteHumanLine($"Health profile: {report.ProfileName}"); + foreach (ResolverProbeReport query in report.Queries) { + WriteHumanLine($" {query.Summary.Name} {query.Summary.RecordType}: {query.Summary.SuccessfulCandidates}/{query.Summary.CandidateCount} successes; policy {(query.Summary.PolicyPassed ? "pass" : "fail")}"); + foreach (ResolverProbeReportResult result in query.Results) { + WriteProbeResult(result); + } + } + if (report.Benchmark != null) { + foreach (ResolverBenchmarkReportResult result in report.Benchmark.Results) { + WriteBenchmarkResult(result); + } + } + WriteHumanLine($"Policy result: {(report.PolicyPassed ? "pass" : "fail")}"); + WriteHumanLine(report.AnswerVariationNote); + } + return report.PolicyPassed ? 0 : 3; + } + + private static int RunSnapshotComparison(CliOptions options) { + ResolverScoreSnapshot baseline = ResolverScoreStore.Load(options.CompareBaselinePath!); + ResolverScoreSnapshot current = ResolverScoreStore.Load(options.CompareCurrentPath!); + ResolverAnswerReference[]? references = options.ReferenceResponsePath == null ? null : + DnsClientXJsonSerializer.Deserialize(File.ReadAllText(Path.GetFullPath(options.ReferenceResponsePath))); + ResolverScoreComparison comparison = ResolverScoreComparer.Compare(baseline, current, references); + if (options.OutputFormat == QueryOutputFormat.Json) { + Console.WriteLine(DnsClientXJsonSerializer.Serialize(comparison)); + } else { + WriteHumanLine($"Comparable scope: {comparison.ScopeComparable}; health regression: {comparison.HealthRegression}"); + foreach (string reason in comparison.IncomparableReasons) { + WriteHumanLine($" {reason}"); + } + foreach (ResolverScoreChange change in comparison.Candidates) { + WriteHumanLine($" {change.Target}: {change.Change}; success delta {change.SuccessPercentDelta?.ToString() ?? "unavailable"} percentage points; latency delta {change.AverageMsDelta?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unavailable"} ms"); + } + foreach (ResolverAnswerChange answer in comparison.Answers) { + WriteHumanLine($" {answer.Target} {answer.Name} {answer.RecordType}: answers {(!answer.Changed.HasValue ? "unavailable" : answer.Changed.Value ? "changed" : "unchanged")}; reference {answer.ReferenceComparison}"); + } + WriteHumanLine(comparison.AnswerVariationNote); + } + return comparison.HealthRegression ? 3 : 0; + } + } +} diff --git a/DnsClientX.Cli/Program.Query.cs b/DnsClientX.Cli/Program.Query.cs new file mode 100644 index 00000000..c420416d --- /dev/null +++ b/DnsClientX.Cli/Program.Query.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static ResolverExecutionClientOptions CreateExecutionClientOptions(CliOptions options, bool useBenchmarkTimeout = false) { + if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath)) { + options.DnsSecSignatureVerifier ??= DnsSecSignatureVerifierLoader.Load(options.DnsSecVerifierPath!); + } + string? envPort = Environment.GetEnvironmentVariable("DNSCLIENTX_CLI_PORT"); + int? customPort = int.TryParse(envPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedPort) && parsedPort > 0 + ? parsedPort + : null; + + return new ResolverExecutionClientOptions { + EnableAudit = options.Explain || options.Trace, + TimeoutMs = useBenchmarkTimeout ? options.BenchmarkTimeoutMs : null, + PortOverride = customPort, + ForceDohWirePost = options.WirePost, + BootstrapResolver = options.BootstrapResolver, + DnsSecSignatureVerifier = options.DnsSecSignatureVerifier, + RequestNsid = options.RequestNsid + }; + } + + private static async Task RunZoneTransferAsync(CliOptions options, CancellationToken cancellationToken) { + ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options); + RecursiveZoneTransferResult result = await ResolverZoneTransferWorkflow.RunRecursiveAsync( + new ResolverExecutionTargetSource { + BuiltInEndpoints = new[] { options.Endpoint } + }, + options.Domain!, + port: clientOptions.PortOverride ?? 53, + clientOptions: clientOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + WriteZoneTransferResponse(result, options); + return 0; + } + + private static async Task RunStandardQueryAsync(CliOptions options, CancellationToken cancellationToken) { + ResolverExecutionTargetSource targetSource = CreateStandardQueryTargetSource(options); + ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options); + ResolverSingleOperationResult result; + DnsResponse response; + if (options.DoUpdate) { + result = await ResolverSingleOperationWorkflow.UpdateAsync( + targetSource, + options.Zone!, + options.UpdateName!, + options.RecordType, + options.UpdateData!, + options.Ttl, + clientOptions, + cancellationToken).ConfigureAwait(false); + response = result.Response; + + Console.WriteLine($"Update status: {response.Status} (retries {response.RetryCount})"); + if (options.Explain || options.Trace) { + WriteExplain( + result, + operation: "update", + target: options.UpdateName!, + recordType: options.RecordType, + requestDnsSec: false, + validateDnsSec: false, + trace: options.Trace, + zone: options.Zone, + ttl: options.Ttl); + } + } else { + result = await ResolverSingleOperationWorkflow.QueryAsync( + targetSource, + options.Domain!, + options.RecordType, + options.RequestDnsSec, + options.ValidateDnsSec, + clientOptions, + cancellationToken).ConfigureAwait(false); + response = result.Response; + + if (options.OutputFormat == QueryOutputFormat.Json && options.Explain) { + Console.WriteLine(DnsClientXJsonSerializer.Serialize(new { + Response = response, + Explanation = DnsQueryExplanation.Create(response, result.AuditTrail), + result.SelectionStrategy, result.RequestFormat, result.CacheEnabled, + result.ConfiguredResolverHost, result.ConfiguredResolverPort, + ElapsedMs = result.Elapsed.TotalMilliseconds + })); + } else { + WriteQueryResponse(response, options, result.Elapsed); + } + + if ((options.Explain || options.Trace) && options.OutputFormat != QueryOutputFormat.Json) { + WriteExplain( + result, + operation: "query", + target: options.Domain!, + recordType: options.RecordType, + requestDnsSec: options.RequestDnsSec, + validateDnsSec: options.ValidateDnsSec, + trace: options.Trace); + } + } + + if (!string.IsNullOrWhiteSpace(response.Error)) { + WriteHumanError(response.Error); + return 1; + } + return 0; + } + + private static ResolverExecutionTargetSource CreateStandardQueryTargetSource(CliOptions options) { + if (options.HasCustomEndpointInputs) { + return new ResolverExecutionTargetSource { + ResolverEndpoints = options.ProbeEndpoints.ToArray(), + ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(), + ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray() + }; + } + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { + return new ResolverExecutionTargetSource { + ResolverSelectionPath = options.ResolverUsePath + }; + } + + return new ResolverExecutionTargetSource { + BuiltInEndpoints = new[] { options.Endpoint } + }; + } + + private static Task RunResolverSelectionAsync(CliOptions options, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + + ResolverSelectionResult selection = LoadRecommendedResolverSelection(options.ResolverSelectPath!); + + WriteResolverSelection(selection, options); + return Task.FromResult(0); + } + + private static ResolverSelectionResult LoadRecommendedResolverSelection(string path) { + return ResolverExecutionTargetResolver.LoadRecommendedSelection(path); + } + + private static int WriteCapabilities(CliOptions options) { + DnsTransportCapabilityInfo[] capabilities = DnsTransportCapabilities.GetCapabilityReport(); + if (options.OutputFormat == QueryOutputFormat.Json) { + Console.WriteLine(DnsClientXJsonSerializer.Serialize(capabilities)); + return 0; + } + + foreach (string line in DnsTransportCapabilityTextFormatter.BuildLines(capabilities)) { + WriteHumanLine(line); + } + + return 0; + } + + private static int WriteStampInfo(CliOptions options) { + DnsStampInfo info = DnsStamp.Describe(options.StampInfo!); + if (options.OutputFormat == QueryOutputFormat.Json) { + Console.WriteLine(DnsClientXJsonSerializer.Serialize(info)); + return 0; + } + + Console.WriteLine("DNS Stamp:"); + Console.WriteLine($" Transport: {info.Transport}"); + Console.WriteLine($" Request format: {info.RequestFormat}"); + WriteHumanLine($" Host: {info.Host}"); + Console.WriteLine($" Port: {info.Port}"); + if (info.DohUrl != null) { + WriteHumanLine($" DoH URL: {info.DohUrl}"); + } + Console.WriteLine($" DNSSEC property: {(info.DnsSecOk ? "yes" : "no")}"); + WriteHumanLine($" Endpoint: {info.Endpoint}"); + WriteHumanLine($" Normalized stamp: {info.NormalizedStamp}"); + return 0; + } + + private static async Task RunResolverValidationAsync(CliOptions options, CancellationToken cancellationToken) { + ResolverEndpointValidationResult[] results = await EndpointParser.ValidateManyAsync( + options.ProbeEndpoints, + options.ProbeEndpointFiles, + options.ProbeEndpointUrls, + cancellationToken).ConfigureAwait(false); + + if (options.OutputFormat == QueryOutputFormat.Json) { + Console.WriteLine(DnsClientXJsonSerializer.Serialize(results)); + } else { + WriteResolverValidationResults(results); + } + + return results.Any(result => !result.IsValid) ? 1 : 0; + } + + private static void WriteResolverValidationResults(ResolverEndpointValidationResult[] results) { + int valid = results.Count(result => result.IsValid); + int invalid = results.Length - valid; + + Console.WriteLine("Resolver Validation:"); + Console.WriteLine($" Entries: {results.Length}"); + Console.WriteLine($" Valid: {valid}"); + Console.WriteLine($" Invalid: {invalid}"); + + foreach (ResolverEndpointValidationResult result in results) { + string location = string.IsNullOrWhiteSpace(result.Source) + ? "unknown" + : result.LineNumber.HasValue + ? $"{result.Source}:{result.LineNumber.Value}" + : result.Source; + + if (result.IsValid) { + WriteHumanLine($" valid {location} {result.Entry} -> {result.Endpoint}"); + } else { + WriteHumanLine($" invalid {location} {result.Entry} ({result.Error})"); + } + } + } + + } +} diff --git a/DnsClientX.Cli/Program.ResolverOperations.cs b/DnsClientX.Cli/Program.ResolverOperations.cs new file mode 100644 index 00000000..67021b55 --- /dev/null +++ b/DnsClientX.Cli/Program.ResolverOperations.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static async Task RunBenchmarkAsync(CliOptions options, CancellationToken cancellationToken) { + string[] domains = options.BenchmarkDomains + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + DnsRecordType[] recordTypes = options.BenchmarkRecordTypes + .Distinct() + .ToArray(); + ResolverExecutionTargetSource targetSource = CreateBenchmarkTargetSource(options); + ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false); + if (options.HasCustomEndpointInputs && targets.Length == 0) { + Console.Error.WriteLine("No custom resolver endpoints were supplied."); + return 1; + } + + return await RunBenchmarkWithTargetsAsync( + options, + targets, + domains, + recordTypes, + GetBenchmarkEndpointProfile(options), + cancellationToken).ConfigureAwait(false); + } + + private static async Task RunBenchmarkWithTargetsAsync( + CliOptions options, + ResolverExecutionTarget[] targets, + string[] domains, + DnsRecordType[] recordTypes, + string? endpointProfile, + CancellationToken cancellationToken) { + if (options.OutputFormat == QueryOutputFormat.Json) { + ResolverBenchmarkReport jsonReport = await ExecuteBenchmarkReportAsync(targets, domains, recordTypes, options, cancellationToken).ConfigureAwait(false); + if (options.BenchmarkSavePath != null) { + SaveResolverScoreSnapshot(options.BenchmarkSavePath, jsonReport.Snapshot); + } + Console.WriteLine(DnsClientXJsonSerializer.Serialize(jsonReport)); + return GetBenchmarkExitCode(jsonReport.Evaluation, options); + } + Console.WriteLine("Benchmark:"); + WriteHumanLine($" Domains: {string.Join(", ", domains)}"); + Console.WriteLine($" Types: {string.Join(", ", recordTypes)}"); + Console.WriteLine($" Attempts per combination: {options.BenchmarkAttempts}"); + Console.WriteLine($" Timeout (ms): {options.BenchmarkTimeoutMs}"); + Console.WriteLine($" Concurrency: {options.BenchmarkConcurrency}"); + Console.WriteLine($" Connections: {options.BenchmarkConnectionMode}"); + Console.WriteLine($" Detail mode: {(options.BenchmarkSummaryOnly ? "summary-only" : "full")}"); + if (!string.IsNullOrWhiteSpace(endpointProfile)) { + WriteHumanLine($" Endpoint profile: {endpointProfile}"); + } + + Console.WriteLine($" Candidates: {targets.Length}"); + Console.WriteLine($" Queries per candidate: {domains.Length * recordTypes.Length * options.BenchmarkAttempts}"); + + ResolverBenchmarkReport report = await ExecuteBenchmarkReportAsync( + targets, + domains, + recordTypes, + options, + cancellationToken).ConfigureAwait(false); + if (!options.BenchmarkSummaryOnly) { + foreach (ResolverBenchmarkReportResult result in report.Results) { + WriteBenchmarkResult(result); + } + } + + return WriteBenchmarkSummary(report, options); + } + + private static void WriteBenchmarkResult(ResolverBenchmarkReportResult result) { + foreach (string line in ResolverReportTextFormatter.BuildBenchmarkResultLines(result, FormatDuration)) { + Console.WriteLine(line); + } + } + + private static int WriteBenchmarkSummary(ResolverBenchmarkReport report, CliOptions options) { + ResolverBenchmarkEvaluation evaluation = report.Evaluation; + + Console.WriteLine("Benchmark Summary:"); + Console.WriteLine($" Successful candidates: {report.Summary.SuccessfulCandidates}/{report.Summary.CandidateCount}"); + Console.WriteLine($" Successful queries: {report.Summary.OverallSuccessCount}/{report.Summary.OverallQueryCount}"); + Console.WriteLine($" Successful query rate: {report.Summary.OverallSuccessPercent}%"); + Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeBenchmarkRuntimeCapabilities(report.Summary)}"); + for (int i = 0; i < report.Results.Length; i++) { + Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkRankedLine(report.Results[i], i + 1, FormatDuration)); + } + Console.WriteLine($" Best endpoint: {ResolverReportTextFormatter.DescribeBenchmarkBest(report.Results, report.Summary, FormatDuration)}"); + WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}"); + + int exitCode = GetBenchmarkExitCode(evaluation, options); + if (options.BenchmarkSummaryLine) { + Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkSummaryLine(report.Summary, exitCode)); + } + + if (!string.IsNullOrWhiteSpace(options.BenchmarkSavePath)) { + SaveResolverScoreSnapshot(options.BenchmarkSavePath!, report.Snapshot); + } + + return exitCode; + } + + private static ResolverBenchmarkPolicy CreateBenchmarkPolicy(CliOptions options) { + return new ResolverBenchmarkPolicy { + MinSuccessPercent = options.BenchmarkMinSuccessPercent, + MinSuccessfulCandidates = options.BenchmarkMinSuccessfulCandidates + }; + } + + private static int GetBenchmarkExitCode(ResolverBenchmarkEvaluation evaluation, CliOptions options) { + if (evaluation.SuccessfulCandidates == 0) { + return 1; + } + + if (options.BenchmarkMinSuccessfulCandidates.HasValue && evaluation.SuccessfulCandidates < options.BenchmarkMinSuccessfulCandidates.Value) { + return 3; + } + + if (options.BenchmarkMinSuccessPercent.HasValue && evaluation.OverallSuccessPercent < options.BenchmarkMinSuccessPercent.Value) { + return 3; + } + + return evaluation.PolicyPassed ? 0 : 1; + } + + private static async Task RunProbeAsync(CliOptions options, CancellationToken cancellationToken) { + string domain = options.Domain!; + ResolverExecutionTargetSource targetSource = CreateProbeTargetSource(options); + ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false); + if (options.HasCustomEndpointInputs && targets.Length == 0) { + Console.Error.WriteLine("No custom resolver endpoints were supplied."); + return 1; + } + + return await RunProbeWithTargetsAsync( + options, + targets, + domain, + GetProbeEndpointProfile(options), + cancellationToken).ConfigureAwait(false); + } + + private static async Task RunProbeWithTargetsAsync( + CliOptions options, + ResolverExecutionTarget[] targets, + string domain, + string endpointProfile, + CancellationToken cancellationToken) { + if (options.OutputFormat == QueryOutputFormat.Json) { + ResolverProbeReport jsonReport = await ExecuteProbeReportAsync(targets, domain, options, cancellationToken).ConfigureAwait(false); + if (options.ProbeSavePath != null) { + SaveResolverScoreSnapshot(options.ProbeSavePath, jsonReport.Snapshot); + } + Console.WriteLine(DnsClientXJsonSerializer.Serialize(jsonReport)); + return GetProbeExitCode(jsonReport.Evaluation, options); + } + Console.WriteLine("Probe:"); + WriteHumanLine($" Domain: {domain}"); + WriteHumanLine($" Endpoint profile: {endpointProfile}"); + Console.WriteLine($" Candidates: {targets.Length}"); + Console.WriteLine($" Detail mode: {(options.ProbeSummaryOnly ? "summary-only" : "full")}"); + + ResolverProbeReport report = await ExecuteProbeReportAsync(targets, domain, options, cancellationToken).ConfigureAwait(false); + if (!options.ProbeSummaryOnly) { + foreach (ResolverProbeReportResult result in report.Results) { + WriteProbeResult(result); + } + } + + return WriteProbeSummary(report, options); + } + + private static ResolverQueryRunOptions CreateQueryRunOptions(CliOptions options) { + ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options, useBenchmarkTimeout: options.Benchmark); + return new ResolverQueryRunOptions { + TimeoutMs = options.Benchmark ? options.BenchmarkTimeoutMs : Configuration.DefaultTimeout, + ConnectionMode = options.BenchmarkConnectionMode, + RequestDnsSec = options.RequestDnsSec, + ValidateDnsSec = options.ValidateDnsSec, + MaxRetries = 1, + RetryDelayMs = 0, + PortOverride = clientOptions.PortOverride, + ForceDohWirePost = clientOptions.ForceDohWirePost, + BootstrapResolver = clientOptions.BootstrapResolver, + DnsSecSignatureVerifier = clientOptions.DnsSecSignatureVerifier, + RequestNsid = clientOptions.RequestNsid + }; + } + + private static string[] GetEffectiveBenchmarkDomains(CliOptions options) { + if (options.BenchmarkDomains.Count > 0) { + return options.BenchmarkDomains.ToArray(); + } + + return string.IsNullOrWhiteSpace(options.Domain) + ? Array.Empty() + : new[] { options.Domain! }; + } + + private static Task ExecuteBenchmarkReportAsync( + ResolverExecutionTarget[] targets, + string[] domains, + DnsRecordType[] recordTypes, + CliOptions options, + CancellationToken cancellationToken) { + return ResolverBenchmarkWorkflow.RunAsync( + targets, + domains, + recordTypes, + options.BenchmarkAttempts, + options.BenchmarkConcurrency, + options.BenchmarkTimeoutMs, + CreateQueryRunOptions(options), + CreateBenchmarkPolicy(options), + builtInOverride: CreateBuiltInOverride(), + explicitOverride: CreateExplicitOverride(), + cancellationToken: cancellationToken); + } + + private static ResolverExecutionTargetSource CreateBenchmarkTargetSource(CliOptions options) { + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { + return new ResolverExecutionTargetSource { + ResolverSelectionPath = options.ResolverUsePath + }; + } + + if (options.HasCustomEndpointInputs) { + return new ResolverExecutionTargetSource { + ResolverEndpoints = options.ProbeEndpoints.ToArray(), + ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(), + ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray() + }; + } + + return new ResolverExecutionTargetSource { + BuiltInEndpoints = options.BenchmarkEndpoints.Distinct().ToArray() + }; + } + + private static Task ExecuteProbeReportAsync( + ResolverExecutionTarget[] targets, + string domain, + CliOptions options, + CancellationToken cancellationToken) { + return ResolverProbeWorkflow.RunAsync( + targets, + domain, + options.RecordType, + Configuration.DefaultTimeout, + CreateQueryRunOptions(options), + CreateProbePolicy(options), + builtInOverride: CreateBuiltInOverride(), + explicitOverride: CreateExplicitOverride(), + cancellationToken: cancellationToken); + } + + private static ResolverExecutionTargetSource CreateProbeTargetSource(CliOptions options) { + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { + return new ResolverExecutionTargetSource { + ResolverSelectionPath = options.ResolverUsePath + }; + } + + if (options.HasCustomEndpointInputs) { + return new ResolverExecutionTargetSource { + ResolverEndpoints = options.ProbeEndpoints.ToArray(), + ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(), + ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray() + }; + } + + return new ResolverExecutionTargetSource { + ProbeProfile = options.Endpoint + }; + } + + private static string? GetBenchmarkEndpointProfile(CliOptions options) { + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { + return "selected"; + } + + return options.HasCustomEndpointInputs ? "custom" : null; + } + + private static string GetProbeEndpointProfile(CliOptions options) { + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { + return "selected"; + } + + return options.HasCustomEndpointInputs ? "custom" : options.Endpoint.ToString(); + } + + private static Func>? CreateBuiltInOverride() { + if (ProbeOverride == null) { + return null; + } + + return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult( + endpoint.ToString(), + await ProbeOverride(endpoint, name, cancellationToken).ConfigureAwait(false)); + } + + private static Func>? CreateExplicitOverride() { + if (ProbeEndpointOverride == null) { + return null; + } + + return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult( + ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint), + await ProbeEndpointOverride(endpoint, name, cancellationToken).ConfigureAwait(false)); + } + + private static ResolverQueryAttemptResult CreateOverrideAttemptResult( + string target, + (DnsResponse Response, TimeSpan Elapsed, string Resolver, DnsRequestFormat RequestFormat) result) { + return new ResolverQueryAttemptResult { + Target = target, + RequestFormat = result.RequestFormat, + Resolver = result.Resolver, + Response = result.Response, + Elapsed = result.Elapsed + }; + } + + private static void WriteProbeResult(ResolverProbeReportResult result) { + foreach (string line in ResolverReportTextFormatter.BuildProbeResultLines(result, FormatDuration)) { + Console.WriteLine(line); + } + } + + private static int WriteProbeSummary(ResolverProbeReport report, CliOptions options) { + ResolverProbeEvaluation evaluation = report.Evaluation; + ResolverProbeReportSummary summary = report.Summary; + int successCount = summary.SuccessfulCandidates; + Console.WriteLine("Probe Summary:"); + Console.WriteLine($" Successful probes: {successCount}/{summary.CandidateCount}"); + Console.WriteLine($" Failed probes: {summary.FailedCandidates}"); + Console.WriteLine($" Fastest success: {ResolverReportTextFormatter.DescribeProbeFastestSuccess(summary, FormatDuration)}"); + Console.WriteLine($" Fastest consensus responder: {ResolverReportTextFormatter.DescribeProbeFastestConsensus(summary, FormatDuration)}"); + Console.WriteLine($" Transport coverage: {ResolverReportTextFormatter.DescribeProbeTransportCoverage(summary)}"); + Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeProbeRuntimeCapabilities(summary)}"); + Console.WriteLine($" Answer consensus: {ResolverReportTextFormatter.DescribeProbeAnswerConsensus(summary)}"); + Console.WriteLine($" Mismatched responders: {ResolverReportTextFormatter.DescribeProbeMismatchedTargets(summary)}"); + Console.WriteLine($" Distinct answer sets: {summary.DistinctAnswerSets}"); + Console.WriteLine($" Answer variants: {ResolverReportTextFormatter.DescribeProbeAnswerVariants(summary)}"); + Console.WriteLine($" Recommended endpoint: {ResolverReportTextFormatter.DescribeProbeRecommended(summary, FormatDuration)}"); + WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}"); + + int exitCode = GetProbeExitCode(evaluation, options); + if (options.ProbeSummaryLine) { + Console.WriteLine(ResolverReportTextFormatter.BuildProbeSummaryLine(summary, exitCode)); + } + + if (!string.IsNullOrWhiteSpace(options.ProbeSavePath)) { + SaveResolverScoreSnapshot(options.ProbeSavePath!, report.Snapshot); + } + + return exitCode; + } + + private static ResolverProbePolicy CreateProbePolicy(CliOptions options) { + return new ResolverProbePolicy { + MinSuccessCount = options.ProbeMinSuccessCount, + MinSuccessPercent = options.ProbeMinSuccessPercent, + MinConsensusPercent = options.ProbeMinConsensusPercent, + RequireConsensus = options.ProbeRequireConsensus + }; + } + + private static int GetProbeExitCode(ResolverProbeEvaluation evaluation, CliOptions options) { + if (evaluation.SuccessfulCandidates == 0) { + return 1; + } + + if (options.ProbeMinSuccessCount.HasValue && evaluation.SuccessfulCandidates < options.ProbeMinSuccessCount.Value) { + return 3; + } + + if (options.ProbeMinSuccessPercent.HasValue && evaluation.SuccessPercent < options.ProbeMinSuccessPercent.Value) { + return 3; + } + + if (options.ProbeRequireConsensus && evaluation.DistinctAnswerSets > 1) { + return 2; + } + + if (options.ProbeMinConsensusPercent.HasValue && evaluation.ConsensusPercent < options.ProbeMinConsensusPercent.Value) { + return 2; + } + + return evaluation.PolicyPassed ? 0 : 1; + } + + } +} diff --git a/DnsClientX.Cli/Program.Validation.cs b/DnsClientX.Cli/Program.Validation.cs new file mode 100644 index 00000000..b739a1ac --- /dev/null +++ b/DnsClientX.Cli/Program.Validation.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Cli { + internal static partial class Program { + private static bool ValidateParsedOptions(CliOptions options, out string? errorMessage) { + errorMessage = null; + if (!ValidateOperationalMode(options, out errorMessage)) { + return false; + } + if (options.ProfilePath != null || options.CompareBaselinePath != null) { + return true; + } + if (options.HasCustomEndpointInputs && options.EndpointInputs.Count > 0) { + errorMessage = "Use either --endpoint or explicit resolver inputs."; + return false; + } + + if (options.ShowCapabilities && + (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer || + !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) || + !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate)) { + errorMessage = "--capabilities cannot be combined with query, probe, benchmark, update, axfr, resolver selection, or stamp modes."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath) && + (options.ShowCapabilities || !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate || + !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.ZoneTransfer || options.DoUpdate)) { + errorMessage = "--dnssec-verifier applies only to query, probe, and benchmark modes."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.StampInfo) && + (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer || + !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) || + options.ResolverValidate)) { + errorMessage = "--stamp-info cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes."; + return false; + } + + if (options.ResolverValidate && + (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer || + !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath))) { + errorMessage = "--resolver-validate cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes."; + return false; + } + + if (options.DoUpdate && (options.Probe || options.Benchmark || options.ZoneTransfer)) { + errorMessage = options.Benchmark + ? "--benchmark cannot be combined with --update." + : options.Probe + ? "--probe cannot be combined with --update." + : "--axfr cannot be combined with --update."; + return false; + } + + if (options.Probe && options.Benchmark) { + errorMessage = "--probe cannot be combined with --benchmark."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) && + (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer)) { + errorMessage = "--resolver-select cannot be combined with probe, benchmark, update, or axfr modes."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) && + (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.DoUpdate || options.ZoneTransfer)) { + errorMessage = "--resolver-use cannot be combined with --resolver-select, update, or axfr modes."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) && + (options.EndpointInputs.Count > 0 || options.HasCustomEndpointInputs)) { + errorMessage = "--resolver-use cannot be combined with --endpoint, --probe-endpoint, --resolver-file, or --resolver-url."; + return false; + } + + if (options.ZoneTransfer && (options.Probe || options.Benchmark)) { + errorMessage = options.Probe + ? "--axfr cannot be combined with --probe." + : "--axfr cannot be combined with --benchmark."; + return false; + } + + if (options.ZoneTransfer && options.ReverseLookup) { + errorMessage = "--reverse cannot be combined with --axfr."; + return false; + } + + if (!ApplyReverseLookupDefaults(options, out errorMessage)) { + return false; + } + + if (options.ShortOutput && options.OutputFormat != QueryOutputFormat.Pretty) { + errorMessage = "--short cannot be combined with --format json or --format raw."; + return false; + } + + if (options.TxtConcatOutput && options.OutputFormat == QueryOutputFormat.Json) { + errorMessage = "--txt-concat cannot be combined with --format json."; + return false; + } + + if (options.ShowCapabilities && + (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) { + errorMessage = "Capability mode supports only default output or --format json."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.StampInfo) && + (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw || + options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost || + !string.IsNullOrWhiteSpace(options.Domain))) { + errorMessage = "Stamp mode supports only --stamp-info and optional --format json."; + return false; + } + + if (options.ResolverValidate && + (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw || + options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost || + !string.IsNullOrWhiteSpace(options.Domain) || !options.HasCustomEndpointInputs)) { + errorMessage = "Resolver validation mode requires --resolver-validate with --probe-endpoint, --resolver-file, or --resolver-url, and supports optional --format json."; + return false; + } + + if ((options.Benchmark || options.Probe || options.DoUpdate) && + (options.ShortOutput || options.TxtConcatOutput || options.OutputFormat == QueryOutputFormat.Raw || options.DoUpdate && options.OutputFormat == QueryOutputFormat.Json || options.HasExplicitSectionSelection || options.TransferSummary)) { + errorMessage = "Probe and benchmark support pretty or JSON reports. Query section, raw, short, and TXT switches apply only to queries; updates use pretty output."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) && + (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) { + errorMessage = "Resolver selection mode supports only default output or --format json."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) && + (options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw && (options.Probe || options.Benchmark))) { + errorMessage = "Resolver reuse mode does not change the output rules of probe or benchmark workflows."; + return false; + } + + if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) && + (options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost)) { + errorMessage = "Query switches do not apply to --resolver-select."; + return false; + } + + if (options.ZoneTransfer && + (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.OutputFormat == QueryOutputFormat.Raw)) { + errorMessage = "AXFR mode supports only --format pretty|json and optional --transfer-summary."; + return false; + } + + if (options.TransferSummary && !options.ZoneTransfer) { + errorMessage = "--transfer-summary applies only to --axfr mode."; + return false; + } + + if (options.ZoneTransfer && options.RecordTypeSpecified && options.RecordType != DnsRecordType.AXFR) { + errorMessage = "--type can be used with --axfr only when set to AXFR."; + return false; + } + + if (options.DoUpdate) { + if (!options.HasCustomEndpointInputs || options.EndpointInputs.Count > 0) { + errorMessage = "--update requires an explicit authoritative endpoint via --probe-endpoint, --resolver-file, or --resolver-url; do not combine it with --endpoint."; + return false; + } + if (options.Zone is null || options.UpdateName is null || options.UpdateData is null) { + errorMessage = "Invalid --update arguments."; + return false; + } + } else if (options.ShowCapabilities) { + // Capability mode does not require a domain. + } else if (!string.IsNullOrWhiteSpace(options.StampInfo)) { + // Stamp mode requires only the stamp input. + } else if (options.ResolverValidate) { + // Resolver validation mode requires only resolver inputs. + } else if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath)) { + // Selection mode requires only the saved snapshot path. + } else if (options.ZoneTransfer && string.IsNullOrWhiteSpace(options.Domain)) { + errorMessage = "Zone name is required."; + return false; + } else if (!options.Probe && !options.Benchmark && string.IsNullOrWhiteSpace(options.Domain)) { + errorMessage = "Domain name is required."; + return false; + } else if (options.Probe && string.IsNullOrWhiteSpace(options.Domain)) { + options.Domain = "example.com"; + } else if (options.Benchmark) { + if (options.BenchmarkDomains.Count == 0) { + options.BenchmarkDomains.Add(!string.IsNullOrWhiteSpace(options.Domain) ? options.Domain! : "example.com"); + } + if (options.BenchmarkEndpoints.Count == 0 && !options.HasCustomEndpointInputs) { + options.BenchmarkEndpoints.Add(options.Endpoint); + } + if (options.BenchmarkRecordTypes.Count == 0) { + options.BenchmarkRecordTypes.Add(options.RecordType); + } + } + + return true; + } + + } +} diff --git a/DnsClientX.Cli/Program.cs b/DnsClientX.Cli/Program.cs index d29955d6..e1a81129 100644 --- a/DnsClientX.Cli/Program.cs +++ b/DnsClientX.Cli/Program.cs @@ -8,87 +8,7 @@ using System.Threading.Tasks; namespace DnsClientX.Cli { - internal static class Program { - private enum QueryOutputFormat { - Pretty, - Json, - Raw - } - - private sealed class CliOptions { - public string? Domain { get; set; } - public DnsRecordType RecordType { get; set; } = DnsRecordType.A; - public bool RecordTypeSpecified { get; set; } - public DnsEndpoint Endpoint { get; set; } = DnsEndpoint.System; - public QueryOutputFormat OutputFormat { get; set; } = QueryOutputFormat.Pretty; - public bool ShortOutput { get; set; } - public bool TxtConcatOutput { get; set; } - public bool ShowQuestionSection { get; set; } - public bool ShowAnswerSection { get; set; } - public bool ShowAuthoritySection { get; set; } - public bool ShowAdditionalSection { get; set; } - public bool ReverseLookup { get; set; } - public bool ZoneTransfer { get; set; } - public bool TransferSummary { get; set; } - public bool Benchmark { get; set; } - public int BenchmarkAttempts { get; set; } = 3; - public int BenchmarkTimeoutMs { get; set; } = 2000; - public int BenchmarkConcurrency { get; set; } = 4; - public ResolverQueryConnectionMode BenchmarkConnectionMode { get; set; } - public int? BenchmarkMinSuccessPercent { get; set; } - public int? BenchmarkMinSuccessfulCandidates { get; set; } - public bool BenchmarkSummaryOnly { get; set; } - public bool BenchmarkSummaryLine { get; set; } - public List BenchmarkEndpoints { get; } = new List(); - public List BenchmarkDomains { get; } = new List(); - public List BenchmarkRecordTypes { get; } = new List(); - public List EndpointInputs { get; } = new List(); - public List DomainInputs { get; } = new List(); - public List RecordTypeInputs { get; } = new List(); - public List ProbeEndpointFiles { get; } = new List(); - public List ProbeEndpointUrls { get; } = new List(); - public bool RequestDnsSec { get; set; } - public bool ValidateDnsSec { get; set; } - public string? DnsSecVerifierPath { get; set; } - public IDnsSecSignatureVerifier? DnsSecSignatureVerifier { get; set; } - public bool WirePost { get; set; } - public DnsResolverEndpoint? BootstrapResolver { get; set; } - public bool RequestNsid { get; set; } - public bool Probe { get; set; } - public List ProbeEndpoints { get; } = new List(); - public string? ProbeSavePath { get; set; } - public string? ResolverSelectPath { get; set; } - public string? ResolverUsePath { get; set; } - public bool ProbeSummaryOnly { get; set; } - public bool ProbeSummaryLine { get; set; } - public bool ProbeRequireConsensus { get; set; } - public int? ProbeMinConsensusPercent { get; set; } - public int? ProbeMinSuccessCount { get; set; } - public int? ProbeMinSuccessPercent { get; set; } - public string? BenchmarkSavePath { get; set; } - public bool Explain { get; set; } - public bool Trace { get; set; } - public bool ShowCapabilities { get; set; } - public string? StampInfo { get; set; } - public bool ResolverValidate { get; set; } - public bool DoUpdate { get; set; } - public string? Zone { get; set; } - public string? UpdateName { get; set; } - public string? UpdateData { get; set; } - public int Ttl { get; set; } = 300; - - public bool HasExplicitSectionSelection => - ShowQuestionSection || - ShowAnswerSection || - ShowAuthoritySection || - ShowAdditionalSection; - - public bool HasCustomEndpointInputs => - ProbeEndpoints.Count > 0 || - ProbeEndpointFiles.Count > 0 || - ProbeEndpointUrls.Count > 0; - } - + internal static partial class Program { private static Func>? ProbeOverride = null; private static Func>? ProbeEndpointOverride = null; @@ -116,13 +36,16 @@ private static async Task Main(string[] args) { } using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => { + ConsoleCancelEventHandler cancelHandler = (_, e) => { e.Cancel = true; cts.Cancel(); }; + Console.CancelKeyPress += cancelHandler; try { CliOptions parsedOptions = options!; + if (parsedOptions.ProfilePath != null) return await RunProfileAsync(parsedOptions, cts.Token).ConfigureAwait(false); + if (parsedOptions.CompareBaselinePath != null) return RunSnapshotComparison(parsedOptions); if (parsedOptions.Benchmark) { return await RunBenchmarkAsync(parsedOptions, cts.Token).ConfigureAwait(false); } @@ -158,1635 +81,10 @@ private static async Task Main(string[] args) { } catch (Exception ex) { WriteHumanError(ex.Message); return 1; + } finally { + Console.CancelKeyPress -= cancelHandler; } } - private static bool TryParseArgs(string[] args, out CliOptions? options, out string? errorMessage, out IReadOnlyList? invalidSwitches) { - options = new CliOptions(); - errorMessage = null; - var unknown = new List(); - - for (int i = 0; i < args.Length; i++) { - string arg = args[i]; - switch (arg) { - case var opt when opt.Equals("-t", StringComparison.OrdinalIgnoreCase) || - opt.Equals("--type", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--type", out string? recordTypeValue, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - AddOptionValues(options.RecordTypeInputs, recordTypeValue); - options.RecordTypeSpecified = true; - break; - case var opt when opt.Equals("-e", StringComparison.OrdinalIgnoreCase) || - opt.Equals("--endpoint", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--endpoint", out string? endpointValue, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - AddOptionValues(options.EndpointInputs, endpointValue); - break; - case var opt when opt.Equals("--domain", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--domain", out string? domainValue, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - AddOptionValues(options.DomainInputs, domainValue); - break; - case var opt when opt.Equals("--reverse", StringComparison.OrdinalIgnoreCase) || - opt.Equals("--ptr", StringComparison.OrdinalIgnoreCase): - options.ReverseLookup = true; - break; - case var opt when opt.Equals("--axfr", StringComparison.OrdinalIgnoreCase) || - opt.Equals("--zone-transfer", StringComparison.OrdinalIgnoreCase): - options.ZoneTransfer = true; - break; - case var opt when opt.Equals("--transfer-summary", StringComparison.OrdinalIgnoreCase): - options.TransferSummary = true; - break; - case var opt when opt.Equals("--dnssec", StringComparison.OrdinalIgnoreCase): - options.RequestDnsSec = true; - break; - case var opt when opt.Equals("--validate-dnssec", StringComparison.OrdinalIgnoreCase): - options.ValidateDnsSec = true; - break; - case var opt when opt.Equals("--dnssec-verifier", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--dnssec-verifier", out string? verifierPath, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - if (string.IsNullOrWhiteSpace(verifierPath)) { - errorMessage = "--dnssec-verifier requires a nonblank local DLL path."; - invalidSwitches = null; - options = null; - return false; - } - options.DnsSecVerifierPath = verifierPath; - break; - case var opt when opt.Equals("--wire-post", StringComparison.OrdinalIgnoreCase): - options.WirePost = true; - break; - case var opt when opt.Equals("--nsid", StringComparison.OrdinalIgnoreCase): - options.RequestNsid = true; - break; - case var opt when opt.Equals("--bootstrap", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--bootstrap", out string? bootstrap, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - try { options.BootstrapResolver = EndpointParser.ParseBootstrap(bootstrap!); } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { - errorMessage = ex.Message; - invalidSwitches = null; - options = null; - return false; - } - break; - case var opt when opt.Equals("--format", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--format", out string? formatValue, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - if (!TryParseQueryOutputFormat(formatValue, out QueryOutputFormat outputFormat)) { - errorMessage = $"Invalid value for --format: {formatValue}"; - invalidSwitches = null; - options = null; - return false; - } - options.OutputFormat = outputFormat; - break; - case var opt when opt.Equals("--short", StringComparison.OrdinalIgnoreCase): - options.ShortOutput = true; - break; - case var opt when opt.Equals("--txt-concat", StringComparison.OrdinalIgnoreCase): - options.TxtConcatOutput = true; - break; - case var opt when opt.Equals("--question", StringComparison.OrdinalIgnoreCase): - options.ShowQuestionSection = true; - break; - case var opt when opt.Equals("--answer", StringComparison.OrdinalIgnoreCase): - options.ShowAnswerSection = true; - break; - case var opt when opt.Equals("--authority", StringComparison.OrdinalIgnoreCase): - options.ShowAuthoritySection = true; - break; - case var opt when opt.Equals("--additional", StringComparison.OrdinalIgnoreCase): - options.ShowAdditionalSection = true; - break; - case var opt when opt.Equals("--benchmark", StringComparison.OrdinalIgnoreCase): - options.Benchmark = true; - break; - case var opt when opt.Equals("--benchmark-connections", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-connections", out string? connectionMode, out errorMessage) || - (connectionMode != "cold" && connectionMode != "warm") || - !Enum.TryParse(connectionMode, true, out ResolverQueryConnectionMode parsedMode) || - (parsedMode != ResolverQueryConnectionMode.Cold && parsedMode != ResolverQueryConnectionMode.Warm)) { - errorMessage = "--benchmark-connections accepts cold or warm."; - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkConnectionMode = parsedMode; - break; - case var opt when opt.Equals("--benchmark-attempts", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-attempts", out string? benchmarkAttemptsValue, out errorMessage) || - !int.TryParse(benchmarkAttemptsValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkAttempts)) { - errorMessage ??= $"Invalid value for --benchmark-attempts: {benchmarkAttemptsValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (benchmarkAttempts < 1) { - errorMessage = $"--benchmark-attempts must be at least 1: {benchmarkAttempts}"; - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkAttempts = benchmarkAttempts; - break; - case var opt when opt.Equals("--benchmark-timeout", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-timeout", out string? benchmarkTimeoutValue, out errorMessage) || - !int.TryParse(benchmarkTimeoutValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkTimeoutMs)) { - errorMessage ??= $"Invalid value for --benchmark-timeout: {benchmarkTimeoutValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (benchmarkTimeoutMs < 1) { - errorMessage = $"--benchmark-timeout must be at least 1: {benchmarkTimeoutMs}"; - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkTimeoutMs = benchmarkTimeoutMs; - break; - case var opt when opt.Equals("--benchmark-concurrency", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-concurrency", out string? benchmarkConcurrencyValue, out errorMessage) || - !int.TryParse(benchmarkConcurrencyValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkConcurrency)) { - errorMessage ??= $"Invalid value for --benchmark-concurrency: {benchmarkConcurrencyValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (benchmarkConcurrency < 1) { - errorMessage = $"--benchmark-concurrency must be at least 1: {benchmarkConcurrency}"; - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkConcurrency = benchmarkConcurrency; - break; - case var opt when opt.Equals("--benchmark-min-success-percent", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-min-success-percent", out string? benchmarkMinSuccessPercentValue, out errorMessage) || - !int.TryParse(benchmarkMinSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessPercent)) { - errorMessage ??= $"Invalid value for --benchmark-min-success-percent: {benchmarkMinSuccessPercentValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (benchmarkMinSuccessPercent < 1 || benchmarkMinSuccessPercent > 100) { - errorMessage = $"--benchmark-min-success-percent must be between 1 and 100: {benchmarkMinSuccessPercent}"; - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkMinSuccessPercent = benchmarkMinSuccessPercent; - break; - case var opt when opt.Equals("--benchmark-min-successful-candidates", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-min-successful-candidates", out string? benchmarkMinSuccessfulCandidatesValue, out errorMessage) || - !int.TryParse(benchmarkMinSuccessfulCandidatesValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessfulCandidates)) { - errorMessage ??= $"Invalid value for --benchmark-min-successful-candidates: {benchmarkMinSuccessfulCandidatesValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (benchmarkMinSuccessfulCandidates < 1) { - errorMessage = $"--benchmark-min-successful-candidates must be at least 1: {benchmarkMinSuccessfulCandidates}"; - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkMinSuccessfulCandidates = benchmarkMinSuccessfulCandidates; - break; - case var opt when opt.Equals("--benchmark-summary-line", StringComparison.OrdinalIgnoreCase): - options.Benchmark = true; - options.BenchmarkSummaryLine = true; - break; - case var opt when opt.Equals("--benchmark-save", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--benchmark-save", out string? benchmarkSavePath, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - options.Benchmark = true; - options.BenchmarkSavePath = benchmarkSavePath; - break; - case var opt when opt.Equals("--benchmark-summary-only", StringComparison.OrdinalIgnoreCase): - options.Benchmark = true; - options.BenchmarkSummaryOnly = true; - break; - case var opt when opt.Equals("--probe", StringComparison.OrdinalIgnoreCase): - options.Probe = true; - break; - case var opt when opt.Equals("--probe-endpoint", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--probe-endpoint", out string? probeEndpoint, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - AddOptionValues(options.ProbeEndpoints, probeEndpoint); - break; - case var opt when opt.Equals("--resolver-file", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--resolver-file", out string? resolverFile, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - AddOptionValues(options.ProbeEndpointFiles, resolverFile); - break; - case var opt when opt.Equals("--resolver-url", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--resolver-url", out string? resolverUrl, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - AddOptionValues(options.ProbeEndpointUrls, resolverUrl); - break; - case var opt when opt.Equals("--resolver-validate", StringComparison.OrdinalIgnoreCase): - options.ResolverValidate = true; - break; - case var opt when opt.Equals("--probe-summary-only", StringComparison.OrdinalIgnoreCase): - options.Probe = true; - options.ProbeSummaryOnly = true; - break; - case var opt when opt.Equals("--probe-summary-line", StringComparison.OrdinalIgnoreCase): - options.Probe = true; - options.ProbeSummaryLine = true; - break; - case var opt when opt.Equals("--resolver-select", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--resolver-select", out string? resolverSelectPath, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - options.ResolverSelectPath = resolverSelectPath; - break; - case var opt when opt.Equals("--resolver-use", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--resolver-use", out string? resolverUsePath, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - options.ResolverUsePath = resolverUsePath; - break; - case var opt when opt.Equals("--probe-save", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--probe-save", out string? probeSavePath, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - options.Probe = true; - options.ProbeSavePath = probeSavePath; - break; - case var opt when opt.Equals("--probe-require-consensus", StringComparison.OrdinalIgnoreCase): - options.Probe = true; - options.ProbeRequireConsensus = true; - break; - case var opt when opt.Equals("--probe-min-consensus", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--probe-min-consensus", out string? minConsensusValue, out errorMessage) || - !int.TryParse(minConsensusValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minConsensusPercent)) { - errorMessage ??= $"Invalid value for --probe-min-consensus: {minConsensusValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (minConsensusPercent < 1 || minConsensusPercent > 100) { - errorMessage = $"--probe-min-consensus must be between 1 and 100: {minConsensusPercent}"; - invalidSwitches = null; - options = null; - return false; - } - options.Probe = true; - options.ProbeMinConsensusPercent = minConsensusPercent; - break; - case var opt when opt.Equals("--probe-min-success", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--probe-min-success", out string? minSuccessValue, out errorMessage) || - !int.TryParse(minSuccessValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessCount)) { - errorMessage ??= $"Invalid value for --probe-min-success: {minSuccessValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (minSuccessCount < 1) { - errorMessage = $"--probe-min-success must be at least 1: {minSuccessCount}"; - invalidSwitches = null; - options = null; - return false; - } - options.Probe = true; - options.ProbeMinSuccessCount = minSuccessCount; - break; - case var opt when opt.Equals("--probe-min-success-percent", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--probe-min-success-percent", out string? minSuccessPercentValue, out errorMessage) || - !int.TryParse(minSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessPercent)) { - errorMessage ??= $"Invalid value for --probe-min-success-percent: {minSuccessPercentValue}"; - invalidSwitches = null; - options = null; - return false; - } - if (minSuccessPercent < 1 || minSuccessPercent > 100) { - errorMessage = $"--probe-min-success-percent must be between 1 and 100: {minSuccessPercent}"; - invalidSwitches = null; - options = null; - return false; - } - options.Probe = true; - options.ProbeMinSuccessPercent = minSuccessPercent; - break; - case var opt when opt.Equals("--explain", StringComparison.OrdinalIgnoreCase): - options.Explain = true; - break; - case var opt when opt.Equals("--trace", StringComparison.OrdinalIgnoreCase): - options.Trace = true; - options.Explain = true; - break; - case var opt when opt.Equals("--capabilities", StringComparison.OrdinalIgnoreCase): - options.ShowCapabilities = true; - break; - case var opt when opt.Equals("--stamp-info", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--stamp-info", out string? stampInfo, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - options.StampInfo = stampInfo; - break; - case var opt when opt.Equals("--update", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--update", out string? zone, out errorMessage) || - !TryReadNext(args, ref i, "--update", out string? updateName, out errorMessage) || - !TryReadNext(args, ref i, "--update", out string? updateTypeValue, out errorMessage) || - !TryReadNext(args, ref i, "--update", out string? updateData, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - if (!Enum.TryParse(updateTypeValue, true, out DnsRecordType updateType)) { - errorMessage = $"Invalid value for --update type: {updateTypeValue}"; - invalidSwitches = null; - options = null; - return false; - } - options.DoUpdate = true; - options.Zone = zone; - options.UpdateName = updateName; - options.RecordType = updateType; - options.UpdateData = updateData; - break; - case var opt when opt.Equals("--ttl", StringComparison.OrdinalIgnoreCase): - if (!TryReadNext(args, ref i, "--ttl", out string? ttlValue, out errorMessage) || - !int.TryParse(ttlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int ttl)) { - errorMessage ??= $"Invalid value for --ttl: {ttlValue}"; - invalidSwitches = null; - options = null; - return false; - } - options.Ttl = ttl; - break; - default: - if (options.Domain is null && !arg.StartsWith("-", StringComparison.Ordinal)) { - options.Domain = arg; - } else { - unknown.Add(arg); - } - break; - } - } - - if (unknown.Count > 0) { - invalidSwitches = unknown; - options = null; - return false; - } - - if (!FinalizeParsedLists(options, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - - if (options.HasCustomEndpointInputs && !options.Benchmark && !options.ResolverValidate && !options.DoUpdate) { - options.Probe = true; - } - - if (options.ShowCapabilities && - (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer || - !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) || - !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate)) { - errorMessage = "--capabilities cannot be combined with query, probe, benchmark, update, axfr, resolver selection, or stamp modes."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath) && - (options.ShowCapabilities || !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate || - !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.ZoneTransfer || options.DoUpdate)) { - errorMessage = "--dnssec-verifier applies only to query, probe, and benchmark modes."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.StampInfo) && - (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer || - !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) || - options.ResolverValidate)) { - errorMessage = "--stamp-info cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ResolverValidate && - (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer || - !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath))) { - errorMessage = "--resolver-validate cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.DoUpdate && (options.Probe || options.Benchmark || options.ZoneTransfer)) { - errorMessage = options.Benchmark - ? "--benchmark cannot be combined with --update." - : options.Probe - ? "--probe cannot be combined with --update." - : "--axfr cannot be combined with --update."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.Probe && options.Benchmark) { - errorMessage = "--probe cannot be combined with --benchmark."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) && - (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer)) { - errorMessage = "--resolver-select cannot be combined with probe, benchmark, update, or axfr modes."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) && - (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.DoUpdate || options.ZoneTransfer)) { - errorMessage = "--resolver-use cannot be combined with --resolver-select, update, or axfr modes."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) && - (options.EndpointInputs.Count > 0 || options.HasCustomEndpointInputs)) { - errorMessage = "--resolver-use cannot be combined with --endpoint, --probe-endpoint, --resolver-file, or --resolver-url."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ZoneTransfer && (options.Probe || options.Benchmark)) { - errorMessage = options.Probe - ? "--axfr cannot be combined with --probe." - : "--axfr cannot be combined with --benchmark."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ZoneTransfer && options.ReverseLookup) { - errorMessage = "--reverse cannot be combined with --axfr."; - invalidSwitches = null; - options = null; - return false; - } - - if (!ApplyReverseLookupDefaults(options, out errorMessage)) { - invalidSwitches = null; - options = null; - return false; - } - - if (options.ShortOutput && options.OutputFormat != QueryOutputFormat.Pretty) { - errorMessage = "--short cannot be combined with --format json or --format raw."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.TxtConcatOutput && options.OutputFormat == QueryOutputFormat.Json) { - errorMessage = "--txt-concat cannot be combined with --format json."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ShowCapabilities && - (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) { - errorMessage = "Capability mode supports only default output or --format json."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.StampInfo) && - (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw || - options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost || - !string.IsNullOrWhiteSpace(options.Domain))) { - errorMessage = "Stamp mode supports only --stamp-info and optional --format json."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ResolverValidate && - (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw || - options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost || - !string.IsNullOrWhiteSpace(options.Domain) || !options.HasCustomEndpointInputs)) { - errorMessage = "Resolver validation mode requires --resolver-validate with --probe-endpoint, --resolver-file, or --resolver-url, and supports optional --format json."; - invalidSwitches = null; - options = null; - return false; - } - - if ((options.Benchmark || options.Probe || options.DoUpdate) && - (options.ShortOutput || options.TxtConcatOutput || options.OutputFormat != QueryOutputFormat.Pretty || options.HasExplicitSectionSelection || options.TransferSummary)) { - errorMessage = "Query output switches (--format, --short, --txt-concat, --question, --answer, --authority, --additional) apply only to standard query mode."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) && - (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) { - errorMessage = "Resolver selection mode supports only default output or --format json."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) && - (options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw && (options.Probe || options.Benchmark))) { - errorMessage = "Resolver reuse mode does not change the output rules of probe or benchmark workflows."; - invalidSwitches = null; - options = null; - return false; - } - - if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) && - (options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost)) { - errorMessage = "Query switches do not apply to --resolver-select."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ZoneTransfer && - (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.OutputFormat == QueryOutputFormat.Raw)) { - errorMessage = "AXFR mode supports only --format pretty|json and optional --transfer-summary."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.TransferSummary && !options.ZoneTransfer) { - errorMessage = "--transfer-summary applies only to --axfr mode."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.ZoneTransfer && options.RecordTypeSpecified && options.RecordType != DnsRecordType.AXFR) { - errorMessage = "--type can be used with --axfr only when set to AXFR."; - invalidSwitches = null; - options = null; - return false; - } - - if (options.DoUpdate) { - if (!options.HasCustomEndpointInputs || options.EndpointInputs.Count > 0) { - errorMessage = "--update requires an explicit authoritative endpoint via --probe-endpoint, --resolver-file, or --resolver-url; do not combine it with --endpoint."; - invalidSwitches = null; - options = null; - return false; - } - if (options.Zone is null || options.UpdateName is null || options.UpdateData is null) { - errorMessage = "Invalid --update arguments."; - invalidSwitches = null; - options = null; - return false; - } - } else if (options.ShowCapabilities) { - // Capability mode does not require a domain. - } else if (!string.IsNullOrWhiteSpace(options.StampInfo)) { - // Stamp mode requires only the stamp input. - } else if (options.ResolverValidate) { - // Resolver validation mode requires only resolver inputs. - } else if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath)) { - // Selection mode requires only the saved snapshot path. - } else if (options.ZoneTransfer && string.IsNullOrWhiteSpace(options.Domain)) { - errorMessage = "Zone name is required."; - invalidSwitches = null; - options = null; - return false; - } else if (!options.Probe && !options.Benchmark && string.IsNullOrWhiteSpace(options.Domain)) { - errorMessage = "Domain name is required."; - invalidSwitches = null; - options = null; - return false; - } else if (options.Probe && string.IsNullOrWhiteSpace(options.Domain)) { - options.Domain = "example.com"; - } else if (options.Benchmark) { - if (options.BenchmarkDomains.Count == 0) { - options.BenchmarkDomains.Add(!string.IsNullOrWhiteSpace(options.Domain) ? options.Domain! : "example.com"); - } - if (options.BenchmarkEndpoints.Count == 0 && !options.HasCustomEndpointInputs) { - options.BenchmarkEndpoints.Add(options.Endpoint); - } - if (options.BenchmarkRecordTypes.Count == 0) { - options.BenchmarkRecordTypes.Add(options.RecordType); - } - } - - invalidSwitches = Array.Empty(); - return true; - } - - private static bool TryReadNext(string[] args, ref int index, string optionName, out string? value, out string? errorMessage) { - if (index + 1 >= args.Length) { - value = null; - errorMessage = $"Missing value for {optionName}"; - return false; - } - - value = args[++index]; - errorMessage = null; - return true; - } - - private static bool TryParseQueryOutputFormat(string? value, out QueryOutputFormat format) { - switch (value?.Trim().ToLowerInvariant()) { - case "pretty": - format = QueryOutputFormat.Pretty; - return true; - case "json": - format = QueryOutputFormat.Json; - return true; - case "raw": - format = QueryOutputFormat.Raw; - return true; - default: - format = default; - return false; - } - } - - private static bool ApplyReverseLookupDefaults(CliOptions options, out string? errorMessage) { - errorMessage = null; - - if (options.ReverseLookup) { - if (options.RecordTypeSpecified && - options.BenchmarkRecordTypes.Any(recordType => recordType != DnsRecordType.PTR)) { - errorMessage = "--reverse cannot be combined with a record type other than PTR."; - return false; - } - - options.RecordType = DnsRecordType.PTR; - if (options.BenchmarkRecordTypes.Count > 0) { - options.BenchmarkRecordTypes.Clear(); - options.BenchmarkRecordTypes.Add(DnsRecordType.PTR); - } - - return true; - } - - if (options.RecordTypeSpecified || options.DoUpdate) { - return true; - } - - string[] effectiveBenchmarkDomains = options.Benchmark ? GetEffectiveBenchmarkDomains(options) : Array.Empty(); - bool shouldUsePtrByDefault = options.Benchmark - ? effectiveBenchmarkDomains.Length > 0 && effectiveBenchmarkDomains.All(IsIpAddressLiteral) - : !string.IsNullOrWhiteSpace(options.Domain) && IsIpAddressLiteral(options.Domain); - - if (shouldUsePtrByDefault) { - options.RecordType = DnsRecordType.PTR; - } - - return true; - } - - private static bool IsIpAddressLiteral(string? value) { - if (string.IsNullOrWhiteSpace(value)) { - return false; - } - - string trimmed = value!.Trim(); - return IPAddress.TryParse(trimmed, out _); - } - - private static void AddOptionValues(ICollection target, string? rawValue) { - foreach (string value in SplitOptionValues(rawValue)) { - target.Add(value); - } - } - - private static IEnumerable SplitOptionValues(string? rawValue) { - if (string.IsNullOrWhiteSpace(rawValue)) { - yield break; - } - - string valueText = rawValue!; - string[] parts = valueText.Split(','); - foreach (string part in parts) { - string value = part.Trim(); - if (!string.IsNullOrWhiteSpace(value)) { - yield return value; - } - } - } - - private static bool FinalizeParsedLists(CliOptions options, out string? errorMessage) { - errorMessage = null; - - if (options.EndpointInputs.Count > 0) { - foreach (string endpointValue in options.EndpointInputs) { - if (!Enum.TryParse(endpointValue, true, out DnsEndpoint endpoint)) { - errorMessage = $"Invalid value for --endpoint: {endpointValue}"; - return false; - } - if (endpoint == DnsEndpoint.Custom) { - errorMessage = "--endpoint Custom is not supported in the CLI. Use explicit resolver endpoint syntax such as --probe-endpoint, --resolver-file, or --resolver-url instead."; - return false; - } - options.BenchmarkEndpoints.Add(endpoint); - } - - options.Endpoint = options.BenchmarkEndpoints[0]; - if (!options.Benchmark && options.BenchmarkEndpoints.Count > 1) { - errorMessage = "--endpoint accepts multiple values only with --benchmark."; - return false; - } - } - - if (options.RecordTypeInputs.Count > 0) { - foreach (string recordTypeValue in options.RecordTypeInputs) { - if (!Enum.TryParse(recordTypeValue, true, out DnsRecordType recordType)) { - errorMessage = $"Invalid value for --type: {recordTypeValue}"; - return false; - } - options.BenchmarkRecordTypes.Add(recordType); - } - - options.RecordType = options.BenchmarkRecordTypes[0]; - if (!options.Benchmark && options.BenchmarkRecordTypes.Count > 1) { - errorMessage = "--type accepts multiple values only with --benchmark."; - return false; - } - } - - if (options.DomainInputs.Count > 0) { - foreach (string domainValue in options.DomainInputs) { - options.BenchmarkDomains.Add(domainValue); - } - - options.Domain = options.BenchmarkDomains[0]; - if (!options.Benchmark && options.BenchmarkDomains.Count > 1) { - errorMessage = "--domain accepts multiple values only with --benchmark."; - return false; - } - } - - return true; - } - - private static ResolverExecutionClientOptions CreateExecutionClientOptions(CliOptions options, bool useBenchmarkTimeout = false) { - if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath)) - options.DnsSecSignatureVerifier ??= DnsSecSignatureVerifierLoader.Load(options.DnsSecVerifierPath!); - string? envPort = Environment.GetEnvironmentVariable("DNSCLIENTX_CLI_PORT"); - int? customPort = int.TryParse(envPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedPort) && parsedPort > 0 - ? parsedPort - : null; - - return new ResolverExecutionClientOptions { - EnableAudit = options.Explain || options.Trace, - TimeoutMs = useBenchmarkTimeout ? options.BenchmarkTimeoutMs : null, - PortOverride = customPort, - ForceDohWirePost = options.WirePost, - BootstrapResolver = options.BootstrapResolver, - DnsSecSignatureVerifier = options.DnsSecSignatureVerifier, - RequestNsid = options.RequestNsid - }; - } - - private static async Task RunZoneTransferAsync(CliOptions options, CancellationToken cancellationToken) { - ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options); - RecursiveZoneTransferResult result = await ResolverZoneTransferWorkflow.RunRecursiveAsync( - new ResolverExecutionTargetSource { - BuiltInEndpoints = new[] { options.Endpoint } - }, - options.Domain!, - port: clientOptions.PortOverride ?? 53, - clientOptions: clientOptions, - cancellationToken: cancellationToken).ConfigureAwait(false); - - WriteZoneTransferResponse(result, options); - return 0; - } - - private static async Task RunStandardQueryAsync(CliOptions options, CancellationToken cancellationToken) { - ResolverExecutionTargetSource targetSource = CreateStandardQueryTargetSource(options); - ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options); - ResolverSingleOperationResult result; - DnsResponse response; - if (options.DoUpdate) { - result = await ResolverSingleOperationWorkflow.UpdateAsync( - targetSource, - options.Zone!, - options.UpdateName!, - options.RecordType, - options.UpdateData!, - options.Ttl, - clientOptions, - cancellationToken).ConfigureAwait(false); - response = result.Response; - - Console.WriteLine($"Update status: {response.Status} (retries {response.RetryCount})"); - if (options.Explain || options.Trace) { - WriteExplain( - result, - operation: "update", - target: options.UpdateName!, - recordType: options.RecordType, - requestDnsSec: false, - validateDnsSec: false, - trace: options.Trace, - zone: options.Zone, - ttl: options.Ttl); - } - } else { - result = await ResolverSingleOperationWorkflow.QueryAsync( - targetSource, - options.Domain!, - options.RecordType, - options.RequestDnsSec, - options.ValidateDnsSec, - clientOptions, - cancellationToken).ConfigureAwait(false); - response = result.Response; - - WriteQueryResponse(response, options, result.Elapsed); - - if (options.Explain || options.Trace) { - WriteExplain( - result, - operation: "query", - target: options.Domain!, - recordType: options.RecordType, - requestDnsSec: options.RequestDnsSec, - validateDnsSec: options.ValidateDnsSec, - trace: options.Trace); - } - } - - if (!string.IsNullOrWhiteSpace(response.Error)) { - WriteHumanError(response.Error); - return 1; - } - return 0; - } - - private static ResolverExecutionTargetSource CreateStandardQueryTargetSource(CliOptions options) { - if (options.DoUpdate && options.HasCustomEndpointInputs) { - return new ResolverExecutionTargetSource { - ResolverEndpoints = options.ProbeEndpoints.ToArray(), - ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(), - ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray() - }; - } - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { - return new ResolverExecutionTargetSource { - ResolverSelectionPath = options.ResolverUsePath - }; - } - - return new ResolverExecutionTargetSource { - BuiltInEndpoints = new[] { options.Endpoint } - }; - } - - private static Task RunResolverSelectionAsync(CliOptions options, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - - ResolverSelectionResult selection = LoadRecommendedResolverSelection(options.ResolverSelectPath!); - - WriteResolverSelection(selection, options); - return Task.FromResult(0); - } - - private static ResolverSelectionResult LoadRecommendedResolverSelection(string path) { - return ResolverExecutionTargetResolver.LoadRecommendedSelection(path); - } - - private static int WriteCapabilities(CliOptions options) { - DnsTransportCapabilityInfo[] capabilities = DnsTransportCapabilities.GetCapabilityReport(); - if (options.OutputFormat == QueryOutputFormat.Json) { - Console.WriteLine(DnsClientXJsonSerializer.Serialize(capabilities)); - return 0; - } - - foreach (string line in DnsTransportCapabilityTextFormatter.BuildLines(capabilities)) { - WriteHumanLine(line); - } - - return 0; - } - - private static int WriteStampInfo(CliOptions options) { - DnsStampInfo info = DnsStamp.Describe(options.StampInfo!); - if (options.OutputFormat == QueryOutputFormat.Json) { - Console.WriteLine(DnsClientXJsonSerializer.Serialize(info)); - return 0; - } - - Console.WriteLine("DNS Stamp:"); - Console.WriteLine($" Transport: {info.Transport}"); - Console.WriteLine($" Request format: {info.RequestFormat}"); - WriteHumanLine($" Host: {info.Host}"); - Console.WriteLine($" Port: {info.Port}"); - if (info.DohUrl != null) { - WriteHumanLine($" DoH URL: {info.DohUrl}"); - } - Console.WriteLine($" DNSSEC property: {(info.DnsSecOk ? "yes" : "no")}"); - WriteHumanLine($" Endpoint: {info.Endpoint}"); - WriteHumanLine($" Normalized stamp: {info.NormalizedStamp}"); - return 0; - } - - private static async Task RunResolverValidationAsync(CliOptions options, CancellationToken cancellationToken) { - ResolverEndpointValidationResult[] results = await EndpointParser.ValidateManyAsync( - options.ProbeEndpoints, - options.ProbeEndpointFiles, - options.ProbeEndpointUrls, - cancellationToken).ConfigureAwait(false); - - if (options.OutputFormat == QueryOutputFormat.Json) { - Console.WriteLine(DnsClientXJsonSerializer.Serialize(results)); - } else { - WriteResolverValidationResults(results); - } - - return results.Any(result => !result.IsValid) ? 1 : 0; - } - - private static void WriteResolverValidationResults(ResolverEndpointValidationResult[] results) { - int valid = results.Count(result => result.IsValid); - int invalid = results.Length - valid; - - Console.WriteLine("Resolver Validation:"); - Console.WriteLine($" Entries: {results.Length}"); - Console.WriteLine($" Valid: {valid}"); - Console.WriteLine($" Invalid: {invalid}"); - - foreach (ResolverEndpointValidationResult result in results) { - string location = string.IsNullOrWhiteSpace(result.Source) - ? "unknown" - : result.LineNumber.HasValue - ? $"{result.Source}:{result.LineNumber.Value}" - : result.Source; - - if (result.IsValid) { - WriteHumanLine($" valid {location} {result.Entry} -> {result.Endpoint}"); - } else { - WriteHumanLine($" invalid {location} {result.Entry} ({result.Error})"); - } - } - } - - private static async Task RunBenchmarkAsync(CliOptions options, CancellationToken cancellationToken) { - string[] domains = options.BenchmarkDomains - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - DnsRecordType[] recordTypes = options.BenchmarkRecordTypes - .Distinct() - .ToArray(); - ResolverExecutionTargetSource targetSource = CreateBenchmarkTargetSource(options); - ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false); - if (options.HasCustomEndpointInputs && targets.Length == 0) { - Console.Error.WriteLine("No custom resolver endpoints were supplied."); - return 1; - } - - return await RunBenchmarkWithTargetsAsync( - options, - targets, - domains, - recordTypes, - GetBenchmarkEndpointProfile(options), - cancellationToken).ConfigureAwait(false); - } - - private static async Task RunBenchmarkWithTargetsAsync( - CliOptions options, - ResolverExecutionTarget[] targets, - string[] domains, - DnsRecordType[] recordTypes, - string? endpointProfile, - CancellationToken cancellationToken) { - Console.WriteLine("Benchmark:"); - WriteHumanLine($" Domains: {string.Join(", ", domains)}"); - Console.WriteLine($" Types: {string.Join(", ", recordTypes)}"); - Console.WriteLine($" Attempts per combination: {options.BenchmarkAttempts}"); - Console.WriteLine($" Timeout (ms): {options.BenchmarkTimeoutMs}"); - Console.WriteLine($" Concurrency: {options.BenchmarkConcurrency}"); - Console.WriteLine($" Connections: {options.BenchmarkConnectionMode}"); - Console.WriteLine($" Detail mode: {(options.BenchmarkSummaryOnly ? "summary-only" : "full")}"); - if (!string.IsNullOrWhiteSpace(endpointProfile)) { - WriteHumanLine($" Endpoint profile: {endpointProfile}"); - } - - Console.WriteLine($" Candidates: {targets.Length}"); - Console.WriteLine($" Queries per candidate: {domains.Length * recordTypes.Length * options.BenchmarkAttempts}"); - - ResolverBenchmarkReport report = await ExecuteBenchmarkReportAsync( - targets, - domains, - recordTypes, - options, - cancellationToken).ConfigureAwait(false); - if (!options.BenchmarkSummaryOnly) { - foreach (ResolverBenchmarkReportResult result in report.Results) { - WriteBenchmarkResult(result); - } - } - - return WriteBenchmarkSummary(report, options); - } - - private static void WriteBenchmarkResult(ResolverBenchmarkReportResult result) { - foreach (string line in ResolverReportTextFormatter.BuildBenchmarkResultLines(result, FormatDuration)) { - Console.WriteLine(line); - } - } - - private static int WriteBenchmarkSummary(ResolverBenchmarkReport report, CliOptions options) { - ResolverBenchmarkEvaluation evaluation = report.Evaluation; - - Console.WriteLine("Benchmark Summary:"); - Console.WriteLine($" Successful candidates: {report.Summary.SuccessfulCandidates}/{report.Summary.CandidateCount}"); - Console.WriteLine($" Successful queries: {report.Summary.OverallSuccessCount}/{report.Summary.OverallQueryCount}"); - Console.WriteLine($" Successful query rate: {report.Summary.OverallSuccessPercent}%"); - Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeBenchmarkRuntimeCapabilities(report.Summary)}"); - for (int i = 0; i < report.Results.Length; i++) { - Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkRankedLine(report.Results[i], i + 1, FormatDuration)); - } - Console.WriteLine($" Best endpoint: {ResolverReportTextFormatter.DescribeBenchmarkBest(report.Results, report.Summary, FormatDuration)}"); - WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}"); - - int exitCode = GetBenchmarkExitCode(evaluation, options); - if (options.BenchmarkSummaryLine) { - Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkSummaryLine(report.Summary, exitCode)); - } - - if (!string.IsNullOrWhiteSpace(options.BenchmarkSavePath)) { - SaveResolverScoreSnapshot(options.BenchmarkSavePath!, report.Snapshot); - } - - return exitCode; - } - - private static ResolverBenchmarkPolicy CreateBenchmarkPolicy(CliOptions options) { - return new ResolverBenchmarkPolicy { - MinSuccessPercent = options.BenchmarkMinSuccessPercent, - MinSuccessfulCandidates = options.BenchmarkMinSuccessfulCandidates - }; - } - - private static int GetBenchmarkExitCode(ResolverBenchmarkEvaluation evaluation, CliOptions options) { - if (evaluation.SuccessfulCandidates == 0) { - return 1; - } - - if (options.BenchmarkMinSuccessfulCandidates.HasValue && evaluation.SuccessfulCandidates < options.BenchmarkMinSuccessfulCandidates.Value) { - return 3; - } - - if (options.BenchmarkMinSuccessPercent.HasValue && evaluation.OverallSuccessPercent < options.BenchmarkMinSuccessPercent.Value) { - return 3; - } - - return evaluation.PolicyPassed ? 0 : 1; - } - - private static async Task RunProbeAsync(CliOptions options, CancellationToken cancellationToken) { - string domain = options.Domain!; - ResolverExecutionTargetSource targetSource = CreateProbeTargetSource(options); - ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false); - if (options.HasCustomEndpointInputs && targets.Length == 0) { - Console.Error.WriteLine("No custom resolver endpoints were supplied."); - return 1; - } - - return await RunProbeWithTargetsAsync( - options, - targets, - domain, - GetProbeEndpointProfile(options), - cancellationToken).ConfigureAwait(false); - } - - private static async Task RunProbeWithTargetsAsync( - CliOptions options, - ResolverExecutionTarget[] targets, - string domain, - string endpointProfile, - CancellationToken cancellationToken) { - Console.WriteLine("Probe:"); - WriteHumanLine($" Domain: {domain}"); - WriteHumanLine($" Endpoint profile: {endpointProfile}"); - Console.WriteLine($" Candidates: {targets.Length}"); - Console.WriteLine($" Detail mode: {(options.ProbeSummaryOnly ? "summary-only" : "full")}"); - - ResolverProbeReport report = await ExecuteProbeReportAsync(targets, domain, options, cancellationToken).ConfigureAwait(false); - if (!options.ProbeSummaryOnly) { - foreach (ResolverProbeReportResult result in report.Results) { - WriteProbeResult(result); - } - } - - return WriteProbeSummary(report, options); - } - - private static ResolverQueryRunOptions CreateQueryRunOptions(CliOptions options) { - ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options, useBenchmarkTimeout: options.Benchmark); - return new ResolverQueryRunOptions { - TimeoutMs = options.Benchmark ? options.BenchmarkTimeoutMs : Configuration.DefaultTimeout, - ConnectionMode = options.BenchmarkConnectionMode, - RequestDnsSec = options.RequestDnsSec, - ValidateDnsSec = options.ValidateDnsSec, - MaxRetries = 1, - RetryDelayMs = 0, - PortOverride = clientOptions.PortOverride, - ForceDohWirePost = clientOptions.ForceDohWirePost, - BootstrapResolver = clientOptions.BootstrapResolver, - DnsSecSignatureVerifier = clientOptions.DnsSecSignatureVerifier, - RequestNsid = clientOptions.RequestNsid - }; - } - - private static string[] GetEffectiveBenchmarkDomains(CliOptions options) { - if (options.BenchmarkDomains.Count > 0) { - return options.BenchmarkDomains.ToArray(); - } - - return string.IsNullOrWhiteSpace(options.Domain) - ? Array.Empty() - : new[] { options.Domain! }; - } - - private static Task ExecuteBenchmarkReportAsync( - ResolverExecutionTarget[] targets, - string[] domains, - DnsRecordType[] recordTypes, - CliOptions options, - CancellationToken cancellationToken) { - return ResolverBenchmarkWorkflow.RunAsync( - targets, - domains, - recordTypes, - options.BenchmarkAttempts, - options.BenchmarkConcurrency, - options.BenchmarkTimeoutMs, - CreateQueryRunOptions(options), - CreateBenchmarkPolicy(options), - builtInOverride: CreateBuiltInOverride(), - explicitOverride: CreateExplicitOverride(), - cancellationToken: cancellationToken); - } - - private static ResolverExecutionTargetSource CreateBenchmarkTargetSource(CliOptions options) { - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { - return new ResolverExecutionTargetSource { - ResolverSelectionPath = options.ResolverUsePath - }; - } - - if (options.HasCustomEndpointInputs) { - return new ResolverExecutionTargetSource { - ResolverEndpoints = options.ProbeEndpoints.ToArray(), - ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(), - ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray() - }; - } - - return new ResolverExecutionTargetSource { - BuiltInEndpoints = options.BenchmarkEndpoints.Distinct().ToArray() - }; - } - - private static Task ExecuteProbeReportAsync( - ResolverExecutionTarget[] targets, - string domain, - CliOptions options, - CancellationToken cancellationToken) { - return ResolverProbeWorkflow.RunAsync( - targets, - domain, - options.RecordType, - Configuration.DefaultTimeout, - CreateQueryRunOptions(options), - CreateProbePolicy(options), - builtInOverride: CreateBuiltInOverride(), - explicitOverride: CreateExplicitOverride(), - cancellationToken: cancellationToken); - } - - private static ResolverExecutionTargetSource CreateProbeTargetSource(CliOptions options) { - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { - return new ResolverExecutionTargetSource { - ResolverSelectionPath = options.ResolverUsePath - }; - } - - if (options.HasCustomEndpointInputs) { - return new ResolverExecutionTargetSource { - ResolverEndpoints = options.ProbeEndpoints.ToArray(), - ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(), - ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray() - }; - } - - return new ResolverExecutionTargetSource { - ProbeProfile = options.Endpoint - }; - } - - private static string? GetBenchmarkEndpointProfile(CliOptions options) { - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { - return "selected"; - } - - return options.HasCustomEndpointInputs ? "custom" : null; - } - - private static string GetProbeEndpointProfile(CliOptions options) { - if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) { - return "selected"; - } - - return options.HasCustomEndpointInputs ? "custom" : options.Endpoint.ToString(); - } - - private static Func>? CreateBuiltInOverride() { - if (ProbeOverride == null) { - return null; - } - - return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult( - endpoint.ToString(), - await ProbeOverride(endpoint, name, cancellationToken).ConfigureAwait(false)); - } - - private static Func>? CreateExplicitOverride() { - if (ProbeEndpointOverride == null) { - return null; - } - - return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult( - ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint), - await ProbeEndpointOverride(endpoint, name, cancellationToken).ConfigureAwait(false)); - } - - private static ResolverQueryAttemptResult CreateOverrideAttemptResult( - string target, - (DnsResponse Response, TimeSpan Elapsed, string Resolver, DnsRequestFormat RequestFormat) result) { - return new ResolverQueryAttemptResult { - Target = target, - RequestFormat = result.RequestFormat, - Resolver = result.Resolver, - Response = result.Response, - Elapsed = result.Elapsed - }; - } - - private static void WriteProbeResult(ResolverProbeReportResult result) { - foreach (string line in ResolverReportTextFormatter.BuildProbeResultLines(result, FormatDuration)) { - Console.WriteLine(line); - } - } - - private static int WriteProbeSummary(ResolverProbeReport report, CliOptions options) { - ResolverProbeEvaluation evaluation = report.Evaluation; - ResolverProbeReportSummary summary = report.Summary; - int successCount = summary.SuccessfulCandidates; - Console.WriteLine("Probe Summary:"); - Console.WriteLine($" Successful probes: {successCount}/{summary.CandidateCount}"); - Console.WriteLine($" Failed probes: {summary.FailedCandidates}"); - Console.WriteLine($" Fastest success: {ResolverReportTextFormatter.DescribeProbeFastestSuccess(summary, FormatDuration)}"); - Console.WriteLine($" Fastest consensus responder: {ResolverReportTextFormatter.DescribeProbeFastestConsensus(summary, FormatDuration)}"); - Console.WriteLine($" Transport coverage: {ResolverReportTextFormatter.DescribeProbeTransportCoverage(summary)}"); - Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeProbeRuntimeCapabilities(summary)}"); - Console.WriteLine($" Answer consensus: {ResolverReportTextFormatter.DescribeProbeAnswerConsensus(summary)}"); - Console.WriteLine($" Mismatched responders: {ResolverReportTextFormatter.DescribeProbeMismatchedTargets(summary)}"); - Console.WriteLine($" Distinct answer sets: {summary.DistinctAnswerSets}"); - Console.WriteLine($" Answer variants: {ResolverReportTextFormatter.DescribeProbeAnswerVariants(summary)}"); - Console.WriteLine($" Recommended endpoint: {ResolverReportTextFormatter.DescribeProbeRecommended(summary, FormatDuration)}"); - WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}"); - - int exitCode = GetProbeExitCode(evaluation, options); - if (options.ProbeSummaryLine) { - Console.WriteLine(ResolverReportTextFormatter.BuildProbeSummaryLine(summary, exitCode)); - } - - if (!string.IsNullOrWhiteSpace(options.ProbeSavePath)) { - SaveResolverScoreSnapshot(options.ProbeSavePath!, report.Snapshot); - } - - return exitCode; - } - - private static ResolverProbePolicy CreateProbePolicy(CliOptions options) { - return new ResolverProbePolicy { - MinSuccessCount = options.ProbeMinSuccessCount, - MinSuccessPercent = options.ProbeMinSuccessPercent, - MinConsensusPercent = options.ProbeMinConsensusPercent, - RequireConsensus = options.ProbeRequireConsensus - }; - } - - private static int GetProbeExitCode(ResolverProbeEvaluation evaluation, CliOptions options) { - if (evaluation.SuccessfulCandidates == 0) { - return 1; - } - - if (options.ProbeMinSuccessCount.HasValue && evaluation.SuccessfulCandidates < options.ProbeMinSuccessCount.Value) { - return 3; - } - - if (options.ProbeMinSuccessPercent.HasValue && evaluation.SuccessPercent < options.ProbeMinSuccessPercent.Value) { - return 3; - } - - if (options.ProbeRequireConsensus && evaluation.DistinctAnswerSets > 1) { - return 2; - } - - if (options.ProbeMinConsensusPercent.HasValue && evaluation.ConsensusPercent < options.ProbeMinConsensusPercent.Value) { - return 2; - } - - return evaluation.PolicyPassed ? 0 : 1; - } - - private static void WriteQueryResponse(DnsResponse response, CliOptions options, TimeSpan elapsed) { - var presentationOptions = new DnsResponsePresentationOptions { - Mode = GetResponsePresentationMode(options), - TxtConcat = options.TxtConcatOutput, - ShowQuestions = ShouldShowQuestionSection(options, rawDefault: false), - ShowAnswers = ShouldShowAnswerSection(options, rawDefault: false), - ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: false), - ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: false) - }; - - if (presentationOptions.Mode == DnsResponsePresentationMode.Raw) { - presentationOptions.ShowQuestions = ShouldShowQuestionSection(options, rawDefault: true); - presentationOptions.ShowAnswers = ShouldShowAnswerSection(options, rawDefault: true); - presentationOptions.ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: true); - presentationOptions.ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: true); - } - - foreach (string line in DnsResponseTextFormatter.BuildOutputLines(response, presentationOptions, elapsed, FormatDuration)) { - Console.WriteLine(line); - } - } - - private static void WriteZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) { - switch (options.OutputFormat) { - case QueryOutputFormat.Json: - Console.WriteLine(DnsClientXJsonSerializer.Serialize(result)); - return; - default: - WritePrettyZoneTransferResponse(result, options); - return; - } - } - - private static void SaveResolverScoreSnapshot(string path, ResolverScoreSnapshot snapshot) { - ResolverScoreStore.Save(path, snapshot); - } - - private static void WriteResolverSelection(ResolverSelectionResult selection, CliOptions options) { - if (options.OutputFormat == QueryOutputFormat.Json) { - Console.WriteLine(DnsClientXJsonSerializer.Serialize(selection)); - return; - } - - WriteHumanLine(selection.Target); - } - - private static void WritePrettyZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) { - WriteHumanLine($"Zone Transfer: {result.Zone}"); - WriteHumanLine($" Selected authority: {result.SelectedAuthority}"); - WriteHumanLine($" Selected server: {result.SelectedServer}:{result.Port}"); - Console.WriteLine($" Chunks: {result.RecordSets.Length}"); - - if (options.TransferSummary) { - WriteHumanLine($" Authorities discovered: {string.Join(", ", result.Authorities)}"); - WriteHumanLine($" Tried servers: {string.Join(", ", result.TriedServers)}"); - } - - Console.WriteLine("Records:"); - foreach (ZoneTransferResult recordSet in result.RecordSets) { - if (recordSet.IsOpening) { - Console.WriteLine(" ;; opening SOA"); - } else if (recordSet.IsClosing) { - Console.WriteLine(" ;; closing SOA"); - } - - foreach (DnsAnswer record in recordSet.Records) { - Console.WriteLine($" {DnsTerminalText.Escape(record.Name)}\t{record.TTL}\tIN\t{record.Type}\t{DnsTerminalText.Escape(record.Data)}"); - } - } - } - - private static DnsResponsePresentationMode GetResponsePresentationMode(CliOptions options) { - if (options.ShortOutput) { - return DnsResponsePresentationMode.Short; - } - - return options.OutputFormat switch { - QueryOutputFormat.Json => DnsResponsePresentationMode.Json, - QueryOutputFormat.Raw => DnsResponsePresentationMode.Raw, - _ => DnsResponsePresentationMode.Pretty - }; - } - - private static bool ShouldShowQuestionSection(CliOptions options, bool rawDefault) { - if (options.HasExplicitSectionSelection) { - return options.ShowQuestionSection; - } - - return rawDefault; - } - - private static bool ShouldShowAnswerSection(CliOptions options, bool rawDefault) { - if (options.HasExplicitSectionSelection) { - return options.ShowAnswerSection; - } - - return !rawDefault || options.OutputFormat == QueryOutputFormat.Raw; - } - - private static bool ShouldShowAuthoritySection(CliOptions options, bool rawDefault) { - if (options.HasExplicitSectionSelection) { - return options.ShowAuthoritySection; - } - - return rawDefault; - } - - private static bool ShouldShowAdditionalSection(CliOptions options, bool rawDefault) { - if (options.HasExplicitSectionSelection) { - return options.ShowAdditionalSection; - } - - return rawDefault; - } - - private static void WriteExplain( - ResolverSingleOperationResult result, - string operation, - string target, - DnsRecordType recordType, - bool requestDnsSec, - bool validateDnsSec, - bool trace, - string? zone = null, - int? ttl = null) { - foreach (string line in ResolverReportTextFormatter.BuildSingleOperationExplainLines( - result, - operation, - target, - recordType, - requestDnsSec, - validateDnsSec, - FormatDuration, - zone, - ttl)) { - Console.WriteLine(line); - } - - if (trace) { - WriteTrace(result); - } - } - - private static void WriteTrace(ResolverSingleOperationResult result) { - foreach (string line in ResolverReportTextFormatter.BuildSingleOperationTraceLines(result, FormatDuration)) { - Console.WriteLine(line); - } - } - - private static string FormatDuration(TimeSpan duration) { - if (duration <= TimeSpan.Zero) { - return "0 ms"; - } - - if (duration.TotalMilliseconds < 1000) { - return $"{duration.TotalMilliseconds:F0} ms"; - } - - return $"{duration.TotalSeconds:F3} s"; - } - - private static void WriteHumanLine(string value) => Console.WriteLine(DnsTerminalText.Escape(value)); - - private static void WriteHumanError(string? value) => Console.Error.WriteLine(DnsTerminalText.Escape(value)); - - private static void ShowHelp() { - Console.WriteLine("DnsClientX.Cli - simple DNS query tool"); - Console.WriteLine("Usage: DnsClientX.Cli [options] "); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" -t, --type DNS record type (default A, comma-separated with --benchmark)"); - Console.WriteLine(" -e, --endpoint DNS endpoint name (default System, comma-separated with --benchmark)"); - Console.WriteLine(" --domain Domain name (comma-separated with --benchmark)"); - Console.WriteLine(" --dnssec Request DNSSEC records"); - Console.WriteLine(" --validate-dnssec Validate DNSSEC records"); - Console.WriteLine(" --dnssec-verifier Optional DNSSEC provider DLL path (dependencies beside it)"); - Console.WriteLine(" --wire-post Use DNS over HTTPS wire POST (when supported)"); - Console.WriteLine(" --bootstrap Resolve endpoint hostnames through an IP-literal UDP/TCP resolver, e.g. udp@1.1.1.1:53"); - Console.WriteLine(" --nsid Request resolver identity through EDNS (wire transports)"); - Console.WriteLine(" --format Query output format: pretty, json, raw"); - Console.WriteLine(" --short Print answer values only"); - Console.WriteLine(" --txt-concat Flatten TXT output into a single string"); - Console.WriteLine(" --question Show the question section"); - Console.WriteLine(" --answer Show the answer section"); - Console.WriteLine(" --authority Show the authority section"); - Console.WriteLine(" --additional Show the additional section"); - Console.WriteLine(" --reverse Force PTR lookup mode"); - Console.WriteLine(" --axfr Discover authoritative servers and attempt recursive AXFR"); - Console.WriteLine(" --transfer-summary Print discovered authorities and tried transfer targets"); - Console.WriteLine(" --benchmark Benchmark one or more endpoints across repeated queries"); - Console.WriteLine(" --benchmark-attempts Repeat each domain/type combination this many times"); - Console.WriteLine(" --benchmark-timeout Per-query timeout to apply during benchmark runs"); - Console.WriteLine(" --benchmark-concurrency Max concurrent queries across the benchmark run"); - Console.WriteLine(" --benchmark-connections Fresh clients per attempt or retained clients per target (default cold)"); - Console.WriteLine(" --benchmark-min-success-percent Require a minimum overall benchmark success rate"); - Console.WriteLine(" --benchmark-min-successful-candidates Require this many candidates to return at least one success"); - Console.WriteLine(" --benchmark-summary-only Suppress per-candidate benchmark rows and print only the header and summary"); - Console.WriteLine(" --benchmark-summary-line Append one stable BENCHMARK_SUMMARY key=value line for automation"); - Console.WriteLine(" --benchmark-save Persist benchmark scoring and recommendation data as JSON"); - Console.WriteLine(" --probe Probe the selected endpoint profile and related variants"); - Console.WriteLine(" --probe-endpoint Probe a custom endpoint such as tcp@1.1.1.1:53, doq@dns.quad9.net:853, or doh3@https://dns.quad9.net/dns-query"); - Console.WriteLine(" --resolver-file Load custom endpoints from a text file for probe or benchmark"); - Console.WriteLine(" --resolver-url Load custom endpoints from an HTTP or HTTPS URL for probe or benchmark"); - Console.WriteLine(" --resolver-validate Validate resolver endpoint inputs without querying DNS"); - Console.WriteLine(" --probe-summary-only Suppress per-endpoint probe lines and print only the header and summary"); - Console.WriteLine(" --probe-summary-line Append one stable PROBE_SUMMARY key=value line for automation"); - Console.WriteLine(" --probe-save Persist probe scoring and health data as JSON"); - Console.WriteLine(" --resolver-select Load a saved score snapshot and print the recommended resolver"); - Console.WriteLine(" --resolver-use Reuse the recommended resolver from a saved snapshot for query, probe, or benchmark"); - Console.WriteLine(" --probe-require-consensus Fail when successful probe responders disagree"); - Console.WriteLine(" --probe-min-consensus Fail when the top answer set is below the given 1-100 percentage"); - Console.WriteLine(" --probe-min-success Fail when fewer than the given number of probes succeed"); - Console.WriteLine(" --probe-min-success-percent Fail when the probe success rate is below the given 1-100 percentage"); - Console.WriteLine(" --capabilities Print the shared transport capability report"); - Console.WriteLine(" --stamp-info Print parsed DNS stamp details without querying DNS"); - Console.WriteLine(" --explain Print resolver and response diagnostics"); - Console.WriteLine(" --trace Print explain output plus audit details"); - Console.WriteLine(" --update Send dynamic update to one explicit authoritative endpoint"); - Console.WriteLine(" --ttl TTL for update (default 300)"); - Console.WriteLine(); - Console.WriteLine("Available endpoints:"); - foreach (var (ep, desc) in DnsEndpointExtensions.GetAllWithDescriptions()) { - Console.WriteLine($" {ep,-20} {desc}"); - } - } } } diff --git a/DnsClientX.PowerShell/CmdletClearDnsCache.cs b/DnsClientX.PowerShell/CmdletClearDnsCache.cs new file mode 100644 index 00000000..e2daa56d --- /dev/null +++ b/DnsClientX.PowerShell/CmdletClearDnsCache.cs @@ -0,0 +1,35 @@ +using System.Management.Automation; +using System.Threading.Tasks; + +namespace DnsClientX.PowerShell { + /// + /// Clears process-wide cached DNS responses. + /// Clears all response entries, or entries for one queried name and optional type across all resolver policies. Returns the number removed. This affects every cache-enabled DnsClientX client in this process; active queries may repopulate the cache. FastestWins endpoint choices are managed by Clear-DnsMultiResolverCache. + /// + /// Clear all cached responsesClear-DnsCache + /// Remove cached A responses for one nameClear-DnsCache -Name example.com -Type A + /// Preview selective invalidationClear-DnsCache -Name example.com -WhatIf + [Cmdlet(VerbsCommon.Clear, "DnsCache", SupportsShouldProcess = true, DefaultParameterSetName = "All")] + [OutputType(typeof(int))] + public sealed class CmdletClearDnsCache : AsyncPSCmdlet { + /// Original queried name to invalidate, including the expanded system-search name when applicable. Names normalize case, Unicode and the trailing root dot. Alias targets do not invalidate a different original query. + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Name", ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Domain", "DnsName")] + [ValidateNotNullOrEmpty] + public string Name { get; set; } = string.Empty; + + /// Only remove this query type. Omit to remove every cached type for Name. + [Parameter(Position = 1, ParameterSetName = "Name")] + public DnsRecordType? Type { get; set; } + + /// + protected override Task ProcessRecordAsync() { + bool selective = ParameterSetName == "Name"; + string target = selective ? $"{Name} ({(Type.HasValue ? Type.Value.ToString() : "all types")}) across all resolver policies" : "all cached DNS responses"; + if (ShouldProcess(target, "Clear process-wide DNS response cache entries")) { + WriteObject(selective ? ClientX.InvalidateResponseCache(Name, Type) : ClientX.ClearResponseCache()); + } + return Task.CompletedTask; + } + } +} diff --git a/DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs b/DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs new file mode 100644 index 00000000..fd607994 --- /dev/null +++ b/DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs @@ -0,0 +1,43 @@ +using System.Management.Automation; + +namespace DnsClientX.PowerShell { + /// + /// Compares resolver health and answers between saved DNS snapshots. + /// Reports candidate and answer changes, health regressions, and incompatible query scopes. Optional authoritative responses provide comparison context; a majority answer is not proof of authoritative correctness. + /// + /// Compare two saved probe or benchmark snapshotsCompare-DnsResolverSnapshot -BaselinePath './before.json' -CurrentPath './after.json' + [Cmdlet(VerbsData.Compare, "DnsResolverSnapshot", DefaultParameterSetName = "Path")] + [OutputType(typeof(ResolverScoreComparison))] + public sealed class CmdletCompareDnsResolverSnapshot : PSCmdlet { + /// Baseline resolver score snapshot JSON path. + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Path")] + [ValidateNotNullOrEmpty] + public string BaselinePath { get; set; } = string.Empty; + + /// Current resolver score snapshot JSON path. + [Parameter(Mandatory = true, Position = 1, ParameterSetName = "Path")] + [ValidateNotNullOrEmpty] + public string CurrentPath { get; set; } = string.Empty; + + /// Baseline typed resolver score snapshot. + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Snapshot")] + [ValidateNotNull] + public ResolverScoreSnapshot? Baseline { get; set; } + + /// Current typed resolver score snapshot, supplied directly or through the pipeline. + [Parameter(Mandatory = true, Position = 1, ParameterSetName = "Snapshot", ValueFromPipeline = true)] + [ValidateNotNull] + public ResolverScoreSnapshot? Current { get; set; } + + /// Caller-supplied authoritative responses for name/type comparison context. These must assert the AA flag; response origin remains the caller's responsibility. + [Parameter] + public ResolverAnswerReference[]? AuthoritativeReference { get; set; } + + /// + protected override void ProcessRecord() { + ResolverScoreSnapshot baseline = Baseline ?? ResolverScoreStore.Load(SessionState.Path.GetUnresolvedProviderPathFromPSPath(BaselinePath)); + ResolverScoreSnapshot current = Current ?? ResolverScoreStore.Load(SessionState.Path.GetUnresolvedProviderPathFromPSPath(CurrentPath)); + WriteObject(ResolverScoreComparer.Compare(baseline, current, AuthoritativeReference)); + } + } +} diff --git a/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs b/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs index b2fb5fe1..65243dce 100644 --- a/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs +++ b/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs @@ -4,7 +4,7 @@ namespace DnsClientX.PowerShell { /// /// Retrieves services advertised via DNS Service Discovery. - /// Queries the _services._dns-sd._udp tree for the specified domain and returns SRV/TXT data describing each advertised service. + /// Streams individual DNS-SD instances under a domain, browses one service type, or resolves SRV targets in connection-attempt order. Empty discovery produces no pipeline objects; use an array expression to collect zero or more results. /// /// /// Discover services under a domain @@ -14,19 +14,58 @@ namespace DnsClientX.PowerShell { /// Pipe results to select useful fields /// Get-DnsService -Domain example.com | Select-Object ServiceName,Target,Port /// - [Cmdlet(VerbsCommon.Get, "DnsService")] - public sealed class CmdletDiscoverDnsService : AsyncPSCmdlet { + /// + /// Browse HTTP service instances through an explicit resolver + /// Get-DnsService -Domain example.com -Service http -ResolverEndpoint 'https://dns.google/dns-query' + /// + /// + /// Resolve LDAP targets and addresses in connection-attempt order + /// Get-DnsService -Domain example.com -Service ldap -Resolve -ResolveHosts + /// + [Cmdlet(VerbsCommon.Get, "DnsService", DefaultParameterSetName = "Discover")] + [OutputType(typeof(DnsService), typeof(DnsSrvRecord))] + public sealed class CmdletDiscoverDnsService : DnsServiceCmdletBase { /// /// Domain name to search for advertised services. /// - [Parameter(Mandatory = true, Position = 0)] + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Name", "DnsName")] + [ValidateNotNullOrEmpty] public string Domain { get; set; } = string.Empty; + /// Service label to browse or resolve, such as http or _http. + [Parameter(Mandatory = true, ParameterSetName = "Browse")] + [Parameter(Mandatory = true, ParameterSetName = "Resolve")] + [ValidateNotNullOrEmpty] + public string Service { get; set; } = string.Empty; + + /// Service transport label, such as tcp or udp. + [Parameter(ParameterSetName = "Browse")] + [Parameter(ParameterSetName = "Resolve")] + [ValidateSet("tcp", "udp", "_tcp", "_udp")] + public string Protocol { get; set; } = "tcp"; + + /// Resolve SRV targets in RFC 2782 connection-attempt order instead of browsing DNS-SD instances. + [Parameter(Mandatory = true, ParameterSetName = "Resolve")] + public SwitchParameter Resolve { get; set; } + + /// With Resolve, include A and AAAA addresses for each target host. + [Parameter(ParameterSetName = "Resolve")] + public SwitchParameter ResolveHosts { get; set; } + /// protected override async Task ProcessRecordAsync() { - using var client = new ClientX(); - var results = await client.DiscoverServices(Domain); - WriteObject(results); + bool browse = ParameterSetName == "Browse"; + using var client = await CreateDiscoveryClientAsync().ConfigureAwait(false); + if (Resolve.IsPresent) { + WriteObject(await client.ResolveServiceAsync(Service, Protocol, Domain, ResolveHosts.IsPresent, CancelToken).ConfigureAwait(false), true); + } else if (browse) { + WriteObject(await client.BrowseServicesAsync(Service, Protocol, Domain, CancelToken).ConfigureAwait(false), true); + } else { + await foreach (DnsService service in client.EnumerateServicesAsync(Domain, CancelToken).ConfigureAwait(false)) { + WriteObject(service); + } + } } } } diff --git a/DnsClientX.PowerShell/CmdletDnsUpdate.cs b/DnsClientX.PowerShell/CmdletDnsUpdate.cs index a9d94c56..d04b4e50 100644 --- a/DnsClientX.PowerShell/CmdletDnsUpdate.cs +++ b/DnsClientX.PowerShell/CmdletDnsUpdate.cs @@ -7,6 +7,7 @@ namespace DnsClientX.PowerShell; /// /// Sends DNS UPDATE messages to a server. /// Adds or removes records in a zone using RFC 2136 over TCP. +/// Use WhatIf to preview an update without contacting the server, or Confirm to approve each change. /// /// /// Add an A record @@ -20,7 +21,7 @@ namespace DnsClientX.PowerShell; /// Delete one value while preserving the rest of the RRset /// Invoke-DnsUpdate -Zone example.com -Server 127.0.0.1 -Name www -Type A -Data 192.0.2.10 -DeleteValue /// -[Cmdlet(VerbsLifecycle.Invoke, "DnsUpdate")] +[Cmdlet(VerbsLifecycle.Invoke, "DnsUpdate", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] public sealed class CmdletDnsUpdate : AsyncPSCmdlet { /// Zone to update. [Parameter(Mandatory = true, Position = 0)] @@ -73,6 +74,13 @@ protected override async Task ProcessRecordAsync() { throw new PSArgumentException("-DeleteValue requires -Data with the exact record value to remove."); } + string action = Delete.IsPresent ? "Delete DNS record set" + : DeleteValue.IsPresent ? $"Delete DNS record value {Data}" + : $"Add DNS record {Data} with TTL {Ttl}"; + if (!ShouldProcess($"{Name} {Type} in zone {Zone} on {Server}:{Port}", action)) { + return; + } + var target = new ResolverExecutionTarget { DisplayName = $"tcp@{Server}:{Port}", ExplicitEndpoint = new DnsResolverEndpoint { diff --git a/DnsClientX.PowerShell/CmdletFindDnsService.cs b/DnsClientX.PowerShell/CmdletFindDnsService.cs index 413d84b8..59342792 100644 --- a/DnsClientX.PowerShell/CmdletFindDnsService.cs +++ b/DnsClientX.PowerShell/CmdletFindDnsService.cs @@ -4,7 +4,7 @@ namespace DnsClientX.PowerShell { /// /// Discovers services advertised via DNS Service Discovery. - /// Wraps to return services under a domain. + /// Streams individual service instances under a domain, browses one service type, or lists advertised service types. An empty discovery produces no pipeline objects; use an array expression to collect zero or more results. /// /// /// Find services under a domain @@ -14,17 +14,51 @@ namespace DnsClientX.PowerShell { /// Filter for a specific service type /// Find-DnsService -Domain example.com | Where-Object {$_.ServiceType -like '_http._tcp.*'} /// - [Cmdlet(VerbsCommon.Find, "DnsService")] - public sealed class CmdletFindDnsService : AsyncPSCmdlet { + /// + /// List advertised service types through a specific resolver + /// Find-DnsService -Domain example.com -ServiceTypes -ResolverEndpoint 'tcp@192.0.2.53:53' + /// + /// + /// Browse only HTTP instances + /// Find-DnsService -Domain example.com -Service http -Protocol tcp + /// + [Cmdlet(VerbsCommon.Find, "DnsService", DefaultParameterSetName = "Discover")] + [OutputType(typeof(DnsService), typeof(string))] + public sealed class CmdletFindDnsService : DnsServiceCmdletBase { /// Domain name to search for advertised services. - [Parameter(Mandatory = true, Position = 0)] + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Name", "DnsName")] + [ValidateNotNullOrEmpty] public string Domain { get; set; } = string.Empty; + /// List fully qualified service types from the DNS-SD meta-query. + [Parameter(Mandatory = true, ParameterSetName = "Types")] + [Alias("ListServiceTypes")] + public SwitchParameter ServiceTypes { get; set; } + + /// Service label to browse, such as http or _http. + [Parameter(Mandatory = true, ParameterSetName = "Browse")] + [ValidateNotNullOrEmpty] + public string Service { get; set; } = string.Empty; + + /// Service transport label, such as tcp or udp. + [Parameter(ParameterSetName = "Browse")] + [ValidateSet("tcp", "udp", "_tcp", "_udp")] + public string Protocol { get; set; } = "tcp"; + /// protected override async Task ProcessRecordAsync() { - using var client = new ClientX(); - var results = await client.DiscoverServices(Domain); - WriteObject(results); + bool browse = ParameterSetName == "Browse"; + using var client = await CreateDiscoveryClientAsync().ConfigureAwait(false); + if (ServiceTypes.IsPresent) { + WriteObject(await client.DiscoverServiceTypesAsync(Domain, CancelToken).ConfigureAwait(false), true); + } else if (browse) { + WriteObject(await client.BrowseServicesAsync(Service, Protocol, Domain, CancelToken).ConfigureAwait(false), true); + } else { + await foreach (DnsService service in client.EnumerateServicesAsync(Domain, CancelToken).ConfigureAwait(false)) { + WriteObject(service); + } + } } } } diff --git a/DnsClientX.PowerShell/CmdletGetDnsCache.cs b/DnsClientX.PowerShell/CmdletGetDnsCache.cs new file mode 100644 index 00000000..c7456a56 --- /dev/null +++ b/DnsClientX.PowerShell/CmdletGetDnsCache.cs @@ -0,0 +1,17 @@ +using System.Management.Automation; + +namespace DnsClientX.PowerShell { + /// + /// Gets process-wide DNS response cache statistics. + /// Returns capacity, current entries and lifetime lookup counters shared by all cache-enabled DnsClientX clients in this PowerShell process. Clearing entries does not reset these counters. + /// + /// Inspect the response cache used by Resolve-DnsGet-DnsCache + [Cmdlet(VerbsCommon.Get, "DnsCache")] + [OutputType(typeof(DnsResponseCacheStatistics))] + public sealed class CmdletGetDnsCache : PSCmdlet { + /// + protected override void ProcessRecord() { + WriteObject(ClientX.GetResponseCacheStatistics()); + } + } +} diff --git a/DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs b/DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs new file mode 100644 index 00000000..6804ffa1 --- /dev/null +++ b/DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs @@ -0,0 +1,27 @@ +using System.Management.Automation; + +namespace DnsClientX.PowerShell { + /// + /// Explains captured evidence from a completed DNS response. + /// Projects resolver, transport, cache, alias, authority and local DNSSEC evidence without additional DNS queries. UnavailableEvidence lists facts that the response cannot establish, including a complete delegation or DNSSEC trust-chain traversal. + /// + /// Explain a response returned by Resolve-DnsResolve-Dns example.com -FullResponse | Get-DnsQueryExplanation + [Cmdlet(VerbsCommon.Get, "DnsQueryExplanation")] + [OutputType(typeof(DnsQueryExplanation))] + public sealed class CmdletGetDnsQueryExplanation : PSCmdlet { + /// Completed DNS response, supplied directly or through the pipeline. + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] + [Alias("Response")] + [ValidateNotNull] + public DnsResponse InputObject { get; set; } = null!; + + /// Optional captured audit entries for the supplied response. Omit this parameter when individual attempt evidence was not captured. + [Parameter] + public AuditEntry[]? AuditTrail { get; set; } + + /// + protected override void ProcessRecord() { + WriteObject(DnsQueryExplanation.Create(InputObject, AuditTrail)); + } + } +} diff --git a/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs b/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs index 3d8a0396..fcc011f9 100644 --- a/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs +++ b/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs @@ -11,6 +11,7 @@ namespace DnsClientX.PowerShell { /// Resolves DNS records (A, AAAA, MX, TXT, …) over UDP, TCP, DoT, DoH, QUIC, or multicast with optional multi-resolver strategies. /// Supports single-provider queries, explicit servers with transport selection, multiple providers with FirstSuccess/FastestWins/SequentialFallback/RoundRobin, direct resolver endpoints, DNSSEC, EDNS/ECS, concurrency control, and TTL-based response caching. /// Specify either Name or Pattern. When no resolver source is specified, the library's default resolver is used. Timeout, retry, response, typed-record, and DNSSEC options apply to every resolver source. + /// Name accepts pipeline strings or objects with Name, Domain, or DnsName properties. By default each answer is an individual pipeline row. Use FullResponse for complete response objects, TypedRecords for typed values, or TypedRecordMetadata to keep typed values and record metadata together. /// /// /// Simple (system default) @@ -56,14 +57,29 @@ namespace DnsClientX.PowerShell { /// Reuse the recommended resolver from a saved selection snapshot /// Resolve-Dns -Name 'example.com' -Type A -ResolverSelectionPath '.\resolver-score.json' /// + /// + /// Resolve names from a pipeline and filter individual answers + /// 'example.com','example.net' | Resolve-Dns -Type A | Where-Object Type -EQ A + /// + /// + /// Stream a bounded batch in completion order + /// Resolve-Dns -Name 'example.com','example.net' -Type A,AAAA -Stream -CompletionOrder -MaxConcurrency 4 + /// + /// + /// Keep typed payloads together with their owner and TTL + /// Resolve-Dns -Name 'example.com' -Type MX -TypedRecordMetadata | Select-Object Name,TTL,Value + /// /// [Alias("Resolve-DnsQuery")] [Cmdlet(VerbsDiagnostic.Resolve, "Dns", DefaultParameterSetName = "Name")] + [OutputType(typeof(DnsAnswerMinimal), typeof(DnsResponse), typeof(DnsTypedRecord))] public sealed class CmdletResolveDnsQuery : AsyncPSCmdlet { /// - /// The name of the DNS record to query for + /// DNS names to query. Accepts pipeline strings and Name, Domain, or DnsName properties. /// - [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Name")] + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Name", ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Domain", "DnsName")] + [ValidateNotNullOrEmpty] public string[] Name { get; set; } = Array.Empty(); /// @@ -164,7 +180,7 @@ public sealed class CmdletResolveDnsQuery : AsyncPSCmdlet { public int PerEndpointMaxInFlight { get; set; } = 0; /// - /// Enables response caching based on TTLs for repeated queries of the same (name,type). Disabled by default. + /// Enables TTL-based response caching for any resolver source. Disabled by default. /// [Parameter(Mandatory = false)] public SwitchParameter ResponseCache { get; set; } @@ -195,18 +211,30 @@ public sealed class CmdletResolveDnsQuery : AsyncPSCmdlet { [Parameter(Mandatory = false)] public SwitchParameter RandomServer; /// - /// Provides the full response of the query. If not specified, only the minimal response is provided (just the answer). + /// Provides full response objects. If not specified, answers are enumerated as individual pipeline rows. /// If specified, the full response is provided (answer, authority, and additional sections). /// [Parameter(Mandatory = false)] public SwitchParameter FullResponse; + /// Emit each response when its turn is available in record type order, then name order. MaxConcurrency bounds active queries without collecting the entire batch. + [Parameter] + public SwitchParameter Stream { get; set; } + + /// With Stream, emit responses in completion order. Omit this switch to preserve record type order, then name order. + [Parameter] + public SwitchParameter CompletionOrder { get; set; } + /// /// When set, attempts to parse answers into typed record objects. /// [Parameter(Mandatory = false)] public SwitchParameter TypedRecords; + /// Return typed answer values together with their owner, TTL, class and original record. This implies TypedRecords; FullResponse takes precedence. + [Parameter] + public SwitchParameter TypedRecordMetadata { get; set; } + /// /// Gets or sets a value indicating whether TXT records should be /// parsed into specialized types (DMARC, SPF, etc.) when is @@ -342,10 +370,16 @@ public sealed class CmdletResolveDnsQuery : AsyncPSCmdlet { private void WriteResponse(DnsResponse record) { if (FullResponse.IsPresent) { WriteObject(record); + } else if (TypedRecordMetadata.IsPresent) { + foreach (DnsTypedRecord answer in record.GetTypedAnswers(ParseTypedTxtRecords.IsPresent)) { + PSObject output = PSObject.AsPSObject(answer); + output.TypeNames.Insert(0, "DnsClientX.TypedRecord"); + WriteObject(output); + } } else if (TypedRecords.IsPresent && record.TypedAnswers != null) { WriteObject(record.TypedAnswers, true); } else { - WriteObject(record.AnswersMinimal); + WriteObject(record.AnswersMinimal, true); } } @@ -378,7 +412,7 @@ private ResolveDnsRequest CreateRequest() { RequestDnsSec = RequestDnsSec.IsPresent || ValidateDnsSec.IsPresent, ValidateDnsSec = ValidateDnsSec.IsPresent, DnsSecSignatureVerifier = DnsSecProviderPath.Load(this, DnsSecVerifierPath), - TypedRecords = TypedRecords.IsPresent, + TypedRecords = TypedRecords.IsPresent || TypedRecordMetadata.IsPresent, ParseTypedTxtRecords = ParseTypedTxtRecords.IsPresent, EnableEdns = EnableEdns.IsPresent, EdnsBufferSize = EdnsBufferSize, @@ -424,7 +458,7 @@ private void ValidateResolverInputs() { bound.ContainsKey(nameof(ResolverDnsProvider)); if ((!hasMultiResolverSource || bound.ContainsKey(nameof(Server)) || bound.ContainsKey(nameof(ResolverSelectionPath))) && new[] { nameof(ResolverStrategy), nameof(MaxParallelism), nameof(RespectEndpointTimeout), nameof(FastestCacheMinutes), - nameof(PerEndpointMaxInFlight), nameof(ResponseCache), nameof(MaxCacheTtlSeconds) + nameof(PerEndpointMaxInFlight) }.Any(bound.ContainsKey)) { throw new PSArgumentException("Multi-resolver options require multiple DnsProvider values, ResolverEndpoint, or ResolverDnsProvider."); } @@ -445,6 +479,9 @@ private void LogResponse(DnsResponse record) { /// protected override async Task ProcessRecordAsync() { ValidateResolverInputs(); + if (CompletionOrder.IsPresent && !Stream.IsPresent) { + throw new PSArgumentException("CompletionOrder requires Stream."); + } ResolveDnsRequest request; try { request = CreateRequest(); @@ -454,10 +491,17 @@ protected override async Task ProcessRecordAsync() { } try { - var result = await ClientX.QueryDns(request, CancelToken).ConfigureAwait(false); - foreach (var record in result) { - LogResponse(record); - WriteResponse(record); + if (Stream.IsPresent) { + await foreach (DnsResponse record in ClientX.QueryDnsStreaming(request, !CompletionOrder.IsPresent, CancelToken).ConfigureAwait(false)) { + LogResponse(record); + WriteResponse(record); + } + } else { + var result = await ClientX.QueryDns(request, CancelToken).ConfigureAwait(false); + foreach (var record in result) { + LogResponse(record); + WriteResponse(record); + } } } catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { WriteError(new ErrorRecord(ex, "ResolveDnsInvalidInput", ErrorCategory.InvalidArgument, request)); diff --git a/DnsClientX.PowerShell/CmdletTestDnsBenchmark.cs b/DnsClientX.PowerShell/CmdletTestDnsBenchmark.cs index 0dc73ea5..cd08e557 100644 --- a/DnsClientX.PowerShell/CmdletTestDnsBenchmark.cs +++ b/DnsClientX.PowerShell/CmdletTestDnsBenchmark.cs @@ -431,6 +431,10 @@ public sealed class CmdletTestDnsBenchmark : AsyncPSCmdlet { [Parameter(Mandatory = false, ParameterSetName = "ResolverSelection")] public int? MinSuccessfulCandidates { get; set; } + /// Write a PolicyViolation error when the selected benchmark thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + [Parameter] + public SwitchParameter FailOnPolicyViolation { get; set; } + /// /// Emits a run-level summary object after the per-candidate benchmark results. /// @@ -499,6 +503,10 @@ protected override async Task ProcessRecordAsync() { if (IncludeSummary.IsPresent) { WriteObject(ToDnsBenchmarkSummary(report.Summary)); } + if (FailOnPolicyViolation.IsPresent && !report.Evaluation.PolicyPassed) { + WriteError(new ErrorRecord(new InvalidOperationException($"Benchmark policy failed: {report.Evaluation.PolicyReason}"), + "DnsBenchmarkPolicyViolation", ErrorCategory.InvalidResult, report.Summary)); + } } private ResolverBenchmarkPolicy CreateBenchmarkPolicy() { diff --git a/DnsClientX.PowerShell/CmdletTestDnsHealth.cs b/DnsClientX.PowerShell/CmdletTestDnsHealth.cs new file mode 100644 index 00000000..215500f7 --- /dev/null +++ b/DnsClientX.PowerShell/CmdletTestDnsHealth.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Management.Automation; +using System.Threading.Tasks; + +namespace DnsClientX.PowerShell { + /// + /// Runs a repeatable DNS resolver health profile. + /// Runs the profile's DNS query catalog and policy against its configured resolvers. IncludeBenchmark measures the same catalog. Returns the complete report; resolver agreement alone does not establish authoritative correctness. + /// + /// Run a saved profile and fail an automation run when its policy failsTest-DnsHealth -Path './dns-health.json' -FailOnPolicyViolation -ErrorAction Stop + /// Include performance measurements for the same profileTest-DnsHealth -Path './dns-health.json' -IncludeBenchmark + [Cmdlet(VerbsDiagnostic.Test, "DnsHealth", DefaultParameterSetName = "Path")] + [OutputType(typeof(ResolverHealthReport))] + public sealed class CmdletTestDnsHealth : AsyncPSCmdlet { + /// Local JSON health profile path. + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Path")] + [ValidateNotNullOrEmpty] + public string Path { get; set; } = string.Empty; + + /// Typed health profile supplied directly or through the pipeline. + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Profile", ValueFromPipeline = true)] + [ValidateNotNull] + public ResolverHealthProfile? Profile { get; set; } + + /// Benchmark the same DNS names, types and resolver targets after the health queries. + [Parameter] + public SwitchParameter IncludeBenchmark { get; set; } + + /// Write a PolicyViolation error on failed health gates. Use ErrorAction Stop for enforcement; the default only warns. + [Parameter] + public SwitchParameter FailOnPolicyViolation { get; set; } + + /// Local optional DNSSEC verifier DLL with dependencies beside it. + [Parameter] + public string? DnsSecVerifierPath { get; set; } + + /// + protected override async Task ProcessRecordAsync() { + ResolverHealthProfile profile = Profile ?? ResolverHealthProfileStore.Load(SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path)); + IDnsSecSignatureVerifier? verifier = DnsSecProviderPath.Load(this, DnsSecVerifierPath); + ResolverHealthReport report = await ResolverHealthWorkflow.RunAsync(profile, IncludeBenchmark.IsPresent, verifier, + cancellationToken: CancelToken).ConfigureAwait(false); + WriteObject(report); + if (!report.PolicyPassed) { + int failedQueries = report.Queries.Count(query => !query.Evaluation.PolicyPassed || query.Evaluation.SuccessfulCandidates == 0); + string message = $"DNS health profile '{report.ProfileName}' failed: {failedQueries} query gate(s) failed. Inspect Queries and Benchmark for details."; + if (FailOnPolicyViolation.IsPresent) { + WriteError(new ErrorRecord(new InvalidOperationException(message), "DnsHealthPolicyViolation", ErrorCategory.InvalidResult, report)); + } else { + WriteWarning(message); + } + } + } + } +} diff --git a/DnsClientX.PowerShell/CmdletTestDnsProbe.cs b/DnsClientX.PowerShell/CmdletTestDnsProbe.cs index 519ee5a7..43643ff8 100644 --- a/DnsClientX.PowerShell/CmdletTestDnsProbe.cs +++ b/DnsClientX.PowerShell/CmdletTestDnsProbe.cs @@ -426,6 +426,10 @@ public sealed class CmdletTestDnsProbe : AsyncPSCmdlet { [Parameter(Mandatory = false, ParameterSetName = "ResolverSelection")] public int? MinSuccessPercent { get; set; } + /// Write a PolicyViolation error when the selected probe thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + [Parameter] + public SwitchParameter FailOnPolicyViolation { get; set; } + /// /// Emits a run-level summary object after the per-candidate probe rows. /// @@ -492,6 +496,10 @@ protected override async Task ProcessRecordAsync() { if (IncludeSummary.IsPresent) { WriteObject(summary); } + if (FailOnPolicyViolation.IsPresent && !report.Evaluation.PolicyPassed) { + WriteError(new ErrorRecord(new InvalidOperationException($"Probe policy failed: {report.Evaluation.PolicyReason}"), + "DnsProbePolicyViolation", ErrorCategory.InvalidResult, summary)); + } } private void ValidateParameters() { diff --git a/DnsClientX.PowerShell/DnsServiceCmdletBase.cs b/DnsClientX.PowerShell/DnsServiceCmdletBase.cs new file mode 100644 index 00000000..5bdc9993 --- /dev/null +++ b/DnsClientX.PowerShell/DnsServiceCmdletBase.cs @@ -0,0 +1,54 @@ +using System; +using System.Management.Automation; +using System.Threading.Tasks; + +namespace DnsClientX.PowerShell { + /// Resolver binding shared by DNS service discovery commands. + public abstract class DnsServiceCmdletBase : AsyncPSCmdlet { + /// Built-in resolver used for discovery. Defaults to the system resolver. + [Parameter] + public DnsEndpoint DnsProvider { get; set; } = DnsEndpoint.System; + + /// One explicit resolver, such as udp@192.0.2.53:53, tcp@192.0.2.53:53, mdns@224.0.0.251:5353, or a DoH URL. + [Parameter] + [ValidateNotNullOrEmpty] + public string? ResolverEndpoint { get; set; } + + /// Saved resolver score snapshot whose recommended resolver is reused for discovery. + [Parameter] + [ValidateNotNullOrEmpty] + public string? ResolverSelectionPath { get; set; } + + /// Per-query timeout in milliseconds. + [Parameter] + [ValidateRange(1, int.MaxValue)] + public int TimeOut { get; set; } = Configuration.DefaultTimeout; + + /// IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + [Parameter] + public string? BootstrapResolver { get; set; } + + /// Create the configured client through the shared resolver execution owner. + protected async Task CreateDiscoveryClientAsync() { + bool explicitEndpoint = !string.IsNullOrWhiteSpace(ResolverEndpoint); + bool selection = !string.IsNullOrWhiteSpace(ResolverSelectionPath); + if ((explicitEndpoint && selection) || + ((explicitEndpoint || selection) && MyInvocation.BoundParameters.ContainsKey(nameof(DnsProvider)))) { + throw new PSArgumentException("Specify only one resolver source: DnsProvider, ResolverEndpoint, or ResolverSelectionPath."); + } + + var source = new ResolverExecutionTargetSource { + BuiltInEndpoints = explicitEndpoint || selection ? Array.Empty() : new[] { DnsProvider }, + ResolverEndpoints = explicitEndpoint ? new[] { ResolverEndpoint! } : Array.Empty(), + ResolverSelectionPath = selection + ? SessionState.Path.GetUnresolvedProviderPathFromPSPath(ResolverSelectionPath!) : null + }; + var options = new ResolverExecutionClientOptions { + TimeoutMs = TimeOut, + BootstrapResolver = string.IsNullOrWhiteSpace(BootstrapResolver) ? null : EndpointParser.ParseBootstrap(BootstrapResolver!) + }; + ResolverExecutionTarget target = await ResolverExecutionTargetResolver.ResolveSingleAsync(source, CancelToken).ConfigureAwait(false); + return ResolverExecutionClientFactory.CreateClient(target, options); + } + } +} diff --git a/DnsClientX.Tests/CliDnsUpdateTests.cs b/DnsClientX.Tests/CliDnsUpdateTests.cs index 80863954..1bd67eed 100644 --- a/DnsClientX.Tests/CliDnsUpdateTests.cs +++ b/DnsClientX.Tests/CliDnsUpdateTests.cs @@ -53,6 +53,29 @@ public async Task Update_WithoutExplicitEndpoint_ReportsRequiredOption() { Assert.Contains("--probe-endpoint", error); } + /// Update TTL validation rejects negative input while allowing immediate expiry and positive TTLs. + [Theory] + [InlineData(-1, false)] + [InlineData(0, true)] + [InlineData(300, true)] + public void Update_TtlIsValidatedBeforeExecution(int ttl, bool expectedSuccess) { + Type program = Assembly.Load("DnsClientX.Cli").GetType("DnsClientX.Cli.Program")!; + MethodInfo parse = program.GetMethod("TryParseArgs", BindingFlags.NonPublic | BindingFlags.Static)!; + object?[] parameters = { + new[] { "--resolver", "tcp@127.0.0.1:53", "--update", "example.com", "www.example.com", "A", "192.0.2.10", "--ttl", ttl.ToString(System.Globalization.CultureInfo.InvariantCulture) }, + null, null, null + }; + + Assert.Equal(expectedSuccess, (bool)parse.Invoke(null, parameters)!); + if (expectedSuccess) { + Assert.Equal(ttl, parameters[1]!.GetType().GetProperty("Ttl")!.GetValue(parameters[1])); + Assert.Null(parameters[2]); + } else { + Assert.Null(parameters[1]); + Assert.Contains("--ttl", parameters[2]?.ToString()); + } + } + private static async Task ReadAndReplyAsync(TcpListener listener, CancellationToken cancellationToken) { using TcpClient client = await listener.AcceptTcpClientAsync(); using NetworkStream stream = client.GetStream(); diff --git a/DnsClientX.Tests/CliResolverImportTests.cs b/DnsClientX.Tests/CliResolverImportTests.cs index b2ac83e4..b43783e1 100644 --- a/DnsClientX.Tests/CliResolverImportTests.cs +++ b/DnsClientX.Tests/CliResolverImportTests.cs @@ -101,10 +101,10 @@ private static (DnsResponse Response, TimeSpan Elapsed, string Resolver, DnsRequ } /// - /// Ensures resolver files can auto-enable probe mode and import multiple custom endpoints. + /// Ensures explicit probe mode imports multiple custom endpoints from resolver files. /// [Fact] - public async Task ResolverFile_AutoEnablesProbeAndLoadsCustomEndpoints() { + public async Task ResolverFile_ExplicitProbeLoadsCustomEndpoints() { string resolverFile = Path.GetTempFileName(); File.WriteAllText( resolverFile, @@ -117,7 +117,7 @@ public async Task ResolverFile_AutoEnablesProbeAndLoadsCustomEndpoints() { try { Console.SetOut(output); - int exitCode = await InvokeCliAsync("--resolver-file", resolverFile, "--probe-summary-only"); + int exitCode = await InvokeCliAsync("--probe", "--resolver-file", resolverFile, "--probe-summary-only"); Assert.Equal(0, exitCode); string text = output.ToString(); diff --git a/DnsClientX.Tests/CliWorkflowJsonTests.cs b/DnsClientX.Tests/CliWorkflowJsonTests.cs new file mode 100644 index 00000000..2ca7e1d8 --- /dev/null +++ b/DnsClientX.Tests/CliWorkflowJsonTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Tests; + +/// Exercises CLI JSON automation through real loopback transports and saved workflow artifacts. +[Collection("NoParallel")] +public sealed class CliWorkflowJsonTests { + private static Task InvokeAsync(params string[] args) => (Task)Assembly.Load("DnsClientX.Cli").GetType("DnsClientX.Cli.Program")!.GetMethod("Main", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, new object[] { args })!; + + /// Regression proof: JSON stdout remains one document when explain/trace accompany a direct resolver query. + [Theory] + [InlineData("--explain")] + [InlineData("--trace")] + public async Task DirectResolver_QueryJsonIncludesStructuredDiagnostics(string diagnosticSwitch) { + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + int port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + Task server = Task.Run(async () => { + Task receive = udp.ReceiveAsync(); + Assert.Same(receive, await Task.WhenAny(receive, Task.Delay(5000, timeout.Token))); + UdpReceiveResult query = await receive; + byte[] response = TestUtilities.CreateResponseFromQuery(query.Buffer); + await udp.SendAsync(response, response.Length, query.RemoteEndPoint); + }); + TextWriter original = Console.Out; + using var output = new StringWriter(); + try { + Console.SetOut(output); + Assert.Equal(0, await InvokeAsync("--resolver", $"udp@127.0.0.1:{port}", "--format", "json", diagnosticSwitch, "example.com")); + using JsonDocument json = JsonDocument.Parse(output.ToString()); + Assert.Equal("example.com", json.RootElement.GetProperty("Response").GetProperty("Question")[0].GetProperty("name").GetString()); + Assert.Equal("Network", json.RootElement.GetProperty("Explanation").GetProperty("ResponseSource").GetString()); + Assert.NotEqual(0, json.RootElement.GetProperty("Explanation").GetProperty("Attempts").GetArrayLength()); + Assert.True(json.RootElement.TryGetProperty("ConfiguredResolverPort", out var configuredPort)); + Assert.Equal(port, configuredPort.GetInt32()); + await server; + } finally { Console.SetOut(original); } + } + + /// Full probe/benchmark reports are parseable and retain correlated snapshots rather than human prefixes. + [Theory] + [InlineData("--probe")] + [InlineData("--benchmark")] + public async Task ResolverReports_JsonContainsFullReport(string mode) { + var server = new ResolverBenchmarkTcpFixture(); + TextWriter original = Console.Out; + using var output = new StringWriter(); + try { + Console.SetOut(output); + string[] args = mode == "--probe" + ? new[] { mode, "--resolver", $"tcp@127.0.0.1:{server.Port}", "--format", "json", "example.com" } + : new[] { mode, "--resolver", $"tcp@127.0.0.1:{server.Port}", "--format", "json", "--benchmark-attempts", "1", "example.com" }; + Assert.Equal(0, await InvokeAsync(args)); + using JsonDocument json = JsonDocument.Parse(output.ToString()); + Assert.Equal(1, json.RootElement.GetProperty("Results").GetArrayLength()); + Assert.True(json.RootElement.GetProperty("Summary").GetProperty("PolicyPassed").GetBoolean()); + Assert.Equal("example.com", json.RootElement.GetProperty("Snapshot").GetProperty("Answers")[0].GetProperty("Name").GetString()); + } finally { Console.SetOut(original); await server.DisposeAsync(); } + } + + /// A saved portable profile reaches the real CLI execution path and produces a complete reusable report. + [Fact] + public async Task Profile_ExecutesSavedSettingsAndSavesFullJsonReport() { + var server = new ResolverBenchmarkTcpFixture(); + string profilePath = Path.GetTempFileName(), reportPath = Path.GetTempFileName(); + TextWriter original = Console.Out; + using var output = new StringWriter(); + try { + ResolverHealthProfileStore.Save(profilePath, new ResolverHealthProfile { ResolverEndpoints = new[] { $"tcp@127.0.0.1:{server.Port}" }, Names = new[] { "profile.example" }, AttemptsPerCombination = 1, ProbePolicy = new ResolverProbePolicy { MinSuccessCount = 1 } }); + Console.SetOut(output); + Assert.Equal(0, await InvokeAsync("--profile", profilePath, "--benchmark", "--format", "json", "--profile-save", reportPath)); + Assert.Equal(output.ToString().Trim(), File.ReadAllText(reportPath).Trim()); + using JsonDocument json = JsonDocument.Parse(output.ToString()); + Assert.True(json.RootElement.GetProperty("PolicyPassed").GetBoolean()); + Assert.Equal("profile.example", json.RootElement.GetProperty("Queries")[0].GetProperty("Summary").GetProperty("Name").GetString()); + Assert.Equal(2, server.Queries); + } finally { Console.SetOut(original); File.Delete(profilePath); File.Delete(reportPath); await server.DisposeAsync(); } + } + + /// Saved snapshots reach the comparison workflow and expose missing evidence without claiming regression. + [Fact] + public async Task SnapshotComparison_JsonPreservesUnknownEvidence() { + string before = Path.GetTempFileName(), after = Path.GetTempFileName(); + TextWriter original = Console.Out; + using var output = new StringWriter(); + try { + ResolverScoreStore.Save(before, new ResolverScoreSnapshot { Summary = new ResolverScoreSummary { PolicyPassed = true }, Results = new[] { new ResolverScoreEntry { Target = "Removed" } } }); + ResolverScoreStore.Save(after, new ResolverScoreSnapshot { Summary = new ResolverScoreSummary { PolicyPassed = false }, Results = new[] { new ResolverScoreEntry { Target = "Added" } } }); + Console.SetOut(output); + Assert.Equal(0, await InvokeAsync("--resolver-compare", before, after, "--format", "json")); + using JsonDocument json = JsonDocument.Parse(output.ToString()); + Assert.False(json.RootElement.GetProperty("ScopeComparable").GetBoolean()); + Assert.False(json.RootElement.GetProperty("HealthRegression").GetBoolean()); + Assert.Equal(2, json.RootElement.GetProperty("Candidates").GetArrayLength()); + Assert.NotEqual(0, json.RootElement.GetProperty("IncomparableReasons").GetArrayLength()); + } finally { Console.SetOut(original); File.Delete(before); File.Delete(after); } + } + + /// Explicit CLI overrides cannot be silently ignored when settings come from a saved profile. + [Theory] + [InlineData("--benchmark-attempts")] + [InlineData("--benchmark-timeout")] + public async Task Profile_RejectsCliRunOverridesBeforeLoadingConfiguration(string option) { + TextWriter original = Console.Out, originalError = Console.Error; + using var output = new StringWriter(); using var error = new StringWriter(); + try { + Console.SetOut(output); Console.SetError(error); + Assert.Equal(1, await InvokeAsync("--profile", "unused-profile.json", "--benchmark", option, "1")); + Assert.Contains("profile run settings come from the profile", error.ToString()); + } finally { Console.SetOut(original); Console.SetError(originalError); } + } +} diff --git a/DnsClientX.Tests/DnsQueryExplanationTests.cs b/DnsClientX.Tests/DnsQueryExplanationTests.cs new file mode 100644 index 00000000..2d601c50 --- /dev/null +++ b/DnsClientX.Tests/DnsQueryExplanationTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Text.Json; + +namespace DnsClientX.Tests; + +/// Protects evidence provenance and JSON-safe diagnostics without additional network work. +public sealed class DnsQueryExplanationTests { + /// Hidden wire aliases and actual DNSSEC material survive a projected response explanation. + [Fact] + public void Explanation_UsesOriginalAliasesAndRecordedDependencies() { + var response = new DnsResponse { + Status = DnsResponseCode.NoError, ResponseSource = DnsResponseSource.Cache, DnsSecValidationStatus = DnsSecValidationStatus.Secure, + Answers = new[] { new DnsAnswer { Name = "target.example", Type = DnsRecordType.A, DataRaw = "192.0.2.1" } }, + WireAnswers = new[] { new DnsWireResourceRecord("alias.example", DnsRecordType.CNAME, 1, 60, 60, 0, 0, "target.example") }, + DnsSecValidationDependencies = new[] { new DnsSecValidationDependency("example", DnsRecordType.DNSKEY, new DnsResponse { Status = DnsResponseCode.NoError }, null) } + }; + var audit = new AuditEntry("alias.example", DnsRecordType.A) { Exception = new InvalidOperationException("transport failed"), RequestFormat = DnsRequestFormat.DnsOverTCP }; + DnsQueryExplanation explanation = DnsQueryExplanation.Create(response, new[] { audit }); + Assert.Single(explanation.Aliases); + Assert.Equal("alias.example", explanation.Aliases[0].Name); + Assert.Single(explanation.DnsSecValidationDependencies); + Assert.False(explanation.ResolverAuthenticData); + Assert.Equal(DnsSecValidationStatus.Secure, explanation.DnsSecValidationStatus); + Assert.Equal(DnsResponseSource.Cache, explanation.ResponseSource); + Assert.NotEmpty(explanation.UnavailableEvidence); + using JsonDocument json = JsonDocument.Parse(DnsClientXJsonSerializer.Serialize(explanation)); + Assert.Equal("transport failed", json.RootElement.GetProperty("Attempts")[0].GetProperty("Error").GetString()); + } +} diff --git a/DnsClientX.Tests/DnsQueryWorkflowTests.cs b/DnsClientX.Tests/DnsQueryWorkflowTests.cs new file mode 100644 index 00000000..b63464d0 --- /dev/null +++ b/DnsClientX.Tests/DnsQueryWorkflowTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace DnsClientX.Tests; + +/// Protects the public workflow contract across typed results, options and request streaming. +[Collection("NoParallel")] +public class DnsQueryWorkflowTests { + /// Filtering payload types retains the matching record's metadata, including unknown records. + [Fact] + public void TypedMetadataFollowsItsRecordRatherThanFilteredArrayPositions() { + var response = new DnsResponse { + Answers = new[] { + new DnsAnswer { Name = "alias.example", Type = DnsRecordType.CNAME, TTL = 13, DataRaw = "target.example" }, + new DnsAnswer { Name = "target.example", Type = DnsRecordType.A, TTL = 7, Class = 1, DataRaw = "192.0.2.1" }, + new DnsAnswer { Name = "bad.example", Type = DnsRecordType.A, TTL = 5, DataRaw = "not-an-address" } + } + }; + var address = Assert.Single(response.GetTypedAnswers()); + Assert.Equal("target.example", address.Name); + Assert.Equal(7, address.TTL); + Assert.Equal((ushort)1, address.Class); + Assert.Equal("192.0.2.1", address.Value.Address.ToString()); + Assert.Single(response.GetTypedAnswers()); + Assert.Equal(3, response.GetTypedAnswers().Length); + } + + /// Options streaming snapshots behavior and keeps bounded admission until consumers request more. + [Fact] + public async Task OptionsStreamingSnapshotsTypedProjectionAndPreservesInputOrder() { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = new ClientX("127.0.0.1", DnsRequestFormat.DnsOverUDP, enableCache: false); + client.EndpointConfiguration.Port = ((IPEndPoint)server.Client.LocalEndPoint!).Port; + client.EndpointConfiguration.MaxConcurrency = 2; + var peer = Task.Run(async () => { + for (int index = 0; index < 2; index++) { + var receive = server.ReceiveAsync(); + Assert.Same(receive, await Task.WhenAny(receive, Task.Delay(5000))); + var query = await receive; + int questionEnd = 12; + while (query.Buffer[questionEnd] != 0) questionEnd += query.Buffer[questionEnd] + 1; + questionEnd += 5; + byte[] answer = query.Buffer.Take(questionEnd).Concat(new byte[] { + 0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 15, 0, 4, 192, 0, 2, 1 + }).ToArray(); + answer[2] = 0x81; answer[3] = 0x80; answer[6] = 0; answer[7] = 1; + answer[8] = answer[9] = answer[10] = answer[11] = 0; + await server.SendAsync(answer, answer.Length, query.RemoteEndPoint); + } + }); + var options = new DnsResolveOptions { TypedRecords = true, RetryOnTransient = false }; + var stream = client.ResolveStreaming(new[] { "one.example", "two.example" }, new[] { DnsRecordType.A }, options); + options.TypedRecords = false; + var results = await CollectAsync(stream); + await peer; + Assert.Equal(new[] { "one.example", "two.example" }, results.Select(r => r.Answers[0].Name)); + Assert.All(results, result => Assert.IsType(Assert.Single(result.TypedAnswers!))); + await Assert.ThrowsAsync(() => client.Resolve("example", DnsRecordType.A, + new DnsResolveOptions { MaxRetries = -1 })); + } + + /// Request streaming retains source and DNSSEC/typed policy for every name/type combination. + [Fact] + public async Task RequestStreamingPreservesPolicyAndTypeMajorOrder() { + try { + ClientX.QueryDnsRequestOverride = (request, names, type, token) => { + Assert.True(request.TypedRecords); + Assert.True(request.ValidateDnsSec); + Assert.Equal(DnsEndpoint.Cloudflare, Assert.Single(request.DnsProviders)); + return Task.FromResult(names.Select(name => new DnsResponse { + Questions = new[] { new DnsQuestion { Name = name, Type = type } } + }).ToArray()); + }; + var results = await CollectAsync(ClientX.QueryDnsStreaming(new ResolveDnsRequest { + Names = new[] { "one.example", "two.example" }, RecordTypes = new[] { DnsRecordType.A, DnsRecordType.AAAA }, + DnsProviders = new[] { DnsEndpoint.Cloudflare }, TypedRecords = true, ValidateDnsSec = true, MaxConcurrency = 2 + })); + Assert.Equal(new[] { DnsRecordType.A, DnsRecordType.A, DnsRecordType.AAAA, DnsRecordType.AAAA }, + results.Select(r => r.Questions[0].Type)); + Assert.Equal(new[] { "one.example", "two.example", "one.example", "two.example" }, + results.Select(r => r.Questions[0].Name)); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + private static async Task CollectAsync(IAsyncEnumerable stream) { + var results = new List(); + await foreach (var response in stream) results.Add(response); + return results.ToArray(); + } +} diff --git a/DnsClientX.Tests/DnsResponseCacheTests.cs b/DnsClientX.Tests/DnsResponseCacheTests.cs index 92193a70..414e0802 100644 --- a/DnsClientX.Tests/DnsResponseCacheTests.cs +++ b/DnsClientX.Tests/DnsResponseCacheTests.cs @@ -216,11 +216,7 @@ private static void InjectClient(ClientX client, HttpClient httpClient) { } private static void ClearCache() { - var cacheField = typeof(ClientX).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Static)!; - var cache = cacheField.GetValue(null)!; - var dictField = cache.GetType().GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!; - var dict = dictField.GetValue(cache)!; - dict.GetType().GetMethod("Clear")!.Invoke(dict, null); + ClientX.ClearResponseCache(); } private static TimeSpan GetCachedTtl() { diff --git a/DnsClientX.Tests/DnsResponseCacheWorkflowTests.cs b/DnsClientX.Tests/DnsResponseCacheWorkflowTests.cs new file mode 100644 index 00000000..1504949c --- /dev/null +++ b/DnsClientX.Tests/DnsResponseCacheWorkflowTests.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using Xunit; + +namespace DnsClientX.Tests; + +/// Exercises cache lifetimes, evidence, and operations through real loopback DNS queries. +[Collection("NoParallel")] +public class DnsResponseCacheWorkflowTests { + /// Aliases never bypass RFC 2308 or denial proof lifetimes for NXDOMAIN or NODATA. + [Theory] + [InlineData(DnsResponseCode.NXDomain, 10, 1, 10, 10)] + [InlineData(DnsResponseCode.NoError, 10, 1, 10, 10)] + [InlineData(DnsResponseCode.NXDomain, 1, 10, 10, 10)] + [InlineData(DnsResponseCode.NXDomain, 10, 10, 1, 10)] + [InlineData(DnsResponseCode.NoError, 10, 10, 10, 1)] + public async Task NegativeAliasExpiresWithShortestDependency(DnsResponseCode status, + int soaTtl, int soaMinimum, int aliasTtl, int denialTtl) { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = CreateClient(server); + string name = Guid.NewGuid().ToString("N") + ".example"; + var first = await QueryNetwork(client, server, name, DnsRecordType.A, + query => NegativeAnswer(query, status, soaTtl, aliasTtl, denialTtl, soaMinimum: soaMinimum)); + Assert.Equal(status, first.Status); + Assert.Empty(first.Answers); // Caller projection hides the CNAME, not its lifetime dependency. + Assert.Equal(DnsResponseSource.Cache, (await client.Resolve(name, retryOnTransient: false)).ResponseSource); + await Task.Delay(1150); + var later = await QueryNetwork(client, server, name, DnsRecordType.A, + query => PositiveAnswer(query, DnsRecordType.A, 60)); + Assert.Equal(DnsResponseSource.Network, later.ResponseSource); + Assert.Equal("192.0.2.1", Assert.Single(later.Answers).Data); + } + + /// An alias-only negative response without SOA does not gain a guessed cache lifetime. + [Theory] + [InlineData(DnsResponseCode.NXDomain)] + [InlineData(DnsResponseCode.NoError)] + public async Task NegativeAliasWithoutSoaIsNotCached(DnsResponseCode status) { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = CreateClient(server); + string name = Guid.NewGuid().ToString("N") + ".example"; + await QueryNetwork(client, server, name, DnsRecordType.A, + query => NegativeAnswer(query, status, 10, 10, 10, includeSoa: false)); + var later = await QueryNetwork(client, server, name, DnsRecordType.A, + query => PositiveAnswer(query, DnsRecordType.A, 60)); + Assert.Equal(DnsResponseSource.Network, later.ResponseSource); + } + + /// All exposed TTL sections and metrics age while raw received bytes and OPT flags remain intact. + [Fact] + public async Task CachedSectionsAgeWithoutRewritingWireEvidence() { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = CreateClient(server); + string name = Guid.NewGuid().ToString("N") + ".example"; + var network = await QueryNetwork(client, server, name, DnsRecordType.A, + query => PositiveAnswer(query, DnsRecordType.A, 60, includeSections: true), typedRecords: true); + byte[] received = network.RawWireMessage!; + await Task.Delay(1150); + var cached = await client.Resolve(name, typedRecords: true, retryOnTransient: false); + Assert.Equal(DnsResponseSource.Cache, cached.ResponseSource); + Assert.InRange(Assert.Single(cached.Answers).TTL, 1, 59); + Assert.Equal(cached.Answers[0].TTL, cached.AnswersMinimal[0].TTL); + Assert.Equal(cached.Answers[0].TTL, cached.TtlMin); + Assert.Equal((double)cached.Answers[0].TTL, cached.TtlAvg); + Assert.InRange(Assert.Single(cached.Authorities).TTL, 1, 59); + Assert.InRange(Assert.Single(cached.Additional, answer => answer.Type == DnsRecordType.A).TTL, 1, 59); + Assert.Equal(Assert.Single(network.Additional, answer => answer.Type == DnsRecordType.OPT).TTL, + Assert.Single(cached.Additional, answer => answer.Type == DnsRecordType.OPT).TTL); + Assert.Equal(0x8000u, Assert.Single(cached.WireAdditional, record => record.Type == DnsRecordType.OPT).RawTtl); + Assert.IsType(Assert.Single(cached.TypedAnswers!)); + Assert.Equal(cached.Answers[0].TTL, Assert.Single(cached.GetTypedAnswers()).TTL); + Assert.Equal(cached.Authorities[0].TTL, Assert.Single(cached.GetTypedAuthorities()).TTL); + Assert.Equal(Assert.Single(cached.Additional, answer => answer.Type == DnsRecordType.A).TTL, + Assert.Single(cached.GetTypedAdditional()).TTL); + Assert.Equal(received, cached.RawWireMessage); + Assert.Equal(received, cached.Answers[0].SourceWireMessage); + Assert.Equal(60, network.Answers[0].TTL); + Assert.Equal(60, Assert.Single(cached.WireAnswers).Ttl); + } + + /// Public invalidation selects query identity across policies; capacity and counts cover shared clients. + [Fact] + public async Task ProcessCacheOperationsRespectNameTypeAndPolicyBoundaries() { + int originalCapacity = ClientX.ResponseCacheCapacity; + ClientX.ClearResponseCache(); + var baseline = ClientX.GetResponseCacheStatistics(); + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var first = CreateClient(server); + using var second = CreateClient(server); + first.MaxCacheTtl = TimeSpan.FromMinutes(1); + second.MaxCacheTtl = TimeSpan.FromMinutes(2); + const string name = "xn--bcher-kva.example"; + try { + await QueryNetwork(first, server, name, DnsRecordType.A, query => PositiveAnswer(query, DnsRecordType.A, 60)); + await QueryNetwork(second, server, name, DnsRecordType.A, query => PositiveAnswer(query, DnsRecordType.A, 60)); + await QueryNetwork(first, server, name, DnsRecordType.AAAA, query => PositiveAnswer(query, DnsRecordType.AAAA, 60)); + await QueryNetwork(first, server, "unrelated.example", DnsRecordType.A, query => PositiveAnswer(query, DnsRecordType.A, 60)); + Assert.Equal(DnsResponseSource.Cache, (await first.Resolve(name, retryOnTransient: false)).ResponseSource); + Assert.Equal(4, ClientX.GetResponseCacheStatistics().EntryCount); + Assert.Equal(2, ClientX.InvalidateResponseCache("BÜCHER.EXAMPLE.", DnsRecordType.A)); + Assert.Equal(DnsResponseSource.Cache, (await first.Resolve(name, DnsRecordType.AAAA, retryOnTransient: false)).ResponseSource); + Assert.Equal(1, ClientX.InvalidateResponseCache(name)); + Assert.Equal(1, ClientX.GetResponseCacheStatistics().EntryCount); + await QueryNetwork(first, server, "another.example", DnsRecordType.A, query => PositiveAnswer(query, DnsRecordType.A, 60)); + ClientX.ResponseCacheCapacity = 1; + var stats = ClientX.GetResponseCacheStatistics(); + Assert.Equal(1, stats.Capacity); + Assert.Equal(1, stats.EntryCount); + Assert.True(stats.Hits >= baseline.Hits + 2); + Assert.True(stats.Misses > baseline.Misses); + Assert.Equal(baseline.Evictions + 1, stats.Evictions); + Assert.Throws(() => ClientX.ResponseCacheCapacity = 0); + Assert.Equal(1, ClientX.ClearResponseCache()); + Assert.Equal(0, ClientX.GetResponseCacheStatistics().EntryCount); + } finally { + ClientX.ClearResponseCache(); + ClientX.ResponseCacheCapacity = originalCapacity; + } + } + + private static ClientX CreateClient(UdpClient server) { + var client = new ClientX("127.0.0.1", DnsRequestFormat.DnsOverUDP, timeOutMilliseconds: 3000, enableCache: true); + client.EndpointConfiguration.Port = ((IPEndPoint)server.Client.LocalEndPoint!).Port; + return client; + } + + private static async Task QueryNetwork(ClientX client, UdpClient server, string name, + DnsRecordType type, Func answer, bool typedRecords = false) { + var operation = client.Resolve(name, type, typedRecords: typedRecords, retryOnTransient: false); + var receiving = server.ReceiveAsync(); + Assert.Same(receiving, await Task.WhenAny(receiving, Task.Delay(4000))); + var packet = await receiving; + byte[] bytes = answer(packet.Buffer); + await server.SendAsync(bytes, bytes.Length, packet.RemoteEndPoint); + return await operation; + } + + private static byte[] NegativeAnswer(byte[] query, DnsResponseCode status, int soaTtl, + int aliasTtl, int denialTtl, bool includeSoa = true, int? soaMinimum = null) { + byte[] header = TestUtilities.CreateResponseFromQuery(query, (ushort)(0x8180 | (int)status)); + header[7] = 1; + header[9] = (byte)(includeSoa ? 2 : 1); + using var response = new MemoryStream(); + response.Write(header, 0, header.Length); + WriteRecord(response, new byte[] { 0xc0, 0x0c }, DnsRecordType.CNAME, aliasTtl, DnsWireNameCodec.ToCanonicalWire("target.example")); + if (includeSoa) WriteRecord(response, DnsWireNameCodec.ToCanonicalWire("example"), DnsRecordType.SOA, soaTtl, SoaData(soaMinimum ?? soaTtl)); + WriteRecord(response, DnsWireNameCodec.ToCanonicalWire("target.example"), DnsRecordType.NSEC, denialTtl, + DnsWireNameCodec.ToCanonicalWire("z.example").Concat(new byte[] { 0, 1, 0x20 }).ToArray()); + return response.ToArray(); + } + + private static byte[] PositiveAnswer(byte[] query, DnsRecordType type, int ttl, bool includeSections = false) { + byte[] header = TestUtilities.CreateResponseFromQuery(query); + header[7] = 1; + header[9] = (byte)(includeSections ? 1 : 0); + header[11] = (byte)(includeSections ? 2 : 0); + using var response = new MemoryStream(); + response.Write(header, 0, header.Length); + byte[] address = type == DnsRecordType.AAAA ? IPAddress.Parse("2001:db8::1").GetAddressBytes() : new byte[] { 192, 0, 2, 1 }; + WriteRecord(response, new byte[] { 0xc0, 0x0c }, type, ttl, address); + if (includeSections) { + WriteRecord(response, DnsWireNameCodec.ToCanonicalWire("example"), DnsRecordType.SOA, ttl, SoaData(ttl)); + WriteRecord(response, DnsWireNameCodec.ToCanonicalWire("ns.example"), DnsRecordType.A, ttl, new byte[] { 192, 0, 2, 53 }); + WriteRecord(response, new byte[] { 0 }, DnsRecordType.OPT, 0x8000, Array.Empty(), recordClass: 1232); + } + return response.ToArray(); + } + + private static byte[] SoaData(int minimum) { + using var stream = new MemoryStream(); + byte[] names = DnsWireNameCodec.ToCanonicalWire("ns.example").Concat(DnsWireNameCodec.ToCanonicalWire("hostmaster.example")).ToArray(); + stream.Write(names, 0, names.Length); + foreach (int value in new[] { 1, 3600, 600, 86400, minimum }) Write32(stream, value); + return stream.ToArray(); + } + + private static void WriteRecord(Stream stream, byte[] name, DnsRecordType type, int ttl, byte[] rdata, int recordClass = 1) { + stream.Write(name, 0, name.Length); + Write16(stream, (ushort)type); Write16(stream, recordClass); Write32(stream, ttl); Write16(stream, rdata.Length); + stream.Write(rdata, 0, rdata.Length); + } + private static void Write16(Stream stream, int value) { stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); } + private static void Write32(Stream stream, int value) { + stream.WriteByte((byte)(value >> 24)); stream.WriteByte((byte)(value >> 16)); stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); + } +} diff --git a/DnsClientX.Tests/DnsSecMaterialCacheTests.cs b/DnsClientX.Tests/DnsSecMaterialCacheTests.cs new file mode 100644 index 00000000..fd27a89b --- /dev/null +++ b/DnsClientX.Tests/DnsSecMaterialCacheTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace DnsClientX.Tests; + +/// Protects authenticated material reuse against cache poisoning and lifetime renewal. +public class DnsSecMaterialCacheTests { + /// Independent signed answers reuse the chain without another DNSKEY or DS fetch. + [Fact] + public async Task AcceptedMaterialIsReusedAndClearedWithoutRenewingItsReceipt() { + using var fixture = new DnsSecSignedFixture(); + using var cache = new DnsResponseCache(); + int lookups = 0; + async Task Lookup(string name, DnsRecordType type, CancellationToken token) { + Interlocked.Increment(ref lookups); + return await fixture.LookupAsync(name, type, token); + } + async Task Validate(string name) { + var session = new DnsSecMaterialSession(cache, (owner, type) => owner + ":" + type, Lookup); + var validator = new DnsSecValidationEngine(session.LookupAsync, trustAnchorStorePath: fixture.AnchorPath); + var answer = fixture.Signed(name, DnsRecordType.A, new byte[] { 192, 0, 2, 1 }); + var result = await validator.ValidateAsync(answer, name, DnsRecordType.A, default); + Assert.Equal(DnsSecValidationStatus.Secure, result.Status); + return session.Complete(validator, result.Status, TimeSpan.FromMinutes(5)); + } + var first = await Validate("one.example.com"); + int firstLookups = lookups; + Assert.True(firstLookups >= 3); + Assert.All(first, item => Assert.NotNull(item.ReusableUntilUtc)); + var second = await Validate("two.example.com"); + Assert.Equal(firstLookups, lookups); + Assert.All(second, item => Assert.Equal(DnsResponseSource.Cache, item.ResponseSource)); + Assert.True(cache.Clear() >= 3); + await Validate("three.example.com"); + Assert.Equal(firstLookups * 2, lookups); + } + + /// A bogus answer cannot authorize caching the otherwise well-formed chain material. + [Fact] + public async Task BogusValidationDoesNotPopulateMaterialCache() { + using var fixture = new DnsSecSignedFixture(); + using var cache = new DnsResponseCache(); + for (int index = 0; index < 2; index++) { + var session = new DnsSecMaterialSession(cache, (name, type) => name + ":" + type, + (name, type, token) => fixture.LookupAsync(name, type, token)); + var validator = new DnsSecValidationEngine(session.LookupAsync, trustAnchorStorePath: fixture.AnchorPath); + var answer = fixture.Signed("www.example.com", DnsRecordType.A, new byte[] { 192, 0, 2, 1 }); + answer.WireMessage[0] ^= 1; + var result = await validator.ValidateAsync(answer, "www.example.com", DnsRecordType.A, default); + Assert.Equal(DnsSecValidationStatus.Bogus, result.Status); + var dependencies = session.Complete(validator, result.Status, TimeSpan.FromMinutes(5)); + Assert.All(dependencies, item => Assert.Null(item.ReusableUntilUtc)); + Assert.Equal(0, cache.GetStatistics().EntryCount); + } + } + + /// Reusing material never resets its receipt-based authenticated TTL. + [Fact] + public async Task ExpiredMaterialRequiresAnotherFetch() { + using var fixture = new DnsSecSignedFixture { MaterialTtl = 2 }; + using var cache = new DnsResponseCache(); + int lookups = 0; + async Task Validate() { + var session = new DnsSecMaterialSession(cache, (name, type) => name + ":" + type, async (name, type, token) => { + lookups++; + return await fixture.LookupAsync(name, type, token); + }); + var validator = new DnsSecValidationEngine(session.LookupAsync, trustAnchorStorePath: fixture.AnchorPath); + var result = await validator.ValidateAsync(fixture.Signed("www.example.com", DnsRecordType.A, + new byte[] { 192, 0, 2, 1 }), "www.example.com", DnsRecordType.A, default); + Assert.Equal(DnsSecValidationStatus.Secure, result.Status); + session.Complete(validator, result.Status, TimeSpan.FromMinutes(5)); + } + await Validate(); + int first = lookups; + await Validate(); + Assert.Equal(first, lookups); + await Task.Delay(2200); + await Validate(); + Assert.Equal(first * 2, lookups); + } +} diff --git a/DnsClientX.Tests/DnsTransportCapabilitiesTests.cs b/DnsClientX.Tests/DnsTransportCapabilitiesTests.cs index a8dcae7f..95eca420 100644 --- a/DnsClientX.Tests/DnsTransportCapabilitiesTests.cs +++ b/DnsClientX.Tests/DnsTransportCapabilitiesTests.cs @@ -27,6 +27,27 @@ public void Supports_RequestFormat_UsesCapabilitySurface() { Assert.Equal(DnsTransportCapabilities.SupportsDnsOverHttp3, DnsTransportCapabilities.Supports(DnsRequestFormat.DnsOverHttp3)); Assert.Equal(DnsTransportCapabilities.SupportsDnsOverQuic, DnsTransportCapabilities.Supports(DnsRequestFormat.DnsOverQuic)); Assert.True(DnsTransportCapabilities.Supports(DnsRequestFormat.DnsOverHttps)); + Assert.False(DnsTransportCapabilities.Supports((DnsRequestFormat)int.MaxValue)); +#if NET8_0_OR_GREATER + Assert.True(DnsTransportCapabilities.Supports(DnsRequestFormat.DnsOverGrpc)); +#else + Assert.False(DnsTransportCapabilities.Supports(DnsRequestFormat.DnsOverGrpc)); +#endif + } + + /// Every public transport identifier reports its actual implementation and target support. + [Fact] + public void CapabilityReportIncludesJsonMulticastAndUnimplementedProtocols() { + DnsTransportCapabilityInfo[] report = DnsTransportCapabilities.GetCapabilityReport(); + foreach (DnsRequestFormat format in Enum.GetValues(typeof(DnsRequestFormat))) { + var entry = Assert.Single(report, item => item.RequestFormat == format); + Assert.Equal(DnsTransportCapabilities.Supports(format), entry.Supported); + Assert.False(string.IsNullOrWhiteSpace(entry.Notes)); + } + Assert.Contains("custom", Assert.Single(report, + item => item.RequestFormat == DnsRequestFormat.DnsOverGrpc).Name, StringComparison.OrdinalIgnoreCase); + Assert.False(Assert.Single(report, + item => item.RequestFormat == DnsRequestFormat.ObliviousDnsOverHttps).Supported); } /// diff --git a/DnsClientX.Tests/ReleaseSanityTests.cs b/DnsClientX.Tests/ReleaseSanityTests.cs index 0ea280f5..487a31b1 100644 --- a/DnsClientX.Tests/ReleaseSanityTests.cs +++ b/DnsClientX.Tests/ReleaseSanityTests.cs @@ -155,6 +155,7 @@ private static string ReadCmdletVerb(string source) { if (source.Contains("VerbsCommon.Clear", StringComparison.Ordinal)) return "Clear"; if (source.Contains("VerbsCommon.Find", StringComparison.Ordinal)) return "Find"; if (source.Contains("VerbsData.ConvertFrom", StringComparison.Ordinal)) return "ConvertFrom"; + if (source.Contains("VerbsData.Compare", StringComparison.Ordinal)) return "Compare"; if (source.Contains("VerbsDiagnostic.Resolve", StringComparison.Ordinal)) return "Resolve"; if (source.Contains("VerbsDiagnostic.Test", StringComparison.Ordinal)) return "Test"; if (source.Contains("VerbsLifecycle.Invoke", StringComparison.Ordinal)) return "Invoke"; diff --git a/DnsClientX.Tests/ResolveAsyncEnumerableLifetimeTests.cs b/DnsClientX.Tests/ResolveAsyncEnumerableLifetimeTests.cs new file mode 100644 index 00000000..841f8e5d --- /dev/null +++ b/DnsClientX.Tests/ResolveAsyncEnumerableLifetimeTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace DnsClientX.Tests; + +/// Protects bounded streaming, early disposal and shared transport cancellation through UDP. +[Collection("NoParallel")] +public class ResolveAsyncEnumerableLifetimeTests { + /// The stream starts only its bounded window, yields the first completed query and drains on break. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompletionOrderIsBoundedAndDisposalReleasesUdpTransport(bool enableCache) { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = CreateClient(server, enableCache); + string[] names = Enumerable.Range(0, 12).Select(index => index + "-" + Guid.NewGuid().ToString("N") + ".example").ToArray(); + var enumerator = client.ResolveAsyncEnumerable(names, DnsRecordType.A, retryOnTransient: false).GetAsyncEnumerator(); + try { + var advancing = enumerator.MoveNextAsync().AsTask(); + var first = await Receive(server); + var second = await Receive(server); + Assert.False(advancing.IsCompleted); + Assert.Equal(0, server.Available); + byte[] answer = Answer(second.Buffer); + await server.SendAsync(answer, answer.Length, second.RemoteEndPoint); + Assert.True(await Bounded(advancing)); + Assert.Equal(QueryName(second.Buffer), Assert.Single(enumerator.Current.Questions).Name); + await enumerator.DisposeAsync(); + using var replacement = new UdpClient(AddressFamily.InterNetwork); + replacement.Client.ExclusiveAddressUse = true; + replacement.Client.Bind(first.RemoteEndPoint); + Assert.Equal(0, server.Available); // No admission after the yielded item or disposal. + } finally { + await enumerator.DisposeAsync(); + } + } + + /// A stream owns its waiter, not another caller's shared query or eventual cache result. + [Fact] + public async Task DisposingStreamKeepsAnIndependentSingleFlightWaiterAlive() { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = CreateClient(server, enableCache: true); + string prefix = Guid.NewGuid().ToString("N"); + string[] names = { "first-" + prefix + ".example", "shared-" + prefix + ".example", "never-" + prefix + ".example" }; + var enumerator = client.ResolveAsyncEnumerable(names, DnsRecordType.A, retryOnTransient: false).GetAsyncEnumerator(); + try { + var advancing = enumerator.MoveNextAsync().AsTask(); + var first = await Receive(server); + var shared = await Receive(server); + if (QueryName(first.Buffer) != names[0]) (first, shared) = (shared, first); + var survivor = client.Resolve(names[1], retryOnTransient: false); + byte[] firstAnswer = Answer(first.Buffer); + await server.SendAsync(firstAnswer, firstAnswer.Length, first.RemoteEndPoint); + Assert.True(await Bounded(advancing)); + await enumerator.DisposeAsync(); + Assert.False(survivor.IsCompleted); + byte[] sharedAnswer = Answer(shared.Buffer); + await server.SendAsync(sharedAnswer, sharedAnswer.Length, shared.RemoteEndPoint); + var result = await Bounded(survivor); + Assert.Equal(DnsResponseSource.CoalescedNetwork, result.ResponseSource); + Assert.Equal("192.0.2.1", Assert.Single(result.Answers).Data); + Assert.Equal(DnsResponseSource.Cache, (await client.Resolve(names[1], retryOnTransient: false)).ResponseSource); + Assert.Equal(0, server.Available); + } finally { + await enumerator.DisposeAsync(); + } + } + + /// A failed query cancels and observes an admitted peer instead of leaving background work running. + [Fact] + public async Task QueryFailureCancelsAndObservesAdmittedPeers() { + using var client = new ClientX("127.0.0.1", DnsRequestFormat.DnsOverUDP); + client.EndpointConfiguration.MaxConcurrency = 2; + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.ResolverOverride = async (name, type, token) => { + if (name == "fail.example") throw new InvalidOperationException("fixture failure"); + try { await Task.Delay(Timeout.Infinite, token); } + finally { canceled.TrySetResult(token.IsCancellationRequested); } + return new DnsResponse(); + }; + var enumerator = client.ResolveAsyncEnumerable(new[] { "pending.example", "fail.example", "never.example" }, + DnsRecordType.A, retryOnTransient: false).GetAsyncEnumerator(); + try { + await Assert.ThrowsAsync(() => enumerator.MoveNextAsync().AsTask()); + Assert.True(await Bounded(canceled.Task)); + } finally { + await enumerator.DisposeAsync(); + } + } + + /// Unconfigured completion-order streams keep a bounded default for large inputs. + [Fact] + public async Task UnconfiguredStreamDoesNotAdmitEveryName() { + using var client = new ClientX("127.0.0.1", DnsRequestFormat.DnsOverUDP); + int entered = 0; + int canceled = 0; + client.ResolverOverride = async (name, type, token) => { + Interlocked.Increment(ref entered); + try { await Task.Delay(Timeout.Infinite, token); } + finally { Interlocked.Increment(ref canceled); } + return new DnsResponse(); + }; + using var caller = new CancellationTokenSource(); + var enumerator = client.ResolveAsyncEnumerable(Enumerable.Range(0, 100).Select(index => index + ".example").ToArray(), + DnsRecordType.A, retryOnTransient: false, cancellationToken: caller.Token).GetAsyncEnumerator(); + try { + var advancing = enumerator.MoveNextAsync().AsTask(); + Assert.Equal(32, Volatile.Read(ref entered)); + caller.Cancel(); + await Assert.ThrowsAnyAsync(() => advancing); + Assert.Equal(32, Volatile.Read(ref canceled)); + } finally { + await enumerator.DisposeAsync(); + } + } + + private static ClientX CreateClient(UdpClient server, bool enableCache) { + var client = new ClientX("127.0.0.1", DnsRequestFormat.DnsOverUDP, timeOutMilliseconds: 3000, enableCache: enableCache); + client.EndpointConfiguration.Port = ((IPEndPoint)server.Client.LocalEndPoint!).Port; + client.EndpointConfiguration.MaxConcurrency = 2; + return client; + } + private static Task Receive(UdpClient server) => Bounded(server.ReceiveAsync()); + private static async Task Bounded(Task task) { + Assert.Same(task, await Task.WhenAny(task, Task.Delay(4000))); + return await task; + } + private static byte[] Answer(byte[] query) { + byte[] response = TestUtilities.CreateResponseFromQuery(query); + response[7] = 1; + return response.Concat(new byte[] { 0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 192, 0, 2, 1 }).ToArray(); + } + private static string QueryName(byte[] query) { + var reader = new DnsWireReader(query, 12, query.Length); + return DnsWireNameCodec.TrimTrailingRootDot(reader.ReadName()); + } +} diff --git a/DnsClientX.Tests/ResolveDnsRequestTests.cs b/DnsClientX.Tests/ResolveDnsRequestTests.cs index 86b74a55..e5c06b39 100644 --- a/DnsClientX.Tests/ResolveDnsRequestTests.cs +++ b/DnsClientX.Tests/ResolveDnsRequestTests.cs @@ -285,6 +285,8 @@ public void CreateClientForEndpoint_RequestOptionsArePreserved() { UserAgent = "DnsClientX.Tests", HttpVersion = new Version(3, 0), IgnoreCertificateErrors = true, + ResponseCache = true, + MaxCacheTtlSeconds = 23, UseTcpFallback = false, ProxyUri = new Uri("http://127.0.0.1:8888"), MaxConnectionsPerServer = 12 @@ -292,6 +294,8 @@ public void CreateClientForEndpoint_RequestOptionsArePreserved() { var endpoint = new DnsResolverEndpoint { Transport = Transport.Doh, DohUrl = new Uri("https://dns.example/dns-query"), + TlsServerName = "dns.example", + Family = System.Net.Sockets.AddressFamily.InterNetworkV6, AllowTcpFallback = true }; @@ -300,6 +304,10 @@ public void CreateClientForEndpoint_RequestOptionsArePreserved() { Assert.Equal("DnsClientX.Tests", client.EndpointConfiguration.UserAgent); Assert.Equal(new Version(3, 0), client.EndpointConfiguration.HttpVersion); Assert.True(client.IgnoreCertificateErrors); + Assert.True(client.CacheEnabled); + Assert.Equal(TimeSpan.FromSeconds(23), client.MaxCacheTtl); + Assert.Equal("dns.example", client.EndpointConfiguration.TlsServerName); + Assert.Equal(System.Net.Sockets.AddressFamily.InterNetworkV6, client.EndpointConfiguration.PreferredAddressFamily); Assert.False(client.EndpointConfiguration.UseTcpFallback); Assert.Equal(12, client.EndpointConfiguration.MaxConnectionsPerServer); Assert.Equal(4321, client.EndpointConfiguration.TimeOut); diff --git a/DnsClientX.Tests/ResolveDnsServerFallbackTests.cs b/DnsClientX.Tests/ResolveDnsServerFallbackTests.cs new file mode 100644 index 00000000..fa8c6620 --- /dev/null +++ b/DnsClientX.Tests/ResolveDnsServerFallbackTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace DnsClientX.Tests { + /// Protects explicit-server fallback, failure diagnostics, retry outcomes, and cancellation. + [Collection("NoParallel")] + public sealed class ResolveDnsServerFallbackTests { + /// Exhausting one server's transport deadline must still allow fallback and all-server execution. + [Theory] + [InlineData(false, false, "timeout")] + [InlineData(false, false, "task-canceled")] + [InlineData(false, false, "operation-canceled")] + [InlineData(false, true, "timeout")] + [InlineData(false, true, "task-canceled")] + [InlineData(false, true, "operation-canceled")] + [InlineData(true, false, "timeout")] + [InlineData(true, false, "task-canceled")] + [InlineData(true, false, "operation-canceled")] + [InlineData(true, true, "timeout")] + [InlineData(true, true, "task-canceled")] + [InlineData(true, true, "operation-canceled")] + public async Task QueryDns_ExhaustedServerDeadlineContinuesToNextServer(bool streamed, bool allServers, string failureKind) { + int calls = 0; + try { + ClientX.QueryDnsRequestOverride = (request, names, type, token) => { + if (++calls <= 2) { + Exception failure = failureKind switch { + "task-canceled" => new TaskCanceledException("First server deadline elapsed."), + "operation-canceled" => new OperationCanceledException("First server deadline elapsed."), + _ => new TimeoutException("First server deadline elapsed.") + }; + throw failure; + } + return Task.FromResult(new[] { ServerTestResponse(names[0], type, DnsResponseCode.NoError) }); + }; + var request = ServerTestRequest(allServers); + request.RetryCount = 2; + + DnsResponse[] responses = await ExecuteServerTestRequest(request, streamed); + + Assert.Equal(3, calls); + Assert.Equal(2, responses.Length); + Assert.Equal(DnsResponseCode.ServerFailure, responses[0].Status); + Assert.Equal(DnsQueryErrorCode.Timeout, responses[0].ErrorCode); + Assert.Equal(failureKind switch { + "task-canceled" => typeof(TaskCanceledException), + "operation-canceled" => typeof(OperationCanceledException), + _ => typeof(TimeoutException) + }, responses[0].Exception!.GetType()); + Assert.Equal(DnsResponseCode.NoError, responses[1].Status); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + /// When all servers fail, each requested name/type retains a typed failure and endpoint diagnostic. + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task QueryDns_AllServerDeadlinesReturnPerQueryDiagnostics(bool streamed, bool allServers) { + try { + ClientX.QueryDnsRequestOverride = (_, _, _, _) => throw new TimeoutException("Resolver deadline elapsed."); + var request = ServerTestRequest(allServers); + request.Names = new[] { "one.example", "two.example" }; + request.RecordTypes = new[] { DnsRecordType.A, DnsRecordType.AAAA }; + + DnsResponse[] responses = await ExecuteServerTestRequest(request, streamed); + + Assert.Equal(8, responses.Length); + Assert.All(responses, response => { + Assert.Equal(DnsResponseCode.ServerFailure, response.Status); + Assert.Equal(DnsQueryErrorCode.Timeout, response.ErrorCode); + Assert.IsType(response.Exception); + Assert.Contains("Resolver deadline elapsed", response.Error, StringComparison.Ordinal); + DnsQuestion question = Assert.Single(response.Questions); + Assert.Equal(DnsRequestFormat.DnsOverHttps, question.RequestFormat); + Assert.Contains(question.HostName, request.Servers); + Assert.Contains(question.Name, request.Names); + Assert.Contains(question.Type, request.RecordTypes); + }); + foreach (string server in request.Servers) + foreach (string name in request.Names) + foreach (DnsRecordType type in request.RecordTypes) + Assert.Single(responses, response => response.Questions[0].HostName == server + && response.Questions[0].Name == name && response.Questions[0].Type == type); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + /// One failed name must not erase another name's successful result from the same server. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task QueryDns_MixedServerBatchPreservesEachQueryOutcome(bool streamed) { + int completedPairs = 0; + try { + ClientX.QueryDnsRequestOverride = (request, names, type, token) => { + bool firstServer = completedPairs < request.Names.Length; + completedPairs += names.Length; + if (firstServer && names.Contains("timeout.example")) throw new TimeoutException("One name timed out."); + DnsResponse[] responses = names.Select(name => ServerTestResponse(name, type, DnsResponseCode.NoError)).ToArray(); + foreach (DnsResponse response in responses) response.Questions[0].HostName = request.Servers[firstServer ? 0 : 1]; + return Task.FromResult(responses); + }; + var request = ServerTestRequest(allServers: true); + request.Names = new[] { "success.example", "timeout.example" }; + + DnsResponse[] responses = await ExecuteServerTestRequest(request, streamed); + + Assert.Equal(4, responses.Length); + Assert.Equal(new[] { "success.example", "timeout.example", "success.example", "timeout.example" }, + responses.Select(response => response.Questions[0].Name)); + Assert.Equal(DnsResponseCode.NoError, responses[0].Status); + Assert.Equal(request.Servers[0], responses[0].Questions[0].HostName); + Assert.Equal(DnsResponseCode.ServerFailure, responses[1].Status); + Assert.Equal(DnsQueryErrorCode.Timeout, responses[1].ErrorCode); + Assert.IsType(responses[1].Exception); + Assert.All(responses.Skip(2), response => Assert.Equal(DnsResponseCode.NoError, response.Status)); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + /// Caller cancellation must stop admission before another server is attempted. + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task QueryDns_CallerCancellationStopsServerFallback(bool streamed, bool allServers) { + using var cancellation = new CancellationTokenSource(); + int calls = 0; + try { + ClientX.QueryDnsRequestOverride = (_, _, _, token) => { + calls++; + cancellation.Cancel(); + throw new TaskCanceledException("Caller canceled the query."); + }; + + await Assert.ThrowsAnyAsync(() => + ExecuteServerTestRequest(ServerTestRequest(allServers), streamed, cancellation.Token)); + Assert.Equal(1, calls); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + /// A later DNS failure response supersedes an earlier thrown transport error after retries are exhausted. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task QueryDns_FinalRetryResponseReplacesOlderException(bool streamed) { + int calls = 0; + try { + ClientX.QueryDnsRequestOverride = (_, names, type, _) => { + if (++calls == 1) throw new TimeoutException("Earlier deadline elapsed."); + var response = ServerTestResponse(names[0], type, DnsResponseCode.ServerFailure); + response.Error = "Final DNS server failure."; + return Task.FromResult(new[] { response }); + }; + var request = ServerTestRequest(allServers: false); + request.Servers = new[] { request.Servers[0] }; + request.Fallback = false; + request.RetryCount = 2; + + DnsResponse response = Assert.Single(await ExecuteServerTestRequest(request, streamed)); + + Assert.Equal(2, calls); + Assert.Equal(DnsResponseCode.ServerFailure, response.Status); + Assert.Equal("Final DNS server failure.", response.Error); + Assert.Null(response.Exception); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + /// Single-server execution without a fallback/reporting flag keeps its exception contract. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task QueryDns_SingleServerDeadlineStillThrowsWithoutFallback(bool streamed) { + try { + ClientX.QueryDnsRequestOverride = (_, _, _, _) => throw new TimeoutException("Resolver deadline elapsed."); + var request = ServerTestRequest(allServers: false); + request.Fallback = false; + + await Assert.ThrowsAsync(() => ExecuteServerTestRequest(request, streamed)); + } finally { ClientX.QueryDnsRequestOverride = null; } + } + + private static ResolveDnsRequest ServerTestRequest(bool allServers) => new() { + Names = new[] { "example.com" }, RecordTypes = new[] { DnsRecordType.A }, + Servers = new[] { "192.0.2.1", "192.0.2.2" }, RequestFormat = DnsRequestFormat.DnsOverHttps, + Fallback = !allServers, AllServers = allServers, RetryCount = 1, RetryDelayMs = 0, MaxConcurrency = 1 + }; + + private static DnsResponse ServerTestResponse(string name, DnsRecordType type, DnsResponseCode status) => new() { + Status = status, + Questions = new[] { new DnsQuestion { Name = name, OriginalName = name, Type = type, RequestFormat = DnsRequestFormat.DnsOverHttps } } + }; + + private static async Task ExecuteServerTestRequest(ResolveDnsRequest request, bool streamed, + CancellationToken cancellationToken = default) { + if (!streamed) return await ClientX.QueryDns(request, cancellationToken); + var responses = new List(); + await foreach (DnsResponse response in ClientX.QueryDnsStreaming(request, cancellationToken: cancellationToken)) responses.Add(response); + return responses.ToArray(); + } + + } +} diff --git a/DnsClientX.Tests/ResolverBenchmarkEvidenceTests.cs b/DnsClientX.Tests/ResolverBenchmarkEvidenceTests.cs new file mode 100644 index 00000000..c31f1542 --- /dev/null +++ b/DnsClientX.Tests/ResolverBenchmarkEvidenceTests.cs @@ -0,0 +1,22 @@ +using System; + +namespace DnsClientX.Tests; + +/// Protects correlations for independently produced benchmark attempts. +public sealed class ResolverBenchmarkEvidenceTests { + /// Result input order is not fabricated into name/type evidence. + [Fact] + public void Report_UsesCapturedQueryMetadataAndKeepsUnrecordedScopeUnavailable() { + var attempts = new[] { + new ResolverQueryAttemptResult { Target = "System", Name = "second.example", RecordType = DnsRecordType.AAAA, Error = "timeout" }, + new ResolverQueryAttemptResult { Target = "System", Name = "first.example", RecordType = DnsRecordType.A, Response = new DnsResponse { Status = DnsResponseCode.NoError } }, + new ResolverQueryAttemptResult { Target = "System", Error = "unattributed attempt" } + }; + ResolverBenchmarkReport report = ResolverBenchmarkReportBuilder.Build(attempts, new[] { "first.example", "second.example" }, new[] { DnsRecordType.A, DnsRecordType.AAAA }, 1, 1, 2000, new ResolverBenchmarkPolicy()); + Assert.Equal("second.example", report.Attempts[0].Name); + Assert.Equal(DnsRecordType.AAAA, report.Attempts[0].RecordType); + Assert.Equal("timeout", report.Attempts[0].Error); + Assert.Null(report.Attempts[2].RecordType); + Assert.Equal(2, report.Snapshot.Answers.Length); + } +} diff --git a/DnsClientX.Tests/ResolverHealthWorkflowTests.cs b/DnsClientX.Tests/ResolverHealthWorkflowTests.cs new file mode 100644 index 00000000..de40007b --- /dev/null +++ b/DnsClientX.Tests/ResolverHealthWorkflowTests.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Tests; + +/// Protects portable profile application and correlated execution through the existing engines. +public sealed class ResolverHealthWorkflowTests { + /// Equivalent endpoint inputs cannot inflate candidate gates or dispatch through profile consumers. + [Theory] + [InlineData("udp@127.0.0.1:53", "udp@127.0.0.1:53")] + [InlineData("127.0.0.1", "udp@127.0.0.1:53")] + [InlineData("dot@resolver.example", "tls@RESOLVER.EXAMPLE:853")] + [InlineData("https://resolver.example/dns-query", "doh@https://resolver.example:443/dns-query")] + public async Task Profile_RejectsEquivalentExecutionTargetsBeforeStorageOrDispatch(string first, string second) { + var profile = new ResolverHealthProfile { + ResolverEndpoints = new[] { first, second }, Names = new[] { "example.com" }, + ProbePolicy = new ResolverProbePolicy { MinSuccessCount = 2 }, + BenchmarkPolicy = new ResolverBenchmarkPolicy { MinSuccessfulCandidates = 2 } + }; + DnsResolverEndpoint[] parsed = EndpointParser.TryParseMany(profile.ResolverEndpoints, out var errors); + Assert.Empty(errors); + Assert.Equal(2, parsed.Length); + Assert.Single(ResolverExecutionPlanBuilder.BuildExplicitTargets(parsed)); + ArgumentException error = Assert.Throws(() => profile.Validate()); + Assert.Contains("distinct", error.Message, StringComparison.OrdinalIgnoreCase); + string path = Path.GetTempFileName(); + try { + Assert.Throws(() => ResolverHealthProfileStore.Save(path, profile)); + File.WriteAllText(path, DnsClientXJsonSerializer.Serialize(profile)); + Assert.Throws(() => ResolverHealthProfileStore.Load(path)); + } finally { File.Delete(path); } + int dispatched = 0; + await Assert.ThrowsAsync(() => ResolverHealthWorkflow.RunAsync(profile, includeBenchmark: true, + explicitOverride: (endpoint, name, type, token) => { + Interlocked.Increment(ref dispatched); + return Task.FromResult(new ResolverQueryAttemptResult { Response = new DnsResponse { Status = DnsResponseCode.NoError } }); + })); + Assert.Equal(0, dispatched); + } + + /// Distinct DNSSEC settings remain separate executions even when endpoint display labels match. + [Fact] + public async Task Profile_AllowsSameLabelWithDistinctExecutionSettings() { + string[] inputs = new[] { false, true }.Select(dnsSec => DnsStamp.Create(new DnsResolverEndpoint { + Host = "127.0.0.1", Port = 53, Transport = Transport.Udp, DnsSecOk = dnsSec + })).ToArray(); + var profile = new ResolverHealthProfile { + ResolverEndpoints = inputs, Names = new[] { "example.com" }, + ProbePolicy = new ResolverProbePolicy { MinSuccessCount = 2 }, + BenchmarkPolicy = new ResolverBenchmarkPolicy { MinSuccessfulCandidates = 2 } + }; + profile.Validate(); + DnsResolverEndpoint[] parsed = EndpointParser.TryParseMany(inputs, out var errors); + Assert.Empty(errors); + ResolverExecutionTarget[] targets = ResolverExecutionPlanBuilder.BuildExplicitTargets(parsed); + Assert.Equal(2, targets.Length); + Assert.Equal(targets[0].DisplayName, targets[1].DisplayName); + int dispatched = 0; + ResolverHealthReport report = await ResolverHealthWorkflow.RunAsync(profile, + explicitOverride: (endpoint, name, type, token) => { + Interlocked.Increment(ref dispatched); + return Task.FromResult(new ResolverQueryAttemptResult { + Target = ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint), + Response = new DnsResponse { Status = DnsResponseCode.NoError } + }); + }); + Assert.True(report.PolicyPassed); + Assert.Equal(2, Assert.Single(report.Queries).Results.Length); + Assert.Equal(2, dispatched); + Assert.Equal(inputs, profile.ResolverEndpoints); + } + + /// Benchmark aggregation cannot safely present distinct executions under one candidate label. + [Fact] + public async Task Profile_BenchmarkRejectsSameLabelSettingsBeforeAnyDispatch() { + var profile = new ResolverHealthProfile { + ResolverEndpoints = new[] { false, true }.Select(dnsSec => DnsStamp.Create(new DnsResolverEndpoint { + Host = "127.0.0.1", Port = 53, Transport = Transport.Udp, DnsSecOk = dnsSec + })).ToArray(), + Names = new[] { "example.com" }, ProbePolicy = new ResolverProbePolicy { MinSuccessCount = 2 } + }; + int dispatched = 0; + ArgumentException exception = await Assert.ThrowsAsync(() => ResolverHealthWorkflow.RunAsync(profile, + includeBenchmark: true, explicitOverride: (endpoint, name, type, token) => { + Interlocked.Increment(ref dispatched); + return Task.FromResult(new ResolverQueryAttemptResult { + Target = ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint), + Response = new DnsResponse { Status = DnsResponseCode.NoError } + }); + })); + Assert.Contains("separate benchmark profiles", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, dispatched); + } + + /// Exercises an actual TCP target with a multi-query profile and bounded benchmark. + [Fact] + public async Task Profile_UsesActualEndpointAndCorrelatesBenchmarkCatalog() { + var server = new ResolverBenchmarkTcpFixture(); + try { + var profile = new ResolverHealthProfile { + ResolverEndpoints = new[] { $"tcp@127.0.0.1:{server.Port}" }, Names = new[] { "one.example", "two.example" }, + RecordTypes = new[] { DnsRecordType.A, DnsRecordType.AAAA }, AttemptsPerCombination = 2, + MaxConcurrency = 2, TimeoutMs = 1500, ProbePolicy = new ResolverProbePolicy { MinSuccessCount = 1 }, + BenchmarkPolicy = new ResolverBenchmarkPolicy { MinSuccessPercent = 100 } + }; + ResolverHealthReport report = await ResolverHealthWorkflow.RunAsync(profile, includeBenchmark: true); + Assert.True(report.PolicyPassed); + Assert.Equal(4, report.Queries.Length); + Assert.All(report.Queries, q => Assert.Equal(1500, q.Summary.TimeoutMs)); + Assert.Equal(12, server.Queries); + Assert.Equal(8, report.Benchmark!.Snapshot.Answers.Length); + Assert.Equal(4, report.Benchmark.Snapshot.Answers.Select(a => (a.Name, a.RecordType)).Distinct().Count()); + Assert.NotNull(report.Benchmark.Snapshot.Context); + Assert.All(report.Queries, q => Assert.Single(q.Snapshot.Answers)); + } finally { await server.DisposeAsync(); } + } + + /// Protects bounded JSON loading and persisted thresholds rather than prose/configuration shape. + [Fact] + public void ProfileStore_RoundTripsAndRejectsUnknownSchemaAndOversizedInput() { + string path = Path.GetTempFileName(); + try { + var profile = new ResolverHealthProfile { BuiltInEndpoints = new[] { DnsEndpoint.System }, Names = new[] { "example.com" }, ProbePolicy = new ResolverProbePolicy { MinSuccessPercent = 100 } }; + ResolverHealthProfileStore.Save(path, profile); + Assert.Equal(100, ResolverHealthProfileStore.Load(path).ProbePolicy.MinSuccessPercent); + profile.SchemaVersion = 2; + File.WriteAllText(path, DnsClientXJsonSerializer.Serialize(profile)); + Assert.Throws(() => ResolverHealthProfileStore.Load(path)); + File.WriteAllText(path, new string(' ', ResolverHealthProfileStore.MaximumProfileBytes + 1)); + Assert.Throws(() => ResolverHealthProfileStore.Load(path)); + } finally { File.Delete(path); } + } + + /// Prevents an unbounded request catalog from dispatching and preserves caller cancellation. + [Fact] + public async Task Profile_RejectsOversizedWorkloadAndHonorsCanceledCaller() { + var profile = new ResolverHealthProfile { BuiltInEndpoints = new[] { DnsEndpoint.System }, Names = Enumerable.Range(0, 64).Select(n => $"name{n}.example").ToArray(), RecordTypes = new[] { DnsRecordType.A, DnsRecordType.AAAA }, AttemptsPerCombination = 1000 }; + Assert.Throws(() => profile.Validate()); + profile.Names = new[] { "example.com" }; profile.AttemptsPerCombination = 1; + using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => ResolverHealthWorkflow.RunAsync(profile, cancellationToken: cancellation.Token)); + } + + /// The profile's per-query threshold changes the outcome when one catalog entry fails. + [Fact] + public async Task Profile_AppliesPerQueryPolicyRatherThanOverallMajority() { + var profile = new ResolverHealthProfile { BuiltInEndpoints = new[] { DnsEndpoint.System }, Names = new[] { "good.example", "failed.example" }, ProbePolicy = new ResolverProbePolicy { MinSuccessCount = 1 } }; + ResolverHealthReport report = await ResolverHealthWorkflow.RunAsync(profile, builtInOverride: (endpoint, name, type, token) => Task.FromResult(new ResolverQueryAttemptResult { + Target = "System", Response = new DnsResponse { Status = name == "good.example" ? DnsResponseCode.NoError : DnsResponseCode.ServerFailure } + })); + Assert.False(report.PolicyPassed); + Assert.True(report.Queries[0].Evaluation.PolicyPassed); + Assert.False(report.Queries[1].Evaluation.PolicyPassed); + } +} diff --git a/DnsClientX.Tests/ResolverSavedEndpointContractTests.cs b/DnsClientX.Tests/ResolverSavedEndpointContractTests.cs new file mode 100644 index 00000000..91dfb2f0 --- /dev/null +++ b/DnsClientX.Tests/ResolverSavedEndpointContractTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace DnsClientX.Tests; + +/// Protects persisted resolver settings and the ambiguity boundary of readable candidate labels. +public sealed class ResolverSavedEndpointContractTests { + private static DnsResolverEndpoint ConfiguredEndpoint(bool doh) => new DnsResolverEndpoint { + Host = "resolver.example", Port = doh ? 8443 : 8853, Transport = doh ? Transport.Doh : Transport.Dot, + DohUrl = doh ? new Uri("https://resolver.example:8443/dns-query") : null, + RequestFormat = doh ? DnsRequestFormat.DnsOverHttpsWirePost : DnsRequestFormatMapper.FromTransport(Transport.Dot), + TlsServerName = "tls.resolver.example", Family = AddressFamily.InterNetworkV6, + Timeout = TimeSpan.FromMilliseconds(1234), EdnsBufferSize = 1280, DnsSecOk = true, + AllowTcpFallback = false + }; + + private static Task ProbeAsync(DnsResolverEndpoint[] endpoints) => ResolverProbeWorkflow.RunAsync( + ResolverExecutionPlanBuilder.BuildExplicitTargets(endpoints), "example.com", DnsRecordType.A, 2000, + new ResolverQueryRunOptions(), new ResolverProbePolicy(), + explicitOverride: (endpoint, name, type, token) => Task.FromResult(new ResolverQueryAttemptResult { + Target = ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint), Resolver = endpoint.Host!, + RequestFormat = endpoint.RequestFormat ?? DnsRequestFormatMapper.FromTransport(endpoint.Transport), + Response = new DnsResponse { Status = DnsResponseCode.NoError, Answers = new[] { + new DnsAnswer { Name = name, Type = type, DataRaw = endpoint.Timeout!.Value.TotalMilliseconds == 1234 ? "192.0.2.1" : "192.0.2.2" } + } }, Elapsed = endpoint.Timeout.Value + })); + + /// Saving a recommendation preserves settings omitted from a readable endpoint label. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SaveLoadSelection_PreservesExplicitExecutionSettings(bool doh) { + DnsResolverEndpoint endpoint = ConfiguredEndpoint(doh); + string originalKey = ResolverEndpointClientFactory.GetExecutionKey(endpoint); + ResolverProbeReport report = await ProbeAsync(new[] { endpoint }); + string path = Path.GetTempFileName(); + try { + ResolverScoreStore.Save(path, report.Snapshot); + ResolverScoreSnapshot loaded = ResolverScoreStore.Load(path); + Assert.True(ResolverScoreSelector.TrySelectRecommended(loaded, out ResolverSelectionResult? selection, out string? error), error); + Assert.Equal(originalKey, ResolverEndpointClientFactory.GetExecutionKey(selection!.ExplicitEndpoint!)); + Assert.Equal(originalKey, ResolverEndpointClientFactory.GetExecutionKey(endpoint)); + } finally { File.Delete(path); } + } + + /// Two distinct configurations with the same label cannot be reused or compared as one candidate. + [Fact] + public async Task ActualTcpWorkflows_SameLabelRetainEvidenceWithoutAmbiguousSelection() { + var server = new ResolverBenchmarkTcpFixture(); + try { + var first = new DnsResolverEndpoint { Host = "127.0.0.1", Port = server.Port, Transport = Transport.Tcp, Timeout = TimeSpan.FromSeconds(1) }; + var second = new DnsResolverEndpoint { Host = "127.0.0.1", Port = server.Port, Transport = Transport.Tcp, Timeout = TimeSpan.FromSeconds(2) }; + ResolverExecutionTarget[] targets = ResolverExecutionPlanBuilder.BuildExplicitTargets(new[] { first, second }); + Assert.Equal(2, targets.Length); + Assert.Equal(targets[0].DisplayName, targets[1].DisplayName); + ResolverProbeReport probe = await ResolverProbeWorkflow.RunAsync(targets, "example.com", DnsRecordType.A, 2000, new ResolverQueryRunOptions(), new ResolverProbePolicy()); + ResolverBenchmarkReport benchmark = await ResolverBenchmarkWorkflow.RunAsync(targets, new[] { "example.com" }, new[] { DnsRecordType.A }, 1, 1, 2000, new ResolverQueryRunOptions(), new ResolverBenchmarkPolicy()); + Assert.Equal(4, server.Queries); + Assert.Equal(2, probe.Results.Length); + Assert.Equal(2, benchmark.Attempts.Length); + foreach (ResolverScoreSnapshot snapshot in new[] { probe.Snapshot, benchmark.Snapshot }) { + Assert.False(snapshot.Summary.RecommendationAvailable); + Assert.False(ResolverScoreSelector.TrySelectRecommended(snapshot, out _, out string? error)); + Assert.Contains("ambiguous", error!, StringComparison.OrdinalIgnoreCase); + Assert.False(ResolverScoreComparer.Compare(snapshot, snapshot).ScopeComparable); + Assert.DoesNotContain(snapshot.Results, row => row.IsRecommended); + } + ResolverScoreChange candidate = Assert.Single(ResolverScoreComparer.Compare(probe.Snapshot, probe.Snapshot).Candidates); + Assert.Equal("Ambiguous", candidate.Change); + Assert.Null(candidate.Baseline); + Assert.Null(candidate.Current); + Assert.Null(candidate.SuccessPercentDelta); + Assert.Null(candidate.AverageMsDelta); + } finally { await server.DisposeAsync(); } + } + + /// Ranked probe rows retain the payload of their source attempt when readable labels collide. + [Fact] + public async Task Probe_SameLabelRanksPreserveSourceAttemptEvidence() { + DnsResolverEndpoint first = ConfiguredEndpoint(false); + var second = new DnsResolverEndpoint { Host = first.Host, Port = first.Port, Transport = first.Transport, Timeout = TimeSpan.FromMilliseconds(1500) }; + ResolverProbeReport report = await ProbeAsync(new[] { second, first }); + Assert.Equal(2, report.Results.Length); + Assert.Equal(1234, report.Results[0].ElapsedMs); + Assert.Contains("192.0.2.1", report.Results[0].AnswerSignature); + Assert.Equal(1500, report.Results[1].ElapsedMs); + Assert.Contains("192.0.2.2", report.Results[1].AnswerSignature); + Assert.Single(report.Results, row => row.IsFastestSuccess); + } + + /// New snapshots cannot fall back to lossy labels when their saved metadata is missing or contradictory. + [Theory] + [InlineData("missing")] + [InlineData("inconsistent")] + [InlineData("ambiguous")] + [InlineData("identity-missing")] + public async Task Selection_RejectsIncompleteOrInconsistentNewEndpointMetadata(string defect) { + ResolverProbeReport report = await ProbeAsync(new[] { ConfiguredEndpoint(true) }); + ResolverScoreRunContext context = report.Snapshot.Context!; + string target = report.Snapshot.Summary.RecommendedTarget; + switch (defect) { + case "missing": context.ExplicitEndpoints = null; break; + case "inconsistent": context.ExplicitEndpoints![target] = ConfiguredEndpoint(false); break; + case "ambiguous": + context.ExplicitEndpoints = new Dictionary { + [target] = ConfiguredEndpoint(true), [target.ToUpperInvariant()] = ConfiguredEndpoint(true) + }; + break; + case "identity-missing": context.TargetExecutionKeys = null; break; + } + Assert.False(ResolverScoreSelector.TrySelectRecommended(report.Snapshot, out ResolverSelectionResult? selection, out string? error)); + Assert.Null(selection); + Assert.Contains("saved", error!, StringComparison.OrdinalIgnoreCase); + } + + /// Genuinely older snapshots retain the established label-parser selection behavior. + [Fact] + public void Selection_LegacySnapshotRetainsLabelFallback() { + var snapshot = new ResolverScoreSnapshot { Summary = new ResolverScoreSummary { + RecommendationAvailable = true, RecommendedTarget = "tls@resolver.example:8853" + } }; + Assert.True(ResolverScoreSelector.TrySelectRecommended(snapshot, out ResolverSelectionResult? selection, out string? error), error); + Assert.Equal("resolver.example", selection!.ExplicitEndpoint!.Host); + Assert.Equal(8853, selection.ExplicitEndpoint.Port); + Assert.Equal(Transport.Dot, selection.ExplicitEndpoint.Transport); + } +} diff --git a/DnsClientX.Tests/ResolverScoreComparerTests.cs b/DnsClientX.Tests/ResolverScoreComparerTests.cs new file mode 100644 index 00000000..b3f8b948 --- /dev/null +++ b/DnsClientX.Tests/ResolverScoreComparerTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DnsClientX.Tests; + +/// Protects factual change reporting and the distinction between evidence and authoritative truth. +public sealed class ResolverScoreComparerTests { + private static DnsResponse Answer(string value, bool authoritative = false) => new DnsResponse { Status = DnsResponseCode.NoError, IsAuthoritativeAnswer = authoritative, Answers = new[] { new DnsAnswer { Name = "example.com", Type = DnsRecordType.A, DataRaw = value, TTL = 60 } } }; + private static ResolverScoreSnapshot Snapshot(bool passed, string value) => new ResolverScoreSnapshot { + Context = new ResolverScoreRunContext { Host = "host", Runtime = "runtime", OperatingSystem = "os", Architecture = "arch", TargetExecutionKeys = new Dictionary { ["System"] = "builtin:System" } }, + Summary = new ResolverScoreSummary { Domains = new[] { "example.com" }, RecordTypes = new[] { DnsRecordType.A }, TimeoutMs = 2000, PolicyPassed = passed }, + Results = new[] { new ResolverScoreEntry { Target = "System", SuccessCount = 1, SuccessPercent = 100, AverageMs = 10 } }, + Answers = new[] { new ResolverAnswerObservation { Target = "System", Name = "example.com", RecordType = DnsRecordType.A, Status = "NoError", AnswerSignature = DnsResponseAnswerSignature.Build(Answer(value)) } } + }; + + /// Only matching measurement scopes can produce a regression conclusion. + [Fact] + public void Compare_GatesHealthAndLatencyOnScope() { + ResolverScoreSnapshot before = Snapshot(true, "192.0.2.1"), after = Snapshot(false, "192.0.2.2"); + after.Results[0].AverageMs = 15; + ResolverScoreComparison comparable = ResolverScoreComparer.Compare(before, after); + Assert.True(comparable.ScopeComparable); Assert.True(comparable.HealthRegression); + Assert.Equal(5d, comparable.Candidates[0].AverageMsDelta); + after.Context!.Host = "another-host"; + ResolverScoreComparison differentHost = ResolverScoreComparer.Compare(before, after); + Assert.False(differentHost.ScopeComparable); Assert.False(differentHost.HealthRegression); + Assert.Null(differentHost.Candidates[0].AverageMsDelta); + after.Context = null; + after.Answers = Array.Empty(); + ResolverScoreComparison legacy = ResolverScoreComparer.Compare(before, after); + Assert.False(legacy.ScopeComparable); Assert.Null(Assert.Single(legacy.Answers).Changed); + } + + /// Reports additions/removals and reference divergence without deriving correctness from a majority. + [Fact] + public void Compare_ReportsChangesAndCallerSuppliedAuthoritativeContext() { + ResolverScoreSnapshot before = Snapshot(true, "192.0.2.1"), after = Snapshot(true, "192.0.2.2"); + before.Results = before.Results.Concat(new[] { new ResolverScoreEntry { Target = "Google" } }).ToArray(); + after.Results = after.Results.Concat(new[] { new ResolverScoreEntry { Target = "Cloudflare" } }).ToArray(); + before.Context!.TargetExecutionKeys!["Google"] = "builtin:Google"; + after.Context!.TargetExecutionKeys!["Cloudflare"] = "builtin:Cloudflare"; + ResolverScoreComparison comparison = ResolverScoreComparer.Compare(before, after, new[] { new ResolverAnswerReference { Name = "example.com.", RecordType = DnsRecordType.A, Source = "operator capture", Response = Answer("192.0.2.3", authoritative: true) } }); + Assert.Contains(comparison.Candidates, c => c.Target == "Google" && c.Change == "Removed"); + Assert.Contains(comparison.Candidates, c => c.Target == "Cloudflare" && c.Change == "Added"); + Assert.True(Assert.Single(comparison.Answers).Changed); + Assert.Equal("Differs", comparison.Answers[0].ReferenceComparison); + Assert.False(comparison.HealthRegression); + Assert.Throws(() => ResolverScoreComparer.Compare(before, after, new[] { new ResolverAnswerReference { Name = "example.com", RecordType = DnsRecordType.A, Response = Answer("192.0.2.3") } })); + } +} diff --git a/DnsClientX.Tests/ResolverScoreComparisonContractTests.cs b/DnsClientX.Tests/ResolverScoreComparisonContractTests.cs new file mode 100644 index 00000000..03fc823c --- /dev/null +++ b/DnsClientX.Tests/ResolverScoreComparisonContractTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Linq; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX.Tests; + +/// Protects comparison scope against hidden endpoint differences and raw authoritative response envelopes. +public sealed class ResolverScoreComparisonContractTests { + private static DnsResolverEndpoint Endpoint(string setting, bool alternate) => new DnsResolverEndpoint { + Host = setting == "tls" ? "192.0.2.1" : "resolver.example", + Transport = setting == "doh-post" ? Transport.Doh : setting == "tls" ? Transport.Dot : setting == "edns" || setting == "dnssec" || setting == "fallback" ? Transport.Udp : Transport.Tcp, + DohUrl = setting == "doh-post" ? new Uri("https://resolver.example/dns-query") : null, + RequestFormat = setting == "doh-post" ? alternate ? DnsRequestFormat.DnsOverHttpsWirePost : DnsRequestFormat.DnsOverHttps : null, + Timeout = setting == "timeout" ? TimeSpan.FromSeconds(alternate ? 2 : 1) : null, + Family = setting == "family" ? alternate ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork : null, + TlsServerName = setting == "tls" ? alternate ? "second.example" : "first.example" : null, + EdnsBufferSize = setting == "edns" ? alternate ? 1280 : 1232 : null, + DnsSecOk = setting == "dnssec" ? alternate : null, + AllowTcpFallback = setting != "fallback" || alternate + }; + + private static DnsResponse Answer(string owner = "example.com") => new DnsResponse { + Status = DnsResponseCode.NoError, + Answers = new[] { new DnsAnswer { Name = owner, Type = DnsRecordType.A, DataRaw = "192.0.2.1", TTL = 60 } } + }; + + private static Task ProbeAsync(DnsResolverEndpoint endpoint, ResolverQueryRunOptions? options = null) => ResolverProbeWorkflow.RunAsync( + ResolverExecutionPlanBuilder.BuildExplicitTargets(new[] { endpoint }), "example.com", DnsRecordType.A, 2000, + options ?? new ResolverQueryRunOptions(), new ResolverProbePolicy(), + explicitOverride: (configured, name, type, token) => Task.FromResult(new ResolverQueryAttemptResult { + Target = ResolverExecutionPlanBuilder.DescribeEndpoint(configured), Resolver = "resolver.example", + RequestFormat = configured.RequestFormat ?? DnsRequestFormatMapper.FromTransport(configured.Transport), + Response = Answer(), Elapsed = TimeSpan.FromMilliseconds(5) + })); + + /// Readable labels cannot erase changes that affect execution or DNS security. + [Theory] + [InlineData("timeout")] + [InlineData("family")] + [InlineData("tls")] + [InlineData("edns")] + [InlineData("dnssec")] + [InlineData("fallback")] + [InlineData("doh-post")] + public async Task Compare_RejectsSameLabelWithDifferentExecutionSettings(string setting) { + DnsResolverEndpoint first = Endpoint(setting, false), second = Endpoint(setting, true); + Assert.Equal(ResolverExecutionPlanBuilder.DescribeEndpoint(first), ResolverExecutionPlanBuilder.DescribeEndpoint(second)); + ResolverProbeReport before = await ProbeAsync(first), after = await ProbeAsync(second); + ResolverScoreComparison comparison = ResolverScoreComparer.Compare(before.Snapshot, after.Snapshot); + Assert.False(comparison.ScopeComparable); + Assert.Null(Assert.Single(comparison.Candidates).AverageMsDelta); + } + + /// Real probe and benchmark snapshot capture retain endpoint timeouts beyond display labels. + [Fact] + public async Task SnapshotCapture_ActualTcpWorkflowRetainsEndpointExecutionScope() { + var server = new ResolverBenchmarkTcpFixture(); + try { + var first = new DnsResolverEndpoint { Host = "127.0.0.1", Port = server.Port, Transport = Transport.Tcp, Timeout = TimeSpan.FromSeconds(1) }; + var second = new DnsResolverEndpoint { Host = "127.0.0.1", Port = server.Port, Transport = Transport.Tcp, Timeout = TimeSpan.FromSeconds(2) }; + ResolverProbeReport before = await ResolverProbeWorkflow.RunAsync(ResolverExecutionPlanBuilder.BuildExplicitTargets(new[] { first }), "example.com", DnsRecordType.A, 2000, new ResolverQueryRunOptions(), new ResolverProbePolicy()); + ResolverBenchmarkReport after = await ResolverBenchmarkWorkflow.RunAsync(ResolverExecutionPlanBuilder.BuildExplicitTargets(new[] { second }), new[] { "example.com" }, new[] { DnsRecordType.A }, 1, 1, 2000, new ResolverQueryRunOptions(), new ResolverBenchmarkPolicy()); + // Different workflows are already incomparable; isolate the persisted endpoint identity below. + after.Snapshot.Summary.Mode = before.Snapshot.Summary.Mode; + after.Snapshot.Summary.MaxConcurrency = before.Snapshot.Summary.MaxConcurrency; + Assert.Equal(2, server.Queries); + Assert.Equal(ResolverEndpointClientFactory.GetExecutionKey(first), before.Snapshot.Context!.TargetExecutionKeys![before.Results[0].Target]); + Assert.Equal(ResolverEndpointClientFactory.GetExecutionKey(second), after.Snapshot.Context!.TargetExecutionKeys![after.Results[0].Target]); + Assert.False(ResolverScoreComparer.Compare(before.Snapshot, after.Snapshot).ScopeComparable); + } finally { await server.DisposeAsync(); } + } + + /// Raw authoritative aliases and signatures compare using the normal requested answer projection. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Reference_UsesRequestedProjectionWithoutMutatingCaller(bool alias) { + ResolverProbeReport report = await ProbeAsync(Endpoint("timeout", false)); + string owner = alias ? "target.example" : "example.com"; + DnsResponse reference = Answer(owner); + reference.IsAuthoritativeAnswer = true; + reference.Questions = new[] { new DnsQuestion { Name = "example.com", Type = DnsRecordType.A } }; + reference.Answers = reference.Answers.Concat(new[] { new DnsAnswer { + Name = "example.com", Type = alias ? DnsRecordType.CNAME : DnsRecordType.RRSIG, + DataRaw = alias ? "target.example" : "A 8 2 60 20270101000000 20261001000000 12345 example.com signature" + } }).ToArray(); + report.Snapshot.Answers[0].AnswerSignature = DnsResponseAnswerSignature.Build(Answer(owner)); + ResolverScoreComparison comparison = ResolverScoreComparer.Compare(report.Snapshot, report.Snapshot, new[] { + new ResolverAnswerReference { Name = "example.com", RecordType = DnsRecordType.A, Response = reference } + }); + Assert.Equal("Matches", Assert.Single(comparison.Answers).ReferenceComparison); + Assert.Equal(2, reference.Answers.Length); + Assert.False(reference.RequestedAnswerPresent); + } + + /// Legacy context cannot establish an execution identity, even when its readable labels match. + [Fact] + public async Task Compare_MissingExecutionIdentityWithholdsRegressionConclusions() { + ResolverProbeReport before = await ProbeAsync(Endpoint("timeout", false)), after = await ProbeAsync(Endpoint("timeout", false)); + before.Snapshot.Summary.PolicyPassed = true; + after.Snapshot.Summary.PolicyPassed = false; + after.Snapshot.Context!.TargetExecutionKeys = null; + ResolverScoreComparison comparison = ResolverScoreComparer.Compare(before.Snapshot, after.Snapshot); + Assert.False(comparison.ScopeComparable); + Assert.False(comparison.HealthRegression); + Assert.Null(Assert.Single(comparison.Candidates).AverageMsDelta); + } + + /// The bootstrap's endpoint settings use the same canonical identity as query targets. + [Fact] + public async Task Compare_BootstrapIdentityPreservesHiddenSettings() { + var first = new DnsResolverEndpoint { Host = "127.0.0.1", Transport = Transport.Udp, Timeout = TimeSpan.FromSeconds(1) }; + var second = new DnsResolverEndpoint { Host = "127.0.0.1", Transport = Transport.Udp, Timeout = TimeSpan.FromSeconds(2) }; + Assert.Equal(ResolverExecutionPlanBuilder.DescribeEndpoint(first), ResolverExecutionPlanBuilder.DescribeEndpoint(second)); + ResolverProbeReport before = await ProbeAsync(Endpoint("timeout", false), new ResolverQueryRunOptions { BootstrapResolver = first }); + ResolverProbeReport after = await ProbeAsync(Endpoint("timeout", false), new ResolverQueryRunOptions { BootstrapResolver = second }); + Assert.Equal(ResolverEndpointClientFactory.GetExecutionKey(first), before.Snapshot.Context!.BootstrapEndpoint); + Assert.False(ResolverScoreComparer.Compare(before.Snapshot, after.Snapshot).ScopeComparable); + } +} diff --git a/DnsClientX.Tests/ResolverSingleOperationWorkflowTests.cs b/DnsClientX.Tests/ResolverSingleOperationWorkflowTests.cs index a28641cd..125640ab 100644 --- a/DnsClientX.Tests/ResolverSingleOperationWorkflowTests.cs +++ b/DnsClientX.Tests/ResolverSingleOperationWorkflowTests.cs @@ -126,6 +126,64 @@ public async Task QueryAsync_ReturnsOperationMetadata() { Assert.Single(result.AuditTrail); } + /// Saved endpoint DNSSEC settings survive normal selection; an explicit request can also enable DO. + [Theory] + [InlineData(true, false, true)] + [InlineData(false, false, false)] + [InlineData(null, false, false)] + [InlineData(false, true, true)] + public async Task QueryAsync_SavedEndpointPreservesDnsSecDoBit(bool? endpointDnsSec, bool requestDnsSec, bool expectedDo) { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + int port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + Task serverTask = CaptureProbeAndQueryAsync(udp, cts.Token); + string path = Path.GetTempFileName(); + try { + var endpoint = new DnsResolverEndpoint { Host = "127.0.0.1", Port = port, Transport = Transport.Udp, DnsSecOk = endpointDnsSec }; + ResolverProbeReport probe = await ResolverProbeWorkflow.RunAsync( + ResolverExecutionPlanBuilder.BuildExplicitTargets(new[] { endpoint }), "example.com", DnsRecordType.A, 2000, + new ResolverQueryRunOptions(), new ResolverProbePolicy(), cancellationToken: cts.Token); + ResolverScoreStore.Save(path, probe.Snapshot); + ResolverSingleOperationResult result = await ResolverSingleOperationWorkflow.QueryAsync( + new ResolverExecutionTargetSource { ResolverSelectionPath = path }, "example.com", DnsRecordType.A, + requestDnsSec: requestDnsSec, cancellationToken: cts.Token); + byte[][] queries = await serverTask; + Assert.Equal(DnsResponseCode.NoError, result.Response.Status); + Assert.Equal(endpointDnsSec == true, HasDoBit(queries[0])); + Assert.Equal(expectedDo, HasDoBit(queries[1])); + Assert.Equal(endpointDnsSec, endpoint.DnsSecOk); + } finally { cts.Cancel(); File.Delete(path); } + } + + private static async Task CaptureProbeAndQueryAsync(UdpClient udp, CancellationToken token) { + var queries = new byte[2][]; + for (int index = 0; index < queries.Length; index++) { +#if NET5_0_OR_GREATER + UdpReceiveResult received = await udp.ReceiveAsync(token); +#else + Task receive = udp.ReceiveAsync(); + if (await Task.WhenAny(receive, Task.Delay(Timeout.Infinite, token)) != receive) throw new OperationCanceledException(token); + UdpReceiveResult received = await receive; +#endif + queries[index] = received.Buffer; + byte[] response = TestUtilities.CreateResponseFromQuery(received.Buffer); + await udp.SendAsync(response, response.Length, received.RemoteEndPoint); + } + return queries; + } + + private static bool HasDoBit(byte[] query) { + int additionalCount = (query[10] << 8) | query[11]; + if (additionalCount == 0) return false; + Assert.Equal(1, additionalCount); + int offset = 12; + while (query[offset] != 0) offset += 1 + query[offset]; + offset += 5; // Question terminator, type, and class. + Assert.Equal(0, query[offset]); + Assert.Equal((ushort)DnsRecordType.OPT, (ushort)((query[offset + 1] << 8) | query[offset + 2])); + return (query[offset + 7] & 0x80) != 0; + } + /// /// Ensures update workflows return response and resolver metadata. /// diff --git a/DnsClientX/Configuration.cs b/DnsClientX/Configuration.cs index 0467656f..9a969b92 100644 --- a/DnsClientX/Configuration.cs +++ b/DnsClientX/Configuration.cs @@ -255,9 +255,10 @@ public string? Rfc5011TrustAnchorStorePath { /// /// Optional cap for client-side query parallelism when resolving multiple names. - /// When null, the library uses its current behavior (no explicit cap). + /// When null or nonpositive, materialized array helpers have no explicit cap; + /// completion-order streaming helpers use a bounded default of 32. /// When set to a positive value, array-based resolve helpers will limit - /// the number of in-flight requests to this value. + /// the number of in-flight requests to this value, including streaming helpers. /// public int? MaxConcurrency { get; set; } diff --git a/DnsClientX/Definitions/DnsResponse.TypedRecords.cs b/DnsClientX/Definitions/DnsResponse.TypedRecords.cs index fa2e2705..0eae8db8 100644 --- a/DnsClientX/Definitions/DnsResponse.TypedRecords.cs +++ b/DnsClientX/Definitions/DnsResponse.TypedRecords.cs @@ -4,6 +4,24 @@ namespace DnsClientX; public partial class DnsResponse { + /// Parses answers while retaining metadata for each matching payload. + public DnsTypedRecord[] GetTypedAnswers(bool parseTypedTxtRecords = false) where TRecord : class => + GetTypedRecords(Answers, parseTypedTxtRecords); + + /// Parses authority records while retaining metadata for each matching payload. + public DnsTypedRecord[] GetTypedAuthorities(bool parseTypedTxtRecords = false) where TRecord : class => + GetTypedRecords(Authorities, parseTypedTxtRecords); + + /// Parses additional records while retaining metadata for each matching payload. + public DnsTypedRecord[] GetTypedAdditional(bool parseTypedTxtRecords = false) where TRecord : class => + GetTypedRecords(Additional, parseTypedTxtRecords); + + private static DnsTypedRecord[] GetTypedRecords(DnsAnswer[]? section, bool parseTypedTxtRecords) where TRecord : class => + (section ?? Array.Empty()) + .Select(record => (Record: record, Value: DnsRecordFactory.Create(record, parseTypedTxtRecords))) + .Where(parsed => parsed.Value is TRecord) + .Select(parsed => new DnsTypedRecord(parsed.Record, (TRecord)parsed.Value!)).ToArray(); + internal void PopulateTypedRecords(bool parseTypedTxtRecords) { TypedAnswers = Parse(Answers); TypedAuthorities = Parse(Authorities); diff --git a/DnsClientX/Definitions/DnsResponse.cs b/DnsClientX/Definitions/DnsResponse.cs index db0ca67b..1319bf01 100644 --- a/DnsClientX/Definitions/DnsResponse.cs +++ b/DnsClientX/Definitions/DnsResponse.cs @@ -109,6 +109,9 @@ public partial class DnsResponse { [JsonPropertyName("dnssec_validation_message")] public string DnsSecValidationMessage { get; internal set; } = string.Empty; + /// Gets material lookups observed during local recursive-resolver validation. + public DnsSecValidationDependency[] DnsSecValidationDependencies { get; internal set; } = Array.Empty(); + /// Bounds cached validation by the lifetime of every authenticated dependency. [JsonIgnore] internal DateTimeOffset? DnsSecValidationExpiresUtc { get; set; } @@ -429,6 +432,7 @@ internal void RefreshDerivedData(int? port = null, DnsRequestFormat? requestForm /// public DnsResponse Clone() { var clone = (DnsResponse)MemberwiseClone(); + clone.DnsSecValidationDependencies = (DnsSecValidationDependency[])DnsSecValidationDependencies.Clone(); // Section models are structs, so cloning each array copies every record value. clone.Questions = Questions == null ? Array.Empty() : (DnsQuestion[])Questions.Clone(); clone.Answers = Answers == null ? Array.Empty() : (DnsAnswer[])Answers.Clone(); diff --git a/DnsClientX/DnsClientX.Cache.cs b/DnsClientX/DnsClientX.Cache.cs new file mode 100644 index 00000000..1a5649cf --- /dev/null +++ b/DnsClientX/DnsClientX.Cache.cs @@ -0,0 +1,36 @@ +using System; + +namespace DnsClientX; + +public partial class ClientX { + /// Gets or sets the positive capacity of the process-wide response cache. + /// + /// Applies to all cache-enabled clients. Reducing capacity immediately removes the oldest + /// inserted entries. Per-client and security policies still separate entries. + /// + public static int ResponseCacheCapacity { + get => _cache.Capacity; + set => _cache.Capacity = value; + } + + /// Returns cache storage and lifetime lookup statistics for all clients in this process. + public static DnsResponseCacheStatistics GetResponseCacheStatistics() => _cache.GetStatistics(); + + /// Clears all process-wide cached responses and returns the number removed. + /// Does not cancel in-flight queries; those may populate the cache when they complete. + public static int ClearResponseCache() => _cache.Clear(); + + /// Invalidates cached queries for a name and optional record type across all resolver policies. + /// Actual queried name, including a full system-search candidate when applicable. + /// Query type to remove, or null to remove all types for the name. + /// Number of entries removed. + /// + /// Names are case-insensitive; Unicode names and a trailing root dot normalize to the same + /// cache identity. Pass a reverse DNS name to invalidate an address lookup made with PTR. + /// Alias targets do not invalidate a different original query. In-flight queries are unaffected. + /// + public static int InvalidateResponseCache(string name, DnsRecordType? type = null) { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name)); + return _cache.Invalidate(DnsWireNameCodec.Canonical(ConvertToPunycode(name)), type); + } +} diff --git a/DnsClientX/DnsClientX.Dispose.cs b/DnsClientX/DnsClientX.Dispose.cs index e1fa8440..86640505 100644 --- a/DnsClientX/DnsClientX.Dispose.cs +++ b/DnsClientX/DnsClientX.Dispose.cs @@ -52,6 +52,7 @@ protected virtual void Dispose(bool disposing) { } try { if (disposing) { + if (_dnsSecMaterialCache.IsValueCreated) _dnsSecMaterialCache.Value.Dispose(); _udpClientPool.Dispose(); _streamConnectionPool.Dispose(); #if NET8_0_OR_GREATER @@ -108,6 +109,7 @@ protected virtual async ValueTask DisposeAsyncCore() { return; } try { + if (_dnsSecMaterialCache.IsValueCreated) _dnsSecMaterialCache.Value.Dispose(); _udpClientPool.Dispose(); await _streamConnectionPool.DisposeAsync().ConfigureAwait(false); #if NET8_0_OR_GREATER diff --git a/DnsClientX/DnsClientX.DnsSecMaterialCache.cs b/DnsClientX/DnsClientX.DnsSecMaterialCache.cs new file mode 100644 index 00000000..187f5452 --- /dev/null +++ b/DnsClientX/DnsClientX.DnsSecMaterialCache.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX; + +public partial class ClientX { + private readonly Lazy _dnsSecMaterialCache = new(() => new DnsResponseCache(cleanupThreshold: 256)); + + /// Gets or sets whether authenticated DNSSEC material can be reused by this client. + /// Material is revalidated on every query and bounded by TTL and authenticated proof expiry. + /// This cache serves recursive-resolver validation; iterative root traversal retains its own validation session. + public bool DnsSecMaterialCacheEnabled { get; set; } = true; + + private DnsResponseCache? GetDnsSecMaterialCache() { + lock (_lock) { + ThrowIfDisposed(); + return DnsSecMaterialCacheEnabled ? _dnsSecMaterialCache.Value : null; + } + } + + /// Removes material retained by this client without changing the shared response cache. + public int ClearDnsSecMaterialCache() { + ThrowIfDisposed(); + return _dnsSecMaterialCache.IsValueCreated ? _dnsSecMaterialCache.Value.Clear() : 0; + } + private async Task ValidateDnsSecResponseAsync(DnsResponse response, string name, DnsRecordType type, + Configuration queryConfiguration, int retryDelayMs, bool ignoreCertificateErrors, CancellationToken cancellationToken) { + var materialSession = new DnsSecMaterialSession( + GetDnsSecMaterialCache(), + (materialName, materialType) => DnsCacheKeyBuilder.Build(queryConfiguration, + ConvertToPunycode(materialName), materialType, true, true, true, false, false, + MaxCacheTtl, ignoreCertificateErrors), + (materialName, materialType, token) => + ResolveInternal(materialName, materialType, requestDnsSec: true, validateDnsSec: false, + returnAllTypes: true, maxRetries: 1, retryDelayMs: retryDelayMs, + typedRecords: false, parseTypedTxtRecords: false, cancellationToken: token, + dnsSecMaterialQuery: true, + queryConfigurationOverride: queryConfiguration.CreateQuerySnapshot(materialName), + certificatePolicy: ignoreCertificateErrors)); + var validator = new DnsSecValidationEngine(materialSession.LookupAsync, + trustAnchorStorePath: queryConfiguration.Rfc5011TrustAnchorStorePath, + signatureVerifier: queryConfiguration.DnsSecSignatureVerifier); + DnsSecValidationResult validation = await validator.ValidateAsync(response, name, type, cancellationToken).ConfigureAwait(false); + response.DnsSecValidationStatus = validation.Status; + response.DnsSecValidationMessage = validation.Message; + response.DnsSecValidationExpiresUtc = validator.CacheExpiresAtUtc; + response.DnsSecValidationDependencies = materialSession.Complete(validator, validation.Status, MaxCacheTtl); + if (validation.Status == DnsSecValidationStatus.Bogus || validation.Status == DnsSecValidationStatus.Indeterminate) { + string validationError = $"DNSSEC {validation.Status.ToString().ToLowerInvariant()}: {validation.Message}"; + response.Error = string.IsNullOrEmpty(response.Error) ? validationError : $"{response.Error} {validationError}"; + } + } + +} diff --git a/DnsClientX/DnsClientX.QueryDnsRequest.cs b/DnsClientX/DnsClientX.QueryDnsRequest.cs index 30b09b79..5036516e 100644 --- a/DnsClientX/DnsClientX.QueryDnsRequest.cs +++ b/DnsClientX/DnsClientX.QueryDnsRequest.cs @@ -79,7 +79,23 @@ private static async Task ExecuteMultiResolverAsync(ResolveDnsReq throw new InvalidOperationException("No endpoints were produced from the specified resolver inputs."); } - var options = new MultiResolverOptions { + MultiResolverOptions options = CreateMultiResolverOptions(request, ednsOptions); + + using var resolver = new DnsMultiResolver(endpoints, options); + var responses = new List(); + foreach (var recordType in request.RecordTypes) { + var result = await ExecuteWithRetryAsync( + request, + () => resolver.QueryBatchAsync(namesToUse, recordType, cancellationToken), + cancellationToken).ConfigureAwait(false); + responses.AddRange(result); + } + + return responses.ToArray(); + } + + private static MultiResolverOptions CreateMultiResolverOptions(ResolveDnsRequest request, EdnsOptions? ednsOptions) => + new MultiResolverOptions { Strategy = request.ResolverStrategy, MaxParallelism = Math.Max(1, request.MaxParallelism), RespectEndpointTimeout = request.RespectEndpointTimeout, @@ -105,19 +121,6 @@ private static async Task ExecuteMultiResolverAsync(ResolveDnsReq MaxConcurrency = request.EffectiveMaxConcurrency }; - using var resolver = new DnsMultiResolver(endpoints, options); - var responses = new List(); - foreach (var recordType in request.RecordTypes) { - var result = await ExecuteWithRetryAsync( - request, - () => resolver.QueryBatchAsync(namesToUse, recordType, cancellationToken), - cancellationToken).ConfigureAwait(false); - responses.AddRange(result); - } - - return responses.ToArray(); - } - private static async Task ExecuteServerAsync(ResolveDnsRequest request, string[] namesToUse, EdnsOptions? ednsOptions, CancellationToken cancellationToken) { List serverOrder = GetValidatedServers(request); if (request.RandomServer) { @@ -129,25 +132,19 @@ private static async Task ExecuteServerAsync(ResolveDnsRequest re } IEnumerable responses; - if (request.AllServers) { + if (request.AllServers || request.Fallback) { var aggregated = new List(); foreach (string serverName in serverOrder) { - aggregated.AddRange(await ExecuteWithRetryAsync(request, async () => { - using var client = CreateClientForServer(request, serverName, ednsOptions); - return await QueryWithRequestAsync(client, request, namesToUse, cancellationToken).ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false)); - } - - responses = aggregated; - } else if (request.Fallback) { - var aggregated = new List(); - foreach (string serverName in serverOrder) { - var result = await ExecuteWithRetryAsync(request, async () => { - using var client = CreateClientForServer(request, serverName, ednsOptions); - return await QueryWithRequestAsync(client, request, namesToUse, cancellationToken).ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false); - aggregated.AddRange(result); - if (aggregated.Any(r => r.Status == DnsResponseCode.NoError)) { + using var client = CreateClientForServer(request, serverName, ednsOptions); + bool succeeded = false; + await foreach (DnsResponse[] batch in DnsConcurrentStream.Execute( + EnumerateRequestQueries(namesToUse, request.RecordTypes), + (query, token) => QueryOneWithRequestAsync(client, request, query.Name, query.Type, token), + request.EffectiveMaxConcurrency, preserveOrder: true, cancellationToken).ConfigureAwait(false)) { + aggregated.AddRange(batch); + succeeded |= batch.Any(response => response.Status == DnsResponseCode.NoError); + } + if (!request.AllServers && succeeded) { break; } } @@ -228,10 +225,11 @@ private static async Task ExecuteWithRetryAsync(ResolveDnsRequest try { lastResults = await query().ConfigureAwait(false); + lastException = null; if (!lastResults.Any(DnsQueryDiagnostics.IsTransient)) { return lastResults; } - } catch (Exception ex) when (DnsQueryDiagnostics.IsTransient(ex)) { + } catch (Exception ex) when (IsNonCallerTransientFailure(ex, cancellationToken)) { lastException = ex; } @@ -247,6 +245,46 @@ private static async Task ExecuteWithRetryAsync(ResolveDnsRequest return lastResults; } + /// Owns one query's retry outcome and failure envelope without erasing sibling results. + private static async Task QueryOneWithRequestAsync(ClientX client, ResolveDnsRequest request, + string name, DnsRecordType type, CancellationToken cancellationToken, bool? requestDnsSecOverride = null) { + try { + return await ExecuteWithRetryAsync(request, async () => { + var overrideHandler = QueryDnsRequestOverride; + if (overrideHandler != null) { + return await overrideHandler(request, new[] { name }, type, cancellationToken).ConfigureAwait(false); + } + return new[] { await client.Resolve(name, type, requestDnsSecOverride ?? request.ShouldRequestDnsSec, + request.ShouldValidateDnsSec, false, false, 1, request.RetryDelayMs, + request.TypedRecords, request.ParseTypedTxtRecords, cancellationToken).ConfigureAwait(false) }; + }, cancellationToken).ConfigureAwait(false); + } catch (Exception ex) when ((request.Fallback || request.AllServers) + && IsNonCallerTransientFailure(ex, cancellationToken)) { + cancellationToken.ThrowIfCancellationRequested(); + DnsResponse failure = ex is DnsClientException { Response: { } response } + ? response.Clone() : new DnsResponse { Status = DnsResponseCode.ServerFailure }; + failure.Questions = new[] { new DnsQuestion { Name = name, OriginalName = name, Type = type } }; + failure.ErrorCode = DnsQueryDiagnostics.ClassifyFailure(ex); + failure.Exception = ex; + if (string.IsNullOrEmpty(failure.Error)) failure.Error = ex.Message; + failure.RetryCount = request.RetryCount - 1; + failure.AddServerDetails(client.EndpointConfiguration); + return new[] { failure }; + } + } + + private static IEnumerable<(string Name, DnsRecordType Type)> EnumerateRequestQueries(string[] names, + DnsRecordType[] types) { + foreach (DnsRecordType type in types) + foreach (string name in names) yield return (name, type); + } + + // A linked HTTP body deadline can throw plain OperationCanceledException. + // The operation token distinguishes that transport timeout from caller/stream cancellation. + private static bool IsNonCallerTransientFailure(Exception exception, CancellationToken cancellationToken) => + !cancellationToken.IsCancellationRequested + && (exception is OperationCanceledException || DnsQueryDiagnostics.IsTransient(exception)); + private static ClientX CreateClientForServer(ResolveDnsRequest request, string serverName, EdnsOptions? ednsOptions) { ClientX client; if (UsesExplicitServerBaseUri(request.RequestFormat)) { @@ -257,7 +295,7 @@ private static ClientX CreateClientForServer(ResolveDnsRequest request, string s userAgent: request.UserAgent, httpVersion: request.HttpVersion, ignoreCertificateErrors: request.IgnoreCertificateErrors, - enableCache: false, + enableCache: request.ResponseCache, useTcpFallback: request.UseTcpFallback, webProxy: request.CreateWebProxy(), maxConnectionsPerServer: request.EffectiveMaxConnectionsPerServer); @@ -269,7 +307,7 @@ private static ClientX CreateClientForServer(ResolveDnsRequest request, string s userAgent: request.UserAgent, httpVersion: request.HttpVersion, ignoreCertificateErrors: request.IgnoreCertificateErrors, - enableCache: false, + enableCache: request.ResponseCache, useTcpFallback: request.UseTcpFallback, webProxy: request.CreateWebProxy(), maxConnectionsPerServer: request.EffectiveMaxConnectionsPerServer); @@ -291,7 +329,7 @@ private static ClientX CreateClientForProvider(ResolveDnsRequest request, DnsEnd request.UserAgent, request.HttpVersion, request.IgnoreCertificateErrors, - enableCache: false, + enableCache: request.ResponseCache, useTcpFallback: request.UseTcpFallback, webProxy: request.CreateWebProxy(), maxConnectionsPerServer: request.EffectiveMaxConnectionsPerServer); @@ -309,7 +347,7 @@ private static ClientX CreateClientForEndpoint(ResolveDnsRequest request, DnsRes } if (endpoint.Timeout.HasValue) { - client.EndpointConfiguration.TimeOut = (int)Math.Max(1, endpoint.Timeout.Value.TotalMilliseconds); + client.EndpointConfiguration.TimeOut = ResolverEndpointClientFactory.ToTimeoutMilliseconds(endpoint.Timeout.Value); } if (endpoint.Transport != Transport.Doh) { @@ -320,39 +358,17 @@ private static ClientX CreateClientForEndpoint(ResolveDnsRequest request, DnsRes } private static ClientX CreateConfiguredEndpointClient(ResolveDnsRequest request, DnsResolverEndpoint endpoint) { - DnsRequestFormat requestFormat = endpoint.RequestFormat ?? DnsRequestFormatMapper.FromTransport(endpoint.Transport); - if (ResolverEndpointClientFactory.IsUriBasedRequestFormat(requestFormat) || endpoint.Transport == Transport.Doh || endpoint.DohUrl != null) { - return new ClientX( - EndpointParser.BuildDohUri(endpoint), - requestFormat, - timeOutMilliseconds: request.TimeOutMilliseconds, - userAgent: request.UserAgent, - httpVersion: request.HttpVersion, - ignoreCertificateErrors: request.IgnoreCertificateErrors, - enableCache: false, - useTcpFallback: request.UseTcpFallback && endpoint.AllowTcpFallback, - webProxy: request.CreateWebProxy(), - maxConnectionsPerServer: request.EffectiveMaxConnectionsPerServer); - } - - if (string.IsNullOrWhiteSpace(endpoint.Host)) { - throw new ArgumentException("Resolver endpoint requires Host.", nameof(endpoint)); - } - - return new ClientX( - endpoint.Host!, - requestFormat, - timeOutMilliseconds: request.TimeOutMilliseconds, - userAgent: request.UserAgent, - httpVersion: request.HttpVersion, - ignoreCertificateErrors: request.IgnoreCertificateErrors, - enableCache: false, - useTcpFallback: request.UseTcpFallback && endpoint.AllowTcpFallback, - webProxy: request.CreateWebProxy(), - maxConnectionsPerServer: request.EffectiveMaxConnectionsPerServer); + Configuration configuration = ResolverEndpointClientFactory.CreateConfiguration(endpoint); + configuration.TimeOut = request.TimeOutMilliseconds; + if (request.UserAgent != null) configuration.UserAgent = request.UserAgent; + if (request.HttpVersion != null) configuration.HttpVersion = request.HttpVersion; + configuration.MaxConnectionsPerServer = request.EffectiveMaxConnectionsPerServer; + configuration.BootstrapResolver = request.BootstrapResolver; + return new ClientX(configuration, request.IgnoreCertificateErrors, request.ResponseCache, request.CreateWebProxy()); } private static void ApplyRequestConfiguration(ClientX client, ResolveDnsRequest request, EdnsOptions? ednsOptions) { + if (request.MaxCacheTtlSeconds > 0) client.MaxCacheTtl = TimeSpan.FromSeconds(request.MaxCacheTtlSeconds); client.EndpointConfiguration.BootstrapResolver = request.BootstrapResolver; client.EndpointConfiguration.DnsSecSignatureVerifier = request.DnsSecSignatureVerifier; client.EndpointConfiguration.TimeOut = request.TimeOutMilliseconds; diff --git a/DnsClientX/DnsClientX.QueryDnsStreaming.cs b/DnsClientX/DnsClientX.QueryDnsStreaming.cs new file mode 100644 index 00000000..38b664bf --- /dev/null +++ b/DnsClientX/DnsClientX.QueryDnsStreaming.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX; + +public partial class ClientX { + /// Streams a reusable request with bounded concurrency and shared resolver clients. + /// Input order is the default. Explicit server fallback proceeds to the next server + /// only when the completed server produced no successful response, as in QueryDns. + /// Disposing the stream cancels and drains admitted queries before disposing its clients. + public static async IAsyncEnumerable QueryDnsStreaming(ResolveDnsRequest request, + bool preserveOrder = true, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (request == null) throw new ArgumentNullException(nameof(request)); + request.Validate(); + request = request.Snapshot(); + string[] names = request.GetExpandedNames(); + EdnsOptions? edns = request.CreateEdnsOptions(); + DnsResolverEndpoint? selectedEndpoint = null; + DnsEndpoint? selectedProvider = null; + if (!string.IsNullOrWhiteSpace(request.ResolverSelectionPath)) { + ResolverExecutionTarget target = await ResolverExecutionTargetResolver.ResolveSingleAsync( + new ResolverExecutionTargetSource { ResolverSelectionPath = request.ResolverSelectionPath }, + cancellationToken).ConfigureAwait(false); + selectedEndpoint = target.ExplicitEndpoint; + selectedProvider = target.BuiltInEndpoint; + } + if (request.IsMultiResolverRequest) { + DnsResolverEndpoint[] endpoints = await ExpandMultiResolverEndpointsAsync(request, cancellationToken).ConfigureAwait(false); + using var resolver = new DnsMultiResolver(endpoints, CreateMultiResolverOptions(request, edns)); + await foreach (DnsResponse response in Stream(async (name, type, token) => { + DnsResponse[] results = await ExecuteWithRetryAsync(request, + async () => new[] { await resolver.QueryAsync(name, type, token).ConfigureAwait(false) }, token).ConfigureAwait(false); + return results; + }).ConfigureAwait(false)) yield return response; + } else if (request.Servers.Length > 0) { + List servers = GetValidatedServers(request); + if (request.RandomServer || request.AllServers && request.Fallback) Shuffle(servers); + foreach (string server in servers) { + using var client = CreateClientForServer(request, server, edns); + bool succeeded = false; + await foreach (DnsResponse response in Stream((name, type, token) => QueryOneWithRequestAsync(client, request, name, type, token)).ConfigureAwait(false)) { + succeeded |= response.Status == DnsResponseCode.NoError; + yield return response; + } + if (!request.AllServers && (!request.Fallback || succeeded)) break; + } + } else { + using var client = selectedEndpoint != null + ? CreateClientForEndpoint(request, selectedEndpoint, edns) + : CreateClientForProvider(request, selectedProvider ?? (request.DnsProviders.Length > 0 ? request.DnsProviders[0] : DnsEndpoint.System), edns); + await foreach (DnsResponse response in Stream((name, type, token) => QueryOneWithRequestAsync(client, request, name, type, token, + request.ShouldRequestDnsSec || selectedEndpoint?.DnsSecOk == true)).ConfigureAwait(false)) yield return response; + } + + async IAsyncEnumerable Stream(Func> operation) { + await foreach (DnsResponse[] batch in DnsConcurrentStream.Execute(EnumerateRequestQueries(names, request.RecordTypes), + (query, token) => operation(query.Name, query.Type, token), request.EffectiveMaxConcurrency, + preserveOrder, cancellationToken).ConfigureAwait(false)) { + foreach (DnsResponse response in batch) yield return response; + } + } + } +} diff --git a/DnsClientX/DnsClientX.Resolve.cs b/DnsClientX/DnsClientX.Resolve.cs index 64722f53..a6a87bcd 100644 --- a/DnsClientX/DnsClientX.Resolve.cs +++ b/DnsClientX/DnsClientX.Resolve.cs @@ -272,29 +272,14 @@ private async Task ResolveInternal(string name, DnsRecordType type, } if (validateDnsSec && !dnsSecMaterialQuery && queryConfiguration.BuiltInEndpoint != DnsEndpoint.RootServer) { - var validator = new DnsSecValidationEngine((materialName, materialType, token) => - ResolveInternal(materialName, materialType, requestDnsSec: true, validateDnsSec: false, - returnAllTypes: true, maxRetries: 1, retryDelayMs: retryDelayMs, - typedRecords: false, parseTypedTxtRecords: false, cancellationToken: token, - dnsSecMaterialQuery: true, - queryConfigurationOverride: queryConfiguration.CreateQuerySnapshot(materialName), - certificatePolicy: ignoreCertificateErrors), - trustAnchorStorePath: queryConfiguration.Rfc5011TrustAnchorStorePath, - signatureVerifier: queryConfiguration.DnsSecSignatureVerifier); - DnsSecValidationResult validation = await validator.ValidateAsync(response, name, type, cancellationToken).ConfigureAwait(false); - response.DnsSecValidationStatus = validation.Status; - response.DnsSecValidationMessage = validation.Message; - response.DnsSecValidationExpiresUtc = validator.CacheExpiresAtUtc; - if (validation.Status == DnsSecValidationStatus.Bogus || validation.Status == DnsSecValidationStatus.Indeterminate) { - string validationError = $"DNSSEC {validation.Status.ToString().ToLowerInvariant()}: {validation.Message}"; - response.Error = string.IsNullOrEmpty(response.Error) ? validationError : $"{response.Error} {validationError}"; - } + await ValidateDnsSecResponseAsync(response, name, type, queryConfiguration, + retryDelayMs, ignoreCertificateErrors, cancellationToken).ConfigureAwait(false); } // Cache lifetime is determined by the complete answer. In particular, an address // reached through a CNAME must not outlive the alias RRset merely because callers // requested an answer projection that hides the CNAME. - TimeSpan cacheTtl = GetCacheTtl(response); + TimeSpan cacheTtl = GetCacheTtl(response, name, type); // Validate the complete wire answer first, then apply the caller's projection. ApplyAnswerProjection(response, name, type, returnAllTypes); @@ -309,7 +294,7 @@ private async Task ResolveInternal(string name, DnsRecordType type, if (ttl > MaxCacheTtl) ttl = MaxCacheTtl; if (ttl > TimeSpan.Zero) { - _cache.Set(cacheKey!, response, ttl); + _cache.Set(cacheKey!, response, ttl, name, type); } } @@ -368,16 +353,20 @@ internal static void ApplyAnswerProjection( response.Answers = matchingAnswers; } - private static TimeSpan GetCacheTtl(DnsResponse response) { - if (response.Answers is { Length: > 0 }) { - int minimum = response.Answers.Min(answer => Math.Max(0, answer.TTL)); - return minimum == 0 ? TimeSpan.Zero : TimeSpan.FromSeconds(minimum); - } - + /// Bounds complete positive or SOA-backed negative answers before projection. + internal static TimeSpan GetCacheTtl(DnsResponse response, string name, DnsRecordType type) { if (response.Status != DnsResponseCode.NoError && response.Status != DnsResponseCode.NXDomain) { return TimeSpan.Zero; } + if (response.Status == DnsResponseCode.NoError && HasRequestedAnswer(response, name, type)) { + int minimum = response.Answers.Min(answer => Math.Max(0, answer.TTL)); + return minimum == 0 ? TimeSpan.Zero : TimeSpan.FromSeconds(minimum); + } + + // NXDOMAIN and CNAME-to-NODATA responses can contain aliases in Answer. + // Their negative result still needs an SOA, bounded by both SOA TTL and + // MINIMUM (RFC 2308), and cannot outlive an alias or retained denial proof. DnsAnswer soa = (response.Authorities ?? Array.Empty()) .FirstOrDefault(answer => answer.Type == DnsRecordType.SOA); if (soa.Type != DnsRecordType.SOA) return TimeSpan.Zero; @@ -385,6 +374,13 @@ private static TimeSpan GetCacheTtl(DnsResponse response) { if (parts.Length < 7 || !uint.TryParse(parts[parts.Length - 1], out uint minimumTtl)) return TimeSpan.Zero; uint soaTtl = (uint)Math.Max(0, soa.TTL); uint seconds = Math.Min(soaTtl, minimumTtl); + foreach (DnsAnswer record in (response.Answers ?? Array.Empty()) + .Concat(response.Authorities ?? Array.Empty())) { + if (record.Type is DnsRecordType.CNAME or DnsRecordType.DNAME + or DnsRecordType.NSEC or DnsRecordType.NSEC3 or DnsRecordType.RRSIG) { + seconds = Math.Min(seconds, (uint)Math.Max(0, record.TTL)); + } + } return seconds == 0 ? TimeSpan.Zero : TimeSpan.FromSeconds(seconds); } diff --git a/DnsClientX/DnsClientX.ResolveAsyncEnumerable.cs b/DnsClientX/DnsClientX.ResolveAsyncEnumerable.cs index 53bac5b8..16cf0786 100644 --- a/DnsClientX/DnsClientX.ResolveAsyncEnumerable.cs +++ b/DnsClientX/DnsClientX.ResolveAsyncEnumerable.cs @@ -1,20 +1,18 @@ +using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; -using System.Threading.Tasks; namespace DnsClientX { - /// - /// Partial class providing concurrent resolve helpers. - /// - /// - /// Enables streaming of results as each DNS query completes. - /// + /// Provides bounded streaming in query completion order. public partial class ClientX { /// - /// Resolves multiple DNS record types for a single domain name concurrently and yields responses as they complete. + /// Resolves record types for one name and yields responses as they complete. /// - public async IAsyncEnumerable ResolveAsyncEnumerable( + /// + /// Uses when positive, otherwise a limit of 32. + /// Early disposal cancels and drains unfinished queries. Use ResolveStream for sequential input order. + /// + public IAsyncEnumerable ResolveAsyncEnumerable( string name, DnsRecordType[] types, bool requestDnsSec = false, @@ -23,24 +21,19 @@ public async IAsyncEnumerable ResolveAsyncEnumerable( bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 200, - [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var tasks = new List>(); - - foreach (DnsRecordType type in types) { - tasks.Add(Resolve(name, type, requestDnsSec, validateDnsSec, returnAllTypes, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)); - } - - while (tasks.Count > 0) { - Task finished = await Task.WhenAny(tasks).ConfigureAwait(false); - tasks.Remove(finished); - yield return await finished.ConfigureAwait(false); - } - } + CancellationToken cancellationToken = default) => + ResolveAsyncEnumerable(new[] { name }, types, requestDnsSec, validateDnsSec, + returnAllTypes, retryOnTransient, maxRetries, retryDelayMs, cancellationToken); /// - /// Resolves multiple domain names and DNS record types concurrently and yields responses as they complete. + /// Resolves names and record types with bounded concurrency and yields responses as they complete. /// - public async IAsyncEnumerable ResolveAsyncEnumerable( + /// + /// Uses when positive, otherwise a limit of 32. + /// A consumer that stops early cancels and drains admitted queries; names beyond the + /// bounded window are not started. + /// + public IAsyncEnumerable ResolveAsyncEnumerable( string[] names, DnsRecordType[] types, bool requestDnsSec = false, @@ -49,26 +42,21 @@ public async IAsyncEnumerable ResolveAsyncEnumerable( bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 200, - [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var tasks = new List>(); - - foreach (string n in names) { - foreach (DnsRecordType type in types) { - tasks.Add(Resolve(n, type, requestDnsSec, validateDnsSec, returnAllTypes, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)); - } - } - - while (tasks.Count > 0) { - Task finished = await Task.WhenAny(tasks).ConfigureAwait(false); - tasks.Remove(finished); - yield return await finished.ConfigureAwait(false); - } + CancellationToken cancellationToken = default) { + if (names == null) throw new ArgumentNullException(nameof(names)); + if (types == null) throw new ArgumentNullException(nameof(types)); + ThrowIfDisposed(); + return DnsConcurrentStream.Execute(QueryPairs(names, types), + (query, token) => Resolve(query.Name, query.Type, requestDnsSec, validateDnsSec, + returnAllTypes, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: token), + EndpointConfiguration.MaxConcurrency, cancellationToken: cancellationToken); } /// - /// Resolves multiple domain names for a single DNS record type concurrently and yields responses as they complete. + /// Resolves names for one record type with bounded concurrency in completion order. /// - public async IAsyncEnumerable ResolveAsyncEnumerable( + /// Early disposal cancels and drains unfinished queries. + public IAsyncEnumerable ResolveAsyncEnumerable( string[] names, DnsRecordType type, bool requestDnsSec = false, @@ -77,17 +65,14 @@ public async IAsyncEnumerable ResolveAsyncEnumerable( bool retryOnTransient = true, int maxRetries = 3, int retryDelayMs = 200, - [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var tasks = new List>(); - - foreach (string n in names) { - tasks.Add(Resolve(n, type, requestDnsSec, validateDnsSec, returnAllTypes, retryOnTransient, maxRetries, retryDelayMs, cancellationToken: cancellationToken)); - } + CancellationToken cancellationToken = default) => + ResolveAsyncEnumerable(names, new[] { type }, requestDnsSec, validateDnsSec, + returnAllTypes, retryOnTransient, maxRetries, retryDelayMs, cancellationToken); - while (tasks.Count > 0) { - Task finished = await Task.WhenAny(tasks).ConfigureAwait(false); - tasks.Remove(finished); - yield return await finished.ConfigureAwait(false); + private static IEnumerable<(string Name, DnsRecordType Type)> QueryPairs( + IEnumerable names, IEnumerable types) { + foreach (string name in names) { + foreach (DnsRecordType type in types) yield return (name, type); } } } diff --git a/DnsClientX/DnsClientX.ResolveOptions.cs b/DnsClientX/DnsClientX.ResolveOptions.cs new file mode 100644 index 00000000..187257ba --- /dev/null +++ b/DnsClientX/DnsClientX.ResolveOptions.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX; + +public partial class ClientX { + /// Resolves one name using explicit query behavior and this client's resolver. + public Task Resolve(string name, DnsRecordType type, DnsResolveOptions options, + CancellationToken cancellationToken = default) { + if (options == null) throw new ArgumentNullException(nameof(options)); + DnsResolveOptions settings = options.Snapshot(); + return ResolveWithOptions(name, type, settings, cancellationToken); + } + + private Task ResolveWithOptions(string name, DnsRecordType type, DnsResolveOptions options, + CancellationToken cancellationToken) => Resolve(name, type, options.RequestDnsSec, options.ValidateDnsSec, + options.ReturnAllTypes, options.RetryOnTransient, options.MaxRetries, options.RetryDelayMs, + options.TypedRecords, options.ParseTypedTxtRecords, cancellationToken); + + /// Resolves a bounded batch in name and record type input order. + public async Task Resolve(string[] names, DnsRecordType[] types, DnsResolveOptions options, + CancellationToken cancellationToken = default) { + var results = new List(); + await foreach (DnsResponse response in ResolveStreaming(names, types, options, true, cancellationToken).ConfigureAwait(false)) { + results.Add(response); + } + return results.ToArray(); + } + + /// Streams names and types using the configured concurrency limit, or 32 when unset. + /// Input order is the default. Early disposal cancels and drains admitted work. + public IAsyncEnumerable ResolveStreaming(IEnumerable names, IEnumerable types, + DnsResolveOptions options, bool preserveOrder = true, CancellationToken cancellationToken = default) { + if (names == null) throw new ArgumentNullException(nameof(names)); + if (types == null) throw new ArgumentNullException(nameof(types)); + if (options == null) throw new ArgumentNullException(nameof(options)); + ThrowIfDisposed(); + DnsResolveOptions settings = options.Snapshot(); + // Record types are a small reusable query specification; names remain lazy. + var recordTypes = new List(types).ToArray(); + return DnsConcurrentStream.Execute(QueryPairs(names, recordTypes), + (query, token) => ResolveWithOptions(query.Name, query.Type, settings, token), + EndpointConfiguration.MaxConcurrency, preserveOrder, cancellationToken); + } +} diff --git a/DnsClientX/DnsConcurrentStream.cs b/DnsClientX/DnsConcurrentStream.cs new file mode 100644 index 00000000..3d0f9552 --- /dev/null +++ b/DnsClientX/DnsConcurrentStream.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX; + +/// Owns bounded query streaming, consumer backpressure, and unfinished query cleanup. +internal static class DnsConcurrentStream { + internal const int DefaultConcurrency = 32; + + /// + /// Keeps at most the configured number of operations admitted. Disposing the enumerator + /// cancels and observes every admitted operation before releasing its cancellation source. + /// + internal static async IAsyncEnumerable Execute( + IEnumerable work, + Func> operation, + int? maxConcurrency = null, + bool preserveOrder = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (work == null) throw new ArgumentNullException(nameof(work)); + if (operation == null) throw new ArgumentNullException(nameof(operation)); + int concurrency = maxConcurrency is > 0 ? maxConcurrency.Value : DefaultConcurrency; + using var ownedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var source = work.GetEnumerator(); + var pending = new List>(); + bool exhausted = false; + try { + while (true) { + ownedCancellation.Token.ThrowIfCancellationRequested(); + while (!exhausted && pending.Count < concurrency) { + ownedCancellation.Token.ThrowIfCancellationRequested(); + if (!source.MoveNext()) { + exhausted = true; + break; + } + pending.Add(operation(source.Current, ownedCancellation.Token)); + } + if (pending.Count == 0) yield break; + Task finished = preserveOrder + ? pending[0] + : await Task.WhenAny(pending).ConfigureAwait(false); + TResult result = await finished.ConfigureAwait(false); + pending.Remove(finished); + cancellationToken.ThrowIfCancellationRequested(); + // Refill only when the consumer requests another item. A slow consumer + // bounds completed-but-unconsumed responses as well as live queries. + yield return result; + } + } finally { + try { ownedCancellation.Cancel(); } + catch (AggregateException) { /* Callback failures must not skip cleanup. */ } + try { await Task.WhenAll(pending).ConfigureAwait(false); } + catch { /* The consumer sees the initiating failure or cancellation. */ } + } + } +} diff --git a/DnsClientX/DnsQueryAttemptExplanation.cs b/DnsClientX/DnsQueryAttemptExplanation.cs new file mode 100644 index 00000000..099c37f5 --- /dev/null +++ b/DnsClientX/DnsQueryAttemptExplanation.cs @@ -0,0 +1,38 @@ +using System; + +namespace DnsClientX { + /// Provides JSON-safe facts for an audit attempt without serializing exceptions. + public sealed class DnsQueryAttemptExplanation { + /// Gets the query name. + public string Name { get; private set; } = string.Empty; + /// Gets the query type. + public DnsRecordType RecordType { get; private set; } + /// Gets the attempt start time. + public DateTimeOffset StartedAtUtc { get; private set; } + /// Gets the duration in milliseconds. + public double ElapsedMs { get; private set; } + /// Gets the selected resolver. + public string? Resolver { get; private set; } + /// Gets the port. + public int Port { get; private set; } + /// Gets the request format. + public DnsRequestFormat RequestFormat { get; private set; } + /// Gets the actual transport. + public Transport Transport { get; private set; } + /// Gets whether cache supplied the response. + public bool ServedFromCache { get; private set; } + /// Gets the captured DNS status, absent if no response arrived. + public DnsResponseCode? Status { get; private set; } + /// Gets the reason for retrying, when captured. + public string? RetryReason { get; private set; } + /// Gets the captured error message, absent on success. + public string? Error { get; private set; } + + internal static DnsQueryAttemptExplanation Create(AuditEntry entry) => new DnsQueryAttemptExplanation { + Name = entry.Name, RecordType = entry.RecordType, StartedAtUtc = entry.StartedAtUtc, ElapsedMs = entry.Duration.TotalMilliseconds, + Resolver = entry.ResolverHost, Port = entry.ResolverPort, RequestFormat = entry.RequestFormat, Transport = entry.UsedTransport, + ServedFromCache = entry.ServedFromCache, Status = entry.Response?.Status, RetryReason = entry.RetryReason, + Error = entry.Exception?.Message ?? (string.IsNullOrWhiteSpace(entry.Response?.Error) ? null : entry.Response!.Error) + }; + } +} diff --git a/DnsClientX/DnsQueryExplanation.cs b/DnsClientX/DnsQueryExplanation.cs new file mode 100644 index 00000000..5013a1c5 --- /dev/null +++ b/DnsClientX/DnsQueryExplanation.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; + +namespace DnsClientX { + /// Describes only the transport and DNS evidence captured for a completed response. + public sealed class DnsQueryExplanation { + /// Gets the actual response source, including cache and coalesced requests. + public DnsResponseSource ResponseSource { get; private set; } + /// Gets the selected resolver when captured. + public string? Resolver { get; private set; } + /// Gets the observed transport. + public Transport Transport { get; private set; } + /// Gets the response code. + public DnsResponseCode Status { get; private set; } + /// Gets the normalized failure category. + public DnsQueryErrorCode ErrorCode { get; private set; } + /// Gets the captured retry count. + public int RetryCount { get; private set; } + /// Gets endpoint hostname/bootstrap provenance when available. + public DnsServerResolutionInfo? Bootstrap { get; private set; } + /// Gets the resolver's AD assertion; this is distinct from local validation. + public bool ResolverAuthenticData { get; private set; } + /// Gets the local DNSSEC verdict. + public DnsSecValidationStatus DnsSecValidationStatus { get; private set; } + /// Gets the captured explanation of local DNSSEC validation. + public string DnsSecValidationMessage { get; private set; } = string.Empty; + /// Gets recorded recursive DNSSEC material lookups, rather than an inferred ordered trust graph. + public DnsSecValidationDependency[] DnsSecValidationDependencies { get; private set; } = Array.Empty(); + /// Gets aliases present in the original wire answer, or the visible answer when wire evidence is unavailable. + public DnsAnswer[] Aliases { get; private set; } = Array.Empty(); + /// Gets authority/delegation hints present in the returned message. They are not a complete traversal. + public DnsAnswer[] AuthorityHints { get; private set; } = Array.Empty(); + /// Gets DNSSEC material present in the response sections. Presence alone is not verification. + public DnsAnswer[] DnsSecRecords { get; private set; } = Array.Empty(); + /// Gets structured audit attempts when the caller supplied captured audit evidence. + public DnsQueryAttemptExplanation[] Attempts { get; private set; } = Array.Empty(); + /// Gets the number of recorded minimized delegation queries. + public int QNameMinimizedQueryCount { get; private set; } + /// Gets the number of recorded full-question fallbacks. + public int QNameMinimizationFallbackCount { get; private set; } + /// Gets unavailable evidence boundaries so consumers cannot mistake response hints for a full chain. + public string[] UnavailableEvidence { get; private set; } = Array.Empty(); + + /// Projects captured evidence without making additional DNS requests. + public static DnsQueryExplanation Create(DnsResponse response, AuditEntry[]? auditTrail = null) { + if (response == null) throw new ArgumentNullException(nameof(response)); + DnsAnswer[] answers = response.WireAnswers.Length > 0 ? response.WireAnswers.Select(r => new DnsAnswer { Name = r.Name, Type = r.Type, TTL = r.Ttl, DataRaw = r.Data }).ToArray() : response.Answers ?? Array.Empty(); + DnsAnswer[] authorities = response.Authorities ?? Array.Empty(); + var unavailable = new System.Collections.Generic.List { "Complete delegation traversal was not recorded.", "DNSSEC verdict is available; a complete ordered trust chain was not recorded." }; + if (auditTrail == null || auditTrail.Length == 0) unavailable.Add("Individual attempt audit was not supplied."); + if (response.ServerResolution == null) unavailable.Add("Endpoint hostname/bootstrap resolution evidence was not recorded."); + if (response.WireAnswers.Length == 0) unavailable.Add("Original wire answer was unavailable; alias hints use the visible response."); + return new DnsQueryExplanation { + ResponseSource = response.ResponseSource, Resolver = response.ServerAddress, Transport = response.UsedTransport, + Status = response.EffectiveStatus, ErrorCode = response.ErrorCode, RetryCount = response.RetryCount, Bootstrap = response.ServerResolution, + ResolverAuthenticData = response.AuthenticData, DnsSecValidationStatus = response.DnsSecValidationStatus, + DnsSecValidationMessage = response.DnsSecValidationMessage, + DnsSecValidationDependencies = response.DnsSecValidationDependencies.ToArray(), + Aliases = answers.Where(a => a.Type == DnsRecordType.CNAME || a.Type == DnsRecordType.DNAME).ToArray(), + AuthorityHints = authorities.Where(a => a.Type == DnsRecordType.NS || a.Type == DnsRecordType.SOA).ToArray(), + DnsSecRecords = answers.Concat(authorities).Concat(response.Additional ?? Array.Empty()).Where(a => a.Type == DnsRecordType.DNSKEY || a.Type == DnsRecordType.DS || a.Type == DnsRecordType.RRSIG || a.Type == DnsRecordType.NSEC || a.Type == DnsRecordType.NSEC3).ToArray(), + Attempts = (auditTrail ?? Array.Empty()).Select(DnsQueryAttemptExplanation.Create).ToArray(), + QNameMinimizedQueryCount = response.QNameMinimizedQueryCount, QNameMinimizationFallbackCount = response.QNameMinimizationFallbackCount, + UnavailableEvidence = unavailable.ToArray() + }; + } + } +} diff --git a/DnsClientX/DnsResolveOptions.cs b/DnsClientX/DnsResolveOptions.cs new file mode 100644 index 00000000..1b708119 --- /dev/null +++ b/DnsClientX/DnsResolveOptions.cs @@ -0,0 +1,34 @@ +using System; + +namespace DnsClientX; + +/// Query behavior for a reusable client. Resolver and transport settings belong to the client configuration. +public sealed class DnsResolveOptions { + /// Gets or sets whether DNSSEC records are requested. + public bool RequestDnsSec { get; set; } + /// Gets or sets whether responses are validated locally. + public bool ValidateDnsSec { get; set; } + /// Gets or sets whether the complete answer section is retained. + public bool ReturnAllTypes { get; set; } + /// Gets or sets whether transient failures are retried. + public bool RetryOnTransient { get; set; } = true; + /// Gets or sets the retry limit used by the client. + public int MaxRetries { get; set; } = 3; + /// Gets or sets the retry delay in milliseconds. + public int RetryDelayMs { get; set; } = 200; + /// Gets or sets whether parsed payload arrays are populated. + public bool TypedRecords { get; set; } + /// Gets or sets whether TXT records are parsed into specialized payloads. + public bool ParseTypedTxtRecords { get; set; } + + /// Rejects invalid retry settings before sending a query. + public void Validate() { + if (MaxRetries < 0) throw new ArgumentOutOfRangeException(nameof(MaxRetries)); + if (RetryDelayMs < 0) throw new ArgumentOutOfRangeException(nameof(RetryDelayMs)); + } + + internal DnsResolveOptions Snapshot() { + Validate(); + return (DnsResolveOptions)MemberwiseClone(); + } +} diff --git a/DnsClientX/DnsResponseCache.cs b/DnsClientX/DnsResponseCache.cs index 246c0a4d..a671b278 100644 --- a/DnsClientX/DnsResponseCache.cs +++ b/DnsClientX/DnsResponseCache.cs @@ -1,110 +1,130 @@ using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Collections.Generic; namespace DnsClientX { - /// - /// Simple in-memory cache for instances. - /// Stores responses together with their expiration times. - /// - internal class DnsResponseCache : IDisposable { + /// Stores independent response snapshots with bounded lifetimes and storage. + internal sealed class DnsResponseCache : IDisposable { private readonly Dictionary _cache = new(StringComparer.Ordinal); private readonly object _gate = new(); private readonly Timer _cleanupTimer; - private readonly int _cleanupThreshold; + private int _capacity; private readonly LinkedList _insertionOrder = new(); - private readonly TimeSpan _cleanupInterval; + private long _hits, _misses, _evictions, _expiredEntries; private bool _disposed; - /// - /// Initializes a new instance of the class. - /// - /// Interval for automatic cleanup. - /// Maximum number of entries before a cleanup is triggered. + /// Creates a cache with periodic expired-entry removal and a positive capacity. public DnsResponseCache(TimeSpan? cleanupInterval = null, int cleanupThreshold = 1000) { - _cleanupInterval = cleanupInterval ?? TimeSpan.FromMinutes(5); if (cleanupThreshold <= 0) throw new ArgumentOutOfRangeException(nameof(cleanupThreshold)); - _cleanupThreshold = cleanupThreshold; - _cleanupTimer = new Timer(_ => Cleanup(), null, _cleanupInterval, _cleanupInterval); + _capacity = cleanupThreshold; + TimeSpan interval = cleanupInterval ?? TimeSpan.FromMinutes(5); + _cleanupTimer = new Timer(_ => Cleanup(), null, interval, interval); } - /// - /// Wrapper class storing cached response together with its expiration timestamp. - /// - private class CacheEntry { - /// - /// Initializes a new instance of the class. - /// - /// DNS response to cache. - /// Expiration time of the cached entry. - /// The single eviction node owned by this entry. - public CacheEntry(DnsResponse response, DateTimeOffset expiration, LinkedListNode node) { + private sealed class CacheEntry { + internal CacheEntry(DnsResponse response, DateTimeOffset expiration, long insertedTimestamp, + TimeSpan lifetime, LinkedListNode node, string? name, DnsRecordType? type) { Response = response; Expiration = expiration; + InsertedTimestamp = insertedTimestamp; + Lifetime = lifetime; Node = node; + Name = name; + Type = type; } - - /// Gets the cached DNS response. - public DnsResponse Response { get; } - - /// Gets the expiration time for the cached entry. + internal DnsResponse Response { get; } public DateTimeOffset Expiration { get; } + internal long InsertedTimestamp { get; } + internal TimeSpan Lifetime { get; } internal LinkedListNode Node { get; } + internal string? Name { get; } + internal DnsRecordType? Type { get; } + internal double ElapsedSeconds(long timestamp) => + Math.Max(0, (timestamp - InsertedTimestamp) / (double)Stopwatch.Frequency); + internal bool IsExpired(DateTimeOffset utcNow, long timestamp) => + utcNow >= Expiration || ElapsedSeconds(timestamp) >= Lifetime.TotalSeconds; + } + + internal int Capacity { + get { lock (_gate) return _capacity; } + set { + if (value <= 0) throw new ArgumentOutOfRangeException(nameof(value), "Cache capacity must be positive."); + lock (_gate) { + _capacity = value; + CleanupExpired(DateTimeOffset.UtcNow, Stopwatch.GetTimestamp()); + TrimToCapacity(); + } + } } - /// - /// Attempts to retrieve a cached response for a given key. - /// - /// Cache key. - /// Retrieved response if found and not expired. - /// true if a valid entry was found; otherwise false. + /// Returns an aged independent response, preserving original wire evidence. public bool TryGet(string key, [NotNullWhen(true)] out DnsResponse? response) { CacheEntry? found = null; + long timestamp = Stopwatch.GetTimestamp(); lock (_gate) { if (_cache.TryGetValue(key, out var entry)) { - if (DateTimeOffset.UtcNow < entry.Expiration) found = entry; - else Remove(entry.Node); + if (!entry.IsExpired(DateTimeOffset.UtcNow, timestamp)) found = entry; + else { + Remove(entry.Node); + _expiredEntries++; + } } + if (found == null) _misses++; + else _hits++; } if (found != null) { response = found.Response.Clone(); + int elapsedSeconds = (int)Math.Min(int.MaxValue, Math.Floor(found.ElapsedSeconds(timestamp))); + AgeRecords(response.Answers, elapsedSeconds); + AgeRecords(response.Authorities, elapsedSeconds); + AgeRecords(response.Additional, elapsedSeconds); + response.RefreshDerivedData(); return true; } - response = default!; + response = null; return false; } - /// - /// Removes expired entries from the cache. - /// + private static void AgeRecords(DnsAnswer[]? records, int elapsedSeconds) { + if (records == null) return; + for (int index = 0; index < records.Length; index++) { + // OPT uses its wire TTL field for EDNS flags/version, not a record lifetime. + if (records[index].Type != DnsRecordType.OPT) { + records[index].TTL = Math.Max(0, records[index].TTL) > elapsedSeconds + ? records[index].TTL - elapsedSeconds : 0; + } + } + } + + /// Removes expired entries and their eviction bookkeeping. public void Cleanup() { - lock (_gate) { - var now = DateTimeOffset.UtcNow; - var node = _insertionOrder.First; - while (node != null) { - var next = node.Next; - if (_cache[node.Value].Expiration <= now) Remove(node); - node = next; + lock (_gate) CleanupExpired(DateTimeOffset.UtcNow, Stopwatch.GetTimestamp()); + } + + private void CleanupExpired(DateTimeOffset utcNow, long timestamp) { + var node = _insertionOrder.First; + while (node != null) { + var next = node.Next; + if (_cache[node.Value].IsExpired(utcNow, timestamp)) { + Remove(node); + _expiredEntries++; } + node = next; } } - /// - /// Stores a response in the cache using the specified TTL value. - /// - /// Cache key. - /// Response to cache. - /// Time to keep the entry. - /// None. - public void Set(string key, DnsResponse response, TimeSpan ttl) { + /// Stores a snapshot; authentication verdicts cannot outlive their proofs. + public void Set(string key, DnsResponse response, TimeSpan ttl, string? queryName = null, + DnsRecordType? queryType = null) { if (ttl <= TimeSpan.Zero) return; var now = DateTimeOffset.UtcNow; + long timestamp = Stopwatch.GetTimestamp(); var expiration = now.Add(ttl); if (response.DnsSecValidationAttempted) { if (response.DnsSecValidationStatus != DnsSecValidationStatus.Secure && response.DnsSecValidationStatus != DnsSecValidationStatus.Insecure) return; - // A security verdict cannot outlive its DNSKEY, DS, alias, or denial proofs. if (!response.DnsSecValidationExpiresUtc.HasValue) return; if (response.DnsSecValidationExpiresUtc.Value < expiration) { expiration = response.DnsSecValidationExpiresUtc.Value; @@ -112,24 +132,62 @@ public void Set(string key, DnsResponse response, TimeSpan ttl) { if (expiration <= now) return; } DnsResponse snapshot = response.Clone(); + if (queryName == null && response.Questions is { Length: > 0 }) { + queryName = response.Questions[0].Name; + queryType ??= response.Questions[0].Type; + } + string? canonicalName = queryName == null ? null : DnsWireNameCodec.Canonical(queryName); lock (_gate) { var node = _cache.TryGetValue(key, out var previous) ? previous.Node : _insertionOrder.AddLast(key); - _cache[key] = new CacheEntry(snapshot, expiration, node); - if (_cache.Count > _cleanupThreshold) { - Cleanup(); - while (_cache.Count > _cleanupThreshold && _insertionOrder.First != null) { - Remove(_insertionOrder.First); - } + _cache[key] = new CacheEntry(snapshot, expiration, timestamp, + expiration - now, node, canonicalName, queryType); + if (_cache.Count > _capacity) { + CleanupExpired(now, timestamp); + TrimToCapacity(); } } } - /// Clears cached entries and insertion-order bookkeeping. - internal void Clear() { + private void TrimToCapacity() { + while (_cache.Count > _capacity && _insertionOrder.First != null) { + Remove(_insertionOrder.First); + _evictions++; + } + } + + /// Clears entries without resetting lifetime lookup and removal statistics. + internal int Clear() { lock (_gate) { + int removed = _cache.Count; _cache.Clear(); _insertionOrder.Clear(); + return removed; + } + } + + internal int Invalidate(string canonicalName, DnsRecordType? type) { + lock (_gate) { + int removed = 0; + var node = _insertionOrder.First; + while (node != null) { + var next = node.Next; + CacheEntry entry = _cache[node.Value]; + if (entry.Name == canonicalName && (!type.HasValue || entry.Type == type)) { + Remove(node); + removed++; + } + node = next; + } + return removed; + } + } + + internal DnsResponseCacheStatistics GetStatistics() { + lock (_gate) { + CleanupExpired(DateTimeOffset.UtcNow, Stopwatch.GetTimestamp()); + return new DnsResponseCacheStatistics(_capacity, _cache.Count, _hits, _misses, + _evictions, _expiredEntries); } } @@ -140,9 +198,7 @@ private void Remove(LinkedListNode node) { /// public void Dispose() { - if (_disposed) { - return; - } + if (_disposed) return; _disposed = true; _cleanupTimer.Dispose(); } diff --git a/DnsClientX/DnsResponseCacheStatistics.cs b/DnsClientX/DnsResponseCacheStatistics.cs new file mode 100644 index 00000000..f49ff3d9 --- /dev/null +++ b/DnsClientX/DnsResponseCacheStatistics.cs @@ -0,0 +1,31 @@ +namespace DnsClientX; + +/// A snapshot of the process-wide response cache, shared by cache-enabled clients. +/// +/// Counters accumulate for the lifetime of the process and are not reset by clearing entries. +/// Hits and misses count cache lookups, including internal single-flight checks, rather than queries. +/// +public sealed class DnsResponseCacheStatistics { + internal DnsResponseCacheStatistics(int capacity, int entryCount, long hits, long misses, + long evictions, long expiredEntries) { + Capacity = capacity; + EntryCount = entryCount; + Hits = hits; + Misses = misses; + Evictions = evictions; + ExpiredEntries = expiredEntries; + } + + /// Gets the maximum number of cached responses. + public int Capacity { get; } + /// Gets the current unexpired response count. + public int EntryCount { get; } + /// Gets successful cache lookup count. + public long Hits { get; } + /// Gets cache lookups that did not find an unexpired response. + public long Misses { get; } + /// Gets responses removed to enforce capacity. + public long Evictions { get; } + /// Gets responses removed after their lifetime expired. + public long ExpiredEntries { get; } +} diff --git a/DnsClientX/DnsSecMaterialSession.cs b/DnsClientX/DnsSecMaterialSession.cs new file mode 100644 index 00000000..012ed194 --- /dev/null +++ b/DnsClientX/DnsSecMaterialSession.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX; + +/// Stages material until the validator accepts the complete dependency chain. +internal sealed class DnsSecMaterialSession { + private readonly DnsResponseCache? _cache; + private readonly Func _key; + private readonly Func> _lookup; + private readonly List<(string Name, DnsRecordType Type, string Key, DnsResponse Response)> _observed = new(); + internal DnsSecMaterialSession(DnsResponseCache? cache, Func key, + Func> lookup) { + _cache = cache; + _key = key; + _lookup = lookup; + } + + internal async Task LookupAsync(string name, DnsRecordType type, CancellationToken token) { + token.ThrowIfCancellationRequested(); + string key = _key(name, type); + DnsResponse response; + if (_cache != null && _cache.TryGet(key, out var cached)) { + response = cached; + response.ResponseSource = DnsResponseSource.Cache; + } else { + response = await _lookup(name, type, token).ConfigureAwait(false); + response.ReceivedAtUtc ??= DateTimeOffset.UtcNow; + } + _observed.Add((name, type, key, response)); + return response; + } + + internal DnsSecValidationDependency[] Complete(DnsSecValidationEngine validator, DnsSecValidationStatus status, + TimeSpan maxCacheTtl) { + bool accepted = status is DnsSecValidationStatus.Secure or DnsSecValidationStatus.Insecure; + DateTimeOffset now = DateTimeOffset.UtcNow; + var dependencies = new List(); + foreach (var item in _observed) { + DateTimeOffset? reusableUntil = null; + if (accepted && validator.CacheExpiresAtUtc.HasValue) { + validator.ApplyAuthenticatedLifetimes(item.Response); + TimeSpan lifetime = ClientX.GetCacheTtl(item.Response, item.Name, item.Type); + if (lifetime > maxCacheTtl) lifetime = maxCacheTtl; + TimeSpan proofLifetime = validator.CacheExpiresAtUtc.Value - now; + if (lifetime > proofLifetime) lifetime = proofLifetime; + if (lifetime > TimeSpan.Zero) { + reusableUntil = now.Add(lifetime); + // A cache hit already has an absolute expiry. Do not refresh it on reuse. + if (item.Response.ResponseSource != DnsResponseSource.Cache) + _cache?.Set(item.Key, item.Response, lifetime, item.Name, item.Type); + } + } + dependencies.Add(new DnsSecValidationDependency(item.Name, item.Type, item.Response, reusableUntil)); + } + return dependencies.ToArray(); + } +} diff --git a/DnsClientX/DnsSecValidationDependency.cs b/DnsClientX/DnsSecValidationDependency.cs new file mode 100644 index 00000000..a4d6625f --- /dev/null +++ b/DnsClientX/DnsSecValidationDependency.cs @@ -0,0 +1,31 @@ +using System; + +namespace DnsClientX; + +/// Material actually fetched while validating a response. This describes observations, not a complete trust graph. +public sealed class DnsSecValidationDependency { + internal DnsSecValidationDependency(string name, DnsRecordType type, DnsResponse response, + DateTimeOffset? reusableUntilUtc) { + Name = name; + Type = type; + ResponseSource = response.ResponseSource; + Status = response.Status; + AnswerCount = response.Answers.Length; + AuthorityCount = response.Authorities.Length; + ReusableUntilUtc = reusableUntilUtc; + } + /// Gets the queried owner name. + public string Name { get; } + /// Gets the queried record type. + public DnsRecordType Type { get; } + /// Gets whether material came from the network or the client's material cache. + public DnsResponseSource ResponseSource { get; } + /// Gets the material response status. + public DnsResponseCode Status { get; } + /// Gets the observed answer count. + public int AnswerCount { get; } + /// Gets the observed authority count. + public int AuthorityCount { get; } + /// Gets the bounded reuse deadline, or null if the validation did not qualify this material for reuse. + public DateTimeOffset? ReusableUntilUtc { get; } +} diff --git a/DnsClientX/DnsTransportCapabilities.cs b/DnsClientX/DnsTransportCapabilities.cs index 4a819640..d3058334 100644 --- a/DnsClientX/DnsTransportCapabilities.cs +++ b/DnsClientX/DnsTransportCapabilities.cs @@ -12,15 +12,8 @@ namespace DnsClientX { /// reference the same API surface, but unsupported transports are reported explicitly at runtime. /// public static class DnsTransportCapabilities { - private static readonly DnsRequestFormat[] DefaultReportFormats = new[] { - DnsRequestFormat.DnsOverUDP, - DnsRequestFormat.DnsOverTCP, - DnsRequestFormat.DnsOverTLS, - DnsRequestFormat.DnsOverHttps, - DnsRequestFormat.DnsOverHttp2, - DnsRequestFormat.DnsOverHttp3, - DnsRequestFormat.DnsOverQuic - }; + private static readonly DnsRequestFormat[] DefaultReportFormats = + Enum.GetValues(typeof(DnsRequestFormat)).Cast().ToArray(); /// /// Gets a value indicating whether DNS over HTTP/3 is supported by the current target/runtime combination. @@ -61,6 +54,18 @@ public static bool SupportsDnsOverQuic { /// public static bool SupportsModernHttp => SupportsDnsOverHttp3; + /// Gets whether this target includes the custom DNS-over-gRPC service client. + /// This transport implements DnsResolver/QueryDns and requires a matching service. + public static bool SupportsDnsOverGrpc { + get { +#if NET8_0_OR_GREATER + return true; +#else + return false; +#endif + } + } + /// /// Gets a value indicating whether the current target and runtime can provide RFC 9103 /// zone transfer over TLS with TLS 1.3 and the dot ALPN value. @@ -110,10 +115,16 @@ public static bool Supports(DnsRequestFormat requestFormat) { return requestFormat switch { DnsRequestFormat.DnsOverHttp3 => SupportsDnsOverHttp3, DnsRequestFormat.DnsOverQuic => SupportsDnsOverQuic, + DnsRequestFormat.DnsOverGrpc => SupportsDnsOverGrpc, DnsRequestFormat.ObliviousDnsOverHttps => false, DnsRequestFormat.DnsCrypt => false, DnsRequestFormat.DnsCryptRelay => false, - _ => true + DnsRequestFormat.DnsOverUDP or DnsRequestFormat.DnsOverTCP or DnsRequestFormat.DnsOverTLS + or DnsRequestFormat.DnsOverHttps or DnsRequestFormat.DnsOverHttpsPOST + or DnsRequestFormat.DnsOverHttpsWirePost or DnsRequestFormat.DnsOverHttpsJSON + or DnsRequestFormat.DnsOverHttpsJSONPOST or DnsRequestFormat.DnsOverHttp2 + or DnsRequestFormat.Multicast => true, + _ => false }; } @@ -124,7 +135,8 @@ public static bool Supports(DnsRequestFormat requestFormat) { /// Capability report entries. public static DnsTransportCapabilityInfo[] GetCapabilityReport(bool modernOnly = false) { IEnumerable formats = modernOnly - ? DefaultReportFormats.Where(format => format == DnsRequestFormat.DnsOverHttp3 || format == DnsRequestFormat.DnsOverQuic) + ? DefaultReportFormats.Where(format => format is DnsRequestFormat.DnsOverHttp3 + or DnsRequestFormat.DnsOverQuic or DnsRequestFormat.DnsOverGrpc) : DefaultReportFormats; return formats @@ -180,6 +192,8 @@ public static string GetUnsupportedMessage(DnsRequestFormat requestFormat) { "DNS over HTTP/3 is supported in the core package on net8+ and is unavailable on this runtime.", DnsRequestFormat.DnsOverQuic => "DNS over QUIC is supported in the core package on net8+ when the runtime provides QUIC support and is unavailable on this runtime.", + DnsRequestFormat.DnsOverGrpc => + "The custom DNS-over-gRPC service client requires the net8.0 or newer target and is unavailable in this target.", DnsRequestFormat.ObliviousDnsOverHttps => "ODoH requires HPKE plus relay and target handling and is not implemented by the core package.", DnsRequestFormat.DnsCrypt or DnsRequestFormat.DnsCryptRelay => @@ -211,12 +225,46 @@ private static DnsTransportCapabilityInfo CreateCapabilityInfo(DnsRequestFormat TargetFrameworkScope = "All supported targets", Notes = "Encrypted core transport available without adding extra transport packages." }, - DnsRequestFormat.DnsOverHttps => new DnsTransportCapabilityInfo { - Name = "DNS over HTTPS", + DnsRequestFormat.DnsOverHttps or DnsRequestFormat.DnsOverHttpsPOST + or DnsRequestFormat.DnsOverHttpsWirePost => new DnsTransportCapabilityInfo { + Name = requestFormat == DnsRequestFormat.DnsOverHttps ? "DNS over HTTPS (wire GET)" : "DNS over HTTPS (wire POST)", + RequestFormat = requestFormat, + Supported = true, + TargetFrameworkScope = "All supported targets", + Notes = "RFC 8484 wire-format DNS over HTTPS." + }, + DnsRequestFormat.DnsOverHttpsJSON or DnsRequestFormat.DnsOverHttpsJSONPOST => new DnsTransportCapabilityInfo { + Name = requestFormat == DnsRequestFormat.DnsOverHttpsJSON ? "DNS over HTTPS (JSON GET)" : "DNS over HTTPS (JSON POST)", + RequestFormat = requestFormat, + Supported = true, + TargetFrameworkScope = "All supported targets", + Notes = "Provider JSON API; requires an endpoint implementing the selected JSON contract." + }, + DnsRequestFormat.Multicast => new DnsTransportCapabilityInfo { + Name = "Multicast DNS", RequestFormat = requestFormat, Supported = true, TargetFrameworkScope = "All supported targets", - Notes = "Covers the shared DoH family used by browser-safe and local workflows." + RuntimeRequirement = "Requires access to the selected local multicast interface", + Notes = "RFC 6762 local-link DNS; availability depends on network and interface policy." + }, + DnsRequestFormat.DnsOverGrpc => new DnsTransportCapabilityInfo { + Name = "DNS over gRPC (custom service)", + RequestFormat = requestFormat, + Supported = SupportsDnsOverGrpc, + TargetFrameworkScope = ".NET 8+", + RuntimeRequirement = "Requires a compatible DnsResolver/QueryDns service over HTTP/2", + Notes = SupportsDnsOverGrpc + ? "Custom service contract, not a standardized DNS transport." + : GetUnsupportedMessage(requestFormat) + }, + DnsRequestFormat.DnsCrypt or DnsRequestFormat.DnsCryptRelay + or DnsRequestFormat.ObliviousDnsOverHttps => new DnsTransportCapabilityInfo { + Name = requestFormat == DnsRequestFormat.ObliviousDnsOverHttps ? "Oblivious DNS over HTTPS" : requestFormat.ToString(), + RequestFormat = requestFormat, + Supported = false, + TargetFrameworkScope = "Not implemented", + Notes = GetUnsupportedMessage(requestFormat) }, DnsRequestFormat.DnsOverHttp2 => new DnsTransportCapabilityInfo { Name = "DNS over HTTP/2", diff --git a/DnsClientX/DnsTypedRecord.cs b/DnsClientX/DnsTypedRecord.cs new file mode 100644 index 00000000..dfa0b58c --- /dev/null +++ b/DnsClientX/DnsTypedRecord.cs @@ -0,0 +1,23 @@ +namespace DnsClientX; + +/// A parsed DNS record together with its owner, class, remaining TTL and wire evidence. +/// The parsed payload type. +public sealed class DnsTypedRecord where TRecord : class { + internal DnsTypedRecord(DnsAnswer record, TRecord value) { + Record = record; + Value = value; + } + + /// Gets the source record, including raw RDATA when available. + public DnsAnswer Record { get; } + /// Gets the parsed payload. + public TRecord Value { get; } + /// Gets the record owner. + public string Name => Record.Name; + /// Gets the DNS record type. + public DnsRecordType Type => Record.Type; + /// Gets the remaining lifetime in seconds. + public int TTL => Record.TTL; + /// Gets the DNS class. + public ushort? Class => Record.Class; +} diff --git a/DnsClientX/ResolveDnsRequest.cs b/DnsClientX/ResolveDnsRequest.cs index 85980455..ea4fbfca 100644 --- a/DnsClientX/ResolveDnsRequest.cs +++ b/DnsClientX/ResolveDnsRequest.cs @@ -89,7 +89,7 @@ public sealed class ResolveDnsRequest { public int PerEndpointMaxInFlight { get; set; } /// - /// Gets or sets whether multi-resolver response caching is enabled. + /// Gets or sets whether response caching is enabled for the selected resolver source. /// public bool ResponseCache { get; set; } @@ -383,6 +383,20 @@ public string[] GetExpandedNames() { return ProxyUri is null ? null : new WebProxy(ProxyUri); } + internal ResolveDnsRequest Snapshot() { + var copy = (ResolveDnsRequest)MemberwiseClone(); + copy.Names = (string[])Names.Clone(); + copy.RecordTypes = (DnsRecordType[])RecordTypes.Clone(); + copy.DnsProviders = (DnsEndpoint[])DnsProviders.Clone(); + copy.Servers = (string[])Servers.Clone(); + copy.ResolverEndpoints = (string[])ResolverEndpoints.Clone(); + copy.ResolverEndpointFiles = (string[])ResolverEndpointFiles.Clone(); + copy.ResolverEndpointUrls = (string[])ResolverEndpointUrls.Clone(); + copy.ResolverDnsProviders = (DnsEndpoint[])ResolverDnsProviders.Clone(); + copy.EdnsCookie = EdnsCookie == null ? null : (byte[])EdnsCookie.Clone(); + return copy; + } + internal EdnsOptions? CreateEdnsOptions() { bool hasSubnet = !string.IsNullOrWhiteSpace(ClientSubnet); bool hasCookie = EdnsCookie is { Length: > 0 }; diff --git a/DnsClientX/ResolverAnswerObservation.cs b/DnsClientX/ResolverAnswerObservation.cs new file mode 100644 index 00000000..9a855c03 --- /dev/null +++ b/DnsClientX/ResolverAnswerObservation.cs @@ -0,0 +1,26 @@ +namespace DnsClientX { + /// Correlates an observed DNS status and answer set with its resolver and query. + public sealed class ResolverAnswerObservation { + /// Gets or sets the resolver target. + public string Target { get; set; } = string.Empty; + /// Gets or sets the query name. + public string Name { get; set; } = string.Empty; + /// Gets or sets the query type. + public DnsRecordType RecordType { get; set; } + /// Gets or sets the observed status, including NoResponse. + public string Status { get; set; } = string.Empty; + /// Gets or sets the TTL-independent normalized answer signature. + public string AnswerSignature { get; set; } = string.Empty; + } + /// Provides a caller-supplied authoritative response for one query; its origin is not authenticated by comparison. + public sealed class ResolverAnswerReference { + /// Gets or sets the query name associated with the reference. + public string Name { get; set; } = string.Empty; + /// Gets or sets the reference query type. + public DnsRecordType RecordType { get; set; } + /// Gets or sets the caller's source description. + public string Source { get; set; } = string.Empty; + /// Gets or sets the reference response. Its AA flag must be set. + public DnsResponse Response { get; set; } = new DnsResponse(); + } +} diff --git a/DnsClientX/ResolverBenchmarkAttemptReport.cs b/DnsClientX/ResolverBenchmarkAttemptReport.cs new file mode 100644 index 00000000..dba7a7fd --- /dev/null +++ b/DnsClientX/ResolverBenchmarkAttemptReport.cs @@ -0,0 +1,21 @@ +namespace DnsClientX { + /// Contains correlated, JSON-safe details for an individual benchmark query. + public sealed class ResolverBenchmarkAttemptReport { + /// Gets or sets the resolver target. + public string Target { get; set; } = string.Empty; + /// Gets or sets the queried name. + public string Name { get; set; } = string.Empty; + /// Gets or sets the query type. + public DnsRecordType? RecordType { get; set; } + /// Gets or sets the response status or NoResponse. + public string Status { get; set; } = string.Empty; + /// Gets or sets the effective error, including failures without a response. + public string Error { get; set; } = string.Empty; + /// Gets or sets the complete duration in milliseconds. + public double ElapsedMs { get; set; } + /// Gets or sets the primary transport duration, when measured. + public double? TransportElapsedMs { get; set; } + /// Gets or sets captured response evidence. + public DnsQueryExplanation? Explanation { get; set; } + } +} diff --git a/DnsClientX/ResolverBenchmarkReport.cs b/DnsClientX/ResolverBenchmarkReport.cs index 365bad94..69183ee0 100644 --- a/DnsClientX/ResolverBenchmarkReport.cs +++ b/DnsClientX/ResolverBenchmarkReport.cs @@ -3,6 +3,8 @@ namespace DnsClientX { /// Represents a fully shaped shared benchmark report. /// public sealed class ResolverBenchmarkReport { + /// Gets or sets correlated attempt diagnostics, absent when built from pre-aggregated candidates. + public ResolverBenchmarkAttemptReport[] Attempts { get; set; } = System.Array.Empty(); /// Gets or sets the ranked benchmark results. public ResolverBenchmarkReportResult[] Results { get; set; } = System.Array.Empty(); /// Gets or sets the benchmark summary. diff --git a/DnsClientX/ResolverBenchmarkReportBuilder.cs b/DnsClientX/ResolverBenchmarkReportBuilder.cs index a9fa7bd3..38c9501d 100644 --- a/DnsClientX/ResolverBenchmarkReportBuilder.cs +++ b/DnsClientX/ResolverBenchmarkReportBuilder.cs @@ -50,6 +50,18 @@ public static ResolverBenchmarkReport Build( report.Summary.RuntimeCapabilityWarnings = DnsTransportCapabilities.GetUnsupportedWarnings(attempts); report.Snapshot.Summary.RuntimeUnsupportedCandidateCount = report.Summary.RuntimeUnsupportedCandidateCount; report.Snapshot.Summary.RuntimeCapabilityWarnings = report.Summary.RuntimeCapabilityWarnings; + if (attempts.Count > 0) { + report.Attempts = attempts.Select(attempt => { + return new ResolverBenchmarkAttemptReport { Target = attempt.Target, Name = attempt.Name, RecordType = attempt.RecordType, + Status = attempt.Status, Error = attempt.EffectiveError, ElapsedMs = attempt.Elapsed.TotalMilliseconds, + TransportElapsedMs = attempt.TransportElapsed?.TotalMilliseconds, + Explanation = attempt.Response == null ? null : DnsQueryExplanation.Create(attempt.Response) }; + }).ToArray(); + report.Snapshot.Answers = attempts.Where(attempt => !string.IsNullOrWhiteSpace(attempt.Name) && attempt.RecordType.HasValue).Select(attempt => new ResolverAnswerObservation { + Target = attempt.Target, Name = attempt.Name, RecordType = attempt.RecordType!.Value, + Status = attempt.Status, AnswerSignature = attempt.AnswerSignature + }).ToArray(); + } return report; } diff --git a/DnsClientX/ResolverBenchmarkWorkflow.cs b/DnsClientX/ResolverBenchmarkWorkflow.cs index b3679bff..30768102 100644 --- a/DnsClientX/ResolverBenchmarkWorkflow.cs +++ b/DnsClientX/ResolverBenchmarkWorkflow.cs @@ -102,6 +102,7 @@ public static async Task RunAsync( maxConcurrency, timeoutMs, policy); + ResolverScoreRecommendationBinding.Apply(report, runOptions, targets); return report; } } diff --git a/DnsClientX/ResolverHealthProfile.cs b/DnsClientX/ResolverHealthProfile.cs new file mode 100644 index 00000000..a2ecbc4b --- /dev/null +++ b/DnsClientX/ResolverHealthProfile.cs @@ -0,0 +1,111 @@ +using System; +using System.Linq; + +namespace DnsClientX { + /// Describes a portable, repeatable resolver health query catalog and its policy. + public sealed class ResolverHealthProfile { + /// Gets the supported profile schema version. + public const int CurrentSchemaVersion = 1; + /// Gets or sets the profile schema version. + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + /// Gets or sets a descriptive profile name. + public string Name { get; set; } = "resolver-health"; + /// Gets or sets built-in targets. Use these or explicit endpoints. + public DnsEndpoint[] BuiltInEndpoints { get; set; } = Array.Empty(); + /// Gets or sets portable inline resolver endpoints with distinct execution settings, such as udp@1.1.1.1:53. + public string[] ResolverEndpoints { get; set; } = Array.Empty(); + /// Gets or sets the DNS names in the query catalog. + public string[] Names { get; set; } = Array.Empty(); + /// Gets or sets the record types queried for every name. + public DnsRecordType[] RecordTypes { get; set; } = new[] { DnsRecordType.A }; + /// Gets or sets the timeout per query in milliseconds. + public int TimeoutMs { get; set; } = 2000; + /// Gets or sets the number of benchmark attempts per combination. + public int AttemptsPerCombination { get; set; } = 3; + /// Gets or sets the maximum concurrent benchmark requests. + public int MaxConcurrency { get; set; } = 4; + /// Gets or sets benchmark connection reuse. + public ResolverQueryConnectionMode ConnectionMode { get; set; } = ResolverQueryConnectionMode.Cold; + /// Gets or sets whether DNSSEC material is requested. + public bool RequestDnsSec { get; set; } + /// Gets or sets whether DNSSEC is validated locally. + public bool ValidateDnsSec { get; set; } + /// Gets or sets whether EDNS NSID is requested. + public bool RequestNsid { get; set; } + /// Gets or sets an IP-literal UDP/TCP bootstrap endpoint. + public string? BootstrapEndpoint { get; set; } + /// Gets or sets the gates applied independently to every probe query. + public ResolverProbePolicy ProbePolicy { get; set; } = new ResolverProbePolicy(); + /// Gets or sets the gates applied to an optional benchmark run. + public ResolverBenchmarkPolicy BenchmarkPolicy { get; set; } = new ResolverBenchmarkPolicy(); + + /// Validates the schema, targets, query catalog, workload bounds, and thresholds. + public void Validate() { + if (SchemaVersion != CurrentSchemaVersion) { + throw new InvalidOperationException($"Unsupported resolver health profile schema version {SchemaVersion}; expected {CurrentSchemaVersion}."); + } + if (string.IsNullOrWhiteSpace(Name) || Name.Length > 256) { + throw new ArgumentException("A profile name of 1 through 256 characters is required."); + } + if (BuiltInEndpoints == null || ResolverEndpoints == null || Names == null || RecordTypes == null || ProbePolicy == null || BenchmarkPolicy == null) { + throw new ArgumentException("Profile arrays and policies cannot be null."); + } + if ((BuiltInEndpoints.Length == 0) == (ResolverEndpoints.Length == 0)) { + throw new ArgumentException("Specify either BuiltInEndpoints or ResolverEndpoints in the profile."); + } + int count = BuiltInEndpoints.Length + ResolverEndpoints.Length; + if (count > 64 || BuiltInEndpoints.Any(e => !Enum.IsDefined(typeof(DnsEndpoint), e) || e == DnsEndpoint.Custom) || BuiltInEndpoints.Distinct().Count() != BuiltInEndpoints.Length) { + throw new ArgumentException("Use at most 64 distinct supported resolver targets."); + } + if (ResolverEndpoints.Any(e => string.IsNullOrWhiteSpace(e) || e.Length > 2048)) { + throw new ArgumentException("Resolver endpoint values must contain 1 through 2048 characters."); + } + DnsResolverEndpoint[] parsedEndpoints = EndpointParser.TryParseMany(ResolverEndpoints, out var endpointErrors); + if (endpointErrors.Count > 0) { + throw new ArgumentException(string.Join("; ", endpointErrors)); + } + if (ResolverEndpoints.Length > 0) { + count = parsedEndpoints.Select(ResolverEndpointClientFactory.GetExecutionKey).Distinct(StringComparer.Ordinal).Count(); + if (count != parsedEndpoints.Length) { + throw new ArgumentException("Profile resolver targets must identify distinct executions."); + } + if (count > 64) { + throw new ArgumentException("Use at most 64 distinct supported resolver targets."); + } + } + if (Names.Length < 1 || Names.Length > 64 || Names.Any(n => string.IsNullOrWhiteSpace(n) || n.Length > 253)) { + throw new ArgumentException("Use 1 through 64 DNS names, each at most 253 characters."); + } + if (Names.Select(n => n.TrimEnd('.')).Distinct(StringComparer.OrdinalIgnoreCase).Count() != Names.Length || RecordTypes.Distinct().Count() != RecordTypes.Length) { + throw new ArgumentException("The query catalog cannot contain duplicate names or types."); + } + if (RecordTypes.Length < 1 || RecordTypes.Length > 16 || RecordTypes.Any(t => !Enum.IsDefined(typeof(DnsRecordType), t) || t == DnsRecordType.AXFR || t == DnsRecordType.IXFR)) { + throw new ArgumentException("Use 1 through 16 supported query types; zone transfers are separate workflows."); + } + if (TimeoutMs < 1 || TimeoutMs > 300000 || MaxConcurrency < 1 || MaxConcurrency > 256 || AttemptsPerCombination < 1 || AttemptsPerCombination > 1000) { + throw new ArgumentException("Timeout, concurrency, or attempt count is outside the profile limits."); + } + if ((long)count * Names.Length * RecordTypes.Length * AttemptsPerCombination > 65536) { + throw new ArgumentException("The profile exceeds 65536 benchmark attempts."); + } + if (ConnectionMode != ResolverQueryConnectionMode.Cold && ConnectionMode != ResolverQueryConnectionMode.Warm) { + throw new ArgumentException("Profile connections must be Cold or Warm."); + } + if (!string.IsNullOrWhiteSpace(BootstrapEndpoint)) { + EndpointParser.ParseBootstrap(BootstrapEndpoint!); + } + ValidatePercent(ProbePolicy.MinSuccessPercent); + ValidatePercent(ProbePolicy.MinConsensusPercent); + ValidatePercent(BenchmarkPolicy.MinSuccessPercent); + if (ProbePolicy.MinSuccessCount < 1 || ProbePolicy.MinSuccessCount > count || BenchmarkPolicy.MinSuccessfulCandidates < 1 || BenchmarkPolicy.MinSuccessfulCandidates > count) { + throw new ArgumentException("A minimum candidate count must be between 1 and the target count."); + } + } + + private static void ValidatePercent(int? value) { + if (value < 1 || value > 100) { + throw new ArgumentException("Profile percentages must be between 1 and 100."); + } + } + } +} diff --git a/DnsClientX/ResolverHealthProfileStore.cs b/DnsClientX/ResolverHealthProfileStore.cs new file mode 100644 index 00000000..f45ea00a --- /dev/null +++ b/DnsClientX/ResolverHealthProfileStore.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Text; + +namespace DnsClientX { + /// Reads and writes bounded, versioned local resolver health profiles. + public static class ResolverHealthProfileStore { + /// Gets the maximum UTF-8 profile size accepted by this library. + public const int MaximumProfileBytes = 256 * 1024; + /// Loads and validates a profile without downloading remote configuration. + public static ResolverHealthProfile Load(string path) { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path)); + using var input = File.OpenRead(Path.GetFullPath(path)); + using var buffer = new MemoryStream(); + var block = new byte[4096]; + int read; + while ((read = input.Read(block, 0, block.Length)) > 0) { + if (buffer.Length + read > MaximumProfileBytes) throw new InvalidOperationException("Resolver health profile exceeds the 256 KiB limit."); + buffer.Write(block, 0, read); + } + ResolverHealthProfile profile = DnsClientXJsonSerializer.Deserialize(new UTF8Encoding(false, true).GetString(buffer.ToArray())) ?? throw new InvalidOperationException("Resolver health profile is empty."); + profile.Validate(); + return profile; + } + /// Validates and saves a profile as UTF-8 JSON. + public static void Save(string path, ResolverHealthProfile profile) { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path)); + if (profile == null) throw new ArgumentNullException(nameof(profile)); + profile.Validate(); + string json = DnsClientXJsonSerializer.Serialize(profile); + if (Encoding.UTF8.GetByteCount(json) > MaximumProfileBytes) throw new InvalidOperationException("Resolver health profile exceeds the 256 KiB limit."); + File.WriteAllText(Path.GetFullPath(path), json, new UTF8Encoding(false)); + } + } +} diff --git a/DnsClientX/ResolverHealthReport.cs b/DnsClientX/ResolverHealthReport.cs new file mode 100644 index 00000000..9a8a6da1 --- /dev/null +++ b/DnsClientX/ResolverHealthReport.cs @@ -0,0 +1,19 @@ +using System; + +namespace DnsClientX { + /// Contains correlated query reports for one repeatable health profile run. + public sealed class ResolverHealthReport { + /// Gets or sets the profile name. + public string ProfileName { get; set; } = string.Empty; + /// Gets or sets when the run completed. + public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow; + /// Gets or sets per-name/type probe reports, including response evidence and policies. + public ResolverProbeReport[] Queries { get; set; } = Array.Empty(); + /// Gets or sets the optional benchmark of the same catalog. + public ResolverBenchmarkReport? Benchmark { get; set; } + /// Gets or sets whether every requested policy passed. + public bool PolicyPassed { get; set; } + /// Gets or sets a factual answer-variation explanation. + public string AnswerVariationNote { get; set; } = "Resolver differences can reflect location, caching, or split DNS policy. Agreement alone does not establish authoritative correctness."; + } +} diff --git a/DnsClientX/ResolverHealthWorkflow.cs b/DnsClientX/ResolverHealthWorkflow.cs new file mode 100644 index 00000000..cb07065a --- /dev/null +++ b/DnsClientX/ResolverHealthWorkflow.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace DnsClientX { + /// Applies a validated health profile to the existing probe and benchmark engines. + public static class ResolverHealthWorkflow { + /// Runs all profile queries and optionally benchmarks the same catalog. Benchmark candidates must have distinct display labels. Programmatic verifier providers remain supplied by the caller. + public static async Task RunAsync( + ResolverHealthProfile profile, + bool includeBenchmark = false, + IDnsSecSignatureVerifier? dnsSecSignatureVerifier = null, + Func>? builtInOverride = null, + Func>? explicitOverride = null, + CancellationToken cancellationToken = default) { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + profile.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + var source = new ResolverExecutionTargetSource { BuiltInEndpoints = profile.BuiltInEndpoints, ResolverEndpoints = profile.ResolverEndpoints }; + ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(source, cancellationToken).ConfigureAwait(false); + if (includeBenchmark && targets.GroupBy(target => target.DisplayName, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() > 1)) { + throw new ArgumentException("Benchmark endpoints share a candidate display label. Use separate benchmark profiles until reports can distinguish their execution settings.", nameof(profile)); + } + var options = new ResolverQueryRunOptions { + TimeoutMs = profile.TimeoutMs, ConnectionMode = profile.ConnectionMode, + RequestDnsSec = profile.RequestDnsSec, ValidateDnsSec = profile.ValidateDnsSec, + RequestNsid = profile.RequestNsid, DnsSecSignatureVerifier = dnsSecSignatureVerifier, + BootstrapResolver = string.IsNullOrWhiteSpace(profile.BootstrapEndpoint) ? null : EndpointParser.ParseBootstrap(profile.BootstrapEndpoint!) + }; + var queries = new List(); + foreach (string name in profile.Names) { + foreach (DnsRecordType type in profile.RecordTypes) { + cancellationToken.ThrowIfCancellationRequested(); + queries.Add(await ResolverProbeWorkflow.RunAsync(targets, name, type, profile.TimeoutMs, options, profile.ProbePolicy, + builtInOverride: builtInOverride, explicitOverride: explicitOverride, cancellationToken: cancellationToken).ConfigureAwait(false)); + } + } + ResolverBenchmarkReport? benchmark = includeBenchmark + ? await ResolverBenchmarkWorkflow.RunAsync(targets, profile.Names, profile.RecordTypes, profile.AttemptsPerCombination, profile.MaxConcurrency, + profile.TimeoutMs, options, profile.BenchmarkPolicy, builtInOverride: builtInOverride, explicitOverride: explicitOverride, cancellationToken: cancellationToken).ConfigureAwait(false) + : null; + return new ResolverHealthReport { ProfileName = profile.Name, Queries = queries.ToArray(), Benchmark = benchmark, + PolicyPassed = queries.All(q => q.Evaluation.PolicyPassed && q.Evaluation.SuccessfulCandidates > 0) && (benchmark == null || benchmark.Evaluation.PolicyPassed && benchmark.Evaluation.SuccessfulCandidates > 0) }; + } + } +} diff --git a/DnsClientX/ResolverProbeEvaluationEntry.cs b/DnsClientX/ResolverProbeEvaluationEntry.cs index 098ad256..c70b0e05 100644 --- a/DnsClientX/ResolverProbeEvaluationEntry.cs +++ b/DnsClientX/ResolverProbeEvaluationEntry.cs @@ -3,6 +3,7 @@ namespace DnsClientX { /// Represents one ranked resolver candidate in a probe evaluation. /// public sealed class ResolverProbeEvaluationEntry { + internal int SourceIndex { get; set; } /// /// Gets or sets the human-readable candidate label. /// diff --git a/DnsClientX/ResolverProbeObservation.cs b/DnsClientX/ResolverProbeObservation.cs index 3f811a53..3248aec8 100644 --- a/DnsClientX/ResolverProbeObservation.cs +++ b/DnsClientX/ResolverProbeObservation.cs @@ -5,6 +5,8 @@ namespace DnsClientX { /// Represents one resolver candidate observation from a probe workflow. /// public sealed class ResolverProbeObservation { + // Correlates ranked observations with source attempts without making the display label an ID. + internal int SourceIndex { get; set; } /// /// Gets or sets the human-readable candidate label. /// diff --git a/DnsClientX/ResolverProbeReportBuilder.cs b/DnsClientX/ResolverProbeReportBuilder.cs index 794a296e..30063305 100644 --- a/DnsClientX/ResolverProbeReportBuilder.cs +++ b/DnsClientX/ResolverProbeReportBuilder.cs @@ -23,7 +23,8 @@ public static ResolverProbeReport Build( policy ??= new ResolverProbePolicy(); ResolverProbeObservation[] observations = attempts - .Select(result => new ResolverProbeObservation { + .Select((result, index) => new ResolverProbeObservation { + SourceIndex = index, Target = result.Target, Resolver = result.Resolver, Transport = result.Transport, @@ -34,13 +35,14 @@ public static ResolverProbeReport Build( .ToArray(); ResolverProbeEvaluation evaluation = ResolverProbeScorer.Evaluate(observations, policy); - Dictionary attemptMap = attempts.ToDictionary(result => result.Target, StringComparer.OrdinalIgnoreCase); ResolverProbeReportResult[] results = evaluation.Results .Select(result => { - ResolverQueryAttemptResult attempt = attemptMap[result.Target]; + ResolverQueryAttemptResult attempt = attempts[result.SourceIndex]; return new ResolverProbeReportResult { Target = result.Target, + AnswerSignature = attempt.AnswerSignature, + Explanation = attempt.Response == null ? null : DnsQueryExplanation.Create(attempt.Response), Resolver = attempt.Resolver, RequestFormat = attempt.RequestFormat, Transport = attempt.Transport, @@ -138,6 +140,10 @@ public static ResolverProbeReport Build( ResolverScoreSnapshot snapshot = evaluation.CreateSnapshot(policy, new[] { name ?? string.Empty }, new[] { recordType }, timeoutMs); snapshot.Summary.RuntimeUnsupportedCandidateCount = summary.RuntimeUnsupportedCandidateCount; snapshot.Summary.RuntimeCapabilityWarnings = summary.RuntimeCapabilityWarnings; + snapshot.Answers = attempts.Select(a => new ResolverAnswerObservation { + Target = a.Target, Name = name ?? string.Empty, RecordType = recordType, + Status = a.Status, AnswerSignature = a.AnswerSignature + }).ToArray(); return new ResolverProbeReport { Results = results, diff --git a/DnsClientX/ResolverProbeReportResult.cs b/DnsClientX/ResolverProbeReportResult.cs index 0f110809..df561803 100644 --- a/DnsClientX/ResolverProbeReportResult.cs +++ b/DnsClientX/ResolverProbeReportResult.cs @@ -3,6 +3,12 @@ namespace DnsClientX { /// Describes one ranked probe candidate in a shared probe report. /// public sealed class ResolverProbeReportResult { + /// Gets or sets the TTL-independent answer signature used for factual comparison. + public string AnswerSignature { get; set; } = string.Empty; + + /// Gets or sets captured query diagnostics with explicit unavailable evidence boundaries. + public DnsQueryExplanation? Explanation { get; set; } + /// Gets or sets endpoint hostname resolution provenance for this candidate. public DnsServerResolutionInfo? ServerResolution { get; set; } diff --git a/DnsClientX/ResolverProbeScorer.cs b/DnsClientX/ResolverProbeScorer.cs index abff48d3..c534dfa4 100644 --- a/DnsClientX/ResolverProbeScorer.cs +++ b/DnsClientX/ResolverProbeScorer.cs @@ -54,6 +54,7 @@ public static ResolverProbeEvaluation Evaluate(IEnumerable new ResolverProbeEvaluationEntry { + SourceIndex = result.SourceIndex, Target = result.Target, Resolver = result.Resolver, Transport = result.Transport, @@ -95,6 +96,7 @@ public static ResolverProbeEvaluation Evaluate(IEnumerable } private static bool IsSameTarget(ResolverProbeObservation result, ResolverProbeObservation? other) { - return other != null && string.Equals(result.Target, other.Target, StringComparison.OrdinalIgnoreCase); + return ReferenceEquals(result, other); } } } diff --git a/DnsClientX/ResolverProbeWorkflow.cs b/DnsClientX/ResolverProbeWorkflow.cs index 50b55799..17377fe1 100644 --- a/DnsClientX/ResolverProbeWorkflow.cs +++ b/DnsClientX/ResolverProbeWorkflow.cs @@ -82,12 +82,14 @@ public static async Task RunAsync( explicitOverride, cancellationToken).ConfigureAwait(false); - return ResolverProbeReportBuilder.Build( + ResolverProbeReport report = ResolverProbeReportBuilder.Build( attempts, name, recordType, timeoutMs, policy); + ResolverScoreRecommendationBinding.Apply(report, runOptions, targets); + return report; } } } diff --git a/DnsClientX/ResolverQueryAttemptResult.cs b/DnsClientX/ResolverQueryAttemptResult.cs index fd0af99f..0248c294 100644 --- a/DnsClientX/ResolverQueryAttemptResult.cs +++ b/DnsClientX/ResolverQueryAttemptResult.cs @@ -5,6 +5,11 @@ namespace DnsClientX { /// Represents one executed resolver query attempt. /// public sealed class ResolverQueryAttemptResult { + /// Gets or sets the executed query name, empty when not recorded by an external producer. + public string Name { get; init; } = string.Empty; + + /// Gets or sets the executed query type, absent when not recorded by an external producer. + public DnsRecordType? RecordType { get; init; } /// /// Gets or sets the human-readable target name for the attempt. /// @@ -43,7 +48,14 @@ public sealed class ResolverQueryAttemptResult { internal ResolverQueryAttemptResult WithConnectionMode(ResolverQueryConnectionMode mode) => new() { Target = Target, RequestFormat = RequestFormat, Resolver = Resolver, Response = Response, - Elapsed = Elapsed, TransportElapsed = TransportElapsed, Error = Error, ConnectionMode = mode + Elapsed = Elapsed, TransportElapsed = TransportElapsed, Error = Error, ConnectionMode = mode, + Name = Name, RecordType = RecordType + }; + + internal ResolverQueryAttemptResult WithQuery(string name, DnsRecordType type) => new() { + Target = Target, RequestFormat = RequestFormat, Resolver = Resolver, Response = Response, + Elapsed = Elapsed, TransportElapsed = TransportElapsed, Error = Error, ConnectionMode = ConnectionMode, + Name = name, RecordType = type }; /// diff --git a/DnsClientX/ResolverQueryExecutor.cs b/DnsClientX/ResolverQueryExecutor.cs index 66a72101..8de83ca6 100644 --- a/DnsClientX/ResolverQueryExecutor.cs +++ b/DnsClientX/ResolverQueryExecutor.cs @@ -22,20 +22,20 @@ internal static async Task ExecuteAsync( } cancellationToken.ThrowIfCancellationRequested(); if (target.ExplicitEndpoint != null && explicitOverride != null) { - return await explicitOverride(target.ExplicitEndpoint, name, recordType, cancellationToken).ConfigureAwait(false); + return (await explicitOverride(target.ExplicitEndpoint, name, recordType, cancellationToken).ConfigureAwait(false)).WithQuery(name, recordType); } if (target.ExplicitEndpoint == null && target.BuiltInEndpoint.HasValue && builtInOverride != null) { - return await builtInOverride(target.BuiltInEndpoint.Value, name, recordType, cancellationToken).ConfigureAwait(false); + return (await builtInOverride(target.BuiltInEndpoint.Value, name, recordType, cancellationToken).ConfigureAwait(false)).WithQuery(name, recordType); } // Start before obtaining the client so first-use setup is included in both modes. var stopwatch = Stopwatch.StartNew(); ClientX client = retainedClient != null ? retainedClient() : CreateClient(target, options); try { - return await ExecuteWithClientAsync(client, target.DisplayName, name, recordType, + return (await ExecuteWithClientAsync(client, target.DisplayName, name, recordType, options.RequestDnsSec || target.ExplicitEndpoint?.DnsSecOk == true, options, stopwatch, retainedClient != null ? options.ConnectionMode : ResolverQueryConnectionMode.Cold, - cancellationToken).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false)).WithQuery(name, recordType); } finally { if (retainedClient == null) { await client.DisposeAsync().ConfigureAwait(false); diff --git a/DnsClientX/ResolverScoreComparer.cs b/DnsClientX/ResolverScoreComparer.cs new file mode 100644 index 00000000..75449b81 --- /dev/null +++ b/DnsClientX/ResolverScoreComparer.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DnsClientX { + /// Compares captured resolver evidence without treating consensus as authoritative correctness. + public static class ResolverScoreComparer { + /// Compares candidates and query observations. Health/performance conclusions require matching recorded scopes. + public static ResolverScoreComparison Compare(ResolverScoreSnapshot baseline, ResolverScoreSnapshot current, ResolverAnswerReference[]? authoritativeReferences = null) { + ValidateSnapshot(baseline); ValidateSnapshot(current); + ResolverAnswerReference[] references = authoritativeReferences ?? Array.Empty(); + foreach (ResolverAnswerReference reference in references) { + if (reference == null || string.IsNullOrWhiteSpace(reference.Name) || reference.Response == null || !reference.Response.IsAuthoritativeAnswer) throw new ArgumentException("Each authoritative reference needs a query name and an AA response.", nameof(authoritativeReferences)); + if (reference.Response.Questions?.Length > 0 && !reference.Response.Questions.Any(q => Canonical(q.Name) == Canonical(reference.Name) && q.Type == reference.RecordType)) throw new ArgumentException("A reference response question must match its name and type.", nameof(authoritativeReferences)); + } + if (references.GroupBy(r => (Canonical(r.Name), r.RecordType)).Any(g => g.Count() > 1)) throw new ArgumentException("Only one reference per query name/type is allowed.", nameof(authoritativeReferences)); + var referenceSignatures = references.ToDictionary(reference => (Canonical(reference.Name), reference.RecordType), reference => { + // Match the normal query's terminal RRset projection while preserving the caller's raw envelope. + DnsResponse projected = reference.Response.Clone(); + ClientX.ApplyAnswerProjection(projected, reference.Name, reference.RecordType, returnAllTypes: false); + return DnsResponseAnswerSignature.Build(projected); + }); + string[] reasons = GetScopeReasons(baseline, current).ToArray(); + bool comparable = reasons.Length == 0; + Dictionary before = baseline.Results.GroupBy(e => e.Target, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase); + Dictionary after = current.Results.GroupBy(e => e.Target, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase); + ResolverScoreChange[] candidates = before.Keys.Union(after.Keys, StringComparer.OrdinalIgnoreCase).OrderBy(k => k, StringComparer.OrdinalIgnoreCase).Select(key => { + before.TryGetValue(key, out ResolverScoreEntry[]? oldEntries); after.TryGetValue(key, out ResolverScoreEntry[]? newEntries); + if (oldEntries?.Length > 1 || newEntries?.Length > 1 || + oldEntries != null && HasUnavailableIdentity(baseline.Context, key) || newEntries != null && HasUnavailableIdentity(current.Context, key)) { + return new ResolverScoreChange { Target = key, Change = "Ambiguous" }; + } + ResolverScoreEntry? oldEntry = oldEntries?.Single(), newEntry = newEntries?.Single(); + return new ResolverScoreChange { + Target = key, Baseline = oldEntry, Current = newEntry, + Change = oldEntry == null ? "Added" : newEntry == null ? "Removed" : SameScore(oldEntry, newEntry) ? "Unchanged" : "Changed", + SuccessPercentDelta = !comparable || oldEntry == null || newEntry == null ? null : newEntry.SuccessPercent - oldEntry.SuccessPercent, + AverageMsDelta = !comparable || oldEntry == null || newEntry == null || oldEntry.SuccessCount == 0 || newEntry.SuccessCount == 0 ? null : newEntry.AverageMs - oldEntry.AverageMs + }; + }).ToArray(); + ResolverAnswerObservation[] oldAnswers = baseline.Answers ?? Array.Empty(); + ResolverAnswerObservation[] newAnswers = current.Answers ?? Array.Empty(); + var keys = oldAnswers.Concat(newAnswers).Select(a => (Target: a.Target.ToLowerInvariant(), Name: Canonical(a.Name), a.RecordType)).Distinct(); + ResolverAnswerChange[] answers = keys.OrderBy(k => k.Target, StringComparer.Ordinal).ThenBy(k => k.Name, StringComparer.Ordinal).ThenBy(k => k.RecordType).Select(key => { + ResolverAnswerObservation[] oldValues = oldAnswers.Where(a => a.Target.Equals(key.Target, StringComparison.OrdinalIgnoreCase) && Canonical(a.Name) == key.Name && a.RecordType == key.RecordType).ToArray(); + ResolverAnswerObservation[] newValues = newAnswers.Where(a => a.Target.Equals(key.Target, StringComparison.OrdinalIgnoreCase) && Canonical(a.Name) == key.Name && a.RecordType == key.RecordType).ToArray(); + ResolverAnswerReference? reference = references.FirstOrDefault(r => Canonical(r.Name) == key.Name && r.RecordType == key.RecordType); + int matches = reference == null ? 0 : newValues.Count(a => a.Status == reference.Response.Status.ToString() && a.AnswerSignature == referenceSignatures[(key.Name, key.RecordType)]); + return new ResolverAnswerChange { + Target = newValues.FirstOrDefault()?.Target ?? oldValues[0].Target, Name = key.Name, RecordType = key.RecordType, + Baseline = oldValues, Current = newValues, + Changed = oldValues.Length == 0 || newValues.Length == 0 ? null : !Variants(oldValues).SequenceEqual(Variants(newValues), StringComparer.Ordinal), + ReferenceSource = reference?.Source, + ReferenceComparison = reference == null || newValues.Length == 0 ? "Unavailable" : matches == newValues.Length ? "Matches" : matches == 0 ? "Differs" : "Mixed" + }; + }).ToArray(); + return new ResolverScoreComparison { ScopeComparable = comparable, IncomparableReasons = reasons, + BaselinePolicyPassed = baseline.Summary.PolicyPassed, CurrentPolicyPassed = current.Summary.PolicyPassed, + HealthRegression = comparable && baseline.Summary.PolicyPassed && !current.Summary.PolicyPassed, Candidates = candidates, Answers = answers }; + } + + private static string Canonical(string name) => (name ?? string.Empty).TrimEnd('.').ToLowerInvariant(); + private static IEnumerable Variants(IEnumerable observations) => observations.Select(a => a.Status.Length + ":" + a.Status + a.AnswerSignature).Distinct(StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal); + private static bool SameScore(ResolverScoreEntry a, ResolverScoreEntry b) => a.Resolver == b.Resolver && a.Transport == b.Transport && a.TotalQueries == b.TotalQueries && a.SuccessCount == b.SuccessCount && a.AverageMs == b.AverageMs && a.Rank == b.Rank && a.IsRecommended == b.IsRecommended; + private static void ValidateSnapshot(ResolverScoreSnapshot snapshot) { + if (!ResolverScoreSnapshot.TryValidateCompatibility(snapshot, out string? error)) throw new ArgumentException(error); + if (snapshot.Summary == null || snapshot.Summary.Domains == null || snapshot.Summary.RecordTypes == null || snapshot.Results == null || snapshot.Results.Any(r => r == null || string.IsNullOrWhiteSpace(r.Target))) throw new ArgumentException("Snapshot query catalog, summary, and named candidates are required."); + if (snapshot.Answers?.Any(a => a == null || string.IsNullOrWhiteSpace(a.Target) || string.IsNullOrWhiteSpace(a.Name) || a.Status == null || a.AnswerSignature == null) == true) throw new ArgumentException("Snapshot answer observations must be complete."); + } + private static IEnumerable GetScopeReasons(ResolverScoreSnapshot a, ResolverScoreSnapshot b) { + if (a.Results.GroupBy(r => r.Target, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1) || b.Results.GroupBy(r => r.Target, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1)) yield return "Candidate labels are ambiguous across multiple resolver observations."; + if (a.Answers == null || b.Answers == null || a.Answers.Length == 0 || b.Answers.Length == 0) yield return "One snapshot lacks correlated answer observations."; + if (a.Context == null || b.Context == null) yield return "One snapshot lacks host/runtime/execution context."; + else { + bool firstAvailable = TryGetExecutionKeys(a.Context, a.Results, out Dictionary firstKeys); + bool secondAvailable = TryGetExecutionKeys(b.Context, b.Results, out Dictionary secondKeys); + bool identitiesAvailable = firstAvailable && secondAvailable; + if (!identitiesAvailable) yield return "One snapshot lacks canonical candidate execution identities."; + else if (firstKeys.Count != secondKeys.Count || firstKeys.Any(pair => !secondKeys.TryGetValue(pair.Key, out string? key) || key != pair.Value)) yield return "Resolver execution identity differs."; + if (string.IsNullOrWhiteSpace(a.Context.Host) || string.IsNullOrWhiteSpace(b.Context.Host) || !string.Equals(a.Context.Host, b.Context.Host, StringComparison.OrdinalIgnoreCase)) yield return "Host differs or is unavailable."; + if (string.IsNullOrWhiteSpace(a.Context.Runtime) || string.IsNullOrWhiteSpace(b.Context.Runtime) || string.IsNullOrWhiteSpace(a.Context.OperatingSystem) || string.IsNullOrWhiteSpace(b.Context.OperatingSystem) || string.IsNullOrWhiteSpace(a.Context.Architecture) || string.IsNullOrWhiteSpace(b.Context.Architecture) || a.Context.Runtime != b.Context.Runtime || a.Context.OperatingSystem != b.Context.OperatingSystem || a.Context.Architecture != b.Context.Architecture) yield return "Runtime, OS, or architecture differs or is unavailable."; + if (a.Context.RequestDnsSec != b.Context.RequestDnsSec || a.Context.ValidateDnsSec != b.Context.ValidateDnsSec || a.Context.BootstrapEndpoint != b.Context.BootstrapEndpoint || a.Context.ForceDohWirePost != b.Context.ForceDohWirePost || a.Context.RequestNsid != b.Context.RequestNsid || a.Context.MaxRetries != b.Context.MaxRetries || a.Context.RetryDelayMs != b.Context.RetryDelayMs || a.Context.PortOverride != b.Context.PortOverride) yield return "DNSSEC, bootstrap, or query execution settings differ."; + } + ResolverScoreSummary x = a.Summary, y = b.Summary; + if (x.Domains.Length == 0 || y.Domains.Length == 0 || x.RecordTypes.Length == 0 || y.RecordTypes.Length == 0) yield return "Query catalog is unavailable."; + if (x.Mode != y.Mode || x.TimeoutMs != y.TimeoutMs || x.AttemptsPerCombination != y.AttemptsPerCombination || x.MaxConcurrency != y.MaxConcurrency || x.ConnectionMode != y.ConnectionMode) yield return "Workflow or measurement settings differ."; + if (!SetEqual(x.Domains.Select(Canonical), y.Domains.Select(Canonical)) || !SetEqual(x.RecordTypes.Select(t => t.ToString()), y.RecordTypes.Select(t => t.ToString()))) yield return "Query catalog differs."; + if (!SetEqual(a.Results.Select(r => r.Target.ToLowerInvariant()), b.Results.Select(r => r.Target.ToLowerInvariant()))) yield return "Resolver target catalog differs."; + else if (a.Results.Any(r => b.Results.Any(s => string.Equals(r.Target, s.Target, StringComparison.OrdinalIgnoreCase) && (r.Resolver != s.Resolver || r.Transport != s.Transport || r.ConnectionMode != s.ConnectionMode)))) yield return "Observed resolver, transport, or connection mode differs."; + if (x.RequiredMinSuccessCount != y.RequiredMinSuccessCount || x.RequiredMinSuccessPercent != y.RequiredMinSuccessPercent || x.RequiredMinSuccessfulCandidates != y.RequiredMinSuccessfulCandidates || x.RequiredMinConsensusPercent != y.RequiredMinConsensusPercent || x.RequireConsensus != y.RequireConsensus) yield return "Health policy thresholds differ."; + } + private static bool HasUnavailableIdentity(ResolverScoreRunContext? context, string target) => context?.TargetExecutionKeys != null && !context.TryGetExecutionKey(target, out _); + private static bool TryGetExecutionKeys(ResolverScoreRunContext context, ResolverScoreEntry[] results, out Dictionary keys) { + keys = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (context.TargetExecutionKeys == null) return false; + foreach (var entry in context.TargetExecutionKeys) { + if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value) || keys.ContainsKey(entry.Key)) return false; + keys.Add(entry.Key, entry.Value); + } + foreach (ResolverScoreEntry result in results) if (!keys.ContainsKey(result.Target)) return false; + return results.Length > 0 && keys.Count == results.Length; + } + private static bool SetEqual(IEnumerable a, IEnumerable b) => new HashSet(a, StringComparer.Ordinal).SetEquals(b); + } +} diff --git a/DnsClientX/ResolverScoreComparison.cs b/DnsClientX/ResolverScoreComparison.cs new file mode 100644 index 00000000..01b77e4f --- /dev/null +++ b/DnsClientX/ResolverScoreComparison.cs @@ -0,0 +1,57 @@ +using System; + +namespace DnsClientX { + /// Describes factual snapshot differences and whether their health/performance scopes are comparable. + public sealed class ResolverScoreComparison { + /// Gets or sets whether host, runtime, query, target, and policy scopes match. + public bool ScopeComparable { get; set; } + /// Gets or sets why health/performance regression conclusions are unavailable. + public string[] IncomparableReasons { get; set; } = Array.Empty(); + /// Gets or sets the baseline run policy result. + public bool BaselinePolicyPassed { get; set; } + /// Gets or sets the current run policy result. + public bool CurrentPolicyPassed { get; set; } + /// Gets or sets whether a comparable run changed from passing to failing. + public bool HealthRegression { get; set; } + /// Gets or sets candidate additions, removals, and score changes. + public ResolverScoreChange[] Candidates { get; set; } = Array.Empty(); + /// Gets or sets per-query answer/status changes and reference comparisons. + public ResolverAnswerChange[] Answers { get; set; } = Array.Empty(); + /// Gets or sets the interpretation boundary for answer differences. + public string AnswerVariationNote { get; set; } = "Answer changes are observations, not correctness failures. Location, cache age, and split DNS policy can produce legitimate differences. Caller-supplied authoritative references are not independently authenticated; resolver majority is not authoritative evidence."; + } + /// Describes one resolver's addition, removal, or changed score. + public sealed class ResolverScoreChange { + /// Gets or sets the target. + public string Target { get; set; } = string.Empty; + /// Gets or sets Added, Removed, Changed, Unchanged, or Ambiguous when the label cannot identify one execution. + public string Change { get; set; } = string.Empty; + /// Gets or sets the baseline score, unavailable for ambiguous labels. + public ResolverScoreEntry? Baseline { get; set; } + /// Gets or sets the current score, unavailable for ambiguous labels. + public ResolverScoreEntry? Current { get; set; } + /// Gets or sets the success percentage-point delta only for retained targets with comparable execution scopes. + public int? SuccessPercentDelta { get; set; } + /// Gets or sets the latency delta only when scopes match and both runs contain successes. + public double? AverageMsDelta { get; set; } + } + /// Describes one resolver/query's observed answer variants and optional authoritative reference. + public sealed class ResolverAnswerChange { + /// Gets or sets the target. + public string Target { get; set; } = string.Empty; + /// Gets or sets the query name. + public string Name { get; set; } = string.Empty; + /// Gets or sets the query type. + public DnsRecordType RecordType { get; set; } + /// Gets or sets whether observed variants changed, or null when one side lacks query evidence. + public bool? Changed { get; set; } + /// Gets or sets the baseline observations, absent for older snapshots without evidence. + public ResolverAnswerObservation[] Baseline { get; set; } = Array.Empty(); + /// Gets or sets the current observations. + public ResolverAnswerObservation[] Current { get; set; } = Array.Empty(); + /// Gets or sets Unavailable, Matches, Differs, or Mixed relative to the caller-supplied reference. + public string ReferenceComparison { get; set; } = "Unavailable"; + /// Gets or sets the caller's reference source description. + public string? ReferenceSource { get; set; } + } +} diff --git a/DnsClientX/ResolverScoreRecommendationBinding.cs b/DnsClientX/ResolverScoreRecommendationBinding.cs new file mode 100644 index 00000000..9c51eff7 --- /dev/null +++ b/DnsClientX/ResolverScoreRecommendationBinding.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace DnsClientX { + // Binds saved execution metadata and keeps report/evaluation/snapshot recommendation flags consistent. + internal static class ResolverScoreRecommendationBinding { + internal static void Apply(ResolverProbeReport report, ResolverQueryRunOptions options, IReadOnlyList targets) { + if (!Bind(report.Snapshot, options, targets)) return; + report.Summary.RecommendationAvailable = report.Evaluation.RecommendationAvailable = false; + report.Summary.RecommendedTarget = report.Evaluation.RecommendedTarget = "none"; + report.Summary.RecommendedResolver = report.Evaluation.RecommendedResolver = "none"; + report.Summary.RecommendedTransport = report.Evaluation.RecommendedTransport = "none"; + report.Summary.RecommendedAverageMs = report.Evaluation.RecommendedAverageMs = 0; + report.Summary.RecommendationSource = report.Evaluation.RecommendationSource = "none"; + report.Summary.RecommendationStatus = report.Evaluation.RecommendationStatus = report.Snapshot.Summary.RecommendationStatus; + report.Summary.RecommendationReason = report.Evaluation.RecommendationReason = report.Snapshot.Summary.RecommendationReason; + foreach (var row in report.Results) row.IsRecommended = false; + foreach (var row in report.Evaluation.Results) row.IsRecommended = false; + } + + internal static void Apply(ResolverBenchmarkReport report, ResolverQueryRunOptions options, IReadOnlyList targets) { + if (!Bind(report.Snapshot, options, targets)) return; + report.Summary.RecommendationAvailable = report.Evaluation.RecommendationAvailable = false; + report.Summary.RecommendedTarget = report.Evaluation.RecommendedTarget = "none"; + report.Summary.RecommendedResolver = report.Evaluation.RecommendedResolver = "none"; + report.Summary.RecommendedTransport = report.Evaluation.RecommendedTransport = "none"; + report.Summary.RecommendedAverageMs = report.Evaluation.RecommendedAverageMs = 0; + foreach (var row in report.Results) row.IsRecommended = false; + foreach (var row in report.Evaluation.Results) row.IsRecommended = false; + } + + private static bool Bind(ResolverScoreSnapshot snapshot, ResolverQueryRunOptions options, IReadOnlyList targets) { + snapshot.Context = ResolverScoreRunContext.Capture(options, targets); + ResolverScoreSummary summary = snapshot.Summary; + if (!summary.RecommendationAvailable || snapshot.Context.TryGetExecutionKey(summary.RecommendedTarget, out _)) return false; + summary.RecommendationAvailable = false; + summary.RecommendedTarget = summary.RecommendedResolver = summary.RecommendedTransport = "none"; + summary.RecommendedAverageMs = 0; + summary.RecommendationSource = "none"; + summary.RecommendationStatus = "unavailable"; + summary.RecommendationReason = "ambiguous candidate label identifies multiple resolver execution configurations"; + foreach (var row in snapshot.Results) row.IsRecommended = false; + return true; + } + } +} diff --git a/DnsClientX/ResolverScoreRunContext.cs b/DnsClientX/ResolverScoreRunContext.cs new file mode 100644 index 00000000..bd8c660f --- /dev/null +++ b/DnsClientX/ResolverScoreRunContext.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace DnsClientX { + /// Records environment and execution settings relevant to comparing resolver observations. + public sealed class ResolverScoreRunContext { + /// Gets or sets the host that performed the run. + public string Host { get; set; } = string.Empty; + /// Gets or sets the OS description. + public string OperatingSystem { get; set; } = string.Empty; + /// Gets or sets the executing framework description. + public string Runtime { get; set; } = string.Empty; + /// Gets or sets the process architecture. + public string Architecture { get; set; } = string.Empty; + /// Gets or sets whether DNSSEC records were requested. + public bool RequestDnsSec { get; set; } + /// Gets or sets whether local DNSSEC validation was requested. + public bool ValidateDnsSec { get; set; } + /// Gets or sets the canonical bootstrap execution key, when configured. + public string? BootstrapEndpoint { get; set; } + /// Gets or sets opaque canonical execution keys indexed by candidate label. Missing keys prevent scope comparison. + public Dictionary? TargetExecutionKeys { get; set; } + /// Gets or sets explicit endpoint definitions for uniquely identified labels. Missing or ambiguous definitions cannot be reused automatically. + public Dictionary? ExplicitEndpoints { get; set; } + /// Gets or sets whether DoH wire POST was forced. + public bool ForceDohWirePost { get; set; } + /// Gets or sets whether NSID was requested. + public bool RequestNsid { get; set; } + /// Gets or sets the retry count. + public int MaxRetries { get; set; } + /// Gets or sets the retry delay in milliseconds. + public int RetryDelayMs { get; set; } + /// Gets or sets the global port override. + public int? PortOverride { get; set; } + + internal static ResolverScoreRunContext Capture(ResolverQueryRunOptions options, IReadOnlyList targets) { + var keys = new Dictionary(StringComparer.OrdinalIgnoreCase); + var endpoints = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var group in targets.GroupBy(target => target.DisplayName, StringComparer.OrdinalIgnoreCase)) { + string[] identities = group.Select(target => target.ExplicitEndpoint != null + ? ResolverEndpointClientFactory.GetExecutionKey(target.ExplicitEndpoint) + : target.BuiltInEndpoint.HasValue ? "builtin:" + target.BuiltInEndpoint.Value : string.Empty) + .Distinct(StringComparer.Ordinal).ToArray(); + // A short display label can denote several distinct configurations. Preserve observations, + // but do not pretend that the label identifies an endpoint for comparison or reuse. + if (identities.Length != 1 || string.IsNullOrWhiteSpace(identities[0])) continue; + keys.Add(group.Key, identities[0]); + DnsResolverEndpoint? endpoint = group.First().ExplicitEndpoint; + if (endpoint != null) endpoints.Add(group.Key, endpoint); + } + return new ResolverScoreRunContext { + Host = Environment.MachineName, OperatingSystem = RuntimeInformation.OSDescription, + Runtime = RuntimeInformation.FrameworkDescription, Architecture = RuntimeInformation.ProcessArchitecture.ToString(), + RequestDnsSec = options.RequestDnsSec, ValidateDnsSec = options.ValidateDnsSec, + BootstrapEndpoint = options.BootstrapResolver == null ? null : ResolverEndpointClientFactory.GetExecutionKey(options.BootstrapResolver), + TargetExecutionKeys = keys, ExplicitEndpoints = endpoints, + ForceDohWirePost = options.ForceDohWirePost, RequestNsid = options.RequestNsid, + MaxRetries = options.MaxRetries, RetryDelayMs = options.RetryDelayMs, PortOverride = options.PortOverride + }; + } + + internal bool TryGetExecutionKey(string target, out string? key) { + key = null; + if (TargetExecutionKeys == null) return false; + var matches = TargetExecutionKeys.Where(pair => string.Equals(pair.Key, target, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length != 1 || string.IsNullOrWhiteSpace(matches[0].Value)) return false; + key = matches[0].Value; + return true; + } + + internal bool TryGetExplicitEndpoint(string target, out DnsResolverEndpoint? endpoint) { + endpoint = null; + if (ExplicitEndpoints == null) return false; + var matches = ExplicitEndpoints.Where(pair => string.Equals(pair.Key, target, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length != 1 || matches[0].Value == null) return false; + endpoint = matches[0].Value; + return true; + } + } +} diff --git a/DnsClientX/ResolverScoreSelector.cs b/DnsClientX/ResolverScoreSelector.cs index 435ec345..ad9c35c5 100644 --- a/DnsClientX/ResolverScoreSelector.cs +++ b/DnsClientX/ResolverScoreSelector.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace DnsClientX { /// @@ -29,7 +30,7 @@ public static bool TrySelectRecommended(ResolverScoreSnapshot? snapshot, out Res } if (!summary.RecommendationAvailable) { - error = $"No recommendation is available in the {summary.Mode} snapshot."; + error = $"No recommendation is available in the {summary.Mode} snapshot: {summary.RecommendationReason}."; return false; } @@ -38,8 +39,41 @@ public static bool TrySelectRecommended(ResolverScoreSnapshot? snapshot, out Res error = $"The {summary.Mode} snapshot does not contain a recommended target."; return false; } + if (snapshot.Results?.Count(row => row != null && string.Equals(row.Target, target, StringComparison.OrdinalIgnoreCase)) > 1) { + error = $"Recommended target '{target}' has ambiguous saved candidate rows."; + return false; + } + + // Older snapshots only contain labels. New snapshots must reuse their saved definitions, + // since labels intentionally omit TLS, family, timeout, EDNS, and request-format settings. + DnsResolverEndpoint? savedEndpoint = null; + if (snapshot.Context?.TargetExecutionKeys != null || snapshot.Context?.ExplicitEndpoints != null) { + if (!snapshot.Context.TryGetExecutionKey(target, out string? executionKey)) { + error = $"Recommended target '{target}' has an ambiguous or unavailable saved execution identity."; + return false; + } + if (snapshot.Context.TryGetExplicitEndpoint(target, out savedEndpoint)) { + try { + if (ResolverEndpointClientFactory.GetExecutionKey(savedEndpoint!) != executionKey) { + error = $"Recommended target '{target}' has inconsistent saved endpoint metadata."; + return false; + } + } catch (ArgumentException exception) { + error = $"Recommended target '{target}' has invalid saved endpoint metadata: {exception.Message}"; + return false; + } catch (InvalidOperationException exception) { + error = $"Recommended target '{target}' has invalid saved endpoint metadata: {exception.Message}"; + return false; + } + } else if (snapshot.Context.ExplicitEndpoints?.Keys.Any(key => string.Equals(key, target, StringComparison.OrdinalIgnoreCase)) == true || + !Enum.TryParse(target, ignoreCase: true, out DnsEndpoint identifiedEndpoint) || + !Enum.IsDefined(typeof(DnsEndpoint), identifiedEndpoint) || executionKey != "builtin:" + identifiedEndpoint) { + error = $"Recommended target '{target}' lacks an unambiguous saved endpoint definition."; + return false; + } + } - if (Enum.TryParse(target, ignoreCase: true, out DnsEndpoint builtInEndpoint) && + if (savedEndpoint == null && Enum.TryParse(target, ignoreCase: true, out DnsEndpoint builtInEndpoint) && Enum.IsDefined(typeof(DnsEndpoint), builtInEndpoint) && builtInEndpoint != DnsEndpoint.Custom) { selection = new ResolverSelectionResult { @@ -62,7 +96,9 @@ public static bool TrySelectRecommended(ResolverScoreSnapshot? snapshot, out Res return false; } - DnsResolverEndpoint[] endpoints = EndpointParser.TryParseMany(new[] { target }, out IReadOnlyList errors); + IReadOnlyList errors = Array.Empty(); + DnsResolverEndpoint[] endpoints = savedEndpoint != null ? new[] { savedEndpoint } + : EndpointParser.TryParseMany(new[] { target }, out errors); if (endpoints.Length == 1 && errors.Count == 0) { selection = new ResolverSelectionResult { Mode = summary.Mode, diff --git a/DnsClientX/ResolverScoreSnapshot.cs b/DnsClientX/ResolverScoreSnapshot.cs index beb22b7b..fa620a6d 100644 --- a/DnsClientX/ResolverScoreSnapshot.cs +++ b/DnsClientX/ResolverScoreSnapshot.cs @@ -35,6 +35,12 @@ public sealed class ResolverScoreSnapshot { /// public ResolverScoreEntry[] Results { get; set; } = Array.Empty(); + /// Gets or sets captured run context; absent in older snapshots. + public ResolverScoreRunContext? Context { get; set; } + + /// Gets or sets correlated answer observations; empty in older snapshots. + public ResolverAnswerObservation[] Answers { get; set; } = Array.Empty(); + /// /// Validates whether a resolver score snapshot can be read by this library. /// diff --git a/DnsClientX/ResolverSingleOperationWorkflow.cs b/DnsClientX/ResolverSingleOperationWorkflow.cs index 09e22181..845001be 100644 --- a/DnsClientX/ResolverSingleOperationWorkflow.cs +++ b/DnsClientX/ResolverSingleOperationWorkflow.cs @@ -14,7 +14,7 @@ public static class ResolverSingleOperationWorkflow { /// The shared target source to resolve. /// The DNS name to query. /// The DNS record type to query. - /// Whether DNSSEC data should be requested. + /// Whether to request DNSSEC data in addition to the selected endpoint's DNSSEC OK setting. /// Whether DNSSEC data should be validated. /// Optional shared client creation options. /// The cancellation token. @@ -37,7 +37,7 @@ public static async Task QueryAsync( /// The normalized execution target. /// The DNS name to query. /// The DNS record type to query. - /// Whether DNSSEC data should be requested. + /// Whether to request DNSSEC data in addition to the target endpoint's DNSSEC OK setting. /// Whether DNSSEC data should be validated. /// Optional shared client creation options. /// The cancellation token. @@ -56,7 +56,7 @@ public static async Task QueryAsync( } var stopwatch = Stopwatch.StartNew(); - DnsResponse response = await client.Resolve(name, recordType, requestDnsSec, validateDnsSec, cancellationToken: cancellationToken).ConfigureAwait(false); + DnsResponse response = await client.Resolve(name, recordType, requestDnsSec || target.ExplicitEndpoint?.DnsSecOk == true, validateDnsSec, cancellationToken: cancellationToken).ConfigureAwait(false); stopwatch.Stop(); return CreateResult(client, response, stopwatch.Elapsed); } diff --git a/Module/Build/Build-Module.ps1 b/Module/Build/Build-Module.ps1 index c9efd630..b558ba6b 100644 --- a/Module/Build/Build-Module.ps1 +++ b/Module/Build/Build-Module.ps1 @@ -5,7 +5,13 @@ param( [bool] $SignModule = $true, - [string] $ProjectBuildConfigPath = '..\Build\project.build.json', + [bool] $UpdateVersions = $true, + + [string] $ModuleVersion = '2.1.X', + + [switch] $SkipInstall, + + [string] $ProjectBuildConfigPath = '../Build/project.build.json', [string] $PowerShellGalleryApiKeyPath = 'C:\Support\Important\PowerShellGalleryAPI.txt', @@ -14,10 +20,10 @@ param( Import-Module PSPublishModule -Force -ErrorAction Stop -Build-Module -ModuleName 'DnsClientX' -RunMode $RunMode { +Build-Module -ModuleName 'DnsClientX' -RunMode $RunMode -SkipInstall:$SkipInstall { # Usual defaults as per standard module $Manifest = [ordered] @{ - ModuleVersion = '2.1.X' + ModuleVersion = $ModuleVersion CompatiblePSEditions = @('Desktop', 'Core') GUID = '77fa806c-70b7-48d9-8b88-942ed73f24ed' Author = 'Przemyslaw Klys' @@ -28,9 +34,10 @@ Build-Module -ModuleName 'DnsClientX' -RunMode $RunMode { IconUri = 'https://raw.githubusercontent.com/EvotecIT/DnsClientX/master/Assets/Icons/DnsClientX3_128x128.png' ProjectUri = 'https://github.com/EvotecIT/DnsClientX' PowerShellVersion = '5.1' - #FormatsToProcess = @('DnsClientX.Format.ps1xml') + FormatsToProcess = @('DnsClientX.Format.ps1xml') } New-ConfigurationManifest @Manifest + New-ConfigurationInformation -IncludeRoot '*.psm1', '*.psd1', '*.Libraries.ps1', '*.ps1xml', 'License*' $ConfigurationFormat = [ordered] @{ @@ -74,7 +81,7 @@ Build-Module -ModuleName 'DnsClientX' -RunMode $RunMode { New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'OnMergePSD1' -PSD1Style 'Minimal' # configuration for documentation, at the same time it enables documentation processing - New-ConfigurationDocumentation -Enable -PathReadme 'Docs\Readme.md' -Path 'Docs' -SyncExternalHelpToProjectRoot + New-ConfigurationDocumentation -Enable -PathReadme 'Docs/Readme.md' -Path 'Docs' -SyncExternalHelpToProjectRoot New-ConfigurationImportModule -ImportSelf -ImportRequiredModules @@ -84,7 +91,7 @@ Build-Module -ModuleName 'DnsClientX' -RunMode $RunMode { MergeModuleOnBuild = $true MergeFunctionsFromApprovedModules = $true CertificateThumbprint = '92E95FB58EFFA6A4A75E77A33CDD6BFE6DD30F1A' - NETProjectPath = '..\DnsClientX.PowerShell\DnsClientX.PowerShell.csproj' + NETProjectPath = '../DnsClientX.PowerShell/DnsClientX.PowerShell.csproj' ResolveBinaryConflicts = $true ResolveBinaryConflictsName = 'DnsClientX.PowerShell' NETProjectName = 'DnsClientX.PowerShell' @@ -94,18 +101,18 @@ Build-Module -ModuleName 'DnsClientX' -RunMode $RunMode { DotSourceLibraries = $true NETSearchClass = 'DnsClientX.PowerShell.CmdletResolveDnsQuery' NETBinaryModuleDocumentation = $true - SyncNETProjectVersion = $true + SyncNETProjectVersion = $UpdateVersions } New-ConfigurationBuild @newConfigurationBuildSplat - $GitHubTokenPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($GitHubApiKeyPath) - New-ConfigurationProjectBuild -Name 'DnsClientX' -ConfigPath $ProjectBuildConfigPath -Enabled -BuildBeforeModule -UseAsReleaseVersionSource -ProvideLocalNuGetFeed -PublishNuget -PublishGitHub -Options @{ GitHubAccessTokenFilePath = $GitHubTokenPath } - New-ConfigurationRelease -StageRoot '..\Artefacts\UploadReady' -VersionSource ProjectBuild -PrimaryProject 'DnsClientX' -SynchronizeModuleVersion -BuildOrder 'Packages', 'Module' -PublishOrder 'NuGet', 'PowerShellGallery', 'GitHub' + New-ConfigurationProjectBuild -Name 'DnsClientX' -ConfigPath $ProjectBuildConfigPath -Enabled -BuildBeforeModule -UseAsReleaseVersionSource:$UpdateVersions -ProvideLocalNuGetFeed -UpdateVersions:$UpdateVersions -SignAssemblies:$SignModule -SignPackages:$SignModule -PublishNuget:($RunMode -eq 'Publish') -PublishGitHub:($RunMode -eq 'Publish') -Options @{ GitHubAccessTokenFilePath = $GitHubApiKeyPath } + $ReleaseVersionSource = if ($UpdateVersions) { 'ProjectBuild' } else { 'Module' } + New-ConfigurationRelease -StageRoot '../Artefacts/UploadReady' -VersionSource $ReleaseVersionSource -PrimaryProject 'DnsClientX' -SynchronizeModuleVersion:$UpdateVersions -BuildOrder 'Packages', 'Module' -PublishOrder 'NuGet', 'PowerShellGallery', 'GitHub' - New-ConfigurationArtefact -Type Unpacked -Enable -Path '..\Artefacts\Unpacked' -ModulesPath 'Modules' - New-ConfigurationArtefact -Type Packed -Enable -Path '..\Artefacts\Packed' -IncludeTagName -ArtefactName 'DnsClientX-PowerShellModule..zip' -ID 'ToGitHub' + New-ConfigurationArtefact -Type Unpacked -Enable -Path '../Artefacts/Unpacked' -ModulesPath 'Modules' + New-ConfigurationArtefact -Type Packed -Enable -Path '../Artefacts/Packed' -IncludeTagName -ArtefactName 'DnsClientX-PowerShellModule..zip' -ID 'ToGitHub' New-ConfigurationPublish -Type PowerShellGallery -FilePath $PowerShellGalleryApiKeyPath -Enabled:$false - New-ConfigurationPublish -Type GitHub -FilePath $GitHubTokenPath -UserName 'EvotecIT' -RepositoryName 'DnsClientX' -Enabled:$false -ID 'ToGitHub' -GenerateReleaseNotes -OverwriteTagName 'DnsClientX-v{ModuleVersionWithPreRelease}' -} -ExitCode + New-ConfigurationPublish -Type GitHub -FilePath $GitHubApiKeyPath -UserName 'EvotecIT' -RepositoryName 'DnsClientX' -Enabled:$false -ID 'ToGitHub' -GenerateReleaseNotes -OverwriteTagName 'DnsClientX-v{ModuleVersionWithPreRelease}' +} -ExitCode -ErrorAction Stop diff --git a/Module/DnsClientX.Format.ps1xml b/Module/DnsClientX.Format.ps1xml new file mode 100644 index 00000000..81da14f3 --- /dev/null +++ b/Module/DnsClientX.Format.ps1xml @@ -0,0 +1,60 @@ + + + + + DnsRecords + DnsClientX.DnsAnswerMinimalDnsClientX.DnsAnswer + + 10 + 10Right + + NameType + TTLData + + + + DnsServicesDnsClientX.DnsService + + + 7Right + 9Right + 8Right + + ServiceNameTarget + PortPriorityWeight + + + + DnsSrvTargetsDnsClientX.DnsSrvRecord + + 7 + 98 + + TargetPort + PriorityWeightAddresses + + + + DnsProbeResultsDnsClientX.PowerShell.DnsProbeResult + + 18 + 149 + 913 + + TargetTransportStatus + ElapsedMs{0:N2}AnswerCountIsRecommended + + + + DnsBenchmarkResultsDnsClientX.PowerShell.DnsBenchmarkResult + + 18 + 1012 + 913 + + TargetTransportSuccessPercent + AverageMs{0:N2}TotalQueriesIsRecommended + + + + diff --git a/Module/DnsClientX.Types.ps1xml b/Module/DnsClientX.Types.ps1xml new file mode 100644 index 00000000..e53f1c73 --- /dev/null +++ b/Module/DnsClientX.Types.ps1xml @@ -0,0 +1,51 @@ + + + + DnsClientX.DnsResponse + PSStandardMembers + DefaultDisplayPropertySet + StatusQuestionsAnswersResponseSourceDnsSecValidationStatus + + + + + DnsClientX.TypedRecord + PSStandardMembers + DefaultDisplayPropertySet + NameTypeTTLClassValue + + + + + DnsClientX.PowerShell.DnsProbeSummary + PSStandardMembers + DefaultDisplayPropertySet + NameRecordTypeSuccessfulCandidatesCandidateCountConsensusPercentPolicyPassedPolicyReasonRecommendedTarget + + + + + DnsClientX.PowerShell.DnsBenchmarkSummary + PSStandardMembers + DefaultDisplayPropertySet + DomainsRecordTypesOverallSuccessPercentOverallQueryCountPolicyPassedPolicyReasonRecommendedTargetRecommendedAverageMs + + + + + DnsClientX.ResolverHealthReport + PSStandardMembers + DefaultDisplayPropertySet + ProfileNameGeneratedAtUtcPolicyPassedQueriesBenchmark + + + + + DnsClientX.DnsQueryExplanation + PSStandardMembers + DefaultDisplayPropertySet + ResolverTransportStatusResponseSourceResolverAuthenticDataDnsSecValidationStatusUnavailableEvidence + + + + diff --git a/Module/DnsClientX.psd1 b/Module/DnsClientX.psd1 index a654cb21..cc1f4915 100644 --- a/Module/DnsClientX.psd1 +++ b/Module/DnsClientX.psd1 @@ -1,7 +1,7 @@ -@{ +@{ AliasesToExport = @('Get-DnsZoneTransfer', 'Resolve-DnsQuery') Author = 'Przemyslaw Klys' - CmdletsToExport = @('Clear-DnsMultiResolverCache', 'ConvertFrom-DnsStamp', 'Find-DnsService', 'Get-DnsResolverSelection', 'Get-DnsService', 'Get-DnsTransportCapability', 'Get-DnsZone', 'Invoke-DnsUpdate', 'Resolve-Dns', 'Test-DnsBenchmark', 'Test-DnsProbe', 'Test-DnsResolverCatalog') + CmdletsToExport = @('Clear-DnsCache', 'Clear-DnsMultiResolverCache', 'Compare-DnsResolverSnapshot', 'ConvertFrom-DnsStamp', 'Find-DnsService', 'Get-DnsCache', 'Get-DnsQueryExplanation', 'Get-DnsResolverSelection', 'Get-DnsService', 'Get-DnsTransportCapability', 'Get-DnsZone', 'Invoke-DnsUpdate', 'Resolve-Dns', 'Test-DnsBenchmark', 'Test-DnsHealth', 'Test-DnsProbe', 'Test-DnsResolverCatalog') CompanyName = 'Evotec' CompatiblePSEditions = @('Desktop', 'Core') Copyright = '(c) 2011 - 2026 Przemyslaw Klys @ Evotec. All rights reserved.' @@ -22,5 +22,6 @@ } RootModule = 'DnsClientX.psm1' RequiredModules = @() - ScriptsToProcess = @() + TypesToProcess = @('DnsClientX.Types.ps1xml') + FormatsToProcess = @('DnsClientX.Format.ps1xml') } diff --git a/Module/Docs/Clear-DnsCache.md b/Module/Docs/Clear-DnsCache.md new file mode 100644 index 00000000..363c522b --- /dev/null +++ b/Module/Docs/Clear-DnsCache.md @@ -0,0 +1,99 @@ +--- +external help file: DnsClientX-help.xml +Module Name: DnsClientX +online version: https://github.com/EvotecIT/DnsClientX/blob/master/README.md +schema: 2.0.0 +--- +# Clear-DnsCache +## SYNOPSIS +Clears process-wide cached DNS responses. + +Clears all response entries, or entries for one queried name and optional type across all resolver policies. Returns the number removed. This affects every cache-enabled DnsClientX client in this process; active queries may repopulate the cache. FastestWins endpoint choices are managed by Clear-DnsMultiResolverCache. + +## SYNTAX +### All (Default) +```powershell +Clear-DnsCache [-WhatIf] [-Confirm] [] +``` + +### Name +```powershell +Clear-DnsCache [-Name] [[-Type] ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Clears process-wide cached DNS responses. + +Clears all response entries, or entries for one queried name and optional type across all resolver policies. Returns the number removed. This affects every cache-enabled DnsClientX client in this process; active queries may repopulate the cache. FastestWins endpoint choices are managed by Clear-DnsMultiResolverCache. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Clear-DnsCache +``` + +Clear all cached responses + +### EXAMPLE 2 +```powershell +Clear-DnsCache -Name example.com -Type A +``` + +Remove cached A responses for one name + +### EXAMPLE 3 +```powershell +Clear-DnsCache -Name example.com -WhatIf +``` + +Preview selective invalidation + +## PARAMETERS + +### -Name +Original queried name to invalidate, including the expanded system-search name when applicable. Names normalize case, Unicode and the trailing root dot. Alias targets do not invalidate a different original query. + +```yaml +Type: String +Parameter Sets: Name +Aliases: Domain, DnsName +Possible values: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue, ByPropertyName) +Accept wildcard characters: False +``` + +### -Type +Only remove this query type. Omit to remove every cached type for Name. + +```yaml +Type: DnsRecordType +Parameter Sets: Name +Aliases: None +Possible values: Reserved, A, NS, MD, MF, CNAME, SOA, MB, MG, MR, NULL, WKS, PTR, HINFO, MINFO, MX, TXT, RP, AFSDB, X25, ISDN, RT, NSAP, NSAP_PTR, SIG, PX, AAAA, LOC, NXT, SRV, ATMA, NAPTR, KX, CERT, A6, DNAME, SINK, OPT, APL, DS, SSHFP, IPSECKEY, RRSIG, NSEC, DNSKEY, DHCID, NSEC3, NSEC3PARAM, TLSA, SMIMEA, HIP, NINFO, RKEY, TALINK, CDS, CDNSKEY, OPENPGPKEY, CSYNC, ZONEMD, SVCB, HTTPS, SPF, LP, NXNAME, TKEY, TSIG, IXFR, AXFR, MAILB, MAILA, ANY, URI, CAA, AVC, DOA, AMTRELAY, RESINFO, TA, DLV + +Required: False +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +- `System.String` + +## OUTPUTS + +- `System.Int32` + +## RELATED LINKS + +- None diff --git a/Module/Docs/Compare-DnsResolverSnapshot.md b/Module/Docs/Compare-DnsResolverSnapshot.md new file mode 100644 index 00000000..31f62316 --- /dev/null +++ b/Module/Docs/Compare-DnsResolverSnapshot.md @@ -0,0 +1,133 @@ +--- +external help file: DnsClientX-help.xml +Module Name: DnsClientX +online version: https://github.com/EvotecIT/DnsClientX/blob/master/README.md +schema: 2.0.0 +--- +# Compare-DnsResolverSnapshot +## SYNOPSIS +Compares resolver health and answers between saved DNS snapshots. + +Reports candidate and answer changes, health regressions, and incompatible query scopes. Optional authoritative responses provide comparison context; a majority answer is not proof of authoritative correctness. + +## SYNTAX +### Path (Default) +```powershell +Compare-DnsResolverSnapshot [-BaselinePath] [-CurrentPath] [-AuthoritativeReference ] [] +``` + +### Snapshot +```powershell +Compare-DnsResolverSnapshot [-Baseline] [-Current] [-AuthoritativeReference ] [] +``` + +## DESCRIPTION +Compares resolver health and answers between saved DNS snapshots. + +Reports candidate and answer changes, health regressions, and incompatible query scopes. Optional authoritative responses provide comparison context; a majority answer is not proof of authoritative correctness. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Compare-DnsResolverSnapshot -BaselinePath './before.json' -CurrentPath './after.json' +``` + +Compare two saved probe or benchmark snapshots + +## PARAMETERS + +### -AuthoritativeReference +Caller-supplied authoritative responses for name/type comparison context. These must assert the AA flag; response origin remains the caller's responsibility. + +```yaml +Type: ResolverAnswerReference[] +Parameter Sets: Path, Snapshot +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Baseline +Baseline typed resolver score snapshot. + +```yaml +Type: ResolverScoreSnapshot +Parameter Sets: Snapshot +Aliases: None +Possible values: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BaselinePath +Baseline resolver score snapshot JSON path. + +```yaml +Type: String +Parameter Sets: Path +Aliases: None +Possible values: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Current +Current typed resolver score snapshot, supplied directly or through the pipeline. + +```yaml +Type: ResolverScoreSnapshot +Parameter Sets: Snapshot +Aliases: None +Possible values: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -CurrentPath +Current resolver score snapshot JSON path. + +```yaml +Type: String +Parameter Sets: Path +Aliases: None +Possible values: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +- `DnsClientX.ResolverScoreSnapshot` + +## OUTPUTS + +- `DnsClientX.ResolverScoreComparison` + +## RELATED LINKS + +- None diff --git a/Module/Docs/Find-DnsService.md b/Module/Docs/Find-DnsService.md index 72b84c92..6f5d3673 100644 --- a/Module/Docs/Find-DnsService.md +++ b/Module/Docs/Find-DnsService.md @@ -8,18 +8,28 @@ schema: 2.0.0 ## SYNOPSIS Discovers services advertised via DNS Service Discovery. -Wraps DiscoverServices to return services under a domain. +Streams individual service instances under a domain, browses one service type, or lists advertised service types. An empty discovery produces no pipeline objects; use an array expression to collect zero or more results. ## SYNTAX -### __AllParameterSets +### Discover (Default) ```powershell -Find-DnsService [-Domain] [] +Find-DnsService [-Domain] [-DnsProvider ] [-ResolverEndpoint ] [-ResolverSelectionPath ] [-TimeOut ] [-BootstrapResolver ] [] +``` + +### Types +```powershell +Find-DnsService [-Domain] -ServiceTypes [-DnsProvider ] [-ResolverEndpoint ] [-ResolverSelectionPath ] [-TimeOut ] [-BootstrapResolver ] [] +``` + +### Browse +```powershell +Find-DnsService [-Domain] -Service [-Protocol ] [-DnsProvider ] [-ResolverEndpoint ] [-ResolverSelectionPath ] [-TimeOut ] [-BootstrapResolver ] [] ``` ## DESCRIPTION Discovers services advertised via DNS Service Discovery. -Wraps DiscoverServices to return services under a domain. +Streams individual service instances under a domain, browses one service type, or lists advertised service types. An empty discovery produces no pipeline objects; use an array expression to collect zero or more results. ## EXAMPLES @@ -37,20 +47,162 @@ Find-DnsService -Domain example.com | Where-Object {$_.ServiceType -like '_http. Filter for a specific service type +### EXAMPLE 3 +```powershell +Find-DnsService -Domain example.com -ServiceTypes -ResolverEndpoint 'tcp@192.0.2.53:53' +``` + +List advertised service types through a specific resolver + +### EXAMPLE 4 +```powershell +Find-DnsService -Domain example.com -Service http -Protocol tcp +``` + +Browse only HTTP instances + ## PARAMETERS +### -BootstrapResolver +IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + +```yaml +Type: String +Parameter Sets: Discover, Types, Browse +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DnsProvider +Built-in resolver used for discovery. Defaults to the system resolver. + +```yaml +Type: DnsEndpoint +Parameter Sets: Discover, Types, Browse +Aliases: None +Possible values: System, SystemTcp, Cloudflare, CloudflareSecurity, CloudflareFamily, CloudflareWireFormat, CloudflareWireFormatPost, CloudflareJsonPost, Google, GoogleWireFormat, GoogleWireFormatPost, GoogleJsonPost, Quad9, Quad9ECS, Quad9Unsecure, OpenDNS, OpenDNSFamily, CloudflareQuic, Quad9Http3, Quad9Quic, GoogleQuic, AdGuard, AdGuardFamily, AdGuardNonFiltering, NextDNS, DnsCryptCloudflare, DnsCryptQuad9, DnsCryptRelay, RootServer, CloudflareOdoh, Custom + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Domain Domain name to search for advertised services. ```yaml Type: String -Parameter Sets: __AllParameterSets -Aliases: None +Parameter Sets: Discover, Types, Browse +Aliases: Name, DnsName Possible values: Required: True Position: 0 Default value: None +Accept pipeline input: True (ByValue, ByPropertyName) +Accept wildcard characters: False +``` + +### -Protocol +Service transport label, such as tcp or udp. + +```yaml +Type: String +Parameter Sets: Browse +Aliases: None +Possible values: tcp, udp, _tcp, _udp + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ResolverEndpoint +One explicit resolver, such as udp@192.0.2.53:53, tcp@192.0.2.53:53, mdns@224.0.0.251:5353, or a DoH URL. + +```yaml +Type: String +Parameter Sets: Discover, Types, Browse +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ResolverSelectionPath +Saved resolver score snapshot whose recommended resolver is reused for discovery. + +```yaml +Type: String +Parameter Sets: Discover, Types, Browse +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Service +Service label to browse, such as http or _http. + +```yaml +Type: String +Parameter Sets: Browse +Aliases: None +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ServiceTypes +List fully qualified service types from the DNS-SD meta-query. + +```yaml +Type: SwitchParameter +Parameter Sets: Types +Aliases: ListServiceTypes +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TimeOut +Per-query timeout in milliseconds. + +```yaml +Type: Int32 +Parameter Sets: Discover, Types, Browse +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -60,11 +212,12 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## INPUTS -- `None` +- `System.String` ## OUTPUTS -- `None` +- `DnsClientX.DnsService` +- `System.String` ## RELATED LINKS diff --git a/Module/Docs/Get-DnsCache.md b/Module/Docs/Get-DnsCache.md new file mode 100644 index 00000000..a24eac1f --- /dev/null +++ b/Module/Docs/Get-DnsCache.md @@ -0,0 +1,48 @@ +--- +external help file: DnsClientX-help.xml +Module Name: DnsClientX +online version: https://github.com/EvotecIT/DnsClientX/blob/master/README.md +schema: 2.0.0 +--- +# Get-DnsCache +## SYNOPSIS +Gets process-wide DNS response cache statistics. + +Returns capacity, current entries and lifetime lookup counters shared by all cache-enabled DnsClientX clients in this PowerShell process. Clearing entries does not reset these counters. + +## SYNTAX +### __AllParameterSets +```powershell +Get-DnsCache [] +``` + +## DESCRIPTION +Gets process-wide DNS response cache statistics. + +Returns capacity, current entries and lifetime lookup counters shared by all cache-enabled DnsClientX clients in this PowerShell process. Clearing entries does not reset these counters. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Get-DnsCache +``` + +Inspect the response cache used by Resolve-Dns + +## PARAMETERS + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +- `None` + +## OUTPUTS + +- `DnsClientX.DnsResponseCacheStatistics` + +## RELATED LINKS + +- None diff --git a/Module/Docs/Get-DnsQueryExplanation.md b/Module/Docs/Get-DnsQueryExplanation.md new file mode 100644 index 00000000..bb58778a --- /dev/null +++ b/Module/Docs/Get-DnsQueryExplanation.md @@ -0,0 +1,80 @@ +--- +external help file: DnsClientX-help.xml +Module Name: DnsClientX +online version: https://github.com/EvotecIT/DnsClientX/blob/master/README.md +schema: 2.0.0 +--- +# Get-DnsQueryExplanation +## SYNOPSIS +Explains captured evidence from a completed DNS response. + +Projects resolver, transport, cache, alias, authority and local DNSSEC evidence without additional DNS queries. UnavailableEvidence lists facts that the response cannot establish, including a complete delegation or DNSSEC trust-chain traversal. + +## SYNTAX +### __AllParameterSets +```powershell +Get-DnsQueryExplanation [-InputObject] [-AuditTrail ] [] +``` + +## DESCRIPTION +Explains captured evidence from a completed DNS response. + +Projects resolver, transport, cache, alias, authority and local DNSSEC evidence without additional DNS queries. UnavailableEvidence lists facts that the response cannot establish, including a complete delegation or DNSSEC trust-chain traversal. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Resolve-Dns example.com -FullResponse | Get-DnsQueryExplanation +``` + +Explain a response returned by Resolve-Dns + +## PARAMETERS + +### -AuditTrail +Optional captured audit entries for the supplied response. Omit this parameter when individual attempt evidence was not captured. + +```yaml +Type: AuditEntry[] +Parameter Sets: __AllParameterSets +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -InputObject +Completed DNS response, supplied directly or through the pipeline. + +```yaml +Type: DnsResponse +Parameter Sets: __AllParameterSets +Aliases: Response +Possible values: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +- `DnsClientX.DnsResponse` + +## OUTPUTS + +- `DnsClientX.DnsQueryExplanation` + +## RELATED LINKS + +- None diff --git a/Module/Docs/Get-DnsService.md b/Module/Docs/Get-DnsService.md index 2bea0b39..39974ff6 100644 --- a/Module/Docs/Get-DnsService.md +++ b/Module/Docs/Get-DnsService.md @@ -8,18 +8,28 @@ schema: 2.0.0 ## SYNOPSIS Retrieves services advertised via DNS Service Discovery. -Queries the _services._dns-sd._udp tree for the specified domain and returns SRV/TXT data describing each advertised service. +Streams individual DNS-SD instances under a domain, browses one service type, or resolves SRV targets in connection-attempt order. Empty discovery produces no pipeline objects; use an array expression to collect zero or more results. ## SYNTAX -### __AllParameterSets +### Discover (Default) ```powershell -Get-DnsService [-Domain] [] +Get-DnsService [-Domain] [-DnsProvider ] [-ResolverEndpoint ] [-ResolverSelectionPath ] [-TimeOut ] [-BootstrapResolver ] [] +``` + +### Browse +```powershell +Get-DnsService [-Domain] -Service [-Protocol ] [-DnsProvider ] [-ResolverEndpoint ] [-ResolverSelectionPath ] [-TimeOut ] [-BootstrapResolver ] [] +``` + +### Resolve +```powershell +Get-DnsService [-Domain] -Service -Resolve [-Protocol ] [-ResolveHosts] [-DnsProvider ] [-ResolverEndpoint ] [-ResolverSelectionPath ] [-TimeOut ] [-BootstrapResolver ] [] ``` ## DESCRIPTION Retrieves services advertised via DNS Service Discovery. -Queries the _services._dns-sd._udp tree for the specified domain and returns SRV/TXT data describing each advertised service. +Streams individual DNS-SD instances under a domain, browses one service type, or resolves SRV targets in connection-attempt order. Empty discovery produces no pipeline objects; use an array expression to collect zero or more results. ## EXAMPLES @@ -37,20 +47,178 @@ Get-DnsService -Domain example.com | Select-Object ServiceName,Target,Port Pipe results to select useful fields +### EXAMPLE 3 +```powershell +Get-DnsService -Domain example.com -Service http -ResolverEndpoint 'https://dns.google/dns-query' +``` + +Browse HTTP service instances through an explicit resolver + +### EXAMPLE 4 +```powershell +Get-DnsService -Domain example.com -Service ldap -Resolve -ResolveHosts +``` + +Resolve LDAP targets and addresses in connection-attempt order + ## PARAMETERS +### -BootstrapResolver +IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + +```yaml +Type: String +Parameter Sets: Discover, Browse, Resolve +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DnsProvider +Built-in resolver used for discovery. Defaults to the system resolver. + +```yaml +Type: DnsEndpoint +Parameter Sets: Discover, Browse, Resolve +Aliases: None +Possible values: System, SystemTcp, Cloudflare, CloudflareSecurity, CloudflareFamily, CloudflareWireFormat, CloudflareWireFormatPost, CloudflareJsonPost, Google, GoogleWireFormat, GoogleWireFormatPost, GoogleJsonPost, Quad9, Quad9ECS, Quad9Unsecure, OpenDNS, OpenDNSFamily, CloudflareQuic, Quad9Http3, Quad9Quic, GoogleQuic, AdGuard, AdGuardFamily, AdGuardNonFiltering, NextDNS, DnsCryptCloudflare, DnsCryptQuad9, DnsCryptRelay, RootServer, CloudflareOdoh, Custom + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Domain Domain name to search for advertised services. ```yaml Type: String -Parameter Sets: __AllParameterSets -Aliases: None +Parameter Sets: Discover, Browse, Resolve +Aliases: Name, DnsName Possible values: Required: True Position: 0 Default value: None +Accept pipeline input: True (ByValue, ByPropertyName) +Accept wildcard characters: False +``` + +### -Protocol +Service transport label, such as tcp or udp. + +```yaml +Type: String +Parameter Sets: Browse, Resolve +Aliases: None +Possible values: tcp, udp, _tcp, _udp + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Resolve +Resolve SRV targets in RFC 2782 connection-attempt order instead of browsing DNS-SD instances. + +```yaml +Type: SwitchParameter +Parameter Sets: Resolve +Aliases: None +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ResolveHosts +With Resolve, include A and AAAA addresses for each target host. + +```yaml +Type: SwitchParameter +Parameter Sets: Resolve +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ResolverEndpoint +One explicit resolver, such as udp@192.0.2.53:53, tcp@192.0.2.53:53, mdns@224.0.0.251:5353, or a DoH URL. + +```yaml +Type: String +Parameter Sets: Discover, Browse, Resolve +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ResolverSelectionPath +Saved resolver score snapshot whose recommended resolver is reused for discovery. + +```yaml +Type: String +Parameter Sets: Discover, Browse, Resolve +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Service +Service label to browse or resolve, such as http or _http. + +```yaml +Type: String +Parameter Sets: Browse, Resolve +Aliases: None +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TimeOut +Per-query timeout in milliseconds. + +```yaml +Type: Int32 +Parameter Sets: Discover, Browse, Resolve +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -60,11 +228,12 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## INPUTS -- `None` +- `System.String` ## OUTPUTS -- `None` +- `DnsClientX.DnsService` +- `DnsClientX.DnsSrvRecord` ## RELATED LINKS diff --git a/Module/Docs/Invoke-DnsUpdate.md b/Module/Docs/Invoke-DnsUpdate.md index 3b1a18de..a8cdbf0d 100644 --- a/Module/Docs/Invoke-DnsUpdate.md +++ b/Module/Docs/Invoke-DnsUpdate.md @@ -10,10 +10,12 @@ Sends DNS UPDATE messages to a server. Adds or removes records in a zone using RFC 2136 over TCP. +Use WhatIf to preview an update without contacting the server, or Confirm to approve each change. + ## SYNTAX ### __AllParameterSets ```powershell -Invoke-DnsUpdate [-Zone] [-Server] [-Name] [-Type] [[-Data] ] [-Port ] [-Ttl ] [-TsigKey ] [-Delete] [-DeleteValue] [] +Invoke-DnsUpdate [-Zone] [-Server] [-Name] [-Type] [[-Data] ] [-Port ] [-Ttl ] [-TsigKey ] [-Delete] [-DeleteValue] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -21,6 +23,8 @@ Sends DNS UPDATE messages to a server. Adds or removes records in a zone using RFC 2136 over TCP. +Use WhatIf to preview an update without contacting the server, or Confirm to approve each change. + ## EXAMPLES ### EXAMPLE 1 diff --git a/Module/Docs/Readme.md b/Module/Docs/Readme.md index f6f94c32..fce295aa 100644 --- a/Module/Docs/Readme.md +++ b/Module/Docs/Readme.md @@ -10,11 +10,21 @@ Locale: en-US DnsClientX is PowerShell module that allows you to query DNS servers for information. It supports DNS over UDP, TCP and DNS over HTTPS (DoH) and DNS over TLS (DoT). It supports multiple types of DNS queries and can be used to query public DNS servers, private DNS servers and has built-in DNS Providers. ## DnsClientX Cmdlets +### [Clear-DnsCache](Clear-DnsCache.md) +Clears process-wide cached DNS responses. + +Clears all response entries, or entries for one queried name and optional type across all resolver policies. Returns the number removed. This affects every cache-enabled DnsClientX client in this process; active queries may repopulate the cache. FastestWins endpoint choices are managed by Clear-DnsMultiResolverCache. + ### [Clear-DnsMultiResolverCache](Clear-DnsMultiResolverCache.md) Clears the multi-resolver fastest-endpoint cache used by the FastestWins strategy. Clears the in-memory cache used to remember the fastest endpoint for a given set of endpoints. Use this when you change network conditions or want to force re-probing. Does not affect TTL-based response caching, which expires automatically. +### [Compare-DnsResolverSnapshot](Compare-DnsResolverSnapshot.md) +Compares resolver health and answers between saved DNS snapshots. + +Reports candidate and answer changes, health regressions, and incompatible query scopes. Optional authoritative responses provide comparison context; a majority answer is not proof of authoritative correctness. + ### [ConvertFrom-DnsStamp](ConvertFrom-DnsStamp.md) Parses a DNS stamp into a resolver endpoint description. @@ -23,7 +33,17 @@ Converts a supported sdns:// DNS stamp into the same endpoint model used by DnsC ### [Find-DnsService](Find-DnsService.md) Discovers services advertised via DNS Service Discovery. -Wraps DiscoverServices to return services under a domain. +Streams individual service instances under a domain, browses one service type, or lists advertised service types. An empty discovery produces no pipeline objects; use an array expression to collect zero or more results. + +### [Get-DnsCache](Get-DnsCache.md) +Gets process-wide DNS response cache statistics. + +Returns capacity, current entries and lifetime lookup counters shared by all cache-enabled DnsClientX clients in this PowerShell process. Clearing entries does not reset these counters. + +### [Get-DnsQueryExplanation](Get-DnsQueryExplanation.md) +Explains captured evidence from a completed DNS response. + +Projects resolver, transport, cache, alias, authority and local DNSSEC evidence without additional DNS queries. UnavailableEvidence lists facts that the response cannot establish, including a complete delegation or DNSSEC trust-chain traversal. ### [Get-DnsResolverSelection](Get-DnsResolverSelection.md) Loads a saved resolver score snapshot and returns the recommended resolver selection. @@ -33,7 +53,7 @@ Reads a persisted resolver score snapshot, applies the shared recommendation log ### [Get-DnsService](Get-DnsService.md) Retrieves services advertised via DNS Service Discovery. -Queries the _services._dns-sd._udp tree for the specified domain and returns SRV/TXT data describing each advertised service. +Streams individual DNS-SD instances under a domain, browses one service type, or resolves SRV targets in connection-attempt order. Empty discovery produces no pipeline objects; use an array expression to collect zero or more results. ### [Get-DnsTransportCapability](Get-DnsTransportCapability.md) Returns runtime transport support information for the DnsClientX core transport surface. @@ -50,6 +70,8 @@ Sends DNS UPDATE messages to a server. Adds or removes records in a zone using RFC 2136 over TCP. +Use WhatIf to preview an update without contacting the server, or Confirm to approve each change. + ### [Resolve-Dns](Resolve-Dns.md) Resolves DNS records (A, AAAA, MX, TXT, …) over UDP, TCP, DoT, DoH, QUIC, or multicast with optional multi-resolver strategies. @@ -57,11 +79,18 @@ Supports single-provider queries, explicit servers with transport selection, mul Specify either Name or Pattern. When no resolver source is specified, the library's default resolver is used. Timeout, retry, response, typed-record, and DNSSEC options apply to every resolver source. +Name accepts pipeline strings or objects with Name, Domain, or DnsName properties. By default each answer is an individual pipeline row. Use FullResponse for complete response objects, TypedRecords for typed values, or TypedRecordMetadata to keep typed values and record metadata together. + ### [Test-DnsBenchmark](Test-DnsBenchmark.md) Benchmarks one or more DNS providers or explicit resolver endpoints across repeated queries. Returns one object per candidate with latency, success rate, answer consistency, rank, and recommendation metadata. PowerShell consumers can sort, filter, format, or export the results themselves. +### [Test-DnsHealth](Test-DnsHealth.md) +Runs a repeatable DNS resolver health profile. + +Runs the profile's DNS query catalog and policy against its configured resolvers. IncludeBenchmark measures the same catalog. Returns the complete report; resolver agreement alone does not establish authoritative correctness. + ### [Test-DnsProbe](Test-DnsProbe.md) Probes one built-in resolver profile or a custom resolver set and reports health, consensus, and recommendation data. diff --git a/Module/Docs/Resolve-Dns.md b/Module/Docs/Resolve-Dns.md index e1e84dd7..cd070099 100644 --- a/Module/Docs/Resolve-Dns.md +++ b/Module/Docs/Resolve-Dns.md @@ -12,15 +12,17 @@ Supports single-provider queries, explicit servers with transport selection, mul Specify either Name or Pattern. When no resolver source is specified, the library's default resolver is used. Timeout, retry, response, typed-record, and DNSSEC options apply to every resolver source. +Name accepts pipeline strings or objects with Name, Domain, or DnsName properties. By default each answer is an individual pipeline row. Use FullResponse for complete response objects, TypedRecords for typed values, or TypedRecordMetadata to keep typed values and record metadata together. + ## SYNTAX ### Name (Default) ```powershell -Resolve-Dns [-Name] [[-Type] ] [-DnsProvider ] [-DnsSelectionStrategy ] [-Server ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-ResolverDnsProvider ] [-ResolverSelectionPath ] [-ResolverStrategy ] [-MaxParallelism ] [-RespectEndpointTimeout] [-FastestCacheMinutes ] [-PerEndpointMaxInFlight ] [-ResponseCache] [-MaxCacheTtlSeconds ] [-DnsSecVerifierPath ] [-EdnsBufferSize ] [-ClientSubnet ] [-BootstrapResolver ] [-RequestFormat ] [-Port ] [-UserAgent ] [-HttpVersion ] [-IgnoreCertificateErrors] [-UseTcpFallback ] [-ProxyUri ] [-MaxConnectionsPerServer ] [-MaxConcurrency ] [-AllServers] [-Fallback] [-RandomServer] [-FullResponse] [-TypedRecords] [-ParseTypedTxtRecords] [-TimeOut ] [-RetryCount ] [-RetryDelayMs ] [-RequestDnsSec] [-ValidateDnsSec] [-EnableEdns] [-CheckingDisabled] [-RequestNsid] [] +Resolve-Dns [-Name] [[-Type] ] [-DnsProvider ] [-DnsSelectionStrategy ] [-Server ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-ResolverDnsProvider ] [-ResolverSelectionPath ] [-ResolverStrategy ] [-MaxParallelism ] [-RespectEndpointTimeout] [-FastestCacheMinutes ] [-PerEndpointMaxInFlight ] [-ResponseCache] [-MaxCacheTtlSeconds ] [-Stream] [-CompletionOrder] [-TypedRecordMetadata] [-DnsSecVerifierPath ] [-EdnsBufferSize ] [-ClientSubnet ] [-BootstrapResolver ] [-RequestFormat ] [-Port ] [-UserAgent ] [-HttpVersion ] [-IgnoreCertificateErrors] [-UseTcpFallback ] [-ProxyUri ] [-MaxConnectionsPerServer ] [-MaxConcurrency ] [-AllServers] [-Fallback] [-RandomServer] [-FullResponse] [-TypedRecords] [-ParseTypedTxtRecords] [-TimeOut ] [-RetryCount ] [-RetryDelayMs ] [-RequestDnsSec] [-ValidateDnsSec] [-EnableEdns] [-CheckingDisabled] [-RequestNsid] [] ``` ### Pattern ```powershell -Resolve-Dns [-Pattern] [[-Type] ] [-DnsProvider ] [-DnsSelectionStrategy ] [-Server ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-ResolverDnsProvider ] [-ResolverSelectionPath ] [-ResolverStrategy ] [-MaxParallelism ] [-RespectEndpointTimeout] [-FastestCacheMinutes ] [-PerEndpointMaxInFlight ] [-ResponseCache] [-MaxCacheTtlSeconds ] [-DnsSecVerifierPath ] [-EdnsBufferSize ] [-ClientSubnet ] [-BootstrapResolver ] [-RequestFormat ] [-Port ] [-UserAgent ] [-HttpVersion ] [-IgnoreCertificateErrors] [-UseTcpFallback ] [-ProxyUri ] [-MaxConnectionsPerServer ] [-MaxConcurrency ] [-AllServers] [-Fallback] [-RandomServer] [-FullResponse] [-TypedRecords] [-ParseTypedTxtRecords] [-TimeOut ] [-RetryCount ] [-RetryDelayMs ] [-RequestDnsSec] [-ValidateDnsSec] [-EnableEdns] [-CheckingDisabled] [-RequestNsid] [] +Resolve-Dns [-Pattern] [[-Type] ] [-DnsProvider ] [-DnsSelectionStrategy ] [-Server ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-ResolverDnsProvider ] [-ResolverSelectionPath ] [-ResolverStrategy ] [-MaxParallelism ] [-RespectEndpointTimeout] [-FastestCacheMinutes ] [-PerEndpointMaxInFlight ] [-ResponseCache] [-MaxCacheTtlSeconds ] [-Stream] [-CompletionOrder] [-TypedRecordMetadata] [-DnsSecVerifierPath ] [-EdnsBufferSize ] [-ClientSubnet ] [-BootstrapResolver ] [-RequestFormat ] [-Port ] [-UserAgent ] [-HttpVersion ] [-IgnoreCertificateErrors] [-UseTcpFallback ] [-ProxyUri ] [-MaxConnectionsPerServer ] [-MaxConcurrency ] [-AllServers] [-Fallback] [-RandomServer] [-FullResponse] [-TypedRecords] [-ParseTypedTxtRecords] [-TimeOut ] [-RetryCount ] [-RetryDelayMs ] [-RequestDnsSec] [-ValidateDnsSec] [-EnableEdns] [-CheckingDisabled] [-RequestNsid] [] ``` ## DESCRIPTION @@ -30,6 +32,8 @@ Supports single-provider queries, explicit servers with transport selection, mul Specify either Name or Pattern. When no resolver source is specified, the library's default resolver is used. Timeout, retry, response, typed-record, and DNSSEC options apply to every resolver source. +Name accepts pipeline strings or objects with Name, Domain, or DnsName properties. By default each answer is an individual pipeline row. Use FullResponse for complete response objects, TypedRecords for typed values, or TypedRecordMetadata to keep typed values and record metadata together. + ## EXAMPLES ### EXAMPLE 1 @@ -109,6 +113,27 @@ Resolve-Dns -Name 'example.com' -Type A -ResolverSelectionPath '.\resolver-score Reuse the recommended resolver from a saved selection snapshot +### EXAMPLE 12 +```powershell +'example.com','example.net' | Resolve-Dns -Type A | Where-Object Type -EQ A +``` + +Resolve names from a pipeline and filter individual answers + +### EXAMPLE 13 +```powershell +Resolve-Dns -Name 'example.com','example.net' -Type A,AAAA -Stream -CompletionOrder -MaxConcurrency 4 +``` + +Stream a bounded batch in completion order + +### EXAMPLE 14 +```powershell +Resolve-Dns -Name 'example.com' -Type MX -TypedRecordMetadata | Select-Object Name,TTL,Value +``` + +Keep typed payloads together with their owner and TTL + ## PARAMETERS ### -AllServers @@ -177,6 +202,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -CompletionOrder +With Stream, emit responses in completion order. Omit this switch to preserve record type order, then name order. + +```yaml +Type: SwitchParameter +Parameter Sets: Name, Pattern +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -DnsProvider Predefined provider(s) (DnsEndpoint) for the query. @@ -296,7 +337,7 @@ Accept wildcard characters: False ``` ### -FullResponse -Provides the full response of the query. If not specified, only the minimal response is provided (just the answer). +Provides full response objects. If not specified, answers are enumerated as individual pipeline rows. If specified, the full response is provided (answer, authority, and additional sections). @@ -410,18 +451,18 @@ Accept wildcard characters: False ``` ### -Name -The name of the DNS record to query for +DNS names to query. Accepts pipeline strings and Name, Domain, or DnsName properties. ```yaml Type: String[] Parameter Sets: Name -Aliases: None +Aliases: Domain, DnsName Possible values: Required: True Position: 0 Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByValue, ByPropertyName) Accept wildcard characters: False ``` @@ -686,7 +727,7 @@ Accept wildcard characters: False ``` ### -ResponseCache -Enables response caching based on TTLs for repeated queries of the same (name,type). Disabled by default. +Enables TTL-based response caching for any resolver source. Disabled by default. ```yaml Type: SwitchParameter @@ -751,6 +792,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Stream +Emit each response when its turn is available in record type order, then name order. MaxConcurrency bounds active queries without collecting the entire batch. + +```yaml +Type: SwitchParameter +Parameter Sets: Name, Pattern +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -TimeOut Specifies the timeout for the DNS query, in milliseconds. If the DNS server does not respond within this time, the query will fail. Default is 2000 ms (2 seconds) as defined by DefaultTimeout. Increase this value for slow networks or unreliable servers. @@ -783,6 +840,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -TypedRecordMetadata +Return typed answer values together with their owner, TTL, class and original record. This implies TypedRecords; FullResponse takes precedence. + +```yaml +Type: SwitchParameter +Parameter Sets: Name, Pattern +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -TypedRecords When set, attempts to parse answers into typed record objects. @@ -852,11 +925,13 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## INPUTS -- `None` +- `System.String[]` ## OUTPUTS -- `None` +- `DnsClientX.DnsAnswerMinimal` +- `DnsClientX.DnsResponse` +- `DnsClientX.DnsTypedRecord[System.Object]` ## RELATED LINKS diff --git a/Module/Docs/Test-DnsBenchmark.md b/Module/Docs/Test-DnsBenchmark.md index e082760c..83b1588a 100644 --- a/Module/Docs/Test-DnsBenchmark.md +++ b/Module/Docs/Test-DnsBenchmark.md @@ -13,17 +13,17 @@ Returns one object per candidate with latency, success rate, answer consistency, ## SYNTAX ### DnsProvider (Default) ```powershell -Test-DnsBenchmark [-Name] [[-Type] ] [-DnsProvider ] [-Attempts ] [-MaxConcurrency ] [-ConnectionMode ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-MinSuccessPercent ] [-MinSuccessfulCandidates ] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] +Test-DnsBenchmark [-Name] [[-Type] ] [-DnsProvider ] [-Attempts ] [-MaxConcurrency ] [-ConnectionMode ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-MinSuccessPercent ] [-MinSuccessfulCandidates ] [-FailOnPolicyViolation] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] ``` ### ResolverEndpoint ```powershell -Test-DnsBenchmark [-Name] [[-Type] ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-Attempts ] [-MaxConcurrency ] [-ConnectionMode ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-MinSuccessPercent ] [-MinSuccessfulCandidates ] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] +Test-DnsBenchmark [-Name] [[-Type] ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-Attempts ] [-MaxConcurrency ] [-ConnectionMode ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-MinSuccessPercent ] [-MinSuccessfulCandidates ] [-FailOnPolicyViolation] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] ``` ### ResolverSelection ```powershell -Test-DnsBenchmark [-Name] [[-Type] ] -ResolverSelectionPath [-Attempts ] [-MaxConcurrency ] [-ConnectionMode ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-MinSuccessPercent ] [-MinSuccessfulCandidates ] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] +Test-DnsBenchmark [-Name] [[-Type] ] -ResolverSelectionPath [-Attempts ] [-MaxConcurrency ] [-ConnectionMode ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-MinSuccessPercent ] [-MinSuccessfulCandidates ] [-FailOnPolicyViolation] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] ``` ## DESCRIPTION @@ -178,6 +178,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -FailOnPolicyViolation +Write a PolicyViolation error when the selected benchmark thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + +```yaml +Type: SwitchParameter +Parameter Sets: DnsProvider, ResolverEndpoint, ResolverSelection +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -IncludeSummary Emits a run-level summary object after the per-candidate benchmark results. diff --git a/Module/Docs/Test-DnsHealth.md b/Module/Docs/Test-DnsHealth.md new file mode 100644 index 00000000..7b14c70e --- /dev/null +++ b/Module/Docs/Test-DnsHealth.md @@ -0,0 +1,140 @@ +--- +external help file: DnsClientX-help.xml +Module Name: DnsClientX +online version: https://github.com/EvotecIT/DnsClientX/blob/master/README.md +schema: 2.0.0 +--- +# Test-DnsHealth +## SYNOPSIS +Runs a repeatable DNS resolver health profile. + +Runs the profile's DNS query catalog and policy against its configured resolvers. IncludeBenchmark measures the same catalog. Returns the complete report; resolver agreement alone does not establish authoritative correctness. + +## SYNTAX +### Path (Default) +```powershell +Test-DnsHealth [-Path] [-IncludeBenchmark] [-FailOnPolicyViolation] [-DnsSecVerifierPath ] [] +``` + +### Profile +```powershell +Test-DnsHealth [-Profile] [-IncludeBenchmark] [-FailOnPolicyViolation] [-DnsSecVerifierPath ] [] +``` + +## DESCRIPTION +Runs a repeatable DNS resolver health profile. + +Runs the profile's DNS query catalog and policy against its configured resolvers. IncludeBenchmark measures the same catalog. Returns the complete report; resolver agreement alone does not establish authoritative correctness. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Test-DnsHealth -Path './dns-health.json' -FailOnPolicyViolation -ErrorAction Stop +``` + +Run a saved profile and fail an automation run when its policy fails + +### EXAMPLE 2 +```powershell +Test-DnsHealth -Path './dns-health.json' -IncludeBenchmark +``` + +Include performance measurements for the same profile + +## PARAMETERS + +### -DnsSecVerifierPath +Local optional DNSSEC verifier DLL with dependencies beside it. + +```yaml +Type: String +Parameter Sets: Path, Profile +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -FailOnPolicyViolation +Write a PolicyViolation error on failed health gates. Use ErrorAction Stop for enforcement; the default only warns. + +```yaml +Type: SwitchParameter +Parameter Sets: Path, Profile +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -IncludeBenchmark +Benchmark the same DNS names, types and resolver targets after the health queries. + +```yaml +Type: SwitchParameter +Parameter Sets: Path, Profile +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Path +Local JSON health profile path. + +```yaml +Type: String +Parameter Sets: Path +Aliases: None +Possible values: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Profile +Typed health profile supplied directly or through the pipeline. + +```yaml +Type: ResolverHealthProfile +Parameter Sets: Profile +Aliases: None +Possible values: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +- `DnsClientX.ResolverHealthProfile` + +## OUTPUTS + +- `DnsClientX.ResolverHealthReport` + +## RELATED LINKS + +- None diff --git a/Module/Docs/Test-DnsProbe.md b/Module/Docs/Test-DnsProbe.md index 15b1db6c..e9b63d59 100644 --- a/Module/Docs/Test-DnsProbe.md +++ b/Module/Docs/Test-DnsProbe.md @@ -13,17 +13,17 @@ Runs a single DNS query against each candidate, highlights answer mismatches, ap ## SYNTAX ### DnsProvider (Default) ```powershell -Test-DnsProbe [-Name] [[-Type] ] [-DnsProvider ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-RequireConsensus] [-MinConsensusPercent ] [-MinSuccessCount ] [-MinSuccessPercent ] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] +Test-DnsProbe [-Name] [[-Type] ] [-DnsProvider ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-RequireConsensus] [-MinConsensusPercent ] [-MinSuccessCount ] [-MinSuccessPercent ] [-FailOnPolicyViolation] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] ``` ### ResolverEndpoint ```powershell -Test-DnsProbe [-Name] [[-Type] ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-RequireConsensus] [-MinConsensusPercent ] [-MinSuccessCount ] [-MinSuccessPercent ] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] +Test-DnsProbe [-Name] [[-Type] ] [-ResolverEndpoint ] [-ResolverEndpointFile ] [-ResolverEndpointUrl ] [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-RequireConsensus] [-MinConsensusPercent ] [-MinSuccessCount ] [-MinSuccessPercent ] [-FailOnPolicyViolation] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] ``` ### ResolverSelection ```powershell -Test-DnsProbe [-Name] [[-Type] ] -ResolverSelectionPath [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-RequireConsensus] [-MinConsensusPercent ] [-MinSuccessCount ] [-MinSuccessPercent ] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] +Test-DnsProbe [-Name] [[-Type] ] -ResolverSelectionPath [-TimeOut ] [-RequestDnsSec] [-RequestNsid] [-BootstrapResolver ] [-ValidateDnsSec] [-DnsSecVerifierPath ] [-RequireConsensus] [-MinConsensusPercent ] [-MinSuccessCount ] [-MinSuccessPercent ] [-FailOnPolicyViolation] [-IncludeSummary] [-SummaryOnly] [-SavePath ] [] ``` ## DESCRIPTION @@ -132,6 +132,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -FailOnPolicyViolation +Write a PolicyViolation error when the selected probe thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + +```yaml +Type: SwitchParameter +Parameter Sets: DnsProvider, ResolverEndpoint, ResolverSelection +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -IncludeSummary Emits a run-level summary object after the per-candidate probe rows. diff --git a/Module/Tests/DiscoverService.Tests.ps1 b/Module/Tests/DiscoverService.Tests.ps1 deleted file mode 100644 index 8e6cefae..00000000 --- a/Module/Tests/DiscoverService.Tests.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -Import-Module "$PSScriptRoot/../DnsClientX.psd1" -Force - -Describe 'Get-DnsService cmdlet' { - It 'Returns empty array when no services are discovered' { - $result = Get-DnsService -Domain 'example.com' - ($null -ne $result) | Should -BeTrue - $result.GetType().FullName | Should -Be 'DnsClientX.DnsService[]' - $result.Count | Should -Be 0 - } -} diff --git a/Module/Tests/DnsWorkflowFixture.Tests.ps1 b/Module/Tests/DnsWorkflowFixture.Tests.ps1 new file mode 100644 index 00000000..b261a14d --- /dev/null +++ b/Module/Tests/DnsWorkflowFixture.Tests.ps1 @@ -0,0 +1,65 @@ +BeforeAll { + if (-not ('DnsWorkflowServer' -as [type])) { + Add-Type -Path "$PSScriptRoot/Fixtures/DnsWorkflowServer.cs" + } + + function New-FixtureQuestion { + param([string] $Name) + $packet = [Collections.Generic.List[byte]]::new() + $packet.AddRange([byte[]](0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0)) + foreach ($label in $Name.Split('.')) { + $bytes = [Text.Encoding]::ASCII.GetBytes($label) + $packet.Add([byte]$bytes.Length) + $packet.AddRange($bytes) + } + $packet.AddRange([byte[]](0, 0, 1, 0, 1)) + return ,$packet.ToArray() + } +} + +Describe 'Loopback DNS fixture packet and shutdown safety' { + It 'continues serving valid queries after