Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` <br>`serve` | hosting | install web-server config for a Moshpit name |
| `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
Expand Down
12 changes: 12 additions & 0 deletions apps/pwa/docs/moshpit-ca.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <site_id>`, 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.

Expand Down
72 changes: 72 additions & 0 deletions apps/pwa/src/lib/moshpit-c0mpute.mjs
Original file line number Diff line number Diff line change
@@ -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/<site_id>/ 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=<id>` (the TXT record
* c0mpute documents for custom domains) and a gateway URL ending in
* `/s/<id>/`, 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;
}
39 changes: 37 additions & 2 deletions apps/pwa/src/lib/moshpit-certs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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://<name> 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;

Expand All @@ -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 {
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Postgres twin of migrations/028_moshpit_c0mpute.sql.

alter table moshpit_names add column if not exists c0mpute_site text;
13 changes: 13 additions & 0 deletions apps/pwa/src/migrations/028_moshpit_c0mpute.sql
Original file line number Diff line number Diff line change
@@ -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;
51 changes: 45 additions & 6 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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=<id>`,
* or a gateway URL ending in /s/<id>/). 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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -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],
);
Expand Down
29 changes: 29 additions & 0 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -153,6 +154,7 @@ import {
setAlias,
setContact,
setExempt,
setNameC0mpute,
setNameFeed,
setNameTarget,
setTldPrice,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading