# BNS resolver daemon installer -- run from an ELEVATED PowerShell. # .\setup\install.ps1 # surgical: NRPT rule routes only .bch to the daemon # .\setup\install.ps1 -Mode full # universal: adapter DNS -> 127.0.0.1, daemon forwards the rest param( [ValidateSet("nrpt", "full")] [string]$Mode = "nrpt", [string]$Upstream = "1.1.1.1", # Collision policy the daemon should run under (docs: START-HERE.md 4b). # bcnr-first (default): BCNR wins, ICANN fallback on miss. # 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", # 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 # Firefox falls back to the local resolver. Trade-off: Firefox's other DNS then # goes through the system resolver instead of encrypted to Cloudflare. [switch]$FirefoxNoDoH, # Opt-in: force-close a running Firefox so it reloads the new root CA + policy # immediately (Firefox only reads them at startup). Off by default because it # discards the user's open tabs/unsaved data. [switch]$CloseFirefox ) $ErrorActionPreference = "Stop" # ---- persistent install log -------------------------------------------------- # Silent-install callers (Theseus's NSIS chain-install, `.exe /VERYSILENT`, Inno # `runhidden`) discard our stdout/stderr, so historically any failure between # "files copied" and "scheduled task registered" was invisible. This appends a # timestamped transcript to a well-known path so a broken install always leaves # forensics behind. Errors starting the transcript are non-fatal: an unloggable # install must still complete or fail visibly. $LogDir = Join-Path $env:ProgramData 'Ariadne' try { New-Item -ItemType Directory -Force -Path $LogDir | Out-Null } catch {} $LogFile = Join-Path $LogDir 'install.log' $ErrFile = Join-Path $LogDir 'install-error.log' try { Start-Transcript -Path $LogFile -Append -Force -IncludeInvocationHeader | Out-Null } catch {} Write-Host "" Write-Host ("==== install.ps1 " + (Get-Date -Format 's') + " Mode=$Mode Policy=$Policy Indexer=$Indexer ====") # Script-scope trap: any uncaught exception is written to install-error.log # (separate file so a post-mortem doesn't have to grep through the whole # transcript), the transcript is closed cleanly, then the exception is # re-thrown so the caller (Inno's [Run], NSIS's ExecWait, or the user's PS) # still sees a non-zero exit code and can act on it. trap { try { $stamp = Get-Date -Format 's' $msg = "[FATAL $stamp] $($_ | Out-String)`nScriptStack:`n$($_.ScriptStackTrace)`n" Add-Content -Path $ErrFile -Value $msg -ErrorAction SilentlyContinue Write-Host $msg -ForegroundColor Red } catch {} try { Stop-Transcript | Out-Null } catch {} exit 1 } if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { try { Stop-Transcript | Out-Null } catch {} throw "Run this from an elevated (Administrator) PowerShell." } $root = Split-Path -Parent $PSScriptRoot # Prefer a bundled Node runtime (distributable installer ships runtime\node.exe); # fall back to a Node on PATH for running straight from the dev repo. $node = Join-Path $root "runtime\node.exe" if (-not (Test-Path $node)) { $onPath = Get-Command node -ErrorAction SilentlyContinue if (-not $onPath) { throw "No Node runtime found: expected runtime\node.exe (bundle) or node on PATH (dev repo)." } $node = $onPath.Source } $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 = "" # 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) { 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 } } catch {} if ($ACTIVATED_TLDS.Count -eq 0) { Write-Host "[!] no TLD source available -- using bootstrap set: [bch]" -ForegroundColor Yellow $ACTIVATED_TLDS = @("bch") $tldSource = "bootstrap" } } 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. $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" 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 # 0) Idempotency: clear every previous NRPT rule we own (any TLD). Get-DnsClientNrptRule | Where-Object { $_.Comment -like 'BNS *resolver' } | ForEach-Object { Remove-DnsClientNrptRule -Name $_.Name -Force } try { Stop-ScheduledTask -TaskName "BNS Resolver Daemon" -ErrorAction Stop } catch {} try { Stop-ScheduledTask -TaskName "BNS Sia Bridge" -ErrorAction Stop } catch {} # 1) Scheduled task: run the daemon at boot as SYSTEM (survives logoff, binds port 53/80/443) # Pass --policy so bnsd.js applies the chosen collision mode (docs: 4b). $action = New-ScheduledTaskAction -Execute $node -Argument "`"$daemon`" --upstream $Upstream --policy $Policy" -WorkingDirectory $root $trigger = New-ScheduledTaskTrigger -AtStartup $settings = New-ScheduledTaskSettingsSet -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650) Register-ScheduledTask -TaskName "BNS Resolver Daemon" -Action $action -Trigger $trigger -Settings $settings ` -User "SYSTEM" -RunLevel Highest -Force | Out-Null Start-ScheduledTask -TaskName "BNS Resolver Daemon" Write-Host "[ok] daemon installed as scheduled task 'BNS Resolver Daemon' and started" # 1b) Sia bridge (optional): serves "s3" records from the Sia network $s3d = Join-Path $root "tools\s3d.exe" if (Test-Path $s3d) { # runs as the logged-on user (s3d's login state lives in the user's APPDATA) $s3dAction = New-ScheduledTaskAction -Execute $s3d -WorkingDirectory $root $s3dTrigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME $s3dPrincipal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive Register-ScheduledTask -TaskName "BNS Sia Bridge" -Action $s3dAction -Trigger $s3dTrigger ` -Settings $settings -Principal $s3dPrincipal -Force | Out-Null Start-ScheduledTask -TaskName "BNS Sia Bridge" Write-Host "[ok] s3d Sia bridge installed (runs at your logon) and started" } # 2) Route queries to it $state = @{ mode = $Mode; adapters = @() } if ($Mode -eq "nrpt") { # Surgical mode: one NRPT rule per TLD that tlds.bch has activated on-chain. # BCNR-native TLDs are declared there and only there; a re-install after a new # TLD is activated picks it up with no code release. Dual TLDs that collide # with ICANN are still safe to route because the daemon falls through to # UPSTREAM on any BCNR miss. foreach ($tld in $ACTIVATED_TLDS) { $ns = ".$tld" # Idempotent: clean any pre-existing rule for this namespace first. Get-DnsClientNrptRule | Where-Object { $_.Namespace -contains $ns -and $_.Comment -like 'BNS *' } | ForEach-Object { Remove-DnsClientNrptRule -Name $_.Name -Force } Add-DnsClientNrptRule -Namespace $ns -NameServers "127.0.0.1" -Comment "BNS $ns resolver" Write-Host "[ok] NRPT rule added: $ns -> 127.0.0.1" } Write-Host "[ok] all other DNS untouched" } else { # Reinstall-safe backup: if the machine is ALREADY pointed at us (127.0.0.1) from a # prior install, the "current" servers are a useless restore target. Reuse the last # good backup if we have one, else fall back to DHCP. NEVER record loopback as the # original -- that's exactly what left a past uninstall pointing DNS at a dead daemon. $prior = @{} if (Test-Path $stateFile) { try { (Get-Content $stateFile | ConvertFrom-Json).adapters | ForEach-Object { $prior[$_.alias] = $_ } } catch {} } foreach ($if in Get-DnsClientServerAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notmatch "Loopback" -and $_.ServerAddresses.Count -gt 0 }) { $orig = @($if.ServerAddresses | Where-Object { $_ -ne '127.0.0.1' -and $_ -ne '::1' }) if ($orig.Count -eq 0 -and $prior.ContainsKey($if.InterfaceAlias)) { $orig = @($prior[$if.InterfaceAlias].servers | Where-Object { $_ -and $_ -ne '127.0.0.1' -and $_ -ne '::1' }) } $state.adapters += @{ alias = $if.InterfaceAlias; servers = $orig; resetToDhcp = ($orig.Count -eq 0) } Set-DnsClientServerAddress -InterfaceAlias $if.InterfaceAlias -ServerAddresses 127.0.0.1 $restoreTo = if ($orig.Count) { $orig -join ', ' } else { 'DHCP (automatic)' } Write-Host "[ok] adapter '$($if.InterfaceAlias)' DNS -> 127.0.0.1 (restore target: $restoreTo)" } } # 3) TLS trust anchor: BNS Local Root CA into the machine store, and tell # Firefox (which keeps its own store) to honor Windows enterprise roots. $caCert = Join-Path $root "ca\root-ca.crt" if (Test-Path $caCert) { # Refuse to trust a root that can vouch for the whole internet. Roots generated # before 2026-07-26 carry no name constraints; installing one means anyone who # can read ca\root-ca.key can forge a trusted certificate for any domain on this # machine. Constrained roots are limited to the TLDs in src\lib\tlds.js. $ncOid = "2.5.29.30" $parsed = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($caCert) $hasConstraints = @($parsed.Extensions | Where-Object { $_.Oid.Value -eq $ncOid }).Count -gt 0 $parsed.Dispose() if (-not $hasConstraints) { Write-Host "" Write-Host "[STOP] ca\root-ca.crt has NO X.509 name constraints." -ForegroundColor Red Write-Host " Trusting it would let anyone who reads ca\root-ca.key forge a" -ForegroundColor Red Write-Host " certificate for ANY domain on this machine, not just BNS names." -ForegroundColor Red Write-Host " Rotate the CA, then re-run this installer:" -ForegroundColor Red Write-Host " setup\uninstall.ps1 (removes the old root from the store)" -ForegroundColor Red Write-Host " Remove-Item -Recurse $($root)\ca" Write-Host " setup\install.ps1 (generates a constrained root)" -ForegroundColor Red Write-Host "" throw "refusing to install an unconstrained root CA" } $imported = Import-Certificate -FilePath $caCert -CertStoreLocation Cert:\LocalMachine\Root $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" ` -Name "ImportEnterpriseRoots" -Value 1 -Type DWord # Also install our specific root into Firefox's own store (works even if the # user disabled enterprise-roots). Both policies are applied at Firefox startup. New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates\Install" -Force | Out-Null Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\Certificates\Install" -Name "1" -Value $caCert -Type String Write-Host "[ok] BNS Local Root CA trusted (machine store + Firefox enterprise-roots & install policies)" # Firefox only loads the enterprise-roots policy + new root at startup, so a # running Firefox keeps rejecting .bch until it is restarted. $ff = Get-Process firefox -ErrorAction SilentlyContinue if ($ff) { if ($CloseFirefox) { $ff | Stop-Process -Force Write-Host "[ok] Firefox was closed -- reopen it and .bch certificates will be trusted" } else { Write-Host "[!] Firefox is open -- fully QUIT and reopen it so it trusts .bch (or re-run with -CloseFirefox)." -ForegroundColor Yellow } } } else { Write-Host "[skip] no ca\root-ca.crt found -- https for .bch not enabled" } # 3b) Firefox DNS-over-HTTPS. BCNR-only names (all .bch, etc.) already work in # Firefox with DoH ON -- DoH returns NXDOMAIN and Firefox falls back to the # system resolver (us). Only names that ALSO exist in ICANN (a reclaimed .de) # lose to the real record. Turning DoH off fixes those but sends Firefox's # other DNS through the system resolver instead of encrypted to Cloudflare -- # so it's opt-in (-FirefoxNoDoH) and left unlocked so the user can re-enable. if ($FirefoxNoDoH) { New-Item -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Force | Out-Null Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Mozilla\Firefox\DNSOverHTTPS" -Name "Enabled" -Value 0 -Type DWord Write-Host "[ok] Firefox DNS-over-HTTPS disabled (opt-in) -- ICANN-conflicting names now resolve via BCNR in Firefox too" } else { Write-Host "[info] Firefox DoH left on (BCNR-only names work anyway). To also reclaim names that exist in ICANN, re-run with -FirefoxNoDoH" } $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile Clear-DnsClientCache Write-Host "[done] try: https://hello.bch (uninstall: .\setup\uninstall.ps1)" try { Stop-Transcript | Out-Null } catch {}