diff --git a/README.md b/README.md index fc349d1e..d6acd9b7 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode export` | account | operator only: export users as CSV (this app, or --all properties), optionally cleaned | | `moshcode console` | account | serve or connect to the browser terminal | | `moshcode dns` | hosting | resolve Moshpit names on this machine | -| `moshcode name` | hosting | prove you hold a Moshpit name, so an app can use it as your identity | +| `moshcode name` | hosting | prove you hold a Moshpit name, or serve it from a c0mpute site | | `moshcode doh` | hosting | run the DNS-over-HTTPS resolver | | `moshcode site`
`serve` | hosting | install web-server config for a Moshpit name | | `moshcode template`
`templates` | hosting | scaffold a stack for a Moshpit-hosted service | diff --git a/apps/pwa/docs/moshpit-ca.md b/apps/pwa/docs/moshpit-ca.md index abea9231..a49f16a8 100644 --- a/apps/pwa/docs/moshpit-ca.md +++ b/apps/pwa/docs/moshpit-ca.md @@ -36,6 +36,18 @@ top-level domain are refused unconditionally, whatever the registry's tables say, so a registry bug cannot mint `google.com`. Twenty-four issuances per name per day, so a broken renewal loop is stopped rather than served. +One exception, opted into by the holder: a name pointed at a c0mpute site +(`moshcode name c0mpute `, or `PUT +/api/moshpit/tlds/:tld/names/c0mpute`) may also be issued to a c0mpute edge +account listed in `MOSHPIT_C0MPUTE_EDGE_USERS`. The edge +(`moshpit-proxy/bin/moshpit-c0mpute-edge.ts`) terminates TLS for the name and +fetches the site from c0mpute gateways. The permission lasts exactly as long as +the setting: clear the site, set a target, sell the name or let a lease lapse +and the edge is refused like anyone else. With `MOSHPIT_C0MPUTE_EDGE` set to +the edge's hostname, `/api/moshpit/resolve` answers that hostname as the +name's target (and the site id as `c0mpute`), so existing resolvers need no +change; with it unset nothing resolves differently. + Every leaf's key is also published as a `tls` pin, so a client that still checks pins keeps working through the transition. diff --git a/apps/pwa/src/lib/moshpit-c0mpute.mjs b/apps/pwa/src/lib/moshpit-c0mpute.mjs new file mode 100644 index 00000000..793e853f --- /dev/null +++ b/apps/pwa/src/lib/moshpit-c0mpute.mjs @@ -0,0 +1,72 @@ +// Pointing a Moshpit name at a site on the c0mpute network. +// +// Pure: no database, no request. The registry stores a c0mpute site id on a +// name; this decides what that means to a resolver and to the CA. +// +// Why the name resolves to an edge rather than to c0mpute gateways directly: +// a gateway serves plain HTTP on :7777 and holds no Moshpit certificate, so a +// browser asking for https://blog.eggs would get a refused connection or a +// certificate for some other name. The edge is the one thing on the path that +// terminates TLS, with a leaf the registry signs for the name, and it fetches +// /s// from whichever c0mpute gateways are healthy. Nothing on the +// c0mpute side changes and no c0mpute server is on the path: any gateway can +// serve any site, and the edge fails over between them. + +/** A c0mpute site id: 20 lowercase hex characters (blake3 of owner + name). */ +export const C0MPUTE_SITE_RE = /^[0-9a-f]{20}$/; + +/** + * A site id as stored, `""` for "clear it", or null when it is not one. + * + * Accepts the forms people paste: the bare id, `site=` (the TXT record + * c0mpute documents for custom domains) and a gateway URL ending in + * `/s//`, because all three are what `c0mpute hosting publish` prints. + */ +export function normalizeC0mputeSite(input) { + if (input === null || input === undefined) return ""; + const raw = String(input).trim().toLowerCase(); + if (!raw || raw === "off" || raw === "none") return ""; + const fromUrl = raw.match(/\/s\/([0-9a-f]{20})(?:\/|$)/); + const bare = fromUrl ? fromUrl[1] : raw.replace(/^site=/, ""); + return C0MPUTE_SITE_RE.test(bare) ? bare : null; +} + +const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; + +/** + * The edge hostname names with a c0mpute site resolve to, or null when none is + * configured (`MOSHPIT_C0MPUTE_EDGE`). + * + * A hostname, not an address, for the reason the registry refuses IPv4 + * targets: several edges behind one name is how this gets redundancy, and a + * hostname is the only thing a resolver can follow to all of them. + */ +export function c0mputeEdgeHost(env = process.env) { + const host = String(env.MOSHPIT_C0MPUTE_EDGE || "").trim().toLowerCase().replace(/\.+$/, ""); + return HOSTNAME.test(host) ? host : null; +} + +/** + * The accounts allowed to ask the CA for a certificate on a name that points at + * c0mpute (`MOSHPIT_C0MPUTE_EDGE_USERS`, comma- or space-separated user ids). + * + * Unset means none, and then only the holder can get a certificate, exactly as + * before this existed. + */ +export function c0mputeEdgeUsers(env = process.env) { + return new Set(String(env.MOSHPIT_C0MPUTE_EDGE_USERS || "").split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean)); +} + +/** + * What a resolver should be told a name points at. + * + * A c0mpute site wins over a stored target when an edge is configured: setting + * it is the more deliberate act (and setting a target clears it again). With no + * edge configured the site cannot be served, so the stored target stands and + * nothing changes for anyone. + */ +export function effectiveC0mputeTarget({ target = null, site = null } = {}, env = process.env) { + const edge = c0mputeEdgeHost(env); + if (site && C0MPUTE_SITE_RE.test(site) && edge) return edge; + return target ?? null; +} diff --git a/apps/pwa/src/lib/moshpit-certs.mjs b/apps/pwa/src/lib/moshpit-certs.mjs index 79879ed5..2e01f923 100644 --- a/apps/pwa/src/lib/moshpit-certs.mjs +++ b/apps/pwa/src/lib/moshpit-certs.mjs @@ -11,6 +11,7 @@ import { all, get, run } from "../db.mjs"; import { addPin, controlledName, logAction, normalizeLabel, normalizeTld } from "../moshpit.mjs"; import { issueLeaf } from "./moshpit-ca.mjs"; +import { c0mputeEdgeUsers } from "./moshpit-c0mpute.mjs"; const CERT_COLS = `serial, tld, label, pin, cert, user_id, not_before, not_after, issued_at`; const DAY = 24 * 60 * 60 * 1000; @@ -25,7 +26,18 @@ export const MAX_CERTS_PER_DAY = 24; * or { ok: false, error, status }. */ export async function issueNameCertificate({ tld: tldInput, label: labelInput, userId, csr, now = Date.now() }) { - const owned = await controlledName(tldInput, labelInput, userId, now); + let owned = await controlledName(tldInput, labelInput, userId, now); + // The one exception to "the holder asks": a c0mpute edge, for a name whose + // holder pointed it at a c0mpute site. Setting the site is the holder's + // consent to being served by the edge, and the edge cannot serve + // https:// without a leaf. It lapses with the setting: once the site + // is cleared (or the name changes hands, which clears it) the edge is refused + // like anyone else, and the leaf it holds runs out within LEAF_DAYS. + let pinAs = userId; + if (!owned.ok && c0mputeEdgeUsers().has(userId)) { + const edge = await c0mputeEdgeName(tldInput, labelInput, now); + if (edge) { owned = edge; pinAs = edge.controller; } + } if (!owned.ok) return { ok: false, error: owned.error, status: 403 }; const { tld, label } = owned; @@ -48,7 +60,9 @@ export async function issueNameCertificate({ tld: tldInput, label: labelInput, u // pin-checking client (moshcode dns trust, the pit helper) in step. A pin that // is already there is fine; one already published as a different kind is not // ours to fix here, and the certificate is still valid without it. - const pinned = await addPin({ tld, label, pin: leaf.pin, kind: "tls", note: "moshpit-ca", userId }); + // Published as the name's controller, because a pin is theirs: the edge + // asked for the leaf, but the key it carries is one the holder agreed to. + const pinned = await addPin({ tld, label, pin: leaf.pin, kind: "tls", note: pinAs === userId ? "moshpit-ca" : "moshpit-ca c0mpute-edge", userId: pinAs }); await logAction(tld, userId, `cert:issue:${label}:${leaf.serial.slice(0, 8)}`); return { @@ -64,6 +78,27 @@ export async function issueNameCertificate({ tld: tldInput, label: labelInput, u }; } +/** + * `{ ok, tld, label, controller }` when `label.tld` currently points at a + * c0mpute site, else null. `controller` is whoever controlledName() would + * accept: the tenant while a lease runs, otherwise the holder. + */ +async function c0mputeEdgeName(tldInput, labelInput, now) { + const tld = normalizeTld(tldInput); + const label = normalizeLabel(labelInput); + if (!tld || !label) return null; + const row = await get( + `SELECT user_id, c0mpute_site, leased_to, leased_until FROM moshpit_names WHERE tld = ? AND label = ?`, + [tld, label], + ); + if (!row?.c0mpute_site) return null; + // A lease that ran out but has not been swept yet: the site is the former + // tenant's, and resolveMoshpitName already stopped serving it. + if (row.leased_to && row.leased_until && row.leased_until <= now) return null; + const tenanted = row.leased_to && row.leased_until > now; + return { ok: true, tld, label, controller: tenanted ? row.leased_to : row.user_id }; +} + /** What has been issued under a name, newest first. Public: certificates are. */ export async function listNameCertificates(tldInput, labelInput, { limit = 50, now = Date.now() } = {}) { const tld = normalizeTld(tldInput); diff --git a/apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql b/apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql new file mode 100644 index 00000000..a0eda4cf --- /dev/null +++ b/apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql @@ -0,0 +1,3 @@ +-- Postgres twin of migrations/028_moshpit_c0mpute.sql. + +alter table moshpit_names add column if not exists c0mpute_site text; diff --git a/apps/pwa/src/migrations/028_moshpit_c0mpute.sql b/apps/pwa/src/migrations/028_moshpit_c0mpute.sql new file mode 100644 index 00000000..40a58b77 --- /dev/null +++ b/apps/pwa/src/migrations/028_moshpit_c0mpute.sql @@ -0,0 +1,13 @@ +-- A name may point at a static site on the c0mpute network. +-- +-- `c0mpute_site` is the 20-hex site id a c0mpute publisher prints +-- (`c0mpute hosting publish`). It is a third answer to "where does this name +-- go", beside `target` (a server you run) and `feed_url` (somewhere you +-- publish): a site that a peer-to-peer network keeps alive with no server of +-- yours at all. +-- +-- A column rather than a record type: a resolver never answers it directly. The +-- registry turns it into a target at read time (the c0mpute edge that holds a +-- Moshpit certificate for the name and fetches the site from c0mpute gateways), +-- so every resolver already deployed keeps working unchanged. +ALTER TABLE moshpit_names ADD COLUMN c0mpute_site TEXT; diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 8df7081e..accf6672 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -75,6 +75,7 @@ import { normalizeRecordType, recordConflict, } from "./lib/moshpit-records.mjs"; +import { effectiveC0mputeTarget, normalizeC0mputeSite } from "./lib/moshpit-c0mpute.mjs"; import { TWIN_UNLINK_LEAD_MS, clearnetTwins, @@ -367,7 +368,7 @@ async function ownedTldAndLabel(tldInput, labelInput, userId) { /* ---- names under a TLD ---- */ -const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, leased_to, leased_until, created_at`; +const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, c0mpute_site, leased_to, leased_until, created_at`; export async function getName(tld, label) { return get(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? AND label = ?`, [tld, label]); @@ -480,12 +481,44 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId, if (!owned.ok) return owned; const dest = normalizeTarget(target); if (!dest.ok) return { ok: false, error: dest.error }; - await run(`UPDATE moshpit_names SET target = ? WHERE tld = ? AND label = ?`, - [dest.target, owned.tld, owned.label]); + // Pointing a name at a server is pointing it away from a c0mpute site: a + // site id left behind would keep winning at read time and the new target + // would never be served. Clearing the target leaves the site alone. + if (dest.target) { + await run(`UPDATE moshpit_names SET target = ?, c0mpute_site = NULL WHERE tld = ? AND label = ?`, + [dest.target, owned.tld, owned.label]); + } else { + await run(`UPDATE moshpit_names SET target = ? WHERE tld = ? AND label = ?`, + [dest.target, owned.tld, owned.label]); + } await logAction(owned.tld, userId, `retarget:${owned.label}`); return { ok: true }; } +/** + * Point a name at a static site on the c0mpute network, or take it off. + * + * `site` is the 20-hex id `c0mpute hosting publish` prints (or `site=`, + * or a gateway URL ending in /s//). Empty clears it. The holder or the + * current tenant may set it, the same rule as a target, because it is one. + * + * Stored as given and turned into a target at read time + * (lib/moshpit-c0mpute.mjs), so the registry never has to rewrite rows when the + * edge moves. + */ +export async function setNameC0mpute({ tld: tldInput, label: labelInput, userId, site }) { + const owned = await controlledName(tldInput, labelInput, userId); + if (!owned.ok) return owned; + const id = normalizeC0mputeSite(site); + if (id === null) { + return { ok: false, error: "not a c0mpute site id — 20 hex characters, as printed by `c0mpute hosting publish`" }; + } + await run(`UPDATE moshpit_names SET c0mpute_site = ? WHERE tld = ? AND label = ?`, + [id || null, owned.tld, owned.label]); + await logAction(owned.tld, userId, `${id ? "c0mpute" : "unc0mpute"}:${owned.label}`); + return { ok: true, site: id || null }; +} + /** * Point a name at a feed, or take the feed off it. * @@ -826,7 +859,13 @@ export async function resolveMoshpitName(input) { registered: true, ...(Boolean(owner.alias_of) && !aliased ? { exempt: true } : {}), name_registered: Boolean(entry), - target: lapsed ? null : (entry?.target ?? null), + // A c0mpute site becomes the edge's hostname here, so every resolver that + // already follows a hostname target (the bridge, the DoH server, + // TronBrowser) reaches the edge without learning anything new. + target: lapsed ? null : effectiveC0mputeTarget({ target: entry?.target ?? null, site: entry?.c0mpute_site ?? null }), + // The site id itself, for the edge: it is how the edge learns which site to + // fetch for the Host it was handed. Null for every other name. + c0mpute: lapsed ? null : (entry?.c0mpute_site ?? null), // Carried alongside the target rather than folded into it. A resolver // answering AAAA has no use for a feed and ignores these; /n/ is the caller // that turns them into a page, and it needs both to decide which it serves. @@ -2803,7 +2842,7 @@ async function handOverName(tld, label, buyerId) { // The target goes too. It names a server the seller runs, and leaving it // would point the buyer's new name at the seller's machine. await run( - `UPDATE moshpit_names SET user_id = ?, target = NULL, feed_url = NULL, feed_kind = NULL, + `UPDATE moshpit_names SET user_id = ?, target = NULL, feed_url = NULL, feed_kind = NULL, c0mpute_site = NULL, leased_to = NULL, leased_until = NULL WHERE tld = ? AND label = ?`, [buyerId, tld, label], ); @@ -2833,7 +2872,7 @@ export async function endExpiredLeases(now = Date.now(), limit = 200) { await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [lease.tld, lease.label]); await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [lease.tld, lease.label]); await run( - `UPDATE moshpit_names SET target = NULL, feed_url = NULL, feed_kind = NULL, leased_to = NULL, leased_until = NULL + `UPDATE moshpit_names SET target = NULL, feed_url = NULL, feed_kind = NULL, c0mpute_site = NULL, leased_to = NULL, leased_until = NULL WHERE tld = ? AND label = ?`, [lease.tld, lease.label], ); diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index eda230e8..344b700d 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -23,6 +23,7 @@ // DELETE /api/moshpit/tlds/:tld/records withdraw one // GET /pit the human page // PUT /api/moshpit/tlds/:tld/names/feed point a name you hold at an RSS/Atom feed +// PUT /api/moshpit/tlds/:tld/names/c0mpute point a name you hold at a c0mpute site // GET /api/moshpit/sites/:name/content everything a name publishes // POST /api/moshpit/sites/:name/content publish a page, section or post (upsert — the webhook) // GET /api/moshpit/sites/:name/content/:slug one item @@ -153,6 +154,7 @@ import { setAlias, setContact, setExempt, + setNameC0mpute, setNameFeed, setNameTarget, setTldPrice, @@ -185,6 +187,7 @@ const page = (opts) => shellPage({ ...opts, tracker: config.crawlproofSite }); import { shortLinkUrl } from "../lib/moshpit-links.mjs"; import { caEnabled, caMaterial } from "../lib/moshpit-ca.mjs"; import { getNameCertificate, issueNameCertificate, listNameCertificates } from "../lib/moshpit-certs.mjs"; +import { c0mputeEdgeHost } from "../lib/moshpit-c0mpute.mjs"; export const moshpitRouter = Router(); @@ -504,6 +507,32 @@ moshpitRouter.put("/api/moshpit/tlds/:tld/names/feed", async (req, res) => { }); }); +/** + * Point a name at a c0mpute site, or clear it by sending an empty `site`. + * + * `{ label, site }`, where site is the 20-hex id `c0mpute hosting publish` + * prints. Its own endpoint for the reason the feed has one: a deploy script + * that only knows about targets must not wipe a site it never sent. The answer + * says whether an edge is configured, because without one the site is stored + * but nothing serves it yet, and an owner should hear that now rather than + * from a visitor. + */ +moshpitRouter.put("/api/moshpit/tlds/:tld/names/c0mpute", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await setNameC0mpute({ + tld: req.params.tld, label: req.body?.label, userId: req.user.id, site: req.body?.site, + }); + if (!result.ok) return bad(res, result.error || "could not set that c0mpute site"); + const edge = c0mputeEdgeHost(); + res.json({ + tld: normalizeTld(req.params.tld), + label: normalizeLabel(req.body?.label), + c0mpute: result.site, + edge, + served: Boolean(result.site && edge), + }); +}); + moshpitRouter.put("/api/moshpit/tlds/:tld/names", async (req, res) => { if (!req.user) return unauthorized(res); const result = await setNameTarget({ diff --git a/apps/pwa/test/moshpit-c0mpute.test.mjs b/apps/pwa/test/moshpit-c0mpute.test.mjs new file mode 100644 index 00000000..150e88a8 --- /dev/null +++ b/apps/pwa/test/moshpit-c0mpute.test.mjs @@ -0,0 +1,170 @@ +// Pointing a Moshpit name at a c0mpute site, against a real (throwaway) libSQL +// database and a CA generated in memory. +// +// What matters: the site id is validated and stored; a resolver is told the +// edge's hostname only while an edge is configured (so nothing changes for +// anyone until it is); a target set later takes the name back; and the CA signs +// for the edge only while the holder has the name pointed at c0mpute. +// +// Skips cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { X509Certificate } from "node:crypto"; +import test from "node:test"; + +import { + c0mputeEdgeHost, + c0mputeEdgeUsers, + effectiveC0mputeTarget, + normalizeC0mputeSite, +} from "../src/lib/moshpit-c0mpute.mjs"; + +const require = createRequire(import.meta.url); +let installed = true; +try { require("@libsql/client"); require("@peculiar/x509"); } catch { installed = false; } + +const SITE = "0123456789abcdef0123"; +const OTHER = "fedcba9876543210fedc"; + +test("c0mpute site ids are validated, in the forms people paste", () => { + assert.equal(normalizeC0mputeSite(SITE), SITE); + assert.equal(normalizeC0mputeSite(SITE.toUpperCase()), SITE); + assert.equal(normalizeC0mputeSite(` site=${SITE} `), SITE, "the TXT form"); + assert.equal(normalizeC0mputeSite(`http://gw.example:7777/s/${SITE}/`), SITE, "a gateway URL"); + assert.equal(normalizeC0mputeSite(`http://gw.example:7777/s/${SITE}`), SITE); + assert.equal(normalizeC0mputeSite(""), "", "empty clears"); + assert.equal(normalizeC0mputeSite(null), ""); + assert.equal(normalizeC0mputeSite("off"), ""); + assert.equal(normalizeC0mputeSite("0123"), null, "too short"); + assert.equal(normalizeC0mputeSite(`${SITE}00`), null, "too long"); + assert.equal(normalizeC0mputeSite("g123456789abcdef0123"), null, "not hex"); + assert.equal(normalizeC0mputeSite("../../etc/passwd"), null); +}); + +test("the edge is a hostname or nothing", () => { + assert.equal(c0mputeEdgeHost({}), null); + assert.equal(c0mputeEdgeHost({ MOSHPIT_C0MPUTE_EDGE: "Edge.Moshcode.sh." }), "edge.moshcode.sh"); + assert.equal(c0mputeEdgeHost({ MOSHPIT_C0MPUTE_EDGE: "https://edge.moshcode.sh" }), null, "no scheme"); + assert.equal(c0mputeEdgeHost({ MOSHPIT_C0MPUTE_EDGE: "edge" }), null, "a bare label resolves nowhere"); + assert.deepEqual([...c0mputeEdgeUsers({ MOSHPIT_C0MPUTE_EDGE_USERS: "u1, u2 u3" })], ["u1", "u2", "u3"]); + assert.equal(c0mputeEdgeUsers({}).size, 0); +}); + +test("a site wins over a target only while an edge is configured", () => { + const env = { MOSHPIT_C0MPUTE_EDGE: "edge.moshcode.sh" }; + assert.equal(effectiveC0mputeTarget({ target: "box.example.com", site: SITE }, env), "edge.moshcode.sh"); + assert.equal(effectiveC0mputeTarget({ target: null, site: SITE }, env), "edge.moshcode.sh"); + assert.equal(effectiveC0mputeTarget({ target: "box.example.com", site: null }, env), "box.example.com"); + assert.equal(effectiveC0mputeTarget({ target: "box.example.com", site: SITE }, {}), "box.example.com"); + assert.equal(effectiveC0mputeTarget({ target: null, site: SITE }, {}), null); +}); + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-c0mpute-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const ALICE = "user-alice"; // owns .eggs, blue.eggs, red.eggs +const BOB = "user-bob"; // stranger +const EDGE = "user-edge"; // the c0mpute edge's account + +test("names pointed at c0mpute", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run } = await import("../src/db.mjs"); + for (const [id, email] of [[ALICE, "alice@example.com"], [BOB, "bob@example.com"], [EDGE, "edge@example.com"]]) { + await run(`INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, Date.now()]); + } + const m = await import("../src/moshpit.mjs"); + const ca = await import("../src/lib/moshpit-ca.mjs"); + const certs = await import("../src/lib/moshpit-certs.mjs"); + + assert.equal((await m.registerTld({ tld: "eggs", userId: ALICE })).ok, true); + assert.equal((await m.registerName({ tld: "eggs", label: "blue", userId: ALICE, target: "box.example.com" })).ok, true); + assert.equal((await m.registerName({ tld: "eggs", label: "red", userId: ALICE })).ok, true); + + const saved = { edge: process.env.MOSHPIT_C0MPUTE_EDGE, users: process.env.MOSHPIT_C0MPUTE_EDGE_USERS }; + t.after(() => { + for (const [k, v] of [["MOSHPIT_C0MPUTE_EDGE", saved.edge], ["MOSHPIT_C0MPUTE_EDGE_USERS", saved.users]]) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + }); + delete process.env.MOSHPIT_C0MPUTE_EDGE; + delete process.env.MOSHPIT_C0MPUTE_EDGE_USERS; + + await t.test("only the holder may set it, and only to a site id", async () => { + assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "blue", userId: BOB, site: SITE })).ok, false); + const badId = await m.setNameC0mpute({ tld: "eggs", label: "blue", userId: ALICE, site: "nope" }); + assert.equal(badId.ok, false); + assert.match(badId.error, /20 hex/); + const r = await m.setNameC0mpute({ tld: "eggs", label: "blue", userId: ALICE, site: `site=${SITE}` }); + assert.equal(r.ok, true, r.error); + assert.equal(r.site, SITE); + }); + + await t.test("with no edge configured, resolvers see exactly what they saw before", async () => { + const res = await m.resolveMoshpitName("blue.eggs"); + assert.equal(res.target, "box.example.com"); + assert.equal(res.c0mpute, SITE); + }); + + await t.test("with an edge configured, the name resolves to it", async () => { + process.env.MOSHPIT_C0MPUTE_EDGE = "edge.moshcode.sh"; + const res = await m.resolveMoshpitName("blue.eggs"); + assert.equal(res.target, "edge.moshcode.sh"); + assert.equal(res.c0mpute, SITE); + const plain = await m.resolveMoshpitName("red.eggs"); + assert.equal(plain.target, null, "names without a site are untouched"); + assert.equal(plain.c0mpute, null); + }); + + await t.test("setting a target takes the name back from c0mpute", async () => { + assert.equal((await m.setNameTarget({ tld: "eggs", label: "blue", userId: ALICE, target: "other.example.com" })).ok, true); + const res = await m.resolveMoshpitName("blue.eggs"); + assert.equal(res.target, "other.example.com"); + assert.equal(res.c0mpute, null); + }); + + await t.test("clearing works, and clearing a target leaves the site alone", async () => { + assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: OTHER })).ok, true); + assert.equal((await m.setNameTarget({ tld: "eggs", label: "red", userId: ALICE, target: "" })).ok, true); + assert.equal((await m.resolveMoshpitName("red.eggs")).c0mpute, OTHER); + const cleared = await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: "" }); + assert.deepEqual(cleared, { ok: true, site: null }); + assert.equal((await m.resolveMoshpitName("red.eggs")).target, null); + }); + + await t.test("the CA signs for the edge only while the name points at c0mpute", async () => { + const generated = await ca.generateCa({ rootName: "Test Root", issuerName: "Test Issuer" }); + await ca.configureCa({ cert: generated.issuer.cert, key: generated.issuer.key, root: generated.root.cert, leafDays: 30 }); + t.after(() => ca.resetCaForTests()); + process.env.MOSHPIT_C0MPUTE_EDGE_USERS = EDGE; + + const { csr } = await ca.generateCsr("red.eggs"); + // red.eggs has no site: the edge is a stranger. + assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr })).status, 403); + + assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: SITE })).ok, true); + const r = await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr }); + assert.equal(r.ok, true, r.error); + const leaf = new X509Certificate(r.cert); + assert.equal(leaf.subject, "CN=red.eggs"); + const pins = await m.listPins("eggs", "red", "tls"); + assert.equal(pins.length, 1, "published as a pin"); + assert.equal(pins[0].user_id, ALICE, "under the holder, whose name it is"); + + // A stranger who is not an edge still gets nothing. + assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: BOB, csr })).status, 403); + + // Taken off c0mpute: the edge is refused again. + assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: "" })).ok, true); + assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr })).status, 403); + + // Not an edge account: refused even with a site set. + process.env.MOSHPIT_C0MPUTE_EDGE_USERS = ""; + assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: SITE })).ok, true); + assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr })).status, 403); + }); +}); diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 80c4431d..9274a278 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -706,8 +706,11 @@ export const CORE_CLI_COMMANDS = [ { name: "name", group: "hosting", - description: "prove you hold a Moshpit name, so an app can use it as your identity", - synopsis: [["moshcode name link [--app ]", ""]], + description: "prove you hold a Moshpit name, or serve it from a c0mpute site", + synopsis: [ + ["moshcode name link [--app ]", "prove you hold it, for an app"], + ["moshcode name c0mpute ", "serve it from a c0mpute site, no server of yours"], + ], flags: [ ["--app ", "the app to prove the name to", "https://qrypt.chat"], ["--dir ", "where the name's key and certificate live", "/etc/ssl/moshpit"], @@ -716,6 +719,8 @@ export const CORE_CLI_COMMANDS = [ examples: [ ["sudo moshcode name link chovy.hacker", "the key is root-owned"], ["sudo moshcode name link blue.eggs --json", "hand the bundle to a script"], + ["moshcode name c0mpute blue.eggs 0123456789abcdef0123", "https://blue.eggs serves that c0mpute site"], + ["moshcode name c0mpute blue.eggs off", "stop serving it from c0mpute"], ], seeAlso: ["dns", "site", "whoami"], }, diff --git a/src/name-c0mpute.mjs b/src/name-c0mpute.mjs new file mode 100644 index 00000000..63210be0 --- /dev/null +++ b/src/name-c0mpute.mjs @@ -0,0 +1,117 @@ +// `moshcode name c0mpute ` — serve a Moshpit name from the +// c0mpute network. +// +// c0mpute keeps static sites alive peer to peer: `c0mpute hosting publish` +// prints a 20-hex site id, three hosting nodes hold copies, and every c0mpute +// gateway serves it at /s//. This tells the registry that a name you +// hold is that site. The registry then resolves the name to the c0mpute edge, +// which holds a Moshpit certificate for it and fetches the site from whichever +// gateways are healthy, so https:// works in any browser that trusts the +// Moshpit root, with no server of yours anywhere. +// +// Nothing here touches this machine's DNS. It is one write to the registry. + +import { DEFAULT_REGISTRY_BASE } from "./pit-url.mjs"; +import { loadCreds } from "./auth.mjs"; +import { normalizeName } from "./name-link.mjs"; + +/** A site id as the registry stores it, `""` to clear, or null when it is not one. */ +export function normalizeSiteId(input) { + const raw = String(input ?? "").trim().toLowerCase(); + if (!raw || raw === "off" || raw === "none") return ""; + const fromUrl = raw.match(/\/s\/([0-9a-f]{20})(?:\/|$)/); + const bare = fromUrl ? fromUrl[1] : raw.replace(/^site=/, ""); + return /^[0-9a-f]{20}$/.test(bare) ? bare : null; +} + +/** + * Tell the registry `name` is c0mpute site `site` ("" clears it). + * + * Returns { ok, site, edge, served } or { ok: false, error, needsAuth? }. + */ +export async function setC0mputeSite({ + name, + site, + registryBase = DEFAULT_REGISTRY_BASE, + token, + fetchImpl = fetch, +} = {}) { + if (!token) { + return { ok: false, needsAuth: true, error: "not logged in — run `moshcode login`, or set MOSHCODE_API_KEY" }; + } + const [label, tld] = name.split("."); + const url = `${String(registryBase).replace(/\/+$/, "")}/api/moshpit/tlds/${encodeURIComponent(tld)}/names/c0mpute`; + let response; + try { + response = await fetchImpl(url, { + method: "PUT", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ label, site }), + }); + } catch (error) { + return { ok: false, error: `registry unreachable: ${error.message}` }; + } + if (response.status === 401) return { ok: false, needsAuth: true, error: "the registry rejected the credentials" }; + const body = await response.json().catch(() => null); + if (!response.ok) { + return { ok: false, error: body?.error ? String(body.error) : `registry said ${response.status}` }; + } + return { ok: true, site: body?.c0mpute ?? null, edge: body?.edge ?? null, served: Boolean(body?.served) }; +} + +/** + * `moshcode name c0mpute [--registry ] [--json]`. + * + * @param {string[]} argv the words after `c0mpute` + * @returns {Promise} exit code + */ +export async function nameC0mputeCommand(argv, io = {}) { + const out = io.out || ((s) => console.log(s)); + const err = io.err || ((s) => console.error(s)); + const at = argv.indexOf("--registry"); + const registryBase = at >= 0 && argv[at + 1] ? argv[at + 1] : (io.env?.MOSHPIT_REGISTRY_BASE || DEFAULT_REGISTRY_BASE); + const json = argv.includes("--json"); + const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1] === "--registry")); + const [rawName, rawSite] = positional; + + const usage = "usage: moshcode name c0mpute [--json]"; + const name = normalizeName(rawName); + if (!name) { + err(rawName ? `not a Moshpit name: ${rawName} — expected