diff --git a/.github/workflows/qualify-cli.yml b/.github/workflows/qualify-cli.yml
new file mode 100644
index 00000000..69d6d94a
--- /dev/null
+++ b/.github/workflows/qualify-cli.yml
@@ -0,0 +1,84 @@
+name: Qualify CLI packages
+
+on:
+ push:
+ branches: [main]
+ paths-ignore: ['*.md', 'Docs/**', 'Examples/**', '.gitignore']
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ name: CLI (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { os: windows-latest, runtimes: 'win-x64,win-arm64' }
+ - { os: ubuntu-latest, runtimes: 'linux-x64,linux-arm64' }
+ - { os: macos-latest, runtimes: 'osx-x64,osx-arm64' }
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - name: Select the macOS SDK installation root
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ # PowerForge qualifies SDKs from the standard platform installation roots.
+ sudo mkdir -p /usr/local/share/dotnet
+ sudo chown "$(id -un)" /usr/local/share/dotnet
+ echo 'DOTNET_INSTALL_DIR=/usr/local/share/dotnet' >> "$GITHUB_ENV"
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: |
+ 8.x
+ 10.x
+ - name: Install shared publish engine
+ shell: pwsh
+ run: Install-Module PSPublishModule -MinimumVersion 3.0.151 -Force -Scope CurrentUser -AllowClobber
+ - name: Publish platform CLI candidates
+ shell: pwsh
+ run: |
+ Import-Module PSPublishModule -MinimumVersion 3.0.151 -ErrorAction Stop
+ Invoke-DotNetPublish -ConfigPath Build/cli.publish.json -Runtimes ('${{ matrix.runtimes }}'.Split(',')) -NoPublishSign -NoInteractive -ExitCode
+ - name: Smoke native archive
+ shell: pwsh
+ run: |
+ $architecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant()
+ $platform = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' }
+ $name = if ($IsWindows) { 'DnsClientX.Cli.exe' } else { 'DnsClientX.Cli' }
+ foreach ($line in Get-Content 'Artefacts/Cli/SHA256SUMS') {
+ if ($line -notmatch '^([A-Fa-f0-9]{64}) \*(.+)$') { throw 'Invalid CLI checksum catalog.' }
+ if ((Get-FileHash -LiteralPath $Matches[2] -Algorithm SHA256).Hash -ne $Matches[1]) { throw 'CLI archive checksum mismatch.' }
+ }
+ $archive = Join-Path 'Artefacts/Cli' "DnsClientX-$platform-$architecture.zip"
+ $root = Join-Path $env:RUNNER_TEMP 'cli-candidate'
+ Expand-Archive -LiteralPath $archive -DestinationPath $root
+ $executable = Join-Path $root $name
+ if (-not (Test-Path -LiteralPath $executable)) { throw 'CLI archive is missing its executable.' }
+ & $executable --help
+ if ($LASTEXITCODE) { throw 'Native CLI help failed.' }
+ $json = & $executable --capabilities --format json
+ if ($LASTEXITCODE) { throw 'Native CLI capability report failed.' }
+ $json | ConvertFrom-Json -ErrorAction Stop | Out-Null
+ Add-Type -Path ./Module/Tests/Fixtures/DnsWorkflowServer.cs
+ $peer = [DnsWorkflowServer]::new()
+ try {
+ $json = & $executable package.test --resolver $peer.Endpoint --format json --explain --trace
+ if ($LASTEXITCODE) { throw 'Packaged CLI loopback query failed.' }
+ $report = $json | ConvertFrom-Json -ErrorAction Stop
+ if ($report.Response.Status -ne 'NoError' -or $report.Response.Answer.Count -ne 1 -or $report.Response.Answer[0].data -ne '192.0.2.10' -or $report.Explanation.Transport -ne 'Udp' -or $report.Explanation.Attempts.Count -ne 1 -or $report.Explanation.Attempts[0].Port -ne $peer.Port) { throw 'Packaged CLI query report is incomplete.' }
+ } finally { $peer.Dispose() }
+ - uses: actions/upload-artifact@v7
+ with:
+ name: cli-${{ matrix.os }}
+ path: |
+ Artefacts/Cli/*.zip
+ Artefacts/Cli/manifest.json
+ Artefacts/Cli/SHA256SUMS
+ if-no-files-found: error
diff --git a/.github/workflows/test-powershell.yml b/.github/workflows/test-powershell.yml
index 54bc3927..e600cba3 100644
--- a/.github/workflows/test-powershell.yml
+++ b/.github/workflows/test-powershell.yml
@@ -2,210 +2,172 @@ name: Test PowerShell
on:
push:
- branches:
- - main
- paths-ignore:
- - '*.md'
- - 'Docs/**'
- - 'Examples/**'
- - '.gitignore'
+ branches: [main]
+ paths-ignore: ['*.md', 'Docs/**', 'Examples/**', '.gitignore']
pull_request:
- branches:
- - main
+ branches: [main]
workflow_dispatch:
permissions:
contents: read
-env:
- DOTNET_VERSION: '10.x'
- BUILD_CONFIGURATION: 'Debug'
-
jobs:
- refresh-psd1:
- name: 'Refresh PSD1'
+ package:
+ name: Build unsigned package
runs-on: windows-latest
- timeout-minutes: 10
+ timeout-minutes: 30
steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Setup PowerShell modules
+ - uses: actions/checkout@v7
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: |
+ 8.x
+ 10.x
+ - name: Install shared builder
shell: pwsh
- run: |
- Install-Module PSPublishModule -Force -Scope CurrentUser -AllowClobber
-
- - name: Refresh module manifest
+ run: Install-Module PSPublishModule -MinimumVersion 3.0.151 -Force -Scope CurrentUser -AllowClobber
+ - name: Build through native module entrypoint
shell: pwsh
run: |
- ./Module/Build/Build-Module.ps1 -RunMode Manifest -SignModule:$false
-
- - name: Upload refreshed manifest
+ $version = (Import-PowerShellDataFile ./Module/DnsClientX.psd1).ModuleVersion
+ ./Module/Build/Build-Module.ps1 -RunMode Build -SignModule:$false -UpdateVersions:$false -ModuleVersion $version -SkipInstall
+ - name: Upload module and NuGet candidates
uses: actions/upload-artifact@v7
with:
- name: psd1
- path: Module/DnsClientX.psd1
-
- test-windows-ps5:
- needs: refresh-psd1
- name: 'Windows PowerShell 5.1'
- runs-on: windows-latest
- timeout-minutes: 10
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Download manifest
- uses: actions/download-artifact@v8
- with:
- name: psd1
- path: .
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v6
- with:
- dotnet-version: ${{ env.DOTNET_VERSION }}
-
- - name: Install PowerShell modules
- shell: powershell
- run: |
- Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)"
- Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
- Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
-
- - name: Build .NET solution
- run: |
- dotnet restore DnsClientX.sln
- dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore
-
- - name: Run PowerShell tests
- shell: powershell
- run: ./Module/DnsClientX.Tests.ps1
-
- test-windows-ps7:
- needs: refresh-psd1
- name: 'Windows PowerShell 7'
- runs-on: windows-latest
- timeout-minutes: 10
+ name: unsigned-packages
+ path: |
+ Artefacts/Packed/*.zip
+ Artefacts/ProjectBuild/packages/*.nupkg
+ if-no-files-found: error
+
+ test:
+ needs: package
+ name: ${{ matrix.name }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { name: Windows PowerShell 5.1 package, os: windows-latest, shell: powershell }
+ - { name: Windows PowerShell 7 package, os: windows-latest, shell: pwsh }
+ - { name: Linux PowerShell 7 package, os: ubuntu-latest, shell: pwsh }
+ - { name: macOS PowerShell 7 package, os: macos-latest, shell: pwsh }
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Download manifest
- uses: actions/download-artifact@v8
- with:
- name: psd1
- path: .
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v6
+ - uses: actions/checkout@v7
+ - uses: actions/download-artifact@v8
with:
- dotnet-version: ${{ env.DOTNET_VERSION }}
-
- - name: Install PowerShell modules
+ name: unsigned-packages
+ path: candidate
+ - name: Install Pester
shell: pwsh
run: |
- Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)"
- Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
- Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
-
- - name: Build .NET solution
- run: |
- dotnet restore DnsClientX.sln
- dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore
-
- - name: Run PowerShell tests
+ $install = {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+ Install-Module Pester -MinimumVersion 5.7.1 -Force -Scope CurrentUser -SkipPublisherCheck -AllowClobber -ErrorAction Stop
+ }
+ if ('${{ matrix.shell }}' -eq 'powershell') {
+ & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $install
+ if ($LASTEXITCODE) { throw 'Windows PowerShell Pester installation failed.' }
+ } else {
+ & $install
+ }
+ - name: Import packaged module and run contracts
shell: pwsh
- run: ./Module/DnsClientX.Tests.ps1
+ run: |
+ $archive = @(Get-ChildItem candidate/Packed -Filter 'DnsClientX-PowerShellModule.*.zip')
+ if ($archive.Count -ne 1) { throw 'Expected one module archive.' }
+ $root = Join-Path $env:RUNNER_TEMP 'module-candidate'
+ Expand-Archive -LiteralPath $archive[0].FullName -DestinationPath $root
+ $manifest = @(Get-ChildItem $root -Filter DnsClientX.psd1 -Recurse)
+ if ($manifest.Count -ne 1) { throw 'Expected one packaged module manifest.' }
+ if ('${{ matrix.shell }}' -eq 'powershell') {
+ & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml"
+ if ($LASTEXITCODE) { throw 'Windows PowerShell package validation failed.' }
+ } else {
+ ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml"
+ }
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: pester-${{ matrix.os }}-${{ matrix.shell }}
+ path: ${{ runner.temp }}/pester.xml
- test-ubuntu:
- needs: refresh-psd1
- name: 'Ubuntu PowerShell 7'
+ test-minimum:
+ needs: package
+ name: Minimum PowerShell 7.4.0 package
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 15
steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Download manifest
- uses: actions/download-artifact@v8
+ - uses: actions/checkout@v7
+ - uses: actions/download-artifact@v8
with:
- name: psd1
- path: .
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v6
- with:
- dotnet-version: ${{ env.DOTNET_VERSION }}
-
- - name: Install PowerShell
- run: |
- curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -
- curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list | sudo tee /etc/apt/sources.list.d/microsoft.list
- sudo apt-get update
- sudo apt-get install -y powershell
-
- - name: Install PowerShell modules
+ name: unsigned-packages
+ path: candidate
+ - name: Install isolated minimum supported host
shell: pwsh
run: |
- Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)"
- Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
- Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
-
- - name: Build .NET solution
- run: |
- dotnet restore DnsClientX.sln
- dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore
-
- - name: Run PowerShell tests
+ $root = Join-Path $env:RUNNER_TEMP 'powershell-7.4.0'
+ New-Item -ItemType Directory -Path $root | Out-Null
+ $archive = Join-Path $env:RUNNER_TEMP 'powershell-7.4.0.tar.gz'
+ Invoke-WebRequest https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell-7.4.0-linux-x64.tar.gz -OutFile $archive
+ if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne 'AA82C3E9241503B52DB1827E3FD7FC099DA74F828BA032E9B726E50C72B4BEE5') { throw 'PowerShell download hash mismatch.' }
+ tar -xzf $archive -C $root
+ if ($LASTEXITCODE) { throw 'PowerShell extraction failed.' }
+ chmod +x "$root/pwsh"
+ if ($LASTEXITCODE) { throw 'PowerShell executable setup failed.' }
+ Install-Module Pester -MinimumVersion 5.7.1 -Force -Scope CurrentUser -AllowClobber
+ - name: Import package on minimum host and run contracts
shell: pwsh
- run: ./Module/DnsClientX.Tests.ps1
-
- test-macos:
- needs: refresh-psd1
- name: 'macOS PowerShell 7'
- runs-on: macos-latest
- timeout-minutes: 10
+ run: |
+ $archive = @(Get-ChildItem candidate/Packed -Filter 'DnsClientX-PowerShellModule.*.zip')
+ if ($archive.Count -ne 1) { throw 'Expected one module archive.' }
+ $root = Join-Path $env:RUNNER_TEMP 'module-candidate'
+ Expand-Archive -LiteralPath $archive[0].FullName -DestinationPath $root
+ $manifest = @(Get-ChildItem $root -Filter DnsClientX.psd1 -Recurse)
+ if ($manifest.Count -ne 1) { throw 'Expected one packaged module manifest.' }
+ & "$env:RUNNER_TEMP/powershell-7.4.0/pwsh" -NoProfile -File ./Build/Test-PackagedModule.ps1 -ModulePath $manifest[0].FullName -ResultsPath "$env:RUNNER_TEMP/pester.xml"
+ if ($LASTEXITCODE) { throw 'Minimum PowerShell contract validation failed.' }
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: pester-powershell-7.4.0
+ path: ${{ runner.temp }}/pester.xml
+
+ package-consumer:
+ needs: package
+ name: Packed NuGet consumer (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Download manifest
- uses: actions/download-artifact@v8
+ - uses: actions/checkout@v7
+ - uses: actions/download-artifact@v8
with:
- name: psd1
- path: .
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v6
+ name: unsigned-packages
+ path: candidate
+ - uses: actions/setup-dotnet@v6
with:
- dotnet-version: ${{ env.DOTNET_VERSION }}
-
- - name: Install PowerShell
- shell: bash
- run: |
- if command -v pwsh >/dev/null 2>&1; then
- pwsh --version
- exit 0
- fi
-
- brew update
- brew tap powershell/tap
- brew install --cask powershell || brew install powershell/tap/powershell
-
- - name: Install PowerShell modules
+ dotnet-version: |
+ 8.x
+ 10.x
+ - name: Consume private candidates with isolated restore
shell: pwsh
run: |
- Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)"
- Install-Module -Name Pester -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
- Install-Module -Name PSWriteColor -Repository PSGallery -Force -SkipPublisherCheck -AllowClobber
-
- - name: Build .NET solution
- run: |
- dotnet restore DnsClientX.sln
- dotnet build DnsClientX.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore
-
- - name: Run PowerShell tests
- shell: pwsh
- run: ./Module/DnsClientX.Tests.ps1
-
+ $feed = (Resolve-Path candidate/ProjectBuild/packages).Path
+ $core = @(Get-ChildItem $feed -Filter 'DnsClientX.*.nupkg' | Where-Object Name -Match '^DnsClientX\.\d')
+ $provider = @(Get-ChildItem $feed -Filter 'DnsClientX.DnsSec.EdDsa.*.nupkg')
+ if ($core.Count -ne 1 -or $provider.Count -ne 1) { throw 'Expected core and optional provider candidates.' }
+ $version = $core[0].BaseName.Substring('DnsClientX.'.Length)
+ $providerVersion = $provider[0].BaseName.Substring('DnsClientX.DnsSec.EdDsa.'.Length)
+ $env:NUGET_PACKAGES = Join-Path $env:RUNNER_TEMP 'candidate-nuget-cache'
+ $env:DNSCLIENTX_CANDIDATE_FEED = $feed
+ dotnet restore Build/PackageConsumer/PackageConsumer.csproj --configfile Build/PackageConsumer/NuGet.Config -p:DnsClientXPackageVersion=$version -p:DnsClientXEdDsaPackageVersion=$providerVersion
+ if ($LASTEXITCODE) { throw 'Packed consumer restore failed.' }
+ foreach ($framework in @('net8.0', 'net10.0')) {
+ dotnet run --project Build/PackageConsumer/PackageConsumer.csproj --framework $framework --no-restore -p:DnsClientXPackageVersion=$version -p:DnsClientXEdDsaPackageVersion=$providerVersion
+ if ($LASTEXITCODE) { throw "Packed consumer failed on $framework." }
+ }
diff --git a/.gitignore b/.gitignore
index c2ee5c01..626d0d7b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -368,6 +368,10 @@ build/*
!Build/
!Build/*.ps1
!Build/*.json
+!Build/PackageConsumer/
+!Build/PackageConsumer/*.cs
+!Build/PackageConsumer/*.csproj
+!Build/PackageConsumer/NuGet.Config
/.idea/
diff --git a/Build/PackageConsumer/NuGet.Config b/Build/PackageConsumer/NuGet.Config
new file mode 100644
index 00000000..ebb505f3
--- /dev/null
+++ b/Build/PackageConsumer/NuGet.Config
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Build/PackageConsumer/PackageConsumer.csproj b/Build/PackageConsumer/PackageConsumer.csproj
new file mode 100644
index 00000000..d2534e69
--- /dev/null
+++ b/Build/PackageConsumer/PackageConsumer.csproj
@@ -0,0 +1,15 @@
+
+
+ Exe
+ net8.0;net10.0
+ enable
+ enable
+ false
+ 2.1.2
+ 2.1.1
+
+
+
+
+
+
diff --git a/Build/PackageConsumer/Program.cs b/Build/PackageConsumer/Program.cs
new file mode 100644
index 00000000..2555f26e
--- /dev/null
+++ b/Build/PackageConsumer/Program.cs
@@ -0,0 +1,25 @@
+using DnsClientX;
+using DnsClientX.DnsSec.EdDsa;
+
+var response = new DnsResponse {
+ Answers = [new DnsAnswer { Name = "package.example", Type = DnsRecordType.A, Class = 1, TTL = 9, DataRaw = "192.0.2.1" }]
+};
+var record = response.GetTypedAnswers().Single();
+if (record.Name != "package.example" || record.TTL != 9 || record.Value.Address.ToString() != "192.0.2.1")
+ throw new Exception("Typed metadata contract failed.");
+using var client = new ClientX(DnsEndpoint.System);
+var verifier = new EdDsaDnsSecSignatureVerifier();
+client.EndpointConfiguration.DnsSecSignatureVerifier = verifier;
+if (!client.EndpointConfiguration.SupportedDnsSecAlgorithms.Contains(DnsKeyAlgorithm.ED25519)) throw new Exception("Optional verifier failed to load.");
+// RFC 8032 section 7.1, first Ed25519 test vector. This invokes the shipped
+// cryptographic dependency rather than merely inspecting its algorithm list.
+var publicKey = Convert.FromHexString("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
+var signature = Convert.FromHexString("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" +
+ "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
+if (!verifier.Verify(DnsKeyAlgorithm.ED25519, publicKey, [], signature)) throw new Exception("Packed provider rejected the Ed25519 vector.");
+signature[0] ^= 1;
+if (verifier.Verify(DnsKeyAlgorithm.ED25519, publicKey, [], signature)) throw new Exception("Packed provider accepted an invalid signature.");
+new DnsResolveOptions { TypedRecords = true }.Validate();
+_ = ClientX.GetResponseCacheStatistics();
+_ = DnsQueryExplanation.Create(response);
+Console.WriteLine($"Packed core and optional DNSSEC provider loaded on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}.");
diff --git a/Build/Test-PackagedModule.ps1 b/Build/Test-PackagedModule.ps1
new file mode 100644
index 00000000..f7c909f9
--- /dev/null
+++ b/Build/Test-PackagedModule.ps1
@@ -0,0 +1,36 @@
+param(
+ [Parameter(Mandatory)]
+ [string] $ModulePath,
+ [string] $ResultsPath
+)
+
+$ErrorActionPreference = 'Stop'
+$manifest = Get-Item -LiteralPath $ModulePath
+$module = Import-Module $manifest.FullName -Force -PassThru
+$required = @('Resolve-Dns', 'Find-DnsService', 'Get-DnsService', 'Invoke-DnsUpdate', 'Test-DnsHealth', 'Compare-DnsResolverSnapshot', 'Get-DnsQueryExplanation', 'Get-DnsCache', 'Clear-DnsCache')
+foreach ($name in $required) {
+ if (-not $module.ExportedCommands.ContainsKey($name)) { throw "Packaged module does not export $name." }
+}
+$library = [DnsClientX.ClientX].Assembly.Location
+if (-not $library.StartsWith($manifest.Directory.FullName + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Module loaded its core outside the package: $library"
+}
+$data = Import-PowerShellDataFile -LiteralPath $manifest.FullName
+foreach ($asset in @($data.FormatsToProcess) + @($data.TypesToProcess)) {
+ if (-not (Test-Path -LiteralPath (Join-Path $manifest.Directory.FullName $asset))) { throw "Package asset is missing: $asset" }
+}
+if (@($data.FormatsToProcess).Count -eq 0 -or @($data.TypesToProcess).Count -eq 0) { throw 'Package needs its format and type assets.' }
+$tests = Join-Path $manifest.Directory.FullName 'Tests'
+New-Item -ItemType Directory -Path $tests -Force | Out-Null
+Copy-Item -Path (Join-Path $PSScriptRoot '../Module/Tests/*') -Destination $tests -Recurse -Force
+Import-Module Pester -MinimumVersion 5.0 -ErrorAction Stop
+$configuration = New-PesterConfiguration
+$configuration.Run.Path = $tests
+$configuration.Run.PassThru = $true
+$configuration.Output.Verbosity = 'Detailed'
+if ($ResultsPath) {
+ $configuration.TestResult.Enabled = $true
+ $configuration.TestResult.OutputPath = $ResultsPath
+}
+$result = Invoke-Pester -Configuration $configuration
+if ($result.Result -ne 'Passed' -or $result.FailedCount -gt 0 -or $result.PassedCount -eq 0) { throw "Package tests $($result.Result): $($result.FailedCount) failures, $($result.PassedCount) passes." }
diff --git a/Build/cli.publish.json b/Build/cli.publish.json
new file mode 100644
index 00000000..1563facd
--- /dev/null
+++ b/Build/cli.publish.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://raw.githubusercontent.com/EvotecIT/PSPublishModule/main/Schemas/powerforge.dotnetpublish.schema.json",
+ "SchemaVersion": 1,
+ "DotNet": {
+ "ProjectRoot": "..",
+ "Configuration": "Release",
+ "EnvironmentVariables": {
+ "NUGET_PACKAGES": {
+ "FromEnvironmentVariable": "NUGET_PACKAGES",
+ "Required": false
+ }
+ }
+ },
+ "Targets": [
+ {
+ "Name": "DnsClientX",
+ "ProjectPath": "DnsClientX.Cli/DnsClientX.Cli.csproj",
+ "Kind": "Cli",
+ "Publish": {
+ "Framework": "net8.0",
+ "Runtimes": ["win-x64", "win-arm64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64"],
+ "Style": "FrameworkDependent",
+ "OutputPath": "Artefacts/Cli/{rid}",
+ "Zip": true,
+ "ZipNameTemplate": "DnsClientX-{rid}.zip",
+ "KeepSymbols": false,
+ "KeepDocs": false
+ }
+ }
+ ],
+ "Outputs": {
+ "ManifestJsonPath": "Artefacts/Cli/manifest.json",
+ "ManifestTextPath": "Artefacts/Cli/manifest.txt",
+ "ChecksumsPath": "Artefacts/Cli/SHA256SUMS"
+ }
+}
diff --git a/DnsClientX.Cli/DnsClientX.Cli.csproj b/DnsClientX.Cli/DnsClientX.Cli.csproj
index 6f49e23d..4f859c6a 100644
--- a/DnsClientX.Cli/DnsClientX.Cli.csproj
+++ b/DnsClientX.Cli/DnsClientX.Cli.csproj
@@ -23,7 +23,7 @@
true
false
- win-x64;linux-x64;osx-x64
+ win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64
diff --git a/DnsClientX.Cli/Program.Arguments.cs b/DnsClientX.Cli/Program.Arguments.cs
new file mode 100644
index 00000000..7a14ab0d
--- /dev/null
+++ b/DnsClientX.Cli/Program.Arguments.cs
@@ -0,0 +1,472 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static bool TryParseArgs(string[] args, out CliOptions? options, out string? errorMessage, out IReadOnlyList? invalidSwitches) {
+ options = new CliOptions();
+ errorMessage = null;
+ var unknown = new List();
+
+ for (int i = 0; i < args.Length; i++) {
+ string arg = args[i];
+ if (arg.StartsWith("-", StringComparison.Ordinal)) options.ProvidedSwitches.Add(arg);
+ switch (arg) {
+ case var opt when opt.Equals("--profile", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--profile", out string? profile, out errorMessage)) { invalidSwitches = null; options = null; return false; }
+ options.ProfilePath = profile;
+ break;
+ case var opt when opt.Equals("--profile-save", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--profile-save", out string? profileSave, out errorMessage)) { invalidSwitches = null; options = null; return false; }
+ options.ProfileSavePath = profileSave;
+ break;
+ case var opt when opt.Equals("--resolver-compare", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--resolver-compare", out string? baseline, out errorMessage) || !TryReadNext(args, ref i, "--resolver-compare", out string? current, out errorMessage)) { invalidSwitches = null; options = null; return false; }
+ options.CompareBaselinePath = baseline;
+ options.CompareCurrentPath = current;
+ break;
+ case var opt when opt.Equals("--reference-response", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--reference-response", out string? reference, out errorMessage)) { invalidSwitches = null; options = null; return false; }
+ options.ReferenceResponsePath = reference;
+ break;
+ case var opt when opt.Equals("-t", StringComparison.OrdinalIgnoreCase) ||
+ opt.Equals("--type", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--type", out string? recordTypeValue, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ AddOptionValues(options.RecordTypeInputs, recordTypeValue);
+ options.RecordTypeSpecified = true;
+ break;
+ case var opt when opt.Equals("-e", StringComparison.OrdinalIgnoreCase) ||
+ opt.Equals("--endpoint", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--endpoint", out string? endpointValue, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ AddOptionValues(options.EndpointInputs, endpointValue);
+ break;
+ case var opt when opt.Equals("--domain", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--domain", out string? domainValue, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ AddOptionValues(options.DomainInputs, domainValue);
+ break;
+ case var opt when opt.Equals("--reverse", StringComparison.OrdinalIgnoreCase) ||
+ opt.Equals("--ptr", StringComparison.OrdinalIgnoreCase):
+ options.ReverseLookup = true;
+ break;
+ case var opt when opt.Equals("--axfr", StringComparison.OrdinalIgnoreCase) ||
+ opt.Equals("--zone-transfer", StringComparison.OrdinalIgnoreCase):
+ options.ZoneTransfer = true;
+ break;
+ case var opt when opt.Equals("--transfer-summary", StringComparison.OrdinalIgnoreCase):
+ options.TransferSummary = true;
+ break;
+ case var opt when opt.Equals("--dnssec", StringComparison.OrdinalIgnoreCase):
+ options.RequestDnsSec = true;
+ break;
+ case var opt when opt.Equals("--validate-dnssec", StringComparison.OrdinalIgnoreCase):
+ options.ValidateDnsSec = true;
+ break;
+ case var opt when opt.Equals("--dnssec-verifier", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--dnssec-verifier", out string? verifierPath, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(verifierPath)) {
+ errorMessage = "--dnssec-verifier requires a nonblank local DLL path.";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.DnsSecVerifierPath = verifierPath;
+ break;
+ case var opt when opt.Equals("--wire-post", StringComparison.OrdinalIgnoreCase):
+ options.WirePost = true;
+ break;
+ case var opt when opt.Equals("--nsid", StringComparison.OrdinalIgnoreCase):
+ options.RequestNsid = true;
+ break;
+ case var opt when opt.Equals("--bootstrap", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--bootstrap", out string? bootstrap, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ try { options.BootstrapResolver = EndpointParser.ParseBootstrap(bootstrap!); }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException) {
+ errorMessage = ex.Message;
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ break;
+ case var opt when opt.Equals("--format", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--format", out string? formatValue, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (!TryParseQueryOutputFormat(formatValue, out QueryOutputFormat outputFormat)) {
+ errorMessage = $"Invalid value for --format: {formatValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.OutputFormat = outputFormat;
+ break;
+ case var opt when opt.Equals("--short", StringComparison.OrdinalIgnoreCase):
+ options.ShortOutput = true;
+ break;
+ case var opt when opt.Equals("--txt-concat", StringComparison.OrdinalIgnoreCase):
+ options.TxtConcatOutput = true;
+ break;
+ case var opt when opt.Equals("--question", StringComparison.OrdinalIgnoreCase):
+ options.ShowQuestionSection = true;
+ break;
+ case var opt when opt.Equals("--answer", StringComparison.OrdinalIgnoreCase):
+ options.ShowAnswerSection = true;
+ break;
+ case var opt when opt.Equals("--authority", StringComparison.OrdinalIgnoreCase):
+ options.ShowAuthoritySection = true;
+ break;
+ case var opt when opt.Equals("--additional", StringComparison.OrdinalIgnoreCase):
+ options.ShowAdditionalSection = true;
+ break;
+ case var opt when opt.Equals("--benchmark", StringComparison.OrdinalIgnoreCase):
+ options.Benchmark = true;
+ break;
+ case var opt when opt.Equals("--benchmark-connections", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-connections", out string? connectionMode, out errorMessage) ||
+ (connectionMode != "cold" && connectionMode != "warm") ||
+ !Enum.TryParse(connectionMode, true, out ResolverQueryConnectionMode parsedMode) ||
+ (parsedMode != ResolverQueryConnectionMode.Cold && parsedMode != ResolverQueryConnectionMode.Warm)) {
+ errorMessage = "--benchmark-connections accepts cold or warm.";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkConnectionMode = parsedMode;
+ break;
+ case var opt when opt.Equals("--benchmark-attempts", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-attempts", out string? benchmarkAttemptsValue, out errorMessage) ||
+ !int.TryParse(benchmarkAttemptsValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkAttempts)) {
+ errorMessage ??= $"Invalid value for --benchmark-attempts: {benchmarkAttemptsValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (benchmarkAttempts < 1) {
+ errorMessage = $"--benchmark-attempts must be at least 1: {benchmarkAttempts}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkAttempts = benchmarkAttempts;
+ break;
+ case var opt when opt.Equals("--benchmark-timeout", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-timeout", out string? benchmarkTimeoutValue, out errorMessage) ||
+ !int.TryParse(benchmarkTimeoutValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkTimeoutMs)) {
+ errorMessage ??= $"Invalid value for --benchmark-timeout: {benchmarkTimeoutValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (benchmarkTimeoutMs < 1) {
+ errorMessage = $"--benchmark-timeout must be at least 1: {benchmarkTimeoutMs}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkTimeoutMs = benchmarkTimeoutMs;
+ break;
+ case var opt when opt.Equals("--benchmark-concurrency", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-concurrency", out string? benchmarkConcurrencyValue, out errorMessage) ||
+ !int.TryParse(benchmarkConcurrencyValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkConcurrency)) {
+ errorMessage ??= $"Invalid value for --benchmark-concurrency: {benchmarkConcurrencyValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (benchmarkConcurrency < 1) {
+ errorMessage = $"--benchmark-concurrency must be at least 1: {benchmarkConcurrency}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkConcurrency = benchmarkConcurrency;
+ break;
+ case var opt when opt.Equals("--benchmark-min-success-percent", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-min-success-percent", out string? benchmarkMinSuccessPercentValue, out errorMessage) ||
+ !int.TryParse(benchmarkMinSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessPercent)) {
+ errorMessage ??= $"Invalid value for --benchmark-min-success-percent: {benchmarkMinSuccessPercentValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (benchmarkMinSuccessPercent < 1 || benchmarkMinSuccessPercent > 100) {
+ errorMessage = $"--benchmark-min-success-percent must be between 1 and 100: {benchmarkMinSuccessPercent}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkMinSuccessPercent = benchmarkMinSuccessPercent;
+ break;
+ case var opt when opt.Equals("--benchmark-min-successful-candidates", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-min-successful-candidates", out string? benchmarkMinSuccessfulCandidatesValue, out errorMessage) ||
+ !int.TryParse(benchmarkMinSuccessfulCandidatesValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessfulCandidates)) {
+ errorMessage ??= $"Invalid value for --benchmark-min-successful-candidates: {benchmarkMinSuccessfulCandidatesValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (benchmarkMinSuccessfulCandidates < 1) {
+ errorMessage = $"--benchmark-min-successful-candidates must be at least 1: {benchmarkMinSuccessfulCandidates}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkMinSuccessfulCandidates = benchmarkMinSuccessfulCandidates;
+ break;
+ case var opt when opt.Equals("--benchmark-summary-line", StringComparison.OrdinalIgnoreCase):
+ options.Benchmark = true;
+ options.BenchmarkSummaryLine = true;
+ break;
+ case var opt when opt.Equals("--benchmark-save", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--benchmark-save", out string? benchmarkSavePath, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Benchmark = true;
+ options.BenchmarkSavePath = benchmarkSavePath;
+ break;
+ case var opt when opt.Equals("--benchmark-summary-only", StringComparison.OrdinalIgnoreCase):
+ options.Benchmark = true;
+ options.BenchmarkSummaryOnly = true;
+ break;
+ case var opt when opt.Equals("--probe", StringComparison.OrdinalIgnoreCase):
+ options.Probe = true;
+ break;
+ case var opt when opt.Equals("--probe-endpoint", StringComparison.OrdinalIgnoreCase) || opt.Equals("--resolver", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--probe-endpoint", out string? probeEndpoint, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ AddOptionValues(options.ProbeEndpoints, probeEndpoint);
+ break;
+ case var opt when opt.Equals("--resolver-file", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--resolver-file", out string? resolverFile, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ AddOptionValues(options.ProbeEndpointFiles, resolverFile);
+ break;
+ case var opt when opt.Equals("--resolver-url", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--resolver-url", out string? resolverUrl, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ AddOptionValues(options.ProbeEndpointUrls, resolverUrl);
+ break;
+ case var opt when opt.Equals("--resolver-validate", StringComparison.OrdinalIgnoreCase):
+ options.ResolverValidate = true;
+ break;
+ case var opt when opt.Equals("--probe-summary-only", StringComparison.OrdinalIgnoreCase):
+ options.Probe = true;
+ options.ProbeSummaryOnly = true;
+ break;
+ case var opt when opt.Equals("--probe-summary-line", StringComparison.OrdinalIgnoreCase):
+ options.Probe = true;
+ options.ProbeSummaryLine = true;
+ break;
+ case var opt when opt.Equals("--resolver-select", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--resolver-select", out string? resolverSelectPath, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.ResolverSelectPath = resolverSelectPath;
+ break;
+ case var opt when opt.Equals("--resolver-use", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--resolver-use", out string? resolverUsePath, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.ResolverUsePath = resolverUsePath;
+ break;
+ case var opt when opt.Equals("--probe-save", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--probe-save", out string? probeSavePath, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Probe = true;
+ options.ProbeSavePath = probeSavePath;
+ break;
+ case var opt when opt.Equals("--probe-require-consensus", StringComparison.OrdinalIgnoreCase):
+ options.Probe = true;
+ options.ProbeRequireConsensus = true;
+ break;
+ case var opt when opt.Equals("--probe-min-consensus", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--probe-min-consensus", out string? minConsensusValue, out errorMessage) ||
+ !int.TryParse(minConsensusValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minConsensusPercent)) {
+ errorMessage ??= $"Invalid value for --probe-min-consensus: {minConsensusValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (minConsensusPercent < 1 || minConsensusPercent > 100) {
+ errorMessage = $"--probe-min-consensus must be between 1 and 100: {minConsensusPercent}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Probe = true;
+ options.ProbeMinConsensusPercent = minConsensusPercent;
+ break;
+ case var opt when opt.Equals("--probe-min-success", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--probe-min-success", out string? minSuccessValue, out errorMessage) ||
+ !int.TryParse(minSuccessValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessCount)) {
+ errorMessage ??= $"Invalid value for --probe-min-success: {minSuccessValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (minSuccessCount < 1) {
+ errorMessage = $"--probe-min-success must be at least 1: {minSuccessCount}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Probe = true;
+ options.ProbeMinSuccessCount = minSuccessCount;
+ break;
+ case var opt when opt.Equals("--probe-min-success-percent", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--probe-min-success-percent", out string? minSuccessPercentValue, out errorMessage) ||
+ !int.TryParse(minSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessPercent)) {
+ errorMessage ??= $"Invalid value for --probe-min-success-percent: {minSuccessPercentValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (minSuccessPercent < 1 || minSuccessPercent > 100) {
+ errorMessage = $"--probe-min-success-percent must be between 1 and 100: {minSuccessPercent}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Probe = true;
+ options.ProbeMinSuccessPercent = minSuccessPercent;
+ break;
+ case var opt when opt.Equals("--explain", StringComparison.OrdinalIgnoreCase):
+ options.Explain = true;
+ break;
+ case var opt when opt.Equals("--trace", StringComparison.OrdinalIgnoreCase):
+ options.Trace = true;
+ options.Explain = true;
+ break;
+ case var opt when opt.Equals("--capabilities", StringComparison.OrdinalIgnoreCase):
+ options.ShowCapabilities = true;
+ break;
+ case var opt when opt.Equals("--stamp-info", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--stamp-info", out string? stampInfo, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.StampInfo = stampInfo;
+ break;
+ case var opt when opt.Equals("--update", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--update", out string? zone, out errorMessage) ||
+ !TryReadNext(args, ref i, "--update", out string? updateName, out errorMessage) ||
+ !TryReadNext(args, ref i, "--update", out string? updateTypeValue, out errorMessage) ||
+ !TryReadNext(args, ref i, "--update", out string? updateData, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (!Enum.TryParse(updateTypeValue, true, out DnsRecordType updateType)) {
+ errorMessage = $"Invalid value for --update type: {updateTypeValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.DoUpdate = true;
+ options.Zone = zone;
+ options.UpdateName = updateName;
+ options.RecordType = updateType;
+ options.UpdateData = updateData;
+ break;
+ case var opt when opt.Equals("--ttl", StringComparison.OrdinalIgnoreCase):
+ if (!TryReadNext(args, ref i, "--ttl", out string? ttlValue, out errorMessage) ||
+ !int.TryParse(ttlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int ttl)) {
+ errorMessage ??= $"Invalid value for --ttl: {ttlValue}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ if (ttl < 0) {
+ errorMessage = $"--ttl must be zero or greater: {ttl}";
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+ options.Ttl = ttl;
+ break;
+ default:
+ if (options.Domain is null && !arg.StartsWith("-", StringComparison.Ordinal)) {
+ options.Domain = arg;
+ } else {
+ unknown.Add(arg);
+ }
+ break;
+ }
+ }
+
+ if (unknown.Count > 0) {
+ invalidSwitches = unknown;
+ options = null;
+ return false;
+ }
+
+ if (!FinalizeParsedLists(options, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+
+ if (!ValidateParsedOptions(options, out errorMessage)) {
+ invalidSwitches = null;
+ options = null;
+ return false;
+ }
+
+ invalidSwitches = Array.Empty();
+ return true;
+ }
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.Help.cs b/DnsClientX.Cli/Program.Help.cs
new file mode 100644
index 00000000..6f072f90
--- /dev/null
+++ b/DnsClientX.Cli/Program.Help.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static void ShowHelp() {
+ Console.WriteLine("DnsClientX.Cli - simple DNS query tool");
+ Console.WriteLine("Usage: DnsClientX.Cli [options] ");
+ Console.WriteLine(" --resolver Query one explicit resolver directly (UDP/TCP/DoT/DoH/DoQ etc.)");
+ Console.WriteLine(" --profile [--benchmark] Run a versioned resolver health profile; settings come from its JSON");
+ Console.WriteLine(" --profile-save Save the complete health report JSON");
+ Console.WriteLine(" --resolver-compare Compare saved probe/benchmark snapshots");
+ Console.WriteLine(" --reference-response Caller-supplied authoritative reference array for comparison");
+ Console.WriteLine(" Explicit resolver inputs query directly; add --probe for multiple-candidate probing.");
+ Console.WriteLine(" --format json emits a complete JSON report; --explain/--trace query JSON includes structured evidence.");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" -t, --type DNS record type (default A, comma-separated with --benchmark)");
+ Console.WriteLine(" -e, --endpoint DNS endpoint name (default System, comma-separated with --benchmark)");
+ Console.WriteLine(" --domain Domain name (comma-separated with --benchmark)");
+ Console.WriteLine(" --dnssec Request DNSSEC records");
+ Console.WriteLine(" --validate-dnssec Validate DNSSEC records");
+ Console.WriteLine(" --dnssec-verifier Optional DNSSEC provider DLL path (dependencies beside it)");
+ Console.WriteLine(" --wire-post Use DNS over HTTPS wire POST (when supported)");
+ Console.WriteLine(" --bootstrap Resolve endpoint hostnames through an IP-literal UDP/TCP resolver, e.g. udp@1.1.1.1:53");
+ Console.WriteLine(" --nsid Request resolver identity through EDNS (wire transports)");
+ Console.WriteLine(" --format Query output format: pretty, json, raw");
+ Console.WriteLine(" --short Print answer values only");
+ Console.WriteLine(" --txt-concat Flatten TXT output into a single string");
+ Console.WriteLine(" --question Show the question section");
+ Console.WriteLine(" --answer Show the answer section");
+ Console.WriteLine(" --authority Show the authority section");
+ Console.WriteLine(" --additional Show the additional section");
+ Console.WriteLine(" --reverse Force PTR lookup mode");
+ Console.WriteLine(" --axfr Discover authoritative servers and attempt recursive AXFR");
+ Console.WriteLine(" --transfer-summary Print discovered authorities and tried transfer targets");
+ Console.WriteLine(" --benchmark Benchmark one or more endpoints across repeated queries");
+ Console.WriteLine(" --benchmark-attempts Repeat each domain/type combination this many times");
+ Console.WriteLine(" --benchmark-timeout Per-query timeout to apply during benchmark runs");
+ Console.WriteLine(" --benchmark-concurrency Max concurrent queries across the benchmark run");
+ Console.WriteLine(" --benchmark-connections Fresh clients per attempt or retained clients per target (default cold)");
+ Console.WriteLine(" --benchmark-min-success-percent Require a minimum overall benchmark success rate");
+ Console.WriteLine(" --benchmark-min-successful-candidates Require this many candidates to return at least one success");
+ Console.WriteLine(" --benchmark-summary-only Suppress per-candidate benchmark rows and print only the header and summary");
+ Console.WriteLine(" --benchmark-summary-line Append one stable BENCHMARK_SUMMARY key=value line for automation");
+ Console.WriteLine(" --benchmark-save Persist benchmark scoring and recommendation data as JSON");
+ Console.WriteLine(" --probe Probe the selected endpoint profile and related variants");
+ Console.WriteLine(" --probe-endpoint Probe a custom endpoint such as tcp@1.1.1.1:53, doq@dns.quad9.net:853, or doh3@https://dns.quad9.net/dns-query");
+ Console.WriteLine(" --resolver-file Load custom endpoints from a text file for probe or benchmark");
+ Console.WriteLine(" --resolver-url Load custom endpoints from an HTTP or HTTPS URL for probe or benchmark");
+ Console.WriteLine(" --resolver-validate Validate resolver endpoint inputs without querying DNS");
+ Console.WriteLine(" --probe-summary-only Suppress per-endpoint probe lines and print only the header and summary");
+ Console.WriteLine(" --probe-summary-line Append one stable PROBE_SUMMARY key=value line for automation");
+ Console.WriteLine(" --probe-save Persist probe scoring and health data as JSON");
+ Console.WriteLine(" --resolver-select Load a saved score snapshot and print the recommended resolver");
+ Console.WriteLine(" --resolver-use Reuse the recommended resolver from a saved snapshot for query, probe, or benchmark");
+ Console.WriteLine(" --probe-require-consensus Fail when successful probe responders disagree");
+ Console.WriteLine(" --probe-min-consensus Fail when the top answer set is below the given 1-100 percentage");
+ Console.WriteLine(" --probe-min-success Fail when fewer than the given number of probes succeed");
+ Console.WriteLine(" --probe-min-success-percent Fail when the probe success rate is below the given 1-100 percentage");
+ Console.WriteLine(" --capabilities Print the shared transport capability report");
+ Console.WriteLine(" --stamp-info Print parsed DNS stamp details without querying DNS");
+ Console.WriteLine(" --explain Print resolver and response diagnostics");
+ Console.WriteLine(" --trace Print explain output plus audit details");
+ Console.WriteLine(" --update Send dynamic update to one explicit authoritative endpoint");
+ Console.WriteLine(" --ttl TTL for update (default 300)");
+ Console.WriteLine();
+ Console.WriteLine("Available endpoints:");
+ foreach (var (ep, desc) in DnsEndpointExtensions.GetAllWithDescriptions()) {
+ Console.WriteLine($" {ep,-20} {desc}");
+ }
+ }
+ }
+}
diff --git a/DnsClientX.Cli/Program.Inputs.cs b/DnsClientX.Cli/Program.Inputs.cs
new file mode 100644
index 00000000..844cc374
--- /dev/null
+++ b/DnsClientX.Cli/Program.Inputs.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static bool TryReadNext(string[] args, ref int index, string optionName, out string? value, out string? errorMessage) {
+ if (index + 1 >= args.Length) {
+ value = null;
+ errorMessage = $"Missing value for {optionName}";
+ return false;
+ }
+
+ value = args[++index];
+ errorMessage = null;
+ return true;
+ }
+
+ private static bool TryParseQueryOutputFormat(string? value, out QueryOutputFormat format) {
+ switch (value?.Trim().ToLowerInvariant()) {
+ case "pretty":
+ format = QueryOutputFormat.Pretty;
+ return true;
+ case "json":
+ format = QueryOutputFormat.Json;
+ return true;
+ case "raw":
+ format = QueryOutputFormat.Raw;
+ return true;
+ default:
+ format = default;
+ return false;
+ }
+ }
+
+ private static bool ApplyReverseLookupDefaults(CliOptions options, out string? errorMessage) {
+ errorMessage = null;
+
+ if (options.ReverseLookup) {
+ if (options.RecordTypeSpecified &&
+ options.BenchmarkRecordTypes.Any(recordType => recordType != DnsRecordType.PTR)) {
+ errorMessage = "--reverse cannot be combined with a record type other than PTR.";
+ return false;
+ }
+
+ options.RecordType = DnsRecordType.PTR;
+ if (options.BenchmarkRecordTypes.Count > 0) {
+ options.BenchmarkRecordTypes.Clear();
+ options.BenchmarkRecordTypes.Add(DnsRecordType.PTR);
+ }
+
+ return true;
+ }
+
+ if (options.RecordTypeSpecified || options.DoUpdate) {
+ return true;
+ }
+
+ string[] effectiveBenchmarkDomains = options.Benchmark ? GetEffectiveBenchmarkDomains(options) : Array.Empty();
+ bool shouldUsePtrByDefault = options.Benchmark
+ ? effectiveBenchmarkDomains.Length > 0 && effectiveBenchmarkDomains.All(IsIpAddressLiteral)
+ : !string.IsNullOrWhiteSpace(options.Domain) && IsIpAddressLiteral(options.Domain);
+
+ if (shouldUsePtrByDefault) {
+ options.RecordType = DnsRecordType.PTR;
+ }
+
+ return true;
+ }
+
+ private static bool IsIpAddressLiteral(string? value) {
+ if (string.IsNullOrWhiteSpace(value)) {
+ return false;
+ }
+
+ string trimmed = value!.Trim();
+ return IPAddress.TryParse(trimmed, out _);
+ }
+
+ private static void AddOptionValues(ICollection target, string? rawValue) {
+ foreach (string value in SplitOptionValues(rawValue)) {
+ target.Add(value);
+ }
+ }
+
+ private static IEnumerable SplitOptionValues(string? rawValue) {
+ if (string.IsNullOrWhiteSpace(rawValue)) {
+ yield break;
+ }
+
+ string valueText = rawValue!;
+ string[] parts = valueText.Split(',');
+ foreach (string part in parts) {
+ string value = part.Trim();
+ if (!string.IsNullOrWhiteSpace(value)) {
+ yield return value;
+ }
+ }
+ }
+
+ private static bool FinalizeParsedLists(CliOptions options, out string? errorMessage) {
+ errorMessage = null;
+
+ if (options.EndpointInputs.Count > 0) {
+ foreach (string endpointValue in options.EndpointInputs) {
+ if (!Enum.TryParse(endpointValue, true, out DnsEndpoint endpoint)) {
+ errorMessage = $"Invalid value for --endpoint: {endpointValue}";
+ return false;
+ }
+ if (endpoint == DnsEndpoint.Custom) {
+ errorMessage = "--endpoint Custom is not supported in the CLI. Use explicit resolver endpoint syntax such as --probe-endpoint, --resolver-file, or --resolver-url instead.";
+ return false;
+ }
+ options.BenchmarkEndpoints.Add(endpoint);
+ }
+
+ options.Endpoint = options.BenchmarkEndpoints[0];
+ if (!options.Benchmark && options.BenchmarkEndpoints.Count > 1) {
+ errorMessage = "--endpoint accepts multiple values only with --benchmark.";
+ return false;
+ }
+ }
+
+ if (options.RecordTypeInputs.Count > 0) {
+ foreach (string recordTypeValue in options.RecordTypeInputs) {
+ if (!Enum.TryParse(recordTypeValue, true, out DnsRecordType recordType)) {
+ errorMessage = $"Invalid value for --type: {recordTypeValue}";
+ return false;
+ }
+ options.BenchmarkRecordTypes.Add(recordType);
+ }
+
+ options.RecordType = options.BenchmarkRecordTypes[0];
+ if (!options.Benchmark && options.BenchmarkRecordTypes.Count > 1) {
+ errorMessage = "--type accepts multiple values only with --benchmark.";
+ return false;
+ }
+ }
+
+ if (options.DomainInputs.Count > 0) {
+ foreach (string domainValue in options.DomainInputs) {
+ options.BenchmarkDomains.Add(domainValue);
+ }
+
+ options.Domain = options.BenchmarkDomains[0];
+ if (!options.Benchmark && options.BenchmarkDomains.Count > 1) {
+ errorMessage = "--domain accepts multiple values only with --benchmark.";
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.Options.cs b/DnsClientX.Cli/Program.Options.cs
new file mode 100644
index 00000000..e79036e5
--- /dev/null
+++ b/DnsClientX.Cli/Program.Options.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private enum QueryOutputFormat {
+ Pretty,
+ Json,
+ Raw
+ }
+
+ private sealed class CliOptions {
+ public HashSet ProvidedSwitches { get; } = new HashSet(StringComparer.OrdinalIgnoreCase);
+ public string? ProfilePath { get; set; }
+ public string? ProfileSavePath { get; set; }
+ public string? CompareBaselinePath { get; set; }
+ public string? CompareCurrentPath { get; set; }
+ public string? ReferenceResponsePath { get; set; }
+ public string? Domain { get; set; }
+ public DnsRecordType RecordType { get; set; } = DnsRecordType.A;
+ public bool RecordTypeSpecified { get; set; }
+ public DnsEndpoint Endpoint { get; set; } = DnsEndpoint.System;
+ public QueryOutputFormat OutputFormat { get; set; } = QueryOutputFormat.Pretty;
+ public bool ShortOutput { get; set; }
+ public bool TxtConcatOutput { get; set; }
+ public bool ShowQuestionSection { get; set; }
+ public bool ShowAnswerSection { get; set; }
+ public bool ShowAuthoritySection { get; set; }
+ public bool ShowAdditionalSection { get; set; }
+ public bool ReverseLookup { get; set; }
+ public bool ZoneTransfer { get; set; }
+ public bool TransferSummary { get; set; }
+ public bool Benchmark { get; set; }
+ public int BenchmarkAttempts { get; set; } = 3;
+ public int BenchmarkTimeoutMs { get; set; } = 2000;
+ public int BenchmarkConcurrency { get; set; } = 4;
+ public ResolverQueryConnectionMode BenchmarkConnectionMode { get; set; }
+ public int? BenchmarkMinSuccessPercent { get; set; }
+ public int? BenchmarkMinSuccessfulCandidates { get; set; }
+ public bool BenchmarkSummaryOnly { get; set; }
+ public bool BenchmarkSummaryLine { get; set; }
+ public List BenchmarkEndpoints { get; } = new List();
+ public List BenchmarkDomains { get; } = new List();
+ public List BenchmarkRecordTypes { get; } = new List();
+ public List EndpointInputs { get; } = new List();
+ public List DomainInputs { get; } = new List();
+ public List RecordTypeInputs { get; } = new List();
+ public List ProbeEndpointFiles { get; } = new List();
+ public List ProbeEndpointUrls { get; } = new List();
+ public bool RequestDnsSec { get; set; }
+ public bool ValidateDnsSec { get; set; }
+ public string? DnsSecVerifierPath { get; set; }
+ public IDnsSecSignatureVerifier? DnsSecSignatureVerifier { get; set; }
+ public bool WirePost { get; set; }
+ public DnsResolverEndpoint? BootstrapResolver { get; set; }
+ public bool RequestNsid { get; set; }
+ public bool Probe { get; set; }
+ public List ProbeEndpoints { get; } = new List();
+ public string? ProbeSavePath { get; set; }
+ public string? ResolverSelectPath { get; set; }
+ public string? ResolverUsePath { get; set; }
+ public bool ProbeSummaryOnly { get; set; }
+ public bool ProbeSummaryLine { get; set; }
+ public bool ProbeRequireConsensus { get; set; }
+ public int? ProbeMinConsensusPercent { get; set; }
+ public int? ProbeMinSuccessCount { get; set; }
+ public int? ProbeMinSuccessPercent { get; set; }
+ public string? BenchmarkSavePath { get; set; }
+ public bool Explain { get; set; }
+ public bool Trace { get; set; }
+ public bool ShowCapabilities { get; set; }
+ public string? StampInfo { get; set; }
+ public bool ResolverValidate { get; set; }
+ public bool DoUpdate { get; set; }
+ public string? Zone { get; set; }
+ public string? UpdateName { get; set; }
+ public string? UpdateData { get; set; }
+ public int Ttl { get; set; } = 300;
+
+ public bool HasExplicitSectionSelection =>
+ ShowQuestionSection ||
+ ShowAnswerSection ||
+ ShowAuthoritySection ||
+ ShowAdditionalSection;
+
+ public bool HasCustomEndpointInputs =>
+ ProbeEndpoints.Count > 0 ||
+ ProbeEndpointFiles.Count > 0 ||
+ ProbeEndpointUrls.Count > 0;
+ }
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.Output.cs b/DnsClientX.Cli/Program.Output.cs
new file mode 100644
index 00000000..7eebb564
--- /dev/null
+++ b/DnsClientX.Cli/Program.Output.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static void WriteQueryResponse(DnsResponse response, CliOptions options, TimeSpan elapsed) {
+ var presentationOptions = new DnsResponsePresentationOptions {
+ Mode = GetResponsePresentationMode(options),
+ TxtConcat = options.TxtConcatOutput,
+ ShowQuestions = ShouldShowQuestionSection(options, rawDefault: false),
+ ShowAnswers = ShouldShowAnswerSection(options, rawDefault: false),
+ ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: false),
+ ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: false)
+ };
+
+ if (presentationOptions.Mode == DnsResponsePresentationMode.Raw) {
+ presentationOptions.ShowQuestions = ShouldShowQuestionSection(options, rawDefault: true);
+ presentationOptions.ShowAnswers = ShouldShowAnswerSection(options, rawDefault: true);
+ presentationOptions.ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: true);
+ presentationOptions.ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: true);
+ }
+
+ foreach (string line in DnsResponseTextFormatter.BuildOutputLines(response, presentationOptions, elapsed, FormatDuration)) {
+ Console.WriteLine(line);
+ }
+ }
+
+ private static void WriteZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) {
+ switch (options.OutputFormat) {
+ case QueryOutputFormat.Json:
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(result));
+ return;
+ default:
+ WritePrettyZoneTransferResponse(result, options);
+ return;
+ }
+ }
+
+ private static void SaveResolverScoreSnapshot(string path, ResolverScoreSnapshot snapshot) {
+ ResolverScoreStore.Save(path, snapshot);
+ }
+
+ private static void WriteResolverSelection(ResolverSelectionResult selection, CliOptions options) {
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ if (options.Explain) {
+ ResolverScoreSnapshot snapshot = ResolverScoreStore.Load(options.ResolverSelectPath!);
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(new { Selection = selection, snapshot.Summary, snapshot.Context }));
+ } else {
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(selection));
+ }
+ return;
+ }
+
+ WriteHumanLine(selection.Target);
+ }
+
+ private static void WritePrettyZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) {
+ WriteHumanLine($"Zone Transfer: {result.Zone}");
+ WriteHumanLine($" Selected authority: {result.SelectedAuthority}");
+ WriteHumanLine($" Selected server: {result.SelectedServer}:{result.Port}");
+ Console.WriteLine($" Chunks: {result.RecordSets.Length}");
+
+ if (options.TransferSummary) {
+ WriteHumanLine($" Authorities discovered: {string.Join(", ", result.Authorities)}");
+ WriteHumanLine($" Tried servers: {string.Join(", ", result.TriedServers)}");
+ }
+
+ Console.WriteLine("Records:");
+ foreach (ZoneTransferResult recordSet in result.RecordSets) {
+ if (recordSet.IsOpening) {
+ Console.WriteLine(" ;; opening SOA");
+ } else if (recordSet.IsClosing) {
+ Console.WriteLine(" ;; closing SOA");
+ }
+
+ foreach (DnsAnswer record in recordSet.Records) {
+ Console.WriteLine($" {DnsTerminalText.Escape(record.Name)}\t{record.TTL}\tIN\t{record.Type}\t{DnsTerminalText.Escape(record.Data)}");
+ }
+ }
+ }
+
+ private static DnsResponsePresentationMode GetResponsePresentationMode(CliOptions options) {
+ if (options.ShortOutput) {
+ return DnsResponsePresentationMode.Short;
+ }
+
+ return options.OutputFormat switch {
+ QueryOutputFormat.Json => DnsResponsePresentationMode.Json,
+ QueryOutputFormat.Raw => DnsResponsePresentationMode.Raw,
+ _ => DnsResponsePresentationMode.Pretty
+ };
+ }
+
+ private static bool ShouldShowQuestionSection(CliOptions options, bool rawDefault) {
+ if (options.HasExplicitSectionSelection) {
+ return options.ShowQuestionSection;
+ }
+
+ return rawDefault;
+ }
+
+ private static bool ShouldShowAnswerSection(CliOptions options, bool rawDefault) {
+ if (options.HasExplicitSectionSelection) {
+ return options.ShowAnswerSection;
+ }
+
+ return !rawDefault || options.OutputFormat == QueryOutputFormat.Raw;
+ }
+
+ private static bool ShouldShowAuthoritySection(CliOptions options, bool rawDefault) {
+ if (options.HasExplicitSectionSelection) {
+ return options.ShowAuthoritySection;
+ }
+
+ return rawDefault;
+ }
+
+ private static bool ShouldShowAdditionalSection(CliOptions options, bool rawDefault) {
+ if (options.HasExplicitSectionSelection) {
+ return options.ShowAdditionalSection;
+ }
+
+ return rawDefault;
+ }
+
+ private static void WriteExplain(
+ ResolverSingleOperationResult result,
+ string operation,
+ string target,
+ DnsRecordType recordType,
+ bool requestDnsSec,
+ bool validateDnsSec,
+ bool trace,
+ string? zone = null,
+ int? ttl = null) {
+ foreach (string line in ResolverReportTextFormatter.BuildSingleOperationExplainLines(
+ result,
+ operation,
+ target,
+ recordType,
+ requestDnsSec,
+ validateDnsSec,
+ FormatDuration,
+ zone,
+ ttl)) {
+ Console.WriteLine(line);
+ }
+
+ if (trace) {
+ WriteTrace(result);
+ }
+ }
+
+ private static void WriteTrace(ResolverSingleOperationResult result) {
+ foreach (string line in ResolverReportTextFormatter.BuildSingleOperationTraceLines(result, FormatDuration)) {
+ Console.WriteLine(line);
+ }
+ }
+
+ private static string FormatDuration(TimeSpan duration) {
+ if (duration <= TimeSpan.Zero) {
+ return "0 ms";
+ }
+
+ if (duration.TotalMilliseconds < 1000) {
+ return $"{duration.TotalMilliseconds:F0} ms";
+ }
+
+ return $"{duration.TotalSeconds:F3} s";
+ }
+
+ private static void WriteHumanLine(string value) => Console.WriteLine(DnsTerminalText.Escape(value));
+
+ private static void WriteHumanError(string? value) => Console.Error.WriteLine(DnsTerminalText.Escape(value));
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.ProfileOperations.cs b/DnsClientX.Cli/Program.ProfileOperations.cs
new file mode 100644
index 00000000..ae5c9411
--- /dev/null
+++ b/DnsClientX.Cli/Program.ProfileOperations.cs
@@ -0,0 +1,82 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static bool ValidateOperationalMode(CliOptions options, out string? error) {
+ error = null;
+ bool profile = options.ProfilePath != null;
+ bool comparison = options.CompareBaselinePath != null;
+ if (!profile && !comparison) {
+ if (options.ProfileSavePath != null || options.ReferenceResponsePath != null) { error = "--profile-save requires --profile; --reference-response requires --resolver-compare."; return false; }
+ return true;
+ }
+ var allowed = new HashSet(profile
+ ? new[] { "--profile", "--profile-save", "--benchmark", "--format", "--dnssec-verifier" }
+ : new[] { "--resolver-compare", "--reference-response", "--format" }, StringComparer.OrdinalIgnoreCase);
+ if (profile && comparison || options.Domain != null || options.OutputFormat == QueryOutputFormat.Raw || options.ProvidedSwitches.Any(s => !allowed.Contains(s))) {
+ error = "Profile/comparison modes support pretty or JSON output; profile run settings come from the profile. Use --profile path [--benchmark] [--profile-save report.json], or --resolver-compare baseline.json current.json [--reference-response references.json].";
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(options.ProfilePath) && profile || comparison && (string.IsNullOrWhiteSpace(options.CompareBaselinePath) || string.IsNullOrWhiteSpace(options.CompareCurrentPath))) { error = "A nonblank profile or snapshot path is required."; return false; }
+ return true;
+ }
+
+ private static async Task RunProfileAsync(CliOptions options, CancellationToken token) {
+ ResolverHealthProfile profile = ResolverHealthProfileStore.Load(options.ProfilePath!);
+ IDnsSecSignatureVerifier? verifier = string.IsNullOrWhiteSpace(options.DnsSecVerifierPath) ? null : DnsSecSignatureVerifierLoader.Load(options.DnsSecVerifierPath!);
+ ResolverHealthReport report = await ResolverHealthWorkflow.RunAsync(profile, options.Benchmark, verifier, CreateBuiltInOverride(), CreateExplicitOverride(), token).ConfigureAwait(false);
+ string json = DnsClientXJsonSerializer.Serialize(report);
+ if (options.ProfileSavePath != null) {
+ File.WriteAllText(Path.GetFullPath(options.ProfileSavePath), json);
+ }
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ Console.WriteLine(json);
+ } else {
+ WriteHumanLine($"Health profile: {report.ProfileName}");
+ foreach (ResolverProbeReport query in report.Queries) {
+ WriteHumanLine($" {query.Summary.Name} {query.Summary.RecordType}: {query.Summary.SuccessfulCandidates}/{query.Summary.CandidateCount} successes; policy {(query.Summary.PolicyPassed ? "pass" : "fail")}");
+ foreach (ResolverProbeReportResult result in query.Results) {
+ WriteProbeResult(result);
+ }
+ }
+ if (report.Benchmark != null) {
+ foreach (ResolverBenchmarkReportResult result in report.Benchmark.Results) {
+ WriteBenchmarkResult(result);
+ }
+ }
+ WriteHumanLine($"Policy result: {(report.PolicyPassed ? "pass" : "fail")}");
+ WriteHumanLine(report.AnswerVariationNote);
+ }
+ return report.PolicyPassed ? 0 : 3;
+ }
+
+ private static int RunSnapshotComparison(CliOptions options) {
+ ResolverScoreSnapshot baseline = ResolverScoreStore.Load(options.CompareBaselinePath!);
+ ResolverScoreSnapshot current = ResolverScoreStore.Load(options.CompareCurrentPath!);
+ ResolverAnswerReference[]? references = options.ReferenceResponsePath == null ? null :
+ DnsClientXJsonSerializer.Deserialize(File.ReadAllText(Path.GetFullPath(options.ReferenceResponsePath)));
+ ResolverScoreComparison comparison = ResolverScoreComparer.Compare(baseline, current, references);
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(comparison));
+ } else {
+ WriteHumanLine($"Comparable scope: {comparison.ScopeComparable}; health regression: {comparison.HealthRegression}");
+ foreach (string reason in comparison.IncomparableReasons) {
+ WriteHumanLine($" {reason}");
+ }
+ foreach (ResolverScoreChange change in comparison.Candidates) {
+ WriteHumanLine($" {change.Target}: {change.Change}; success delta {change.SuccessPercentDelta?.ToString() ?? "unavailable"} percentage points; latency delta {change.AverageMsDelta?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unavailable"} ms");
+ }
+ foreach (ResolverAnswerChange answer in comparison.Answers) {
+ WriteHumanLine($" {answer.Target} {answer.Name} {answer.RecordType}: answers {(!answer.Changed.HasValue ? "unavailable" : answer.Changed.Value ? "changed" : "unchanged")}; reference {answer.ReferenceComparison}");
+ }
+ WriteHumanLine(comparison.AnswerVariationNote);
+ }
+ return comparison.HealthRegression ? 3 : 0;
+ }
+ }
+}
diff --git a/DnsClientX.Cli/Program.Query.cs b/DnsClientX.Cli/Program.Query.cs
new file mode 100644
index 00000000..c420416d
--- /dev/null
+++ b/DnsClientX.Cli/Program.Query.cs
@@ -0,0 +1,227 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static ResolverExecutionClientOptions CreateExecutionClientOptions(CliOptions options, bool useBenchmarkTimeout = false) {
+ if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath)) {
+ options.DnsSecSignatureVerifier ??= DnsSecSignatureVerifierLoader.Load(options.DnsSecVerifierPath!);
+ }
+ string? envPort = Environment.GetEnvironmentVariable("DNSCLIENTX_CLI_PORT");
+ int? customPort = int.TryParse(envPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedPort) && parsedPort > 0
+ ? parsedPort
+ : null;
+
+ return new ResolverExecutionClientOptions {
+ EnableAudit = options.Explain || options.Trace,
+ TimeoutMs = useBenchmarkTimeout ? options.BenchmarkTimeoutMs : null,
+ PortOverride = customPort,
+ ForceDohWirePost = options.WirePost,
+ BootstrapResolver = options.BootstrapResolver,
+ DnsSecSignatureVerifier = options.DnsSecSignatureVerifier,
+ RequestNsid = options.RequestNsid
+ };
+ }
+
+ private static async Task RunZoneTransferAsync(CliOptions options, CancellationToken cancellationToken) {
+ ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options);
+ RecursiveZoneTransferResult result = await ResolverZoneTransferWorkflow.RunRecursiveAsync(
+ new ResolverExecutionTargetSource {
+ BuiltInEndpoints = new[] { options.Endpoint }
+ },
+ options.Domain!,
+ port: clientOptions.PortOverride ?? 53,
+ clientOptions: clientOptions,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ WriteZoneTransferResponse(result, options);
+ return 0;
+ }
+
+ private static async Task RunStandardQueryAsync(CliOptions options, CancellationToken cancellationToken) {
+ ResolverExecutionTargetSource targetSource = CreateStandardQueryTargetSource(options);
+ ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options);
+ ResolverSingleOperationResult result;
+ DnsResponse response;
+ if (options.DoUpdate) {
+ result = await ResolverSingleOperationWorkflow.UpdateAsync(
+ targetSource,
+ options.Zone!,
+ options.UpdateName!,
+ options.RecordType,
+ options.UpdateData!,
+ options.Ttl,
+ clientOptions,
+ cancellationToken).ConfigureAwait(false);
+ response = result.Response;
+
+ Console.WriteLine($"Update status: {response.Status} (retries {response.RetryCount})");
+ if (options.Explain || options.Trace) {
+ WriteExplain(
+ result,
+ operation: "update",
+ target: options.UpdateName!,
+ recordType: options.RecordType,
+ requestDnsSec: false,
+ validateDnsSec: false,
+ trace: options.Trace,
+ zone: options.Zone,
+ ttl: options.Ttl);
+ }
+ } else {
+ result = await ResolverSingleOperationWorkflow.QueryAsync(
+ targetSource,
+ options.Domain!,
+ options.RecordType,
+ options.RequestDnsSec,
+ options.ValidateDnsSec,
+ clientOptions,
+ cancellationToken).ConfigureAwait(false);
+ response = result.Response;
+
+ if (options.OutputFormat == QueryOutputFormat.Json && options.Explain) {
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(new {
+ Response = response,
+ Explanation = DnsQueryExplanation.Create(response, result.AuditTrail),
+ result.SelectionStrategy, result.RequestFormat, result.CacheEnabled,
+ result.ConfiguredResolverHost, result.ConfiguredResolverPort,
+ ElapsedMs = result.Elapsed.TotalMilliseconds
+ }));
+ } else {
+ WriteQueryResponse(response, options, result.Elapsed);
+ }
+
+ if ((options.Explain || options.Trace) && options.OutputFormat != QueryOutputFormat.Json) {
+ WriteExplain(
+ result,
+ operation: "query",
+ target: options.Domain!,
+ recordType: options.RecordType,
+ requestDnsSec: options.RequestDnsSec,
+ validateDnsSec: options.ValidateDnsSec,
+ trace: options.Trace);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(response.Error)) {
+ WriteHumanError(response.Error);
+ return 1;
+ }
+ return 0;
+ }
+
+ private static ResolverExecutionTargetSource CreateStandardQueryTargetSource(CliOptions options) {
+ if (options.HasCustomEndpointInputs) {
+ return new ResolverExecutionTargetSource {
+ ResolverEndpoints = options.ProbeEndpoints.ToArray(),
+ ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(),
+ ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray()
+ };
+ }
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
+ return new ResolverExecutionTargetSource {
+ ResolverSelectionPath = options.ResolverUsePath
+ };
+ }
+
+ return new ResolverExecutionTargetSource {
+ BuiltInEndpoints = new[] { options.Endpoint }
+ };
+ }
+
+ private static Task RunResolverSelectionAsync(CliOptions options, CancellationToken cancellationToken) {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ResolverSelectionResult selection = LoadRecommendedResolverSelection(options.ResolverSelectPath!);
+
+ WriteResolverSelection(selection, options);
+ return Task.FromResult(0);
+ }
+
+ private static ResolverSelectionResult LoadRecommendedResolverSelection(string path) {
+ return ResolverExecutionTargetResolver.LoadRecommendedSelection(path);
+ }
+
+ private static int WriteCapabilities(CliOptions options) {
+ DnsTransportCapabilityInfo[] capabilities = DnsTransportCapabilities.GetCapabilityReport();
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(capabilities));
+ return 0;
+ }
+
+ foreach (string line in DnsTransportCapabilityTextFormatter.BuildLines(capabilities)) {
+ WriteHumanLine(line);
+ }
+
+ return 0;
+ }
+
+ private static int WriteStampInfo(CliOptions options) {
+ DnsStampInfo info = DnsStamp.Describe(options.StampInfo!);
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(info));
+ return 0;
+ }
+
+ Console.WriteLine("DNS Stamp:");
+ Console.WriteLine($" Transport: {info.Transport}");
+ Console.WriteLine($" Request format: {info.RequestFormat}");
+ WriteHumanLine($" Host: {info.Host}");
+ Console.WriteLine($" Port: {info.Port}");
+ if (info.DohUrl != null) {
+ WriteHumanLine($" DoH URL: {info.DohUrl}");
+ }
+ Console.WriteLine($" DNSSEC property: {(info.DnsSecOk ? "yes" : "no")}");
+ WriteHumanLine($" Endpoint: {info.Endpoint}");
+ WriteHumanLine($" Normalized stamp: {info.NormalizedStamp}");
+ return 0;
+ }
+
+ private static async Task RunResolverValidationAsync(CliOptions options, CancellationToken cancellationToken) {
+ ResolverEndpointValidationResult[] results = await EndpointParser.ValidateManyAsync(
+ options.ProbeEndpoints,
+ options.ProbeEndpointFiles,
+ options.ProbeEndpointUrls,
+ cancellationToken).ConfigureAwait(false);
+
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(results));
+ } else {
+ WriteResolverValidationResults(results);
+ }
+
+ return results.Any(result => !result.IsValid) ? 1 : 0;
+ }
+
+ private static void WriteResolverValidationResults(ResolverEndpointValidationResult[] results) {
+ int valid = results.Count(result => result.IsValid);
+ int invalid = results.Length - valid;
+
+ Console.WriteLine("Resolver Validation:");
+ Console.WriteLine($" Entries: {results.Length}");
+ Console.WriteLine($" Valid: {valid}");
+ Console.WriteLine($" Invalid: {invalid}");
+
+ foreach (ResolverEndpointValidationResult result in results) {
+ string location = string.IsNullOrWhiteSpace(result.Source)
+ ? "unknown"
+ : result.LineNumber.HasValue
+ ? $"{result.Source}:{result.LineNumber.Value}"
+ : result.Source;
+
+ if (result.IsValid) {
+ WriteHumanLine($" valid {location} {result.Entry} -> {result.Endpoint}");
+ } else {
+ WriteHumanLine($" invalid {location} {result.Entry} ({result.Error})");
+ }
+ }
+ }
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.ResolverOperations.cs b/DnsClientX.Cli/Program.ResolverOperations.cs
new file mode 100644
index 00000000..67021b55
--- /dev/null
+++ b/DnsClientX.Cli/Program.ResolverOperations.cs
@@ -0,0 +1,404 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static async Task RunBenchmarkAsync(CliOptions options, CancellationToken cancellationToken) {
+ string[] domains = options.BenchmarkDomains
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ DnsRecordType[] recordTypes = options.BenchmarkRecordTypes
+ .Distinct()
+ .ToArray();
+ ResolverExecutionTargetSource targetSource = CreateBenchmarkTargetSource(options);
+ ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false);
+ if (options.HasCustomEndpointInputs && targets.Length == 0) {
+ Console.Error.WriteLine("No custom resolver endpoints were supplied.");
+ return 1;
+ }
+
+ return await RunBenchmarkWithTargetsAsync(
+ options,
+ targets,
+ domains,
+ recordTypes,
+ GetBenchmarkEndpointProfile(options),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task RunBenchmarkWithTargetsAsync(
+ CliOptions options,
+ ResolverExecutionTarget[] targets,
+ string[] domains,
+ DnsRecordType[] recordTypes,
+ string? endpointProfile,
+ CancellationToken cancellationToken) {
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ ResolverBenchmarkReport jsonReport = await ExecuteBenchmarkReportAsync(targets, domains, recordTypes, options, cancellationToken).ConfigureAwait(false);
+ if (options.BenchmarkSavePath != null) {
+ SaveResolverScoreSnapshot(options.BenchmarkSavePath, jsonReport.Snapshot);
+ }
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(jsonReport));
+ return GetBenchmarkExitCode(jsonReport.Evaluation, options);
+ }
+ Console.WriteLine("Benchmark:");
+ WriteHumanLine($" Domains: {string.Join(", ", domains)}");
+ Console.WriteLine($" Types: {string.Join(", ", recordTypes)}");
+ Console.WriteLine($" Attempts per combination: {options.BenchmarkAttempts}");
+ Console.WriteLine($" Timeout (ms): {options.BenchmarkTimeoutMs}");
+ Console.WriteLine($" Concurrency: {options.BenchmarkConcurrency}");
+ Console.WriteLine($" Connections: {options.BenchmarkConnectionMode}");
+ Console.WriteLine($" Detail mode: {(options.BenchmarkSummaryOnly ? "summary-only" : "full")}");
+ if (!string.IsNullOrWhiteSpace(endpointProfile)) {
+ WriteHumanLine($" Endpoint profile: {endpointProfile}");
+ }
+
+ Console.WriteLine($" Candidates: {targets.Length}");
+ Console.WriteLine($" Queries per candidate: {domains.Length * recordTypes.Length * options.BenchmarkAttempts}");
+
+ ResolverBenchmarkReport report = await ExecuteBenchmarkReportAsync(
+ targets,
+ domains,
+ recordTypes,
+ options,
+ cancellationToken).ConfigureAwait(false);
+ if (!options.BenchmarkSummaryOnly) {
+ foreach (ResolverBenchmarkReportResult result in report.Results) {
+ WriteBenchmarkResult(result);
+ }
+ }
+
+ return WriteBenchmarkSummary(report, options);
+ }
+
+ private static void WriteBenchmarkResult(ResolverBenchmarkReportResult result) {
+ foreach (string line in ResolverReportTextFormatter.BuildBenchmarkResultLines(result, FormatDuration)) {
+ Console.WriteLine(line);
+ }
+ }
+
+ private static int WriteBenchmarkSummary(ResolverBenchmarkReport report, CliOptions options) {
+ ResolverBenchmarkEvaluation evaluation = report.Evaluation;
+
+ Console.WriteLine("Benchmark Summary:");
+ Console.WriteLine($" Successful candidates: {report.Summary.SuccessfulCandidates}/{report.Summary.CandidateCount}");
+ Console.WriteLine($" Successful queries: {report.Summary.OverallSuccessCount}/{report.Summary.OverallQueryCount}");
+ Console.WriteLine($" Successful query rate: {report.Summary.OverallSuccessPercent}%");
+ Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeBenchmarkRuntimeCapabilities(report.Summary)}");
+ for (int i = 0; i < report.Results.Length; i++) {
+ Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkRankedLine(report.Results[i], i + 1, FormatDuration));
+ }
+ Console.WriteLine($" Best endpoint: {ResolverReportTextFormatter.DescribeBenchmarkBest(report.Results, report.Summary, FormatDuration)}");
+ WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}");
+
+ int exitCode = GetBenchmarkExitCode(evaluation, options);
+ if (options.BenchmarkSummaryLine) {
+ Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkSummaryLine(report.Summary, exitCode));
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.BenchmarkSavePath)) {
+ SaveResolverScoreSnapshot(options.BenchmarkSavePath!, report.Snapshot);
+ }
+
+ return exitCode;
+ }
+
+ private static ResolverBenchmarkPolicy CreateBenchmarkPolicy(CliOptions options) {
+ return new ResolverBenchmarkPolicy {
+ MinSuccessPercent = options.BenchmarkMinSuccessPercent,
+ MinSuccessfulCandidates = options.BenchmarkMinSuccessfulCandidates
+ };
+ }
+
+ private static int GetBenchmarkExitCode(ResolverBenchmarkEvaluation evaluation, CliOptions options) {
+ if (evaluation.SuccessfulCandidates == 0) {
+ return 1;
+ }
+
+ if (options.BenchmarkMinSuccessfulCandidates.HasValue && evaluation.SuccessfulCandidates < options.BenchmarkMinSuccessfulCandidates.Value) {
+ return 3;
+ }
+
+ if (options.BenchmarkMinSuccessPercent.HasValue && evaluation.OverallSuccessPercent < options.BenchmarkMinSuccessPercent.Value) {
+ return 3;
+ }
+
+ return evaluation.PolicyPassed ? 0 : 1;
+ }
+
+ private static async Task RunProbeAsync(CliOptions options, CancellationToken cancellationToken) {
+ string domain = options.Domain!;
+ ResolverExecutionTargetSource targetSource = CreateProbeTargetSource(options);
+ ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false);
+ if (options.HasCustomEndpointInputs && targets.Length == 0) {
+ Console.Error.WriteLine("No custom resolver endpoints were supplied.");
+ return 1;
+ }
+
+ return await RunProbeWithTargetsAsync(
+ options,
+ targets,
+ domain,
+ GetProbeEndpointProfile(options),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task RunProbeWithTargetsAsync(
+ CliOptions options,
+ ResolverExecutionTarget[] targets,
+ string domain,
+ string endpointProfile,
+ CancellationToken cancellationToken) {
+ if (options.OutputFormat == QueryOutputFormat.Json) {
+ ResolverProbeReport jsonReport = await ExecuteProbeReportAsync(targets, domain, options, cancellationToken).ConfigureAwait(false);
+ if (options.ProbeSavePath != null) {
+ SaveResolverScoreSnapshot(options.ProbeSavePath, jsonReport.Snapshot);
+ }
+ Console.WriteLine(DnsClientXJsonSerializer.Serialize(jsonReport));
+ return GetProbeExitCode(jsonReport.Evaluation, options);
+ }
+ Console.WriteLine("Probe:");
+ WriteHumanLine($" Domain: {domain}");
+ WriteHumanLine($" Endpoint profile: {endpointProfile}");
+ Console.WriteLine($" Candidates: {targets.Length}");
+ Console.WriteLine($" Detail mode: {(options.ProbeSummaryOnly ? "summary-only" : "full")}");
+
+ ResolverProbeReport report = await ExecuteProbeReportAsync(targets, domain, options, cancellationToken).ConfigureAwait(false);
+ if (!options.ProbeSummaryOnly) {
+ foreach (ResolverProbeReportResult result in report.Results) {
+ WriteProbeResult(result);
+ }
+ }
+
+ return WriteProbeSummary(report, options);
+ }
+
+ private static ResolverQueryRunOptions CreateQueryRunOptions(CliOptions options) {
+ ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options, useBenchmarkTimeout: options.Benchmark);
+ return new ResolverQueryRunOptions {
+ TimeoutMs = options.Benchmark ? options.BenchmarkTimeoutMs : Configuration.DefaultTimeout,
+ ConnectionMode = options.BenchmarkConnectionMode,
+ RequestDnsSec = options.RequestDnsSec,
+ ValidateDnsSec = options.ValidateDnsSec,
+ MaxRetries = 1,
+ RetryDelayMs = 0,
+ PortOverride = clientOptions.PortOverride,
+ ForceDohWirePost = clientOptions.ForceDohWirePost,
+ BootstrapResolver = clientOptions.BootstrapResolver,
+ DnsSecSignatureVerifier = clientOptions.DnsSecSignatureVerifier,
+ RequestNsid = clientOptions.RequestNsid
+ };
+ }
+
+ private static string[] GetEffectiveBenchmarkDomains(CliOptions options) {
+ if (options.BenchmarkDomains.Count > 0) {
+ return options.BenchmarkDomains.ToArray();
+ }
+
+ return string.IsNullOrWhiteSpace(options.Domain)
+ ? Array.Empty()
+ : new[] { options.Domain! };
+ }
+
+ private static Task ExecuteBenchmarkReportAsync(
+ ResolverExecutionTarget[] targets,
+ string[] domains,
+ DnsRecordType[] recordTypes,
+ CliOptions options,
+ CancellationToken cancellationToken) {
+ return ResolverBenchmarkWorkflow.RunAsync(
+ targets,
+ domains,
+ recordTypes,
+ options.BenchmarkAttempts,
+ options.BenchmarkConcurrency,
+ options.BenchmarkTimeoutMs,
+ CreateQueryRunOptions(options),
+ CreateBenchmarkPolicy(options),
+ builtInOverride: CreateBuiltInOverride(),
+ explicitOverride: CreateExplicitOverride(),
+ cancellationToken: cancellationToken);
+ }
+
+ private static ResolverExecutionTargetSource CreateBenchmarkTargetSource(CliOptions options) {
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
+ return new ResolverExecutionTargetSource {
+ ResolverSelectionPath = options.ResolverUsePath
+ };
+ }
+
+ if (options.HasCustomEndpointInputs) {
+ return new ResolverExecutionTargetSource {
+ ResolverEndpoints = options.ProbeEndpoints.ToArray(),
+ ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(),
+ ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray()
+ };
+ }
+
+ return new ResolverExecutionTargetSource {
+ BuiltInEndpoints = options.BenchmarkEndpoints.Distinct().ToArray()
+ };
+ }
+
+ private static Task ExecuteProbeReportAsync(
+ ResolverExecutionTarget[] targets,
+ string domain,
+ CliOptions options,
+ CancellationToken cancellationToken) {
+ return ResolverProbeWorkflow.RunAsync(
+ targets,
+ domain,
+ options.RecordType,
+ Configuration.DefaultTimeout,
+ CreateQueryRunOptions(options),
+ CreateProbePolicy(options),
+ builtInOverride: CreateBuiltInOverride(),
+ explicitOverride: CreateExplicitOverride(),
+ cancellationToken: cancellationToken);
+ }
+
+ private static ResolverExecutionTargetSource CreateProbeTargetSource(CliOptions options) {
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
+ return new ResolverExecutionTargetSource {
+ ResolverSelectionPath = options.ResolverUsePath
+ };
+ }
+
+ if (options.HasCustomEndpointInputs) {
+ return new ResolverExecutionTargetSource {
+ ResolverEndpoints = options.ProbeEndpoints.ToArray(),
+ ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(),
+ ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray()
+ };
+ }
+
+ return new ResolverExecutionTargetSource {
+ ProbeProfile = options.Endpoint
+ };
+ }
+
+ private static string? GetBenchmarkEndpointProfile(CliOptions options) {
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
+ return "selected";
+ }
+
+ return options.HasCustomEndpointInputs ? "custom" : null;
+ }
+
+ private static string GetProbeEndpointProfile(CliOptions options) {
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
+ return "selected";
+ }
+
+ return options.HasCustomEndpointInputs ? "custom" : options.Endpoint.ToString();
+ }
+
+ private static Func>? CreateBuiltInOverride() {
+ if (ProbeOverride == null) {
+ return null;
+ }
+
+ return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult(
+ endpoint.ToString(),
+ await ProbeOverride(endpoint, name, cancellationToken).ConfigureAwait(false));
+ }
+
+ private static Func>? CreateExplicitOverride() {
+ if (ProbeEndpointOverride == null) {
+ return null;
+ }
+
+ return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult(
+ ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint),
+ await ProbeEndpointOverride(endpoint, name, cancellationToken).ConfigureAwait(false));
+ }
+
+ private static ResolverQueryAttemptResult CreateOverrideAttemptResult(
+ string target,
+ (DnsResponse Response, TimeSpan Elapsed, string Resolver, DnsRequestFormat RequestFormat) result) {
+ return new ResolverQueryAttemptResult {
+ Target = target,
+ RequestFormat = result.RequestFormat,
+ Resolver = result.Resolver,
+ Response = result.Response,
+ Elapsed = result.Elapsed
+ };
+ }
+
+ private static void WriteProbeResult(ResolverProbeReportResult result) {
+ foreach (string line in ResolverReportTextFormatter.BuildProbeResultLines(result, FormatDuration)) {
+ Console.WriteLine(line);
+ }
+ }
+
+ private static int WriteProbeSummary(ResolverProbeReport report, CliOptions options) {
+ ResolverProbeEvaluation evaluation = report.Evaluation;
+ ResolverProbeReportSummary summary = report.Summary;
+ int successCount = summary.SuccessfulCandidates;
+ Console.WriteLine("Probe Summary:");
+ Console.WriteLine($" Successful probes: {successCount}/{summary.CandidateCount}");
+ Console.WriteLine($" Failed probes: {summary.FailedCandidates}");
+ Console.WriteLine($" Fastest success: {ResolverReportTextFormatter.DescribeProbeFastestSuccess(summary, FormatDuration)}");
+ Console.WriteLine($" Fastest consensus responder: {ResolverReportTextFormatter.DescribeProbeFastestConsensus(summary, FormatDuration)}");
+ Console.WriteLine($" Transport coverage: {ResolverReportTextFormatter.DescribeProbeTransportCoverage(summary)}");
+ Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeProbeRuntimeCapabilities(summary)}");
+ Console.WriteLine($" Answer consensus: {ResolverReportTextFormatter.DescribeProbeAnswerConsensus(summary)}");
+ Console.WriteLine($" Mismatched responders: {ResolverReportTextFormatter.DescribeProbeMismatchedTargets(summary)}");
+ Console.WriteLine($" Distinct answer sets: {summary.DistinctAnswerSets}");
+ Console.WriteLine($" Answer variants: {ResolverReportTextFormatter.DescribeProbeAnswerVariants(summary)}");
+ Console.WriteLine($" Recommended endpoint: {ResolverReportTextFormatter.DescribeProbeRecommended(summary, FormatDuration)}");
+ WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}");
+
+ int exitCode = GetProbeExitCode(evaluation, options);
+ if (options.ProbeSummaryLine) {
+ Console.WriteLine(ResolverReportTextFormatter.BuildProbeSummaryLine(summary, exitCode));
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ProbeSavePath)) {
+ SaveResolverScoreSnapshot(options.ProbeSavePath!, report.Snapshot);
+ }
+
+ return exitCode;
+ }
+
+ private static ResolverProbePolicy CreateProbePolicy(CliOptions options) {
+ return new ResolverProbePolicy {
+ MinSuccessCount = options.ProbeMinSuccessCount,
+ MinSuccessPercent = options.ProbeMinSuccessPercent,
+ MinConsensusPercent = options.ProbeMinConsensusPercent,
+ RequireConsensus = options.ProbeRequireConsensus
+ };
+ }
+
+ private static int GetProbeExitCode(ResolverProbeEvaluation evaluation, CliOptions options) {
+ if (evaluation.SuccessfulCandidates == 0) {
+ return 1;
+ }
+
+ if (options.ProbeMinSuccessCount.HasValue && evaluation.SuccessfulCandidates < options.ProbeMinSuccessCount.Value) {
+ return 3;
+ }
+
+ if (options.ProbeMinSuccessPercent.HasValue && evaluation.SuccessPercent < options.ProbeMinSuccessPercent.Value) {
+ return 3;
+ }
+
+ if (options.ProbeRequireConsensus && evaluation.DistinctAnswerSets > 1) {
+ return 2;
+ }
+
+ if (options.ProbeMinConsensusPercent.HasValue && evaluation.ConsensusPercent < options.ProbeMinConsensusPercent.Value) {
+ return 2;
+ }
+
+ return evaluation.PolicyPassed ? 0 : 1;
+ }
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.Validation.cs b/DnsClientX.Cli/Program.Validation.cs
new file mode 100644
index 00000000..b739a1ac
--- /dev/null
+++ b/DnsClientX.Cli/Program.Validation.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace DnsClientX.Cli {
+ internal static partial class Program {
+ private static bool ValidateParsedOptions(CliOptions options, out string? errorMessage) {
+ errorMessage = null;
+ if (!ValidateOperationalMode(options, out errorMessage)) {
+ return false;
+ }
+ if (options.ProfilePath != null || options.CompareBaselinePath != null) {
+ return true;
+ }
+ if (options.HasCustomEndpointInputs && options.EndpointInputs.Count > 0) {
+ errorMessage = "Use either --endpoint or explicit resolver inputs.";
+ return false;
+ }
+
+ if (options.ShowCapabilities &&
+ (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer ||
+ !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) ||
+ !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate)) {
+ errorMessage = "--capabilities cannot be combined with query, probe, benchmark, update, axfr, resolver selection, or stamp modes.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath) &&
+ (options.ShowCapabilities || !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate ||
+ !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.ZoneTransfer || options.DoUpdate)) {
+ errorMessage = "--dnssec-verifier applies only to query, probe, and benchmark modes.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.StampInfo) &&
+ (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer ||
+ !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) ||
+ options.ResolverValidate)) {
+ errorMessage = "--stamp-info cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes.";
+ return false;
+ }
+
+ if (options.ResolverValidate &&
+ (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer ||
+ !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath))) {
+ errorMessage = "--resolver-validate cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes.";
+ return false;
+ }
+
+ if (options.DoUpdate && (options.Probe || options.Benchmark || options.ZoneTransfer)) {
+ errorMessage = options.Benchmark
+ ? "--benchmark cannot be combined with --update."
+ : options.Probe
+ ? "--probe cannot be combined with --update."
+ : "--axfr cannot be combined with --update.";
+ return false;
+ }
+
+ if (options.Probe && options.Benchmark) {
+ errorMessage = "--probe cannot be combined with --benchmark.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) &&
+ (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer)) {
+ errorMessage = "--resolver-select cannot be combined with probe, benchmark, update, or axfr modes.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) &&
+ (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.DoUpdate || options.ZoneTransfer)) {
+ errorMessage = "--resolver-use cannot be combined with --resolver-select, update, or axfr modes.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) &&
+ (options.EndpointInputs.Count > 0 || options.HasCustomEndpointInputs)) {
+ errorMessage = "--resolver-use cannot be combined with --endpoint, --probe-endpoint, --resolver-file, or --resolver-url.";
+ return false;
+ }
+
+ if (options.ZoneTransfer && (options.Probe || options.Benchmark)) {
+ errorMessage = options.Probe
+ ? "--axfr cannot be combined with --probe."
+ : "--axfr cannot be combined with --benchmark.";
+ return false;
+ }
+
+ if (options.ZoneTransfer && options.ReverseLookup) {
+ errorMessage = "--reverse cannot be combined with --axfr.";
+ return false;
+ }
+
+ if (!ApplyReverseLookupDefaults(options, out errorMessage)) {
+ return false;
+ }
+
+ if (options.ShortOutput && options.OutputFormat != QueryOutputFormat.Pretty) {
+ errorMessage = "--short cannot be combined with --format json or --format raw.";
+ return false;
+ }
+
+ if (options.TxtConcatOutput && options.OutputFormat == QueryOutputFormat.Json) {
+ errorMessage = "--txt-concat cannot be combined with --format json.";
+ return false;
+ }
+
+ if (options.ShowCapabilities &&
+ (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) {
+ errorMessage = "Capability mode supports only default output or --format json.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.StampInfo) &&
+ (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw ||
+ options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost ||
+ !string.IsNullOrWhiteSpace(options.Domain))) {
+ errorMessage = "Stamp mode supports only --stamp-info and optional --format json.";
+ return false;
+ }
+
+ if (options.ResolverValidate &&
+ (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw ||
+ options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost ||
+ !string.IsNullOrWhiteSpace(options.Domain) || !options.HasCustomEndpointInputs)) {
+ errorMessage = "Resolver validation mode requires --resolver-validate with --probe-endpoint, --resolver-file, or --resolver-url, and supports optional --format json.";
+ return false;
+ }
+
+ if ((options.Benchmark || options.Probe || options.DoUpdate) &&
+ (options.ShortOutput || options.TxtConcatOutput || options.OutputFormat == QueryOutputFormat.Raw || options.DoUpdate && options.OutputFormat == QueryOutputFormat.Json || options.HasExplicitSectionSelection || options.TransferSummary)) {
+ errorMessage = "Probe and benchmark support pretty or JSON reports. Query section, raw, short, and TXT switches apply only to queries; updates use pretty output.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) &&
+ (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) {
+ errorMessage = "Resolver selection mode supports only default output or --format json.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) &&
+ (options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw && (options.Probe || options.Benchmark))) {
+ errorMessage = "Resolver reuse mode does not change the output rules of probe or benchmark workflows.";
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) &&
+ (options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost)) {
+ errorMessage = "Query switches do not apply to --resolver-select.";
+ return false;
+ }
+
+ if (options.ZoneTransfer &&
+ (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.OutputFormat == QueryOutputFormat.Raw)) {
+ errorMessage = "AXFR mode supports only --format pretty|json and optional --transfer-summary.";
+ return false;
+ }
+
+ if (options.TransferSummary && !options.ZoneTransfer) {
+ errorMessage = "--transfer-summary applies only to --axfr mode.";
+ return false;
+ }
+
+ if (options.ZoneTransfer && options.RecordTypeSpecified && options.RecordType != DnsRecordType.AXFR) {
+ errorMessage = "--type can be used with --axfr only when set to AXFR.";
+ return false;
+ }
+
+ if (options.DoUpdate) {
+ if (!options.HasCustomEndpointInputs || options.EndpointInputs.Count > 0) {
+ errorMessage = "--update requires an explicit authoritative endpoint via --probe-endpoint, --resolver-file, or --resolver-url; do not combine it with --endpoint.";
+ return false;
+ }
+ if (options.Zone is null || options.UpdateName is null || options.UpdateData is null) {
+ errorMessage = "Invalid --update arguments.";
+ return false;
+ }
+ } else if (options.ShowCapabilities) {
+ // Capability mode does not require a domain.
+ } else if (!string.IsNullOrWhiteSpace(options.StampInfo)) {
+ // Stamp mode requires only the stamp input.
+ } else if (options.ResolverValidate) {
+ // Resolver validation mode requires only resolver inputs.
+ } else if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath)) {
+ // Selection mode requires only the saved snapshot path.
+ } else if (options.ZoneTransfer && string.IsNullOrWhiteSpace(options.Domain)) {
+ errorMessage = "Zone name is required.";
+ return false;
+ } else if (!options.Probe && !options.Benchmark && string.IsNullOrWhiteSpace(options.Domain)) {
+ errorMessage = "Domain name is required.";
+ return false;
+ } else if (options.Probe && string.IsNullOrWhiteSpace(options.Domain)) {
+ options.Domain = "example.com";
+ } else if (options.Benchmark) {
+ if (options.BenchmarkDomains.Count == 0) {
+ options.BenchmarkDomains.Add(!string.IsNullOrWhiteSpace(options.Domain) ? options.Domain! : "example.com");
+ }
+ if (options.BenchmarkEndpoints.Count == 0 && !options.HasCustomEndpointInputs) {
+ options.BenchmarkEndpoints.Add(options.Endpoint);
+ }
+ if (options.BenchmarkRecordTypes.Count == 0) {
+ options.BenchmarkRecordTypes.Add(options.RecordType);
+ }
+ }
+
+ return true;
+ }
+
+ }
+}
diff --git a/DnsClientX.Cli/Program.cs b/DnsClientX.Cli/Program.cs
index d29955d6..e1a81129 100644
--- a/DnsClientX.Cli/Program.cs
+++ b/DnsClientX.Cli/Program.cs
@@ -8,87 +8,7 @@
using System.Threading.Tasks;
namespace DnsClientX.Cli {
- internal static class Program {
- private enum QueryOutputFormat {
- Pretty,
- Json,
- Raw
- }
-
- private sealed class CliOptions {
- public string? Domain { get; set; }
- public DnsRecordType RecordType { get; set; } = DnsRecordType.A;
- public bool RecordTypeSpecified { get; set; }
- public DnsEndpoint Endpoint { get; set; } = DnsEndpoint.System;
- public QueryOutputFormat OutputFormat { get; set; } = QueryOutputFormat.Pretty;
- public bool ShortOutput { get; set; }
- public bool TxtConcatOutput { get; set; }
- public bool ShowQuestionSection { get; set; }
- public bool ShowAnswerSection { get; set; }
- public bool ShowAuthoritySection { get; set; }
- public bool ShowAdditionalSection { get; set; }
- public bool ReverseLookup { get; set; }
- public bool ZoneTransfer { get; set; }
- public bool TransferSummary { get; set; }
- public bool Benchmark { get; set; }
- public int BenchmarkAttempts { get; set; } = 3;
- public int BenchmarkTimeoutMs { get; set; } = 2000;
- public int BenchmarkConcurrency { get; set; } = 4;
- public ResolverQueryConnectionMode BenchmarkConnectionMode { get; set; }
- public int? BenchmarkMinSuccessPercent { get; set; }
- public int? BenchmarkMinSuccessfulCandidates { get; set; }
- public bool BenchmarkSummaryOnly { get; set; }
- public bool BenchmarkSummaryLine { get; set; }
- public List BenchmarkEndpoints { get; } = new List();
- public List BenchmarkDomains { get; } = new List();
- public List BenchmarkRecordTypes { get; } = new List();
- public List EndpointInputs { get; } = new List();
- public List DomainInputs { get; } = new List();
- public List RecordTypeInputs { get; } = new List();
- public List ProbeEndpointFiles { get; } = new List();
- public List ProbeEndpointUrls { get; } = new List();
- public bool RequestDnsSec { get; set; }
- public bool ValidateDnsSec { get; set; }
- public string? DnsSecVerifierPath { get; set; }
- public IDnsSecSignatureVerifier? DnsSecSignatureVerifier { get; set; }
- public bool WirePost { get; set; }
- public DnsResolverEndpoint? BootstrapResolver { get; set; }
- public bool RequestNsid { get; set; }
- public bool Probe { get; set; }
- public List ProbeEndpoints { get; } = new List();
- public string? ProbeSavePath { get; set; }
- public string? ResolverSelectPath { get; set; }
- public string? ResolverUsePath { get; set; }
- public bool ProbeSummaryOnly { get; set; }
- public bool ProbeSummaryLine { get; set; }
- public bool ProbeRequireConsensus { get; set; }
- public int? ProbeMinConsensusPercent { get; set; }
- public int? ProbeMinSuccessCount { get; set; }
- public int? ProbeMinSuccessPercent { get; set; }
- public string? BenchmarkSavePath { get; set; }
- public bool Explain { get; set; }
- public bool Trace { get; set; }
- public bool ShowCapabilities { get; set; }
- public string? StampInfo { get; set; }
- public bool ResolverValidate { get; set; }
- public bool DoUpdate { get; set; }
- public string? Zone { get; set; }
- public string? UpdateName { get; set; }
- public string? UpdateData { get; set; }
- public int Ttl { get; set; } = 300;
-
- public bool HasExplicitSectionSelection =>
- ShowQuestionSection ||
- ShowAnswerSection ||
- ShowAuthoritySection ||
- ShowAdditionalSection;
-
- public bool HasCustomEndpointInputs =>
- ProbeEndpoints.Count > 0 ||
- ProbeEndpointFiles.Count > 0 ||
- ProbeEndpointUrls.Count > 0;
- }
-
+ internal static partial class Program {
private static Func>? ProbeOverride = null;
private static Func>? ProbeEndpointOverride = null;
@@ -116,13 +36,16 @@ private static async Task Main(string[] args) {
}
using var cts = new CancellationTokenSource();
- Console.CancelKeyPress += (_, e) => {
+ ConsoleCancelEventHandler cancelHandler = (_, e) => {
e.Cancel = true;
cts.Cancel();
};
+ Console.CancelKeyPress += cancelHandler;
try {
CliOptions parsedOptions = options!;
+ if (parsedOptions.ProfilePath != null) return await RunProfileAsync(parsedOptions, cts.Token).ConfigureAwait(false);
+ if (parsedOptions.CompareBaselinePath != null) return RunSnapshotComparison(parsedOptions);
if (parsedOptions.Benchmark) {
return await RunBenchmarkAsync(parsedOptions, cts.Token).ConfigureAwait(false);
}
@@ -158,1635 +81,10 @@ private static async Task Main(string[] args) {
} catch (Exception ex) {
WriteHumanError(ex.Message);
return 1;
+ } finally {
+ Console.CancelKeyPress -= cancelHandler;
}
}
- private static bool TryParseArgs(string[] args, out CliOptions? options, out string? errorMessage, out IReadOnlyList? invalidSwitches) {
- options = new CliOptions();
- errorMessage = null;
- var unknown = new List();
-
- for (int i = 0; i < args.Length; i++) {
- string arg = args[i];
- switch (arg) {
- case var opt when opt.Equals("-t", StringComparison.OrdinalIgnoreCase) ||
- opt.Equals("--type", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--type", out string? recordTypeValue, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- AddOptionValues(options.RecordTypeInputs, recordTypeValue);
- options.RecordTypeSpecified = true;
- break;
- case var opt when opt.Equals("-e", StringComparison.OrdinalIgnoreCase) ||
- opt.Equals("--endpoint", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--endpoint", out string? endpointValue, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- AddOptionValues(options.EndpointInputs, endpointValue);
- break;
- case var opt when opt.Equals("--domain", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--domain", out string? domainValue, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- AddOptionValues(options.DomainInputs, domainValue);
- break;
- case var opt when opt.Equals("--reverse", StringComparison.OrdinalIgnoreCase) ||
- opt.Equals("--ptr", StringComparison.OrdinalIgnoreCase):
- options.ReverseLookup = true;
- break;
- case var opt when opt.Equals("--axfr", StringComparison.OrdinalIgnoreCase) ||
- opt.Equals("--zone-transfer", StringComparison.OrdinalIgnoreCase):
- options.ZoneTransfer = true;
- break;
- case var opt when opt.Equals("--transfer-summary", StringComparison.OrdinalIgnoreCase):
- options.TransferSummary = true;
- break;
- case var opt when opt.Equals("--dnssec", StringComparison.OrdinalIgnoreCase):
- options.RequestDnsSec = true;
- break;
- case var opt when opt.Equals("--validate-dnssec", StringComparison.OrdinalIgnoreCase):
- options.ValidateDnsSec = true;
- break;
- case var opt when opt.Equals("--dnssec-verifier", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--dnssec-verifier", out string? verifierPath, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (string.IsNullOrWhiteSpace(verifierPath)) {
- errorMessage = "--dnssec-verifier requires a nonblank local DLL path.";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.DnsSecVerifierPath = verifierPath;
- break;
- case var opt when opt.Equals("--wire-post", StringComparison.OrdinalIgnoreCase):
- options.WirePost = true;
- break;
- case var opt when opt.Equals("--nsid", StringComparison.OrdinalIgnoreCase):
- options.RequestNsid = true;
- break;
- case var opt when opt.Equals("--bootstrap", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--bootstrap", out string? bootstrap, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- try { options.BootstrapResolver = EndpointParser.ParseBootstrap(bootstrap!); }
- catch (Exception ex) when (ex is ArgumentException or NotSupportedException) {
- errorMessage = ex.Message;
- invalidSwitches = null;
- options = null;
- return false;
- }
- break;
- case var opt when opt.Equals("--format", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--format", out string? formatValue, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (!TryParseQueryOutputFormat(formatValue, out QueryOutputFormat outputFormat)) {
- errorMessage = $"Invalid value for --format: {formatValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.OutputFormat = outputFormat;
- break;
- case var opt when opt.Equals("--short", StringComparison.OrdinalIgnoreCase):
- options.ShortOutput = true;
- break;
- case var opt when opt.Equals("--txt-concat", StringComparison.OrdinalIgnoreCase):
- options.TxtConcatOutput = true;
- break;
- case var opt when opt.Equals("--question", StringComparison.OrdinalIgnoreCase):
- options.ShowQuestionSection = true;
- break;
- case var opt when opt.Equals("--answer", StringComparison.OrdinalIgnoreCase):
- options.ShowAnswerSection = true;
- break;
- case var opt when opt.Equals("--authority", StringComparison.OrdinalIgnoreCase):
- options.ShowAuthoritySection = true;
- break;
- case var opt when opt.Equals("--additional", StringComparison.OrdinalIgnoreCase):
- options.ShowAdditionalSection = true;
- break;
- case var opt when opt.Equals("--benchmark", StringComparison.OrdinalIgnoreCase):
- options.Benchmark = true;
- break;
- case var opt when opt.Equals("--benchmark-connections", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-connections", out string? connectionMode, out errorMessage) ||
- (connectionMode != "cold" && connectionMode != "warm") ||
- !Enum.TryParse(connectionMode, true, out ResolverQueryConnectionMode parsedMode) ||
- (parsedMode != ResolverQueryConnectionMode.Cold && parsedMode != ResolverQueryConnectionMode.Warm)) {
- errorMessage = "--benchmark-connections accepts cold or warm.";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkConnectionMode = parsedMode;
- break;
- case var opt when opt.Equals("--benchmark-attempts", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-attempts", out string? benchmarkAttemptsValue, out errorMessage) ||
- !int.TryParse(benchmarkAttemptsValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkAttempts)) {
- errorMessage ??= $"Invalid value for --benchmark-attempts: {benchmarkAttemptsValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (benchmarkAttempts < 1) {
- errorMessage = $"--benchmark-attempts must be at least 1: {benchmarkAttempts}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkAttempts = benchmarkAttempts;
- break;
- case var opt when opt.Equals("--benchmark-timeout", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-timeout", out string? benchmarkTimeoutValue, out errorMessage) ||
- !int.TryParse(benchmarkTimeoutValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkTimeoutMs)) {
- errorMessage ??= $"Invalid value for --benchmark-timeout: {benchmarkTimeoutValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (benchmarkTimeoutMs < 1) {
- errorMessage = $"--benchmark-timeout must be at least 1: {benchmarkTimeoutMs}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkTimeoutMs = benchmarkTimeoutMs;
- break;
- case var opt when opt.Equals("--benchmark-concurrency", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-concurrency", out string? benchmarkConcurrencyValue, out errorMessage) ||
- !int.TryParse(benchmarkConcurrencyValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkConcurrency)) {
- errorMessage ??= $"Invalid value for --benchmark-concurrency: {benchmarkConcurrencyValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (benchmarkConcurrency < 1) {
- errorMessage = $"--benchmark-concurrency must be at least 1: {benchmarkConcurrency}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkConcurrency = benchmarkConcurrency;
- break;
- case var opt when opt.Equals("--benchmark-min-success-percent", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-min-success-percent", out string? benchmarkMinSuccessPercentValue, out errorMessage) ||
- !int.TryParse(benchmarkMinSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessPercent)) {
- errorMessage ??= $"Invalid value for --benchmark-min-success-percent: {benchmarkMinSuccessPercentValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (benchmarkMinSuccessPercent < 1 || benchmarkMinSuccessPercent > 100) {
- errorMessage = $"--benchmark-min-success-percent must be between 1 and 100: {benchmarkMinSuccessPercent}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkMinSuccessPercent = benchmarkMinSuccessPercent;
- break;
- case var opt when opt.Equals("--benchmark-min-successful-candidates", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-min-successful-candidates", out string? benchmarkMinSuccessfulCandidatesValue, out errorMessage) ||
- !int.TryParse(benchmarkMinSuccessfulCandidatesValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int benchmarkMinSuccessfulCandidates)) {
- errorMessage ??= $"Invalid value for --benchmark-min-successful-candidates: {benchmarkMinSuccessfulCandidatesValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (benchmarkMinSuccessfulCandidates < 1) {
- errorMessage = $"--benchmark-min-successful-candidates must be at least 1: {benchmarkMinSuccessfulCandidates}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkMinSuccessfulCandidates = benchmarkMinSuccessfulCandidates;
- break;
- case var opt when opt.Equals("--benchmark-summary-line", StringComparison.OrdinalIgnoreCase):
- options.Benchmark = true;
- options.BenchmarkSummaryLine = true;
- break;
- case var opt when opt.Equals("--benchmark-save", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--benchmark-save", out string? benchmarkSavePath, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Benchmark = true;
- options.BenchmarkSavePath = benchmarkSavePath;
- break;
- case var opt when opt.Equals("--benchmark-summary-only", StringComparison.OrdinalIgnoreCase):
- options.Benchmark = true;
- options.BenchmarkSummaryOnly = true;
- break;
- case var opt when opt.Equals("--probe", StringComparison.OrdinalIgnoreCase):
- options.Probe = true;
- break;
- case var opt when opt.Equals("--probe-endpoint", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--probe-endpoint", out string? probeEndpoint, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- AddOptionValues(options.ProbeEndpoints, probeEndpoint);
- break;
- case var opt when opt.Equals("--resolver-file", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--resolver-file", out string? resolverFile, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- AddOptionValues(options.ProbeEndpointFiles, resolverFile);
- break;
- case var opt when opt.Equals("--resolver-url", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--resolver-url", out string? resolverUrl, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- AddOptionValues(options.ProbeEndpointUrls, resolverUrl);
- break;
- case var opt when opt.Equals("--resolver-validate", StringComparison.OrdinalIgnoreCase):
- options.ResolverValidate = true;
- break;
- case var opt when opt.Equals("--probe-summary-only", StringComparison.OrdinalIgnoreCase):
- options.Probe = true;
- options.ProbeSummaryOnly = true;
- break;
- case var opt when opt.Equals("--probe-summary-line", StringComparison.OrdinalIgnoreCase):
- options.Probe = true;
- options.ProbeSummaryLine = true;
- break;
- case var opt when opt.Equals("--resolver-select", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--resolver-select", out string? resolverSelectPath, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.ResolverSelectPath = resolverSelectPath;
- break;
- case var opt when opt.Equals("--resolver-use", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--resolver-use", out string? resolverUsePath, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.ResolverUsePath = resolverUsePath;
- break;
- case var opt when opt.Equals("--probe-save", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--probe-save", out string? probeSavePath, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Probe = true;
- options.ProbeSavePath = probeSavePath;
- break;
- case var opt when opt.Equals("--probe-require-consensus", StringComparison.OrdinalIgnoreCase):
- options.Probe = true;
- options.ProbeRequireConsensus = true;
- break;
- case var opt when opt.Equals("--probe-min-consensus", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--probe-min-consensus", out string? minConsensusValue, out errorMessage) ||
- !int.TryParse(minConsensusValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minConsensusPercent)) {
- errorMessage ??= $"Invalid value for --probe-min-consensus: {minConsensusValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (minConsensusPercent < 1 || minConsensusPercent > 100) {
- errorMessage = $"--probe-min-consensus must be between 1 and 100: {minConsensusPercent}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Probe = true;
- options.ProbeMinConsensusPercent = minConsensusPercent;
- break;
- case var opt when opt.Equals("--probe-min-success", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--probe-min-success", out string? minSuccessValue, out errorMessage) ||
- !int.TryParse(minSuccessValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessCount)) {
- errorMessage ??= $"Invalid value for --probe-min-success: {minSuccessValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (minSuccessCount < 1) {
- errorMessage = $"--probe-min-success must be at least 1: {minSuccessCount}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Probe = true;
- options.ProbeMinSuccessCount = minSuccessCount;
- break;
- case var opt when opt.Equals("--probe-min-success-percent", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--probe-min-success-percent", out string? minSuccessPercentValue, out errorMessage) ||
- !int.TryParse(minSuccessPercentValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minSuccessPercent)) {
- errorMessage ??= $"Invalid value for --probe-min-success-percent: {minSuccessPercentValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (minSuccessPercent < 1 || minSuccessPercent > 100) {
- errorMessage = $"--probe-min-success-percent must be between 1 and 100: {minSuccessPercent}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Probe = true;
- options.ProbeMinSuccessPercent = minSuccessPercent;
- break;
- case var opt when opt.Equals("--explain", StringComparison.OrdinalIgnoreCase):
- options.Explain = true;
- break;
- case var opt when opt.Equals("--trace", StringComparison.OrdinalIgnoreCase):
- options.Trace = true;
- options.Explain = true;
- break;
- case var opt when opt.Equals("--capabilities", StringComparison.OrdinalIgnoreCase):
- options.ShowCapabilities = true;
- break;
- case var opt when opt.Equals("--stamp-info", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--stamp-info", out string? stampInfo, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.StampInfo = stampInfo;
- break;
- case var opt when opt.Equals("--update", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--update", out string? zone, out errorMessage) ||
- !TryReadNext(args, ref i, "--update", out string? updateName, out errorMessage) ||
- !TryReadNext(args, ref i, "--update", out string? updateTypeValue, out errorMessage) ||
- !TryReadNext(args, ref i, "--update", out string? updateData, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (!Enum.TryParse(updateTypeValue, true, out DnsRecordType updateType)) {
- errorMessage = $"Invalid value for --update type: {updateTypeValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.DoUpdate = true;
- options.Zone = zone;
- options.UpdateName = updateName;
- options.RecordType = updateType;
- options.UpdateData = updateData;
- break;
- case var opt when opt.Equals("--ttl", StringComparison.OrdinalIgnoreCase):
- if (!TryReadNext(args, ref i, "--ttl", out string? ttlValue, out errorMessage) ||
- !int.TryParse(ttlValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int ttl)) {
- errorMessage ??= $"Invalid value for --ttl: {ttlValue}";
- invalidSwitches = null;
- options = null;
- return false;
- }
- options.Ttl = ttl;
- break;
- default:
- if (options.Domain is null && !arg.StartsWith("-", StringComparison.Ordinal)) {
- options.Domain = arg;
- } else {
- unknown.Add(arg);
- }
- break;
- }
- }
-
- if (unknown.Count > 0) {
- invalidSwitches = unknown;
- options = null;
- return false;
- }
-
- if (!FinalizeParsedLists(options, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.HasCustomEndpointInputs && !options.Benchmark && !options.ResolverValidate && !options.DoUpdate) {
- options.Probe = true;
- }
-
- if (options.ShowCapabilities &&
- (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer ||
- !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) ||
- !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate)) {
- errorMessage = "--capabilities cannot be combined with query, probe, benchmark, update, axfr, resolver selection, or stamp modes.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath) &&
- (options.ShowCapabilities || !string.IsNullOrWhiteSpace(options.StampInfo) || options.ResolverValidate ||
- !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.ZoneTransfer || options.DoUpdate)) {
- errorMessage = "--dnssec-verifier applies only to query, probe, and benchmark modes.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.StampInfo) &&
- (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer ||
- !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath) ||
- options.ResolverValidate)) {
- errorMessage = "--stamp-info cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ResolverValidate &&
- (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer ||
- !string.IsNullOrWhiteSpace(options.ResolverSelectPath) || !string.IsNullOrWhiteSpace(options.ResolverUsePath))) {
- errorMessage = "--resolver-validate cannot be combined with query, probe, benchmark, update, axfr, or resolver selection modes.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.DoUpdate && (options.Probe || options.Benchmark || options.ZoneTransfer)) {
- errorMessage = options.Benchmark
- ? "--benchmark cannot be combined with --update."
- : options.Probe
- ? "--probe cannot be combined with --update."
- : "--axfr cannot be combined with --update.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.Probe && options.Benchmark) {
- errorMessage = "--probe cannot be combined with --benchmark.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) &&
- (options.Probe || options.Benchmark || options.DoUpdate || options.ZoneTransfer)) {
- errorMessage = "--resolver-select cannot be combined with probe, benchmark, update, or axfr modes.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) &&
- (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) || options.DoUpdate || options.ZoneTransfer)) {
- errorMessage = "--resolver-use cannot be combined with --resolver-select, update, or axfr modes.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) &&
- (options.EndpointInputs.Count > 0 || options.HasCustomEndpointInputs)) {
- errorMessage = "--resolver-use cannot be combined with --endpoint, --probe-endpoint, --resolver-file, or --resolver-url.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ZoneTransfer && (options.Probe || options.Benchmark)) {
- errorMessage = options.Probe
- ? "--axfr cannot be combined with --probe."
- : "--axfr cannot be combined with --benchmark.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ZoneTransfer && options.ReverseLookup) {
- errorMessage = "--reverse cannot be combined with --axfr.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!ApplyReverseLookupDefaults(options, out errorMessage)) {
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ShortOutput && options.OutputFormat != QueryOutputFormat.Pretty) {
- errorMessage = "--short cannot be combined with --format json or --format raw.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.TxtConcatOutput && options.OutputFormat == QueryOutputFormat.Json) {
- errorMessage = "--txt-concat cannot be combined with --format json.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ShowCapabilities &&
- (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) {
- errorMessage = "Capability mode supports only default output or --format json.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.StampInfo) &&
- (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw ||
- options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost ||
- !string.IsNullOrWhiteSpace(options.Domain))) {
- errorMessage = "Stamp mode supports only --stamp-info and optional --format json.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ResolverValidate &&
- (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw ||
- options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost ||
- !string.IsNullOrWhiteSpace(options.Domain) || !options.HasCustomEndpointInputs)) {
- errorMessage = "Resolver validation mode requires --resolver-validate with --probe-endpoint, --resolver-file, or --resolver-url, and supports optional --format json.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if ((options.Benchmark || options.Probe || options.DoUpdate) &&
- (options.ShortOutput || options.TxtConcatOutput || options.OutputFormat != QueryOutputFormat.Pretty || options.HasExplicitSectionSelection || options.TransferSummary)) {
- errorMessage = "Query output switches (--format, --short, --txt-concat, --question, --answer, --authority, --additional) apply only to standard query mode.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) &&
- (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw)) {
- errorMessage = "Resolver selection mode supports only default output or --format json.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath) &&
- (options.TransferSummary || options.OutputFormat == QueryOutputFormat.Raw && (options.Probe || options.Benchmark))) {
- errorMessage = "Resolver reuse mode does not change the output rules of probe or benchmark workflows.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath) &&
- (options.ReverseLookup || options.RecordTypeSpecified || options.RequestDnsSec || options.ValidateDnsSec || options.WirePost)) {
- errorMessage = "Query switches do not apply to --resolver-select.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ZoneTransfer &&
- (options.ShortOutput || options.TxtConcatOutput || options.HasExplicitSectionSelection || options.OutputFormat == QueryOutputFormat.Raw)) {
- errorMessage = "AXFR mode supports only --format pretty|json and optional --transfer-summary.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.TransferSummary && !options.ZoneTransfer) {
- errorMessage = "--transfer-summary applies only to --axfr mode.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.ZoneTransfer && options.RecordTypeSpecified && options.RecordType != DnsRecordType.AXFR) {
- errorMessage = "--type can be used with --axfr only when set to AXFR.";
- invalidSwitches = null;
- options = null;
- return false;
- }
-
- if (options.DoUpdate) {
- if (!options.HasCustomEndpointInputs || options.EndpointInputs.Count > 0) {
- errorMessage = "--update requires an explicit authoritative endpoint via --probe-endpoint, --resolver-file, or --resolver-url; do not combine it with --endpoint.";
- invalidSwitches = null;
- options = null;
- return false;
- }
- if (options.Zone is null || options.UpdateName is null || options.UpdateData is null) {
- errorMessage = "Invalid --update arguments.";
- invalidSwitches = null;
- options = null;
- return false;
- }
- } else if (options.ShowCapabilities) {
- // Capability mode does not require a domain.
- } else if (!string.IsNullOrWhiteSpace(options.StampInfo)) {
- // Stamp mode requires only the stamp input.
- } else if (options.ResolverValidate) {
- // Resolver validation mode requires only resolver inputs.
- } else if (!string.IsNullOrWhiteSpace(options.ResolverSelectPath)) {
- // Selection mode requires only the saved snapshot path.
- } else if (options.ZoneTransfer && string.IsNullOrWhiteSpace(options.Domain)) {
- errorMessage = "Zone name is required.";
- invalidSwitches = null;
- options = null;
- return false;
- } else if (!options.Probe && !options.Benchmark && string.IsNullOrWhiteSpace(options.Domain)) {
- errorMessage = "Domain name is required.";
- invalidSwitches = null;
- options = null;
- return false;
- } else if (options.Probe && string.IsNullOrWhiteSpace(options.Domain)) {
- options.Domain = "example.com";
- } else if (options.Benchmark) {
- if (options.BenchmarkDomains.Count == 0) {
- options.BenchmarkDomains.Add(!string.IsNullOrWhiteSpace(options.Domain) ? options.Domain! : "example.com");
- }
- if (options.BenchmarkEndpoints.Count == 0 && !options.HasCustomEndpointInputs) {
- options.BenchmarkEndpoints.Add(options.Endpoint);
- }
- if (options.BenchmarkRecordTypes.Count == 0) {
- options.BenchmarkRecordTypes.Add(options.RecordType);
- }
- }
-
- invalidSwitches = Array.Empty();
- return true;
- }
-
- private static bool TryReadNext(string[] args, ref int index, string optionName, out string? value, out string? errorMessage) {
- if (index + 1 >= args.Length) {
- value = null;
- errorMessage = $"Missing value for {optionName}";
- return false;
- }
-
- value = args[++index];
- errorMessage = null;
- return true;
- }
-
- private static bool TryParseQueryOutputFormat(string? value, out QueryOutputFormat format) {
- switch (value?.Trim().ToLowerInvariant()) {
- case "pretty":
- format = QueryOutputFormat.Pretty;
- return true;
- case "json":
- format = QueryOutputFormat.Json;
- return true;
- case "raw":
- format = QueryOutputFormat.Raw;
- return true;
- default:
- format = default;
- return false;
- }
- }
-
- private static bool ApplyReverseLookupDefaults(CliOptions options, out string? errorMessage) {
- errorMessage = null;
-
- if (options.ReverseLookup) {
- if (options.RecordTypeSpecified &&
- options.BenchmarkRecordTypes.Any(recordType => recordType != DnsRecordType.PTR)) {
- errorMessage = "--reverse cannot be combined with a record type other than PTR.";
- return false;
- }
-
- options.RecordType = DnsRecordType.PTR;
- if (options.BenchmarkRecordTypes.Count > 0) {
- options.BenchmarkRecordTypes.Clear();
- options.BenchmarkRecordTypes.Add(DnsRecordType.PTR);
- }
-
- return true;
- }
-
- if (options.RecordTypeSpecified || options.DoUpdate) {
- return true;
- }
-
- string[] effectiveBenchmarkDomains = options.Benchmark ? GetEffectiveBenchmarkDomains(options) : Array.Empty();
- bool shouldUsePtrByDefault = options.Benchmark
- ? effectiveBenchmarkDomains.Length > 0 && effectiveBenchmarkDomains.All(IsIpAddressLiteral)
- : !string.IsNullOrWhiteSpace(options.Domain) && IsIpAddressLiteral(options.Domain);
-
- if (shouldUsePtrByDefault) {
- options.RecordType = DnsRecordType.PTR;
- }
-
- return true;
- }
-
- private static bool IsIpAddressLiteral(string? value) {
- if (string.IsNullOrWhiteSpace(value)) {
- return false;
- }
-
- string trimmed = value!.Trim();
- return IPAddress.TryParse(trimmed, out _);
- }
-
- private static void AddOptionValues(ICollection target, string? rawValue) {
- foreach (string value in SplitOptionValues(rawValue)) {
- target.Add(value);
- }
- }
-
- private static IEnumerable SplitOptionValues(string? rawValue) {
- if (string.IsNullOrWhiteSpace(rawValue)) {
- yield break;
- }
-
- string valueText = rawValue!;
- string[] parts = valueText.Split(',');
- foreach (string part in parts) {
- string value = part.Trim();
- if (!string.IsNullOrWhiteSpace(value)) {
- yield return value;
- }
- }
- }
-
- private static bool FinalizeParsedLists(CliOptions options, out string? errorMessage) {
- errorMessage = null;
-
- if (options.EndpointInputs.Count > 0) {
- foreach (string endpointValue in options.EndpointInputs) {
- if (!Enum.TryParse(endpointValue, true, out DnsEndpoint endpoint)) {
- errorMessage = $"Invalid value for --endpoint: {endpointValue}";
- return false;
- }
- if (endpoint == DnsEndpoint.Custom) {
- errorMessage = "--endpoint Custom is not supported in the CLI. Use explicit resolver endpoint syntax such as --probe-endpoint, --resolver-file, or --resolver-url instead.";
- return false;
- }
- options.BenchmarkEndpoints.Add(endpoint);
- }
-
- options.Endpoint = options.BenchmarkEndpoints[0];
- if (!options.Benchmark && options.BenchmarkEndpoints.Count > 1) {
- errorMessage = "--endpoint accepts multiple values only with --benchmark.";
- return false;
- }
- }
-
- if (options.RecordTypeInputs.Count > 0) {
- foreach (string recordTypeValue in options.RecordTypeInputs) {
- if (!Enum.TryParse(recordTypeValue, true, out DnsRecordType recordType)) {
- errorMessage = $"Invalid value for --type: {recordTypeValue}";
- return false;
- }
- options.BenchmarkRecordTypes.Add(recordType);
- }
-
- options.RecordType = options.BenchmarkRecordTypes[0];
- if (!options.Benchmark && options.BenchmarkRecordTypes.Count > 1) {
- errorMessage = "--type accepts multiple values only with --benchmark.";
- return false;
- }
- }
-
- if (options.DomainInputs.Count > 0) {
- foreach (string domainValue in options.DomainInputs) {
- options.BenchmarkDomains.Add(domainValue);
- }
-
- options.Domain = options.BenchmarkDomains[0];
- if (!options.Benchmark && options.BenchmarkDomains.Count > 1) {
- errorMessage = "--domain accepts multiple values only with --benchmark.";
- return false;
- }
- }
-
- return true;
- }
-
- private static ResolverExecutionClientOptions CreateExecutionClientOptions(CliOptions options, bool useBenchmarkTimeout = false) {
- if (!string.IsNullOrWhiteSpace(options.DnsSecVerifierPath))
- options.DnsSecSignatureVerifier ??= DnsSecSignatureVerifierLoader.Load(options.DnsSecVerifierPath!);
- string? envPort = Environment.GetEnvironmentVariable("DNSCLIENTX_CLI_PORT");
- int? customPort = int.TryParse(envPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedPort) && parsedPort > 0
- ? parsedPort
- : null;
-
- return new ResolverExecutionClientOptions {
- EnableAudit = options.Explain || options.Trace,
- TimeoutMs = useBenchmarkTimeout ? options.BenchmarkTimeoutMs : null,
- PortOverride = customPort,
- ForceDohWirePost = options.WirePost,
- BootstrapResolver = options.BootstrapResolver,
- DnsSecSignatureVerifier = options.DnsSecSignatureVerifier,
- RequestNsid = options.RequestNsid
- };
- }
-
- private static async Task RunZoneTransferAsync(CliOptions options, CancellationToken cancellationToken) {
- ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options);
- RecursiveZoneTransferResult result = await ResolverZoneTransferWorkflow.RunRecursiveAsync(
- new ResolverExecutionTargetSource {
- BuiltInEndpoints = new[] { options.Endpoint }
- },
- options.Domain!,
- port: clientOptions.PortOverride ?? 53,
- clientOptions: clientOptions,
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- WriteZoneTransferResponse(result, options);
- return 0;
- }
-
- private static async Task RunStandardQueryAsync(CliOptions options, CancellationToken cancellationToken) {
- ResolverExecutionTargetSource targetSource = CreateStandardQueryTargetSource(options);
- ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options);
- ResolverSingleOperationResult result;
- DnsResponse response;
- if (options.DoUpdate) {
- result = await ResolverSingleOperationWorkflow.UpdateAsync(
- targetSource,
- options.Zone!,
- options.UpdateName!,
- options.RecordType,
- options.UpdateData!,
- options.Ttl,
- clientOptions,
- cancellationToken).ConfigureAwait(false);
- response = result.Response;
-
- Console.WriteLine($"Update status: {response.Status} (retries {response.RetryCount})");
- if (options.Explain || options.Trace) {
- WriteExplain(
- result,
- operation: "update",
- target: options.UpdateName!,
- recordType: options.RecordType,
- requestDnsSec: false,
- validateDnsSec: false,
- trace: options.Trace,
- zone: options.Zone,
- ttl: options.Ttl);
- }
- } else {
- result = await ResolverSingleOperationWorkflow.QueryAsync(
- targetSource,
- options.Domain!,
- options.RecordType,
- options.RequestDnsSec,
- options.ValidateDnsSec,
- clientOptions,
- cancellationToken).ConfigureAwait(false);
- response = result.Response;
-
- WriteQueryResponse(response, options, result.Elapsed);
-
- if (options.Explain || options.Trace) {
- WriteExplain(
- result,
- operation: "query",
- target: options.Domain!,
- recordType: options.RecordType,
- requestDnsSec: options.RequestDnsSec,
- validateDnsSec: options.ValidateDnsSec,
- trace: options.Trace);
- }
- }
-
- if (!string.IsNullOrWhiteSpace(response.Error)) {
- WriteHumanError(response.Error);
- return 1;
- }
- return 0;
- }
-
- private static ResolverExecutionTargetSource CreateStandardQueryTargetSource(CliOptions options) {
- if (options.DoUpdate && options.HasCustomEndpointInputs) {
- return new ResolverExecutionTargetSource {
- ResolverEndpoints = options.ProbeEndpoints.ToArray(),
- ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(),
- ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray()
- };
- }
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
- return new ResolverExecutionTargetSource {
- ResolverSelectionPath = options.ResolverUsePath
- };
- }
-
- return new ResolverExecutionTargetSource {
- BuiltInEndpoints = new[] { options.Endpoint }
- };
- }
-
- private static Task RunResolverSelectionAsync(CliOptions options, CancellationToken cancellationToken) {
- cancellationToken.ThrowIfCancellationRequested();
-
- ResolverSelectionResult selection = LoadRecommendedResolverSelection(options.ResolverSelectPath!);
-
- WriteResolverSelection(selection, options);
- return Task.FromResult(0);
- }
-
- private static ResolverSelectionResult LoadRecommendedResolverSelection(string path) {
- return ResolverExecutionTargetResolver.LoadRecommendedSelection(path);
- }
-
- private static int WriteCapabilities(CliOptions options) {
- DnsTransportCapabilityInfo[] capabilities = DnsTransportCapabilities.GetCapabilityReport();
- if (options.OutputFormat == QueryOutputFormat.Json) {
- Console.WriteLine(DnsClientXJsonSerializer.Serialize(capabilities));
- return 0;
- }
-
- foreach (string line in DnsTransportCapabilityTextFormatter.BuildLines(capabilities)) {
- WriteHumanLine(line);
- }
-
- return 0;
- }
-
- private static int WriteStampInfo(CliOptions options) {
- DnsStampInfo info = DnsStamp.Describe(options.StampInfo!);
- if (options.OutputFormat == QueryOutputFormat.Json) {
- Console.WriteLine(DnsClientXJsonSerializer.Serialize(info));
- return 0;
- }
-
- Console.WriteLine("DNS Stamp:");
- Console.WriteLine($" Transport: {info.Transport}");
- Console.WriteLine($" Request format: {info.RequestFormat}");
- WriteHumanLine($" Host: {info.Host}");
- Console.WriteLine($" Port: {info.Port}");
- if (info.DohUrl != null) {
- WriteHumanLine($" DoH URL: {info.DohUrl}");
- }
- Console.WriteLine($" DNSSEC property: {(info.DnsSecOk ? "yes" : "no")}");
- WriteHumanLine($" Endpoint: {info.Endpoint}");
- WriteHumanLine($" Normalized stamp: {info.NormalizedStamp}");
- return 0;
- }
-
- private static async Task RunResolverValidationAsync(CliOptions options, CancellationToken cancellationToken) {
- ResolverEndpointValidationResult[] results = await EndpointParser.ValidateManyAsync(
- options.ProbeEndpoints,
- options.ProbeEndpointFiles,
- options.ProbeEndpointUrls,
- cancellationToken).ConfigureAwait(false);
-
- if (options.OutputFormat == QueryOutputFormat.Json) {
- Console.WriteLine(DnsClientXJsonSerializer.Serialize(results));
- } else {
- WriteResolverValidationResults(results);
- }
-
- return results.Any(result => !result.IsValid) ? 1 : 0;
- }
-
- private static void WriteResolverValidationResults(ResolverEndpointValidationResult[] results) {
- int valid = results.Count(result => result.IsValid);
- int invalid = results.Length - valid;
-
- Console.WriteLine("Resolver Validation:");
- Console.WriteLine($" Entries: {results.Length}");
- Console.WriteLine($" Valid: {valid}");
- Console.WriteLine($" Invalid: {invalid}");
-
- foreach (ResolverEndpointValidationResult result in results) {
- string location = string.IsNullOrWhiteSpace(result.Source)
- ? "unknown"
- : result.LineNumber.HasValue
- ? $"{result.Source}:{result.LineNumber.Value}"
- : result.Source;
-
- if (result.IsValid) {
- WriteHumanLine($" valid {location} {result.Entry} -> {result.Endpoint}");
- } else {
- WriteHumanLine($" invalid {location} {result.Entry} ({result.Error})");
- }
- }
- }
-
- private static async Task RunBenchmarkAsync(CliOptions options, CancellationToken cancellationToken) {
- string[] domains = options.BenchmarkDomains
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray();
- DnsRecordType[] recordTypes = options.BenchmarkRecordTypes
- .Distinct()
- .ToArray();
- ResolverExecutionTargetSource targetSource = CreateBenchmarkTargetSource(options);
- ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false);
- if (options.HasCustomEndpointInputs && targets.Length == 0) {
- Console.Error.WriteLine("No custom resolver endpoints were supplied.");
- return 1;
- }
-
- return await RunBenchmarkWithTargetsAsync(
- options,
- targets,
- domains,
- recordTypes,
- GetBenchmarkEndpointProfile(options),
- cancellationToken).ConfigureAwait(false);
- }
-
- private static async Task RunBenchmarkWithTargetsAsync(
- CliOptions options,
- ResolverExecutionTarget[] targets,
- string[] domains,
- DnsRecordType[] recordTypes,
- string? endpointProfile,
- CancellationToken cancellationToken) {
- Console.WriteLine("Benchmark:");
- WriteHumanLine($" Domains: {string.Join(", ", domains)}");
- Console.WriteLine($" Types: {string.Join(", ", recordTypes)}");
- Console.WriteLine($" Attempts per combination: {options.BenchmarkAttempts}");
- Console.WriteLine($" Timeout (ms): {options.BenchmarkTimeoutMs}");
- Console.WriteLine($" Concurrency: {options.BenchmarkConcurrency}");
- Console.WriteLine($" Connections: {options.BenchmarkConnectionMode}");
- Console.WriteLine($" Detail mode: {(options.BenchmarkSummaryOnly ? "summary-only" : "full")}");
- if (!string.IsNullOrWhiteSpace(endpointProfile)) {
- WriteHumanLine($" Endpoint profile: {endpointProfile}");
- }
-
- Console.WriteLine($" Candidates: {targets.Length}");
- Console.WriteLine($" Queries per candidate: {domains.Length * recordTypes.Length * options.BenchmarkAttempts}");
-
- ResolverBenchmarkReport report = await ExecuteBenchmarkReportAsync(
- targets,
- domains,
- recordTypes,
- options,
- cancellationToken).ConfigureAwait(false);
- if (!options.BenchmarkSummaryOnly) {
- foreach (ResolverBenchmarkReportResult result in report.Results) {
- WriteBenchmarkResult(result);
- }
- }
-
- return WriteBenchmarkSummary(report, options);
- }
-
- private static void WriteBenchmarkResult(ResolverBenchmarkReportResult result) {
- foreach (string line in ResolverReportTextFormatter.BuildBenchmarkResultLines(result, FormatDuration)) {
- Console.WriteLine(line);
- }
- }
-
- private static int WriteBenchmarkSummary(ResolverBenchmarkReport report, CliOptions options) {
- ResolverBenchmarkEvaluation evaluation = report.Evaluation;
-
- Console.WriteLine("Benchmark Summary:");
- Console.WriteLine($" Successful candidates: {report.Summary.SuccessfulCandidates}/{report.Summary.CandidateCount}");
- Console.WriteLine($" Successful queries: {report.Summary.OverallSuccessCount}/{report.Summary.OverallQueryCount}");
- Console.WriteLine($" Successful query rate: {report.Summary.OverallSuccessPercent}%");
- Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeBenchmarkRuntimeCapabilities(report.Summary)}");
- for (int i = 0; i < report.Results.Length; i++) {
- Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkRankedLine(report.Results[i], i + 1, FormatDuration));
- }
- Console.WriteLine($" Best endpoint: {ResolverReportTextFormatter.DescribeBenchmarkBest(report.Results, report.Summary, FormatDuration)}");
- WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}");
-
- int exitCode = GetBenchmarkExitCode(evaluation, options);
- if (options.BenchmarkSummaryLine) {
- Console.WriteLine(ResolverReportTextFormatter.BuildBenchmarkSummaryLine(report.Summary, exitCode));
- }
-
- if (!string.IsNullOrWhiteSpace(options.BenchmarkSavePath)) {
- SaveResolverScoreSnapshot(options.BenchmarkSavePath!, report.Snapshot);
- }
-
- return exitCode;
- }
-
- private static ResolverBenchmarkPolicy CreateBenchmarkPolicy(CliOptions options) {
- return new ResolverBenchmarkPolicy {
- MinSuccessPercent = options.BenchmarkMinSuccessPercent,
- MinSuccessfulCandidates = options.BenchmarkMinSuccessfulCandidates
- };
- }
-
- private static int GetBenchmarkExitCode(ResolverBenchmarkEvaluation evaluation, CliOptions options) {
- if (evaluation.SuccessfulCandidates == 0) {
- return 1;
- }
-
- if (options.BenchmarkMinSuccessfulCandidates.HasValue && evaluation.SuccessfulCandidates < options.BenchmarkMinSuccessfulCandidates.Value) {
- return 3;
- }
-
- if (options.BenchmarkMinSuccessPercent.HasValue && evaluation.OverallSuccessPercent < options.BenchmarkMinSuccessPercent.Value) {
- return 3;
- }
-
- return evaluation.PolicyPassed ? 0 : 1;
- }
-
- private static async Task RunProbeAsync(CliOptions options, CancellationToken cancellationToken) {
- string domain = options.Domain!;
- ResolverExecutionTargetSource targetSource = CreateProbeTargetSource(options);
- ResolverExecutionTarget[] targets = await ResolverExecutionTargetResolver.ResolveAsync(targetSource, cancellationToken).ConfigureAwait(false);
- if (options.HasCustomEndpointInputs && targets.Length == 0) {
- Console.Error.WriteLine("No custom resolver endpoints were supplied.");
- return 1;
- }
-
- return await RunProbeWithTargetsAsync(
- options,
- targets,
- domain,
- GetProbeEndpointProfile(options),
- cancellationToken).ConfigureAwait(false);
- }
-
- private static async Task RunProbeWithTargetsAsync(
- CliOptions options,
- ResolverExecutionTarget[] targets,
- string domain,
- string endpointProfile,
- CancellationToken cancellationToken) {
- Console.WriteLine("Probe:");
- WriteHumanLine($" Domain: {domain}");
- WriteHumanLine($" Endpoint profile: {endpointProfile}");
- Console.WriteLine($" Candidates: {targets.Length}");
- Console.WriteLine($" Detail mode: {(options.ProbeSummaryOnly ? "summary-only" : "full")}");
-
- ResolverProbeReport report = await ExecuteProbeReportAsync(targets, domain, options, cancellationToken).ConfigureAwait(false);
- if (!options.ProbeSummaryOnly) {
- foreach (ResolverProbeReportResult result in report.Results) {
- WriteProbeResult(result);
- }
- }
-
- return WriteProbeSummary(report, options);
- }
-
- private static ResolverQueryRunOptions CreateQueryRunOptions(CliOptions options) {
- ResolverExecutionClientOptions clientOptions = CreateExecutionClientOptions(options, useBenchmarkTimeout: options.Benchmark);
- return new ResolverQueryRunOptions {
- TimeoutMs = options.Benchmark ? options.BenchmarkTimeoutMs : Configuration.DefaultTimeout,
- ConnectionMode = options.BenchmarkConnectionMode,
- RequestDnsSec = options.RequestDnsSec,
- ValidateDnsSec = options.ValidateDnsSec,
- MaxRetries = 1,
- RetryDelayMs = 0,
- PortOverride = clientOptions.PortOverride,
- ForceDohWirePost = clientOptions.ForceDohWirePost,
- BootstrapResolver = clientOptions.BootstrapResolver,
- DnsSecSignatureVerifier = clientOptions.DnsSecSignatureVerifier,
- RequestNsid = clientOptions.RequestNsid
- };
- }
-
- private static string[] GetEffectiveBenchmarkDomains(CliOptions options) {
- if (options.BenchmarkDomains.Count > 0) {
- return options.BenchmarkDomains.ToArray();
- }
-
- return string.IsNullOrWhiteSpace(options.Domain)
- ? Array.Empty()
- : new[] { options.Domain! };
- }
-
- private static Task ExecuteBenchmarkReportAsync(
- ResolverExecutionTarget[] targets,
- string[] domains,
- DnsRecordType[] recordTypes,
- CliOptions options,
- CancellationToken cancellationToken) {
- return ResolverBenchmarkWorkflow.RunAsync(
- targets,
- domains,
- recordTypes,
- options.BenchmarkAttempts,
- options.BenchmarkConcurrency,
- options.BenchmarkTimeoutMs,
- CreateQueryRunOptions(options),
- CreateBenchmarkPolicy(options),
- builtInOverride: CreateBuiltInOverride(),
- explicitOverride: CreateExplicitOverride(),
- cancellationToken: cancellationToken);
- }
-
- private static ResolverExecutionTargetSource CreateBenchmarkTargetSource(CliOptions options) {
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
- return new ResolverExecutionTargetSource {
- ResolverSelectionPath = options.ResolverUsePath
- };
- }
-
- if (options.HasCustomEndpointInputs) {
- return new ResolverExecutionTargetSource {
- ResolverEndpoints = options.ProbeEndpoints.ToArray(),
- ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(),
- ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray()
- };
- }
-
- return new ResolverExecutionTargetSource {
- BuiltInEndpoints = options.BenchmarkEndpoints.Distinct().ToArray()
- };
- }
-
- private static Task ExecuteProbeReportAsync(
- ResolverExecutionTarget[] targets,
- string domain,
- CliOptions options,
- CancellationToken cancellationToken) {
- return ResolverProbeWorkflow.RunAsync(
- targets,
- domain,
- options.RecordType,
- Configuration.DefaultTimeout,
- CreateQueryRunOptions(options),
- CreateProbePolicy(options),
- builtInOverride: CreateBuiltInOverride(),
- explicitOverride: CreateExplicitOverride(),
- cancellationToken: cancellationToken);
- }
-
- private static ResolverExecutionTargetSource CreateProbeTargetSource(CliOptions options) {
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
- return new ResolverExecutionTargetSource {
- ResolverSelectionPath = options.ResolverUsePath
- };
- }
-
- if (options.HasCustomEndpointInputs) {
- return new ResolverExecutionTargetSource {
- ResolverEndpoints = options.ProbeEndpoints.ToArray(),
- ResolverEndpointFiles = options.ProbeEndpointFiles.ToArray(),
- ResolverEndpointUrls = options.ProbeEndpointUrls.ToArray()
- };
- }
-
- return new ResolverExecutionTargetSource {
- ProbeProfile = options.Endpoint
- };
- }
-
- private static string? GetBenchmarkEndpointProfile(CliOptions options) {
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
- return "selected";
- }
-
- return options.HasCustomEndpointInputs ? "custom" : null;
- }
-
- private static string GetProbeEndpointProfile(CliOptions options) {
- if (!string.IsNullOrWhiteSpace(options.ResolverUsePath)) {
- return "selected";
- }
-
- return options.HasCustomEndpointInputs ? "custom" : options.Endpoint.ToString();
- }
-
- private static Func>? CreateBuiltInOverride() {
- if (ProbeOverride == null) {
- return null;
- }
-
- return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult(
- endpoint.ToString(),
- await ProbeOverride(endpoint, name, cancellationToken).ConfigureAwait(false));
- }
-
- private static Func>? CreateExplicitOverride() {
- if (ProbeEndpointOverride == null) {
- return null;
- }
-
- return async (endpoint, name, recordType, cancellationToken) => CreateOverrideAttemptResult(
- ResolverExecutionPlanBuilder.DescribeEndpoint(endpoint),
- await ProbeEndpointOverride(endpoint, name, cancellationToken).ConfigureAwait(false));
- }
-
- private static ResolverQueryAttemptResult CreateOverrideAttemptResult(
- string target,
- (DnsResponse Response, TimeSpan Elapsed, string Resolver, DnsRequestFormat RequestFormat) result) {
- return new ResolverQueryAttemptResult {
- Target = target,
- RequestFormat = result.RequestFormat,
- Resolver = result.Resolver,
- Response = result.Response,
- Elapsed = result.Elapsed
- };
- }
-
- private static void WriteProbeResult(ResolverProbeReportResult result) {
- foreach (string line in ResolverReportTextFormatter.BuildProbeResultLines(result, FormatDuration)) {
- Console.WriteLine(line);
- }
- }
-
- private static int WriteProbeSummary(ResolverProbeReport report, CliOptions options) {
- ResolverProbeEvaluation evaluation = report.Evaluation;
- ResolverProbeReportSummary summary = report.Summary;
- int successCount = summary.SuccessfulCandidates;
- Console.WriteLine("Probe Summary:");
- Console.WriteLine($" Successful probes: {successCount}/{summary.CandidateCount}");
- Console.WriteLine($" Failed probes: {summary.FailedCandidates}");
- Console.WriteLine($" Fastest success: {ResolverReportTextFormatter.DescribeProbeFastestSuccess(summary, FormatDuration)}");
- Console.WriteLine($" Fastest consensus responder: {ResolverReportTextFormatter.DescribeProbeFastestConsensus(summary, FormatDuration)}");
- Console.WriteLine($" Transport coverage: {ResolverReportTextFormatter.DescribeProbeTransportCoverage(summary)}");
- Console.WriteLine($" Runtime capability hints: {ResolverReportTextFormatter.DescribeProbeRuntimeCapabilities(summary)}");
- Console.WriteLine($" Answer consensus: {ResolverReportTextFormatter.DescribeProbeAnswerConsensus(summary)}");
- Console.WriteLine($" Mismatched responders: {ResolverReportTextFormatter.DescribeProbeMismatchedTargets(summary)}");
- Console.WriteLine($" Distinct answer sets: {summary.DistinctAnswerSets}");
- Console.WriteLine($" Answer variants: {ResolverReportTextFormatter.DescribeProbeAnswerVariants(summary)}");
- Console.WriteLine($" Recommended endpoint: {ResolverReportTextFormatter.DescribeProbeRecommended(summary, FormatDuration)}");
- WriteHumanLine($" Policy result: {(evaluation.PolicyPassed ? "pass" : $"fail ({evaluation.PolicyReason})")}");
-
- int exitCode = GetProbeExitCode(evaluation, options);
- if (options.ProbeSummaryLine) {
- Console.WriteLine(ResolverReportTextFormatter.BuildProbeSummaryLine(summary, exitCode));
- }
-
- if (!string.IsNullOrWhiteSpace(options.ProbeSavePath)) {
- SaveResolverScoreSnapshot(options.ProbeSavePath!, report.Snapshot);
- }
-
- return exitCode;
- }
-
- private static ResolverProbePolicy CreateProbePolicy(CliOptions options) {
- return new ResolverProbePolicy {
- MinSuccessCount = options.ProbeMinSuccessCount,
- MinSuccessPercent = options.ProbeMinSuccessPercent,
- MinConsensusPercent = options.ProbeMinConsensusPercent,
- RequireConsensus = options.ProbeRequireConsensus
- };
- }
-
- private static int GetProbeExitCode(ResolverProbeEvaluation evaluation, CliOptions options) {
- if (evaluation.SuccessfulCandidates == 0) {
- return 1;
- }
-
- if (options.ProbeMinSuccessCount.HasValue && evaluation.SuccessfulCandidates < options.ProbeMinSuccessCount.Value) {
- return 3;
- }
-
- if (options.ProbeMinSuccessPercent.HasValue && evaluation.SuccessPercent < options.ProbeMinSuccessPercent.Value) {
- return 3;
- }
-
- if (options.ProbeRequireConsensus && evaluation.DistinctAnswerSets > 1) {
- return 2;
- }
-
- if (options.ProbeMinConsensusPercent.HasValue && evaluation.ConsensusPercent < options.ProbeMinConsensusPercent.Value) {
- return 2;
- }
-
- return evaluation.PolicyPassed ? 0 : 1;
- }
-
- private static void WriteQueryResponse(DnsResponse response, CliOptions options, TimeSpan elapsed) {
- var presentationOptions = new DnsResponsePresentationOptions {
- Mode = GetResponsePresentationMode(options),
- TxtConcat = options.TxtConcatOutput,
- ShowQuestions = ShouldShowQuestionSection(options, rawDefault: false),
- ShowAnswers = ShouldShowAnswerSection(options, rawDefault: false),
- ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: false),
- ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: false)
- };
-
- if (presentationOptions.Mode == DnsResponsePresentationMode.Raw) {
- presentationOptions.ShowQuestions = ShouldShowQuestionSection(options, rawDefault: true);
- presentationOptions.ShowAnswers = ShouldShowAnswerSection(options, rawDefault: true);
- presentationOptions.ShowAuthorities = ShouldShowAuthoritySection(options, rawDefault: true);
- presentationOptions.ShowAdditional = ShouldShowAdditionalSection(options, rawDefault: true);
- }
-
- foreach (string line in DnsResponseTextFormatter.BuildOutputLines(response, presentationOptions, elapsed, FormatDuration)) {
- Console.WriteLine(line);
- }
- }
-
- private static void WriteZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) {
- switch (options.OutputFormat) {
- case QueryOutputFormat.Json:
- Console.WriteLine(DnsClientXJsonSerializer.Serialize(result));
- return;
- default:
- WritePrettyZoneTransferResponse(result, options);
- return;
- }
- }
-
- private static void SaveResolverScoreSnapshot(string path, ResolverScoreSnapshot snapshot) {
- ResolverScoreStore.Save(path, snapshot);
- }
-
- private static void WriteResolverSelection(ResolverSelectionResult selection, CliOptions options) {
- if (options.OutputFormat == QueryOutputFormat.Json) {
- Console.WriteLine(DnsClientXJsonSerializer.Serialize(selection));
- return;
- }
-
- WriteHumanLine(selection.Target);
- }
-
- private static void WritePrettyZoneTransferResponse(RecursiveZoneTransferResult result, CliOptions options) {
- WriteHumanLine($"Zone Transfer: {result.Zone}");
- WriteHumanLine($" Selected authority: {result.SelectedAuthority}");
- WriteHumanLine($" Selected server: {result.SelectedServer}:{result.Port}");
- Console.WriteLine($" Chunks: {result.RecordSets.Length}");
-
- if (options.TransferSummary) {
- WriteHumanLine($" Authorities discovered: {string.Join(", ", result.Authorities)}");
- WriteHumanLine($" Tried servers: {string.Join(", ", result.TriedServers)}");
- }
-
- Console.WriteLine("Records:");
- foreach (ZoneTransferResult recordSet in result.RecordSets) {
- if (recordSet.IsOpening) {
- Console.WriteLine(" ;; opening SOA");
- } else if (recordSet.IsClosing) {
- Console.WriteLine(" ;; closing SOA");
- }
-
- foreach (DnsAnswer record in recordSet.Records) {
- Console.WriteLine($" {DnsTerminalText.Escape(record.Name)}\t{record.TTL}\tIN\t{record.Type}\t{DnsTerminalText.Escape(record.Data)}");
- }
- }
- }
-
- private static DnsResponsePresentationMode GetResponsePresentationMode(CliOptions options) {
- if (options.ShortOutput) {
- return DnsResponsePresentationMode.Short;
- }
-
- return options.OutputFormat switch {
- QueryOutputFormat.Json => DnsResponsePresentationMode.Json,
- QueryOutputFormat.Raw => DnsResponsePresentationMode.Raw,
- _ => DnsResponsePresentationMode.Pretty
- };
- }
-
- private static bool ShouldShowQuestionSection(CliOptions options, bool rawDefault) {
- if (options.HasExplicitSectionSelection) {
- return options.ShowQuestionSection;
- }
-
- return rawDefault;
- }
-
- private static bool ShouldShowAnswerSection(CliOptions options, bool rawDefault) {
- if (options.HasExplicitSectionSelection) {
- return options.ShowAnswerSection;
- }
-
- return !rawDefault || options.OutputFormat == QueryOutputFormat.Raw;
- }
-
- private static bool ShouldShowAuthoritySection(CliOptions options, bool rawDefault) {
- if (options.HasExplicitSectionSelection) {
- return options.ShowAuthoritySection;
- }
-
- return rawDefault;
- }
-
- private static bool ShouldShowAdditionalSection(CliOptions options, bool rawDefault) {
- if (options.HasExplicitSectionSelection) {
- return options.ShowAdditionalSection;
- }
-
- return rawDefault;
- }
-
- private static void WriteExplain(
- ResolverSingleOperationResult result,
- string operation,
- string target,
- DnsRecordType recordType,
- bool requestDnsSec,
- bool validateDnsSec,
- bool trace,
- string? zone = null,
- int? ttl = null) {
- foreach (string line in ResolverReportTextFormatter.BuildSingleOperationExplainLines(
- result,
- operation,
- target,
- recordType,
- requestDnsSec,
- validateDnsSec,
- FormatDuration,
- zone,
- ttl)) {
- Console.WriteLine(line);
- }
-
- if (trace) {
- WriteTrace(result);
- }
- }
-
- private static void WriteTrace(ResolverSingleOperationResult result) {
- foreach (string line in ResolverReportTextFormatter.BuildSingleOperationTraceLines(result, FormatDuration)) {
- Console.WriteLine(line);
- }
- }
-
- private static string FormatDuration(TimeSpan duration) {
- if (duration <= TimeSpan.Zero) {
- return "0 ms";
- }
-
- if (duration.TotalMilliseconds < 1000) {
- return $"{duration.TotalMilliseconds:F0} ms";
- }
-
- return $"{duration.TotalSeconds:F3} s";
- }
-
- private static void WriteHumanLine(string value) => Console.WriteLine(DnsTerminalText.Escape(value));
-
- private static void WriteHumanError(string? value) => Console.Error.WriteLine(DnsTerminalText.Escape(value));
-
- private static void ShowHelp() {
- Console.WriteLine("DnsClientX.Cli - simple DNS query tool");
- Console.WriteLine("Usage: DnsClientX.Cli [options] ");
- Console.WriteLine();
- Console.WriteLine("Options:");
- Console.WriteLine(" -t, --type DNS record type (default A, comma-separated with --benchmark)");
- Console.WriteLine(" -e, --endpoint DNS endpoint name (default System, comma-separated with --benchmark)");
- Console.WriteLine(" --domain Domain name (comma-separated with --benchmark)");
- Console.WriteLine(" --dnssec Request DNSSEC records");
- Console.WriteLine(" --validate-dnssec Validate DNSSEC records");
- Console.WriteLine(" --dnssec-verifier Optional DNSSEC provider DLL path (dependencies beside it)");
- Console.WriteLine(" --wire-post Use DNS over HTTPS wire POST (when supported)");
- Console.WriteLine(" --bootstrap Resolve endpoint hostnames through an IP-literal UDP/TCP resolver, e.g. udp@1.1.1.1:53");
- Console.WriteLine(" --nsid Request resolver identity through EDNS (wire transports)");
- Console.WriteLine(" --format Query output format: pretty, json, raw");
- Console.WriteLine(" --short Print answer values only");
- Console.WriteLine(" --txt-concat Flatten TXT output into a single string");
- Console.WriteLine(" --question Show the question section");
- Console.WriteLine(" --answer Show the answer section");
- Console.WriteLine(" --authority Show the authority section");
- Console.WriteLine(" --additional Show the additional section");
- Console.WriteLine(" --reverse Force PTR lookup mode");
- Console.WriteLine(" --axfr Discover authoritative servers and attempt recursive AXFR");
- Console.WriteLine(" --transfer-summary Print discovered authorities and tried transfer targets");
- Console.WriteLine(" --benchmark Benchmark one or more endpoints across repeated queries");
- Console.WriteLine(" --benchmark-attempts Repeat each domain/type combination this many times");
- Console.WriteLine(" --benchmark-timeout Per-query timeout to apply during benchmark runs");
- Console.WriteLine(" --benchmark-concurrency Max concurrent queries across the benchmark run");
- Console.WriteLine(" --benchmark-connections Fresh clients per attempt or retained clients per target (default cold)");
- Console.WriteLine(" --benchmark-min-success-percent Require a minimum overall benchmark success rate");
- Console.WriteLine(" --benchmark-min-successful-candidates Require this many candidates to return at least one success");
- Console.WriteLine(" --benchmark-summary-only Suppress per-candidate benchmark rows and print only the header and summary");
- Console.WriteLine(" --benchmark-summary-line Append one stable BENCHMARK_SUMMARY key=value line for automation");
- Console.WriteLine(" --benchmark-save Persist benchmark scoring and recommendation data as JSON");
- Console.WriteLine(" --probe Probe the selected endpoint profile and related variants");
- Console.WriteLine(" --probe-endpoint Probe a custom endpoint such as tcp@1.1.1.1:53, doq@dns.quad9.net:853, or doh3@https://dns.quad9.net/dns-query");
- Console.WriteLine(" --resolver-file Load custom endpoints from a text file for probe or benchmark");
- Console.WriteLine(" --resolver-url Load custom endpoints from an HTTP or HTTPS URL for probe or benchmark");
- Console.WriteLine(" --resolver-validate Validate resolver endpoint inputs without querying DNS");
- Console.WriteLine(" --probe-summary-only Suppress per-endpoint probe lines and print only the header and summary");
- Console.WriteLine(" --probe-summary-line Append one stable PROBE_SUMMARY key=value line for automation");
- Console.WriteLine(" --probe-save Persist probe scoring and health data as JSON");
- Console.WriteLine(" --resolver-select Load a saved score snapshot and print the recommended resolver");
- Console.WriteLine(" --resolver-use Reuse the recommended resolver from a saved snapshot for query, probe, or benchmark");
- Console.WriteLine(" --probe-require-consensus Fail when successful probe responders disagree");
- Console.WriteLine(" --probe-min-consensus Fail when the top answer set is below the given 1-100 percentage");
- Console.WriteLine(" --probe-min-success Fail when fewer than the given number of probes succeed");
- Console.WriteLine(" --probe-min-success-percent Fail when the probe success rate is below the given 1-100 percentage");
- Console.WriteLine(" --capabilities Print the shared transport capability report");
- Console.WriteLine(" --stamp-info Print parsed DNS stamp details without querying DNS");
- Console.WriteLine(" --explain Print resolver and response diagnostics");
- Console.WriteLine(" --trace Print explain output plus audit details");
- Console.WriteLine(" --update Send dynamic update to one explicit authoritative endpoint");
- Console.WriteLine(" --ttl TTL for update (default 300)");
- Console.WriteLine();
- Console.WriteLine("Available endpoints:");
- foreach (var (ep, desc) in DnsEndpointExtensions.GetAllWithDescriptions()) {
- Console.WriteLine($" {ep,-20} {desc}");
- }
- }
}
}
diff --git a/DnsClientX.PowerShell/CmdletClearDnsCache.cs b/DnsClientX.PowerShell/CmdletClearDnsCache.cs
new file mode 100644
index 00000000..e2daa56d
--- /dev/null
+++ b/DnsClientX.PowerShell/CmdletClearDnsCache.cs
@@ -0,0 +1,35 @@
+using System.Management.Automation;
+using System.Threading.Tasks;
+
+namespace DnsClientX.PowerShell {
+ ///
+ /// Clears process-wide cached DNS responses.
+ /// Clears all response entries, or entries for one queried name and optional type across all resolver policies. Returns the number removed. This affects every cache-enabled DnsClientX client in this process; active queries may repopulate the cache. FastestWins endpoint choices are managed by Clear-DnsMultiResolverCache.
+ ///
+ /// Clear all cached responsesClear-DnsCache
+ /// Remove cached A responses for one nameClear-DnsCache -Name example.com -Type A
+ /// Preview selective invalidationClear-DnsCache -Name example.com -WhatIf
+ [Cmdlet(VerbsCommon.Clear, "DnsCache", SupportsShouldProcess = true, DefaultParameterSetName = "All")]
+ [OutputType(typeof(int))]
+ public sealed class CmdletClearDnsCache : AsyncPSCmdlet {
+ /// Original queried name to invalidate, including the expanded system-search name when applicable. Names normalize case, Unicode and the trailing root dot. Alias targets do not invalidate a different original query.
+ [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Name", ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
+ [Alias("Domain", "DnsName")]
+ [ValidateNotNullOrEmpty]
+ public string Name { get; set; } = string.Empty;
+
+ /// Only remove this query type. Omit to remove every cached type for Name.
+ [Parameter(Position = 1, ParameterSetName = "Name")]
+ public DnsRecordType? Type { get; set; }
+
+ ///
+ protected override Task ProcessRecordAsync() {
+ bool selective = ParameterSetName == "Name";
+ string target = selective ? $"{Name} ({(Type.HasValue ? Type.Value.ToString() : "all types")}) across all resolver policies" : "all cached DNS responses";
+ if (ShouldProcess(target, "Clear process-wide DNS response cache entries")) {
+ WriteObject(selective ? ClientX.InvalidateResponseCache(Name, Type) : ClientX.ClearResponseCache());
+ }
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs b/DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs
new file mode 100644
index 00000000..fd607994
--- /dev/null
+++ b/DnsClientX.PowerShell/CmdletCompareDnsResolverSnapshot.cs
@@ -0,0 +1,43 @@
+using System.Management.Automation;
+
+namespace DnsClientX.PowerShell {
+ ///
+ /// Compares resolver health and answers between saved DNS snapshots.
+ /// Reports candidate and answer changes, health regressions, and incompatible query scopes. Optional authoritative responses provide comparison context; a majority answer is not proof of authoritative correctness.
+ ///
+ /// Compare two saved probe or benchmark snapshotsCompare-DnsResolverSnapshot -BaselinePath './before.json' -CurrentPath './after.json'
+ [Cmdlet(VerbsData.Compare, "DnsResolverSnapshot", DefaultParameterSetName = "Path")]
+ [OutputType(typeof(ResolverScoreComparison))]
+ public sealed class CmdletCompareDnsResolverSnapshot : PSCmdlet {
+ /// Baseline resolver score snapshot JSON path.
+ [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Path")]
+ [ValidateNotNullOrEmpty]
+ public string BaselinePath { get; set; } = string.Empty;
+
+ /// Current resolver score snapshot JSON path.
+ [Parameter(Mandatory = true, Position = 1, ParameterSetName = "Path")]
+ [ValidateNotNullOrEmpty]
+ public string CurrentPath { get; set; } = string.Empty;
+
+ /// Baseline typed resolver score snapshot.
+ [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Snapshot")]
+ [ValidateNotNull]
+ public ResolverScoreSnapshot? Baseline { get; set; }
+
+ /// Current typed resolver score snapshot, supplied directly or through the pipeline.
+ [Parameter(Mandatory = true, Position = 1, ParameterSetName = "Snapshot", ValueFromPipeline = true)]
+ [ValidateNotNull]
+ public ResolverScoreSnapshot? Current { get; set; }
+
+ /// Caller-supplied authoritative responses for name/type comparison context. These must assert the AA flag; response origin remains the caller's responsibility.
+ [Parameter]
+ public ResolverAnswerReference[]? AuthoritativeReference { get; set; }
+
+ ///
+ protected override void ProcessRecord() {
+ ResolverScoreSnapshot baseline = Baseline ?? ResolverScoreStore.Load(SessionState.Path.GetUnresolvedProviderPathFromPSPath(BaselinePath));
+ ResolverScoreSnapshot current = Current ?? ResolverScoreStore.Load(SessionState.Path.GetUnresolvedProviderPathFromPSPath(CurrentPath));
+ WriteObject(ResolverScoreComparer.Compare(baseline, current, AuthoritativeReference));
+ }
+ }
+}
diff --git a/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs b/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs
index b2fb5fe1..65243dce 100644
--- a/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs
+++ b/DnsClientX.PowerShell/CmdletDiscoverDnsService.cs
@@ -4,7 +4,7 @@
namespace DnsClientX.PowerShell {
///
/// Retrieves services advertised via DNS Service Discovery.
- /// Queries the _services._dns-sd._udp tree for the specified domain and returns SRV/TXT data describing each advertised service.
+ /// Streams individual DNS-SD instances under a domain, browses one service type, or resolves SRV targets in connection-attempt order. Empty discovery produces no pipeline objects; use an array expression to collect zero or more results.
///
///
/// Discover services under a domain
@@ -14,19 +14,58 @@ namespace DnsClientX.PowerShell {
/// Pipe results to select useful fields
/// Get-DnsService -Domain example.com | Select-Object ServiceName,Target,Port
///
- [Cmdlet(VerbsCommon.Get, "DnsService")]
- public sealed class CmdletDiscoverDnsService : AsyncPSCmdlet {
+ ///
+ /// Browse HTTP service instances through an explicit resolver
+ /// Get-DnsService -Domain example.com -Service http -ResolverEndpoint 'https://dns.google/dns-query'
+ ///
+ ///
+ /// Resolve LDAP targets and addresses in connection-attempt order
+ /// Get-DnsService -Domain example.com -Service ldap -Resolve -ResolveHosts
+ ///
+ [Cmdlet(VerbsCommon.Get, "DnsService", DefaultParameterSetName = "Discover")]
+ [OutputType(typeof(DnsService), typeof(DnsSrvRecord))]
+ public sealed class CmdletDiscoverDnsService : DnsServiceCmdletBase {
///
/// Domain name to search for advertised services.
///
- [Parameter(Mandatory = true, Position = 0)]
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
+ [Alias("Name", "DnsName")]
+ [ValidateNotNullOrEmpty]
public string Domain { get; set; } = string.Empty;
+ /// Service label to browse or resolve, such as http or _http.
+ [Parameter(Mandatory = true, ParameterSetName = "Browse")]
+ [Parameter(Mandatory = true, ParameterSetName = "Resolve")]
+ [ValidateNotNullOrEmpty]
+ public string Service { get; set; } = string.Empty;
+
+ /// Service transport label, such as tcp or udp.
+ [Parameter(ParameterSetName = "Browse")]
+ [Parameter(ParameterSetName = "Resolve")]
+ [ValidateSet("tcp", "udp", "_tcp", "_udp")]
+ public string Protocol { get; set; } = "tcp";
+
+ /// Resolve SRV targets in RFC 2782 connection-attempt order instead of browsing DNS-SD instances.
+ [Parameter(Mandatory = true, ParameterSetName = "Resolve")]
+ public SwitchParameter Resolve { get; set; }
+
+ /// With Resolve, include A and AAAA addresses for each target host.
+ [Parameter(ParameterSetName = "Resolve")]
+ public SwitchParameter ResolveHosts { get; set; }
+
///
protected override async Task ProcessRecordAsync() {
- using var client = new ClientX();
- var results = await client.DiscoverServices(Domain);
- WriteObject(results);
+ bool browse = ParameterSetName == "Browse";
+ using var client = await CreateDiscoveryClientAsync().ConfigureAwait(false);
+ if (Resolve.IsPresent) {
+ WriteObject(await client.ResolveServiceAsync(Service, Protocol, Domain, ResolveHosts.IsPresent, CancelToken).ConfigureAwait(false), true);
+ } else if (browse) {
+ WriteObject(await client.BrowseServicesAsync(Service, Protocol, Domain, CancelToken).ConfigureAwait(false), true);
+ } else {
+ await foreach (DnsService service in client.EnumerateServicesAsync(Domain, CancelToken).ConfigureAwait(false)) {
+ WriteObject(service);
+ }
+ }
}
}
}
diff --git a/DnsClientX.PowerShell/CmdletDnsUpdate.cs b/DnsClientX.PowerShell/CmdletDnsUpdate.cs
index a9d94c56..d04b4e50 100644
--- a/DnsClientX.PowerShell/CmdletDnsUpdate.cs
+++ b/DnsClientX.PowerShell/CmdletDnsUpdate.cs
@@ -7,6 +7,7 @@ namespace DnsClientX.PowerShell;
///
/// Sends DNS UPDATE messages to a server.
/// Adds or removes records in a zone using RFC 2136 over TCP.
+/// Use WhatIf to preview an update without contacting the server, or Confirm to approve each change.
///
///
/// Add an A record
@@ -20,7 +21,7 @@ namespace DnsClientX.PowerShell;
/// Delete one value while preserving the rest of the RRset
/// Invoke-DnsUpdate -Zone example.com -Server 127.0.0.1 -Name www -Type A -Data 192.0.2.10 -DeleteValue
///
-[Cmdlet(VerbsLifecycle.Invoke, "DnsUpdate")]
+[Cmdlet(VerbsLifecycle.Invoke, "DnsUpdate", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
public sealed class CmdletDnsUpdate : AsyncPSCmdlet {
/// Zone to update.
[Parameter(Mandatory = true, Position = 0)]
@@ -73,6 +74,13 @@ protected override async Task ProcessRecordAsync() {
throw new PSArgumentException("-DeleteValue requires -Data with the exact record value to remove.");
}
+ string action = Delete.IsPresent ? "Delete DNS record set"
+ : DeleteValue.IsPresent ? $"Delete DNS record value {Data}"
+ : $"Add DNS record {Data} with TTL {Ttl}";
+ if (!ShouldProcess($"{Name} {Type} in zone {Zone} on {Server}:{Port}", action)) {
+ return;
+ }
+
var target = new ResolverExecutionTarget {
DisplayName = $"tcp@{Server}:{Port}",
ExplicitEndpoint = new DnsResolverEndpoint {
diff --git a/DnsClientX.PowerShell/CmdletFindDnsService.cs b/DnsClientX.PowerShell/CmdletFindDnsService.cs
index 413d84b8..59342792 100644
--- a/DnsClientX.PowerShell/CmdletFindDnsService.cs
+++ b/DnsClientX.PowerShell/CmdletFindDnsService.cs
@@ -4,7 +4,7 @@
namespace DnsClientX.PowerShell {
///
/// Discovers services advertised via DNS Service Discovery.
- /// Wraps to return services under a domain.
+ /// Streams individual service instances under a domain, browses one service type, or lists advertised service types. An empty discovery produces no pipeline objects; use an array expression to collect zero or more results.
///
///
/// Find services under a domain
@@ -14,17 +14,51 @@ namespace DnsClientX.PowerShell {
/// Filter for a specific service type
/// Find-DnsService -Domain example.com | Where-Object {$_.ServiceType -like '_http._tcp.*'}
///
- [Cmdlet(VerbsCommon.Find, "DnsService")]
- public sealed class CmdletFindDnsService : AsyncPSCmdlet {
+ ///
+ /// List advertised service types through a specific resolver
+ /// Find-DnsService -Domain example.com -ServiceTypes -ResolverEndpoint 'tcp@192.0.2.53:53'
+ ///
+ ///
+ /// Browse only HTTP instances
+ /// Find-DnsService -Domain example.com -Service http -Protocol tcp
+ ///
+ [Cmdlet(VerbsCommon.Find, "DnsService", DefaultParameterSetName = "Discover")]
+ [OutputType(typeof(DnsService), typeof(string))]
+ public sealed class CmdletFindDnsService : DnsServiceCmdletBase {
/// Domain name to search for advertised services.
- [Parameter(Mandatory = true, Position = 0)]
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
+ [Alias("Name", "DnsName")]
+ [ValidateNotNullOrEmpty]
public string Domain { get; set; } = string.Empty;
+ /// List fully qualified service types from the DNS-SD meta-query.
+ [Parameter(Mandatory = true, ParameterSetName = "Types")]
+ [Alias("ListServiceTypes")]
+ public SwitchParameter ServiceTypes { get; set; }
+
+ /// Service label to browse, such as http or _http.
+ [Parameter(Mandatory = true, ParameterSetName = "Browse")]
+ [ValidateNotNullOrEmpty]
+ public string Service { get; set; } = string.Empty;
+
+ /// Service transport label, such as tcp or udp.
+ [Parameter(ParameterSetName = "Browse")]
+ [ValidateSet("tcp", "udp", "_tcp", "_udp")]
+ public string Protocol { get; set; } = "tcp";
+
///
protected override async Task ProcessRecordAsync() {
- using var client = new ClientX();
- var results = await client.DiscoverServices(Domain);
- WriteObject(results);
+ bool browse = ParameterSetName == "Browse";
+ using var client = await CreateDiscoveryClientAsync().ConfigureAwait(false);
+ if (ServiceTypes.IsPresent) {
+ WriteObject(await client.DiscoverServiceTypesAsync(Domain, CancelToken).ConfigureAwait(false), true);
+ } else if (browse) {
+ WriteObject(await client.BrowseServicesAsync(Service, Protocol, Domain, CancelToken).ConfigureAwait(false), true);
+ } else {
+ await foreach (DnsService service in client.EnumerateServicesAsync(Domain, CancelToken).ConfigureAwait(false)) {
+ WriteObject(service);
+ }
+ }
}
}
}
diff --git a/DnsClientX.PowerShell/CmdletGetDnsCache.cs b/DnsClientX.PowerShell/CmdletGetDnsCache.cs
new file mode 100644
index 00000000..c7456a56
--- /dev/null
+++ b/DnsClientX.PowerShell/CmdletGetDnsCache.cs
@@ -0,0 +1,17 @@
+using System.Management.Automation;
+
+namespace DnsClientX.PowerShell {
+ ///
+ /// Gets process-wide DNS response cache statistics.
+ /// Returns capacity, current entries and lifetime lookup counters shared by all cache-enabled DnsClientX clients in this PowerShell process. Clearing entries does not reset these counters.
+ ///
+ /// Inspect the response cache used by Resolve-DnsGet-DnsCache
+ [Cmdlet(VerbsCommon.Get, "DnsCache")]
+ [OutputType(typeof(DnsResponseCacheStatistics))]
+ public sealed class CmdletGetDnsCache : PSCmdlet {
+ ///
+ protected override void ProcessRecord() {
+ WriteObject(ClientX.GetResponseCacheStatistics());
+ }
+ }
+}
diff --git a/DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs b/DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs
new file mode 100644
index 00000000..6804ffa1
--- /dev/null
+++ b/DnsClientX.PowerShell/CmdletGetDnsQueryExplanation.cs
@@ -0,0 +1,27 @@
+using System.Management.Automation;
+
+namespace DnsClientX.PowerShell {
+ ///
+ /// Explains captured evidence from a completed DNS response.
+ /// Projects resolver, transport, cache, alias, authority and local DNSSEC evidence without additional DNS queries. UnavailableEvidence lists facts that the response cannot establish, including a complete delegation or DNSSEC trust-chain traversal.
+ ///
+ /// Explain a response returned by Resolve-DnsResolve-Dns example.com -FullResponse | Get-DnsQueryExplanation
+ [Cmdlet(VerbsCommon.Get, "DnsQueryExplanation")]
+ [OutputType(typeof(DnsQueryExplanation))]
+ public sealed class CmdletGetDnsQueryExplanation : PSCmdlet {
+ /// Completed DNS response, supplied directly or through the pipeline.
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)]
+ [Alias("Response")]
+ [ValidateNotNull]
+ public DnsResponse InputObject { get; set; } = null!;
+
+ /// Optional captured audit entries for the supplied response. Omit this parameter when individual attempt evidence was not captured.
+ [Parameter]
+ public AuditEntry[]? AuditTrail { get; set; }
+
+ ///
+ protected override void ProcessRecord() {
+ WriteObject(DnsQueryExplanation.Create(InputObject, AuditTrail));
+ }
+ }
+}
diff --git a/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs b/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs
index 3d8a0396..fcc011f9 100644
--- a/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs
+++ b/DnsClientX.PowerShell/CmdletResolveDnsQuery.cs
@@ -11,6 +11,7 @@ namespace DnsClientX.PowerShell {
/// Resolves DNS records (A, AAAA, MX, TXT, …) over UDP, TCP, DoT, DoH, QUIC, or multicast with optional multi-resolver strategies.
/// Supports single-provider queries, explicit servers with transport selection, multiple providers with FirstSuccess/FastestWins/SequentialFallback/RoundRobin, direct resolver endpoints, DNSSEC, EDNS/ECS, concurrency control, and TTL-based response caching.
/// Specify either Name or Pattern. When no resolver source is specified, the library's default resolver is used. Timeout, retry, response, typed-record, and DNSSEC options apply to every resolver source.
+ /// Name accepts pipeline strings or objects with Name, Domain, or DnsName properties. By default each answer is an individual pipeline row. Use FullResponse for complete response objects, TypedRecords for typed values, or TypedRecordMetadata to keep typed values and record metadata together.
///
///
/// Simple (system default)
@@ -56,14 +57,29 @@ namespace DnsClientX.PowerShell {
/// Reuse the recommended resolver from a saved selection snapshot
/// Resolve-Dns -Name 'example.com' -Type A -ResolverSelectionPath '.\resolver-score.json'
///
+ ///
+ /// Resolve names from a pipeline and filter individual answers
+ /// 'example.com','example.net' | Resolve-Dns -Type A | Where-Object Type -EQ A
+ ///
+ ///
+ /// Stream a bounded batch in completion order
+ /// Resolve-Dns -Name 'example.com','example.net' -Type A,AAAA -Stream -CompletionOrder -MaxConcurrency 4
+ ///
+ ///
+ /// Keep typed payloads together with their owner and TTL
+ /// Resolve-Dns -Name 'example.com' -Type MX -TypedRecordMetadata | Select-Object Name,TTL,Value
+ ///
///
[Alias("Resolve-DnsQuery")]
[Cmdlet(VerbsDiagnostic.Resolve, "Dns", DefaultParameterSetName = "Name")]
+ [OutputType(typeof(DnsAnswerMinimal), typeof(DnsResponse), typeof(DnsTypedRecord