158 lines
8.2 KiB
JavaScript
158 lines
8.2 KiB
JavaScript
|
|
// 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>`;
|
||
|
|
}
|