From 82ef84aa49ff35d0f7de36c09b3ee9117ffb311a Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 02:21:17 +0200 Subject: [PATCH] Fold public-acme into route-proxy; drop dhcpcd and cloudflare-dns (hq ADR 0226) public-acme ran nothing and had one consumer. The proxy now states the issuer itself, byte for byte what the binding rendered, so its account directory and every certificate stay put. dhcpcd and cloudflare-dns are assigned nowhere and nothing requires what they provide. --- modules/cloudflare-dns/client.ts | 127 -------------------- modules/cloudflare-dns/module.json | 73 ----------- modules/cloudflare-dns/package.json | 14 --- modules/cloudflare-dns/provisioner/index.ts | 44 ------- modules/cloudflare-dns/tools/index.ts | 23 ---- modules/cloudflare-dns/tsconfig.json | 16 --- modules/dhcpcd/README.md | 49 -------- modules/dhcpcd/module.json | 40 ------ modules/public-acme/module.json | 19 --- modules/route-adapter/README.md | 6 +- modules/route-proxy/README.md | 38 +++--- modules/route-proxy/module.json | 4 +- 12 files changed, 26 insertions(+), 427 deletions(-) delete mode 100644 modules/cloudflare-dns/client.ts delete mode 100644 modules/cloudflare-dns/module.json delete mode 100644 modules/cloudflare-dns/package.json delete mode 100644 modules/cloudflare-dns/provisioner/index.ts delete mode 100644 modules/cloudflare-dns/tools/index.ts delete mode 100644 modules/cloudflare-dns/tsconfig.json delete mode 100644 modules/dhcpcd/README.md delete mode 100644 modules/dhcpcd/module.json delete mode 100644 modules/public-acme/module.json 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",