feat(gateway): a name's owner decides what its subdomains serve

Until now a subdomain served the folder its labels spelled out, and that was
the only thing it could ever do. There was no way to point one somewhere
else, send one to another site, or stop one answering — the mapping was a
convention with no override.

A verified hosts entry now decides: a different folder, a redirect, or
nothing at all. Block beats redirect beats path, so a host that is both
blocked and redirected stays blocked. The manifest is fetched only when the
request is actually for a subdomain, and the result — including the absence
of one — is cached for a minute, so the apex pays nothing and a name that
never published one pays one lookup per minute rather than one per request.
A manifest that fails to verify is treated exactly like no manifest: the
name keeps serving from its chain records instead of failing shut.

The folder derivation moves next to the picking rule and grows tests,
because getting it wrong does not raise an error — it quietly serves the
wrong bytes. The subtle parts are that labels reverse (DNS reads outward, a
path reads inward) and that a .bch name indexes as <label>.bch even though
normalizeName collapses it to a bare label; matching the bare label would
have served the bucket root for every subdomain of every .bch name.

/api/dns answers for a host too. A host with its own dns block owns its
record set outright, the way classic DNS works; one without inherits the
apex, the way everything else on BNS does.
This commit is contained in:
Silent Mode 2026-09-28 01:04:36 +02:00
parent 98232def96
commit 9c8b2bfafe
3 changed files with 134 additions and 9 deletions

View file

