diff --git a/scenarios/a-public-name.yml b/scenarios/a-public-name.yml new file mode 100644 index 0000000..ecb24e8 --- /dev/null +++ b/scenarios/a-public-name.yml @@ -0,0 +1,26 @@ +# One machine serving a public name with a certificate from an authority it did not run itself. +# +# The lab keeps production's two-authority split rather than collapsing it (01-RESEARCH/004): the +# mesh's own authority certifies `.internal` names, and a name reachable from outside is certified +# by ACME. A single-authority lab would hide any fault living in that split, so this raises a real +# ACME server and makes the proxy actually order from it. +# +# Pebble rather than a stub, for the reason the lab exists at all: what is under test is whether an +# HTTP-01 challenge is answered at the name being certified, and a fake would be told to agree. +scenario: a-public-name + +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] + +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10] } + inbound: allow + +images: + - ghcr.io/letsencrypt/pebble:2.5.0 + +place: + all: [runtime] diff --git a/test/integration/certificates.test.ts b/test/integration/certificates.test.ts new file mode 100644 index 0000000..81f4d46 --- /dev/null +++ b/test/integration/certificates.test.ts @@ -0,0 +1,191 @@ +/** + * A public name, served with a certificate from an authority the mesh did not run. + * + * The mesh's own authority certifies `.internal` names and is proven elsewhere. This is the other + * half of the split: a name reachable from outside needs a certificate somebody else's browser + * already trusts, which means ordering one over ACME and answering a challenge **at the name being + * certified**. + * + * Against a real ACME server rather than a stub, for the reason the lab exists: what is under test + * is whether an order, a challenge and a handshake agree with each other, and a stub would be told + * to agree. + */ + +import { test, after, before } from "node:test"; +import assert from "node:assert/strict"; +import { loadScenario } from "../../src/declaration/parse.ts"; +import { raise } from "../../src/lifecycle/raise.ts"; +import { destroy, exec } from "../../src/lifecycle/operate.ts"; +import { labIsUsable, destroyAll } from "./harness.ts"; +import { incus } from "../../src/incus/client.ts"; +import { machineName } from "../../src/lifecycle/names.ts"; + +const capability = await labIsUsable(); +const proxy = process.env["MESH_LAB_ROUTE_PROXY"] ?? ""; +const skip = !capability.usable + ? `lab not usable: ${capability.why}` + : !proxy + ? "set MESH_LAB_ROUTE_PROXY to a built proxy (mesh-control: go build ./examples/route-proxy)" + : false; + +const SCENARIO = "a-public-name"; +const MACHINE = "anchor"; +const NAME = "photos.example"; +const ACME = "/var/lib/acme"; + +let instanceId = ""; + +function shellQuote(s: string): string { + return `'${s.replaceAll("'", `'\\''`)}'`; +} + +async function on(command: string): Promise<{ out: string; ok: boolean }> { + const { stdout } = await exec(instanceId, MACHINE, [ + "sh", "-c", `${command} 2>&1; echo "__exit=$?"`, + ]); + const marker = stdout.lastIndexOf("__exit="); + return { out: stdout.slice(0, marker), ok: Number(stdout.slice(marker + 7).trim()) === 0 }; +} + +async function must(command: string): Promise { + const { out, ok } = await on(command); + if (!ok) throw new Error(`${command}\n${out}`); + return out; +} + +before(async () => { + if (skip) return; + const scenario = loadScenario(`scenarios/${SCENARIO}.yml`); + const instance = await raise(scenario, {}); + instanceId = instance.instanceId; + + const pebble = instance.images.find((r) => r.includes("pebble")); + assert.ok(pebble, `the scenario stocked no ACME server: ${instance.images.join(", ")}`); + + await must(`mkdir -p ${ACME}/cache`); + + // The authority's own API certificate is signed by a root nothing trusts yet. Taken out of the + // image rather than disabling verification, which is the same reason the proxy names a bundle: + // "skip" would still apply on the day this points at a public authority. + await must(`docker create --name pebble-certs ${pebble}`); + await must(`docker cp pebble-certs:/test/certs/pebble.minica.pem ${ACME}/authority-api.pem`); + await must(`docker rm pebble-certs`); + + // **The challenge must arrive on port 80**, which is where a proxy serving a public name + // listens. The authority's own default is 5002 — convenient for its test suite and wrong here, + // because the thing being proven is that the real path works. + // + // **Its own configuration, with one field changed.** The first version of this wrote a config + // from scratch and silently dropped two fields the default carries; the order then came back + // valid with no certificate to fetch, and the failure looked like a client bug. Take what works + // and change the one thing that must differ. + await must(`docker create --name pebble-config ${pebble}`); + await must(`docker cp pebble-config:/test/config/pebble-config.json ${ACME}/pebble.json`); + await must(`docker rm pebble-config`); + await must( + `python3 -c "import json,sys;` + + `c=json.load(open('${ACME}/pebble.json'));` + + `c['pebble']['httpPort']=80;` + + `json.dump(c,open('${ACME}/pebble.json','w'),indent=2)"`, + ); + + // The name resolves to this machine, so the authority's challenge reaches the proxy rather than + // whatever else on the internet answers to it. + await must(`grep -q ${shellQuote(NAME)} /etc/hosts || echo "127.0.0.1 ${NAME}" >> /etc/hosts`); + + await must( + `docker run -d --name acme --network host ` + + `-v ${ACME}/pebble.json:/test/config/pebble-config.json:ro ` + + `${pebble} -config /test/config/pebble-config.json -dnsserver 127.0.0.53:53`, + ); + + let up = false; + for (let i = 0; i < 60 && !up; i++) { + ({ ok: up } = await on( + `curl -sf --cacert ${ACME}/authority-api.pem https://127.0.0.1:14000/dir -o /dev/null`, + )); + if (!up) await new Promise((r) => setTimeout(r, 1000)); + } + assert.ok(up, `the ACME server never answered:\n${(await on(`docker logs acme`)).out}`); + + await incus([ + "file", "push", proxy, + `${machineName(instanceId, MACHINE)}/usr/local/bin/mesh-route-proxy`, + "--mode", "0755", + ], 180_000); + + // Something for the route to point at, so the proxy is serving a real name and not a hole. + await must( + `printf %s ${shellQuote(JSON.stringify({ + given: [{ from: "photos", node: "", at: "", values: { name: NAME, port: 8080 } }], + }))} > ${ACME}/routes.json`, + ); + await must( + `nohup sh -c 'while true; do printf "HTTP/1.1 200 OK\\r\\nContent-Length: 5\\r\\n\\r\\nhello" | nc -l -p 8080 -q 1; done' >/dev/null 2>&1 &`, + ); +}, { timeout: 1_200_000 }); + +after(async () => { + if (instanceId) await destroy(instanceId); + await destroyAll(`${SCENARIO}-`); +}, { timeout: 600_000 }); + +test("a public name is served with a certificate the mesh did not issue", { + skip, timeout: 600_000, +}, async () => { + await must( + `ROUTES=${ACME}/routes.json LISTEN=:80 TLS_LISTEN=:443 ` + + `ACME_CACHE=${ACME}/cache ` + + `ACME_DIRECTORY=https://127.0.0.1:14000/dir ` + + `ACME_CA_BUNDLE=${ACME}/authority-api.pem ` + + `nohup /usr/local/bin/mesh-route-proxy >${ACME}/proxy.log 2>&1 & sleep 3`, + ); + + // The authority's issuing root, so the handshake can be checked rather than merely completed. + await must( + `curl -sf --cacert ${ACME}/authority-api.pem https://127.0.0.1:15000/roots/0 > ${ACME}/issuer.pem`, + ); + + // The first request is what triggers the order: autocert obtains on demand for a name its + // policy allows. Retried because ordering, the challenge and issuance take a moment. + let served = { out: "", ok: false }; + for (let i = 0; i < 40 && !served.ok; i++) { + served = await on(`curl -sf --cacert ${ACME}/issuer.pem https://${NAME}/ `); + if (!served.ok) await new Promise((r) => setTimeout(r, 2000)); + } + if (!served.ok) { + // Both sides, gathered before asserting. The proxy's log says what it tried; the authority's + // says whether it ever heard from it — and "the client never spoke to it" and "it refused + // what the client said" are different faults with nothing in common. + const proxyLog = (await on(`cat ${ACME}/proxy.log`)).out; + const authority = (await on(`docker logs acme 2>&1 | tail -40`)).out; + const directory = (await on( + `curl -s --cacert ${ACME}/authority-api.pem https://127.0.0.1:14000/dir`)).out; + assert.fail( + `the name was never served over TLS: ${served.out}\n\n` + + `── the proxy tried:\n${proxyLog}\n` + + `── the authority heard:\n${authority}\n` + + `── the directory it was pointed at:\n${directory}\n`); + } + assert.match(served.out, /hello/); + + // And it is the authority's certificate, not something self-signed that happens to work. + const issuer = await must( + `echo | openssl s_client -connect ${NAME}:443 -servername ${NAME} 2>/dev/null ` + + `| openssl x509 -noout -issuer -subject`, + ); + assert.match(issuer, /Pebble/i, `the certificate was not issued by the ACME server:\n${issuer}`); + assert.match(issuer, new RegExp(NAME), `the certificate is not for the name asked for:\n${issuer}`); +}); + +test("no certificate is ordered for a name the mesh does not route", { + skip, timeout: 300_000, +}, async () => { + // The policy that stops a quota being spent by a scan. Refused before any order is placed, so + // the authority never sees it. + const { out } = await on( + `echo | openssl s_client -connect 127.0.0.1:443 -servername nobody-asked-for-this.example 2>&1 | head -20`, + ); + assert.doesNotMatch(out, /Pebble/i, + `a certificate was obtained for a name nothing routes here:\n${out}`); +});