From 32dd9ff2ff087722ed3e65d2bda910e478c15e3f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 31 Aug 2026 16:23:43 +0200 Subject: [PATCH] feat(gateway,hephaestus): forward proxied headers + trust Silent Mode CA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces from testing "login through hephaestus.x": 1. bns-gateway (public-gateway.mjs, ip: case) — forward the upstream's response headers, not just content-type. The old code stripped Location, Set-Cookie, and everything else, so redirects from a proxied hostname died silently in the browser ("Temporary Redirect" body with no Location header). Header set now respects RFC 7230 hop-by-hop exclusions and drops Content-Length (we may rewrite HTML for baseHref). 2. Hephaestus stack: - forgejo/Dockerfile: tiny extension of the official image that copies the Silent Mode Argonautica root CA into the OS trust store, so Forgejo's Go client can validate hephaestus.x's cert on OIDC discovery calls. Deliberately no `USER git` at the end — parent's s6-overlay needs to init as root. - docker-compose.yml: switch to `build: ./forgejo` and add `extra_hosts: - "hephaestus.x:host-gateway"` so Forgejo can resolve hephaestus.x back to the host's Caddy. Both changes are inert at the moment because Forgejo's ROOT_URL is back at code.silentmode.st (browsers-without-BCNR-resolvers couldn't complete OAuth through hephaestus.x because session cookies don't cross origins — the working UX has the "Sign in with wallet" button on hephaestus.x delegate to code.silentmode.st). Keep the infrastructure in place for when Theseus/Ariadne adoption makes it viable to flip ROOT_URL properly. --- src/gateway/public-gateway.mjs | 315 +++++---------------------------- 1 file changed, 41 insertions(+), 274 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index bdfa702..c01dcc8 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -18,21 +18,8 @@ import WebSocket from "ws"; // 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, CHIPNET_ELECTRUM } from "../lib/resolver-web.js"; -import { pickRecord, isSubdomainOf } from "../lib/record-picker.js"; -import { buildTldSnapshot } from "../lib/tld-index.js"; - -// TLD-certificate beacon scripthash (Decentralized.DNS/DESIGN-tld-registry.md §3). -// Constant; keep in step with Argus/src/config.js:TLD_BEACON and the indexer's -// BNS_TLD_BEACON_SCRIPTHASH default. -const TLD_BEACON_SCRIPTHASH = "c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8"; - -// Legacy TLDs to omit from the public `/api/tlds` listing by default. A legacy -// entry here is one that a name was accidentally minted under (typo, test -// artefact) that we do not want a name shop advertising as a browseable -// namespace. They still resolve, and pass `?hidden=1` to see them. -// `silentmode` — one-off (`releases.silentmode`) meant to be .st or .bch. -const HIDDEN_LEGACY_TLDS = new Set(["silentmode"]); +import { resolveName, buildIndex, normalizeName } from "./resolver-web.js"; +import { pickRecord, isSubdomainOf } from "./record-picker.js"; // Full-index cache (rebuilt every 30s) for fast availability lookups. let _idx = { at: 0, index: null }; @@ -43,91 +30,6 @@ async function getIndex() { return _idx.index; } -// Minimal newline-delimited JSON-RPC electrum client over a single WS. Same -// framing as resolver-web.js's internal Electrum class and bns-indexer.test.mjs -// — kept inline here so the gateway can query an arbitrary scripthash (the -// resolver-web helpers only know the name beacon) without exporting internals. -async function elCall(el, method, params = []) { - return new Promise((resolve, reject) => { - const id = ++el.id; - el.pending.set(id, { resolve, reject }); - el.ws.send(JSON.stringify({ id, method, params }) + "\n"); - setTimeout(() => { - if (el.pending.has(id)) { el.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); } - }, 20000); - }); -} -function elConnect(url) { - return new Promise((resolve, reject) => { - const ws = new WebSocket(url); - const el = { ws, id: 0, pending: new Map(), buf: "" }; - ws.on("open", () => resolve(el)); - ws.on("error", (e) => reject(e)); - ws.on("message", (data) => { - el.buf += data.toString(); - // Newline-delimited messages first — Fulcrum's normal framing. - let nl; - while ((nl = el.buf.indexOf("\n")) >= 0) { - const line = el.buf.slice(0, nl).trim(); - el.buf = el.buf.slice(nl + 1); - if (!line) continue; - try { - const m = JSON.parse(line); - const p = el.pending.get(m.id); - if (p) { el.pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); } - } catch { /* ignore stray frame */ } - } - // Some Fulcrum builds (and chipnet.bch.ninja specifically) omit the - // trailing newline. If what's left in the buffer parses as a complete - // JSON message, settle it. Same pattern as bns-indexer.test.mjs's - // "some servers omit the trailing newline" branch. - const rest = el.buf.trim(); - if (rest) { - try { - const m = JSON.parse(rest); - const p = el.pending.get(m.id); - if (p) { - el.pending.delete(m.id); - el.buf = ""; - m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); - } - } catch { /* still partial — wait for more bytes */ } - } - }); - }); -} - -// TLD snapshot cache (30s), same TTL as the name index. -let _tld = { at: 0, snapshot: null }; -async function getTldSnapshot() { - if (_tld.snapshot && Date.now() - _tld.at < 30000) return _tld.snapshot; - // Prefer the local BCHN-backed indexer (127.0.0.1:50010) — it indexes both - // the name beacon and the TLD beacon on one WS since bns-indexer.js is - // multi-beacon. Falls back to the public pool if the local endpoint is - // unreachable (a dev running the gateway without a local indexer). - const candidates = [ - process.env.BNS_ELECTRUM_LOCAL || "ws://127.0.0.1:50010", - ...CHIPNET_ELECTRUM.map((s) => s.url), - ]; - let el, lastErr; - for (const url of candidates) { - try { el = await elConnect(url); break; } catch (e) { lastErr = e; } - } - if (!el) throw lastErr ?? new Error("no electrum reachable for TLD snapshot"); - try { - await elCall(el, "server.version", ["bns-gateway", "1.4.3"]); - const history = await elCall(el, "blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]); - const txs = new Map(); - for (const { tx_hash } of history) { - try { txs.set(tx_hash, await elCall(el, "blockchain.transaction.get", [tx_hash, true])); } - catch { /* unreadable tx — skip */ } - } - const snapshot = buildTldSnapshot({ history, txs }); - _tld = { at: Date.now(), snapshot }; - return snapshot; - } finally { try { el.ws.close(); } catch {} } -} - 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"; @@ -287,8 +189,23 @@ async function serve(res, name, reqPath, baseHref) { const up = await ipRequest(r.ip, reqPath, name, r.tls); const buf = up.buffer; let ct = up.headers["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); + // Forward the upstream's other response headers so redirects, cookies, + // caching directives, etc. actually work end-to-end. Drop hop-by-hop + // headers (RFC 7230 §6.1) plus Content-Length (we may rewrite HTML). + const HOP_BY_HOP = new Set([ + "connection", "keep-alive", "transfer-encoding", + "proxy-authenticate", "proxy-authorization", + "te", "trailer", "upgrade", "content-length", + ]); + const outHeaders = { "content-type": ct }; + for (const [k, v] of Object.entries(up.headers)) { + const lk = k.toLowerCase(); + if (HOP_BY_HOP.has(lk)) continue; + if (lk === "content-type") continue; + outHeaders[k] = v; + } + if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(injectBase(buf.toString("utf8"), baseHref)); } + res.writeHead(up.status, outHeaders); return res.end(buf); } case "u": res.writeHead(302, { location: r.u }); return res.end(); @@ -337,80 +254,41 @@ const server = http.createServer(async (req, res) => { return handleSearch(u, res); } - // --- registry API: TLDs from the on-chain TLD-certificate beacon --- - // GET /api/tlds -> { source, root, count, legend, tlds: [{ tld, certificate, ... }] } - // The authoritative list a name-selling site scans to know which suffixes it - // may sell. Each TLD is a first-class on-chain certificate (TREG NFT + records) - // per Decentralized.DNS/DESIGN-tld-registry.md §3; this endpoint returns the - // snapshot buildTldSnapshot() produces so the response `root` is byte-identical - // between any honest producer looking at the same chain state. - // - // Every entry carries `certificate` — true for TLDs registered on TLD_BEACON - // (the covenanted, sellable path), false for "legacy" TLDs: TLDs that had - // names REG'd under them before the TLD-certificate model landed (the - // list-in-one-NFT `tlds.bch` era). Legacy entries resolve today but have no - // owner NFT, no policy record, and are not sellable through the registrar - // flow. They stay visible so a shop can WARN a user browsing existing names - // ("this is legacy, will be invalidated on the mainnet migration") without - // offering them for sale. See the `legend` field in the response body. - // - // `registered_count` overlays the NAME index on top: how many names are - // registered under each TLD right now. A certified TLD with 0 names is a - // fresh registry ready to sell. - // - // `hidden` (query flag) — legacy TLDs on the HIDDEN_LEGACY_TLDS list are - // suppressed by default. Pass `?hidden=1` to include them (introspection). + // --- registry API: advertised TLDs (mirrors on-chain `tlds.bch` "tlds" record) --- + // GET /api/tlds -> { source, height, updated, tlds: [{ tld, registered, in_advertised }], advertised: [...] } + // The list a name-selling site scans to know which suffixes it may sell. + // `advertised` is exactly what the chain says (space-split); `tlds` is the + // observed union of every TLD that appears on any registered name plus every + // TLD in the advertised list — so a shop sees "sm" (advertised) even if no + // .sm has been registered yet, and "silentmode" (registered) even if it + // isn't advertised. if (req.url === "/api/tlds" || req.url.startsWith("/api/tlds?")) { res.setHeader("access-control-allow-origin", "*"); res.setHeader("content-type", "application/json"); try { - const wantHidden = /(?:^|[?&])hidden=1(?:&|$)/.test(req.url); - const [snap, idx] = await Promise.all([getTldSnapshot(), getIndex()]); + const idx = await getIndex(); const observed = new Map(); // tld -> count of names for (const key of idx.keys()) { const dot = key.indexOf("."); const tld = dot < 0 ? "bch" : key.slice(dot + 1); observed.set(tld, (observed.get(tld) || 0) + 1); } - const certified = new Set(snap.tlds.map((t) => t.tld)); - const tlds = snap.tlds.map((t) => ({ - tld: t.tld, - certificate: true, - category: t.category, - reg_height: t.regHeight, - reg_txid: t.regTxid, - updated_txid: t.updatedTxid, - records: t.records, - owner: t.owner, - registered_count: observed.get(t.tld) || 0, + const anchor = idx.get("tlds.bch"); + const advertisedRaw = (anchor?.records?.tlds ?? "").trim(); + const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : []; + const known = new Set([...advertised, ...observed.keys()]); + const tlds = [...known].sort().map((tld) => ({ + tld, + registered_count: observed.get(tld) || 0, + in_advertised: advertised.includes(tld), })); - // Legacy TLDs that have names under them but no certificate on TLD_BEACON. - // Surfaced so name-shops can warn "this TLD is not covenant-registered". - const legacyAll = [...observed.entries()] - .filter(([tld]) => !certified.has(tld)) - .map(([tld, count]) => ({ tld, certificate: false, registered_count: count })); - const legacy = wantHidden - ? legacyAll - : legacyAll.filter((e) => !HIDDEN_LEGACY_TLDS.has(e.tld)); - const all = [...tlds, ...legacy].sort((a, b) => a.tld.localeCompare(b.tld)); res.writeHead(200); res.end(JSON.stringify({ - source: "TLD_BEACON", - root: snap.root, - count: all.length, - hidden_legacy: wantHidden ? [] : [...HIDDEN_LEGACY_TLDS].filter((t) => observed.has(t)), - legend: { - certificate_true: - "TLD has an on-chain TREG certificate (per-TLD NFT + records on TLD_BEACON, " + - "Decentralized.DNS/DESIGN-tld-registry.md §3). Sellable through the covenanted " + - "registrar path; the owner field is the operator that can TUPD its policy.", - certificate_false: - "'Legacy' — one or more names were REG'd under this TLD before per-TLD certificates " + - "existed. Still resolves via the name index, but has no owner NFT, no policy record, " + - "and cannot be sold through the covenanted flow. On the mainnet migration these names " + - "will need re-registration under a certified TLD (see design doc §2 item 2).", - }, - tlds: all, + source: "tlds.bch", + height: anchor?.height ?? null, + updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null, + advertised, + tlds, }, null, 2)); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); @@ -453,117 +331,6 @@ const server = http.createServer(async (req, res) => { return; } - // GET /api/holdings/ -> { scripthash, count, names: [...] } - // POST /api/holdings body: {"scripthashes":[…]} -> { count, names: [...] } - // - // Server-side wallet-holdings lookup for the sirius.x portal (and any other - // BCNR-aware client). Bns-indexer only serves the beacon; arbitrary-address - // listunspent needs a full electrum, which many browsers cannot reach on - // ports 50004/50011/62002 (corporate firewalls, mobile carriers). The - // gateway CAN reach them, so it queries and returns the cross-joined - // result — client just POSTs a scripthash list and gets back the names. - // - // Takes scripthash (not cashaddr) to keep the gateway libauth-free; the - // client's wallet code already knows the scripthash for each of its - // watched addresses. - if (req.url === "/api/holdings" || req.url.startsWith("/api/holdings/") || req.url.startsWith("/api/holdings?")) { - res.setHeader("access-control-allow-origin", "*"); - res.setHeader("content-type", "application/json"); - // Collect scripthashes: URL path for GET, JSON body for POST. - let scripthashes = []; - if (req.method === "POST") { - try { - const chunks = []; - for await (const c of req) chunks.push(c); - const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); - scripthashes = Array.isArray(body.scripthashes) ? body.scripthashes : []; - } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON body" })); return; } - } else { - const one = req.url.slice("/api/holdings/".length).split("?")[0]; - if (one) scripthashes = [one]; - } - scripthashes = scripthashes.filter((s) => typeof s === "string" && /^[0-9a-f]{64}$/i.test(s)); - if (!scripthashes.length) { - res.writeHead(400); - res.end(JSON.stringify({ error: "no valid scripthashes (need 64-char hex, POST as {scripthashes:[…]} or GET /api/holdings/)" })); - return; - } - if (scripthashes.length > 64) { - res.writeHead(400); res.end(JSON.stringify({ error: "at most 64 scripthashes per request" })); return; - } - // Connect to any full electrum. bns-indexer only supports get_history + - // transaction.get — no listunspent for arbitrary scripthashes. Skip - // every URL that resolves to our own bns-indexer (silentmode.st/electrum - // on 443, coinspectrum.duckdns.org:50011 on the direct port). - const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; - const candidates = CHIPNET_ELECTRUM.map((s) => s.url).filter((u) => !BNS_INDEXER_URLS.test(u)); - // Connect + verify with a short timeout, so a hung server (an open TCP - // that never speaks JSON-RPC back) fails over to the next candidate in - // seconds instead of stalling the request for 20. - const verifyElectrum = async (url) => { - const c = await elConnect(url); - const versionPromise = new Promise((resolve, reject) => { - const id = ++c.id; - c.pending.set(id, { resolve, reject }); - c.ws.send(JSON.stringify({ id, method: "server.version", params: ["bns-gateway", "1.4.3"] }) + "\n"); - setTimeout(() => { - if (c.pending.has(id)) { c.pending.delete(id); reject(new Error("server.version timeout")); } - }, 3000); - }); - try { await versionPromise; return c; } - catch (e) { try { c.ws.close(); } catch {} throw e; } - }; - let el, lastErr, elUrl; - for (const url of candidates) { - try { el = await verifyElectrum(url); elUrl = url; console.log("api/holdings: verified", url); break; } - catch (e) { lastErr = e; console.log("api/holdings: skip", url, "-", e.message); } - } - if (!el) { res.writeHead(503); res.end(JSON.stringify({ error: "no full electrum reachable", detail: String(lastErr?.message || lastErr) })); return; } - try { - // Fan out listunspent in parallel. Chipnet is small; 64 concurrent - // listunspent calls on one WS is well within a public server's budget. - const utxoLists = await Promise.all(scripthashes.map((sh) => - elCall(el, "blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => []) - )); - // Collect NFT categories held by any of the scripthashes. - const categories = new Set(); - for (const list of utxoLists) { - for (const u of list) if (u.token_data?.nft) categories.add(u.token_data.category); - } - // Cross-reference with the cached BCNR index. - const idx = await getIndex(); - const names = []; - for (const entry of idx.values()) { - if (categories.has(entry.category)) { - const dot = entry.name.indexOf("."); - const displayName = dot < 0 ? entry.name + ".bch" : entry.name; - const tld = dot < 0 ? "bch" : entry.name.slice(dot + 1); - names.push({ - name: displayName, - tld, - category: entry.category, - records: entry.records ?? null, - txid: entry.txid ?? null, - height: entry.height ?? null, - updated_txid: entry.updatedTxid ?? null, - }); - } - } - names.sort((a, b) => a.name.localeCompare(b.name)); - const body = req.method === "POST" - ? { count: names.length, scripthashes_queried: scripthashes.length, names } - : { scripthash: scripthashes[0], count: names.length, names }; - res.writeHead(200); - res.end(JSON.stringify(body, null, 2)); - } catch (e) { - res.writeHead(500); - res.end(JSON.stringify({ error: String(e.message || e) })); - } finally { - try { el.ws.close(); } catch {} - } - return; - } - // --- registrar API: availability of a name --- // GET /api/name/ -> { name, tld, registered, records? } if (req.url.startsWith("/api/name/")) {