@celilo/cli 0.24.1 → 0.25.1

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.
@@ -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. **Declares `network.control-plane-vpn.subnet` BEFORE it creates the interface** the first side effect of `on_install`, before any command touches the box. The ordering is load-bearing, not tidiness: the firewall classifies every interface on its host and one no declaration accounts for is `alien`, so creating `wg0` first would leave a window in which a converge a timer, the registry-poll CD, any unrelated module deploy could shut the admin VPN down as an intruder's interface. The module declares the NETWORK, never the device: only the firewall decides what an interface is for. `health_check` asserts the declaration still holds, because the other checks all pass while it is missing. 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).
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 retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **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
 
@@ -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 declares networks that are not placement zones (`network.control-plane-vpn.subnet`, written by `wireguard` before it brings `wg0` up). Injected into the firewall capability as a LIVE reader (`declaredNetworks`), alongside the trusted-source store and for the same reason a declaration written during a hook run must be visible to the converge that follows it in that same run, which a snapshot taken at capability-build time cannot be.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,7 +61,7 @@
61
61
  "@celilo/capabilities": "^1.2.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.8.0",
64
- "@celilo/event-bus": "^0.5.0",
64
+ "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
67
67
  "ink": "^7.0.1",
@@ -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
+ }
@@ -620,6 +620,7 @@ export async function getCompletions(words: string[], current: number): Promise<
620
620
  const subcommands = [
621
621
  'init',
622
622
  'apply-config',
623
+ 'discover-network',
623
624
  'config',
624
625
  'secret',
625
626
  'vault-password',
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) {
@@ -54,7 +54,13 @@ describe('celilo#699 — stdout is undecorated', () => {
54
54
  // an empty roster would pass every assertion below without testing them.
55
55
  const imported = celilo('module import ../../modules/celilo-mgmt');
56
56
  expect(imported.status, `module import failed:\n${imported.stderr}`).toBe(0);
57
- });
57
+ // The spawn above is budgeted 60s, but bun's DEFAULT hook timeout is 5s, so
58
+ // the hook killed the import at 5001ms and the failure surfaced as
59
+ // `status: null` with an empty stderr — which reads as "module import is
60
+ // broken" rather than "this hook is not allowed to take as long as the work
61
+ // inside it". `module import` runs ansible-galaxy, measured at ~7s here; CI
62
+ // is under 5s, so the suite is green there and red on a slower machine.
63
+ }, 90_000);
58
64
 
59
65
  afterAll(async () => {
60
66
  await ctx.cleanup();
@@ -0,0 +1,63 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { afterEach, describe, expect, test } from 'bun:test';
3
+ import { rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createDbClient } from './client';
7
+
8
+ // #798: a command that opened the db while a deploy still held it died on
9
+ // SQLITE_BUSY immediately, because the connection carried no busy timeout.
10
+ describe('createDbClient concurrency', () => {
11
+ const paths: string[] = [];
12
+
13
+ const freshPath = () => {
14
+ const path = join(tmpdir(), `celilo-client-test-${Bun.nanoseconds()}.db`);
15
+ paths.push(path);
16
+ return path;
17
+ };
18
+
19
+ afterEach(() => {
20
+ for (const path of paths.splice(0)) {
21
+ for (const suffix of ['', '-wal', '-shm']) {
22
+ rmSync(`${path}${suffix}`, { force: true });
23
+ }
24
+ }
25
+ });
26
+
27
+ test('sets a non-zero busy timeout so a contended open waits instead of throwing', () => {
28
+ const { $client } = createDbClient({ path: freshPath() });
29
+
30
+ const timeout = $client.query<{ timeout: number }, []>('PRAGMA busy_timeout').get()?.timeout;
31
+
32
+ // Red before #798: bun:sqlite defaults to 0, i.e. fail on first contention.
33
+ expect(timeout).toBeGreaterThan(0);
34
+ });
35
+
36
+ test('opens in WAL mode', () => {
37
+ const { $client } = createDbClient({ path: freshPath() });
38
+
39
+ const mode = $client.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get();
40
+
41
+ expect(mode?.journal_mode.toLowerCase()).toBe('wal');
42
+ });
43
+
44
+ test('a second open succeeds while another connection holds a write transaction', () => {
45
+ const path = freshPath();
46
+ createDbClient({ path });
47
+
48
+ // Hold the write lock the way a deploy in progress does.
49
+ const writer = new Database(path);
50
+ writer.run(`PRAGMA busy_timeout = ${0}`);
51
+ writer.run('BEGIN IMMEDIATE');
52
+
53
+ try {
54
+ // Reading is what `module show-config` does; it must not throw.
55
+ const { $client } = createDbClient({ path, readonly: true });
56
+ const mode = $client.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get();
57
+ expect(mode?.journal_mode.toLowerCase()).toBe('wal');
58
+ } finally {
59
+ writer.run('ROLLBACK');
60
+ writer.close();
61
+ }
62
+ });
63
+ });
package/src/db/client.ts CHANGED
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
2
2
  import { existsSync, mkdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
5
6
  import { drizzle } from 'drizzle-orm/bun-sqlite';
6
7
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
7
8
  import { getDbPath } from '../config/paths';
@@ -60,12 +61,18 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
60
61
  create: true,
61
62
  });
62
63
 
64
+ // Set FIRST so it covers every statement below, including the migrations. A
65
+ // command that opens the db while a deploy still holds it used to die on
66
+ // SQLITE_BUSY immediately instead of waiting (#798).
67
+ sqlite.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
68
+
63
69
  // Enable foreign keys
64
70
  sqlite.run('PRAGMA foreign_keys = ON');
65
71
 
66
- // Enable WAL mode for better concurrency
72
+ // Enable WAL mode for better concurrency. busy_timeout does NOT cover this
73
+ // transition, so the switch needs its own retry — see ensureWalMode.
67
74
  if (!readonly) {
68
- sqlite.run('PRAGMA journal_mode = WAL');
75
+ ensureWalMode(sqlite);
69
76
  }
70
77
 
71
78
  const db = drizzle(sqlite, { schema });
@@ -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, for the same reason as the chain path: a declaration written
636
- // during this hook run must be visible to the converge that follows it.
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. Everything else here is captured when the
746
- * capability is built before any hook runs — and for declarations that is
747
- * wrong: `wireguard` declares its VPN subnet and brings `wg0` up inside one
748
- * hook run, and the converge that follows has to see the declaration.
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 declares
855
- * networks that are not placement zones. `wireguard`'s `on_install` writes
856
- * `network.control-plane-vpn.subnet` before it brings `wg0` up, exactly as the
857
- * design requires (D3), and the firewall is supposed to classify `wg0` "by the
858
- * same subnet containment it uses for every other leg". Reading only
859
- * `NETWORK_ZONES` left that declaration invisible: the converge could not
860
- * attribute `wg0` and refused and on a firewall with a baseline it would have
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, not a snapshot: a declaration written during this hook run —
1113
- // `wireguard` declaring its VPN subnet before creating `wg0` — must be
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