From 9bfa3a8556cf8b83dd0a8f75f6973f0f3915385a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 13 Oct 2026 00:54:12 +0200 Subject: [PATCH 1/5] Improve DNS correctness and cross-platform workflows --- .github/workflows/qualify-cli.yml | 76 + .github/workflows/test-powershell.yml | 301 +- .gitignore | 4 + Build/PackageConsumer/NuGet.Config | 17 + Build/PackageConsumer/PackageConsumer.csproj | 15 + Build/PackageConsumer/Program.cs | 25 + Build/Test-PackagedModule.ps1 | 36 + Build/cli.publish.json | 36 + DnsClientX.Cli/DnsClientX.Cli.csproj | 2 +- DnsClientX.Cli/Program.Arguments.cs | 466 +++ DnsClientX.Cli/Program.Help.cs | 80 + DnsClientX.Cli/Program.Inputs.cs | 161 + DnsClientX.Cli/Program.Options.cs | 99 + DnsClientX.Cli/Program.Output.cs | 183 + DnsClientX.Cli/Program.ProfileOperations.cs | 66 + DnsClientX.Cli/Program.Query.cs | 226 ++ DnsClientX.Cli/Program.ResolverOperations.cs | 400 +++ DnsClientX.Cli/Program.Validation.cs | 212 ++ DnsClientX.Cli/Program.cs | 1716 +--------- DnsClientX.PowerShell/CmdletClearDnsCache.cs | 35 + .../CmdletCompareDnsResolverSnapshot.cs | 43 + .../CmdletDiscoverDnsService.cs | 53 +- DnsClientX.PowerShell/CmdletDnsUpdate.cs | 10 +- DnsClientX.PowerShell/CmdletFindDnsService.cs | 48 +- DnsClientX.PowerShell/CmdletGetDnsCache.cs | 17 + .../CmdletGetDnsQueryExplanation.cs | 27 + .../CmdletResolveDnsQuery.cs | 66 +- .../CmdletTestDnsBenchmark.cs | 8 + DnsClientX.PowerShell/CmdletTestDnsHealth.cs | 56 + DnsClientX.PowerShell/CmdletTestDnsProbe.cs | 8 + DnsClientX.PowerShell/DnsServiceCmdletBase.cs | 54 + DnsClientX.Tests/CliResolverImportTests.cs | 6 +- DnsClientX.Tests/CliWorkflowJsonTests.cs | 119 + DnsClientX.Tests/DnsQueryExplanationTests.cs | 29 + DnsClientX.Tests/DnsQueryWorkflowTests.cs | 96 + DnsClientX.Tests/DnsResponseCacheTests.cs | 6 +- .../DnsResponseCacheWorkflowTests.cs | 190 ++ DnsClientX.Tests/DnsSecMaterialCacheTests.cs | 84 + .../DnsTransportCapabilitiesTests.cs | 21 + DnsClientX.Tests/ReleaseSanityTests.cs | 1 + .../ResolveAsyncEnumerableLifetimeTests.cs | 143 + DnsClientX.Tests/ResolveDnsRequestTests.cs | 8 + .../ResolverBenchmarkEvidenceTests.cs | 22 + .../ResolverHealthWorkflowTests.cs | 71 + .../ResolverSavedEndpointContractTests.cs | 130 + .../ResolverScoreComparerTests.cs | 51 + .../ResolverScoreComparisonContractTests.cs | 122 + .../ResolverSingleOperationWorkflowTests.cs | 58 + DnsClientX/Configuration.cs | 5 +- .../Definitions/DnsResponse.TypedRecords.cs | 18 + DnsClientX/Definitions/DnsResponse.cs | 4 + DnsClientX/DnsClientX.Cache.cs | 36 + DnsClientX/DnsClientX.Dispose.cs | 2 + DnsClientX/DnsClientX.DnsSecMaterialCache.cs | 55 + DnsClientX/DnsClientX.QueryDnsRequest.cs | 77 +- DnsClientX/DnsClientX.QueryDnsStreaming.cs | 79 + DnsClientX/DnsClientX.Resolve.cs | 46 +- .../DnsClientX.ResolveAsyncEnumerable.cs | 87 +- DnsClientX/DnsClientX.ResolveOptions.cs | 47 + DnsClientX/DnsConcurrentStream.cs | 59 + DnsClientX/DnsQueryAttemptExplanation.cs | 38 + DnsClientX/DnsQueryExplanation.cs | 68 + DnsClientX/DnsResolveOptions.cs | 34 + DnsClientX/DnsResponseCache.cs | 196 +- DnsClientX/DnsResponseCacheStatistics.cs | 31 + DnsClientX/DnsSecMaterialSession.cs | 60 + DnsClientX/DnsSecValidationDependency.cs | 31 + DnsClientX/DnsTransportCapabilities.cs | 76 +- DnsClientX/DnsTypedRecord.cs | 23 + DnsClientX/ResolveDnsRequest.cs | 16 +- DnsClientX/ResolverAnswerObservation.cs | 26 + DnsClientX/ResolverBenchmarkAttemptReport.cs | 21 + DnsClientX/ResolverBenchmarkReport.cs | 2 + DnsClientX/ResolverBenchmarkReportBuilder.cs | 12 + DnsClientX/ResolverBenchmarkWorkflow.cs | 1 + DnsClientX/ResolverHealthProfile.cs | 72 + DnsClientX/ResolverHealthProfileStore.cs | 35 + DnsClientX/ResolverHealthReport.cs | 19 + DnsClientX/ResolverHealthWorkflow.cs | 45 + DnsClientX/ResolverProbeEvaluationEntry.cs | 1 + DnsClientX/ResolverProbeObservation.cs | 2 + DnsClientX/ResolverProbeReportBuilder.cs | 12 +- DnsClientX/ResolverProbeReportResult.cs | 6 + DnsClientX/ResolverProbeScorer.cs | 4 +- DnsClientX/ResolverProbeWorkflow.cs | 4 +- DnsClientX/ResolverQueryAttemptResult.cs | 14 +- DnsClientX/ResolverQueryExecutor.cs | 8 +- DnsClientX/ResolverScoreComparer.cs | 105 + DnsClientX/ResolverScoreComparison.cs | 57 + .../ResolverScoreRecommendationBinding.cs | 45 + DnsClientX/ResolverScoreRunContext.cs | 82 + DnsClientX/ResolverScoreSelector.cs | 42 +- DnsClientX/ResolverScoreSnapshot.cs | 6 + DnsClientX/ResolverSingleOperationWorkflow.cs | 6 +- Module/Build/Build-Module.ps1 | 35 +- Module/DnsClientX.Format.ps1xml | 60 + Module/DnsClientX.Types.ps1xml | 51 + Module/DnsClientX.psd1 | 7 +- Module/Docs/Clear-DnsCache.md | 99 + Module/Docs/Compare-DnsResolverSnapshot.md | 133 + Module/Docs/Find-DnsService.md | 169 +- Module/Docs/Get-DnsCache.md | 48 + Module/Docs/Get-DnsQueryExplanation.md | 80 + Module/Docs/Get-DnsService.md | 185 +- Module/Docs/Invoke-DnsUpdate.md | 6 +- Module/Docs/Readme.md | 33 +- Module/Docs/Resolve-Dns.md | 93 +- Module/Docs/Test-DnsBenchmark.md | 22 +- Module/Docs/Test-DnsHealth.md | 140 + Module/Docs/Test-DnsProbe.md | 22 +- Module/Tests/DiscoverService.Tests.ps1 | 10 - Module/Tests/DnsWorkflows.Tests.ps1 | 174 + Module/Tests/DnssecMessage.Tests.ps1 | 18 +- Module/Tests/FindDnsService.Tests.ps1 | 10 - Module/Tests/Fixtures/DnsWorkflowServer.cs | 135 + Module/Tests/InvokeDnsUpdate.Tests.ps1 | 14 + Module/Tests/ResolveDns.Tests.ps1 | 28 - Module/Tests/ResolveDnsBinding.Tests.ps1 | 19 +- Module/en-US/DnsClientX-help.xml | 2999 ++++++++++++++--- PLAN.md | 307 +- README.md | 140 +- 121 files changed, 9472 insertions(+), 2983 deletions(-) create mode 100644 .github/workflows/qualify-cli.yml create mode 100644 Build/PackageConsumer/NuGet.Config create mode 100644 Build/PackageConsumer/PackageConsumer.csproj create mode 100644 Build/PackageConsumer/Program.cs create mode 100644 Build/Test-PackagedModule.ps1 create mode 100644 Build/cli.publish.json create mode 100644 DnsClientX.Cli/Program.Arguments.cs create mode 100644 DnsClientX.Cli/Program.Help.cs create mode 100644 DnsClientX.Cli/Program.Inputs.cs create mode 100644 DnsClientX.Cli/Program.Options.cs create mode 100644 DnsClientX.Cli/Program.Output.cs create mode 100644 DnsClientX.Cli/Program.ProfileOperations.cs create mode 100644 DnsClientX.Cli/Program.Query.cs create mode 100644 DnsClientX.Cli/Program.ResolverOperations.cs create mode 100644 DnsClientX.Cli/Program.Validation.cs create mode 100644 DnsClientX.PowerShell/CmdletClearDnsCache.cs create mode 100644 DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs create mode 100644 DnsClientX.PowerShell/CmdletGetDnsCache.cs create mode 100644 DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs create mode 100644 DnsClientX.PowerShell/CmdletTestDnsHealth.cs create mode 100644 DnsClientX.PowerShell/DnsServiceCmdletBase.cs create mode 100644 DnsClientX.Tests/CliWorkflowJsonTests.cs create mode 100644 DnsClientX.Tests/DnsQueryExplanationTests.cs create mode 100644 DnsClientX.Tests/DnsQueryWorkflowTests.cs create mode 100644 DnsClientX.Tests/DnsResponseCacheWorkflowTests.cs create mode 100644 DnsClientX.Tests/DnsSecMaterialCacheTests.cs create mode 100644 DnsClientX.Tests/ResolveAsyncEnumerableLifetimeTests.cs create mode 100644 DnsClientX.Tests/ResolverBenchmarkEvidenceTests.cs create mode 100644 DnsClientX.Tests/ResolverHealthWorkflowTests.cs create mode 100644 DnsClientX.Tests/ResolverSavedEndpointContractTests.cs create mode 100644 DnsClientX.Tests/ResolverScoreComparerTests.cs create mode 100644 DnsClientX.Tests/ResolverScoreComparisonContractTests.cs create mode 100644 DnsClientX/DnsClientX.Cache.cs create mode 100644 DnsClientX/DnsClientX.DnsSecMaterialCache.cs create mode 100644 DnsClientX/DnsClientX.QueryDnsStreaming.cs create mode 100644 DnsClientX/DnsClientX.ResolveOptions.cs create mode 100644 DnsClientX/DnsConcurrentStream.cs create mode 100644 DnsClientX/DnsQueryAttemptExplanation.cs create mode 100644 DnsClientX/DnsQueryExplanation.cs create mode 100644 DnsClientX/DnsResolveOptions.cs create mode 100644 DnsClientX/DnsResponseCacheStatistics.cs create mode 100644 DnsClientX/DnsSecMaterialSession.cs create mode 100644 DnsClientX/DnsSecValidationDependency.cs create mode 100644 DnsClientX/DnsTypedRecord.cs create mode 100644 DnsClientX/ResolverAnswerObservation.cs create mode 100644 DnsClientX/ResolverBenchmarkAttemptReport.cs create mode 100644 DnsClientX/ResolverHealthProfile.cs create mode 100644 DnsClientX/ResolverHealthProfileStore.cs create mode 100644 DnsClientX/ResolverHealthReport.cs create mode 100644 DnsClientX/ResolverHealthWorkflow.cs create mode 100644 DnsClientX/ResolverScoreComparer.cs create mode 100644 DnsClientX/ResolverScoreComparison.cs create mode 100644 DnsClientX/ResolverScoreRecommendationBinding.cs create mode 100644 DnsClientX/ResolverScoreRunContext.cs create mode 100644 Module/DnsClientX.Format.ps1xml create mode 100644 Module/DnsClientX.Types.ps1xml create mode 100644 Module/Docs/Clear-DnsCache.md create mode 100644 Module/Docs/Compare-DnsResolverSnapshot.md create mode 100644 Module/Docs/Get-DnsCache.md create mode 100644 Module/Docs/Get-DnsQueryExplanation.md create mode 100644 Module/Docs/Test-DnsHealth.md delete mode 100644 Module/Tests/DiscoverService.Tests.ps1 create mode 100644 Module/Tests/DnsWorkflows.Tests.ps1 delete mode 100644 Module/Tests/FindDnsService.Tests.ps1 create mode 100644 Module/Tests/Fixtures/DnsWorkflowServer.cs diff --git a/.github/workflows/qualify-cli.yml b/.github/workflows/qualify-cli.yml new file mode 100644 index 00000000..76a18d65 --- /dev/null +++ b/.github/workflows/qualify-cli.yml @@ -0,0 +1,76 @@ +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 + - 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..67e8fa26 100644 --- a/.github/workflows/test-powershell.yml +++ b/.github/workflows/test-powershell.yml @@ -2,210 +2,159 @@ 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 + 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 - shell: powershell + name: unsigned-packages + path: candidate + - name: Install Pester + shell: ${{ matrix.shell }} 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 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module Pester -MinimumVersion 5.7.1 -Force -Scope CurrentUser -SkipPublisherCheck -AllowClobber + - name: Import packaged module and run contracts + shell: ${{ matrix.shell }} 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 - 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 + $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.' } + ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml" + - uses: actions/upload-artifact@v7 + if: always() with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Install PowerShell modules - 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: pester-${{ matrix.os }}-${{ matrix.shell }} + path: ${{ runner.temp }}/pester.xml - - name: Run PowerShell tests - shell: pwsh - run: ./Module/DnsClientX.Tests.ps1 - - 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..a56d8470 --- /dev/null +++ b/DnsClientX.Cli/Program.Arguments.cs @@ -0,0 +1,466 @@ +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; + } + 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..91584866 --- /dev/null +++ b/DnsClientX.Cli/Program.ProfileOperations.cs @@ -0,0 +1,66 @@ +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..9122ddeb --- /dev/null +++ b/DnsClientX.Cli/Program.Query.cs @@ -0,0 +1,226 @@ +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..b1a2fb9f --- /dev/null +++ b/DnsClientX.Cli/Program.ResolverOperations.cs @@ -0,0 +1,400 @@ +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..a8e58cb6 --- /dev/null +++ b/DnsClientX.Cli/Program.Validation.cs @@ -0,0 +1,212 @@ +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/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/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..cb5595ba --- /dev/null +++ b/DnsClientX.Tests/ResolverHealthWorkflowTests.cs @@ -0,0 +1,71 @@ +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 { + /// 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..fe7f97b2 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) { @@ -257,7 +260,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 +272,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 +294,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 +312,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 +323,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..581d6d6f --- /dev/null +++ b/DnsClientX/DnsClientX.QueryDnsStreaming.cs @@ -0,0 +1,79 @@ +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) => QueryOne(client, 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) => QueryOne(client, 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(Pairs(), + (query, token) => operation(query.Name, query.Type, token), request.EffectiveMaxConcurrency, + preserveOrder, cancellationToken).ConfigureAwait(false)) { + foreach (DnsResponse response in batch) yield return response; + } + } + IEnumerable<(string Name, DnsRecordType Type)> Pairs() { + foreach (DnsRecordType type in request.RecordTypes) + foreach (string name in names) yield return (name, type); + } + Task QueryOne(ClientX client, string name, DnsRecordType type, CancellationToken token, bool? dnsSec = null) => + ExecuteWithRetryAsync(request, async () => { + if (QueryDnsRequestOverride != null) + return await QueryDnsRequestOverride(request, new[] { name }, type, token).ConfigureAwait(false); + return new[] { await client.Resolve(name, type, dnsSec ?? request.ShouldRequestDnsSec, + request.ShouldValidateDnsSec, false, false, 1, request.RetryDelayMs, + request.TypedRecords, request.ParseTypedTxtRecords, token).ConfigureAwait(false) }; + }, token); + } +} 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..031a2a77 --- /dev/null +++ b/DnsClientX/ResolverHealthProfile.cs @@ -0,0 +1,72 @@ +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, 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.Length; + if (count != ResolverEndpoints.Length) throw new ArgumentException("Profile resolver targets must be distinct."); + } + 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..2fd220a1 --- /dev/null +++ b/DnsClientX/ResolverHealthWorkflow.cs @@ -0,0 +1,45 @@ +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. 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); + 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/DnsWorkflows.Tests.ps1 b/Module/Tests/DnsWorkflows.Tests.ps1 new file mode 100644 index 00000000..9d1e63fa --- /dev/null +++ b/Module/Tests/DnsWorkflows.Tests.ps1 @@ -0,0 +1,174 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../DnsClientX.psd1" -Force + if (-not ('DnsWorkflowServer' -as [type])) { + Add-Type -Path "$PSScriptRoot/Fixtures/DnsWorkflowServer.cs" + } +} + +Describe 'PowerShell DNS workflows against a loopback peer' { + BeforeEach { $script:dnsPeer = [DnsWorkflowServer]::new() } + AfterEach { $script:dnsPeer.Dispose() } + + It 'binds names by pipeline value and property and emits individual answer rows' { + $rows = @('rows.test' | Resolve-Dns -Server 127.0.0.1 -Port $dnsPeer.Port -RetryCount 1) + $rows.Count | Should -Be 2 + $rows[0].GetType().FullName | Should -Be 'DnsClientX.DnsAnswerMinimal' + $filtered = @($rows | Where-Object Data -EQ '192.0.2.10') + $filtered.Count | Should -Be 1 + $filtered[0].Data | Should -Be '192.0.2.10' + $response = [pscustomobject]@{ DnsName = 'property.test' } | + Resolve-Dns -Server 127.0.0.1 -Port $dnsPeer.Port -FullResponse -RetryCount 1 + $response.GetType().FullName | Should -Be 'DnsClientX.DnsResponse' + $response.Questions[0].Name.TrimEnd('.') | Should -Be 'property.test' + } + + It 'returns typed values with their record metadata when requested' { + $rows = @(Resolve-Dns rows.test -Server 127.0.0.1 -Port $dnsPeer.Port -TypedRecordMetadata -RetryCount 1) + $rows.Count | Should -Be 2 + $rows[0].Name.TrimEnd('.') | Should -Be 'rows.test' + $rows[0].TTL | Should -Be 120 + $rows[0].Value.GetType().FullName | Should -Be 'DnsClientX.ARecord' + $rows[0].Record.Data | Should -Be '192.0.2.10' + } + + It 'returns individual typed payloads and preserves full-response precedence' { + $rows = @(Resolve-Dns rows.test -Server 127.0.0.1 -Port $dnsPeer.Port -TypedRecords -RetryCount 1) + $rows.Count | Should -Be 2 + $rows[0].GetType().FullName | Should -Be 'DnsClientX.ARecord' + $rows[0].Address.IPAddressToString | Should -Be '192.0.2.10' + $response = Resolve-Dns rows.test -Server 127.0.0.1 -Port $dnsPeer.Port -FullResponse -TypedRecordMetadata -RetryCount 1 + $response.GetType().FullName | Should -Be 'DnsClientX.DnsResponse' + $response.TypedAnswers.Count | Should -Be 2 + } + + It 'streams batches with input ordering and optional completion ordering' { + $ordered = @(Resolve-Dns slow.test,fast.test -Server 127.0.0.1 -Port $dnsPeer.Port -Stream -FullResponse -MaxConcurrency 2 -RetryCount 1) + $ordered[0].Questions[0].Name.TrimEnd('.') | Should -Be 'slow.test' + $completed = @(Resolve-Dns slow.test,fast.test -Server 127.0.0.1 -Port $dnsPeer.Port -Stream -CompletionOrder -FullResponse -MaxConcurrency 2 -RetryCount 1) + $completed[0].Questions[0].Name.TrimEnd('.') | Should -Be 'fast.test' + $completed.Count | Should -Be 2 + { Resolve-Dns fast.test -CompletionOrder -ErrorAction Stop } | Should -Throw '*requires Stream*' + } + + It 'stops a bulk stream after the pipeline consumer takes its first result' { + $names = @('fast.test') + @(1..11 | ForEach-Object { 'slow.test' }) + $first = Resolve-Dns -Name $names -Server 127.0.0.1 -Port $dnsPeer.Port -Stream -CompletionOrder -FullResponse -MaxConcurrency 2 -RetryCount 1 | + Select-Object -First 1 + $first.Questions[0].Name.TrimEnd('.') | Should -Be 'fast.test' + $receivedAtStop = $dnsPeer.Queries.Count + $receivedAtStop | Should -BeLessThan $names.Count + Start-Sleep -Milliseconds 350 + $dnsPeer.Queries.Count | Should -Be $receivedAtStop + } + + It 'inspects and selectively invalidates the process-wide response cache with WhatIf support' { + Clear-DnsCache | Out-Null + try { + Resolve-Dns cache.test -Type A,AAAA -Server 127.0.0.1 -Port $dnsPeer.Port -ResponseCache -RetryCount 1 | Out-Null + (Get-DnsCache).EntryCount | Should -Be 2 + Clear-DnsCache cache.test -Type A -WhatIf + (Get-DnsCache).EntryCount | Should -Be 2 + (Clear-DnsCache cache.test -Type A) | Should -Be 1 + (Get-DnsCache).EntryCount | Should -Be 1 + (Clear-DnsCache cache.test) | Should -Be 1 + (Get-DnsCache).EntryCount | Should -Be 0 + } finally { Clear-DnsCache | Out-Null } + } + + It 'streams services so filtering never leaks an unmatched service' { + $services = @('services.test' | Find-DnsService -ResolverEndpoint $dnsPeer.Endpoint) + $services.Count | Should -Be 2 + $services[0].GetType().FullName | Should -Be 'DnsClientX.DnsService' + $http = @($services | Where-Object ServiceType -Like '_http._tcp.*') + $http.Count | Should -Be 1 + $http[0].Port | Should -Be 8080 + $http[0].Metadata['path'] | Should -Be '/status' + @(Get-DnsService empty.test -ResolverEndpoint $dnsPeer.Endpoint).Count | Should -Be 0 + @(Find-DnsService empty.test -ResolverEndpoint $dnsPeer.Endpoint).Count | Should -Be 0 + } + + It 'lists service types, browses a selected type and resolves host addresses' { + $types = @(Find-DnsService services.test -ServiceTypes -ResolverEndpoint $dnsPeer.Endpoint) + $types | Should -Contain '_http._tcp.services.test' + $types | Should -Contain '_ssh._tcp.services.test' + $services = @(Get-DnsService services.test -Service http -ResolverEndpoint $dnsPeer.Endpoint) + $services.Count | Should -Be 1 + $services[0].ServiceType | Should -Be '_http._tcp.services.test' + $resolved = @(Get-DnsService services.test -Service http -Resolve -ResolveHosts -ResolverEndpoint $dnsPeer.Endpoint) + $resolved.Count | Should -Be 1 + $resolved[0].Addresses.Count | Should -Be 2 + $resolved[0].Addresses.IPAddressToString | Should -Contain '192.0.2.10' + $resolved[0].Addresses.IPAddressToString | Should -Contain '2001:db8::10' + } + + It 'supports automation policy errors while retaining warning-only defaults' { + $summary = Test-DnsProbe fail.test -ResolverEndpoint $dnsPeer.Endpoint -MinSuccessCount 1 -SummaryOnly -WarningAction SilentlyContinue + $summary.PolicyPassed | Should -BeFalse + { Test-DnsProbe fail.test -ResolverEndpoint $dnsPeer.Endpoint -MinSuccessCount 1 -FailOnPolicyViolation -WarningAction SilentlyContinue -ErrorAction Stop } | + Should -Throw '*Probe policy failed*' + { Test-DnsBenchmark fail.test -ResolverEndpoint $dnsPeer.Endpoint -Attempts 1 -MinSuccessfulCandidates 1 -FailOnPolicyViolation -WarningAction SilentlyContinue -ErrorAction Stop } | + Should -Throw '*Benchmark policy failed*' + } + + It 'runs a saved health profile and accepts typed profiles through the pipeline' { + $profile = [DnsClientX.ResolverHealthProfile]@{ + Name = 'loopback-health' + ResolverEndpoints = @($dnsPeer.Endpoint) + Names = @('health.test') + AttemptsPerCombination = 1 + ProbePolicy = [DnsClientX.ResolverProbePolicy]@{ MinSuccessCount = 1 } + } + $profilePath = Join-Path $TestDrive 'health.json' + [DnsClientX.ResolverHealthProfileStore]::Save($profilePath, $profile) + $report = Test-DnsHealth -Path $profilePath -IncludeBenchmark -ErrorAction Stop + $report.ProfileName | Should -Be 'loopback-health' + $report.PolicyPassed | Should -BeTrue + $report.Queries.Count | Should -Be 1 + $report.Benchmark.Summary.OverallSuccessCount | Should -Be 1 + $profile.Names = @('fail.test') + { $profile | Test-DnsHealth -FailOnPolicyViolation -ErrorAction Stop } | Should -Throw '*health profile*failed*' + } + + It 'compares saved snapshots from the same query scope and retains answer changes' { + $baselinePath = Join-Path $TestDrive 'baseline.json' + $currentPath = Join-Path $TestDrive 'current.json' + Test-DnsProbe change.test -ResolverEndpoint $dnsPeer.Endpoint -SavePath $baselinePath | Out-Null + $dnsPeer.Address = '192.0.2.20' + Test-DnsProbe change.test -ResolverEndpoint $dnsPeer.Endpoint -SavePath $currentPath | Out-Null + $comparison = Compare-DnsResolverSnapshot -BaselinePath $baselinePath -CurrentPath $currentPath + $comparison.ScopeComparable | Should -BeTrue + $comparison.Answers.Count | Should -BeGreaterThan 0 + $comparison.Answers[0].Changed | Should -BeTrue + $comparison.HealthRegression | Should -BeFalse + $baseline = [DnsClientX.ResolverScoreStore]::Load($baselinePath) + $current = [DnsClientX.ResolverScoreStore]::Load($currentPath) + ($current | Compare-DnsResolverSnapshot -Baseline $baseline).ScopeComparable | Should -BeTrue + } + + It 'explains captured response evidence without issuing another DNS query' { + $response = Resolve-Dns explain.test -Server 127.0.0.1 -Port $dnsPeer.Port -FullResponse -RetryCount 1 + $before = $dnsPeer.Queries.Count + $explanation = $response | Get-DnsQueryExplanation + $explanation.Status | Should -Be ([DnsClientX.DnsResponseCode]::NoError) + $explanation.Transport | Should -Be ([DnsClientX.Transport]::Udp) + $explanation.ResponseSource | Should -Be ([DnsClientX.DnsResponseSource]::Network) + $explanation.UnavailableEvidence.Count | Should -BeGreaterThan 0 + $dnsPeer.Queries.Count | Should -Be $before + } + + It 'cancels service discovery when the PowerShell invocation stops' { + $runner = [powershell]::Create() + try { + $module = (Resolve-Path "$PSScriptRoot/../DnsClientX.psd1").Path + $null = $runner.AddScript('param($module,$endpoint) Import-Module $module -Force; Find-DnsService cancel.test -ResolverEndpoint $endpoint -TimeOut 30000').AddArgument($module).AddArgument($dnsPeer.Endpoint) + $invocation = $runner.BeginInvoke() + $deadline = [DateTime]::UtcNow.AddSeconds(5) + while ($dnsPeer.Queries.Count -lt 2 -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 20 } + $dnsPeer.Queries.Count | Should -Be 2 + $watch = [System.Diagnostics.Stopwatch]::StartNew() + $runner.Stop() + $watch.Elapsed.TotalSeconds | Should -BeLessThan 3 + $runner.InvocationStateInfo.State | Should -Be ([System.Management.Automation.PSInvocationState]::Stopped) + } finally { $runner.Dispose() } + } +} diff --git a/Module/Tests/DnssecMessage.Tests.ps1 b/Module/Tests/DnssecMessage.Tests.ps1 index a524b7e2..0f476988 100644 --- a/Module/Tests/DnssecMessage.Tests.ps1 +++ b/Module/Tests/DnssecMessage.Tests.ps1 @@ -1,21 +1,7 @@ Import-Module "$PSScriptRoot/../DnsClientX.psd1" -Force -$candidateFrameworks = @('net8.0', 'net10.0', 'netstandard2.0') -$dnsClientAssemblyPath = foreach ($framework in $candidateFrameworks) { - $candidatePath = Join-Path $PSScriptRoot "../../DnsClientX/bin/Debug/$framework/DnsClientX.dll" - if (Test-Path -Path $candidatePath) { - $candidatePath - break - } -} -if (-not $dnsClientAssemblyPath) { - throw 'Unable to locate a built DnsClientX assembly for PowerShell tests.' -} - -$loadedAssembly = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'DnsClientX' } | Select-Object -First 1 -if (-not $loadedAssembly) { - Add-Type -Path $dnsClientAssemblyPath -} +# Exercise the assembly imported by the module, including packaged installations. +$null = [DnsClientX.ClientX].Assembly Describe 'DnsMessage DO bit' { It 'Sets DO bit when requestDnsSec is true' { diff --git a/Module/Tests/FindDnsService.Tests.ps1 b/Module/Tests/FindDnsService.Tests.ps1 deleted file mode 100644 index 4cbb4324..00000000 --- a/Module/Tests/FindDnsService.Tests.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -Import-Module "$PSScriptRoot/../DnsClientX.psd1" -Force - -Describe 'Find-DnsService cmdlet' { - It 'Returns empty array when no services are discovered' { - $result = Find-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/Fixtures/DnsWorkflowServer.cs b/Module/Tests/Fixtures/DnsWorkflowServer.cs new file mode 100644 index 00000000..2470d503 --- /dev/null +++ b/Module/Tests/Fixtures/DnsWorkflowServer.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +// A loopback DNS peer exercises the actual packaged cmdlets without public DNS dependencies. +public sealed class DnsWorkflowServer : IDisposable { + private readonly UdpClient socket = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + private readonly ConcurrentQueue queries = new ConcurrentQueue(); + private readonly ConcurrentBag replies = new ConcurrentBag(); + private readonly Task receiver; + private volatile bool stopped; + + public int Port { get { return ((IPEndPoint)socket.Client.LocalEndPoint).Port; } } + public string Endpoint { get { return "udp@127.0.0.1:" + Port; } } + public string[] Queries { get { return queries.ToArray(); } } + public string Address { get; set; } + + public DnsWorkflowServer() { + Address = "192.0.2.10"; + socket.Client.ReceiveTimeout = 100; + receiver = Task.Run((Action)Receive); + } + + private void Receive() { + while (!stopped) { + try { + IPEndPoint sender = null; + byte[] query = socket.Receive(ref sender); + int offset = 12; + string name = ReadName(query, ref offset); + int type = query[offset] * 256 + query[offset + 1]; + int questionEnd = offset + 4; + queries.Enqueue(name); + if (name.EndsWith("cancel.test", StringComparison.OrdinalIgnoreCase) + && !name.StartsWith("_services.", StringComparison.OrdinalIgnoreCase)) continue; + IPEndPoint replyTarget = sender; + replies.Add(Task.Run(() => Reply(query, questionEnd, name, type, replyTarget))); + } catch (SocketException) { + if (stopped) return; + } catch (ObjectDisposedException) { return; } + } + } + + private void Reply(byte[] query, int questionEnd, string name, int type, IPEndPoint sender) { + if (name == "slow.test") Thread.Sleep(300); + if (stopped) return; + var records = new List(); + bool failed = name == "fail.test"; + if (!failed && !name.EndsWith("empty.test", StringComparison.OrdinalIgnoreCase)) { + if (type == 12) { + if (name.StartsWith("_services.", StringComparison.OrdinalIgnoreCase)) { + string domain = name.Substring("_services._dns-sd._udp.".Length); + records.Add(Record(type, Name("_http._tcp." + domain))); + records.Add(Record(type, Name("_ssh._tcp." + domain))); + } else { + records.Add(Record(type, Name("Console." + name))); + } + } else if (type == 33) { + using (var data = new MemoryStream()) { + Write16(data, 0); Write16(data, 0); Write16(data, name.Contains("_ssh.") ? 22 : 8080); + byte[] target = Name("target.test"); data.Write(target, 0, target.Length); + records.Add(Record(type, data.ToArray())); + } + } else if (type == 16) { + byte[] text = Encoding.ASCII.GetBytes("path=/status"); + var data = new byte[text.Length + 1]; data[0] = (byte)text.Length; + Array.Copy(text, 0, data, 1, text.Length); + records.Add(Record(type, data)); + } else if (type == 1) { + records.Add(Record(type, IPAddress.Parse(Address).GetAddressBytes())); + if (name == "rows.test") records.Add(Record(type, IPAddress.Parse("192.0.2.11").GetAddressBytes())); + } else if (type == 28) { + records.Add(Record(type, IPAddress.Parse("2001:db8::10").GetAddressBytes())); + } + } + using (var response = new MemoryStream()) { + response.WriteByte(query[0]); response.WriteByte(query[1]); + response.WriteByte(0x81); response.WriteByte(failed ? (byte)0x82 : (byte)0x80); + Write16(response, 1); Write16(response, records.Count); Write16(response, 0); Write16(response, 0); + response.Write(query, 12, questionEnd - 12); + foreach (byte[] record in records) response.Write(record, 0, record.Length); + byte[] bytes = response.ToArray(); + try { socket.Send(bytes, bytes.Length, sender); } + catch (ObjectDisposedException) { } + catch (SocketException) { if (!stopped) throw; } + } + } + + private static byte[] Record(int type, byte[] data) { + using (var stream = new MemoryStream()) { + Write16(stream, 0xc00c); Write16(stream, type); Write16(stream, 1); + stream.Write(new byte[] { 0, 0, 0, 120 }, 0, 4); + Write16(stream, data.Length); stream.Write(data, 0, data.Length); + return stream.ToArray(); + } + } + + private static string ReadName(byte[] packet, ref int offset) { + var parts = new List(); + while (packet[offset] != 0) { + int length = packet[offset++]; + parts.Add(Encoding.ASCII.GetString(packet, offset, length)); offset += length; + } + offset++; + return string.Join(".", parts); + } + + private static byte[] Name(string name) { + using (var stream = new MemoryStream()) { + foreach (string part in name.Split('.')) { + byte[] bytes = Encoding.ASCII.GetBytes(part); stream.WriteByte((byte)bytes.Length); + stream.Write(bytes, 0, bytes.Length); + } + stream.WriteByte(0); return stream.ToArray(); + } + } + + private static void Write16(Stream stream, int value) { + stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); + } + + public void Dispose() { + stopped = true; + socket.Close(); + receiver.GetAwaiter().GetResult(); + Task.WaitAll(replies.ToArray()); + socket.Dispose(); + } +} diff --git a/Module/Tests/InvokeDnsUpdate.Tests.ps1 b/Module/Tests/InvokeDnsUpdate.Tests.ps1 index 1b9348f9..e9be893b 100644 --- a/Module/Tests/InvokeDnsUpdate.Tests.ps1 +++ b/Module/Tests/InvokeDnsUpdate.Tests.ps1 @@ -9,6 +9,20 @@ Describe 'Invoke-DnsUpdate cmdlet' { { Invoke-DnsUpdate -Zone 'example.com' -Server '127.0.0.1' -Name 'www' -Type A -Data '1.1.1.1' -Port 1 -ErrorAction Stop } | Should -Throw } + It 'previews additions and both deletion modes without contacting the server' { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + $port = $listener.LocalEndpoint.Port + foreach ($operation in @(@{}, @{ Delete = $true }, @{ DeleteValue = $true })) { + $output = @(Invoke-DnsUpdate -Zone example.test -Server 127.0.0.1 -Port $port -Name www -Type A -Data 192.0.2.10 @operation -WhatIf -ErrorAction Stop) + $output.Count | Should -Be 0 + } + $listener.Pending() | Should -BeFalse + (Get-Command Invoke-DnsUpdate).Parameters.Keys | Should -Contain 'Confirm' + } finally { $listener.Stop() } + } + It 'Allows zero Ttl and rejects negative values at binding' { $ttl = (Get-Command Invoke-DnsUpdate).Parameters['Ttl'] $range = $ttl.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } diff --git a/Module/Tests/ResolveDns.Tests.ps1 b/Module/Tests/ResolveDns.Tests.ps1 index e9481667..466b5c9d 100644 --- a/Module/Tests/ResolveDns.Tests.ps1 +++ b/Module/Tests/ResolveDns.Tests.ps1 @@ -14,32 +14,4 @@ Describe 'Resolve-Dns cmdlet' { $result.Count | Should -Be 1 } - It 'Returns typed records when requested' { - $answer = [DnsClientX.DnsAnswer]@{ - Type = [DnsClientX.DnsRecordType]::A - DataRaw = '127.0.0.1' - } - $typed = [DnsClientX.DnsRecordFactory]::Create($answer) - $typed | Should -Not -BeNullOrEmpty - } - - It 'Enumerates typed answers as individual objects' { - $ans1 = [DnsClientX.DnsAnswer]@{ - Type = [DnsClientX.DnsRecordType]::TXT - DataRaw = 'foo=bar' - } - $ans2 = [DnsClientX.DnsAnswer]@{ - Type = [DnsClientX.DnsRecordType]::TXT - DataRaw = 'v=spf1 -all' - } - $response = [DnsClientX.DnsResponse]@{ - Answers = @($ans1, $ans2) - } - $Values = $response.Answers | ForEach-Object { - [DnsClientX.DnsRecordFactory]::Create($_, $true) - } - $types = $Values | ForEach-Object { $_.GetType().Name } - $types | Should -Contain 'KeyValueTxtRecord' - $types | Should -Contain 'SpfRecord' - } } diff --git a/Module/Tests/ResolveDnsBinding.Tests.ps1 b/Module/Tests/ResolveDnsBinding.Tests.ps1 index 3b2268ed..1fef1609 100644 --- a/Module/Tests/ResolveDnsBinding.Tests.ps1 +++ b/Module/Tests/ResolveDnsBinding.Tests.ps1 @@ -100,22 +100,33 @@ Describe 'Resolve-Dns binding' { Should -Throw -ExceptionType ([System.Management.Automation.PSArgumentException]) -ExpectedMessage '*Multi-resolver options*' } - It 'requires an explicit multi-resolver source for strategy and cache options' { - { Resolve-Dns example.com TXT -ResponseCache -TimeOut 0 -ErrorAction Stop } | + It 'requires an explicit multi-resolver source for endpoint strategy options' { + { Resolve-Dns example.com TXT -PerEndpointMaxInFlight 2 -TimeOut 0 -ErrorAction Stop } | Should -Throw -ExceptionType ([System.Management.Automation.PSArgumentException]) -ExpectedMessage '*Multi-resolver options*' { Resolve-Dns example.com TXT -ResolverStrategy FastestWins -TimeOut 0 -ErrorAction Stop } | Should -Throw -ExceptionType ([System.Management.Automation.PSArgumentException]) -ExpectedMessage '*Multi-resolver options*' - { Resolve-Dns -Name 'example.com' -ResolverSelectionPath 'unused.json' -ResponseCache -TimeOut 0 -ErrorAction Stop } | + { Resolve-Dns -Name 'example.com' -ResolverSelectionPath 'unused.json' -MaxParallelism 2 -TimeOut 0 -ErrorAction Stop } | Should -Throw -ExceptionType ([System.Management.Automation.PSArgumentException]) -ExpectedMessage '*Multi-resolver options*' } It 'rejects multi-resolver options for a single built-in provider' { - { Resolve-Dns example.com TXT -DnsProvider Cloudflare -ResponseCache -TimeOut 0 -ErrorAction Stop } | + { Resolve-Dns example.com TXT -DnsProvider Cloudflare -PerEndpointMaxInFlight 2 -TimeOut 0 -ErrorAction Stop } | Should -Throw -ExceptionType ([System.Management.Automation.PSArgumentException]) -ExpectedMessage '*Multi-resolver options*' { Resolve-Dns example.com TXT -DnsProvider Cloudflare -ResolverStrategy FastestWins -TimeOut 0 -ErrorAction Stop } | Should -Throw -ExceptionType ([System.Management.Automation.PSArgumentException]) -ExpectedMessage '*Multi-resolver options*' } + It 'accepts response caching for ' -TestCases @( + @{ Case = 'DefaultResolver'; Source = @{} } + @{ Case = 'SingleProvider'; Source = @{ DnsProvider = 'Cloudflare' } } + @{ Case = 'ExplicitServer'; Source = @{ Server = '127.0.0.1' } } + @{ Case = 'SavedSelection'; Source = @{ ResolverSelectionPath = 'unused.json' } } + ) { + param($Source) + { Resolve-Dns example.com TXT @Source -ResponseCache -MaxCacheTtlSeconds 60 -TimeOut 0 -ErrorAction Stop } | + Should -Throw -ExceptionType ([System.ArgumentOutOfRangeException]) -ExpectedMessage '*TimeOutMilliseconds*' + } + It 'accepts multi-resolver options when the source uses multi-resolver execution: ' -TestCases @( @{ Case = 'MultipleProviders'; Source = @{ DnsProvider = @('Cloudflare', 'Google') } } @{ Case = 'ExplicitResolverProvider'; Source = @{ ResolverDnsProvider = 'Cloudflare' } } diff --git a/Module/en-US/DnsClientX-help.xml b/Module/en-US/DnsClientX-help.xml index c07c8a5c..8f2c3c2f 100644 --- a/Module/en-US/DnsClientX-help.xml +++ b/Module/en-US/DnsClientX-help.xml @@ -2,57 +2,1829 @@ - Clear-DnsMultiResolverCache + Clear-DnsCache Clear - DnsMultiResolverCache + DnsCache - 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. + 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. - 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. + 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 - - All + Clear-DnsCache + + + Clear-DnsCache + + Name - Clear all cached fastest-endpoint choices. + 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. + + String + + String + + + None + + + Type + + Only remove this query type. Omit to remove every cached type for Name. + + DnsRecordType + + 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 + + + DnsRecordType + + + None + + + + + + 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. + + String + + String + + + None + + + Type + + Only remove this query type. Omit to remove every cached type for Name. + + DnsRecordType + + 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 + + + DnsRecordType + + + None + + + + + + System.String + + + + + + + System.Int32 + + + + + + + + + + + EXAMPLE 1 + Clear-DnsCache + + Clear all cached responses + + + + EXAMPLE 2 + Clear-DnsCache -Name example.com -Type A + + Remove cached A responses for one name + + + + EXAMPLE 3 + Clear-DnsCache -Name example.com -WhatIf + + Preview selective invalidation + + + + + + + + Clear-DnsMultiResolverCache + Clear + DnsMultiResolverCache + + 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. + + + + 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. + + + + Clear-DnsMultiResolverCache + + All + + Clear all cached fastest-endpoint choices. + + SwitchParameter + + SwitchParameter + + + None + + + + Clear-DnsMultiResolverCache + + ResolverEndpoint + + Specific endpoints to clear, e.g. "1.1.1.1:53", "[2606:4700:4700::1111]:53", or DoH URLs. + + String[] + + String[] + + + None + + + + Clear-DnsMultiResolverCache + + ResolverDnsProvider + + Clear cache entries for the specified predefined providers (DnsEndpoint enum). + + DnsEndpoint[] + + 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 + + + DnsEndpoint[] + + + None + + + + Clear-DnsMultiResolverCache + + DnsProvider + + Alternate parameter: providers to clear when using the classic -DnsProvider parameter style. + + DnsEndpoint[] + + 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 + + + DnsEndpoint[] + + + None + + + + + + All + + Clear all cached fastest-endpoint choices. + + SwitchParameter + + SwitchParameter + + + None + + + DnsProvider + + Alternate parameter: providers to clear when using the classic -DnsProvider parameter style. + + DnsEndpoint[] + + 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 + + + DnsEndpoint[] + + + None + + + ResolverDnsProvider + + Clear cache entries for the specified predefined providers (DnsEndpoint enum). + + DnsEndpoint[] + + 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 + + + DnsEndpoint[] + + + None + + + ResolverEndpoint + + Specific endpoints to clear, e.g. "1.1.1.1:53", "[2606:4700:4700::1111]:53", or DoH URLs. + + String[] + + String[] + + + None + + + + + + None + + + + + + + + + + + + EXAMPLE 1 + Clear-DnsMultiResolverCache + + Clears the entire FastestWins cache. + + + + EXAMPLE 2 + Clear-DnsMultiResolverCache -ResolverDnsProvider Cloudflare,Google + + Clears cache entries only for the specified provider set. + + + + EXAMPLE 3 + Clear-DnsMultiResolverCache -ResolverEndpoint '1.1.1.1:53','https://dns.google/dns-query' + + Clears cache entries only for the specified endpoints. + + + + + + + + Compare-DnsResolverSnapshot + Compare + DnsResolverSnapshot + + 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. + + + + 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-DnsResolverSnapshot + + AuthoritativeReference + + Caller-supplied authoritative responses for name/type comparison context. These must assert the AA flag; response origin remains the caller's responsibility. + + ResolverAnswerReference[] + + ResolverAnswerReference[] + + + None + + + BaselinePath + + Baseline resolver score snapshot JSON path. + + String + + String + + + None + + + CurrentPath + + Current resolver score snapshot JSON path. + + String + + String + + + None + + + + Compare-DnsResolverSnapshot + + AuthoritativeReference + + Caller-supplied authoritative responses for name/type comparison context. These must assert the AA flag; response origin remains the caller's responsibility. + + ResolverAnswerReference[] + + ResolverAnswerReference[] + + + None + + + Baseline + + Baseline typed resolver score snapshot. + + ResolverScoreSnapshot + + ResolverScoreSnapshot + + + None + + + Current + + Current typed resolver score snapshot, supplied directly or through the pipeline. + + ResolverScoreSnapshot + + ResolverScoreSnapshot + + + None + + + + + + AuthoritativeReference + + Caller-supplied authoritative responses for name/type comparison context. These must assert the AA flag; response origin remains the caller's responsibility. + + ResolverAnswerReference[] + + ResolverAnswerReference[] + + + None + + + Baseline + + Baseline typed resolver score snapshot. + + ResolverScoreSnapshot + + ResolverScoreSnapshot + + + None + + + BaselinePath + + Baseline resolver score snapshot JSON path. + + String + + String + + + None + + + Current + + Current typed resolver score snapshot, supplied directly or through the pipeline. + + ResolverScoreSnapshot + + ResolverScoreSnapshot + + + None + + + CurrentPath + + Current resolver score snapshot JSON path. + + String + + String + + + None + + + + + + DnsClientX.ResolverScoreSnapshot + + + + + + + DnsClientX.ResolverScoreComparison + + + + + + + + + + + EXAMPLE 1 + Compare-DnsResolverSnapshot -BaselinePath './before.json' -CurrentPath './after.json' + + Compare two saved probe or benchmark snapshots + + + + + + + + ConvertFrom-DnsStamp + ConvertFrom + DnsStamp + + Parses a DNS stamp into a resolver endpoint description. + Converts a supported sdns:// DNS stamp into the same endpoint model used by DnsClientX resolver workflows. This command does not perform a DNS query. + + + + Parses a DNS stamp into a resolver endpoint description. + Converts a supported sdns:// DNS stamp into the same endpoint model used by DnsClientX resolver workflows. This command does not perform a DNS query. + + + + ConvertFrom-DnsStamp + + Stamp + + DNS stamp to parse. + + String + + String + + + None + + + + + + Stamp + + DNS stamp to parse. + + String + + String + + + None + + + + + + System.String + + + + + + + DnsClientX.DnsStampInfo + + + + + + + + + + + EXAMPLE 1 + ConvertFrom-DnsStamp -Stamp 'sdns://AgUAAAAAAAAABzEuMS4xLjEAGm1vemlsbGEuY2xvdWRmbGFyZS1kbnMuY29tCi9kbnMtcXVlcnk' + + Parse a DNS-over-HTTPS stamp + + + + + + + + Find-DnsService + Find + DnsService + + Discovers services advertised via DNS Service Discovery. + 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. + + + + Discovers services advertised via DNS Service Discovery. + 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-DnsService + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String + + String + + + None + + + DnsProvider + + Built-in resolver used for discovery. Defaults to the system resolver. + + DnsEndpoint + + 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 + + + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 + + + None + + + + Find-DnsService + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String + + String + + + None + + + DnsProvider + + Built-in resolver used for discovery. Defaults to the system resolver. + + DnsEndpoint + + 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 + + + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + ServiceTypes + + List fully qualified service types from the DNS-SD meta-query. + + SwitchParameter + + SwitchParameter + + + None + + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 + + + None + + + + Find-DnsService + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String + + String + + + None + + + DnsProvider + + Built-in resolver used for discovery. Defaults to the system resolver. + + DnsEndpoint + + 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 + + + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + Protocol + + Service transport label, such as tcp or udp. + + String + + tcp + udp + _tcp + _udp + + + String + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + Service + + Service label to browse, such as http or _http. + + String + + String + + + None + + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 + + + None + + + + + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String + + String + + + None + + + DnsProvider + + Built-in resolver used for discovery. Defaults to the system resolver. + + DnsEndpoint + + 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 + + + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + Protocol + + Service transport label, such as tcp or udp. + + String + + tcp + udp + _tcp + _udp + + + String + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + Service + + Service label to browse, such as http or _http. + + String + + String + + + None + + + ServiceTypes + + List fully qualified service types from the DNS-SD meta-query. + + SwitchParameter + + SwitchParameter + + + None + + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 + + + None + + + + + + System.String + + + + + + + DnsClientX.DnsService + + + + + System.String + + + + + + + + + + + EXAMPLE 1 + Find-DnsService -Domain example.com + + Find services under a domain + + + + EXAMPLE 2 + Find-DnsService -Domain example.com | Where-Object {$_.ServiceType -like '_http._tcp.*'} + + Filter for a specific service type + + + + EXAMPLE 3 + Find-DnsService -Domain example.com -ServiceTypes -ResolverEndpoint 'tcp@192.0.2.53:53' + + List advertised service types through a specific resolver + + + + EXAMPLE 4 + Find-DnsService -Domain example.com -Service http -Protocol tcp + + Browse only HTTP instances + + + + + + + + Get-DnsCache + Get + DnsCache + + 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. + + + + 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-DnsCache + + + + + + + None + + + + + + + DnsClientX.DnsResponseCacheStatistics + + + + + + + + + + + EXAMPLE 1 + Get-DnsCache + + Inspect the response cache used by Resolve-Dns + + + + + + + + Get-DnsQueryExplanation + Get + DnsQueryExplanation + + 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. + + + + 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-DnsQueryExplanation + + AuditTrail + + Optional captured audit entries for the supplied response. Omit this parameter when individual attempt evidence was not captured. + + AuditEntry[] + + AuditEntry[] + + + None + + + InputObject + + Completed DNS response, supplied directly or through the pipeline. + + DnsResponse + + DnsResponse + + + None + + + + + + AuditTrail + + Optional captured audit entries for the supplied response. Omit this parameter when individual attempt evidence was not captured. + + AuditEntry[] + + AuditEntry[] + + + None + + + InputObject + + Completed DNS response, supplied directly or through the pipeline. + + DnsResponse + + DnsResponse + + + None + + + + + + DnsClientX.DnsResponse + + + + + + + DnsClientX.DnsQueryExplanation + + + + + + + + + + + EXAMPLE 1 + Resolve-Dns example.com -FullResponse | Get-DnsQueryExplanation + + Explain a response returned by Resolve-Dns + + + + + + + + Get-DnsResolverSelection + Get + DnsResolverSelection + + Loads a saved resolver score snapshot and returns the recommended resolver selection. + Reads a persisted resolver score snapshot, applies the shared recommendation logic, and returns either the structured selection object or the raw target string for automation use. + + + + Loads a saved resolver score snapshot and returns the recommended resolver selection. + Reads a persisted resolver score snapshot, applies the shared recommendation logic, and returns either the structured selection object or the raw target string for automation use. + + + + Get-DnsResolverSelection + + AsString + + Emits only the raw selected target string instead of the structured selection object. + + SwitchParameter + + SwitchParameter + + + None + + + Path + + Path to the saved resolver score snapshot. + + String + + String + + + None + + + + + + AsString + + Emits only the raw selected target string instead of the structured selection object. + + SwitchParameter + + SwitchParameter + + + None + + + Path + + Path to the saved resolver score snapshot. + + String + + String + + + None + + + + + + None + + + + + + + DnsClientX.ResolverSelectionResult + + + + + System.String + + + + + + + + + + + EXAMPLE 1 + Get-DnsResolverSelection -Path '.\resolver-score.json' + + Get the recommended resolver selection as an object + + + + EXAMPLE 2 + Get-DnsResolverSelection -Path '.\resolver-score.json' -AsString + + Get only the raw selected target string for scripting + + + + + + + + Get-DnsService + Get + DnsService + + Retrieves services advertised via DNS Service Discovery. + 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. + + + + Retrieves services advertised via DNS Service Discovery. + 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-DnsService + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String + + String + + + None + + + DnsProvider + + Built-in resolver used for discovery. Defaults to the system resolver. + + DnsEndpoint + + 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 + + + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + TimeOut + + Per-query timeout in milliseconds. - SwitchParameter + Int32 - SwitchParameter + Int32 None - - Clear-DnsMultiResolverCache - - ResolverEndpoint + + Get-DnsService + + BootstrapResolver - Specific endpoints to clear, e.g. "1.1.1.1:53", "[2606:4700:4700::1111]:53", or DoH URLs. + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. - String[] + String - String[] + String None - - - Clear-DnsMultiResolverCache - - ResolverDnsProvider + + DnsProvider - Clear cache entries for the specified predefined providers (DnsEndpoint enum). + Built-in resolver used for discovery. Defaults to the system resolver. - DnsEndpoint[] + DnsEndpoint System SystemTcp @@ -87,20 +1859,110 @@ Custom - DnsEndpoint[] + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + Protocol + + Service transport label, such as tcp or udp. + + String + + tcp + udp + _tcp + _udp + + + String + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + Service + + Service label to browse or resolve, such as http or _http. + + String + + String + + + None + + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 None - - Clear-DnsMultiResolverCache - + + Get-DnsService + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String + + String + + + None + + DnsProvider - Alternate parameter: providers to clear when using the classic -DnsProvider parameter style. + Built-in resolver used for discovery. Defaults to the system resolver. - DnsEndpoint[] + DnsEndpoint System SystemTcp @@ -135,77 +1997,134 @@ Custom - DnsEndpoint[] + DnsEndpoint + + + None + + + Domain + + Domain name to search for advertised services. + + String + + String + + + None + + + Protocol + + Service transport label, such as tcp or udp. + + String + + tcp + udp + _tcp + _udp + + + String + + + None + + + Resolve + + Resolve SRV targets in RFC 2782 connection-attempt order instead of browsing DNS-SD instances. + + SwitchParameter + + SwitchParameter + + + None + + + ResolveHosts + + With Resolve, include A and AAAA addresses for each target host. + + SwitchParameter + + SwitchParameter + + + None + + + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + Service + + Service label to browse or resolve, such as http or _http. + + String + + String + + + None + + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 None - - - - All - - Clear all cached fastest-endpoint choices. - - SwitchParameter - - SwitchParameter - - - None - - - DnsProvider - - Alternate parameter: providers to clear when using the classic -DnsProvider parameter style. - - DnsEndpoint[] - - 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 - + + + + BootstrapResolver + + IP-literal UDP/TCP resolver used to bootstrap endpoint hostnames. + + String - DnsEndpoint[] + String None - - ResolverDnsProvider + + DnsProvider - Clear cache entries for the specified predefined providers (DnsEndpoint enum). + Built-in resolver used for discovery. Defaults to the system resolver. - DnsEndpoint[] + DnsEndpoint System SystemTcp @@ -240,170 +2159,12 @@ Custom - DnsEndpoint[] - - - None - - - ResolverEndpoint - - Specific endpoints to clear, e.g. "1.1.1.1:53", "[2606:4700:4700::1111]:53", or DoH URLs. - - String[] - - String[] - - - None - - - - - - None - - - - - - - - - - - - EXAMPLE 1 - Clear-DnsMultiResolverCache - - Clears the entire FastestWins cache. - - - - EXAMPLE 2 - Clear-DnsMultiResolverCache -ResolverDnsProvider Cloudflare,Google - - Clears cache entries only for the specified provider set. - - - - EXAMPLE 3 - Clear-DnsMultiResolverCache -ResolverEndpoint '1.1.1.1:53','https://dns.google/dns-query' - - Clears cache entries only for the specified endpoints. - - - - - - - - ConvertFrom-DnsStamp - ConvertFrom - DnsStamp - - Parses a DNS stamp into a resolver endpoint description. - Converts a supported sdns:// DNS stamp into the same endpoint model used by DnsClientX resolver workflows. This command does not perform a DNS query. - - - - Parses a DNS stamp into a resolver endpoint description. - Converts a supported sdns:// DNS stamp into the same endpoint model used by DnsClientX resolver workflows. This command does not perform a DNS query. - - - - ConvertFrom-DnsStamp - - Stamp - - DNS stamp to parse. - - String - - String - - - None - - - - - - Stamp - - DNS stamp to parse. - - String - - String + DnsEndpoint None - - - - - System.String - - - - - - - DnsClientX.DnsStampInfo - - - - - - - - - - - EXAMPLE 1 - ConvertFrom-DnsStamp -Stamp 'sdns://AgUAAAAAAAAABzEuMS4xLjEAGm1vemlsbGEuY2xvdWRmbGFyZS1kbnMuY29tCi9kbnMtcXVlcnk' - - Parse a DNS-over-HTTPS stamp - - - - - - - - Find-DnsService - Find - DnsService - - Discovers services advertised via DNS Service Discovery. - Wraps DiscoverServices to return services under a domain. - - - - Discovers services advertised via DNS Service Discovery. - Wraps DiscoverServices to return services under a domain. - - - - Find-DnsService - - Domain - - Domain name to search for advertised services. - - String - - String - - - None - - - - - + Domain Domain name to search for advertised services. @@ -415,86 +2176,40 @@ None - - - + + Protocol + + Service transport label, such as tcp or udp. + + String + + tcp + udp + _tcp + _udp + - None - - - - - - - - - - - - EXAMPLE 1 - Find-DnsService -Domain example.com - - Find services under a domain - - - - EXAMPLE 2 - Find-DnsService -Domain example.com | Where-Object {$_.ServiceType -like '_http._tcp.*'} - - Filter for a specific service type - - - - - - - - Get-DnsResolverSelection - Get - DnsResolverSelection - - Loads a saved resolver score snapshot and returns the recommended resolver selection. - Reads a persisted resolver score snapshot, applies the shared recommendation logic, and returns either the structured selection object or the raw target string for automation use. - - - - Loads a saved resolver score snapshot and returns the recommended resolver selection. - Reads a persisted resolver score snapshot, applies the shared recommendation logic, and returns either the structured selection object or the raw target string for automation use. - - - - Get-DnsResolverSelection - - AsString - - Emits only the raw selected target string instead of the structured selection object. - - SwitchParameter - - SwitchParameter - - - None - - - Path - - Path to the saved resolver score snapshot. - - String - - String - - - None - - - - + String + + + None + + + Resolve + + Resolve SRV targets in RFC 2782 connection-attempt order instead of browsing DNS-SD instances. + + SwitchParameter + + SwitchParameter + + + None + - AsString + ResolveHosts - Emits only the raw selected target string instead of the structured selection object. + With Resolve, include A and AAAA addresses for each target host. SwitchParameter @@ -503,10 +2218,34 @@ None - - Path + + ResolverEndpoint - Path to the saved resolver score snapshot. + 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. + + String + + String + + + None + + + ResolverSelectionPath + + Saved resolver score snapshot whose recommended resolver is reused for discovery. + + String + + String + + + None + + + Service + + Service label to browse or resolve, such as http or _http. String @@ -515,23 +2254,35 @@ None + + TimeOut + + Per-query timeout in milliseconds. + + Int32 + + Int32 + + + None + - None + System.String - DnsClientX.ResolverSelectionResult + DnsClientX.DnsService - System.String + DnsClientX.DnsSrvRecord @@ -543,92 +2294,30 @@ EXAMPLE 1 - Get-DnsResolverSelection -Path '.\resolver-score.json' + Get-DnsService -Domain example.com - Get the recommended resolver selection as an object + Discover services under a domain EXAMPLE 2 - Get-DnsResolverSelection -Path '.\resolver-score.json' -AsString + Get-DnsService -Domain example.com | Select-Object ServiceName,Target,Port - Get only the raw selected target string for scripting + Pipe results to select useful fields - - - - - - Get-DnsService - Get - DnsService - - 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. - - - - 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. - - - - Get-DnsService - - Domain - - Domain name to search for advertised services. - - String - - String - - - None - - - - - - Domain - - Domain name to search for advertised services. - - String - - String - - - None - - - - - - None - - - - - - - - - - - EXAMPLE 1 - Get-DnsService -Domain example.com + EXAMPLE 3 + Get-DnsService -Domain example.com -Service http -ResolverEndpoint 'https://dns.google/dns-query' - Discover services under a domain + Browse HTTP service instances through an explicit resolver - EXAMPLE 2 - Get-DnsService -Domain example.com | Select-Object ServiceName,Target,Port + EXAMPLE 4 + Get-DnsService -Domain example.com -Service ldap -Resolve -ResolveHosts - Pipe results to select useful fields + Resolve LDAP targets and addresses in connection-attempt order @@ -1032,11 +2721,13 @@ 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. 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. @@ -1494,12 +3185,14 @@ 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. 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. @@ -1553,6 +3246,18 @@ None + + CompletionOrder + + With Stream, emit responses in completion order. Omit this switch to preserve record type order, then name order. + + SwitchParameter + + SwitchParameter + + + None + DnsProvider @@ -1681,7 +3386,7 @@ 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). SwitchParameter @@ -1763,10 +3468,10 @@ None - + Name - The name of the DNS record to query for + DNS names to query. Accepts pipeline strings and Name, Domain, or DnsName properties. String[] @@ -1778,8 +3483,8 @@ ParseTypedTxtRecords - Gets or sets a value indicating whether TXT records should be -parsed into specialized types (DMARC, SPF, etc.) when TypedRecords is + Gets or sets a value indicating whether TXT records should be +parsed into specialized types (DMARC, SPF, etc.) when TypedRecords is specified. When false, returns simple TXT records. SwitchParameter @@ -2019,7 +3724,7 @@ specified. When false, returns simple TXT records. 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. SwitchParameter @@ -2065,6 +3770,18 @@ specified. When false, returns simple TXT records. None + + 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. + + SwitchParameter + + SwitchParameter + + + None + TimeOut @@ -2170,6 +3887,18 @@ specified. When false, returns simple TXT records. None + + TypedRecordMetadata + + Return typed answer values together with their owner, TTL, class and original record. This implies TypedRecords; FullResponse takes precedence. + + SwitchParameter + + SwitchParameter + + + None + TypedRecords @@ -2270,6 +3999,18 @@ specified. When false, returns simple TXT records. None + + CompletionOrder + + With Stream, emit responses in completion order. Omit this switch to preserve record type order, then name order. + + SwitchParameter + + SwitchParameter + + + None + DnsProvider @@ -2398,7 +4139,7 @@ specified. When false, returns simple TXT records. 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). SwitchParameter @@ -2483,8 +4224,8 @@ specified. When false, returns simple TXT records. ParseTypedTxtRecords - Gets or sets a value indicating whether TXT records should be -parsed into specialized types (DMARC, SPF, etc.) when TypedRecords is + Gets or sets a value indicating whether TXT records should be +parsed into specialized types (DMARC, SPF, etc.) when TypedRecords is specified. When false, returns simple TXT records. SwitchParameter @@ -2736,7 +4477,7 @@ specified. When false, returns simple TXT records. 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. SwitchParameter @@ -2782,6 +4523,18 @@ specified. When false, returns simple TXT records. None + + 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. + + SwitchParameter + + SwitchParameter + + + None + TimeOut @@ -2887,6 +4640,18 @@ specified. When false, returns simple TXT records. None + + TypedRecordMetadata + + Return typed answer values together with their owner, TTL, class and original record. This implies TypedRecords; FullResponse takes precedence. + + SwitchParameter + + SwitchParameter + + + None + TypedRecords @@ -2987,6 +4752,18 @@ specified. When false, returns simple TXT records. None + + CompletionOrder + + With Stream, emit responses in completion order. Omit this switch to preserve record type order, then name order. + + SwitchParameter + + SwitchParameter + + + None + DnsProvider @@ -3115,7 +4892,7 @@ specified. When false, returns simple TXT records. 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). SwitchParameter @@ -3197,10 +4974,10 @@ specified. When false, returns simple TXT records. None - + Name - The name of the DNS record to query for + DNS names to query. Accepts pipeline strings and Name, Domain, or DnsName properties. String[] @@ -3212,8 +4989,8 @@ specified. When false, returns simple TXT records. ParseTypedTxtRecords - Gets or sets a value indicating whether TXT records should be -parsed into specialized types (DMARC, SPF, etc.) when TypedRecords is + Gets or sets a value indicating whether TXT records should be +parsed into specialized types (DMARC, SPF, etc.) when TypedRecords is specified. When false, returns simple TXT records. SwitchParameter @@ -3465,7 +5242,7 @@ specified. When false, returns simple TXT records. 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. SwitchParameter @@ -3511,6 +5288,18 @@ specified. When false, returns simple TXT records. None + + 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. + + SwitchParameter + + SwitchParameter + + + None + TimeOut @@ -3616,6 +5405,18 @@ specified. When false, returns simple TXT records. None + + TypedRecordMetadata + + Return typed answer values together with their owner, TTL, class and original record. This implies TypedRecords; FullResponse takes precedence. + + SwitchParameter + + SwitchParameter + + + None + TypedRecords @@ -3668,11 +5469,27 @@ specified. When false, returns simple TXT records. - None + System.String[] - + + + + DnsClientX.DnsAnswerMinimal + + + + + DnsClientX.DnsResponse + + + + + DnsClientX.DnsTypedRecord[System.Object] + + + @@ -3756,6 +5573,27 @@ specified. When false, returns simple TXT records. Reuse the recommended resolver from a saved selection snapshot + + EXAMPLE 12 + 'example.com','example.net' | Resolve-Dns -Type A | Where-Object Type -EQ A + + Resolve names from a pipeline and filter individual answers + + + + EXAMPLE 13 + Resolve-Dns -Name 'example.com','example.net' -Type A,AAAA -Stream -CompletionOrder -MaxConcurrency 4 + + Stream a bounded batch in completion order + + + + EXAMPLE 14 + Resolve-Dns -Name 'example.com' -Type MX -TypedRecordMetadata | Select-Object Name,TTL,Value + + Keep typed payloads together with their owner and TTL + + @@ -3878,6 +5716,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected benchmark thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -4158,6 +6008,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected benchmark thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -4474,6 +6336,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected benchmark thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -4811,6 +6685,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected benchmark thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -5182,6 +7068,223 @@ specified. When false, returns simple TXT records. + + + Test-DnsHealth + Test + DnsHealth + + 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. + + + + 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-DnsHealth + + DnsSecVerifierPath + + Local optional DNSSEC verifier DLL with dependencies beside it. + + String + + String + + + None + + + FailOnPolicyViolation + + Write a PolicyViolation error on failed health gates. Use ErrorAction Stop for enforcement; the default only warns. + + SwitchParameter + + SwitchParameter + + + None + + + IncludeBenchmark + + Benchmark the same DNS names, types and resolver targets after the health queries. + + SwitchParameter + + SwitchParameter + + + None + + + Path + + Local JSON health profile path. + + String + + String + + + None + + + + Test-DnsHealth + + DnsSecVerifierPath + + Local optional DNSSEC verifier DLL with dependencies beside it. + + String + + String + + + None + + + FailOnPolicyViolation + + Write a PolicyViolation error on failed health gates. Use ErrorAction Stop for enforcement; the default only warns. + + SwitchParameter + + SwitchParameter + + + None + + + IncludeBenchmark + + Benchmark the same DNS names, types and resolver targets after the health queries. + + SwitchParameter + + SwitchParameter + + + None + + + Profile + + Typed health profile supplied directly or through the pipeline. + + ResolverHealthProfile + + ResolverHealthProfile + + + None + + + + + + DnsSecVerifierPath + + Local optional DNSSEC verifier DLL with dependencies beside it. + + String + + String + + + None + + + FailOnPolicyViolation + + Write a PolicyViolation error on failed health gates. Use ErrorAction Stop for enforcement; the default only warns. + + SwitchParameter + + SwitchParameter + + + None + + + IncludeBenchmark + + Benchmark the same DNS names, types and resolver targets after the health queries. + + SwitchParameter + + SwitchParameter + + + None + + + Path + + Local JSON health profile path. + + String + + String + + + None + + + Profile + + Typed health profile supplied directly or through the pipeline. + + ResolverHealthProfile + + ResolverHealthProfile + + + None + + + + + + DnsClientX.ResolverHealthProfile + + + + + + + DnsClientX.ResolverHealthReport + + + + + + + + + + + EXAMPLE 1 + Test-DnsHealth -Path './dns-health.json' -FailOnPolicyViolation -ErrorAction Stop + + Run a saved profile and fail an automation run when its policy fails + + + + EXAMPLE 2 + Test-DnsHealth -Path './dns-health.json' -IncludeBenchmark + + Include performance measurements for the same profile + + + + + Test-DnsProbe @@ -5268,6 +7371,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected probe thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -5532,6 +7647,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected probe thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -5832,6 +7959,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected probe thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary @@ -6153,6 +8292,18 @@ specified. When false, returns simple TXT records. None + + FailOnPolicyViolation + + Write a PolicyViolation error when the selected probe thresholds fail. Use ErrorAction Stop to fail an automation run; the default behavior only warns. + + SwitchParameter + + SwitchParameter + + + None + IncludeSummary diff --git a/PLAN.md b/PLAN.md index 0ddfe87a..dacbe42d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,283 +1,64 @@ -# PLAN +# DnsClientX engineering plan -## Purpose +DnsClientX provides one DNS engine for C#, PowerShell and command-line users. The core owns protocol handling, record parsing, validation, caching and resolver operations. The surfaces expose that behavior without maintaining separate query engines. -This roadmap tracks the next library-aligned work for DnsClientX after the recent cleanup wave. +## Maintained capabilities -The project identity remains: +- UDP with TCP fallback, TCP, TLS, HTTPS wire and JSON, HTTP/2, HTTP/3, QUIC and gRPC transports, with runtime capability reporting. Modern transports depend on the target runtime and native platform support. +- Typed records with owner, class, remaining TTL and raw record metadata; unknown payloads retain an explicit representation. +- Local DNSSEC validation, authenticated denial proofs, iterative root resolution, QNAME minimization and RFC 5011 trust-anchor lifecycle management. Portable ECDSA and EdDSA verification remains an optional package. +- Persistent TCP/DoT reuse, response correlation, cancellation and bounded recovery. Exact-policy response cache single-flight distinguishes network, coalesced and cached results. +- System resolver discovery and supported Windows NRPT routing, explicit bootstrap resolvers, DNS stamps, EDNS NSID, padding, cookies and client subnet. +- AXFR, IXFR, transfer over TLS, ZONEMD validation, authenticated updates and DNS service discovery. +- Resolver catalog validation, probe/benchmark policies, saved score snapshots and resolver selection/reuse. +- Bounded local health profiles, scope-aware snapshot comparisons and structured query explanations. -- library first -- cross-platform core -- dependency-light main package -- CLI and PowerShell surfaces built on the same engine -- advanced DNS protocol features without drifting into unrelated network product areas +## Priorities 1–3: implemented contracts -## Principles +These contracts provide the foundation for usable C#, PowerShell and CLI workflows: -### Keep The Core Focused +- [x] Bound negative cache entries by SOA TTL/MINIMUM, aliases and denial proofs; age public TTLs while preserving receipt-time wire evidence. +- [x] Bound streaming admission and completed results; cancel and drain admitted work when the consumer stops, while preserving independent single-flight waiters. +- [x] Make transport capability reports complete and reject unsupported legacy gRPC and unknown transport values consistently. +- [x] Emit individual PowerShell answer/service rows, bind pipeline names, propagate cancellation and honor update confirmation before network activity. +- [x] Keep JSON CLI output parseable with explanation/trace, expose complete probe/benchmark reports and support direct custom resolver queries. +- [x] Add reusable client query options, typed metadata projections, ordered/completion-order streams and process-wide cache management. +- [x] Reuse DNSSEC material only after accepted validation, bound it by TTL/proof expiry and revalidate it for each answer. +- [x] Add repeatable health profiles, comparisons with explicit endpoint scope limitations and caller-supplied authoritative reference responses. +- [x] Preserve tested endpoint settings in saved recommendations; report ambiguous execution evidence instead of inferring a reusable endpoint. -The main package should stay centered on DNS resolution, DNS transport handling, diagnostics, DNSSEC, updates, zone transfer workflows, resolver selection, and protocol parsing. +Protocol fixtures and loopback peers establish controlled correctness. They do not establish independent resolver interoperability, native QUIC availability on every platform, public package publication or universal performance. -### Prefer Additive Depth Over Product Sprawl +## Release and qualification gates -The best next features deepen the resolver, parser, diagnostics, CLI, and automation story. Avoid turning the main package into a proxy, desktop security suite, operating system manager, or broad network-control product. +Before distributing a release, validate the actual core and optional-provider NuGet packages through isolated local-feed consumers, import the actual PowerShell package in fresh processes, and exercise CLI archives on their native architectures. The package matrix covers Windows PowerShell 5.1, PowerShell 7.4 and modern PowerShell on Windows, Linux and macOS. Runtime transport reports must describe unsupported paths clearly. -### Protect The No-Dependency Baseline +The optional DNSSEC provider must be published alongside the core through the coordinated release flow before consumers can rely on its public NuGet availability. Source integration, local packages, public NuGet/PSGallery packages and installed user versions are separate delivery states. CLI archives use the shared PowerForge publish configuration for x64 and ARM64; native execution qualifies only the tested host/RID. -Modern targets should remain dependency-free where practical. If a feature needs specialized dependencies, unusual crypto, a different support contract, or a much larger maintenance surface, it should move into an optional package. +## Next priorities -### Keep Runtime-Native Modern Transports In Core +### Independent protocol qualification -DoH3 and DoQ belong in the core package when the runtime provides the transport support without extra package weight. Older targets should report clear unsupported behavior instead of pulling in compatibility stacks. +- [ ] Expand repeatable interoperability against independently produced DNS messages and resolvers for signed aliases, compact denial, transfers, updates and service discovery. +- [ ] Qualify DoQ and HTTP/3 on supported Windows/Linux/macOS native runtimes, including certificate errors, timeout/cancellation and connection reuse; record unavailable platform paths. +- [ ] Exercise IPv6, split DNS and platform search-domain routing on representative hosts without treating controlled fixtures as native policy proof. +- [ ] Measure warm DNSSEC material reuse and bounded streaming with the existing controlled load runner. Separate allocations, throughput and network latency; publish only reproducible scenario claims. -### Build Through Existing Surfaces +### Diagnostic depth -New capabilities should use the current architecture: +- [ ] Record a complete ordered iterative delegation/trust graph before claiming that explanations show a full resolution or DNSSEC chain. +- [ ] Add resolver-selection explanations that bind the chosen endpoint to persisted score, policy and capability evidence. +- [ ] Design profile overrides for CLI users only when their precedence can remain explicit and portable. Typed C#/PowerShell profiles can already be modified before execution. -- core library for protocol, parsing, resolver, and diagnostics behavior -- CLI for operator workflows and scripting -- PowerShell for automation and administration +### DNS-only policy -## Completed Cleanup Wave +- [ ] Define a deterministic, offline-testable rule model for domain blocking, static answers and resolver selection. +- [ ] Keep one canonical policy evaluator behind library, CLI and PowerShell explain workflows. +- [ ] Keep packet manipulation, browser traffic control, proxy orchestration and OS startup management outside this library. -The previous roadmap items below are now implemented and should be treated as maintained surfaces rather than future work: +### Optional protocol packages -- CLI query output modes: `pretty`, `json`, `raw`, and `short` -- CLI section toggles for question, answer, authority, and additional output -- reverse lookup shortcut support in the CLI -- TXT concatenation output support -- EDNS padding and cookie option support -- recursive AXFR convenience flow in the CLI -- explicit resolver endpoint syntax shared by library, CLI, and PowerShell workflows -- resolver import from files and URLs for probe and benchmark workflows -- persisted probe and benchmark score snapshots -- resolver selection and resolver reuse from saved score snapshots -- runtime transport capability reporting -- DNS stamp parsing and generation for plain DNS, DoH, DoT, and DoQ endpoint models -- no-network DNS stamp inspection in the CLI and PowerShell -- no-network resolver catalog validation in the CLI and PowerShell -- resolver score snapshot schema versioning and future-version compatibility checks -- release sanity coverage for version alignment and CLI help/README parity +- [ ] Assess DNSCrypt and complete ODoH interoperability as separately reviewed protocol work, including dependency approval and independent fixtures. +- [ ] Add server hosting or rule packages only for demonstrated consumers with a distinct support and dependency contract. -## Current Direction - -The project already has strong foundations: - -- broad DNS transport support -- DNSSEC and root validation work -- typed records -- AXFR and dynamic updates -- multi-resolver strategies -- probe, benchmark, scoring, and resolver reuse workflows -- cross-platform targeting - -The next phase should focus on protocol completeness, sharper resolver operations, and a small DNS-only policy model. - -## Active 2.x Engineering Plan - -The current end-to-end goal is deliberately ordered so correctness and measurement land before deeper protocol work: - -- [x] Stabilize public behavior with authoritative-divergence fixtures, an adversarial parser corpus, and claims tied to reproducible evidence -- [x] Maintain parser/cache microbenchmarks plus a separate controlled concurrency runner for 1/32/128-query scenarios -- [x] Implement RFC 7766 persistent TCP and DoT connection reuse, pipelining, response correlation, cancellation, and bounded recovery -- [x] Add exact-key cache single-flight and explicit cache/network provenance without crossing configuration or DNSSEC policy boundaries -- [x] Discover Windows NRPT rules and route only matching namespaces through validated policy-aware resolvers, with explicit unsupported/error states -- [x] Add IXFR, DNS zone transfer over TLS, and ZONEMD validation with bounded transfer and integrity contracts -- [x] Add QNAME minimization and RFC 5011 trust-anchor lifecycle management to iterative DNSSEC resolution -- [x] Put Ed25519/Ed448 DNSSEC verification in a separately versioned optional package so the dependency-light core remains unchanged - -Each capability must include focused contracts, cross-target validation, honest CLI/PowerShell/docs exposure where applicable, packaged-artifact proof, and consumer validation before release. Network benchmarks are environment evidence, not universal performance claims. - -## Roadmap - -## Phase 1: Protocol Completeness - -Goal: improve protocol coverage in the core package without adding dependency pressure. - -### Scope - -- cleaner NSID request and response convenience paths -- stronger validation for EDNS option combinations and public request models -- more focused tests around fully-qualified names, root labels, and wire serialization edge cases - -### Primary File Areas - -- `DnsClientX/Edns/*` -- `DnsClientX/EdnsOptions.cs` -- `DnsClientX/DnsMessageOptions.cs` -- `DnsClientX/ResolveDnsRequest.cs` -- `DnsClientX/EndpointParser.cs` -- `DnsClientX/ProtocolDnsWire/DnsMessage.cs` -- `DnsClientX.Tests/*Edns*` -- `DnsClientX.Tests/*EndpointParser*` - -### Acceptance Criteria - -- supported DNS stamps remain round-trippable through the same `DnsResolverEndpoint` model as explicit endpoint syntax -- NSID can be requested through reusable request models without hand-building EDNS options -- wire-format tests cover trailing-dot and root-label edge cases - -## Phase 2: Resolver Operations - -Goal: make resolver selection workflows more repeatable and operator-friendly. - -### Scope - -- bootstrap resolver control for hostname-based DoH, DoT, DoH3, DoQ, and gRPC endpoints -- resolver catalog import as a first-class library API, not only CLI plumbing -- score snapshot migration guardrails for any future schema changes -- optional resolver health profile files for repeated probe and benchmark runs -- richer explain output for why a resolver was selected or rejected - -### Primary File Areas - -- `DnsClientX/EndpointParser.cs` -- `DnsClientX/ResolverExecutionTargetResolver.cs` -- `DnsClientX/ResolverScoreStore.cs` -- `DnsClientX/ResolverProbe*` -- `DnsClientX/ResolverBenchmark*` -- `DnsClientX.Cli/Program.cs` -- `DnsClientX.PowerShell/*Benchmark*` -- `DnsClientX.PowerShell/*Probe*` - -### Acceptance Criteria - -- hostname-based transports can use an explicit bootstrap path when needed -- resolver catalog loading and validation are reusable from library, CLI, and PowerShell -- future score snapshot schema changes have migration or compatibility guardrails -- explain output names policy, score, health, and capability factors - -## Phase 3: DNS-Only Policy Layer - -Goal: add domain-aware behavior while staying inside the DNS domain. - -### Initial Scope - -- block by domain -- static answer override by domain -- custom resolver selection by domain -- rule explain output -- testable policy decisions independent from network calls - -### Scope Guardrails - -This layer should not include: - -- proxy orchestration -- packet fragmentation -- SNI rewriting -- browser or system traffic manipulation -- OS startup management - -### Primary File Areas - -- new policy-focused files under `DnsClientX/` -- `DnsClientX.QueryDnsRequest.cs` -- `DnsClientX.Cli/Program.cs` -- `DnsClientX.PowerShell/*` -- dedicated policy tests under `DnsClientX.Tests/` - -### Acceptance Criteria - -- policy decisions are deterministic and explainable -- rules can be evaluated without performing network I/O -- public APIs stay DNS-focused and do not imply system-wide traffic control - -## Phase 4: Optional Packages - -Goal: leave room for valuable higher-complexity protocols without weighing down the main package. - -### Best Candidates - -- `DnsClientX.DnsCrypt` -- `DnsClientX.Odoh` -- `DnsClientX.Rules` -- `DnsClientX.Server` - -### Packaging Policy - -- keep dependency-free DoH3 and DoQ support in the core package on modern targets -- do not add compatibility dependencies just to backport modern transports to older targets -- create optional packages when a feature needs specialized dependencies, crypto, server hosting, or a materially different maintenance contract - -## Concrete Backlog - -### Core - -- expose NSID convenience options through `ResolveDnsRequest` -- add bootstrap resolver configuration to request and endpoint models -- add migration guardrails when score snapshot schemas change -- design a DNS-only rule model for block, override, and resolver selection - -### CLI - -- expose bootstrap resolver controls for explicit endpoints -- add explain output for resolver score and policy decisions -- keep help text and README feature lists aligned - -### PowerShell - -- expose bootstrap resolver controls -- add parameter coverage for NSID and stamp workflows -- add rule explain support once the policy model exists - -### Diagnostics And Testing - -- maintain release sanity tests for version alignment -- maintain CLI help and README parity checks for documented switches -- add parser tests for supported and unsupported stamps -- maintain snapshot schema compatibility tests -- add policy engine tests before public policy APIs are finalized - -## Recommended Next 3 PRs - -### PR 1: Bootstrap Resolver Control - -Goal: - -- let hostname-based transports use explicit bootstrap resolver behavior where needed - -Acceptance criteria: - -- bootstrap settings are available from reusable request models -- CLI and PowerShell expose consistent parameter names -- explain output shows when bootstrap behavior was used - -### PR 2: NSID Request Convenience - -Goal: - -- expose NSID as a first-class request option instead of requiring hand-built EDNS options - -Acceptance criteria: - -- reusable request models can ask for NSID -- CLI and PowerShell expose matching NSID switches or parameters -- response parsing and output surfaces include returned NSID data when present - -### PR 3: Resolver Health Profiles - -Goal: - -- make repeated probe and benchmark runs easier to configure and compare - -Acceptance criteria: - -- profile files can define resolver catalogs, domains, record types, and policy thresholds -- CLI and PowerShell can load a profile and still override selected fields -- reports include enough profile metadata to compare runs over time - -## Success Criteria - -The roadmap is succeeding if: - -- the main package remains dependency-light -- new features build on existing abstractions -- CLI and PowerShell stay useful for diagnostics and automation -- protocol coverage improves without product sprawl -- policy features remain DNS-focused -- optional packages absorb higher-complexity protocols when needed - -## Summary - -DnsClientX should deepen along its current axis: protocol completeness, diagnostics, resolver workflows, and eventually DNS-only policy. The main package should avoid becoming a broader network-control product. +DoH3 and DoQ stay in core where the runtime supplies them. Specialized crypto and hosting dependencies belong in optional owners. A new protocol name or green synthetic test alone does not make a feature supported. diff --git a/README.md b/README.md index bce5e773..fc0044fb 100644 --- a/README.md +++ b/README.md @@ -128,23 +128,23 @@ For name, mail, routing, and authority records such as NS, CNAME, MX, SOA, and S - No external dependencies - DoH3 and DoQ stay in the core package with no added NuGet transport dependencies - **.NET Standard 2.0** (Cross-platform compatibility) - - System.Text.Json (10.0.11) - - Microsoft.Bcl.AsyncInterfaces (10.0.11) + - System.Text.Json (10.0.12) + - Microsoft.Bcl.AsyncInterfaces (10.0.12) - **.NET Framework 4.7.2** (Windows only) - System.Net.Http (built-in) - - System.Text.Json (10.0.11) - - Microsoft.Bcl.AsyncInterfaces (10.0.11) + - System.Text.Json (10.0.12) + - Microsoft.Bcl.AsyncInterfaces (10.0.12) - Modern transports such as DoH3 and DoQ are exposed by the shared API surface but return unsupported at runtime ### Command Line Interface (DnsClientX.exe) - **.NET 8.0 / .NET 10.0** (Windows, Linux, macOS) - **.NET Framework 4.7.2** (Windows only) -- Single-file deployment supported +- Framework-dependent single-file deployment for x64 and ARM64 on Windows, Linux, and macOS; install the matching .NET 8 runtime +- Build distributable archives through `Invoke-DotNetPublish -ConfigPath ./Build/cli.publish.json` in PSPublishModule ### PowerShell Module -- **.NET 8.0** (Cross-platform) -- **.NET Standard 2.0** (Windows PowerShell 5.1+ compatibility) -- **.NET Framework 4.7.2** (Windows PowerShell 5.1) +- **PowerShell 7.4 or later** on Windows, Linux, and macOS uses the .NET 8 build +- **Windows PowerShell 5.1** uses the .NET Framework 4.7.2 build - PowerShellStandard.Library (5.1.1) ### Examples Project @@ -206,6 +206,7 @@ DnsClientX.Cli --capabilities --format json Saved probe and benchmark snapshots also persist runtime capability hints, so recommendation files can distinguish unsupported modern transports from ordinary resolver failures. Snapshots include a schema version, and resolver selection rejects snapshots from newer unsupported schemas before attempting reuse. +Saved recommendations preserve explicit endpoint settings, including TLS name, address family, timeout, EDNS and request format. When a display label represents several different configurations, the snapshot retains the observations and reports that automatic reuse and scope comparison are unavailable. ## Explicit Endpoint Syntax @@ -263,6 +264,40 @@ DnsClientX.Cli --resolver-select .\probe.json DnsClientX.Cli --resolver-use .\probe.json --short example.com ``` +Direct resolver queries and structured JSON diagnostics: + +```powershell +DnsClientX.Cli --resolver tcp@1.1.1.1:53 --format json --explain example.com +DnsClientX.Cli --probe --probe-endpoint udp@1.1.1.1:53 --format json example.com +DnsClientX.Cli --benchmark --probe-endpoint tcp@1.1.1.1:53 --format json example.com +``` + +JSON stdout contains one complete document, including structured explanation or trace when requested. Endpoint inputs require an explicit `--probe` or `--benchmark` for those modes; ordinary queries can use `--resolver`. Explanations show observed resolver, cache, bootstrap, aliases, attempts and local DNSSEC dependencies. They identify unavailable evidence and do not infer a complete trust chain or delegation traversal. + +Repeatable health profiles define local, versioned query catalogs and policies: + +```json +{ + "SchemaVersion": 1, + "Name": "public-dns-health", + "ResolverEndpoints": ["udp@1.1.1.1:53", "tcp@8.8.8.8:53"], + "Names": ["example.com", "example.net"], + "RecordTypes": ["A", "AAAA"], + "TimeoutMs": 2000, + "MaxConcurrency": 4, + "ProbePolicy": { "MinSuccessCount": 1, "MinSuccessPercent": 50 } +} +``` + +```powershell +DnsClientX.Cli --profile ./dns-health.json --benchmark --profile-save ./health.json --format json +Test-DnsHealth ./dns-health.json -IncludeBenchmark -FailOnPolicyViolation -ErrorAction Stop +DnsClientX.Cli --resolver-compare ./before.json ./after.json --format json +Compare-DnsResolverSnapshot ./before.json ./after.json +``` + +Profiles are bounded local JSON documents. Settings come from the profile; C# and PowerShell can modify the typed profile before running it. Comparisons use probe/benchmark score snapshots, report candidate and answer changes, and require matching host, runtime, query catalog and policy scope for health or latency conclusions. Older snapshots remain readable but may lack the comparison context. Resolver agreement is evidence of consistency. For authoritative comparison, supply explicit caller-owned authoritative responses; a majority answer does not establish correctness. + DNS stamp inspection without querying DNS: ```powershell @@ -442,7 +477,7 @@ This direct DNS client does not delegate name resolution to the operating-system > If you would like to help, please do so by opening an issue or a pull request. > The public API may change while protocol coverage and RFC conformance continue to mature. -- [ ] Complete the concrete stabilization, transport, cache, system-policy, zone, and DNSSEC work tracked in [PLAN.md](PLAN.md) +- [ ] Extend independent protocol interoperability and platform qualification as tracked in [PLAN.md](PLAN.md) - [ ] Build specialized cryptography and privacy protocols as separately reviewed optional packages; keep the core dependency-light ## Usage in .NET @@ -479,6 +514,39 @@ var responses = await client.Resolve("google.com", recordTypes); responses.DisplayTable(); ``` +### Reusable options and streaming + +Keep resolver configuration on the client and query behavior in `DnsResolveOptions`: + +```csharp +using var client = new ClientX(DnsEndpoint.Cloudflare); +client.EndpointConfiguration.MaxConcurrency = 8; +var options = new DnsResolveOptions { TypedRecords = true, ValidateDnsSec = true }; +await foreach (var response in client.ResolveStreaming( + new[] { "example.com", "example.net" }, new[] { DnsRecordType.A, DnsRecordType.AAAA }, options)) { + foreach (var record in response.GetTypedAnswers()) + Console.WriteLine($"{record.Name}: {record.Value.Address} TTL={record.TTL}"); +} +``` + +`Resolve(name, type, options)` and `Resolve(names, types, options)` share this behavior. `ResolveStreaming` defaults to input order; set `preserveOrder: false` for completion order. `ResolveAsyncEnumerable` also yields in completion order. These streams admit at most `MaxConcurrency` queries, or 32 when unset, and cancel and drain admitted work when the consumer stops. `ResolveStream` retains its sequential behavior. Static `ClientX.QueryDnsStreaming(request)` accepts the same `ResolveDnsRequest` as `QueryDns`, reuses clients, and streams in record type then name order by default. + +Locally accepted DNSSEC material is reused by the same client and revalidated for each response. Reuse expires at the earliest material TTL or authenticated proof deadline. Bogus and indeterminate validations cannot populate that cache. Set `DnsSecMaterialCacheEnabled = false` to disable it, or call `ClearDnsSecMaterialCache()`. Iterative root traversal keeps its separate validation session. + +### PowerShell pipeline and service workflows + +```powershell +Import-Module DnsClientX +'example.com', 'example.net' | Resolve-Dns -Type A -Stream +[pscustomobject]@{ Domain = 'example.com' } | Resolve-Dns -Type MX -TypedRecords -TypedRecordMetadata +Resolve-Dns example.com -FullResponse | Get-DnsQueryExplanation +Find-DnsService example.com -Service http -Protocol tcp +Get-DnsService example.com -Service http -Resolve -ResolveHosts +Invoke-DnsUpdate -Server 192.0.2.53 -Zone example.com -Name host.example.com -Type A -Data 192.0.2.10 -WhatIf +``` + +`Resolve-Dns` emits one answer row per record. `-TypedRecords` emits parsed payloads; `-TypedRecordMetadata` adds owner and TTL metadata. `-FullResponse` emits response envelopes, including status and diagnostics when no answers exist. Names bind by value or through `Name`, `Domain`, and `DnsName` properties. `-Stream` preserves input order; `-CompletionOrder` opts into completion order. DNS service discovery emits individual rows, supports explicit resolvers, and observes pipeline cancellation. Probe, benchmark and health commands warn on failed policy by default; add `-FailOnPolicyViolation -ErrorAction Stop` to enforce a gate in automation. + ### Client Configuration #### Using ClientXBuilder @@ -590,7 +658,9 @@ var opts = new MultiResolverOptions { }; ``` -Positive answers use the lowest authoritative answer TTL. Negative `NoError`/`NXDomain` answers use the RFC 2308 minimum of the SOA TTL and SOA MINIMUM field. Responses without an authoritative cache lifetime are not cached; `MaxCacheTtl` can shorten, but never extend, that lifetime. +Positive answers use the lowest answer TTL, including aliases. Negative `NoError`/`NXDomain` answers require an SOA and use the RFC 2308 minimum of the SOA TTL and SOA MINIMUM field, bounded further by aliases and denial proofs. Cached answer, authority, and additional TTLs decrease with elapsed time; original wire bytes remain receipt-time evidence. `MaxCacheTtl` can shorten a lifetime. Responses without a usable lifetime are not cached. + +The response cache is shared by cache-enabled clients in the process. `ClientX.GetResponseCacheStatistics()`, `ClientX.ResponseCacheCapacity`, `ClientX.ClearResponseCache()`, and `ClientX.InvalidateResponseCache(name, type)` expose its management. Entries remain separated by resolver, transport and DNSSEC policy. Statistics count internal lookups, including single-flight checks; they are not query counts. PowerShell exposes `Get-DnsCache` and `Clear-DnsCache [-Name example.com] [-Type A]`. The reusable `ResolveDnsRequest` and `Resolve-Dns` cache options work with single and multiple resolvers. - PowerShell: @@ -658,6 +728,16 @@ foreach (var typedAnswer in response.TypedAnswers!) { } ``` +Use metadata-preserving projections when the owner, TTL, class or raw RDATA matters. They parse the current records, so a cache hit carries its remaining TTL: + +```csharp +foreach (var record in response.GetTypedAnswers()) { + Console.WriteLine($"{record.Name} TTL={record.TTL}: {record.Value.Exchange}"); +} +``` + +`GetTypedAuthorities()` and `GetTypedAdditional()` provide the same envelope for other sections. `GetTypedAnswers()` includes every parsed payload, including `UnknownRecord` for malformed or unsupported data. Existing `TypedAnswers` payload arrays remain available. + **Available Typed Record Classes:** | Record Type | Typed Class | Key Properties | @@ -1632,15 +1712,15 @@ DnsClientX includes a command-line interface for quick DNS queries and scripting ### Installation -The CLI is distributed as `DnsClientX.exe` and can be built from source or downloaded from releases. +The CLI executable is `DnsClientX.Cli.exe` on Windows and `DnsClientX.Cli` on Linux and macOS. Build it from source with the commands below. Platform archives produced from `Build/cli.publish.json` contain the same executable and require a matching .NET 8 runtime. The Bash examples use `DnsClientX.Cli`; Windows users use `DnsClientX.Cli.exe`. From an extracted folder, prefix the name with `./` on Linux/macOS or `.\` on Windows. ```bash # Build from source dotnet build DnsClientX.Cli/DnsClientX.Cli.csproj -c Release # The executable will be available at: -# DnsClientX.Cli/bin/Release/net8.0/DnsClientX.exe (Windows) -# DnsClientX.Cli/bin/Release/net8.0/DnsClientX (Linux/macOS) +# DnsClientX.Cli/bin/Release/net8.0/DnsClientX.Cli.exe (Windows) +# DnsClientX.Cli/bin/Release/net8.0/DnsClientX.Cli (Linux/macOS) # Or build the entire solution dotnet build DnsClientX.sln -c Release @@ -1650,40 +1730,40 @@ dotnet build DnsClientX.sln -c Release ```bash # Simple A record query using the default System endpoint -DnsClientX.exe google.com +DnsClientX.Cli google.com # Query with a specific built-in endpoint -DnsClientX.exe --endpoint Cloudflare google.com +DnsClientX.Cli --endpoint Cloudflare google.com # Query a specific record type -DnsClientX.exe --type MX google.com +DnsClientX.Cli --type MX google.com # Explain which resolver/transport path was used -DnsClientX.exe --endpoint Cloudflare --type A --explain google.com +DnsClientX.Cli --endpoint Cloudflare --type A --explain google.com # Include per-attempt diagnostics -DnsClientX.exe --endpoint Cloudflare --type A --trace google.com +DnsClientX.Cli --endpoint Cloudflare --type A --trace google.com ``` ### Probe and Benchmark ```bash # Probe a built-in endpoint family -DnsClientX.exe --probe --endpoint Cloudflare example.com +DnsClientX.Cli --probe --endpoint Cloudflare example.com # Probe custom endpoints -DnsClientX.exe --probe --probe-endpoint udp@1.1.1.1:53,tcp@9.9.9.9:53 example.com +DnsClientX.Cli --probe --probe-endpoint udp@1.1.1.1:53,tcp@9.9.9.9:53 example.com # Fail when responders disagree -DnsClientX.exe --probe --probe-endpoint udp@1.1.1.1:53,tcp@9.9.9.9:53 \ +DnsClientX.Cli --probe --probe-endpoint udp@1.1.1.1:53,tcp@9.9.9.9:53 \ --probe-require-consensus example.com # Benchmark built-in endpoints across repeated queries -DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ +DnsClientX.Cli --benchmark --endpoint Cloudflare,Google,Quad9 \ --domain example.com --type A --benchmark-attempts 5 # Benchmark a custom endpoint matrix across domains and record types -DnsClientX.exe --benchmark \ +DnsClientX.Cli --benchmark \ --probe-endpoint udp@1.1.1.1:53,tcp@9.9.9.9:53 \ --domain example.com,microsoft.com \ --type A,AAAA \ @@ -1691,7 +1771,7 @@ DnsClientX.exe --benchmark \ --benchmark-concurrency 4 # Add benchmark policy gates for automation -DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ +DnsClientX.Cli --benchmark --endpoint Cloudflare,Google,Quad9 \ --domain example.com \ --type A \ --benchmark-attempts 3 \ @@ -1700,7 +1780,7 @@ DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ --benchmark-summary-line # Keep CI logs compact while still emitting a machine-readable summary line -DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ +DnsClientX.Cli --benchmark --endpoint Cloudflare,Google,Quad9 \ --domain example.com \ --type A \ --benchmark-attempts 3 \ @@ -1712,13 +1792,13 @@ DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ ```bash # Request and validate DNSSEC data -DnsClientX.exe --endpoint Cloudflare --dnssec --validate-dnssec google.com +DnsClientX.Cli --endpoint Cloudflare --dnssec --validate-dnssec google.com # Use DoH wire POST when supported -DnsClientX.exe --endpoint CloudflareWireFormatPost --wire-post google.com +DnsClientX.Cli --endpoint CloudflareWireFormatPost --wire-post google.com # Send a dynamic DNS update to an explicit authoritative server -DnsClientX.exe --probe-endpoint tcp@192.0.2.53:53 \ +DnsClientX.Cli --probe-endpoint tcp@192.0.2.53:53 \ --update example.com www A 192.0.2.10 --ttl 300 ``` @@ -1728,7 +1808,7 @@ DnsClientX.exe --probe-endpoint tcp@192.0.2.53:53 \ ```bash #!/bin/bash # Benchmark several providers and capture the one-line machine-readable summary -DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ +DnsClientX.Cli --benchmark --endpoint Cloudflare,Google,Quad9 \ --domain example.com \ --type A \ --benchmark-attempts 3 \ @@ -1737,7 +1817,7 @@ DnsClientX.exe --benchmark --endpoint Cloudflare,Google,Quad9 \ ```powershell # PowerShell script for quick probe comparison -& DnsClientX.exe --probe ` +& DnsClientX.Cli.exe --probe ` --probe-endpoint 'udp@1.1.1.1:53,tcp@9.9.9.9:53' ` --probe-summary-line ` example.com From e0a2fefcedab21a6864427a5a9df4e70976ace55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 13 Oct 2026 01:38:38 +0200 Subject: [PATCH 2/5] Correct package qualification runners --- .github/workflows/qualify-cli.yml | 8 ++++++++ .github/workflows/test-powershell.yml | 23 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/qualify-cli.yml b/.github/workflows/qualify-cli.yml index 76a18d65..69d6d94a 100644 --- a/.github/workflows/qualify-cli.yml +++ b/.github/workflows/qualify-cli.yml @@ -25,6 +25,14 @@ jobs: 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: | diff --git a/.github/workflows/test-powershell.yml b/.github/workflows/test-powershell.yml index 67e8fa26..e600cba3 100644 --- a/.github/workflows/test-powershell.yml +++ b/.github/workflows/test-powershell.yml @@ -60,12 +60,20 @@ jobs: name: unsigned-packages path: candidate - name: Install Pester - shell: ${{ matrix.shell }} + shell: pwsh run: | - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-Module Pester -MinimumVersion 5.7.1 -Force -Scope CurrentUser -SkipPublisherCheck -AllowClobber + $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: ${{ matrix.shell }} + shell: pwsh run: | $archive = @(Get-ChildItem candidate/Packed -Filter 'DnsClientX-PowerShellModule.*.zip') if ($archive.Count -ne 1) { throw 'Expected one module archive.' } @@ -73,7 +81,12 @@ jobs: 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.' } - ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml" + 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: From b68cecea4dfefce2a207ffec6db93b3be61da881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 13 Oct 2026 01:49:26 +0200 Subject: [PATCH 3/5] Validate resolver profiles and harden workflow fixtures --- DnsClientX.Cli/Program.Arguments.cs | 6 ++ DnsClientX.Cli/Program.ProfileOperations.cs | 36 +++++--- DnsClientX.Cli/Program.Query.cs | 3 +- DnsClientX.Cli/Program.ResolverOperations.cs | 8 +- DnsClientX.Cli/Program.Validation.cs | 8 +- DnsClientX.Tests/CliDnsUpdateTests.cs | 23 +++++ .../ResolverHealthWorkflowTests.cs | 87 +++++++++++++++++++ DnsClientX/ResolverHealthProfile.cs | 81 ++++++++++++----- DnsClientX/ResolverHealthWorkflow.cs | 5 +- Module/Tests/DnsWorkflowFixture.Tests.ps1 | 65 ++++++++++++++ Module/Tests/Fixtures/DnsWorkflowServer.cs | 27 ++++-- PLAN.md | 1 + README.md | 2 + 13 files changed, 310 insertions(+), 42 deletions(-) create mode 100644 Module/Tests/DnsWorkflowFixture.Tests.ps1 diff --git a/DnsClientX.Cli/Program.Arguments.cs b/DnsClientX.Cli/Program.Arguments.cs index a56d8470..7a14ab0d 100644 --- a/DnsClientX.Cli/Program.Arguments.cs +++ b/DnsClientX.Cli/Program.Arguments.cs @@ -428,6 +428,12 @@ private static bool TryParseArgs(string[] args, out CliOptions? options, out str 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: diff --git a/DnsClientX.Cli/Program.ProfileOperations.cs b/DnsClientX.Cli/Program.ProfileOperations.cs index 91584866..ae5c9411 100644 --- a/DnsClientX.Cli/Program.ProfileOperations.cs +++ b/DnsClientX.Cli/Program.ProfileOperations.cs @@ -31,15 +31,24 @@ private static async Task RunProfileAsync(CliOptions options, CancellationT 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 { + 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); + foreach (ResolverProbeReportResult result in query.Results) { + WriteProbeResult(result); + } + } + if (report.Benchmark != null) { + foreach (ResolverBenchmarkReportResult result in report.Benchmark.Results) { + WriteBenchmarkResult(result); + } } - if (report.Benchmark != null) foreach (ResolverBenchmarkReportResult result in report.Benchmark.Results) WriteBenchmarkResult(result); WriteHumanLine($"Policy result: {(report.PolicyPassed ? "pass" : "fail")}"); WriteHumanLine(report.AnswerVariationNote); } @@ -52,12 +61,19 @@ private static int RunSnapshotComparison(CliOptions options) { 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 { + 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}"); + 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 index 9122ddeb..c420416d 100644 --- a/DnsClientX.Cli/Program.Query.cs +++ b/DnsClientX.Cli/Program.Query.cs @@ -10,8 +10,9 @@ namespace DnsClientX.Cli { internal static partial class Program { private static ResolverExecutionClientOptions CreateExecutionClientOptions(CliOptions options, bool useBenchmarkTimeout = false) { - if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath)) + 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 diff --git a/DnsClientX.Cli/Program.ResolverOperations.cs b/DnsClientX.Cli/Program.ResolverOperations.cs index b1a2fb9f..67021b55 100644 --- a/DnsClientX.Cli/Program.ResolverOperations.cs +++ b/DnsClientX.Cli/Program.ResolverOperations.cs @@ -41,7 +41,9 @@ private static async Task RunBenchmarkWithTargetsAsync( 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); + if (options.BenchmarkSavePath != null) { + SaveResolverScoreSnapshot(options.BenchmarkSavePath, jsonReport.Snapshot); + } Console.WriteLine(DnsClientXJsonSerializer.Serialize(jsonReport)); return GetBenchmarkExitCode(jsonReport.Evaluation, options); } @@ -155,7 +157,9 @@ private static async Task RunProbeWithTargetsAsync( 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); + if (options.ProbeSavePath != null) { + SaveResolverScoreSnapshot(options.ProbeSavePath, jsonReport.Snapshot); + } Console.WriteLine(DnsClientXJsonSerializer.Serialize(jsonReport)); return GetProbeExitCode(jsonReport.Evaluation, options); } diff --git a/DnsClientX.Cli/Program.Validation.cs b/DnsClientX.Cli/Program.Validation.cs index a8e58cb6..b739a1ac 100644 --- a/DnsClientX.Cli/Program.Validation.cs +++ b/DnsClientX.Cli/Program.Validation.cs @@ -11,8 +11,12 @@ 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 (!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; 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/ResolverHealthWorkflowTests.cs b/DnsClientX.Tests/ResolverHealthWorkflowTests.cs index cb5595ba..de40007b 100644 --- a/DnsClientX.Tests/ResolverHealthWorkflowTests.cs +++ b/DnsClientX.Tests/ResolverHealthWorkflowTests.cs @@ -8,6 +8,93 @@ 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() { diff --git a/DnsClientX/ResolverHealthProfile.cs b/DnsClientX/ResolverHealthProfile.cs index 031a2a77..a2ecbc4b 100644 --- a/DnsClientX/ResolverHealthProfile.cs +++ b/DnsClientX/ResolverHealthProfile.cs @@ -12,7 +12,7 @@ public sealed class ResolverHealthProfile { 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, such as udp@1.1.1.1:53. + /// 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(); @@ -41,32 +41,71 @@ public sealed class ResolverHealthProfile { /// 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."); + 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."); + 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 (endpointErrors.Count > 0) { + throw new ArgumentException(string.Join("; ", endpointErrors)); + } if (ResolverEndpoints.Length > 0) { - count = parsedEndpoints.Length; - if (count != ResolverEndpoints.Length) throw new ArgumentException("Profile resolver targets must be distinct."); - } - 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."); + 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."); + if (value < 1 || value > 100) { + throw new ArgumentException("Profile percentages must be between 1 and 100."); + } } } } diff --git a/DnsClientX/ResolverHealthWorkflow.cs b/DnsClientX/ResolverHealthWorkflow.cs index 2fd220a1..cb07065a 100644 --- a/DnsClientX/ResolverHealthWorkflow.cs +++ b/DnsClientX/ResolverHealthWorkflow.cs @@ -7,7 +7,7 @@ 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. Programmatic verifier providers remain supplied by the caller. + /// 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, @@ -20,6 +20,9 @@ public static async Task RunAsync( 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, 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