@ -38,7 +38,7 @@ const TLD_BEACON_SCRIPTHASH = NETWORK.tldBeaconScripthash;
const FULL_ELECTRUM = NETWORK.electrum.filter((s) => !NETWORK.beaconOnly.test(s.url));
// Sia bucket holding operator-provided hosting: <bucket>/<name>/…
const HOSTING_BUCKET = NETWORK.hostingBucket;
import { pickRecord, isSubdomainOf } from "./record-picker.js";
import { pickRecord, isSubdomainOf, subLabelsOf, hostOverride, hostAction, subFolderFor } from "./record-picker.js";
import { verifyManifest } from "./manifest.js"; // flat at runtime: /opt/bns-gateway/ has no lib/
import { buildTldSnapshot } from "./tld-index.js";
import { verifyListing } from "./market-tx.js";
@ -321,10 +321,36 @@ async function resolve(host) {
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("/");
};
// The owner's signed manifest, verified, or null. Cached for a minute —
// including the null, so a name that has never published one does not pay a
// Sia round trip on every request. Keyed by the REGISTERED name: one manifest
// governs a name and all its subdomains.
const _manifestCache = new Map();
async function manifestFor(entry) {
const key = entry?.name;
if (!key) return null;
const c = _manifestCache.get(key);
if (c && Date.now() - c.at < 60000) return c.manifest;
let manifest = null;
const s3Base = entry.records?.s3;
if (s3Base && entry.owner) {
const mk = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json";
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${mk}`);
if (up.ok) {
const j = JSON.parse(await up.text());
const v = verifyManifest(j, {
name: key, owner: entry.owner, seenSeq: _dnsSeqCache.get(key), fallbackPrefix: NETWORK.prefix,
});
// A manifest that does not verify is treated exactly like no manifest:
// the name keeps serving from its chain records rather than failing.
if (v.ok) { _dnsSeqCache.set(key, v.seq); manifest = j; }
}
} catch { /* unreadable manifest — fall through to chain records */ }
}
_manifestCache.set(key, { manifest, at: Date.now() });
return manifest;
}
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",
@ -510,9 +536,25 @@ async function serve(res, name, reqPath, baseHref) {
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;
// A subdomain may be governed by the owner's signed manifest: pointed at a
// different folder, redirected, or switched off. Only fetched when the
// request IS a subdomain, so the apex never pays for the lookup.
const isSub = isSubdomainOf(name, entry.name);
const manifest = isSub ? await manifestFor(entry) : null;
if (isSub) {
const act = hostAction(hostOverride(manifest, name, entry.name));
if (act?.kind === "block") {
res.writeHead(404, { "content-type": "text/html" });
return res.end(nx(name));
}
if (act?.kind === "redirect") {
res.writeHead(302, { location: act.url });
return res.end();
}
}
// `pickRecord` is the single source of truth for record priority; keep this
// call and the inline copy in TheseusNavigator/main.js:serveBns in step.
const pick = pickRecord(r, { isSubdomain: isSubdomainOf(name, entry.name) });
const pick = pickRecord(r, { isSubdomain: isSub });
try {
switch (pick) {
case "h":
@ -520,7 +562,7 @@ async function serve(res, name, reqPath, baseHref) {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end(transformHtml(r.h, baseHref));
case "s3": {
const sub = subFolder(name);
const sub = subFolderFor(name, entry.name, manifest);
let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/";
// Auto-index directory paths. S3 has no dir listing, so `/tools/`
// would 404 (NoSuchKey) without this — matches how nginx handles
@ -1713,10 +1755,14 @@ const server = http.createServer(async (req, res) => {
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET only" })); return; }
const raw = decodeURIComponent(req.url.slice("/api/dns/".length).split("?")[0]);
const raw = decodeURIComponent(req.url.slice("/api/dns/".length).split("?")[0]).toLowerCase();
let name;
try { name = normalizeName(raw); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
// The query may name a subdomain. normalizeName collapses it to the
// registered name for the chain lookup; these are the labels it dropped,
// which is what selects the manifest's `hosts` entry.
const askedHost = subLabelsOf(raw, name);
const entry = (await getIndex()).get(name);
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const s3Base = entry.records?.s3;
@ -1735,15 +1781,22 @@ const server = http.createServer(async (req, res) => {
name, owner: entry.owner, seenSeq: _dnsSeqCache.get(name), fallbackPrefix: NETWORK.prefix,
});
if (!v.ok) { res.writeHead(502); res.end(JSON.stringify({ error: v.error, ...(v.detail || {}) })); return; }
const hostEntry = askedHost ? hostOverride(manifest, raw, name) : null;
const hostDns = hostEntry && typeof hostEntry.dns === "object" ? hostEntry.dns : null;
// Verified. Cache the seq and hand back the DNS block.
_dnsSeqCache.set(name, manifest.seq);
res.setHeader("Cache-Control", "public, max-age=30");
res.writeHead(200);
res.end(JSON.stringify({
name,
host: askedHost || null,
seq: manifest.seq,
updated_at: manifest.updated_at ?? null,
dns: manifest.dns ?? {},
// A host that declares its own dns block owns its record set outright
// — `www` does not inherit the apex's MX, the way classic DNS works.
// A host that declares none inherits the apex, which is how every
// other kind of record behaves on BNS.
dns: hostDns ?? manifest.dns ?? {},
verified_owner: entry.owner,
}, null, 2));
} catch (e) {

View file

@ -115,3 +115,20 @@ export function hostAction(entry) {
if (typeof entry.path === "string") return { kind: "path", path: entry.path };
return null;
}
/**
* Which folder inside the name's bucket serves this host.
*
* By default the labels above the registered name, REVERSED — DNS reads most
* specific first, a path reads least specific first, so `chipnet.explorer.x`
* lives at `explorer/chipnet`. A `hosts` entry replaces that with whatever the
* owner chose, including `""` for the bucket root (how `www` mirrors the apex).
*
* Returns "" for the apex and for a root override. Never leading/trailing "/".
*/
export function subFolderFor(requestedHost, entryName, manifest) {
const act = hostAction(hostOverride(manifest, requestedHost, entryName));
if (act && act.kind === "path") return act.path.replace(/^\/+|\/+$/g, "");
const sub = subLabelsOf(requestedHost, entryName);
return sub ? sub.split(".").reverse().join("/") : "";
}

View file

@ -183,3 +183,58 @@ test("canonicalize: key order and dropped nulls are stable, so signing is reprod
assert.equal(JSON.stringify(a), JSON.stringify(b));
assert.equal(JSON.stringify(a), '{"a":{"c":[1,2]},"b":1}');
});
// ---------- subFolderFor ----------
// The derivation that decides which bytes a visitor gets. A mistake here does
// not error — it silently serves the wrong folder, or the whole site under a
// subdomain that was meant to show one page.
import { subFolderFor } from "../src/lib/record-picker.js";
test("subFolderFor: the apex is the bucket root", () => {
assert.equal(subFolderFor("aegis.x", "aegis.x", null), "");
});
test("subFolderFor: one label is one folder", () => {
assert.equal(subFolderFor("brand.aegis.x", "aegis.x", null), "brand");
});
test("subFolderFor: labels reverse — DNS reads outward, a path reads inward", () => {
// chipnet.explorer.aegis.x and aegis.x/explorer/chipnet are the same page.
assert.equal(subFolderFor("chipnet.explorer.aegis.x", "aegis.x", null), "explorer/chipnet");
assert.equal(subFolderFor("d.c.b.a.aegis.x", "aegis.x", null), "a/b/c/d");
});
test("subFolderFor: an override replaces the derived folder", () => {
const m = { hosts: { shop: { path: "store/v2/" } } };
assert.equal(subFolderFor("shop.aegis.x", "aegis.x", m), "store/v2");
});
test("subFolderFor: an empty override points a subdomain at the apex folder", () => {
const m = { hosts: { www: { path: "" } } };
assert.equal(subFolderFor("www.aegis.x", "aegis.x", m), "");
});
test("subFolderFor: slashes are normalised so the caller can always append one", () => {
assert.equal(subFolderFor("a.aegis.x", "aegis.x", { hosts: { a: { path: "/x/y/" } } }), "x/y");
});
test("subFolderFor: a redirect or block never yields a folder", () => {
assert.equal(subFolderFor("a.aegis.x", "aegis.x", { hosts: { a: { u: "https://x.test/" } } }), "a");
assert.equal(subFolderFor("a.aegis.x", "aegis.x", { hosts: { a: { block: true } } }), "a");
});
test("subFolderFor: an unverified/absent manifest falls back to the convention", () => {
assert.equal(subFolderFor("brand.aegis.x", "aegis.x", undefined), "brand");
assert.equal(subFolderFor("brand.aegis.x", "aegis.x", { hosts: {} }), "brand");
});
test("subFolderFor: .bch names index as <label>.bch, so the suffix match still works", () => {
// normalizeName() collapses "silentmode.bch" to the bare label "silentmode",
// but the INDEX key — and so entry.name — is always "<label>.<tld>". Matching
// against the bare label would silently serve the bucket root for every
// subdomain of every .bch name. Verified live: theseus.silentmode.bch and
// silentmode.bch/theseus/ return the same bytes.
assert.equal(subFolderFor("theseus.silentmode.bch", "silentmode.bch", null), "theseus");
assert.equal(subFolderFor("silentmode.bch", "silentmode.bch", null), "");
});