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: <ip>. 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.
This commit is contained in:
Local Dev 2026-08-16 11:37:24 +02:00
parent b4a550ef41
commit c2578d4017

View file

@ -77,6 +77,19 @@ const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>
// 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: <ip>. 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();