Routers: NAT, port forwarding, policy and mapping expiry
A gateway is the one implicit machine in a declaration — a scenario says a segment sits behind one and never names the thing that serves it. This materialises it. A router is a container, not a virtual machine, because it is scenery rather than something under test (hq ADR 0033). Verified before building that a plain unprivileged container can do all of it: ip_forward and ipv6 forwarding settable, nftables masquerade accepted, and the conntrack timeouts mapping_ttl depends on both writable. No privileged mode. Verified by running, on a machine behind a household gateway reached from one on a routable address: home-server -> anchor 0% loss, through masquerade anchor -> 192.168.1.135 (private, direct) unreachable anchor -> 192.0.2.50:8080 (the GATEWAY) HTTP 200 The last line is the published-but-behind-NAT case research 004 says only exists in production. It is now a 32-second scenario on a workstation. Segments sharing a gateway declaration share ONE router — that is what a VLAN-capable router is, and two routers sharing an external address would not work anyway. mapping_ttl is read back after setting rather than assumed. Those sysctls are not on every kernel, and a scenario that declared an expiring mapping and silently got a permanent one would be exactly the fault being built against. Four bugs found by running it, three of them the same fault — a failure made invisible. The router had no route to a package repository, by design, so installing nftables at raise time could not work. The image is now built once with temporary connectivity and cached; every scenario after that needs no network. That failure was hidden behind `|| true`, which is why it took a raise to find. The builder then failed on DNS: exec works before a container has an address, and I had treated usable as ready. It now waits for the thing actually needed. The stock Alpine image ships `auto eth0 / inet dhcp` and its boot-time networking service flushed the static address the scenario set — on eth0 only, so the outside interface came up bare while inside ones were fine. The image build now neutralises it: a router reconfiguring itself from an image default is the lab overriding the declaration. `ip addr add … || true` had hidden this too, and is now `ip addr replace` with no swallow. And routers were orphaned by destroy, holding their networks open so destroy reported removing zero segments. They now carry the same machine tag as everything else, so one query finds an instance's resources.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseScenario } from "../src/declaration/parse.ts";
|
||||
import { planRouters, ttlSeconds } from "../src/lifecycle/router.ts";
|
||||
|
||||
test("segments sharing a gateway declaration share ONE router", () => {
|
||||
// That is what a VLAN-capable router is, and two routers sharing an external address
|
||||
// would not work anyway.
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments:
|
||||
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||
iot: { kind: private, cidr: [192.168.30.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`);
|
||||
const plans = planRouters(scenario, "inst");
|
||||
assert.equal(plans.length, 1, "one gateway declaration, one router");
|
||||
assert.deepEqual(plans[0]?.inside.sort(), ["home", "iot"]);
|
||||
});
|
||||
|
||||
test("different external addresses mean different routers", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments:
|
||||
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||
other: { kind: private, cidr: [192.168.30.0/24], gateway: { to: pub, address: [192.0.2.6], nat: [v4] } }
|
||||
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`);
|
||||
assert.equal(planRouters(scenario, "inst").length, 2);
|
||||
});
|
||||
|
||||
test("a scenario with no gateways needs no routers", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }`);
|
||||
assert.equal(planRouters(scenario, "inst").length, 0);
|
||||
});
|
||||
|
||||
test("forwardable and nat carry through to the plan", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments:
|
||||
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.9], nat: [v4], forwardable: false, mapping_ttl: 30s } }
|
||||
machines: { a: { at: { segment: cafe, address: [10.50.0.9] } } }`);
|
||||
const plan = planRouters(scenario, "inst")[0];
|
||||
assert.equal(plan?.forwardable, false);
|
||||
assert.deepEqual(plan?.nat, ["v4"]);
|
||||
assert.equal(plan?.mappingTtl, "30s");
|
||||
});
|
||||
|
||||
test("mapping ttl parses the forms a declaration uses", () => {
|
||||
assert.equal(ttlSeconds("30s"), 30);
|
||||
assert.equal(ttlSeconds("120s"), 120);
|
||||
assert.equal(ttlSeconds("2m"), 120);
|
||||
assert.equal(ttlSeconds("1h"), 3600);
|
||||
assert.equal(ttlSeconds("90"), 90);
|
||||
assert.equal(ttlSeconds(undefined), undefined);
|
||||
assert.equal(ttlSeconds("soon"), undefined);
|
||||
});
|
||||
+34
-19
@@ -5,8 +5,9 @@ import { assertSupported, UnsupportedError } from "../src/lifecycle/supported.ts
|
||||
|
||||
/**
|
||||
* A declaration the runtime silently ignores is the fault this lab exists to catch —
|
||||
* novox/hq 04-ISSUES/003, where a firewall key is declared in five manifests and read by
|
||||
* no code. These tests exist so the lab never commits it.
|
||||
* novox/hq 04-ISSUES/003, where a firewall key is declared in five manifests and read by no
|
||||
* code. These tests exist so the lab never commits it, and they move as the runtime catches
|
||||
* up with the model.
|
||||
*/
|
||||
|
||||
const withGateway = `scenario: x
|
||||
@@ -15,49 +16,63 @@ segments:
|
||||
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`;
|
||||
|
||||
test("a scenario with no unimplemented features is raisable", () => {
|
||||
test("a plain scenario is raisable", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }`);
|
||||
assert.doesNotThrow(() => assertSupported(scenario));
|
||||
});
|
||||
|
||||
test("a declared gateway is refused rather than silently absent", () => {
|
||||
assert.throws(() => assertSupported(parseScenario(withGateway)), UnsupportedError);
|
||||
test("gateways are implemented — a router is materialised for them", () => {
|
||||
assert.doesNotThrow(() => assertSupported(parseScenario(withGateway)));
|
||||
});
|
||||
|
||||
test("the refusal names every missing capability, not just the first", () => {
|
||||
test("published ports and policy are implemented", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments:
|
||||
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||
policy: [{ from: home, to: pub, allow: false }]
|
||||
iot: { kind: private, cidr: [192.168.30.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||
policy: [{ from: iot, to: home, allow: false }]
|
||||
machines:
|
||||
a:
|
||||
at: { segment: home, address: [192.168.1.9] }
|
||||
published: [{ port: 443, on: home }]
|
||||
inbound: deny
|
||||
published: [{ port: 443, on: home }]`);
|
||||
assert.doesNotThrow(() => assertSupported(scenario));
|
||||
});
|
||||
|
||||
test("inbound: deny is still refused rather than silently absent", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: deny } }`);
|
||||
assert.throws(() => assertSupported(scenario), UnsupportedError);
|
||||
});
|
||||
|
||||
test("place is still refused — there is nothing to place yet", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }
|
||||
place: { all: [host] }`);
|
||||
assert.throws(() => assertSupported(scenario), UnsupportedError);
|
||||
});
|
||||
|
||||
test("the refusal names every gap, not just the first", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: deny } }
|
||||
place: { all: [host] }`);
|
||||
try {
|
||||
assertSupported(scenario);
|
||||
assert.fail("should have refused");
|
||||
} catch (err) {
|
||||
const missing = (err as UnsupportedError).missing;
|
||||
assert.ok(missing.length >= 5, `expected every gap named, got ${missing.length}`);
|
||||
assert.equal((err as UnsupportedError).missing.length, 2);
|
||||
assert.match(err instanceof Error ? err.message : "", /silently lacks them/);
|
||||
}
|
||||
});
|
||||
|
||||
test("inbound: allow is not a missing capability — only deny needs enforcing", () => {
|
||||
test("inbound: allow is not a gap — only deny needs enforcing", () => {
|
||||
const scenario = parseScenario(`scenario: x
|
||||
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: allow } }`);
|
||||
assert.doesNotThrow(() => assertSupported(scenario));
|
||||
});
|
||||
|
||||
test("validation and raisability are different questions", () => {
|
||||
// The declaration model is complete; the runtime is not. A scenario may be valid and
|
||||
// still not raisable, and conflating the two would hide the gap.
|
||||
assert.doesNotThrow(() => parseScenario(withGateway), "should validate");
|
||||
assert.throws(() => assertSupported(parseScenario(withGateway)), "should not raise");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user