mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
feat(dns): Add real-time DNS speed benchmark and 'Fastest' auto-selection option (#4935)
* feat(dns): Add real-time DNS speed benchmark and 'Fastest' auto-selection option * fix(dns): Address CodeRabbit & Codex PR review feedback * fix(ci): Remove .biomeignore to keep repository root clean * Fix Fastest DNS eligibility and failed probe handling --------- Co-authored-by: Chris Titus <contact@christitus.com>
This commit is contained in:
co-authored by
Chris Titus
parent
f65d2631a0
commit
8e3998d9fd
@@ -81,6 +81,7 @@ Because the final script is concatenated, code cannot rely on runtime module imp
|
||||
## Configuration Contract
|
||||
|
||||
- Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
|
||||
- `config/dns.json` opts unfiltered providers into Fastest selection with `BenchmarkEligible: true`; missing or false values exclude a provider from the TCP latency benchmark.
|
||||
- `config/applications.json` defines installable applications; each entry includes the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
|
||||
- `config/tweaks.json` defines Windows tweaks; registry and service changes include original values or original states when applicable so undo workflows can restore user systems.
|
||||
- Preset and navigation files reference valid config keys. Renaming a config key requires updating all presets, UI references, docs, and code paths together.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"Google":{
|
||||
"BenchmarkEligible": true,
|
||||
"Primary": "8.8.8.8",
|
||||
"Secondary": "8.8.4.4",
|
||||
"Primary6": "2001:4860:4860::8888",
|
||||
@@ -7,6 +8,7 @@
|
||||
"DohTemplate": "https://dns.google/dns-query"
|
||||
},
|
||||
"Cloudflare":{
|
||||
"BenchmarkEligible": true,
|
||||
"Primary": "1.1.1.1",
|
||||
"Secondary": "1.0.0.1",
|
||||
"Primary6": "2606:4700:4700::1111",
|
||||
|
||||
+1
-1
@@ -1885,7 +1885,7 @@
|
||||
"category": "z__Advanced Tweaks - CAUTION",
|
||||
"panel": "1",
|
||||
"Type": "Combobox",
|
||||
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||
"ComboItems": "Default DHCP Fastest Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
|
||||
},
|
||||
"WPFAddUltPerf": {
|
||||
|
||||
@@ -65,6 +65,7 @@ Use the DNS section to switch both IPv4 and IPv6 DNS providers without editing a
|
||||
|
||||
* **Default**: Uses the default DNS settings configured by your ISP or network.
|
||||
* **DHCP**: Automatically acquires DNS settings from the DHCP server.
|
||||
* **Fastest**: Compares TCP port 53 connection times to Google and Cloudflare, then applies the quicker provider. Filtering providers are excluded. If both probes fail, existing DNS settings are preserved. The scan takes up to about three seconds; connection time is only a rough latency estimate, not a DNS lookup or DNS-over-HTTPS performance test.
|
||||
* [**Google**](https://developers.google.com/speed/public-dns?hl=en): A reliable and fast DNS service provided by Google.
|
||||
* [**Cloudflare**](https://developers.cloudflare.com/1.1.1.1/): Known for speed and privacy, Cloudflare DNS is a popular choice for enhancing internet performance.
|
||||
* [**Cloudflare_Malware**](https://developers.cloudflare.com/1.1.1.1/setup/#:~:text=Use%20the%20following%20DNS%20resolvers%20to%20block%20malicious%20content%3A): Provides additional protection by blocking malware sites.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
function Get-WinUtilDNSBenchmark {
|
||||
<#
|
||||
|
||||
.SYNOPSIS
|
||||
Benchmarks neutral DNS providers by measuring TCP port 53 latency (RTT in ms) to determine the fastest DNS server.
|
||||
|
||||
.PARAMETER TimeoutMs
|
||||
Maximum timeout in milliseconds for each connection test. Default is 1500ms.
|
||||
|
||||
.OUTPUTS
|
||||
Array of PSCustomObjects containing Provider, PrimaryIP, and LatencyMs sorted by lowest latency.
|
||||
|
||||
.EXAMPLE
|
||||
$results = Get-WinUtilDNSBenchmark
|
||||
$fastest = $results[0]
|
||||
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateRange(1, 9998)]
|
||||
[int]$TimeoutMs = 1500
|
||||
)
|
||||
|
||||
Write-WinUtilLog -Component "DNS" -Message "Starting DNS latency benchmark scan (TCP port 53)..."
|
||||
|
||||
$dnsConfigs = $sync.configs.dns
|
||||
if ($null -eq $dnsConfigs) {
|
||||
Write-Warning "DNS configurations not found in `$sync.configs.dns."
|
||||
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS configurations not found in `$sync.configs.dns."
|
||||
return @()
|
||||
}
|
||||
|
||||
$results = [System.Collections.Generic.List[PSObject]]::new()
|
||||
|
||||
foreach ($prop in $dnsConfigs.PSObject.Properties) {
|
||||
$providerName = $prop.Name
|
||||
$primaryIp = $prop.Value.Primary
|
||||
if (-not $primaryIp) { continue }
|
||||
|
||||
# Providers must explicitly opt in so new filtering services are never auto-selected.
|
||||
if ($prop.Value.BenchmarkEligible -ne $true) {
|
||||
continue
|
||||
}
|
||||
|
||||
$latency = 9999
|
||||
$client = $null
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$asyncResult = $client.BeginConnect($primaryIp, 53, $null, $null)
|
||||
$success = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
|
||||
$stopwatch.Stop()
|
||||
|
||||
if ($success) {
|
||||
$client.EndConnect($asyncResult)
|
||||
$latency = [int]$stopwatch.ElapsedMilliseconds
|
||||
} else {
|
||||
$latency = 9999
|
||||
}
|
||||
} catch {
|
||||
$latency = 9999
|
||||
} finally {
|
||||
if ($null -ne $client) {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$results.Add([PSCustomObject]@{
|
||||
Provider = $providerName
|
||||
PrimaryIP = $primaryIp
|
||||
LatencyMs = $latency
|
||||
})
|
||||
}
|
||||
|
||||
$sortedResults = @($results | Sort-Object LatencyMs)
|
||||
if ($sortedResults.Count -gt 0 -and $sortedResults[0].LatencyMs -lt 9999) {
|
||||
$fastest = $sortedResults[0]
|
||||
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Fastest neutral provider: $($fastest.Provider) ($($fastest.LatencyMs) ms)"
|
||||
} else {
|
||||
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Could not determine latency for providers."
|
||||
}
|
||||
|
||||
return $sortedResults
|
||||
}
|
||||
@@ -18,6 +18,21 @@ function Set-WinUtilDNS {
|
||||
return $true
|
||||
}
|
||||
|
||||
if($DNSProvider -eq "Fastest") {
|
||||
Write-WinUtilLog -Component "DNS" -Message "Auto-detecting fastest DNS provider via latency benchmark..."
|
||||
$benchmark = Get-WinUtilDNSBenchmark
|
||||
$validFastest = $benchmark | Where-Object { $_.LatencyMs -lt 9999 } | Select-Object -First 1
|
||||
if ($validFastest) {
|
||||
$DNSProvider = $validFastest.Provider
|
||||
Write-Host "Auto-selected fastest DNS provider: $DNSProvider ($($validFastest.LatencyMs) ms)"
|
||||
Write-WinUtilLog -Component "DNS" -Message "Auto-selected fastest DNS provider: $DNSProvider ($($validFastest.LatencyMs) ms)"
|
||||
} else {
|
||||
Write-Warning "Could not measure DNS latency to any provider; keeping current network adapter DNS settings."
|
||||
Write-WinUtilLog -Component "DNS" -Message "Benchmark timeout or all probes failed; aborting DNS change to preserve existing settings."
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$Adapters = Get-NetAdapter | Where-Object {$_.Status -eq "Up"}
|
||||
Write-Host "Ensuring DNS is set to $DNSProvider on the following interfaces:"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
BeforeAll {
|
||||
. "$PSScriptRoot/../functions/private/Get-WinUtilDNSBenchmark.ps1"
|
||||
function Write-WinUtilLog { param($Message, $Level, $Component) }
|
||||
}
|
||||
|
||||
Describe 'Get-WinUtilDNSBenchmark' {
|
||||
BeforeEach {
|
||||
$script:sync = @{ configs = @{ dns = Get-Content "$PSScriptRoot/../config/dns.json" -Raw | ConvertFrom-Json } }
|
||||
$script:probeCompleted = $true
|
||||
$script:connectFails = $false
|
||||
$script:disposed = 0
|
||||
$script:ended = 0
|
||||
$script:probed = @()
|
||||
Mock Write-WinUtilLog { }
|
||||
Mock New-Object {
|
||||
$client = [pscustomobject]@{}
|
||||
$client | Add-Member ScriptMethod BeginConnect {
|
||||
param($Address, $Port, $Callback, $State)
|
||||
$script:probed += $Address
|
||||
$handle = [pscustomobject]@{}
|
||||
$handle | Add-Member ScriptMethod WaitOne { param($Timeout, $ExitContext) return $script:probeCompleted }
|
||||
return [pscustomobject]@{ AsyncWaitHandle = $handle }
|
||||
}
|
||||
$client | Add-Member ScriptMethod EndConnect {
|
||||
param($Result)
|
||||
$script:ended++
|
||||
if ($script:connectFails) { throw 'Connection refused' }
|
||||
}
|
||||
$client | Add-Member ScriptMethod Dispose { $script:disposed++ }
|
||||
return $client
|
||||
} -ParameterFilter { $TypeName -eq 'System.Net.Sockets.TcpClient' }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
Remove-Variable sync -Scope Script
|
||||
}
|
||||
|
||||
It 'only probes explicitly eligible unfiltered providers, including when an unknown provider is added' {
|
||||
$script:sync.configs.dns | Add-Member NoteProperty UnknownProvider ([pscustomobject]@{ Primary = '192.0.2.1' })
|
||||
$results = @(Get-WinUtilDNSBenchmark)
|
||||
$results.Count | Should -Be 2
|
||||
$results.Provider | Should -Contain 'Google'
|
||||
$results.Provider | Should -Contain 'Cloudflare'
|
||||
$script:probed.Count | Should -Be 2
|
||||
$script:probed | Should -Contain '8.8.8.8'
|
||||
$script:probed | Should -Contain '1.1.1.1'
|
||||
@($results | Where-Object LatencyMs -ge 9999).Count | Should -Be 0
|
||||
$script:ended | Should -Be 2
|
||||
$script:disposed | Should -Be 2
|
||||
}
|
||||
|
||||
It 'disposes timed out probes and marks them unavailable' {
|
||||
$script:probeCompleted = $false
|
||||
$results = @(Get-WinUtilDNSBenchmark)
|
||||
@($results | Where-Object LatencyMs -eq 9999).Count | Should -Be 2
|
||||
$script:disposed | Should -Be 2
|
||||
$script:ended | Should -Be 0
|
||||
}
|
||||
|
||||
It 'does not treat EndConnect failures as successful latency measurements' {
|
||||
$script:connectFails = $true
|
||||
$results = @(Get-WinUtilDNSBenchmark)
|
||||
@($results | Where-Object LatencyMs -eq 9999).Count | Should -Be 2
|
||||
$script:disposed | Should -Be 2
|
||||
$script:ended | Should -Be 2
|
||||
}
|
||||
|
||||
It 'returns no results when no providers are eligible' {
|
||||
$script:sync.configs.dns.Google.BenchmarkEligible = $false
|
||||
$script:sync.configs.dns.Cloudflare.BenchmarkEligible = $false
|
||||
@(Get-WinUtilDNSBenchmark).Count | Should -Be 0
|
||||
Should -Invoke New-Object -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'rejects infinite or out-of-range timeouts' {
|
||||
{ Get-WinUtilDNSBenchmark -TimeoutMs -1 } | Should -Throw
|
||||
{ Get-WinUtilDNSBenchmark -TimeoutMs 10000 } | Should -Throw
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ BeforeAll {
|
||||
function Clear-DnsClientCache { }
|
||||
|
||||
. (Join-Path $script:repoRoot "functions\private\Set-WinUtilDNS.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilDNSBenchmark.ps1")
|
||||
}
|
||||
|
||||
Describe "Set-WinUtilDNS" {
|
||||
@@ -131,6 +132,35 @@ Describe "Set-WinUtilDNS" {
|
||||
Should -Invoke -CommandName Clear-DnsClientCache -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It "preserves DNS when Fastest has no successful probes" {
|
||||
Mock Get-WinUtilDNSBenchmark {
|
||||
[pscustomobject]@{ Provider = 'Cloudflare'; LatencyMs = 9999 }
|
||||
}
|
||||
Set-WinUtilDNS -DNSProvider 'Fastest' | Should -BeFalse
|
||||
Should -Invoke Get-NetAdapter -Times 0 -Exactly
|
||||
Should -Invoke Set-DnsClientServerAddress -Times 0 -Exactly
|
||||
Should -Invoke New-ItemProperty -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It "preserves DNS when Fastest returns no results" {
|
||||
Mock Get-WinUtilDNSBenchmark { }
|
||||
Set-WinUtilDNS -DNSProvider 'Fastest' | Should -BeFalse
|
||||
Should -Invoke Get-NetAdapter -Times 0 -Exactly
|
||||
Should -Invoke Set-DnsClientServerAddress -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It "applies the successful Fastest provider through the existing DNS and DoH path" {
|
||||
Mock Get-WinUtilDNSBenchmark {
|
||||
[pscustomobject]@{ Provider = 'Cloudflare'; LatencyMs = 12 }
|
||||
[pscustomobject]@{ Provider = 'Google'; LatencyMs = 9999 }
|
||||
}
|
||||
Set-WinUtilDNS -DNSProvider 'Fastest' | Should -BeTrue
|
||||
Should -Invoke Set-DnsClientServerAddress -Times 1 -Exactly -ParameterFilter {
|
||||
$ServerAddresses[0] -eq '1.1.1.1' -and $ServerAddresses[1] -eq '1.0.0.1'
|
||||
}
|
||||
Should -Invoke Add-DnsClientDohServerAddress -Times 4 -Exactly
|
||||
}
|
||||
|
||||
It "updates an existing DoH entry with the selected provider settings" {
|
||||
Mock Get-DnsClientDohServerAddress {
|
||||
if ($ServerAddress -eq "1.1.1.1") {
|
||||
|
||||
Reference in New Issue
Block a user