diff --git a/lib/resolvers/handshake.cjs b/lib/resolvers/handshake.cjs new file mode 100644 index 00000000..be39bce3 --- /dev/null +++ b/lib/resolvers/handshake.cjs @@ -0,0 +1,58 @@ +"use strict"; +// Handshake resolver — routes non-ICANN TLDs through a public HNS web +// gateway (hns.to by default). hns.to receives `foo..hns.to`, looks up +// the Handshake TLD, fetches the on-chain site, and serves it back. From +// Theseus's side the adapter is a one-liner URL rewrite + a collision check +// to decide whether to touch the hostname at all. +// +// The collision check matters: Handshake auctioned on-chain TLDs that +// collide with real DNS names (e.g. ".app", ".dev"). We don't want to +// hijack those by default. With collision_policy = "icann-wins" (default), +// the adapter returns null for any TLD in a bundled "common ICANN" list so +// clearnet takes over. With "hns-wins", we always try HNS first. +// +// The list isn't the full IANA root zone (~1600 entries). It's the few +// hundred TLDs real users actually see: original gTLDs, popular new gTLDs, +// every ccTLD. If a TLD escapes the list and happens to collide with an +// HNS registration, the user can switch to "icann-wins" and whitelist via +// per-host overrides (future). Nothing breaks silently — the collision is +// opt-in by the user turning HNS on. + +const CACHE_TTL_MS = 10 * 60 * 1000; +const CACHE = new Map(); + +// Common ICANN TLDs — the ones a browser would actually see in practice. +// Not exhaustive; grown as needed. +const ICANN_TLDS = new Set([ + // original gTLDs + "com","org","net","edu","gov","mil","int","arpa","info","biz","name","pro","museum","aero","coop","jobs","mobi","tel","travel","cat","asia","post","tv","me","cc","ws","bz","su","ac", + // common new gTLDs + "app","dev","io","co","xyz","online","site","shop","store","tech","cloud","blog","news","world","world","global","global","email","agency","media","design","photo","art","gallery","pub","bar","cafe","wine","rest","restaurant","hotel","travel","fitness","gym","run","bike","boats","car","cars","fish","flight","flights","garden","farm","pet","pizza","realtor","salon","school","soccer","software","solar","taxi","vet","yoga","academy","band","bank","beer","beauty","best","buzz","build","builders","cash","chat","church","city","claims","clinic","clothing","club","coach","codes","coffee","community","company","computer","construction","contact","contractors","cool","country","credit","dance","deals","delivery","dental","dentist","digital","direct","directory","discount","doctor","download","education","energy","engineer","engineering","enterprises","equipment","events","expert","exposed","express","family","fashion","finance","financial","fit","fitness","food","foundation","fund","furniture","games","gift","glass","gold","graphics","group","guide","guru","haus","healthcare","help","holdings","holiday","home","host","house","icu","immo","industries","ink","insurance","international","investments","jewelry","kitchen","land","lawyer","legal","life","lighting","limited","limo","link","live","loan","loans","lol","love","ltd","management","market","marketing","marketplace","mba","media","medical","money","mortgage","movie","music","network","ninja","parts","party","photo","photography","photos","pics","pictures","plumbing","plus","press","productions","properties","pub","quest","radio","recipes","reviews","rip","run","sale","salon","school","science","security","services","shoes","shop","show","singles","soccer","software","solar","solutions","space","sport","store","studio","style","supplies","supply","support","surgery","systems","taxi","team","tech","technology","theatre","tickets","today","tools","tours","town","trade","training","transfer","travel","tv","university","vacations","ventures","vet","video","villas","vision","vote","voyage","watch","website","wedding","wiki","win","work","works","world","xxx","zone","gay","ltda","health","hiphop","fail","flowers","forsale","free","garden","gives","giving","golf","loan","map","mom","organic","place","play","promo","review","rodeo","show","shows","sky","soccer","social","spa","style","sucks","survey","tech","tires","top","vote","wtf","vip","hey","kiwi","earth", + // common ccTLDs (two-letter) + "ac","ad","ae","af","ag","ai","al","am","ao","aq","ar","as","at","au","aw","ax","az","ba","bb","bd","be","bf","bg","bh","bi","bj","bm","bn","bo","br","bs","bt","bv","bw","by","bz","ca","cc","cd","cf","cg","ch","ci","ck","cl","cm","cn","co","cr","cu","cv","cw","cx","cy","cz","de","dj","dk","dm","do","dz","ec","ee","eg","er","es","et","eu","fi","fj","fk","fm","fo","fr","ga","gb","gd","ge","gf","gg","gh","gi","gl","gm","gn","gp","gq","gr","gs","gt","gu","gw","gy","hk","hm","hn","hr","ht","hu","id","ie","il","im","in","io","iq","ir","is","it","je","jm","jo","jp","ke","kg","kh","ki","km","kn","kp","kr","kw","ky","kz","la","lb","lc","li","lk","lr","ls","lt","lu","lv","ly","ma","mc","md","me","mg","mh","mk","ml","mm","mn","mo","mp","mq","mr","ms","mt","mu","mv","mw","mx","my","mz","na","nc","ne","nf","ng","ni","nl","no","np","nr","nu","nz","om","pa","pe","pf","pg","ph","pk","pl","pm","pn","pr","ps","pt","pw","py","qa","re","ro","rs","ru","rw","sa","sb","sc","sd","se","sg","sh","si","sj","sk","sl","sm","sn","so","sr","ss","st","sv","sx","sy","sz","tc","td","tf","tg","th","tj","tk","tl","tm","tn","to","tr","tt","tv","tw","tz","ua","ug","uk","us","uy","uz","va","vc","ve","vg","vi","vn","vu","wf","ws","ye","yt","za","zm","zw", +]); + +async function resolve(host, { endpoint, collision_policy } = {}) { + if (!host || !host.includes(".")) return null; + const name = String(host).toLowerCase(); + // Collision gate — only skip ICANN TLDs when icann-wins is set (the default). + const tld = name.split(".").pop(); + if ((collision_policy || "icann-wins") === "icann-wins" && ICANN_TLDS.has(tld)) return null; + // BNS TLDs are already handled by the normal BNS path upstream; HNS adapter + // runs for every other dotted host. .eth / .bit go through their own adapters + // first, so by the time we get here they've been eliminated. + if (tld === "eth" || tld === "bit") return null; + + const cached = CACHE.get(name); + if (cached && cached.exp > Date.now()) return cached.result; + + const gw = (endpoint || "https://hns.to").replace(/\/+$/, "").replace(/^https?:\/\//, ""); + const url = `https://${name}.${gw}`; + const result = { url, protocol: "handshake", gateway: gw }; + CACHE.set(name, { result, exp: Date.now() + CACHE_TTL_MS }); + return result; +} + +function clearCache() { CACHE.clear(); } + +module.exports = { resolve, clearCache, ICANN_TLDS }; diff --git a/lib/resolvers/namecoin.cjs b/lib/resolvers/namecoin.cjs new file mode 100644 index 00000000..6f967df3 --- /dev/null +++ b/lib/resolvers/namecoin.cjs @@ -0,0 +1,99 @@ +"use strict"; +// Namecoin resolver — .bit names via a configured Namecoin JSON-RPC endpoint. +// Reads the Namecoin domain record format (dot-bit spec) and follows an +// `alias` or `translate` field to a clearnet host when present. Pure IP +// records aren't followed yet — rendering those cleanly requires in-browser +// DNS override + TLS pinning, which lives in a later phase. +// +// Namecoin has no public free RPC (unlike publicnode for Ethereum), so the +// user must configure their own RPC URL in Settings › Names & Certificates +// › Namecoin › Advanced. Common sources: a self-run namecoind, Zeronet's +// name-lookup gateway, or a shared RPC from the Namecoin community. +// +// Namecoin record format (JSON, from name_show "d/"): +// {"ip":"192.0.2.1", "ip6":"...", "translate":"example.com", +// "alias":"other.bit", "map":{"www":{"ip":"..."}}, ...} +// Spec: https://wiki.namecoin.org/index.php?title=Domain_Name_Specification_2.0 + +const POS_TTL_MS = 10 * 60 * 1000; +const NEG_TTL_MS = 60 * 1000; +const RPC_TIMEOUT_MS = 5000; +const CACHE = new Map(); + +async function rpcCall(rpc, method, params) { + const body = { jsonrpc: "1.0", id: "theseus", method, params }; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(new Error("Namecoin RPC timeout")), RPC_TIMEOUT_MS); + try { + const res = await fetch(rpc, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + if (!res.ok && res.status !== 500) throw new Error(`Namecoin RPC HTTP ${res.status}`); + const json = await res.json(); + if (json.error) throw new Error(`Namecoin RPC error: ${json.error.message || JSON.stringify(json.error)}`); + return json.result; + } finally { clearTimeout(t); } +} + +// Follow one hop of a dot-bit `alias` chain. `alias` points to another +// Namecoin name (like `example.bit`); `translate` points to a clearnet host. +// We walk at most 5 aliases to avoid loops. +async function readRecord(rpc, name, hops = 0) { + if (hops > 5) throw new Error("alias loop"); + const key = `d/${name.replace(/\.bit$/i, "")}`; + const row = await rpcCall(rpc, "name_show", [key]); + if (!row || !row.value) throw new Error("name not registered"); + let rec; try { rec = JSON.parse(row.value); } catch { throw new Error("record is not JSON"); } + if (rec.alias && typeof rec.alias === "string") { + // alias is another .bit name + const next = rec.alias.replace(/\.bit$/i, ""); + return readRecord(rpc, next, hops + 1); + } + return rec; +} + +async function resolve(host, { rpc } = {}) { + if (!host) return null; + const name = String(host).toLowerCase(); + if (!name.endsWith(".bit")) return null; + const cached = CACHE.get(name); + if (cached && cached.exp > Date.now()) return cached.result; + + if (!rpc) { + const result = { error: "no RPC URL configured (Settings › Names & Certificates › Namecoin › Advanced)" }; + CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS }); + return result; + } + + try { + const rec = await readRecord(rpc, name); + // Prefer translate (clearnet redirect) → navigate there. + if (rec.translate && typeof rec.translate === "string") { + const dest = rec.translate.replace(/^https?:\/\//, ""); + const url = `https://${dest}`; + const result = { url, protocol: "namecoin", kind: "translate", target: dest }; + CACHE.set(name, { result, exp: Date.now() + POS_TTL_MS }); + return result; + } + // Pure IP records: not followed (needs host-rules + cert handling). + if (rec.ip || rec.ip6) { + const result = { error: "pure IP records not supported yet; add a translate= field or wait for IP-record support" }; + CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS }); + return result; + } + const result = { error: "no translate/alias/ip record" }; + CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS }); + return result; + } catch (err) { + const result = { error: String(err?.message || err) }; + CACHE.set(name, { result, exp: Date.now() + NEG_TTL_MS }); + return result; + } +} + +function clearCache() { CACHE.clear(); } + +module.exports = { resolve, clearCache }; diff --git a/main.js b/main.js index f9d2a12d..caa10cfd 100644 --- a/main.js +++ b/main.js @@ -888,7 +888,7 @@ const DEFAULT_EXTRA_RESOLVERS = { resolvers: { ens: { enabled: false, rpc: "https://ethereum-rpc.publicnode.com", gateway: "https://ipfs.io" }, namecoin: { enabled: false, rpc: "" }, - handshake: { enabled: false, endpoint: "https://hnsrecursive.easyhandshake.com", collision_policy: "icann-wins" }, + handshake: { enabled: false, endpoint: "https://hns.to", collision_policy: "icann-wins" }, sns: { enabled: false, rpc: "https://api.mainnet-beta.solana.com" }, unstoppable: { enabled: false, endpoint: "https://resolve.unstoppabledomains.com" }, }, @@ -5784,6 +5784,19 @@ async function tryExtraResolvers(t, id, host, rest) { const resolvers = cfg?.resolvers || {}; const h = String(host || "").toLowerCase(); + // Shared helper: load the gateway URL + rest into the tab, update prov, + // and return true so the caller knows to stop looking at later adapters. + async function navigateToExtra(result, kind, extraProv) { + const path = rest && rest !== "/" ? rest : "/"; + const target = result.url + (path === "/" ? "" : path); + t.url = target; + t.prov = { host: h, kind, ...(extraProv || {}) }; + if (isActiveId(id)) pushNav(t.prov); + emitTabs(); + await t.view.webContents.loadURL(target).catch(() => {}); + return true; + } + // ENS — .eth → ENS registry → content hash → IPFS/IPNS gateway. if (h.endsWith(".eth") && resolvers.ens?.enabled) { try { @@ -5792,21 +5805,32 @@ async function tryExtraResolvers(t, id, host, rest) { rpc: resolvers.ens.rpc, gateway: resolvers.ens.gateway, }); - if (result && result.url) { - const path = rest && rest !== "/" ? rest : "/"; - // Path-based IPFS gateways accept /ipfs//. Keep the query - // string intact if the caller carried one through `rest`. - const target = result.url + (path === "/" ? "" : path); - t.url = target; - t.prov = { host: h, kind: "ens", protocol: result.protocol, cid: result.cid, uri: result.uri }; - if (isActiveId(id)) pushNav(t.prov); - emitTabs(); - await t.view.webContents.loadURL(target).catch(() => {}); - return true; - } + if (result && result.url) return navigateToExtra(result, "ens", { protocol: result.protocol, cid: result.cid, uri: result.uri }); } catch (e) { console.warn("[ens] resolve failed:", e?.message); } } + // Namecoin — .bit → name_show "d/" on the configured RPC → follow + // translate/alias to a clearnet host. + if (h.endsWith(".bit") && resolvers.namecoin?.enabled) { + try { + const nmc = require("./lib/resolvers/namecoin.cjs"); + const result = await nmc.resolve(h, { rpc: resolvers.namecoin.rpc }); + if (result && result.url) return navigateToExtra(result, "namecoin", { target: result.target }); + } catch (e) { console.warn("[namecoin] resolve failed:", e?.message); } + } + + // Handshake — route non-ICANN TLDs through hns.to (or configured gateway). + if (resolvers.handshake?.enabled) { + try { + const hns = require("./lib/resolvers/handshake.cjs"); + const result = await hns.resolve(h, { + endpoint: resolvers.handshake.endpoint, + collision_policy: resolvers.handshake.collision_policy, + }); + if (result && result.url) return navigateToExtra(result, "handshake", { gateway: result.gateway }); + } catch (e) { console.warn("[handshake] resolve failed:", e?.message); } + } + return false; } diff --git a/package.json b/package.json index 6c5b9ba8..1dcc02df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.97", + "version": "0.3.98", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", diff --git a/settings.html b/settings.html index 9f5454a8..8fc1c5db 100644 --- a/settings.html +++ b/settings.html @@ -493,7 +493,7 @@
Namecoin
-
.bit → DNS record from the Namecoin blockchain. Not wired yet — coming in 0.3.97.
+
.bit → record from the Namecoin blockchain; translate or alias redirects are followed. Needs an RPC URL — Namecoin has no public free endpoint; set yours in Advanced.
Advanced
@@ -504,9 +504,9 @@
Handshake (HNS)
-
User-registered TLDs on the Handshake chain. Handshake auctioned many 3-letter and ICANN-style TLDs on-chain, so collisions are common. Not wired yet — coming in 0.3.97.
+
User-registered TLDs on the Handshake chain, served through the public hns.to web gateway. The gateway proxies the request to Handshake's own resolver and serves the on-chain content. Handshake auctioned many 3-letter and ICANN-style TLDs on-chain, so the default collision policy keeps ICANN TLDs in clearnet.
Advanced - +