diff --git a/modules/cloudflare-dns/client.ts b/modules/cloudflare-dns/client.ts deleted file mode 100644 index 80e848f..0000000 --- a/modules/cloudflare-dns/client.ts +++ /dev/null @@ -1,127 +0,0 @@ -// cloudflare-dns's own code (novox/hq ADR 0039). It provides the mesh `public-dns` interface -// (ADR 0044): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar -// behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is -// the only place Cloudflare's API appears, and swapping registrars swaps only this module. - -import { readFileSync } from "node:fs"; - -export interface PublicRecord { - id: string; - name: string; - type: string; - content: string; -} - -export class CloudflareClient { - constructor( - private readonly token: string, - private readonly zoneId: string, - /** The zone this registers under, e.g. "example.com". */ - readonly domain: string, - /** What every public name points at — the mesh's public ingress (the reverse proxy). */ - readonly ingress: string, - ) {} - - static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient { - // Which zone, domain and ingress are a mesh's own facts, not this module's — so they are - // settings, merged into a config file the mesh manages (novox/hq ADR 0046), read here. The - // token is the one secret and stays an own-secret. Env is honoured as a fallback for a - // hand-run instance, but the deployed path is the config file settings fill. - const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE); - const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE); - const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID; - const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN; - const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS; - if (!token || !zoneId || !domain || !ingress) { - throw new Error( - "cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " + - "token as its own-secret); until then it registers nothing", - ); - } - return new CloudflareClient(token, zoneId, domain, ingress); - } - - /** - * The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not - * contributed, for the same reason minio derives a bucket name — the harness hands `remove` only - * the identity, so teardown must recompute exactly what creation made. - */ - nameFor(consumer: string): string { - return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`; - } - - /** An IP points at itself (A/AAAA); a hostname points through a CNAME. */ - private recordType(): "A" | "AAAA" | "CNAME" { - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A"; - if (this.ingress.includes(":")) return "AAAA"; - return "CNAME"; - } - - private async api(method: string, path: string, body?: unknown): Promise { - const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { - method, - headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown }; - if (!res.ok || json.success === false) { - throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`); - } - return json.result as T; - } - - async findRecord(name: string): Promise { - const records = await this.api( - "GET", - `/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`, - ); - return records[0]; - } - - /** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */ - async upsert(name: string): Promise { - const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false }; - const existing = await this.findRecord(name); - if (existing) { - return this.api("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body); - } - return this.api("POST", `/zones/${this.zoneId}/dns_records`, body); - } - - /** Remove a public name, idempotently — a record already gone is not an error on reconcile. */ - async remove(name: string): Promise { - const existing = await this.findRecord(name); - if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`); - } - - /** Every record in the zone, for the diagnostic tool. */ - async records(): Promise { - return this.api("GET", `/zones/${this.zoneId}/dns_records`); - } -} - -function readSecret(path: string | undefined): string | undefined { - if (!path) return undefined; - try { - return readFileSync(path, "utf8").trim(); - } catch { - return undefined; - } -} - -interface Config { - zone?: string; - domain?: string; - ingress?: string; -} - -/** The settings-managed config file (a JSON document the mesh merges settings into). Absent or - * unparseable yields an empty config, which fromEnv then reports as unconfigured. */ -function readConfig(path: string | undefined): Config { - if (!path) return {}; - try { - return JSON.parse(readFileSync(path, "utf8")) as Config; - } catch { - return {}; - } -} diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json deleted file mode 100644 index 69b0237..0000000 --- a/modules/cloudflare-dns/module.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "module": "cloudflare-dns", - "version": "1", - "slug": "cfdns", - "provides": [ - { - "name": "public-dns", - "scope": "mesh" - } - ], - "serves": { - "public-dns": {} - }, - "grants": { - "public-dns": "${dir:grants}" - }, - "receives": { - "public-dns": "${dir:grants}/mesh.json" - }, - "own-secrets": { - "token": "${dir:state}/token" - }, - "emits": [ - "record.created", - "record.removed" - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "grants", - "type": "directory", - "mode": "0700" - }, - { - "id": "config", - "type": "file", - "path": "${dir:state}/config.json", - "merge": "json", - "content": "{}", - "mode": "0600" - } - ], - "capabilities": [ - "container-runtime" - ], - "build": { - "artifacts": [ - { - "name": "code", - "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "tools/index.js", - "provisioner/index.js" - ], - "loads": [ - "tools/index.js", - "provisioner/index.js" - ], - "env": { - "MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token", - "MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json", - "MESH_RECEIVES": "${dir:grants}/mesh.json" - } - } - ] - } -} diff --git a/modules/cloudflare-dns/package.json b/modules/cloudflare-dns/package.json deleted file mode 100644 index 0c5bbd5..0000000 --- a/modules/cloudflare-dns/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-cloudflare-dns", - "version": "0.1.0", - "description": "cloudflare-dns — a public-dns provider (ADR 0044): registers public names at Cloudflare.", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/cloudflare-dns/provisioner/index.ts b/modules/cloudflare-dns/provisioner/index.ts deleted file mode 100644 index 80bfcaf..0000000 --- a/modules/cloudflare-dns/provisioner/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface -// (novox/hq ADR 0044). The reconcile loop and the contributions file are the sdk harness's; this -// writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing it -// at the mesh's ingress, and remove it when the consumer is withdrawn (ADR 0048). -// -// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly -// and what it resolves to. Like umami's analytics it is a *data* provision, not a credential one: -// nothing the mesh mints is set here (a DNS record is public, and the only secret is this module's -// own Cloudflare token, which never leaves). So the password the harness carries is unused; the name -// is derived from the login the mesh gave the consumer, which the consumer can derive too. Delivering -// the record back to the consumer is the data-provision return path ADR 0048 leaves out of scope. - -import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; -import { emit } from "@novox/mesh-sdk/events"; -import { CloudflareClient } from "../client.js"; - -const cloudflare = CloudflareClient.fromEnv(); - -runProvisioner("public-dns", { - async create(p: Provision): Promise { - const fqdn = cloudflare.nameFor(p.as); - await cloudflare.upsert(fqdn); - await announce("record.created", { - name: fqdn, - target: cloudflare.ingress, - consumer: p.consumer ?? "", - }); - }, - - async remove(p: { as: string }): Promise { - const fqdn = cloudflare.nameFor(p.as); - await cloudflare.remove(fqdn); - await announce("record.removed", { name: fqdn, consumer: p.as }); - }, -}); - -/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */ -async function announce(type: string, body: unknown): Promise { - try { - await emit(type, body); - } catch (err) { - console.error(`[cloudflare-dns] could not emit ${type}: ${err}`); - } -} diff --git a/modules/cloudflare-dns/tools/index.ts b/modules/cloudflare-dns/tools/index.ts deleted file mode 100644 index 529e7fd..0000000 --- a/modules/cloudflare-dns/tools/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { CloudflareClient } from "../client.js"; - -export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] { - return [ - { - name: "cloudflare_dns_records", - description: "The public DNS records in the mesh's zone — the names it currently publishes.", - input: {}, - run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }), - }, - ]; -} - -registerModuleTools("cloudflare-dns", (env) => { - try { - return getCloudflareDnsTools(CloudflareClient.fromEnv(env)); - } catch { - return []; - } -}); diff --git a/modules/cloudflare-dns/tsconfig.json b/modules/cloudflare-dns/tsconfig.json deleted file mode 100644 index c2a8df0..0000000 --- a/modules/cloudflare-dns/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "client.ts", - "tools/index.ts", - "provisioner/index.ts" - ] -} \ No newline at end of file diff --git a/modules/dhcpcd/README.md b/modules/dhcpcd/README.md deleted file mode 100644 index fb77d4a..0000000 --- a/modules/dhcpcd/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# dhcpcd - -The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It -asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the -private network's interface alone — and it writes that resolver file itself (ADR 0223). It never -declares an interface, an address, a route, a wireless network or its credentials — the link -dhcpcd keeps is the only channel the mesh reaches the machine over. - -## What it writes - -`/etc/resolv.conf`, whole: every resolver of the mesh by its private address — this machine's own -first when it holds one — and `options timeout:1 attempts:2 edns0`, rendered by the mesh from the -holders of `mesh-dns-resolver`. The same file every uplink module writes. Not dhcpcd's own static -`domain_name_servers`: dhcpcd writes the file only through its hook, with its own header, and reads -its configuration only at its next start, so a change to the mesh's resolvers would not reach the -file until then. - -Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no -drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102): - -- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it - takes or renews, which would silently replace the resolvers this module writes there. -- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and - never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so - rather than relying on it. - -**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line -after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in -exactly such a block (the interface, its static address), so appended at the end these two would -quietly apply to one interface only. - -## Why it declares no service - -dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it -drops the address the machine is reached at, and a module unassigned by mistake must not be able -to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and -a restart drops the lease. So the two lines take effect at **dhcpcd's next start**. - -On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and -it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next -starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal -still rewrites `/etc/resolv.conf`, and this module puts it back at the next push. Restart dhcpcd -once, by hand, when losing the link for a moment is acceptable. - -## One manager per machine - -It claims `node-uplink`: a machine runs one network manager, and assigning a second module that -claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's -installs the package and writes the two lines, and starts nothing. diff --git a/modules/dhcpcd/module.json b/modules/dhcpcd/module.json deleted file mode 100644 index 7d7b3bc..0000000 --- a/modules/dhcpcd/module.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "module": "dhcpcd", - "version": "1", - "requires": [ - "wildcard-resolution" - ], - "capabilities": [ - "package-manager", - "service-manager", - "uplink-dhcpcd" - ], - "claims": [ - { - "name": "node-uplink", - "scope": "node" - } - ], - "facts": { - "resolvers": { - "path": "/etc/resolv.conf", - "template": "# Managed by the mesh, and written by the module holding this machine's uplink:\n# the program that manages the machine's network would otherwise rewrite this\n# file on every change of network, so its holder is the one that writes it\n# (novox/hq ADR 0117, ADR 0223). Replaced on every push; edit nothing here.\n#\n# Every resolver of the mesh, by address, and nothing else (novox/hq ADR 0223) \u2014\n# this machine's own first when it holds one, then the others by name. Each\n# answers the mesh's names from the same roster and forwards every other name, so\n# whichever answers first gives the one answer. There is no public resolver here:\n# a C library that asks every listed server at once and takes the first reply \u2014\n# musl, so every Alpine container \u2014 took a public resolver's \"no such name\" for\n# a mesh name and failed. A machine that reaches none of these has no names until\n# it does. Containers copy these lines from their machine.\n{{range index .Holders \"mesh-dns-resolver\"}}nameserver {{.Address}}\n{{end}}options timeout:1 attempts:2 edns0\n" - } - }, - "resources": [ - { - "id": "package", - "type": "package", - "package": "dhcpcd" - }, - { - "id": "config", - "type": "file", - "path": "/etc/dhcpcd.conf", - "mode": "0644", - "into": "block", - "at": "start", - "content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117, ADR 0223): the\n# resolver file is this module's, written whole beside this file. Global\n# options, so kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n" - } - ] -} diff --git a/modules/public-acme/module.json b/modules/public-acme/module.json deleted file mode 100644 index 39e0e07..0000000 --- a/modules/public-acme/module.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "module": "public-acme", - "version": "1", - "slug": "pubacme", - "provides": [ - { - "name": "acme-ca", - "scope": "mesh" - } - ], - "serves": { - "acme-ca": { - "at": "acme-v02.api.letsencrypt.org", - "port": 443, - "path": "/directory", - "roots": "" - } - } -} diff --git a/modules/route-adapter/README.md b/modules/route-adapter/README.md index 42c6655..498b7e7 100644 --- a/modules/route-adapter/README.md +++ b/modules/route-adapter/README.md @@ -26,7 +26,7 @@ the same proxy, pointing at the mesh's container instead of the predecessor's. | `serves.route` | `{}` | a route hands back a name, not a credential | | `receives.route` | `…/routes/mesh.json` | the same contributions file, in the same shape | | `listens` | *nothing* | **the point.** It opens no port, so it stands beside the predecessor rather than replacing it | -| `requires` | *nothing* | in particular not `acme-ca`: the predecessor holds the certificates and this must not ask for one | +| `requires` | *nothing* | in particular no certificate issuer: the predecessor holds the certificates and this must not obtain one | Assign **either** this or `route-proxy` to a node, never both — two providers of one mesh-scoped provision is an ambiguity the resolver is right to refuse. @@ -95,7 +95,7 @@ after the module was unassigned. Where the predecessor keeps the directory its file provider reads is a fact about **one machine**, so it is a setting laid over the module's default rather than a constant in the catalogue — -novox/hq ADR 0100's `ports` is the precedent, and cloudflare-dns's `config.json` is the mechanism: +novox/hq ADR 0100's `ports` is the precedent, and the mechanism is a `merge: "json"` file the mesh composes from the module's defaults and the node's layer, mounted into the container. @@ -141,7 +141,7 @@ published ports — is a decision for novox/hq, not for a module that is schedul ## How it ships The tool runtime carrying this module's compiled code, as -[cloudflare-dns](../cloudflare-dns/Dockerfile) and [mosquitto](../mosquitto/Dockerfile) do — built +[mosquitto](../mosquitto/Dockerfile) does — built from this directory and nothing else. It connects to no broker: reconciling files on the machine it runs on is an offline operation, and `mesh-tools run` gives it exactly that. diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md index 35e35ab..bc0e0d7 100644 --- a/modules/route-proxy/README.md +++ b/modules/route-proxy/README.md @@ -23,20 +23,25 @@ it. | `receives.route` | `…/routes/mesh.json` | where the mesh writes every contribution; the proxy reads it as `$ROUTES` and re-reads on change | | `listens` | `80` and `443`, both `from: anywhere` | the one machine with a public opening; the firewall opens these for free (ADR 0045) | -No broker account, no own-secrets, no provisioner: the proxy neither mints a credential nor emits -an event. It only reads the file the mesh writes. (Contrast `redis`, which mints passwords, and -`cloudflare-dns`, which emits record events.) +No provisioner: the proxy neither mints a credential for anybody nor answers a provision other +than `route`. It reads the file the mesh writes, and its own broker credential is for its tools. -## Which issuer: the node that runs a proxy carries `public-acme` +## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226) -`acme-ca` has two providers in a full mesh — `public-acme` (Let's Encrypt, a facts-only module that -runs nothing) and `step-ca` (the mesh's own authority, which also offers it so a lab without a public -issuer still has one). A proxy beside both resolves by co-location only once a pin names the module -(`pin acme-ca public-acme`, novox/hq #258); a proxy on another machine cannot resolve -at all until it is told. **So every node that runs a route-proxy is assigned `public-acme` too**: the -issuer is then on the proxy's own node, design 23's first rule answers, and `internal-acme-ca` has -one provider mesh-wide. Nothing runs for it; it is the statement "this machine's public issuer is -Let's Encrypt", on the machine that issues. +**The public issuer is this module's own fact, not a provision.** Until 2026-10-06 it came from a +second module, `public-acme`, that ran nothing and offered `acme-ca` pointing at Let's Encrypt; every +node running a proxy was assigned it too, and nothing else ever consumed it. It is retired: the proxy +names the production directory in its own `acme.env`, and the only issuer it is still bound to is the +mesh's own (`internal-acme-ca`, from `step-ca`). + +**`acme.env` is byte for byte what the binding rendered.** The proxy keeps each authority's account +and certificates in a directory under `/var/lib/route-proxy/acme` named after a digest of the +directory URL — `https://acme-v02.api.letsencrypt.org:443/directory`, port included, as the binding +spelled it — and of the root bundle the `trust` container copies in. Any change to either is a new +authority to the proxy: a new account, and every routed name ordered again against Let's Encrypt's +rate limits. So the URL keeps the `:443`, `ACME_ROOTS_PATH` stays empty, and the `trust` artifact's +pinned image is not moved without a decision; mesh-controller's +`TestRouteProxyKeepsItsPublicAccountDirectory` holds all three. ## How it ships the Go proxy @@ -66,9 +71,10 @@ would leave the safe path depending on somebody remembering to opt out of it, on most likely to iterate. A staging certificate is trusted by no browser, so the mistake announces itself on the first request rather than a fortnight later at the rate limit. -**A node that serves real public traffic states so**, by overriding `ACME_DIRECTORY` to the -production directory `https://acme-v02.api.letsencrypt.org/directory` for this module on that node. -It is a node property, and the node facing the public internet is the one that opts in. +**The module states production.** The binary's staging default stands for anything that runs it +without the module — the lab, a developer's machine — and the module's `acme.env` names production +for every node it is assigned to, because a node assigned the public proxy is one that serves public +traffic. | env | default | meaning | |---|---|---| @@ -76,5 +82,5 @@ It is a node property, and the node facing the public internet is the one that o | `LISTEN` | `:80` | HTTP, and the ACME HTTP-01 challenge | | `TLS_LISTEN` | `:443` | HTTPS; unset to serve plain HTTP only | | `ACME_CACHE` | `/acme` | where issued certificates persist; required when `TLS_LISTEN` is set, so a restart does not re-order | -| `ACME_DIRECTORY` | Let's Encrypt **staging** | the issuer; a public-serving node overrides it to production | +| `ACME_DIRECTORY` | Let's Encrypt **staging** in the binary; production in the module's `acme.env` | the issuer | | `ACME_CA_BUNDLE` | *(unset)* | a file of roots to trust for the issuer's own API — set only for a private/lab authority whose API certificate the world does not yet trust | diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 08068f3..4d6e9ad 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -18,11 +18,9 @@ "route": "${dir:routes-dir}/mesh.json" }, "requires": [ - "acme-ca", "internal-acme-ca" ], "binds": { - "acme-ca": "${dir:state}/acme-ca.json", "internal-acme-ca": "${dir:state}/internal-acme-ca.json" }, "own-secrets": { @@ -80,7 +78,7 @@ "type": "file", "path": "${dir:state}/acme.env", "mode": "0600", - "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n" + "content": "ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory\nACME_ROOTS=https://acme-v02.api.letsencrypt.org:443\nACME_ROOTS_PATH=\n" }, { "id": "internal-acme-env",