|
|
|
@@ -44,6 +44,22 @@ const SCENARIO = "two-nodes";
|
|
|
|
|
let instanceId = "";
|
|
|
|
|
/** The scenario's own registry, which serves the images a module may mirror. */
|
|
|
|
|
let registry = "";
|
|
|
|
|
/** What that registry actually serves, by repository. */
|
|
|
|
|
let stocked: string[] = [];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The pinned reference for one of the scenario's images.
|
|
|
|
|
*
|
|
|
|
|
* By digest, because the lab's registry drops tags when it stocks: `registry:2` is not there and
|
|
|
|
|
* asking for it fails with "not found", which reads like a missing image rather than a naming
|
|
|
|
|
* convention. A digest is also what a declaration pins, so this is the reference a module would
|
|
|
|
|
* really carry.
|
|
|
|
|
*/
|
|
|
|
|
function pinned(repository: string): string {
|
|
|
|
|
const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository);
|
|
|
|
|
assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`);
|
|
|
|
|
return found;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function quote(s: string): string {
|
|
|
|
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
|
|
@@ -104,6 +120,7 @@ before(async () => {
|
|
|
|
|
// because the digests are this registry's and are not known until it is up.
|
|
|
|
|
await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`);
|
|
|
|
|
await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`);
|
|
|
|
|
stocked = raised.images;
|
|
|
|
|
const first = raised.images[0];
|
|
|
|
|
assert.ok(first, "the scenario stocked no images, so nothing can be mirrored");
|
|
|
|
|
registry = first.slice(0, first.indexOf("/"));
|
|
|
|
@@ -407,12 +424,164 @@ test("a machine that fell behind catches up without being named", { skip, timeou
|
|
|
|
|
assert.match(await mesh("push --behind"), /every machine is doing what it was told/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The mesh running its own artifact store is proven in its own scenario, not this one.
|
|
|
|
|
//
|
|
|
|
|
// It was here, and adding the image it mirrors to this scenario made the bootstrap fail: the
|
|
|
|
|
// store container did not come up within three minutes, with no output at all from its own
|
|
|
|
|
// readiness check — which says the container was not running rather than that the database was
|
|
|
|
|
// slow. Four images on a machine this size is the difference.
|
|
|
|
|
//
|
|
|
|
|
// Left as a note rather than a silently deleted test: what it asserted is worth asserting, and
|
|
|
|
|
// where it belongs is a scenario with room for it (novox/hq 04-ISSUES/012).
|
|
|
|
|
test("the mesh runs its own artifact store", {
|
|
|
|
|
skip: skip || (!builder ? "set MESH_LAB_BUILDER to a built mesh-builder" : false),
|
|
|
|
|
timeout: 900_000,
|
|
|
|
|
}, async () => {
|
|
|
|
|
// Artifacts go to a registry, and the only registries that existed were raised by the lab or by
|
|
|
|
|
// the bootstrap bundle. A mesh had no way to run its own.
|
|
|
|
|
//
|
|
|
|
|
// Chicken and egg, resolved the way the bootstrap's is: the scenario's registry serves the image
|
|
|
|
|
// the module mirrors, and the module then runs a registry of the mesh's own.
|
|
|
|
|
await must("anchor", `mkdir -p /root/registry && printf %s '{"module":"registry","version":"1",` +
|
|
|
|
|
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
|
|
|
|
`"capabilities":["container-runtime"],` +
|
|
|
|
|
`"claims":[{"name":"the-artifact-store","scope":"node"}],` +
|
|
|
|
|
`"serves":{"artifact-store":{"port":5000}},` +
|
|
|
|
|
`"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"${pinned("registry")}"}]},` +
|
|
|
|
|
`"resources":[` +
|
|
|
|
|
`{"id":"state","type":"directory","path":"/var/lib/mesh/registry","mode":"0700"},` +
|
|
|
|
|
`{"id":"store","type":"container","name":"mesh-registry","artifact":"registry",` +
|
|
|
|
|
`"ports":["5000:5000"],"volumes":["mesh-registry-data:/var/lib/registry"]}]}' ` +
|
|
|
|
|
`> /root/registry/module.json`);
|
|
|
|
|
await must("anchor", `cd /root/registry && git init -q . && git add -A && ` +
|
|
|
|
|
`git -c user.email=lab -c user.name=lab commit -qm registry`);
|
|
|
|
|
|
|
|
|
|
await mesh("build /root/registry --wait 300s", 420_000);
|
|
|
|
|
await mesh("assign anchor registry");
|
|
|
|
|
await mesh("push anchor");
|
|
|
|
|
await new Promise((r) => setTimeout(r, 12_000));
|
|
|
|
|
|
|
|
|
|
// Running, and answering — a container that is up is not a registry that replies.
|
|
|
|
|
assert.match(await must("anchor", `docker ps --format '{{.Names}}'`), /mesh-registry/);
|
|
|
|
|
let answers = false;
|
|
|
|
|
for (let i = 0; i < 20 && !answers; i++) {
|
|
|
|
|
answers = (await on("anchor", `curl -sf http://127.0.0.1:5000/v2/ -o /dev/null`)).ok;
|
|
|
|
|
if (!answers) await new Promise((r) => setTimeout(r, 2000));
|
|
|
|
|
}
|
|
|
|
|
assert.ok(answers, "the mesh's own registry is running and does not answer");
|
|
|
|
|
|
|
|
|
|
// And reachable from another machine over the private network, which is the whole point of an
|
|
|
|
|
// artifact store being a mesh-scoped provision.
|
|
|
|
|
assert.ok((await on("laptop", `curl -sf http://anchor.internal:5000/v2/ -o /dev/null`)).ok,
|
|
|
|
|
"the artifact store is not reachable from another machine, so nothing else can use it");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a machine serves its internal name with a certificate the mesh issued", {
|
|
|
|
|
skip, timeout: 900_000,
|
|
|
|
|
}, async () => {
|
|
|
|
|
// The mesh's own authority certifies names only the mesh knows (novox/hq 08-connectivity).
|
|
|
|
|
// Asserted with a real handshake: a certificate that parses and does not chain fails at the
|
|
|
|
|
// moment something connects, which is the worst place to find out.
|
|
|
|
|
await must("anchor", `printf %s '{"module":"served","version":"1",` +
|
|
|
|
|
`"certificate":{"into":"/etc/mesh/serving.crt","authority":"/etc/mesh/authority.crt"},` +
|
|
|
|
|
`"resources":[{"id":"dir","type":"directory","path":"/etc/mesh","mode":"0755"}]}' ` +
|
|
|
|
|
`> /tmp/served.json`);
|
|
|
|
|
await must("anchor", `docker cp /tmp/served.json mesh-control:/served.json`);
|
|
|
|
|
await mesh("module add /served.json");
|
|
|
|
|
await mesh("assign anchor served");
|
|
|
|
|
await mesh("push anchor");
|
|
|
|
|
await new Promise((r) => setTimeout(r, 8000));
|
|
|
|
|
|
|
|
|
|
assert.ok((await on("anchor", `test -s /etc/mesh/serving.crt`)).ok, "no certificate arrived");
|
|
|
|
|
assert.ok((await on("anchor", `test -s /etc/mesh/authority.crt`)).ok, "no authority arrived");
|
|
|
|
|
|
|
|
|
|
// The name it was issued for is the one the mesh gave this machine.
|
|
|
|
|
const named = await must("anchor",
|
|
|
|
|
`openssl x509 -in /etc/mesh/serving.crt -noout -ext subjectAltName 2>/dev/null || ` +
|
|
|
|
|
`docker run --rm -v /etc/mesh:/m ${pinned("registry")} sh -c ` +
|
|
|
|
|
`"apk add --no-cache openssl >/dev/null 2>&1; openssl x509 -in /m/serving.crt -noout -text" | grep -A1 'Alternative'`);
|
|
|
|
|
assert.match(named, /anchor\.internal/, `the certificate is not for this machine's name:\n${named}`);
|
|
|
|
|
|
|
|
|
|
// And a real handshake: the machine serves TLS with the key it generated, and another machine
|
|
|
|
|
// verifies it against the mesh's authority and nothing else.
|
|
|
|
|
await must("anchor", `openssl s_server -cert /etc/mesh/serving.crt ` +
|
|
|
|
|
`-key /var/lib/mesh-host/serving.key -accept 8443 -naccept 1 -quiet ` +
|
|
|
|
|
`> /var/log/tls.log 2>&1 & sleep 2`);
|
|
|
|
|
await must("laptop", `mkdir -p /etc/mesh`);
|
|
|
|
|
const authority = await must("anchor", `cat /etc/mesh/authority.crt`);
|
|
|
|
|
await must("laptop", `cat > /etc/mesh/authority.crt <<'MESHCA'\n${authority}\nMESHCA`);
|
|
|
|
|
|
|
|
|
|
const shook = await on("laptop",
|
|
|
|
|
`echo | openssl s_client -connect anchor.internal:8443 ` +
|
|
|
|
|
`-CAfile /etc/mesh/authority.crt -verify_return_error -brief 2>&1`);
|
|
|
|
|
assert.ok(shook.ok, `the handshake failed:\n${shook.out}`);
|
|
|
|
|
assert.match(shook.out, /Verification: OK/, shook.out);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a machine filters exactly what its modules declared, and nothing else", {
|
|
|
|
|
skip, timeout: 900_000,
|
|
|
|
|
}, async () => {
|
|
|
|
|
// The rule set is derived from what is assigned, not kept in step by hand — and the proof that
|
|
|
|
|
// matters is not that a file arrived but that packets are treated differently because of it.
|
|
|
|
|
// A rule nothing enforces is the fault this mechanism exists to remove (novox/hq 04-ISSUES/003).
|
|
|
|
|
//
|
|
|
|
|
// Note what the module cannot contain: an action. The link may not carry one (novox/hq ADR 0005),
|
|
|
|
|
// so the mesh writes the rule set and declares that a service must reflect it. `restart-on` is
|
|
|
|
|
// the shape that rule leaves, and this is the first thing to use it for its real purpose.
|
|
|
|
|
await must("laptop", `nohup sh -c 'while true; do python3 -c "` +
|
|
|
|
|
`import socket,sys;s=socket.socket();s.setsockopt(1,2,1);s.bind((\"0.0.0.0\",9101));` +
|
|
|
|
|
`s.listen(1);c,_=s.accept();c.send(b\"declared\");c.close()"; done' ` +
|
|
|
|
|
`> /var/log/declared.log 2>&1 & sleep 2`);
|
|
|
|
|
await must("laptop", `nohup sh -c 'while true; do python3 -c "` +
|
|
|
|
|
`import socket,sys;s=socket.socket();s.setsockopt(1,2,1);s.bind((\"0.0.0.0\",9102));` +
|
|
|
|
|
`s.listen(1);c,_=s.accept();c.send(b\"undeclared\");c.close()"; done' ` +
|
|
|
|
|
`> /var/log/undeclared.log 2>&1 & sleep 2`);
|
|
|
|
|
|
|
|
|
|
// Reachable before any rule set exists, so what changes afterwards is the rule set and not the
|
|
|
|
|
// listener. Without this the test would pass against a service that never started.
|
|
|
|
|
const reach = async (port: number) =>
|
|
|
|
|
(await on("anchor", `timeout 5 python3 -c "` +
|
|
|
|
|
`import socket;s=socket.create_connection((\"192.0.2.20\",${port}),4);print(s.recv(32));s.close()"`)).ok;
|
|
|
|
|
assert.ok(await reach(9101), "the declared port never opened, so nothing below tests anything");
|
|
|
|
|
assert.ok(await reach(9102), "the undeclared port never opened");
|
|
|
|
|
|
|
|
|
|
await must("anchor", `printf %s '{"module":"talker","version":"1",` +
|
|
|
|
|
`"listens":[{"port":9101,"from":"mesh","why":"the thing this test is about"}],` +
|
|
|
|
|
`"resources":[]}' > /tmp/talker.json`);
|
|
|
|
|
// The rule set goes where this machine's nftables unit reads from, and the unit is declared to
|
|
|
|
|
// reflect it. No command anywhere.
|
|
|
|
|
await must("anchor", `printf %s '{"module":"firewall","version":"1",` +
|
|
|
|
|
`"filtering":{"into":"/etc/nftables.conf"},` +
|
|
|
|
|
`"resources":[{"id":"nftables","type":"package","package":"nftables"},` +
|
|
|
|
|
`{"id":"filter","type":"service","unit":"nftables.service","state":"running",` +
|
|
|
|
|
`"boot":"enabled","restart-on":["filtering"]}]}' > /tmp/firewall.json`);
|
|
|
|
|
for (const f of ["talker", "firewall"]) {
|
|
|
|
|
await must("anchor", `docker cp /tmp/${f}.json mesh-control:/${f}.json`);
|
|
|
|
|
await mesh(`module add /${f}.json`);
|
|
|
|
|
}
|
|
|
|
|
await mesh("assign laptop talker");
|
|
|
|
|
await mesh("assign laptop firewall");
|
|
|
|
|
await mesh("push laptop");
|
|
|
|
|
await new Promise((r) => setTimeout(r, 20_000));
|
|
|
|
|
|
|
|
|
|
const written = await must("laptop", `cat /etc/nftables.conf`);
|
|
|
|
|
// A rule names its source. Not decoration: it is the only thing that answers "why is this open".
|
|
|
|
|
assert.match(written, /# talker . the thing this test is about/,
|
|
|
|
|
`the rule does not name what caused it:\n${written}`);
|
|
|
|
|
assert.match(written, /192\.0\.2\.\d+/, `"from the mesh" resolved to nothing:\n${written}`);
|
|
|
|
|
assert.doesNotMatch(written, /dport 9102/, `a port no module declared was opened:\n${written}`);
|
|
|
|
|
|
|
|
|
|
// Loaded, not merely written. The service was restarted because a file it reflects changed.
|
|
|
|
|
const table = await must("laptop", `nft list table inet mesh`);
|
|
|
|
|
assert.match(table, /dport 9101 accept/, `the rule set was never loaded:\n${table}`);
|
|
|
|
|
|
|
|
|
|
// And it filters. The declared port answers from another machine; the undeclared one does not.
|
|
|
|
|
assert.ok(await reach(9101),
|
|
|
|
|
"the declared port is closed, so the machine is filtering more than it was told to");
|
|
|
|
|
assert.ok(!(await reach(9102)),
|
|
|
|
|
"a port no module declared is still reachable, so the rule set restricts nothing");
|
|
|
|
|
|
|
|
|
|
// The machine did not lock itself out of the mesh: it is still taking declarations.
|
|
|
|
|
assert.doesNotMatch(await mesh("status"), /laptop\s+(failed|refused)/,
|
|
|
|
|
"the machine stopped doing what it was told after applying its own rule set");
|
|
|
|
|
|
|
|
|
|
// Removing the module that wanted the port closes it, with nobody editing a rule. This is the
|
|
|
|
|
// whole claim of a derived firewall, and it is also the second load — which must replace the
|
|
|
|
|
// table rather than add to it.
|
|
|
|
|
await mesh("unassign laptop talker");
|
|
|
|
|
await mesh("push laptop");
|
|
|
|
|
await new Promise((r) => setTimeout(r, 20_000));
|
|
|
|
|
assert.ok(!(await reach(9101)),
|
|
|
|
|
"the port stayed open after the module that wanted it was removed");
|
|
|
|
|
});
|
|
|
|
|