Every request through the proxy was refused by the backend, which read as the certificate never arriving and hid behind the authority's refusal — until the second authority issued one and the request behind the handshake still failed.
269 lines
12 KiB
TypeScript
269 lines
12 KiB
TypeScript
/**
|
|
* 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-controller: go build ./examples/route-proxy)"
|
|
: false;
|
|
|
|
const SCENARIO = "a-public-name";
|
|
const MACHINE = "anchor";
|
|
const NAME = "photos.example";
|
|
const ACME = "/var/lib/acme";
|
|
/**
|
|
* The ACME server under test, pulled by the machine over its uplink.
|
|
*
|
|
* It used to be served from a registry the lab raised inside the scenario. Nothing outside the lab
|
|
* has one, so an image only reachable there was a fiction — and this test is about a certificate
|
|
* being obtained over a real path.
|
|
*/
|
|
const AUTHORITY = "ghcr.io/letsencrypt/pebble:2.5.0";
|
|
|
|
/**
|
|
* The second implementation, for the same order (novox/hq 04-ISSUES/020): the certificate
|
|
* authority the catalogue itself runs, pinned as the catalogue pins it. If the order, the challenge
|
|
* and the handshake agree here as well as against Pebble, the one thing 020 could not rule out — a
|
|
* Pebble interop detail — is ruled out; and if they disagree, which side differs is in view.
|
|
*/
|
|
const SECOND_AUTHORITY = "smallstep/step-ca@sha256:a2b17872915c193259b75a5474c398326f41bd199f0842093e52cf4182bc8270";
|
|
|
|
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 = AUTHORITY;
|
|
|
|
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`,
|
|
);
|
|
// A real small server, not a netcat loop: the loop's `nc -l -p … -q` is not this machine's netcat,
|
|
// so it never listened, and every request through the proxy was refused by the backend — which
|
|
// read as the certificate never arriving, and hid behind the authority's refusal until the
|
|
// second authority issued one.
|
|
await must(`mkdir -p ${ACME}/www && printf hello > ${ACME}/www/index.html`);
|
|
await must(`nohup python3 -m http.server 8080 --bind 127.0.0.1 --directory ${ACME}/www >${ACME}/backend.log 2>&1 &`);
|
|
let backend = false;
|
|
for (let i = 0; i < 20 && !backend; i++) {
|
|
({ ok: backend } = await on(`curl -sf http://127.0.0.1:8080/ -o /dev/null`));
|
|
if (!backend) await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
assert.ok(backend, `the backend behind the route never answered:\n${(await on(`cat ${ACME}/backend.log`)).out}`);
|
|
}, { 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("the same order against a second authority: the catalogue's own certificate authority", {
|
|
skip, timeout: 900_000,
|
|
}, async () => {
|
|
// The proxy that served the first test goes; its cache with it, or the certificate Pebble issued
|
|
// would be served again and nothing would have been ordered here.
|
|
await must(`pkill -f '^/usr/local/bin/mesh-route-proxy' || true; sleep 1; mkdir -p ${ACME}/cache2`);
|
|
|
|
// The catalogue's authority, as the catalogue runs it: ACME on, listening on its own port, a
|
|
// root and an intermediate made at first start. It resolves the name through the machine's
|
|
// resolver, which reads the hosts entry the first test wrote.
|
|
await must(
|
|
`docker run -d --name stepca --network host ` +
|
|
`-e DOCKER_STEPCA_INIT_NAME="Lab CA" -e DOCKER_STEPCA_INIT_DNS_NAMES=localhost,127.0.0.1 ` +
|
|
`-e DOCKER_STEPCA_INIT_ACME=true -e DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=false ` +
|
|
`-e DOCKER_STEPCA_INIT_PASSWORD=lab-only-password ${SECOND_AUTHORITY}`,
|
|
);
|
|
let ready = false;
|
|
for (let i = 0; i < 90 && !ready; i++) {
|
|
({ ok: ready } = await on(`docker exec stepca test -s /home/step/certs/root_ca.crt && curl -sk https://127.0.0.1:9000/health -o /dev/null`));
|
|
if (!ready) await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
assert.ok(ready, `the second authority never came up:\n${(await on(`docker logs stepca 2>&1 | tail -30`)).out}`);
|
|
await must(`docker exec stepca cat /home/step/certs/root_ca.crt > ${ACME}/stepca-root.pem`);
|
|
|
|
await must(
|
|
`ROUTES=${ACME}/routes.json LISTEN=:80 TLS_LISTEN=:443 ` +
|
|
`ACME_CACHE=${ACME}/cache2 ` +
|
|
`ACME_DIRECTORY=https://127.0.0.1:9000/acme/acme/directory ` +
|
|
`ACME_CA_BUNDLE=${ACME}/stepca-root.pem ` +
|
|
`nohup /usr/local/bin/mesh-route-proxy >${ACME}/proxy2.log 2>&1 & sleep 3`,
|
|
);
|
|
|
|
let served = { out: "", ok: false };
|
|
for (let i = 0; i < 40 && !served.ok; i++) {
|
|
served = await on(`curl -sf --cacert ${ACME}/stepca-root.pem https://${NAME}/ `);
|
|
if (!served.ok) await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
if (!served.ok) {
|
|
const proxyLog = (await on(`cat ${ACME}/proxy2.log`)).out;
|
|
const authority = (await on(`docker logs stepca 2>&1 | tail -40`)).out;
|
|
assert.fail(
|
|
`the name was never served over TLS from the second authority: ${served.out}\n\n` +
|
|
`── the proxy tried:\n${proxyLog}\n── the authority heard:\n${authority}\n`);
|
|
}
|
|
assert.match(served.out, /hello/);
|
|
const issuer = await must(
|
|
`echo | openssl s_client -connect ${NAME}:443 -servername ${NAME} 2>/dev/null ` +
|
|
`| openssl x509 -noout -issuer -subject`,
|
|
);
|
|
assert.match(issuer, /Lab CA/, `the certificate was not issued by the second authority:\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|Lab CA/i,
|
|
`a certificate was obtained for a name nothing routes here:\n${out}`);
|
|
});
|