A public name, against a real ACME server
The other half of the certificate split: the mesh's own authority certifies internal names, and a name reachable from outside needs one the world already trusts. Against a real server rather than a stub, because what is under test is whether an order, a challenge and a handshake agree, and a stub would be told to agree. One assertion passes and one fails, and the failure is filed as novox/hq 04-ISSUES/020: the authority issues a certificate and the client never collects it. Kept as a failing test rather than deleted or skipped — it is the reproduction, and it proves everything up to the last hop. The passing one is the guard that matters day to day: no certificate is ordered for a name the mesh does not route, so a scan cannot spend an account's rate limit. The failure output gathers both sides before asserting. The first version reported only what the proxy said, which made a server-side question unanswerable — "the client never spoke to it" and "it refused what the client said" are different faults with nothing in common.
This commit is contained in:
@@ -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<string> {
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user