@celilo/cli 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +3 -1
- package/package.json +1 -1
- package/schemas/system_config.json +10 -5
- package/src/cli/commands/system-apply-config-equivalence.test.ts +46 -8
- package/src/cli/commands/system-apply-config.test.ts +57 -1
- package/src/cli/commands/system-apply-config.ts +43 -0
- package/src/cli/commands/system-discover-network.ts +35 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +9 -0
- package/src/hooks/capability-loader.ts +33 -17
- package/src/manifest/network-requirement-schema.test.ts +141 -0
- package/src/manifest/schema.ts +124 -0
- package/src/services/module-deploy.ts +31 -3
- package/src/services/network-discovery.test.ts +198 -0
- package/src/services/network-discovery.ts +164 -0
- package/src/services/network-ensure.test.ts +324 -0
- package/src/services/network-ensure.ts +260 -0
- package/src/services/system-init.ts +14 -3
- package/src/test-utils/bus-responder.ts +14 -1
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -23,11 +23,11 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
23
23
|
|
|
24
24
|
- **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS + DHCP address-pool bounds via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**. Two device behaviours shape this driver and are not shared by **greenwave** (celilo#739): the router **regenerates** `Pool.1.DNSServers` from its own upstream resolver list every ~15 min and on any config commit, so `setDhcpDns` also writes `Device.DNS.X_AXON_CustomServer` — the *input* to that computation, which survives — and **technitium re-asserts** the pool value on `timer.tick.1m`. Optional `dhcp_pool_start`/`dhcp_pool_end` bound the leased range; set them below the addresses IPAM allocates, because **IPAM has no knowledge of the router's DHCP pool** and nothing otherwise stops the router leasing an address celilo already assigned.
|
|
25
25
|
- **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
|
|
26
|
-
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
|
|
26
|
+
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **REQUIRES the networks it has legs on and defines none of them** (`requires.networks: [{from: $self:zones}]`) — the required set is resolved from the same `zones` value that says which NICs the box has, so the legs and the declared networks are ONE list. They used to be two: `zones` and a `provided_networks` array this module wrote into system config from its install hook, with nothing forcing them to agree. A leg whose network has no declared subnet classifies `alien`, and on a firewall with a recorded baseline an alien interface is DISABLED — so under-declaring was never cosmetic. `firewall-interface-classification` §3/D8 reached that conclusion for the test harness; `networks-are-declared-not-written` applies it in production and deletes `provided_networks` outright (breaking: iptables 3.0.0). celilo supplies each range, asking the operator when it holds none. **provides:** `firewall`.
|
|
27
27
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
28
28
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
29
29
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
|
|
30
|
-
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **
|
|
30
|
+
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
31
31
|
|
|
32
32
|
## Public edge (ingress / identity)
|
|
33
33
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -27,7 +27,9 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
27
27
|
- **Infrastructure selection (container-service vs machine pool)** — `apps/celilo/src/services/machine-pool.ts` (`getMachineByHostname`, `addMachine`, `assignModuleToMachine`) and `apps/celilo/src/services/container-service.ts` (`getContainerServiceByName`, `addContainerService`, `verifyContainerService`). Provider API clients: `apps/celilo/src/api-clients/proxmox.ts`, `apps/celilo/src/api-clients/digitalocean.ts`.
|
|
28
28
|
- **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table and returns `NetworkZone | 'unknown'`. It answers CONTAINMENT ONLY. It used to return `'external'` on no-match, which conflated "no declared subnet contains this" with "the internet can route to this" — on a firewall with five RFC1918 legs that reported four of them as facing the internet. `'unknown'` is the honest answer; the caller resolves it (see `machine add`: publicly routable → `external`, otherwise fail asking for `--zone`). The subnet-backed zone list is derived from `NETWORK_ZONES` minus `external`, which has no subnet and must never be given one.
|
|
29
29
|
- **Interface classification** — `packages/capabilities/src/interface-classification.ts` — THE shared classifier, used by the backend and every firewall provider module so the two cannot drift apart again. `isPubliclyRoutable(ip)` is a property of the address alone (false for RFC 1918, RFC 6598 carrier-grade NAT, loopback, link-local, multicast, reserved). `classifyInterfaces(interfaces, zones)` assigns each interface `zone → external → alien`, first match winning, where `external` is the RESIDUAL — routable and claimed by no declared zone — and is never subnet-matched. `externalEdge()` returns none/single/**ambiguous** rather than silently picking the first public address. `defaultRouteFinding()` enforces the invariant that the default route leaves through `internal` or `external`. **`subnetContains(cidr, ip)` lives here and is the ONLY implementation** — three existed and disagreed (the backend's mishandled `/0`); the other two are deleted, not aliased. Design: `openspec/changes/firewall-interface-classification/design.md`.
|
|
30
|
-
- **Declared networks (classification input)** — `readDeclaredNetworks(db)` in `apps/celilo/src/hooks/capability-loader.ts` — every `network.<name>.subnet` in system config, which is what an interface is attributed against. Read from the CONFIG, not from `NETWORK_ZONES`: celilo
|
|
30
|
+
- **Declared networks (classification input)** — `readDeclaredNetworks(db)` in `apps/celilo/src/hooks/capability-loader.ts` — every `network.<name>.subnet` in system config, which is what an interface is attributed against. Read from the CONFIG, not from `NETWORK_ZONES`: celilo holds networks that are not placement zones (`network.control-plane-vpn.subnet`, which `wireguard` requires and reads). Injected into the firewall capability as a LIVE reader (`declaredNetworks`) — that liveness was a mitigation for values written mid-run by a module hook, which `network-declaration` removes; see that spec before assuming a snapshot is still unsafe.
|
|
31
|
+
- **Network requirement + ensure (celilo owns the namespace)** — `apps/celilo/src/services/network-ensure.ts` (`ensureRequiredNetworks`), `NetworkRequirementSchema` / `getRequiredNetworkNames` in `apps/celilo/src/manifest/schema.ts`. A module declares `requires.networks: [{name}]` — a NAME, never a value; the schema is `.strict()` so a `subnet:` on the requirement is rejected with a message saying why. The deploy calls `ensureRequiredNetworks` in its interview phase, BEFORE generation and before any hook, and asks over the generic bus interview (`askText`, so it is answerable headless) for anything undefined. Which attributes a network has is celilo's answer, taken from `apps/celilo/schemas/system_config.json`: that file declares `network.<n>.gateway` for the routed segments and omits it for `control-plane-vpn`, so a gateway is never asked for a network that has none. Well-known names carry a `suggested` range there — deliberately NOT `default`, which `getDefaultConfiguration()` would seed at `system init`. Spec: `openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md`.
|
|
32
|
+
- **The network write path (closed) + celilo's own discovery** — `celilo system apply-config` (`apps/celilo/src/cli/commands/system-apply-config.ts`) REFUSES the whole `network.` namespace, `network.bridge` excepted (a Proxmox bridge name is not addressing, and it is the one network key with a schema default). That is the automation surface a module hook shells out to, so closing it there is what makes "networks are celilo's" an authority rather than a convention every module has to remember. The refusal names the alternative — declare the network, read it with `$system:` — because a bare rejection sends a module author hunting for a typo. The one write that legitimately needed the surface moved INTO celilo: `celilo system discover-network` (`apps/celilo/src/services/network-discovery.ts`, `cli/commands/system-discover-network.ts`) parses the box's own `ip route` and records `network.internal.*` — or `network.secure-mgmt.*` when the box is off the internal LAN (#300). `celilo-mgmt` calls it and decides nothing; it used to parse and write this itself. Idempotent and never overwrites addressing already set. Recurrence gate: `test-integration/module/no-module-writes-networks.test.ts`.
|
|
31
33
|
- **Firewall interface audit** — `apps/celilo/src/services/audit/interface-classification.ts` — reports per-firewall classification in `celilo audit`: alien interfaces by name and address (drift), and the blocking findings a converge refuses on — a carrier-grade NAT leg, an ambiguous external edge, a default route on the wrong leg.
|
|
32
34
|
- **Zone taxonomy (canonical list)** — `apps/celilo/src/db/schema.ts` — `NETWORK_ZONES` is the single array; `NetworkZone` is DERIVED from it. Never hand-maintain a second copy: a duplicate that dropped a member made zone validation return null and silently fall back to a wrong-but-valid zone.
|
|
33
35
|
- **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
|
package/package.json
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"network.dmz.subnet": {
|
|
14
14
|
"type": "string",
|
|
15
15
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
|
|
16
|
-
"description": "DMZ subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone; see openspec/specs/progressive-zone-disclosure/spec.md)"
|
|
16
|
+
"description": "DMZ subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone; see openspec/specs/progressive-zone-disclosure/spec.md)",
|
|
17
|
+
"suggested": "10.0.10.0/24"
|
|
17
18
|
},
|
|
18
19
|
"network.dmz.gateway": {
|
|
19
20
|
"type": "string",
|
|
@@ -29,7 +30,8 @@
|
|
|
29
30
|
"network.app.subnet": {
|
|
30
31
|
"type": "string",
|
|
31
32
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
|
|
32
|
-
"description": "App subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)"
|
|
33
|
+
"description": "App subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)",
|
|
34
|
+
"suggested": "10.0.20.0/24"
|
|
33
35
|
},
|
|
34
36
|
"network.app.gateway": {
|
|
35
37
|
"type": "string",
|
|
@@ -45,7 +47,8 @@
|
|
|
45
47
|
"network.secure.subnet": {
|
|
46
48
|
"type": "string",
|
|
47
49
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
|
|
48
|
-
"description": "Secure subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)"
|
|
50
|
+
"description": "Secure subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)",
|
|
51
|
+
"suggested": "10.0.30.0/24"
|
|
49
52
|
},
|
|
50
53
|
"network.secure.gateway": {
|
|
51
54
|
"type": "string",
|
|
@@ -77,12 +80,14 @@
|
|
|
77
80
|
"network.control-plane-vpn.subnet": {
|
|
78
81
|
"type": "string",
|
|
79
82
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
|
|
80
|
-
"description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon \u2192 in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)"
|
|
83
|
+
"description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon \u2192 in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)",
|
|
84
|
+
"suggested": "10.255.255.0/24"
|
|
81
85
|
},
|
|
82
86
|
"network.secure-mgmt.subnet": {
|
|
83
87
|
"type": "string",
|
|
84
88
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
|
|
85
|
-
"description": "Control-plane subnet CIDR \u2014 the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted \u2014 discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic."
|
|
89
|
+
"description": "Control-plane subnet CIDR \u2014 the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted \u2014 discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic.",
|
|
90
|
+
"suggested": "10.0.120.0/24"
|
|
86
91
|
},
|
|
87
92
|
"network.secure-mgmt.gateway": {
|
|
88
93
|
"type": "string",
|
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
* Catches the bug class where a future change to system-init (e.g.
|
|
11
11
|
* adding a side-effect or gateway computation) drifts away from
|
|
12
12
|
* what apply-config writes.
|
|
13
|
+
*
|
|
14
|
+
* ── One deliberate DIVERGENCE, added by `network-declaration` ──
|
|
15
|
+
*
|
|
16
|
+
* The two surfaces are no longer equivalent for `network.*` keys, and that is
|
|
17
|
+
* the point rather than drift. `system init` is the OPERATOR's surface and may
|
|
18
|
+
* still set addressing; `apply-config` is the AUTOMATION surface a module hook
|
|
19
|
+
* shells out to, and networks are celilo's to define, not a module's
|
|
20
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
21
|
+
* So the equivalence below is asserted over everything EXCEPT addressing, and a
|
|
22
|
+
* separate test asserts the refusal — because "these two agree" and "this one
|
|
23
|
+
* refuses" are both properties worth keeping, and collapsing them would lose
|
|
24
|
+
* whichever was written second.
|
|
13
25
|
*/
|
|
14
26
|
|
|
15
27
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
@@ -40,11 +52,9 @@ describe('system apply-config equivalence with system init --accept-defaults', (
|
|
|
40
52
|
});
|
|
41
53
|
|
|
42
54
|
test('apply-config writes the same systemConfig keys as system init --accept-defaults', async () => {
|
|
55
|
+
// No `network.*` here: apply-config refuses those now, deliberately.
|
|
56
|
+
// Everything else must still land identically through both paths.
|
|
43
57
|
const overrides = [
|
|
44
|
-
'network.dmz.subnet=10.99.10.0/24',
|
|
45
|
-
'network.app.subnet=10.99.20.0/24',
|
|
46
|
-
'network.secure.subnet=10.99.30.0/24',
|
|
47
|
-
'network.internal.subnet=192.168.99.0/24',
|
|
48
58
|
'dns.primary=9.9.9.9',
|
|
49
59
|
'dns.fallback=1.1.1.1 8.8.8.8',
|
|
50
60
|
'ssh.public_key=ssh-ed25519 AAAA== test@equivalence',
|
|
@@ -71,6 +81,37 @@ describe('system apply-config equivalence with system init --accept-defaults', (
|
|
|
71
81
|
}
|
|
72
82
|
});
|
|
73
83
|
|
|
84
|
+
test('the operator surface may still set addressing; the automation surface may not', async () => {
|
|
85
|
+
// Same key, same value, two surfaces, two answers. `system init` is the
|
|
86
|
+
// operator saying what the topology is; `apply-config` is a module asking to
|
|
87
|
+
// decide it. Only the first is an authority over the network namespace.
|
|
88
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'operator.db');
|
|
89
|
+
const viaInit = await handleSystemInit(['network.dmz.subnet=10.99.10.0/24'], {
|
|
90
|
+
'accept-defaults': true,
|
|
91
|
+
});
|
|
92
|
+
expect(viaInit.success).toBe(true);
|
|
93
|
+
expect(loadExistingConfiguration(getDb())['network.dmz.subnet']).toBe('10.99.10.0/24');
|
|
94
|
+
|
|
95
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'automation.db');
|
|
96
|
+
const viaApply = await handleSystemApplyConfig(['network.dmz.subnet=10.99.10.0/24']);
|
|
97
|
+
expect(viaApply.success).toBe(false);
|
|
98
|
+
if (!viaApply.success) {
|
|
99
|
+
expect(viaApply.error).toContain('requires.networks');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a refused network key takes the whole write with it — no partial application', async () => {
|
|
104
|
+
// Half-applying would be worse than refusing: the caller sees an error and
|
|
105
|
+
// the box is left in a state neither surface intended.
|
|
106
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'partial.db');
|
|
107
|
+
const result = await handleSystemApplyConfig([
|
|
108
|
+
'dns.primary=9.9.9.9',
|
|
109
|
+
'network.dmz.subnet=10.99.10.0/24',
|
|
110
|
+
]);
|
|
111
|
+
expect(result.success).toBe(false);
|
|
112
|
+
expect(loadExistingConfiguration(getDb())['dns.primary']).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
74
115
|
test('apply-config refuses to run with no overrides (unlike init which has defaults)', async () => {
|
|
75
116
|
process.env.CELILO_DB_PATH = join(tmpDir, 'empty.db');
|
|
76
117
|
const result = await handleSystemApplyConfig([]);
|
|
@@ -92,10 +133,7 @@ describe('system apply-config equivalence with system init --accept-defaults', (
|
|
|
92
133
|
|
|
93
134
|
test('apply-config writes report the number of values applied', async () => {
|
|
94
135
|
process.env.CELILO_DB_PATH = join(tmpDir, 'count.db');
|
|
95
|
-
const result = await handleSystemApplyConfig([
|
|
96
|
-
'dns.primary=1.1.1.1',
|
|
97
|
-
'network.dmz.subnet=10.0.10.0/24',
|
|
98
|
-
]);
|
|
136
|
+
const result = await handleSystemApplyConfig(['dns.primary=1.1.1.1', 'network.bridge=vmbr9']);
|
|
99
137
|
expect(result.success).toBe(true);
|
|
100
138
|
if (result.success) {
|
|
101
139
|
// initializeSystem() merges with defaults + computed gateways,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from 'bun:test';
|
|
11
|
-
import { parseKeyValueArgs } from './system-apply-config';
|
|
11
|
+
import { networkWriteRefusal, parseKeyValueArgs, rejectedNetworkKeys } from './system-apply-config';
|
|
12
12
|
|
|
13
13
|
describe('parseKeyValueArgs', () => {
|
|
14
14
|
test('empty args → empty overrides + no errors', () => {
|
|
@@ -68,3 +68,59 @@ describe('parseKeyValueArgs', () => {
|
|
|
68
68
|
expect(result.overrides['dns.primary']).toBe('8.8.8.8');
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* §2.2 — the write path is CLOSED, not merely unused.
|
|
74
|
+
*
|
|
75
|
+
* `apply-config` is the automation surface a module hook shells out to, and it
|
|
76
|
+
* is how `wireguard` and `iptables` used to write network definitions. Deleting
|
|
77
|
+
* those two calls stops those two modules; refusing the key stops the next one.
|
|
78
|
+
* The distinction matters because the authority is the point: a rule every
|
|
79
|
+
* caller must remember is not a rule.
|
|
80
|
+
*/
|
|
81
|
+
describe('network keys are refused (celilo owns the network namespace)', () => {
|
|
82
|
+
test('the subnet a module used to write is rejected', () => {
|
|
83
|
+
expect(rejectedNetworkKeys(['network.control-plane-vpn.subnet'])).toEqual([
|
|
84
|
+
'network.control-plane-vpn.subnet',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the whole network namespace is refused, not just .subnet', () => {
|
|
89
|
+
// A module that could set a gateway or a VLAN tag for a network it does not
|
|
90
|
+
// own would be redefining that network by increments, and a subnet-only rule
|
|
91
|
+
// would read as an invitation to do exactly that.
|
|
92
|
+
expect(
|
|
93
|
+
rejectedNetworkKeys([
|
|
94
|
+
'network.dmz.subnet',
|
|
95
|
+
'network.dmz.gateway',
|
|
96
|
+
'network.dmz.vlan',
|
|
97
|
+
'network.secure-mgmt.subnet',
|
|
98
|
+
]),
|
|
99
|
+
).toHaveLength(4);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('network.bridge is exempt — a container-service concept, not addressing', () => {
|
|
103
|
+
// It is also the one network.* key with a schema default, so refusing it
|
|
104
|
+
// would break `system init`.
|
|
105
|
+
expect(rejectedNetworkKeys(['network.bridge'])).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('everything else still writes', () => {
|
|
109
|
+
expect(rejectedNetworkKeys(['dns.primary', 'ssh.public_key', 'public_dns.resolver'])).toEqual(
|
|
110
|
+
[],
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the refusal says whose the namespace is, and what to do instead', () => {
|
|
115
|
+
// A bare "rejected" would send a module author looking for a typo. The
|
|
116
|
+
// message has to name the alternative, because there IS one: declare the
|
|
117
|
+
// network and read it.
|
|
118
|
+
const message = networkWriteRefusal(['network.dmz.subnet']);
|
|
119
|
+
expect(message).toContain('network.dmz.subnet');
|
|
120
|
+
expect(message).toContain('requires.networks');
|
|
121
|
+
expect(message).toContain('$system:network.<name>.subnet');
|
|
122
|
+
// And the operator's own escape hatches, which are not closed.
|
|
123
|
+
expect(message).toContain('celilo system config set');
|
|
124
|
+
expect(message).toContain('celilo system discover-network');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -29,6 +29,44 @@ interface ParsedInput {
|
|
|
29
29
|
errors: string[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Keys this command will not write, however they arrive.
|
|
34
|
+
*
|
|
35
|
+
* `apply-config` is the automation surface — the one a module hook can shell out
|
|
36
|
+
* to — and networks are celilo's to define, never a module's
|
|
37
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
38
|
+
* Closing it here rather than trusting each module is the point: an authority
|
|
39
|
+
* that depends on every caller behaving is not an authority.
|
|
40
|
+
*
|
|
41
|
+
* This is deliberately the WHOLE `network.` namespace and not just `.subnet`.
|
|
42
|
+
* A module that could set a gateway or a VLAN tag for a network it does not own
|
|
43
|
+
* would be redefining that network by increments, and the subnet-only rule would
|
|
44
|
+
* read as an invitation to do exactly that.
|
|
45
|
+
*
|
|
46
|
+
* `network.bridge` is exempt: it is a container-service concept (a Proxmox bridge
|
|
47
|
+
* name like `vmbr0`), not addressing, and it is the one `network.` key that
|
|
48
|
+
* carries a schema default.
|
|
49
|
+
*/
|
|
50
|
+
const NETWORK_KEY = /^network\./;
|
|
51
|
+
const NETWORK_KEY_EXEMPT = new Set(['network.bridge']);
|
|
52
|
+
|
|
53
|
+
export function rejectedNetworkKeys(keys: string[]): string[] {
|
|
54
|
+
return keys.filter((key) => NETWORK_KEY.test(key) && !NETWORK_KEY_EXEMPT.has(key));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function networkWriteRefusal(keys: string[]): string {
|
|
58
|
+
return [
|
|
59
|
+
`Refusing to write ${keys.length === 1 ? 'a network key' : 'network keys'}: ${keys.join(', ')}.`,
|
|
60
|
+
'',
|
|
61
|
+
"Networks are celilo's to define, not a module's. A module declares the networks it",
|
|
62
|
+
'needs under `requires.networks` and reads them with `$system:network.<name>.subnet`;',
|
|
63
|
+
'celilo supplies the value, asking the operator when it does not already hold one.',
|
|
64
|
+
'',
|
|
65
|
+
'If you are the operator: `celilo system config set <key> <value>`.',
|
|
66
|
+
'If this is the management box recording its own network: `celilo system discover-network`.',
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
/**
|
|
33
71
|
* Pure parser for the positional key=value arguments. Exported so the
|
|
34
72
|
* test suite can exercise it without spinning up the CLI.
|
|
@@ -113,6 +151,11 @@ export async function handleSystemApplyConfig(
|
|
|
113
151
|
};
|
|
114
152
|
}
|
|
115
153
|
|
|
154
|
+
const refused = rejectedNetworkKeys(Object.keys(overrides));
|
|
155
|
+
if (refused.length > 0) {
|
|
156
|
+
return { success: false, error: networkWriteRefusal(refused) };
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
const db = getDb();
|
|
117
160
|
try {
|
|
118
161
|
const applied = initializeSystem(db, overrides);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system discover-network` — record the network this box sits on.
|
|
3
|
+
*
|
|
4
|
+
* The celilo-side half of "celilo owns the network namespace"
|
|
5
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
6
|
+
* `system apply-config` refuses `network.*` keys outright, so this is the surface
|
|
7
|
+
* that writes the one network nobody can be asked about: the management box's
|
|
8
|
+
* own, which celilo reads off the kernel rather than out of anyone's config.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent and non-destructive — a value already set is left alone, so a
|
|
11
|
+
* redeploy cannot clobber an operator's addressing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getDb } from '../../db/client';
|
|
15
|
+
import { discoverAndRecordNetwork } from '../../services/network-discovery';
|
|
16
|
+
import type { CommandResult } from '../types';
|
|
17
|
+
|
|
18
|
+
export async function handleSystemDiscoverNetwork(): Promise<CommandResult> {
|
|
19
|
+
const result = discoverAndRecordNetwork(getDb());
|
|
20
|
+
|
|
21
|
+
if (result.applied.length === 0) {
|
|
22
|
+
// Not an error. "Nothing to record" is the ordinary outcome of a redeploy,
|
|
23
|
+
// and an undiscoverable route is a fact to report rather than a failure to
|
|
24
|
+
// abort a deploy on — the operator can set the value by hand.
|
|
25
|
+
return {
|
|
26
|
+
success: true,
|
|
27
|
+
message: `No network recorded: ${result.skipped ?? 'nothing to do'}.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
success: true,
|
|
33
|
+
message: `Recorded the network this box sits on:\n ${result.applied.join('\n ')}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/cli/completion.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -119,6 +119,7 @@ import { handleSubscribersTest } from './commands/subscribers-test';
|
|
|
119
119
|
import { handleSystemApplyConfig } from './commands/system-apply-config';
|
|
120
120
|
import { handleSystemAudit } from './commands/system-audit';
|
|
121
121
|
import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
|
|
122
|
+
import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
|
|
122
123
|
import { handleSystemDoctor } from './commands/system-doctor';
|
|
123
124
|
import { handleSystemInit } from './commands/system-init';
|
|
124
125
|
import { handleSystemMigrate } from './commands/system-migrate';
|
|
@@ -1100,6 +1101,10 @@ Subcommands:
|
|
|
1100
1101
|
apply-config <key=val>... | --from-stdin
|
|
1101
1102
|
Headless config write — designed for module
|
|
1102
1103
|
hooks (no prompts, no guidance, just writes).
|
|
1104
|
+
Refuses network.* keys: networks are celilo's
|
|
1105
|
+
to define, not a module's.
|
|
1106
|
+
|
|
1107
|
+
discover-network Record the network this box sits on, from its own routing table (idempotent)
|
|
1103
1108
|
|
|
1104
1109
|
config set <key> <value> Set system-wide configuration value
|
|
1105
1110
|
config get [key] Get system configuration value(s)
|
|
@@ -2173,6 +2178,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2173
2178
|
return handleSystemApplyConfig(parsed.args, parsed.flags);
|
|
2174
2179
|
}
|
|
2175
2180
|
|
|
2181
|
+
if (parsed.subcommand === 'discover-network') {
|
|
2182
|
+
return handleSystemDiscoverNetwork();
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2176
2185
|
if (parsed.subcommand === 'config') {
|
|
2177
2186
|
const configSubcommand = parsed.args[0];
|
|
2178
2187
|
if (!configSubcommand) {
|
|
@@ -632,8 +632,8 @@ function buildCapabilityInterface(
|
|
|
632
632
|
undefined, // no upstream — the chain path handles that
|
|
633
633
|
undefined, // logger is applied by wrapWithLogging at the loader site
|
|
634
634
|
trustedSourceStore,
|
|
635
|
-
// LIVE
|
|
636
|
-
//
|
|
635
|
+
// LIVE — see FirewallZones.declaredNetworks for why this stays live
|
|
636
|
+
// even though nothing writes a network mid-run any more.
|
|
637
637
|
zones ? { list: zones.declaredNetworks } : undefined,
|
|
638
638
|
);
|
|
639
639
|
return stampProvider(iface, providerModuleId);
|
|
@@ -742,10 +742,28 @@ interface FirewallZones {
|
|
|
742
742
|
* subnet (design D1, amended).
|
|
743
743
|
*/
|
|
744
744
|
/**
|
|
745
|
-
* A LIVE read, not a snapshot
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
745
|
+
* A LIVE read, not a snapshot — and it STAYS live, deliberately, now that the
|
|
746
|
+
* race it was introduced for is gone.
|
|
747
|
+
*
|
|
748
|
+
* It was introduced because `wireguard` declared its VPN subnet and brought
|
|
749
|
+
* `wg0` up inside one hook run, so a converge later in that run had to see a
|
|
750
|
+
* value written after the capability was built. Modules no longer write
|
|
751
|
+
* networks at all (openspec/changes/networks-are-declared-not-written), and
|
|
752
|
+
* `ensureRequiredNetworks` defines every required one BEFORE the deploy loads
|
|
753
|
+
* any capability. So a snapshot would be correct today.
|
|
754
|
+
*
|
|
755
|
+
* It is kept live because being correct today is not the property worth having
|
|
756
|
+
* here. What a stale read costs is specific and severe: an interface celilo
|
|
757
|
+
* cannot attribute is ALIEN, and on a firewall with a recorded baseline an
|
|
758
|
+
* alien interface is disabled — which is how a converge came to shut down the
|
|
759
|
+
* admin VPN, the operator's way back into a remote box. A live read costs one
|
|
760
|
+
* query per converge. That is a trade worth making permanently, not one to
|
|
761
|
+
* re-evaluate whenever the write paths happen to look clean.
|
|
762
|
+
*
|
|
763
|
+
* And they are not entirely clean: `celilo system discover-network` writes
|
|
764
|
+
* `network.internal.*` from inside `celilo-mgmt`'s install hook. No firewall
|
|
765
|
+
* capability exists during that deploy, so it cannot bite — today. Liveness is
|
|
766
|
+
* what makes that sentence not need to stay true.
|
|
749
767
|
*/
|
|
750
768
|
declaredNetworks: () => Array<{ zone: string; subnet: string }>;
|
|
751
769
|
/**
|
|
@@ -851,14 +869,13 @@ export function loadTrustedSubnets(db: DbClient, firewallIp?: string): TrustedSu
|
|
|
851
869
|
* Every network celilo can attribute an interface to: one entry per
|
|
852
870
|
* `network.<name>.subnet` in system config.
|
|
853
871
|
*
|
|
854
|
-
* Read from the config rather than from `NETWORK_ZONES`, because celilo
|
|
855
|
-
* networks that are not placement zones
|
|
856
|
-
* `
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
* `
|
|
860
|
-
*
|
|
861
|
-
* ISOLATED it, shutting down the admin VPN.
|
|
872
|
+
* Read from the config rather than from `NETWORK_ZONES`, because celilo holds
|
|
873
|
+
* networks that are not placement zones — `network.control-plane-vpn.subnet`,
|
|
874
|
+
* which `wireguard` requires and reads. The firewall classifies `wg0` "by the
|
|
875
|
+
* same subnet containment it uses for every other leg", and reading only
|
|
876
|
+
* `NETWORK_ZONES` left that network invisible: the converge could not attribute
|
|
877
|
+
* `wg0` and refused — and on a firewall with a baseline it would have ISOLATED
|
|
878
|
+
* it, shutting down the admin VPN.
|
|
862
879
|
*
|
|
863
880
|
* `external` is deliberately absent even when something has set
|
|
864
881
|
* `network.external.subnet`: it is the RESIDUAL, decided by `isPubliclyRoutable`
|
|
@@ -1109,9 +1126,8 @@ async function buildFirewallChain(
|
|
|
1109
1126
|
currentUpstream,
|
|
1110
1127
|
logger,
|
|
1111
1128
|
trustedSourceStore,
|
|
1112
|
-
// LIVE
|
|
1113
|
-
//
|
|
1114
|
-
// visible to the converge that follows it in the same run.
|
|
1129
|
+
// LIVE — see FirewallZones.declaredNetworks. Cheap insurance against a
|
|
1130
|
+
// failure whose consequence is isolating the admin VPN.
|
|
1115
1131
|
{ list: zones.declaredNetworks },
|
|
1116
1132
|
);
|
|
1117
1133
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `requires.networks` — a module NAMES the networks it depends on and never
|
|
3
|
+
* carries their values
|
|
4
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
5
|
+
*
|
|
6
|
+
* The rejection below is the schema-level half of "a module has no path to
|
|
7
|
+
* create a network definition". The other half is the write path
|
|
8
|
+
* ([[cli/commands/system-apply-config.ts]]).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { NetworkRequirementSchema, getRequiredNetworkNames } from './schema';
|
|
13
|
+
import type { ModuleManifest } from './schema';
|
|
14
|
+
|
|
15
|
+
describe('NetworkRequirementSchema', () => {
|
|
16
|
+
test('a requirement is just a name', () => {
|
|
17
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'control-plane-vpn' }).success).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('names are kebab-case, like every other user-facing identifier', () => {
|
|
21
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'control_plane_vpn' }).success).toBe(false);
|
|
22
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'DMZ' }).success).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a requirement carrying a subnet is rejected, and the message says why', () => {
|
|
26
|
+
const result = NetworkRequirementSchema.safeParse({
|
|
27
|
+
name: 'control-plane-vpn',
|
|
28
|
+
subnet: '10.255.255.0/24',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.success).toBe(false);
|
|
32
|
+
if (result.success) return;
|
|
33
|
+
const message = result.error.errors.map((e) => e.message).join(' ');
|
|
34
|
+
expect(message).toContain('celilo owns');
|
|
35
|
+
expect(message).toContain('$system:network.<name>.subnet');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a gateway or vlan on the requirement is rejected too', () => {
|
|
39
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'dmz', gateway: '10.0.10.1' }).success).toBe(
|
|
40
|
+
false,
|
|
41
|
+
);
|
|
42
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'dmz', vlan: 10 }).success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `from:` is the second form, for a module whose required set is decided per
|
|
47
|
+
* install rather than at authoring time — a firewall requires the networks it
|
|
48
|
+
* has legs on, and which legs it has is a property of the box it lands on.
|
|
49
|
+
* It still names networks and still carries no values.
|
|
50
|
+
*/
|
|
51
|
+
test('a requirement may name a config array instead of a literal', () => {
|
|
52
|
+
expect(NetworkRequirementSchema.safeParse({ from: '$self:zones' }).success).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("from must reference this module's own config, not another source", () => {
|
|
56
|
+
// `$machine:` / `$capability:` would make some other thing the author of the
|
|
57
|
+
// required set, which is the authority question this change exists to settle.
|
|
58
|
+
expect(NetworkRequirementSchema.safeParse({ from: '$machine:zones' }).success).toBe(false);
|
|
59
|
+
expect(NetworkRequirementSchema.safeParse({ from: '$capability:firewall.zones' }).success).toBe(
|
|
60
|
+
false,
|
|
61
|
+
);
|
|
62
|
+
expect(NetworkRequirementSchema.safeParse({ from: 'zones' }).success).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('exactly one of name or from — neither both nor neither', () => {
|
|
66
|
+
expect(NetworkRequirementSchema.safeParse({ name: 'dmz', from: '$self:zones' }).success).toBe(
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
expect(NetworkRequirementSchema.safeParse({}).success).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('getRequiredNetworkNames', () => {
|
|
74
|
+
function manifestWith(
|
|
75
|
+
networks: Array<{ name?: string; from?: string }> | undefined,
|
|
76
|
+
): ModuleManifest {
|
|
77
|
+
return {
|
|
78
|
+
requires: { capabilities: [], networks },
|
|
79
|
+
} as unknown as ModuleManifest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('is empty for a module that requires no network', () => {
|
|
83
|
+
expect(getRequiredNetworkNames(manifestWith(undefined))).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('keeps declaration order and drops duplicates', () => {
|
|
87
|
+
expect(
|
|
88
|
+
getRequiredNetworkNames(manifestWith([{ name: 'dmz' }, { name: 'app' }, { name: 'dmz' }])),
|
|
89
|
+
).toEqual(['dmz', 'app']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("resolves a from: requirement against the module's own config", () => {
|
|
93
|
+
expect(
|
|
94
|
+
getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
|
|
95
|
+
zones: ['dmz', 'app', 'secure'],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual(['dmz', 'app', 'secure']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an array stored as JSON resolves too', () => {
|
|
101
|
+
// Array config arrives as either shape depending on how it was written, and
|
|
102
|
+
// a requirement that silently resolved to nothing would leave a firewall leg
|
|
103
|
+
// undeclared — which is how an interface ends up classified alien.
|
|
104
|
+
expect(
|
|
105
|
+
getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
|
|
106
|
+
zones: '["dmz","secure-mgmt"]',
|
|
107
|
+
}),
|
|
108
|
+
).toEqual(['dmz', 'secure-mgmt']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `external` is the RESIDUAL: an interface is external because it is publicly
|
|
113
|
+
* routable and matched no declared zone, never because it is contained in a
|
|
114
|
+
* subnet. Giving it one would reintroduce the overload that
|
|
115
|
+
* `readDeclaredNetworks` and `deployFirewall` both already exclude.
|
|
116
|
+
*/
|
|
117
|
+
test('external is dropped, however it is named', () => {
|
|
118
|
+
expect(
|
|
119
|
+
getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {
|
|
120
|
+
zones: ['dmz', 'external'],
|
|
121
|
+
}),
|
|
122
|
+
).toEqual(['dmz']);
|
|
123
|
+
expect(getRequiredNetworkNames(manifestWith([{ name: 'external' }]))).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an unanswered config array requires nothing rather than throwing', () => {
|
|
127
|
+
// The firewall's zones are answered by the config interview, which runs
|
|
128
|
+
// before this. An empty result here means the interview has not happened
|
|
129
|
+
// yet, not that the module needs no networks.
|
|
130
|
+
expect(getRequiredNetworkNames(manifestWith([{ from: '$self:zones' }]), {})).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('literal and dynamic requirements combine', () => {
|
|
134
|
+
expect(
|
|
135
|
+
getRequiredNetworkNames(
|
|
136
|
+
manifestWith([{ name: 'control-plane-vpn' }, { from: '$self:zones' }]),
|
|
137
|
+
{ zones: ['dmz'] },
|
|
138
|
+
),
|
|
139
|
+
).toEqual(['control-plane-vpn', 'dmz']);
|
|
140
|
+
});
|
|
141
|
+
});
|