Merge pull request 'Fold public-acme into route-proxy; drop dhcpcd and cloudflare-dns (hq ADR 0226)' (#84) from feat/route-proxy-names-its-public-issuer into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on

This commit was merged in pull request #84.
This commit is contained in:
2026-10-06 13:04:29 +00:00
12 changed files with 26 additions and 431 deletions
-127
View File
@@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<PublicRecord | undefined> {
const records = await this.api<PublicRecord[]>(
"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<PublicRecord> {
const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false };
const existing = await this.findRecord(name);
if (existing) {
return this.api<PublicRecord>("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body);
}
return this.api<PublicRecord>("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<void> {
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<PublicRecord[]> {
return this.api<PublicRecord[]>("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 {};
}
}
-77
View File
@@ -1,77 +0,0 @@
{
"module": "cloudflare-dns",
"version": "1",
"slug": "cfdns",
"provides": [
{
"name": "public-dns",
"scope": "mesh",
"identity": {
"max": 63,
"in": "a DNS label"
}
}
],
"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"
}
}
]
}
}
-14
View File
@@ -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"
}
}
@@ -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<void> {
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<void> {
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<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[cloudflare-dns] could not emit ${type}: ${err}`);
}
}
-23
View File
@@ -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 [];
}
});
-16
View File
@@ -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"
]
}
-49
View File
@@ -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.
-40
View File
@@ -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"
}
]
}
-19
View File
@@ -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": ""
}
}
}
+3 -3
View File
@@ -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.
+22 -16
View File
@@ -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 <node> acme-ca <node> 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 |
+1 -3
View File
@@ -19,11 +19,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": {
@@ -81,7 +79,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",