argus/src/gateway/public-gateway.mjs

158 lines
8.2 KiB
JavaScript
Raw Normal View History

// 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";
import { resolveName, buildIndex, normalizeName } from "./resolver-web.js";
// 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).
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;
try {
if (r.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));
}
if (r.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);
}
if (r.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);
}
if (r.u) { res.writeHead(302, { location: r.u }); return res.end(); }
res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(entry, null, 2));
} catch (e) { res.writeHead(502); res.end("gateway error: " + e.message); }
}
const server = http.createServer(async (req, res) => {
try {
const hostHeader = (req.headers.host || "").split(":")[0].toLowerCase();
// --- 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</h1>
<p>View Bitcoin Cash <b>.bch</b> names in any browser — no install.</p>
<ul>
<li><a href="/bns/hello.bch">/bns/hello.bch</a> · or <a href="https://hello.${SUB_BASE}/">hello.${SUB_BASE}</a></li>
<li><a href="/bns/coinspectrum.deviant.bch">/bns/coinspectrum.deviant.bch</a></li>
</ul>
<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></body>`;
}