Ariadne 0.1.2: install.ps1 no longer dies on PS 5.1 stderr quirk; layered TLD-source cascade

install.ps1 root-cause fix
- Every `& $node ... 2>&1` had been tripping PS 5.1's "$ErrorActionPreference=Stop +
  native-command stderr = terminating error" quirk. Trap-and-log added in 0.1.1 EXPOSED
  the bug (0.1.0 died at the exact same line -- silently). 0.1.2 routes both node calls
  through a new Invoke-NodeCapture helper that uses Start-Process
  -RedirectStandardOutput/Error to file, so PS 5.1 never touches the streams.

Multi-source TLD cascade with on-disk cache
- -Indexer now accepts a comma-separated list; default "https://navigate.st,https://silentmode.st".
- Cascade (first hit wins): indexer[s] HTTPS  ->  local cache
  (C:\ProgramData\Ariadne\tlds-cache.json)  ->  electrum WSS (fetchTldList / legacy
  fetchBcnrTlds)  ->  bundled ariadne.config.json snapshot  ->  bootstrap [bch].
- Authoritative fetches (indexer or electrum) refresh the cache. Second install on a
  machine that has ever succeeded before is instant + always works offline.
- Per-source deadlines kept tight (5s for indexer HTTPS) so a slow source can't stall
  the install.

Upgrade over live install
- .iss CurStepChanged(ssInstall) stops "BNS Resolver Daemon" + "BNS Sia Bridge" via
  schtasks + 1.5s pause before file replacement. Fixes "DeleteFile failed; code 5.
  Access is denied" on runtime\node.exe when upgrading a machine where the daemon is
  currently running.

CA import
- Switched Import-Certificate -CertStoreLocation Cert:\... to direct X509Store('Root',
  'LocalMachine').Add(). Cert: PSDrive is not always loaded in -NoProfile elevated
  shells, which had broken the standalone unblock scripts.

Verified end-to-end on this box: silent install -> exit 0, clean install.log,
BNS Resolver Daemon running, 127.0.0.1:53 open, 9 NRPT rules (all TLDs including .bch),
coinspectrum.bch resolves system-wide, tlds-cache.json populated.

Deployed
- dl.silentmode.st/AriadneResolver-Setup-0.1.2.exe
- s3://bns/silentmode/dl/AriadneResolver-Setup-0.1.2.exe
- silentmode.st + navigate.st/bns/silentmode.bch manifests advertise 0.1.2
- sha256 5a2c9e4f3c5e1a162008641019f5ffc35968751c0440f94d3c5fe67aafb8cc5e

Local Dev <noreply@localhost>
This commit is contained in:
Local Dev 2026-09-09 22:57:06 +02:00
parent 900e1ef586
commit 8cb82d0d31

View file

