From c2578d401745c2f220013b4fffa6c406e8ac1db7 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 16 Aug 2026 11:37:24 +0200 Subject: [PATCH] gateway: fix ip-record fetches (Host header) so coinspectrum.bch resolves Symptom: pasting https://navigate.st/search?q=coinspectrum.bch (or the new default-search-engine redirect from any browser) returned 404 from nginx. Same for any BCNR name whose on-chain record uses ip (h / s3 / p were fine). Cause: the gateway's ip branch used fetch() with { headers: { host: name } }. WHATWG fetch defines Host as a forbidden header, so undici silently drops it and sends Host: . nginx has no server_name for a bare IP, falls to the default server, 404s. The whole point of the ip record is to reach a server_name that matches the BCNR name, so Host must be set. Fix: use http.request from node:http (no forbidden-header list) with an explicit Host header. Same behavior for every ip record; nothing changes for h / s3 / p / u branches. Live-patched on the VPS gateway and restarted the bns-gateway systemd service. coinspectrum.bch now returns 200 (116 KB) via navigate.st/bns/coinspectrum.bch/ and via the /search?q= redirect chain. Argus/src/gateway/public-gateway.mjs mirrors the on-disk change on the VPS at /opt/bns-gateway/public-gateway.mjs so future redeploys keep it. --- src/gateway/public-gateway.mjs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 23817d9..8d8f59c 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -77,6 +77,19 @@ const nx = (h) => `

// 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. +function ipFetch(ip, reqPath, hostHeader) { + return new Promise((resolve, reject) => { + const req = http.request({ host: ip, port: 80, path: reqPath, method: "GET", headers: { Host: hostHeader } }, (r) => { + const chunks = []; + r.on("data", (c) => chunks.push(c)); + r.on("end", () => resolve({ status: r.statusCode, headers: r.headers, body: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.setTimeout(15000, () => req.destroy(new Error("upstream timeout"))); + req.end(); + }); +} + 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); } @@ -102,11 +115,15 @@ async function serve(res, name, reqPath, 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); + // http.request instead of fetch(): WHATWG fetch treats Host as a + // forbidden header, so undici silently drops { headers: { host: name } } + // and sends Host: . nginx then falls to default_server and 404s. + // The whole point of an ip record is to reach a server_name matching the + // BCNR name, so Host must be right. + const up = await ipFetch(r.ip, reqPath, name); + let ct = up.headers["content-type"] || guess(reqPath); + if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(up.body.toString("utf8"), baseHref)); } + res.writeHead(up.status, { "content-type": ct }); return res.end(up.body); } case "u": res.writeHead(302, { location: r.u }); return res.end();