@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +3 -1
- package/package.json +2 -2
- 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/cli/stdout-is-undecorated.test.ts +7 -1
- package/src/db/client.test.ts +63 -0
- package/src/db/client.ts +9 -2
- 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/templates/dns-ingress-ip.test.ts +123 -0
- package/src/templates/generator.ts +66 -43
- 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 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
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
+
}
|
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) {
|
|
@@ -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
|
|
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
|
|
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
|
|