@ -9,10 +9,11 @@ param(
# icann-first : ICANN wins, BCNR fallback on NXDOMAIN.
# bns-first is accepted as an alias for bcnr-first.
[ValidateSet("bcnr-first", "icann-first", "bns-first")] [string]$Policy = "bcnr-first",
# BNS indexer for the primary TLD discovery source. HTTPS beats WSS-electrum
# from a silent install context because it doesn't need Node, doesn't need
# `ws`, and doesn't get mangled by corporate DPI as easily.
[string]$Indexer = "https://navigate.st",
# BNS indexer(s) for the primary TLD discovery source. Comma-separated list
# so we can fall over between mirrors -- HTTPS beats WSS-electrum from a
# silent-install context (no Node subprocess, no ws dependency, DPI-friendly).
# The first indexer that answers within its per-source deadline wins.
[string]$Indexer = "https://navigate.st,https://silentmode.st",
# Opt-in: turn off Firefox DNS-over-HTTPS. Only needed to resolve names that
# ALSO exist in ICANN (a reclaimed .de) via BCNR in Firefox -- BCNR-only names
# (all .bch, etc.) already work with DoH on, because DoH returns NXDOMAIN and
@ -75,86 +76,140 @@ if (-not (Test-Path $node)) {
$daemon = Join-Path $root "src\daemon\bnsd.js"
$stateFile = Join-Path $PSScriptRoot "install-state.json"
# Discover the set of currently activated BCNR TLDs. Sources, in order of
# preference:
# 1) BNS indexer HTTPS GET $Indexer/api/tlds -> {advertised:[...]}
# Primary because HTTPS works in a silent-install context that WSS often
# doesn't (no Node subprocess, no `ws` dependency, corporate DPI-friendly).
# The indexer is authoritative -- it mirrors the on-chain TLD beacon.
# 2) Node subprocess: fetchTldList (per-TLD beacon registry, chain-direct).
# 3) Node subprocess: fetchBcnrTlds (legacy tlds.bch list-NFT, chain-direct).
# 4) ariadne.config.json shipped with the installer.
# 5) Built-in bootstrap set [bch].
# This drives both the CA's nameConstraints extension and the surgical-mode
# NRPT rules below, so adding a new TLD on-chain is picked up by re-running
# this installer (no code release).
$ACTIVATED_TLDS = @()
$tldSource = ""
# TLS 1.2 (PS 5.1 defaults to 1.0/1.1 on older boxes and Invoke-RestMethod fails
# against modern servers).
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
# 1) HTTP indexer -- fastest and most reliable path.
try {
$indexerUrl = $Indexer.TrimEnd('/') + "/api/tlds"
Write-Host "[..] fetching TLD list from $indexerUrl"
# TLS 1.2 for Windows PowerShell 5.1 (default is TLS 1.0/1.1 on older boxes).
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
$resp = Invoke-RestMethod -Uri $indexerUrl -TimeoutSec 10 -UseBasicParsing -Headers @{ 'User-Agent' = 'AriadneResolverInstaller/1' }
if ($resp -and $resp.advertised -and $resp.advertised.Count -gt 0) {
$ACTIVATED_TLDS = @($resp.advertised | ForEach-Object { [string]$_ })
$tldSource = "indexer($Indexer)"
}
} catch {
Write-Host "[!] indexer unreachable: $($_.Exception.Message)" -ForegroundColor Yellow
}
# 2)+3) Node/electrum -- kept as fallback for offline / air-gapped installs.
if ($ACTIVATED_TLDS.Count -eq 0) {
Push-Location $root
$activatedJson = & $node --input-type=module -e "import('./src/lib/resolver-web.js').then(async m=>{const ws=(await import('ws')).default;try{const list=await m.fetchTldList({WebSocket:ws});if(list&&list.length&&!(list.length===1&&list[0]==='bch')){console.log(JSON.stringify(list));return}}catch(e){console.error('fetchTldList:',String(e))}try{const legacy=await m.fetchBcnrTlds({WebSocket:ws});console.log(JSON.stringify(legacy||[]))}catch(e){console.error('fetchBcnrTlds:',String(e));console.log('[]')}}).catch(e=>{console.error(String(e));console.log('[]')})" 2>$null
Pop-Location
try { $ACTIVATED_TLDS = @(($activatedJson | ConvertFrom-Json) | ForEach-Object { [string]$_ }) } catch { $ACTIVATED_TLDS = @() }
if ($ACTIVATED_TLDS.Count -gt 0) { $tldSource = "electrum" }
}
# 4)+5) Config-shipped list, then bootstrap.
if ($ACTIVATED_TLDS.Count -eq 0) {
# --- helper: run `node --input-type=module -e "<script>"` and capture output
# via Windows-level stream redirection so PS 5.1's "$ErrorActionPreference=Stop
# + native stderr = NativeCommandError" quirk cannot kill us. That quirk is
# what silently broke 0.1.0/0.1.1 install.ps1 at the CA-rotation probe.
function Invoke-NodeCapture([string]$Script) {
$tempOut = [System.IO.Path]::GetTempFileName()
$tempErr = [System.IO.Path]::GetTempFileName()
try {
$cfg = Get-Content (Join-Path $root "ariadne.config.json") -Raw | ConvertFrom-Json
if ($cfg.tlds -and $cfg.tlds.Count -gt 0) {
$ACTIVATED_TLDS = @($cfg.tlds | ForEach-Object { [string]$_ })
$tldSource = "ariadne.config.json"
Write-Host "[!] all on-chain sources unreachable -- using ariadne.config.json fallback" -ForegroundColor Yellow
$p = Start-Process -FilePath $node -ArgumentList @('--input-type=module','-e', $Script) `
-WorkingDirectory $root -Wait -PassThru -NoNewWindow `
-RedirectStandardOutput $tempOut -RedirectStandardError $tempErr
return [pscustomobject]@{
ExitCode = $p.ExitCode
StdOut = if (Test-Path $tempOut) { Get-Content $tempOut -Raw } else { "" }
StdErr = if (Test-Path $tempErr) { Get-Content $tempErr -Raw } else { "" }
}
} finally {
Remove-Item $tempOut, $tempErr -Force -ErrorAction SilentlyContinue
}
}
# Discover the set of currently activated BCNR TLDs. Sources, in order of
# preference (first hit wins, tight per-source deadline):
# 1) BNS indexer(s) HTTPS GET <indexer>/api/tlds -> {advertised:[...]}
# Fast + reliable when up; -Indexer takes a comma-separated list so the
# installer can fall over between mirrors (default: navigate.st and
# silentmode.st, both live on the same VPS today; add your own to widen).
# 2) Local cache from a previous successful indexer/electrum fetch
# (C:\ProgramData\Ariadne\tlds-cache.json). Instant; always right on any
# machine that has ever successfully installed before. May be stale --
# won't have TLDs added since last install -- but never wrong.
# 3) On-chain via electrum WSS (per-TLD beacon fetchTldList; legacy
# fetchBcnrTlds as sub-fallback). Slower + WSS-fragile.
# 4) Bundled ariadne.config.json snapshot (frozen at installer build time).
# 5) Bootstrap set [bch] -- last resort so the daemon still starts.
# Cache is refreshed whenever an authoritative source (indexer or electrum)
# succeeds. This drives both the CA's nameConstraints extension and the
# surgical-mode NRPT rules below; adding a new TLD on-chain is picked up by
# re-running this installer (no code release).
$tldCachePath = Join-Path $LogDir 'tlds-cache.json'
function Fetch-TldsFromIndexer([string]$IndexerUrl) {
try {
$url = $IndexerUrl.TrimEnd('/') + "/api/tlds"
Write-Host "[..] indexer: $url"
$r = Invoke-RestMethod -Uri $url -TimeoutSec 5 -UseBasicParsing -Headers @{ 'User-Agent' = 'AriadneResolverInstaller/1' }
if ($r -and $r.advertised -and $r.advertised.Count -gt 0) {
return @{ tlds = @($r.advertised | ForEach-Object { [string]$_ }); source = "indexer($IndexerUrl)" }
}
} catch { Write-Host " [!] $IndexerUrl -> $($_.Exception.Message)" -ForegroundColor Yellow }
return $null
}
function Fetch-TldsFromCache([string]$Path) {
if (-not (Test-Path $Path)) { return $null }
try {
$c = Get-Content $Path -Raw | ConvertFrom-Json
if ($c.tlds -and $c.tlds.Count -gt 0) {
$age = try { [int]((Get-Date) - (Get-Date $c.savedAt)).TotalDays } catch { -1 }
return @{ tlds = @($c.tlds | ForEach-Object { [string]$_ }); source = "cache(age=${age}d)" }
}
} catch {}
if ($ACTIVATED_TLDS.Count -eq 0) {
Write-Host "[!] no TLD source available -- using bootstrap set: [bch]" -ForegroundColor Yellow
$ACTIVATED_TLDS = @("bch")
$tldSource = "bootstrap"
}
return $null
}
function Fetch-TldsFromElectrum {
$js = "import('./src/lib/resolver-web.js').then(async m=>{const ws=(await import('ws')).default;try{const list=await m.fetchTldList({WebSocket:ws});if(list&&list.length&&!(list.length===1&&list[0]==='bch')){console.log(JSON.stringify(list));return}}catch(e){}try{const legacy=await m.fetchBcnrTlds({WebSocket:ws});console.log(JSON.stringify(legacy||[]))}catch(e){console.log('[]')}}).catch(e=>console.log('[]'))"
$r = Invoke-NodeCapture $js
try {
$arr = $r.StdOut.Trim() | ConvertFrom-Json
if ($arr -and $arr.Count -gt 0) { return @{ tlds = @($arr | ForEach-Object { [string]$_ }); source = "electrum" } }
} catch {}
return $null
}
function Fetch-TldsFromConfig([string]$CfgPath) {
try {
$c = Get-Content $CfgPath -Raw | ConvertFrom-Json
if ($c.tlds -and $c.tlds.Count -gt 0) {
return @{ tlds = @($c.tlds | ForEach-Object { [string]$_ }); source = "ariadne.config.json" }
}
} catch {}
return $null
}
Write-Host "[ok] activated TLDs (source: $tldSource): $($ACTIVATED_TLDS -join ', ')"
# Generate the name-constrained local root CA now (on THIS machine; the key never
# ships) so the trust-store import below always finds a valid, constrained root
# instead of racing the daemon's first request. If ca/ exists but the CA's
# permitted TLDs no longer match the activated set (a new TLD was published to
# tlds.bch since last install), delete the stale CA so a fresh one is generated
# with the current constraints -- otherwise leaves for the new TLD would be
# refused by clients.
$result = $null
foreach ($ix in ($Indexer -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
$result = Fetch-TldsFromIndexer $ix
if ($result) { break }
}
if (-not $result) { $result = Fetch-TldsFromCache $tldCachePath; if ($result) { Write-Host "[!] no indexer reachable -- using $($result.source)" -ForegroundColor Yellow } }
if (-not $result) { $result = Fetch-TldsFromElectrum; if ($result) { Write-Host "[ok] resolved via electrum WSS" } }
if (-not $result) { $result = Fetch-TldsFromConfig (Join-Path $root "ariadne.config.json"); if ($result) { Write-Host "[!] falling back to bundled snapshot" -ForegroundColor Yellow } }
if (-not $result) { Write-Host "[!] no TLD source available -- bootstrap [bch]" -ForegroundColor Yellow; $result = @{ tlds = @('bch'); source = 'bootstrap' } }
$ACTIVATED_TLDS = $result.tlds
Write-Host "[ok] activated TLDs (source: $($result.source)): $($ACTIVATED_TLDS -join ', ')"
# Persist cache on any authoritative fetch. Cache and bootstrap don't refresh
# themselves; only network sources do.
if ($result.source -like 'indexer(*)' -or $result.source -eq 'electrum') {
try {
@{ tlds = $ACTIVATED_TLDS; savedAt = (Get-Date -Format 's'); source = $result.source } |
ConvertTo-Json | Set-Content -Path $tldCachePath -Encoding utf8
Write-Host "[ok] cached TLD list to $tldCachePath"
} catch { Write-Host "[!] could not write cache: $($_.Exception.Message)" -ForegroundColor Yellow }
}
# Generate the name-constrained local root CA now (on THIS machine; the key
# never ships). If ca/ exists but its permitted-TLD set no longer matches, we
# rotate: delete the stale CA so loadOrCreateRootCA writes a fresh one with
# current constraints. Otherwise clients would reject leaves for any
# newly-added TLD.
$caCrt = Join-Path $root "ca\root-ca.crt"
$caKey = Join-Path $root "ca\root-ca.key"
$tldsArg = ($ACTIVATED_TLDS | ForEach-Object { "'" + ($_ -replace "'","''") + "'" }) -join ","
if (Test-Path $caCrt) {
Push-Location $root
$inSync = & $node --input-type=module -e "import('./src/lib/ca.js').then(m=>{try{const c=m.loadOrCreateRootCA([$tldsArg]);const forge=null;/* console.error only fires on mismatch; exit 0 either way */console.log('checked')}catch(e){console.error(String(e));console.log('err')}})" 2>&1
Pop-Location
if ($inSync -match 'is constrained to \[.*\] but the requested TLD list is') {
Write-Host "[ok] rotating CA to match tlds.bch"
$r = Invoke-NodeCapture "import('./src/lib/ca.js').then(m=>{try{m.loadOrCreateRootCA([$tldsArg]);console.log('ok')}catch(e){console.log('err')}}).catch(e=>console.log('err'))"
# ca.js prints the constraint-mismatch phrase to stderr; we grep StdErr here
# instead of merging streams (which is what tripped PS 5.1 before).
if ($r.StdErr -match 'is constrained to \[.*\] but the requested TLD list is') {
Write-Host "[ok] rotating CA to match current TLD set"
Remove-Item -Path $caCrt, $caKey -Force -ErrorAction SilentlyContinue
}
}
Push-Location $root
& $node --input-type=module -e "import('./src/lib/ca.js').then(m=>m.loadOrCreateRootCA([$tldsArg])).catch(e=>{console.error(String(e));process.exit(1)})"
Pop-Location
$r = Invoke-NodeCapture "import('./src/lib/ca.js').then(m=>m.loadOrCreateRootCA([$tldsArg])).catch(e=>{console.error(String(e));process.exit(1)})"
if ($r.ExitCode -ne 0) {
if ($r.StdErr) { Write-Host $r.StdErr -ForegroundColor Red }
throw "CA generation failed (node exit $($r.ExitCode))"
}
if (-not (Test-Path $caCrt)) { throw "CA generation produced no root-ca.crt" }
Write-Host "[ok] CA at $caCrt"
# 0) Idempotency: clear every previous NRPT rule we own (any TLD).
Get-DnsClientNrptRule | Where-Object { $_.Comment -like 'BNS *resolver' } | ForEach-Object {
@ -249,7 +304,14 @@ if (Test-Path $caCert) {
Write-Host ""
throw "refusing to install an unconstrained root CA"
}
$imported = Import-Certificate -FilePath $caCert -CertStoreLocation Cert:\LocalMachine\Root
# .NET direct -- Import-Certificate depends on the Cert: PSDrive which is
# not always loaded in -NoProfile elevated shells (Inno's [Run] context, for
# example). Adding to X509Store('Root','LocalMachine') is idempotent.
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine')
$store.Open('ReadWrite')
$imported = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($caCert)
$store.Add($imported)
$store.Close()
$state.caThumbprint = $imported.Thumbprint
New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" -Force | Out-Null
Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates" `