Twenty-eight integration tests each carried their own copy of the same two helpers, which pointed a manifest and the substrate bundle at whatever the lab's registry had assigned. They now share two in the harness, and the difference is the point: ours is rewritten to the ID the machine holds it under, and everything else is left exactly as written so the machine pulls it. **The substrate bundle is where the fiction was most load-bearing.** mesh-host's `examples/substrate-first-node.lock` pins all three of its images at `192.0.2.250:5000/…`, which is the address the lab's registry served from — it was written for a target, and the target was the lab. Two of those are ordinary third-party images and become the digests mesh-catalog's own postgres and lavinmq modules pin, so the substrate's store and broker are literally the images the mesh runs. mesh-control exists in no registry at all and becomes the ID the machine was handed. **The bundle itself should be fixed in mesh-host and this substitution deleted with it.** Beds that wrote a manifest by hand named an image by repository and let the rewrite supply a digest. There is nothing to supply one now, so `onTheMachine` refuses an unpinned reference and hands back the digest the catalogue pins — a bed runs the image the mesh ships, and a bed that drifts from the catalogue is testing a different postgres. Three beds took a third-party image out of the raised list, which no longer contains one: certificates (pebble), objectstore (minio and its client) and provisioner (postgres) now name theirs and pull it. builds and mesh publish into the MESH's own artifact store — the `registry` module's image, on the node, on 5000 — rather than into scenery the lab raised. That is a different claim, and only one of them exists in production. New unit tests cover what a full raise would otherwise be the only way to check: the routes an egress machine gets (that its gateway is still the path to the rest of the scenario, that a range with no path is unreachable rather than leaked to the uplink, that each family gets its own next hop), which machine is handed which of our images, and the `images:` rule that refuses a third-party entry. The "shipped scenarios are valid" test now loads every scenario rather than two of them. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
199 lines
8.4 KiB
TypeScript
199 lines
8.4 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-control: 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";
|
|
|
|
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`,
|
|
);
|
|
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}`);
|
|
});
|