Author SHA1 Message Date
jschoubben a1c9fdbc46 The two-node bed stocks the packet filter's seat runtime
The filter module now serves its verbs from a runtime the mesh builds
(novox/hq ADR 0170), and a bed registered its raw manifest, which the
mesh refuses as unbuilt. The bed stocks mesh-runtime-nftables and the
filter helper registers the module through the stocked image.
2026-10-02 18:08:04 +02:00
jschoubben 1e5b509129 The two-node bed waits for the anchor's first apply before its filter 2026-10-02 16:46:16 +02:00
jschoubben 7914fd74c6 The two-node bed joins its second machine through the tunnel
A token carries the bus's address, and at genesis that is the anchor's
loopback, which no other machine reaches. The anchor joins locally and
becomes the hub; the laptop makes its tunnel key, is issued a token for
it and joins over the tunnel (novox/hq ADR 0169, issue 146).
2026-10-02 16:41:00 +02:00
jschoubben 91ba825975 The two-node bed places the bus's users as genesis must
This bed raises genesis by hand, so it places the composed user list
after each token and each enrolment, as the trust bed does (novox/hq
issue 146); without it the first join was refused.
2026-10-02 16:29:21 +02:00
jschoubben 1a4f570e54 The two-node bed proves a machine joins through the tunnel with the bus closed to it
A third machine makes its tunnel key, is issued a token for it, and
enrols while the anchor drops its packets to the bus at the first hook;
it can only have arrived over the tunnel (novox/hq ADR 0169).
2026-10-02 15:11:18 +02:00
3 changed files with 95 additions and 10 deletions
+10
View File
@@ -28,8 +28,18 @@ machines:
egress: true
inbound: allow
memory: 2GiB
# A third machine that joins through the tunnel with the bus closed to it (novox/hq ADR 0169). It
# carries none of the images: it only has to join.
joiner:
at: { segment: hosting, address: [192.0.2.30] }
egress: true
inbound: allow
images: []
images:
# The packet filter's seat runtime (novox/hq ADR 0170): the filter module now serves its verbs from
# a runtime the mesh builds, so a bed that installs the filter stocks it.
- mesh-runtime-nftables:development
- mesh-controller:development
# And the builder, because it is a module the mesh assigns rather than a program somebody
# starts by hand — which is the only way its credential can be one the mesh delivered.
+5 -1
View File
@@ -376,11 +376,15 @@ function shellQuote(s: string): string {
*/
export async function deriveTheFilterOn(o: {
machine: string; node: string; hubPort: number;
/** The images the machines hold. Given, the filter module's runtime — the packet-filter seat's,
* which the mesh would build (novox/hq ADR 0170) — is the stocked one, as for any catalogue
* module a bed installs; the scenario must then stock `mesh-runtime-nftables:development`. */
held?: HeldImage[];
must: (machine: string, command: string, timeoutMs?: number) => Promise<string>;
mesh: (command: string, timeoutMs?: number) => Promise<string>;
on: (machine: string, command: string, timeoutMs?: number) => Promise<{ out: string; ok: boolean }>;
}): Promise<string> {
const manifest = readFileSync(catalogueManifest(FILTER_MODULE), "utf8");
const manifest = o.held ? catalogueModule(FILTER_MODULE, o.held) : readFileSync(catalogueManifest(FILTER_MODULE), "utf8");
await o.must(o.machine,
`printf %s ${shellQuote(manifest)} > /tmp/${FILTER_MODULE}.json && docker cp /tmp/${FILTER_MODULE}.json mesh-controller:/${FILTER_MODULE}.json`);
await o.mesh(`module add /${FILTER_MODULE}.json`);
+80 -9
View File
@@ -193,6 +193,20 @@ function bundleFor(images: HeldImage[]): string {
return foundationBundle(bundle, images);
}
/**
* Put the mesh's composed user list where the anchor's bus reads it, and make it re-read.
*
* **Genesis's step, done by hand because this bed raises genesis by hand** (novox/hq 04-ISSUES/146).
* The bus here is the bundle's, not a module the mesh delivers, so an account the mesh composes —
* the enrolment when a token is issued, the node's own when it enrols — reaches it only if whoever
* raised it places it. Without this the first join is refused with an authorisation violation.
*/
async function composeTheBusUsers(): Promise<void> {
await must("anchor",
`docker exec mesh-controller /mesh-controller broker accounts > /var/lib/mesh-bus-conf/accounts.conf && ` +
`docker kill -s HUP mesh-broker >/dev/null`);
}
/** Take a token out of what `token issue` printed. It is the one base64url blob on its own line. */
function tokenFrom(said: string): string {
const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" "));
@@ -308,15 +322,44 @@ test("a bare machine becomes a mesh", { skip, timeout: 600_000 }, async () => {
});
test("both machines join it, and the token is all they need", { skip, timeout: 900_000 }, async () => {
for (const [machine, node] of [["anchor", "anchor"], ["laptop", "laptop"]] as const) {
await mesh(`node add ${node}`);
const token = tokenFrom(await mesh(`token issue --node ${node}`));
// No --name. The token says what the mesh calls the machine, which is the fault this walk
// found the first time it was run.
const said = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`);
assert.match(said, new RegExp(`enrolled as ${node}`), said);
assert.match(said, /sealing key/, "no sealing key was generated");
// **The anchor runs the bus, so it joins over its own loopback.** Every other machine joins
// through the tunnel (novox/hq ADR 0169): the anchor is the hub, and its tunnel comes up first.
await mesh(`node add anchor`);
const first = tokenFrom(await mesh(`token issue --node anchor`));
await composeTheBusUsers();
// No --name. The token says what the mesh calls the machine, which is the fault this walk
// found the first time it was run.
const anchorSaid = await must("anchor", `${HOST_PATH} enrol --token ${quote(first)}`);
await composeTheBusUsers();
assert.match(anchorSaid, /enrolled as anchor/, anchorSaid);
assert.match(anchorSaid, /sealing key/, "no sealing key was generated");
await mesh("overlay place anchor --hub --endpoint 192.0.2.10:51820 --site lab");
await mesh("assign anchor networking");
await must("anchor", `pgrep -x mesh-host >/dev/null || (nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3)`);
await mesh("push anchor");
// The host reports which of its links face outside with every apply, and the filter is not
// composed for a machine that has not — so its first apply is waited for, not slept past.
const appliedBy = Date.now() + 180_000;
while (!(await on("anchor", `grep -q 'applied [0-9]' /var/log/mesh-host.log`)).ok) {
if (Date.now() > appliedBy) assert.fail(`the anchor never applied what it was sent:\n${(await on("anchor", "tail -30 /var/log/mesh-host.log")).out}`);
await new Promise((r) => setTimeout(r, 3000));
}
await new Promise((r) => setTimeout(r, 5000));
await deriveTheFilterOn({ machine: "anchor", node: "anchor", hubPort: 51820, held, must, mesh, on });
// **The laptop makes its tunnel key, and the token is issued for it.** The hub is told the key
// before the token is shown, so the tunnel answers the first time the laptop knocks.
await must("laptop", `pacman -Sy --noconfirm --needed wireguard-tools >/dev/null 2>&1`);
const key = (await must("laptop", `${HOST_PATH} key 2>/dev/null`)).trim();
await mesh(`node add laptop`);
const second = tokenFrom(await mesh(`token issue --node laptop --overlay-key ${key}`));
await composeTheBusUsers();
const laptopSaid = await must("laptop", `${HOST_PATH} enrol --token ${quote(second)}`);
await composeTheBusUsers();
assert.match(laptopSaid, /the tunnel to the hub is up/, laptopSaid);
assert.match(laptopSaid, /enrolled as laptop/, laptopSaid);
assert.match(laptopSaid, /sealing key/, "no sealing key was generated");
const recorded = await must("anchor",
`docker exec mesh-store psql -U postgres -d inventory -qAt ` +
@@ -366,7 +409,7 @@ test("a credential reaches both ends and the mesh holds neither", { skip, timeou
// The anchor's derived filter, admitting the hub's port — what genesis does on the control-node,
// and what a bed raised from the bundle must do itself (ADR 0088). Until it is, the base filter
// keeps the hub closed and nothing on the laptop reaches anchor over the private network.
await deriveTheFilterOn({ machine: "anchor", node: "anchor", hubPort: 51820, must, mesh, on });
await deriveTheFilterOn({ machine: "anchor", node: "anchor", hubPort: 51820, held, must, mesh, on });
const onConsumer = (await must("laptop", `cat /etc/meshboard/database.password`)).trim();
// Named after the machine *and* the module, because a consumer is both (novox/hq
@@ -1662,3 +1705,31 @@ test("a third-party workload is adopted, with the credential it already had", {
// a gap that bed should close; "a consumer's cache grant means exactly its own keys" — its tenancy
// assertions (a write outside the consumer's keys and a FLUSHALL are refused) moved into
// mesh-grant-end-to-end, against the catalogue's redis.
// **A machine joins through the tunnel, and needs the bus only through it** (novox/hq ADR 0169).
// The bus is closed to this machine at the anchor's very first hook, before the container runtime's
// forwarding, so the only way its enrolment can arrive is over the tunnel the token gave it.
test("a machine joins through the tunnel, with the bus closed to it", { skip, timeout: 900_000 }, async () => {
await must("anchor", `nft add table ip lab_bus_closed && ` +
`nft add chain ip lab_bus_closed pre '{ type filter hook prerouting priority -300; }' && ` +
`nft add rule ip lab_bus_closed pre ip saddr 192.0.2.30 tcp dport 4222 drop`);
try {
await must("joiner", `pacman -Sy --noconfirm --needed wireguard-tools >/dev/null 2>&1`);
const key = (await must("joiner", `${HOST_PATH} key 2>/dev/null`)).trim();
assert.match(key, /^[A-Za-z0-9+/]{43}=$/, `not a tunnel key: ${key}`);
// Asked again, the same key: a token may already have been issued for it.
assert.equal((await must("joiner", `${HOST_PATH} key 2>/dev/null`)).trim(), key);
const token = tokenFrom(await mesh(`token issue --new joiner --overlay-key ${key}`));
await composeTheBusUsers();
const said = await must("joiner", `${HOST_PATH} enrol --token ${quote(token)}`);
assert.match(said, /the tunnel to the hub is up/, said);
assert.match(said, /enrolled as joiner/, said);
const shakes = await must("joiner", `wg show mesh0 latest-handshakes`);
assert.ok(/\s[1-9]\d*\s*$/m.test(shakes), `the tunnel never shook hands with the hub: ${shakes}`);
} finally {
await on("anchor", `nft delete table ip lab_bus_closed`);
}
});