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
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:
@@ -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 {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 |
|
| `serves.route` | `{}` | a route hands back a name, not a credential |
|
||||||
| `receives.route` | `…/routes/mesh.json` | the same contributions file, in the same shape |
|
| `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 |
|
| `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
|
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.
|
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**,
|
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 —
|
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
|
a `merge: "json"` file the mesh composes from the module's defaults and the node's layer, mounted
|
||||||
into the container.
|
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
|
## How it ships
|
||||||
|
|
||||||
The tool runtime carrying this module's compiled code, as
|
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
|
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.
|
runs on is an offline operation, and `mesh-tools run` gives it exactly that.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| `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) |
|
| `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
|
No provisioner: the proxy neither mints a credential for anybody nor answers a provision other
|
||||||
an event. It only reads the file the mesh writes. (Contrast `redis`, which mints passwords, and
|
than `route`. It reads the file the mesh writes, and its own broker credential is for its tools.
|
||||||
`cloudflare-dns`, which emits record events.)
|
|
||||||
|
|
||||||
## 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
|
**The public issuer is this module's own fact, not a provision.** Until 2026-10-06 it came from a
|
||||||
runs nothing) and `step-ca` (the mesh's own authority, which also offers it so a lab without a public
|
second module, `public-acme`, that ran nothing and offered `acme-ca` pointing at Let's Encrypt; every
|
||||||
issuer still has one). A proxy beside both resolves by co-location only once a pin names the module
|
node running a proxy was assigned it too, and nothing else ever consumed it. It is retired: the proxy
|
||||||
(`pin <node> acme-ca <node> public-acme`, novox/hq #258); a proxy on another machine cannot resolve
|
names the production directory in its own `acme.env`, and the only issuer it is still bound to is the
|
||||||
at all until it is told. **So every node that runs a route-proxy is assigned `public-acme` too**: the
|
mesh's own (`internal-acme-ca`, from `step-ca`).
|
||||||
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
|
**`acme.env` is byte for byte what the binding rendered.** The proxy keeps each authority's account
|
||||||
Let's Encrypt", on the machine that issues.
|
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
|
## 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
|
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.
|
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
|
**The module states production.** The binary's staging default stands for anything that runs it
|
||||||
production directory `https://acme-v02.api.letsencrypt.org/directory` for this module on that node.
|
without the module — the lab, a developer's machine — and the module's `acme.env` names production
|
||||||
It is a node property, and the node facing the public internet is the one that opts in.
|
for every node it is assigned to, because a node assigned the public proxy is one that serves public
|
||||||
|
traffic.
|
||||||
|
|
||||||
| env | default | meaning |
|
| 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 |
|
| `LISTEN` | `:80` | HTTP, and the ACME HTTP-01 challenge |
|
||||||
| `TLS_LISTEN` | `:443` | HTTPS; unset to serve plain HTTP only |
|
| `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_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 |
|
| `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 |
|
||||||
|
|||||||
@@ -19,11 +19,9 @@
|
|||||||
"route": "${dir:routes-dir}/mesh.json"
|
"route": "${dir:routes-dir}/mesh.json"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
"acme-ca",
|
|
||||||
"internal-acme-ca"
|
"internal-acme-ca"
|
||||||
],
|
],
|
||||||
"binds": {
|
"binds": {
|
||||||
"acme-ca": "${dir:state}/acme-ca.json",
|
|
||||||
"internal-acme-ca": "${dir:state}/internal-acme-ca.json"
|
"internal-acme-ca": "${dir:state}/internal-acme-ca.json"
|
||||||
},
|
},
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
@@ -81,7 +79,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/acme.env",
|
"path": "${dir:state}/acme.env",
|
||||||
"mode": "0600",
|
"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",
|
"id": "internal-acme-env",
|
||||||
|
|||||||
Reference in New Issue
Block a user