2026-07-29 13:54:34 +02:00
// BNS public gateway (the "remote relay", convenience tier).
// Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves
// the content over plain HTTPS so ANY device's browser can view .bch sites with
// no install. Two URL forms:
// path form: https://navigate.st/bns/<name>.bch/... (<base> injected)
// subdomain form: https://<name-flattened>.bch.navigate.st/ (own origin, no <base>)
// where dots in a multi-level name become dashes: coinspectrum.deviant.bch
// -> coinspectrum-deviant.bch.navigate.st
//
// Honest disclosure (convenience tier, not the trustless one): the operator can
// see which .bch sites you request. No logs — but that's trust. Install the
// resolver or Theseus for trustless resolution; use Tor for IP privacy.
import http from "http" ;
import { AwsClient } from "aws4fetch" ;
import WebSocket from "ws" ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// resolver-web.js lives at ../lib/, not alongside this file — the previous
// `./resolver-web.js` import was broken (file was moved into lib/ but this
// import wasn't updated). Fix while touching the file.
import { resolveName , buildIndex , normalizeName } from "../lib/resolver-web.js" ;
import { pickRecord , isSubdomainOf } from "../lib/record-picker.js" ;
2026-07-29 13:54:34 +02:00
// Full-index cache (rebuilt every 30s) for fast availability lookups.
let _idx = { at : 0 , index : null } ;
async function getIndex ( ) {
if ( ! _idx . index || Date . now ( ) - _idx . at > 30000 ) {
_idx = { at : Date . now ( ) , index : await buildIndex ( { WebSocket } ) } ;
}
return _idx . index ;
}
const MOUNT = process . env . BNS _MOUNT || "/bns" ;
const PORT = Number ( process . env . BNS _PORT || 8700 ) ;
const S3 _ENDPOINT = process . env . BNS _S3 || "http://127.0.0.1:8500" ;
const SUB _BASE = ( process . env . BNS _SUB _BASE || "bch.navigate.st" ) . toLowerCase ( ) ; // subdomain suffix
const aws = new AwsClient ( {
accessKeyId : process . env . AK , secretAccessKey : process . env . SK ,
service : "s3" , region : "us-east-1" ,
} ) ;
const cache = new Map ( ) ;
async function resolve ( host ) {
const c = cache . get ( host ) ;
if ( c && Date . now ( ) - c . at < 30000 ) return c . entry ;
const entry = await resolveName ( host , { WebSocket } ) ; // resolveName normalizes any <sub>.<label>.<tld>
cache . set ( host , { entry , at : Date . now ( ) } ) ;
return entry ;
}
// subfolder = labels before the registered "<label>.<tld>" (drop tld, drop label)
const subFolder = ( host ) => {
const l = host . split ( "." ) ; l . pop ( ) ; l . pop ( ) ; return l . reverse ( ) . join ( "/" ) ;
} ;
const MIME = { html : "text/html; charset=utf-8" , css : "text/css" , js : "text/javascript" ,
json : "application/json" , png : "image/png" , jpg : "image/jpeg" , jpeg : "image/jpeg" ,
gif : "image/gif" , svg : "image/svg+xml" , ico : "image/x-icon" , webp : "image/webp" ,
woff2 : "font/woff2" , woff : "font/woff" , wasm : "application/wasm" , txt : "text/plain" } ;
const guess = ( p ) => MIME [ p . split ( "." ) . pop ( ) ? . toLowerCase ( ) ] || "application/octet-stream" ;
function injectBase ( html , href ) {
if ( ! href || /<base\s/i . test ( html ) ) return html ;
if ( /<head[^>]*>/i . test ( html ) ) return html . replace ( /<head[^>]*>/i , ( m ) => m + ` <base href=" ${ href } "> ` ) ;
return ` <base href=" ${ href } "> ` + html ;
}
const nx = ( h ) => ` <div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code> ${ h } </code> is not registered on the Bitcoin Cash chain.</p></div> ` ;
// Serve a resolved name. baseHref = null for subdomain form (own origin).
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
//
// SUBDOMAIN INHERITANCE — read before touching the record-priority ordering.
// A subdomain like `checkers.game.x` shares its parent's on-chain entry
// (`normalizeName` collapses it to `game.x`), so we serve it from the parent's
// records. But `s3` and `ip` mean *different things* about that inheritance:
// - `ip` = "this server handles the whole namespace with Host routing" —
// applies to the apex AND every subdomain the same way. Sending
// `Host: checkers.game.x` lets the parent's nginx serve the subdomain.
// - `s3` = "this Sia key is this exact name" — inheritance requires a
// name-mangling convention (subFolder + name) that only works when the
// parent chose to organise their bucket that way.
// So for subdomains, `ip` is the unambiguous intent and takes priority. For the
// apex, current priority stands (h → s3 → ip → u). This does not break
// Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback
// still tries `s3` first as before.
2026-07-29 13:54:34 +02:00
async function serve ( res , name , reqPath , baseHref ) {
let entry ;
try { entry = await resolve ( name ) ; } catch ( e ) { res . writeHead ( 502 ) ; return res . end ( "resolve error: " + e . message ) ; }
if ( ! entry ) { res . writeHead ( 404 , { "content-type" : "text/html" } ) ; return res . end ( nx ( name ) ) ; }
const r = entry . records ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// `pickRecord` is the single source of truth for record priority; keep this
// call and the inline copy in TheseusNavigator/main.js:serveBns in step.
const pick = pickRecord ( r , { isSubdomain : isSubdomainOf ( name , entry . name ) } ) ;
2026-07-29 13:54:34 +02:00
try {
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
switch ( pick ) {
case "h" :
if ( reqPath !== "/" ) { res . writeHead ( 404 ) ; return res . end ( "not found" ) ; }
res . writeHead ( 200 , { "content-type" : "text/html; charset=utf-8" } ) ;
return res . end ( injectBase ( r . h , baseHref ) ) ;
case "s3" : {
const sub = subFolder ( name ) ;
let base = r . s3 ; if ( base . endsWith ( "/" ) && sub ) base += sub + "/" ;
const key = base . endsWith ( "/" ) ? base + ( reqPath === "/" ? "index.html" : reqPath . slice ( 1 ) ) : base ;
const up = await aws . fetch ( ` ${ S3 _ENDPOINT } / ${ key } ` ) ;
const buf = Buffer . from ( await up . arrayBuffer ( ) ) ;
let ct = up . headers . get ( "content-type" ) ; if ( ! ct || ct === "application/octet-stream" ) ct = guess ( key ) ;
if ( ct . includes ( "text/html" ) ) { res . writeHead ( up . status , { "content-type" : ct } ) ; return res . end ( injectBase ( buf . toString ( "utf8" ) , baseHref ) ) ; }
res . writeHead ( up . status , { "content-type" : ct } ) ; return res . end ( buf ) ;
}
case "ip" : {
const up = await fetch ( ` http:// ${ r . ip } ${ reqPath } ` , { headers : { host : name } } ) ;
const buf = Buffer . from ( await up . arrayBuffer ( ) ) ;
let ct = up . headers . get ( "content-type" ) || guess ( reqPath ) ;
if ( ct . includes ( "text/html" ) ) { res . writeHead ( up . status , { "content-type" : ct } ) ; return res . end ( injectBase ( buf . toString ( "utf8" ) , baseHref ) ) ; }
res . writeHead ( up . status , { "content-type" : ct } ) ; return res . end ( buf ) ;
}
case "u" :
res . writeHead ( 302 , { location : r . u } ) ; return res . end ( ) ;
default :
res . writeHead ( 200 , { "content-type" : "application/json" } ) ; return res . end ( JSON . stringify ( entry , null , 2 ) ) ;
2026-07-29 13:54:34 +02:00
}
} catch ( e ) { res . writeHead ( 502 ) ; res . end ( "gateway error: " + e . message ) ; }
}
2026-07-30 21:21:01 +02:00
// Native BNS TLDs — never overlap ICANN, so hijacking them here is safe.
// Dual TLDs (.com, .de, .dev) always fall through to search, so a bare
// `github.com` in the URL bar keeps going to the real GitHub, not to a
// BNS registration that happens to share the name.
const NATIVE _TLDS = new Set ( [ "bch" , "p2p" , "bit" , "nav" ] ) ;
// Universal "set-as-default-search-engine" shim. Users add
// https://navigate.st/search?q=%s
// as their browser's default search engine; typing `silentmode.bch` in the URL
// bar (which Chromium/Firefox otherwise send to Google as a search query
// because .bch isn't in ICANN's known-TLD list) arrives here. If q looks like
// a native BNS name we 302 to the relay (works in every browser, no local
// daemon needed); otherwise forward to DuckDuckGo so ordinary search still
// works. Even an unregistered native-TLD query goes to the relay so the user
// gets a clear NXDOMAIN page instead of a confusing search detour.
async function handleSearch ( url , res ) {
const q = ( url . searchParams . get ( "q" ) || "" ) . trim ( ) . toLowerCase ( ) ;
const bare = q . replace ( /^[a-z]+:\/\// , "" ) . split ( "/" ) [ 0 ] ;
const m = bare . match ( /^([a-z0-9-]+)\.([a-z0-9-]+)$/ ) ;
let target ;
if ( m && NATIVE _TLDS . has ( m [ 2 ] ) ) {
target = ` https://navigate.st/bns/ ${ m [ 1 ] } . ${ m [ 2 ] } / ` ;
} else {
target = ` https://duckduckgo.com/?q= ${ encodeURIComponent ( q ) } ` ;
}
res . writeHead ( 302 , { location : target } ) ;
res . end ( ) ;
}
2026-07-29 13:54:34 +02:00
const server = http . createServer ( async ( req , res ) => {
try {
const hostHeader = ( req . headers . host || "" ) . split ( ":" ) [ 0 ] . toLowerCase ( ) ;
2026-07-30 21:21:01 +02:00
// --- smart search endpoint (default-search-engine shim) ---
if ( req . url . startsWith ( "/search" ) ) {
const u = new URL ( req . url , "http://x" ) ;
return handleSearch ( u , res ) ;
}
2026-07-29 13:54:34 +02:00
// --- registrar API: availability of a name ---
// GET /api/name/<name> -> { name, tld, registered, records? }
if ( req . url . startsWith ( "/api/name/" ) ) {
res . setHeader ( "access-control-allow-origin" , "*" ) ;
res . setHeader ( "content-type" , "application/json" ) ;
const raw = decodeURIComponent ( req . url . slice ( "/api/name/" . length ) . split ( "?" ) [ 0 ] ) ;
try {
const key = normalizeName ( raw ) ; // "<label>.<tld>"
const tld = key . split ( "." ) . pop ( ) ;
const entry = ( await getIndex ( ) ) . get ( key ) ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( {
name : key , tld , registered : ! ! entry ,
records : entry ? . records ? ? null , category : entry ? . category ? ? null ,
} ) ) ;
} catch ( e ) {
res . writeHead ( 400 ) ; res . end ( JSON . stringify ( { error : e . message } ) ) ;
}
return ;
}
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
if ( hostHeader . endsWith ( "." + SUB _BASE ) ) {
const inner = hostHeader . slice ( 0 , - ( "." + SUB _BASE ) . length ) ; // "<flattened>.bch" or "<flattened>"
const flat = inner . endsWith ( ".bch" ) ? inner . slice ( 0 , - 4 ) : inner ;
if ( flat ) {
const name = flat . replace ( /-/g , "." ) + ".bch" ; // dashes back to dots
return serve ( res , name , req . url || "/" , null ) ; // own origin: no <base>
}
}
// bare apex of SUB_BASE (e.g. bch.navigate.st) -> home
if ( hostHeader === SUB _BASE ) { res . writeHead ( 200 , { "content-type" : "text/html" } ) ; return res . end ( home ( ) ) ; }
// --- path form: /bns/<name>.bch/... ---
let p = req . url ;
if ( p === MOUNT || p === MOUNT + "/" ) { res . writeHead ( 200 , { "content-type" : "text/html" } ) ; return res . end ( home ( ) ) ; }
if ( ! p . startsWith ( MOUNT + "/" ) ) { res . writeHead ( 404 ) ; return res . end ( "bns gateway" ) ; }
p = p . slice ( MOUNT . length + 1 ) ;
const m = p . match ( /^([a-z0-9-]+(?:\.[a-z0-9-]+)+)(\/.*)?$/i ) ; // <name>.<tld>, any TLD
if ( ! m ) { res . writeHead ( 400 , { "content-type" : "text/html" } ) ; return res . end ( ` usage: ${ MOUNT } /<name>.<tld>/ ` ) ; }
const name = m [ 1 ] . toLowerCase ( ) ;
const rest = m [ 2 ] || "/" ;
return serve ( res , name , rest , ` ${ MOUNT } / ${ name } / ` ) ;
} catch ( e ) { res . writeHead ( 500 ) ; res . end ( "gateway error: " + e . message ) ; }
} ) ;
server . listen ( PORT , "127.0.0.1" , ( ) => console . log ( ` bns public gateway on 127.0.0.1: ${ PORT } (path ${ MOUNT } , subdomain *. ${ SUB _BASE } ) ` ) ) ;
function home ( ) {
return ` <!doctype html><meta charset=utf-8><title>BNS Gateway</title>
< body style = "font:16px system-ui;max-width:640px;margin:3rem auto;padding:0 1rem;color:#e7eaf1;background:#0f1420" >
< h1 style = "color:#d6ff3d" > ⛓ BNS Gateway < / h 1 >
< p > View Bitcoin Cash < b > . bch < / b > n a m e s i n a n y b r o w s e r — n o i n s t a l l . < / p >
< ul >
< li > < a href = "/bns/hello.bch" > / b n s / h e l l o . b c h < / a > · o r < a h r e f = " h t t p s : / / h e l l o . $ { S U B _ B A S E } / " > h e l l o . $ { S U B _ B A S E } < / a > < / l i >
< li > < a href = "/bns/coinspectrum.deviant.bch" > / b n s / c o i n s p e c t r u m . d e v i a n t . b c h < / a > < / l i >
< / u l >
< p style = "color:#8b98a9;font-size:13px" > This gateway resolves names for you , so it can see which . bch
sites you request ( it keeps no logs — you ' re trusting that ) . For trustless resolution install the local
resolver / extension ; for IP privacy use Tor . By Silent Mode ( a Deviant project ) . < / p > < / b o d y > ` ;
}