@celilo/cli 0.25.1 → 0.26.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.
@@ -27,7 +27,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
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. **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).
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. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **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
 
@@ -60,6 +60,8 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
60
60
  | `registry_publish` | `packages/capabilities/src/registry-publish.ts` | celilo-registry |
61
61
  | `notification` | `packages/capabilities/src/notification.ts` (`send`, optional `receive`) | signal (planned) — transports are ordinary modules, both self-hosted and credential-only |
62
62
  | `cross_module_read` | `packages/capabilities/src/cross-module-read.ts` | framework (read other modules' capability data) |
63
+ | `control_plane_vpn` | `packages/capabilities/src/control-plane-vpn.ts` (`registerClient`, `revokeClient`, `listClients`, `getEndpoint`). **Its state is NOT in a celilo table** — registrations live in the PROVIDER's own module config under `registered_peers`, kept separate from the operator's `peers` so a machine can never rewrite what an operator typed, and so `revokeClient` structurally cannot reach a declared peer. Do not go looking for a `vpn_clients` table; there isn't one and that is deliberate. Addresses are allocated by the CONSUMER from `client_pool` — celilo's IPAM deliberately does not cover VPN clients. ⚠️ Every client registered here reaches every managed zone: the tunnel's client subnet is a registered trusted source. | wireguard |
64
+ | `private_web` | `packages/capabilities/src/private-web.ts` (`publishStaticSite`, `registerReverseProxy`, `unregisterRoutes`, `getCaCertificate`). **Fleet-only HTTP ingress** — internal DNS, an internally-issued certificate, and no public exposure. A SIBLING of `public_web` rather than a flag on it, because `public_web` treats an unreachable route as a deploy failure and publishes a public record to prevent one. Reuses `public_web`'s request types so a consumer can write `private_web ?? public_web` and call through the union without branching. | caddy-internal (planned) |
63
65
 
64
66
  ### In-fleet (`public_web`) vs off-fleet (`external_web`)
65
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.25.1",
3
+ "version": "0.26.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^1.2.0",
61
+ "@celilo/capabilities": "^1.4.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.8.0",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -75,10 +75,12 @@
75
75
  },
76
76
  "devDependencies": {
77
77
  "@biomejs/biome": "^1.9.4",
78
+ "@celilo/terraform-fake": "^0.3.0",
78
79
  "@types/bun": "^1.1.14",
79
80
  "@types/react": "^19.2.14",
80
81
  "drizzle-kit": "^0.30.0",
81
82
  "ink-testing-library": "^4.0.0",
83
+ "msw": "^2.15.0",
82
84
  "typescript": "^5.9.3",
83
85
  "zod-to-json-schema": "^3.25.2"
84
86
  }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The Proxmox client, exercised over a real socket — against the SAME handlers
3
+ * the e2e rig serves (D8).
4
+ *
5
+ * `proxmox.ts` hand-rolls `node:https` across 1,169 lines, and its existing
6
+ * 240-line test covers only the pure helpers: not one assertion reaches the
7
+ * wire. So the auth header, the `{data}` envelope, the 401 path and every
8
+ * non-2xx branch have gone unasserted — in the file that talks to the
9
+ * hypervisor.
10
+ *
11
+ * ## Why a real server rather than `setupServer`
12
+ *
13
+ * D8 proposed MSW's in-process `setupServer` for exactly this. **It does not
14
+ * work under Bun**, measured rather than assumed: `setupServer` intercepts
15
+ * global `fetch`, but a `node:https.request` goes straight past it and fails
16
+ * with `ECONNREFUSED` from Bun's own `node:_http_client`. `proxmox.ts` uses
17
+ * `node:https` directly, so the interceptor never sees it.
18
+ *
19
+ * `@celilo/terraform-fake` already exposes the same handlers as a real HTTPS
20
+ * server, so that is what these use. The D8 goal is met either way — ONE handler
21
+ * set behind both the client's unit tests and the rig's simulator, so the two
22
+ * cannot come to believe different things about what Proxmox returns — but it is
23
+ * met over a socket instead of an interceptor. The cost is a port and a
24
+ * self-signed certificate; the client already sets `rejectUnauthorized: false`,
25
+ * as every real consumer does.
26
+ */
27
+
28
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
29
+ import https from 'node:https';
30
+ import type { AddressInfo } from 'node:net';
31
+ import { type ProxmoxFake, createProxmoxFake } from '@celilo/terraform-fake';
32
+ import {
33
+ ProxmoxClient,
34
+ type ProxmoxCredentials,
35
+ buildProxmoxApiUrl,
36
+ getNodeForVmid,
37
+ listNodeStorage,
38
+ testProxmoxConnection,
39
+ } from './proxmox';
40
+
41
+ let fake: ProxmoxFake;
42
+ let credentials: ProxmoxCredentials;
43
+ let tls: { key: string; cert: string };
44
+
45
+ const TOKEN_ID = 'celilo@pve!e2e';
46
+ const TOKEN_SECRET = '00000000-0000-0000-0000-000000000000';
47
+
48
+ beforeAll(async () => {
49
+ tls = await generateSelfSigned();
50
+ fake = createProxmoxFake({
51
+ tls,
52
+ nodes: [{ name: 'pve1', cores: 8, memoryBytes: 16 * 1024 ** 3, diskBytes: 500 * 1024 ** 3 }],
53
+ storages: [{ name: 'local-lvm', content: 'images,rootdir' }],
54
+ });
55
+ const port = await fake.listen(0);
56
+ credentials = {
57
+ api_url: buildProxmoxApiUrl('127.0.0.1', port),
58
+ api_token_id: TOKEN_ID,
59
+ api_token_secret: TOKEN_SECRET,
60
+ };
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await fake.close();
65
+ });
66
+
67
+ /**
68
+ * Run one request against a throwaway server that answers however the test
69
+ * needs, for the branches a well-behaved Proxmox never exercises.
70
+ */
71
+ async function withStubServer<T>(
72
+ reply: (res: import('node:http').ServerResponse) => void,
73
+ run: (credentials: ProxmoxCredentials) => Promise<T>,
74
+ ): Promise<T> {
75
+ const server = https.createServer(tls, (_req, res) => reply(res));
76
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
77
+ const { port } = server.address() as AddressInfo;
78
+ try {
79
+ return await run({
80
+ api_url: buildProxmoxApiUrl('127.0.0.1', port),
81
+ api_token_id: TOKEN_ID,
82
+ api_token_secret: TOKEN_SECRET,
83
+ });
84
+ } finally {
85
+ await new Promise<void>((resolve) => server.close(() => resolve()));
86
+ }
87
+ }
88
+
89
+ describe('the request the client actually sends', () => {
90
+ test('carries the PVEAPIToken header Proxmox requires', async () => {
91
+ // Never asserted before. Proxmox rejects the session outright if this is
92
+ // malformed, and the client builds it by string concatenation.
93
+ let seen: string | undefined;
94
+ const server = https.createServer(tls, (req, res) => {
95
+ seen = req.headers.authorization;
96
+ res.writeHead(200, { 'content-type': 'application/json' });
97
+ res.end(JSON.stringify({ data: [] }));
98
+ });
99
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
100
+ const { port } = server.address() as AddressInfo;
101
+
102
+ await new ProxmoxClient({
103
+ ...credentials,
104
+ api_url: buildProxmoxApiUrl('127.0.0.1', port),
105
+ }).clusterResources();
106
+ await new Promise<void>((resolve) => server.close(() => resolve()));
107
+
108
+ expect(seen).toBe(`PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`);
109
+ });
110
+
111
+ test('unwraps the `{data}` envelope rather than handing back the whole body', async () => {
112
+ const result = await listNodeStorage(credentials, 'pve1');
113
+
114
+ expect(result.success).toBe(true);
115
+ expect(Array.isArray(result.success && result.data)).toBe(true);
116
+ });
117
+
118
+ test('a query string is DROPPED — the constraint every caller works around', async () => {
119
+ // `makeProxmoxRequest` passes `url.pathname` and never `url.search`, so a
120
+ // filter silently does not arrive. `getNodeForVmid` documents this and
121
+ // fetches the whole inventory instead. Pinned here so that adding `?type=vm`
122
+ // somewhere and quietly getting unfiltered results is a test failure rather
123
+ // than a puzzling runtime one.
124
+ let seenUrl: string | undefined;
125
+ const server = https.createServer(tls, (req, res) => {
126
+ seenUrl = req.url;
127
+ res.writeHead(200, { 'content-type': 'application/json' });
128
+ res.end(JSON.stringify({ data: [] }));
129
+ });
130
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
131
+ const { port } = server.address() as AddressInfo;
132
+
133
+ await getNodeForVmid({ ...credentials, api_url: buildProxmoxApiUrl('127.0.0.1', port) }, 1);
134
+ await new Promise<void>((resolve) => server.close(() => resolve()));
135
+
136
+ expect(seenUrl).toBe('/api2/json/cluster/resources');
137
+ expect(seenUrl).not.toContain('?');
138
+ });
139
+ });
140
+
141
+ describe('a POST — the other half of the wire contract', () => {
142
+ test('sends form-encoded parameters, not JSON', async () => {
143
+ // Proxmox only accepts `application/x-www-form-urlencoded`; sending JSON
144
+ // gets a 400 that names no field. Never asserted before.
145
+ //
146
+ // The stub answers BOTH paths, because `setGuestPower` first resolves the
147
+ // node via `/cluster/resources` and only then issues the POST.
148
+ let contentType: string | undefined;
149
+ let postedTo: string | undefined;
150
+ let body = '';
151
+ const server = https.createServer(tls, (req, res) => {
152
+ if (req.method === 'GET') {
153
+ res.writeHead(200, { 'content-type': 'application/json' });
154
+ res.end(JSON.stringify({ data: [{ vmid: 403, node: 'pve1', type: 'lxc' }] }));
155
+ return;
156
+ }
157
+ contentType = req.headers['content-type'];
158
+ postedTo = req.url;
159
+ req.on('data', (c) => {
160
+ body += c;
161
+ });
162
+ req.on('end', () => {
163
+ res.writeHead(200, { 'content-type': 'application/json' });
164
+ res.end(JSON.stringify({ data: 'UPID:pve1:00000001:0:66BF0000:vzshutdown:403:root@pam:' }));
165
+ });
166
+ });
167
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
168
+ const { port } = server.address() as AddressInfo;
169
+
170
+ const result = await new ProxmoxClient({
171
+ ...credentials,
172
+ api_url: buildProxmoxApiUrl('127.0.0.1', port),
173
+ }).setGuestPower(403, 'lxc', 'shutdown');
174
+ await new Promise<void>((resolve) => server.close(() => resolve()));
175
+
176
+ expect(result.success).toBe(true);
177
+ expect(contentType).toBe('application/x-www-form-urlencoded');
178
+ expect(body).not.toStartWith('{');
179
+ // `shutdown`, not `stop`: a pause is planned, so the guest is asked to go
180
+ // quietly rather than having its power pulled.
181
+ expect(postedTo).toBe('/api2/json/nodes/pve1/lxc/403/status/shutdown');
182
+ }, 30_000);
183
+ });
184
+
185
+ describe('what the client does with a response it did not want', () => {
186
+ test('401 says the credentials are wrong, not merely that something failed', async () => {
187
+ const result = await withStubServer(
188
+ (res) => {
189
+ res.writeHead(401);
190
+ res.end();
191
+ },
192
+ (creds) => new ProxmoxClient(creds).clusterResources(),
193
+ );
194
+
195
+ expect(result.success).toBe(false);
196
+ expect(result.success === false && result.message).toContain('Authentication failed');
197
+ });
198
+
199
+ test('a 500 reports the status, so a caller can tell it from a refusal', async () => {
200
+ const result = await withStubServer(
201
+ (res) => {
202
+ res.writeHead(500);
203
+ res.end('boom');
204
+ },
205
+ (creds) => new ProxmoxClient(creds).clusterResources(),
206
+ );
207
+
208
+ expect(result.success).toBe(false);
209
+ expect(result.success === false && result.message).toContain('500');
210
+ });
211
+
212
+ test('a non-JSON body is a parse failure, not a crash', async () => {
213
+ // Proxmox behind a misconfigured proxy answers HTML. The client must still
214
+ // return a result, because every caller branches on `.success`.
215
+ const result = await withStubServer(
216
+ (res) => {
217
+ res.writeHead(200, { 'content-type': 'text/html' });
218
+ res.end('<html>nope</html>');
219
+ },
220
+ (creds) => new ProxmoxClient(creds).clusterResources(),
221
+ );
222
+
223
+ expect(result.success).toBe(false);
224
+ expect(result.success === false && result.message).toContain('parse');
225
+ });
226
+
227
+ test('an unreachable host resolves to a failure instead of rejecting', async () => {
228
+ // The whole function is a `new Promise(resolve)` with no reject path, so a
229
+ // throw here would surface as an unhandled rejection inside a CLI command.
230
+ const result = await new ProxmoxClient({
231
+ ...credentials,
232
+ // Reserved for documentation (RFC 5737) and never routable.
233
+ api_url: buildProxmoxApiUrl('192.0.2.1', 9),
234
+ }).clusterResources();
235
+
236
+ expect(result.success).toBe(false);
237
+ }, 30_000);
238
+ });
239
+
240
+ describe('against the real handler set', () => {
241
+ test('listNodeStorage returns the storages the fake declares', async () => {
242
+ const result = await listNodeStorage(credentials, 'pve1');
243
+ const names = result.success
244
+ ? (result.data as Array<{ storage: string }>).map((s) => s.storage)
245
+ : [];
246
+
247
+ expect(names).toContain('local-lvm');
248
+ });
249
+
250
+ test('listClusterResources gets node rows AND guest rows from one unfiltered call', async () => {
251
+ // Why the fake returns both when no `?type=` is given, even though the
252
+ // Terraform provider needs the filtered form: this client cannot ask for a
253
+ // filter (see the dropped-query test) and needs both kinds at once.
254
+ fake.state.addGuest({
255
+ vmid: 401,
256
+ node: 'pve1',
257
+ kind: 'lxc',
258
+ hostname: 'client-test',
259
+ status: 'running',
260
+ config: { cores: '2', memory: '1024' },
261
+ });
262
+
263
+ const result = await new ProxmoxClient(credentials).clusterResources();
264
+ const kinds = new Set(result.success ? result.data.map((r) => r.type) : []);
265
+
266
+ expect(kinds.has('node')).toBe(true);
267
+ expect(kinds.has('lxc')).toBe(true);
268
+
269
+ fake.state.removeGuest(401);
270
+ });
271
+
272
+ test('getNodeForVmid finds the node a guest lives on', async () => {
273
+ fake.state.addGuest({
274
+ vmid: 402,
275
+ node: 'pve1',
276
+ kind: 'lxc',
277
+ hostname: 'placed',
278
+ status: 'running',
279
+ config: {},
280
+ });
281
+
282
+ const result = await getNodeForVmid(credentials, 402);
283
+
284
+ expect(result.success && result.data).toBe('pve1');
285
+
286
+ fake.state.removeGuest(402);
287
+ });
288
+
289
+ test('getNodeForVmid returns null for a vmid that does not exist yet', async () => {
290
+ // First deploy: the container has not been created. Distinct from an error,
291
+ // and the callers rely on the difference.
292
+ const result = await getNodeForVmid(credentials, 9999);
293
+
294
+ expect(result.success).toBe(true);
295
+ expect(result.success && result.data).toBeNull();
296
+ });
297
+ });
298
+
299
+ describe('testProxmoxConnection', () => {
300
+ test('succeeds against a token with full permissions', async () => {
301
+ const result = await testProxmoxConnection(credentials);
302
+
303
+ expect(result.success).toBe(true);
304
+ });
305
+
306
+ test('a 401 on the probe fails the connection test', async () => {
307
+ const result = await withStubServer(
308
+ (res) => {
309
+ res.writeHead(401);
310
+ res.end();
311
+ },
312
+ (creds) => testProxmoxConnection(creds),
313
+ );
314
+
315
+ expect(result.success).toBe(false);
316
+ });
317
+ });
318
+
319
+ /** A throwaway pair. Validity beyond "parses as a cert" is not the point. */
320
+ async function generateSelfSigned(): Promise<{ key: string; cert: string }> {
321
+ const dir = `/tmp/proxmox-client-test-${process.pid}`;
322
+ await Bun.$`mkdir -p ${dir}`.quiet();
323
+ await Bun.$`openssl req -x509 -newkey rsa:2048 -keyout ${dir}/key.pem -out ${dir}/cert.pem -days 1 -nodes -subj /CN=proxmox.test`.quiet();
324
+ return {
325
+ key: await Bun.file(`${dir}/key.pem`).text(),
326
+ cert: await Bun.file(`${dir}/cert.pem`).text(),
327
+ };
328
+ }
@@ -109,6 +109,10 @@ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFacto
109
109
  script: 'scripts/publish-functions.ts',
110
110
  legacyFactoryName: 'default',
111
111
  },
112
+ control_plane_vpn: {
113
+ script: 'scripts/control-plane-vpn-functions.ts',
114
+ legacyFactoryName: 'default',
115
+ },
112
116
  };
113
117
 
114
118
  /**
@@ -121,7 +121,8 @@ export type HookName =
121
121
  | 'on_restore'
122
122
  | 'on_system_event'
123
123
  | 'refresh_registrations'
124
- | 'reassert_dhcp_dns';
124
+ | 'reassert_dhcp_dns'
125
+ | 'reconcile_clients';
125
126
 
126
127
  /**
127
128
  * Hook manifest section - maps hook names to definitions
@@ -76,22 +76,19 @@ export function addCIDR(ip: string, maskBits: number): string {
76
76
  }
77
77
 
78
78
  /**
79
- * Check if an IP address belongs to a subnet
80
- * Example: isInSubnet("10.0.10.50/24", "10.0.10.0/24") → true
79
+ * Check if an IP address belongs to a subnet.
80
+ *
81
+ * Re-exported from `@celilo/capabilities` rather than implemented here, so the
82
+ * one answer serves both celilo and module scripts — which cannot import from
83
+ * the backend and would otherwise carry a second copy (celilo#809).
84
+ *
85
+ * The implementation that used to live here compared the first three octets and
86
+ * threw away the prefix it had parsed, so it was right for /24 and wrong in both
87
+ * directions for anything else. It also routed through `parseSubnet`, which
88
+ * REFUSES anything smaller than a /24 — an allocation rule that has no business
89
+ * constraining a containment question.
81
90
  */
82
- export function isInSubnet(ipWithMask: string, subnet: string): boolean {
83
- const ip = stripCIDR(ipWithMask);
84
- const subnetInfo = parseSubnet(subnet);
85
-
86
- const ipOctets = ip.split('.').map((o) => Number.parseInt(o, 10));
87
-
88
- // For /24 subnet, just check first 3 octets
89
- return (
90
- ipOctets[0] === subnetInfo.octets[0] &&
91
- ipOctets[1] === subnetInfo.octets[1] &&
92
- ipOctets[2] === subnetInfo.octets[2]
93
- );
94
- }
91
+ export { isInSubnet } from '@celilo/capabilities';
95
92
 
96
93
  /**
97
94
  * Generate all possible IPs in a subnet range
@@ -210,6 +210,25 @@ export const V1_HOOKS: ContractHooks = {
210
210
  inputs: {},
211
211
  outputs: {},
212
212
  },
213
+ /**
214
+ * Reconcile the clients a self-service app has enrolled against what the
215
+ * VPN provider actually carries (openspec/changes/wireguard-manager, D1).
216
+ *
217
+ * The hook, rather than a bus `handler` subscription, because it needs
218
+ * CAPABILITY INJECTION: it calls `control_plane_vpn.registerClient` and
219
+ * `revokeClient`, and a handler gets no capabilities. No framework inputs —
220
+ * it reads the app's address from its own `systems` and holds a token it
221
+ * minted for itself at install.
222
+ *
223
+ * Typically driven by a `timer.tick.*` subscription, and the tick interval IS
224
+ * the window in which a revoked device still has reach — which is why the
225
+ * design requires the UI to show the pending state rather than imply
226
+ * revocation is instant (D12).
227
+ */
228
+ reconcile_clients: {
229
+ inputs: {},
230
+ outputs: {},
231
+ },
213
232
  /**
214
233
  * Build-bus upstream publish hook. The executor passes the
215
234
  * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
@@ -674,6 +674,7 @@ export const ModuleManifestSchema = z
674
674
  * client can be handed the wrong resolver. See celilo#739.
675
675
  */
676
676
  reassert_dhcp_dns: LifecycleHookSchema.optional(),
677
+ reconcile_clients: LifecycleHookSchema.optional(),
677
678
  /**
678
679
  * Build-bus upstream publish hooks. Array (a module can react
679
680
  * to multiple upstream packages with different actions). See
@@ -410,3 +410,69 @@ describe('reply verbs', () => {
410
410
  expect(parseInbound('K7QM2X akc')).toEqual({ kind: 'unrecognised' });
411
411
  });
412
412
  });
413
+
414
+ /**
415
+ * celilo#500. The accepted grammar, in one table, because it was previously
416
+ * knowable only by reading the parser.
417
+ *
418
+ * The headline case is `reply K7QM2X`: every page ends with that literal line,
419
+ * and an operator who did exactly what it said was told `unrecognised` while
420
+ * the alert kept firing. Two consecutive real operator replies were lost this
421
+ * way. The instruction the system gives was the one input it refused.
422
+ *
423
+ * The trailing-period case is the same shape of unkindness. iOS inserts a full
424
+ * stop on a double space by default, and a one-word message is precisely where
425
+ * that fires.
426
+ *
427
+ * Being liberal here costs nothing: authentication is the token plus the sender
428
+ * check (see the header of inbound.ts), never punctuation strictness.
429
+ */
430
+ describe('the grammar accepts what a phone actually sends (#500)', () => {
431
+ const ACKNOWLEDGES: Array<[string, string]> = [
432
+ ['K7QM2X', 'the bare token'],
433
+ ['k7qm2x', 'lower case'],
434
+ ['K7-QM2X', 'a separator the operator kept'],
435
+ ['K7QM2X ack', 'a trailing verb'],
436
+ ['ack K7QM2X', 'the natural spoken order'],
437
+ ['reply K7QM2X', 'WHAT THE PAGE ITSELF INSTRUCTS'],
438
+ ['K7QM2X.', 'iOS double-space autocorrect'],
439
+ ['Ack K7QM2X.', 'both at once, capitalised'],
440
+ ['reply k7-qm2x.', 'everything at once'],
441
+ ];
442
+
443
+ test.each(ACKNOWLEDGES)('%p acknowledges (%s)', (body) => {
444
+ expect(parseInbound(body)).toEqual({ kind: 'ack', token: 'K7QM2X' });
445
+ });
446
+
447
+ /**
448
+ * The guard that must survive. `<token> resolve` silently acknowledging an
449
+ * alert the operator meant to escalate is the worst outcome available here,
450
+ * so widening the grammar must not widen THIS.
451
+ */
452
+ const REFUSED: Array<[string, string]> = [
453
+ ['K7QM2X resolve', 'a verb celilo does not implement'],
454
+ ['K7QM2X silence 2h', 'a request with an argument'],
455
+ ['K7QM2X akc', 'a typo, not guessed at'],
456
+ ['reply K7QM2X resolve', 'the new verb does not smuggle a sentence through'],
457
+ ['what is going on', 'no token-shaped word anywhere'],
458
+ ['K7QM2XY', 'too long to be a token'],
459
+ ['reply', 'the verb alone names nothing'],
460
+ ];
461
+
462
+ test.each(REFUSED)('%p is refused (%s)', (body) => {
463
+ expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
464
+ });
465
+
466
+ /**
467
+ * The instruction and the parser are pinned to the same literal from both
468
+ * sides, so they cannot drift apart again. `composeBody` in
469
+ * `modules/signal/scripts/notification.ts` emits `\n\nreply <token>`, and
470
+ * `signal-rpc.test.ts:273` asserts that exact output. This asserts the parser
471
+ * accepts it. Change the wording and one of the two goes red.
472
+ */
473
+ test('the exact line composeBody emits is accepted', () => {
474
+ const asSent = 'caddy is down\n\nreply K7QM2X'.split('\n\n')[1];
475
+ expect(asSent).toBe('reply K7QM2X');
476
+ expect(parseInbound(asSent)).toEqual({ kind: 'ack', token: 'K7QM2X' });
477
+ });
478
+ });
@@ -65,6 +65,33 @@ export type InboundIntent =
65
65
  | { kind: 'unrecognised' };
66
66
 
67
67
  const ACK_VERB = /^(ack|ok|k|👍)$/iu;
68
+
69
+ /**
70
+ * A verb that may only LEAD, and does not itself acknowledge.
71
+ *
72
+ * Every page ends with the literal line `reply <TOKEN>` (`composeBody` in
73
+ * modules/signal/scripts/notification.ts), and an operator who did exactly that
74
+ * was told `unrecognised` while the alert kept firing — the instruction the
75
+ * system gives was the one input it refused (#500). Two consecutive real
76
+ * operator replies were lost to it.
77
+ *
78
+ * Distinct from `ACK_VERB` on purpose: an ack synonym standing alone IS an
79
+ * acknowledgement (`bare_ack`), whereas `reply` alone is someone echoing the
80
+ * instruction without the token, which names nothing and stays unrecognised.
81
+ */
82
+ const LEADING_VERB = /^reply$/i;
83
+
84
+ /**
85
+ * Punctuation a phone added that the operator did not type.
86
+ *
87
+ * iOS turns a double space into a full stop by default, and a one-word reply is
88
+ * exactly where that fires. Stripping it costs nothing: authentication here is
89
+ * the token plus the sender check (see the header), never punctuation
90
+ * strictness.
91
+ */
92
+ function stripTrailingPunctuation(word: string): string {
93
+ return word.replace(/[.,!?;:]+$/u, '');
94
+ }
68
95
  /**
69
96
  * One to six characters of the token alphabet. Not anchored to the full
70
97
  * length: a prefix is legal input, and whether it identifies something is a
@@ -76,9 +103,15 @@ export function parseInbound(body: string): InboundIntent {
76
103
  const words = body.trim().split(/\s+/).filter(Boolean);
77
104
  if (words.length === 0) return { kind: 'unrecognised' };
78
105
 
106
+ // Drop a leading `reply` — instruction-echo, not content. Removed before
107
+ // anything else so the rest of the grammar is entirely unaffected by whether
108
+ // the operator included it.
109
+ if (LEADING_VERB.test(words[0])) words.shift();
110
+ if (words.length === 0) return { kind: 'unrecognised' };
111
+
79
112
  // Strip ack synonyms wherever they appear. What remains must be the token,
80
113
  // or nothing at all.
81
- const remainder = words.filter((word) => !ACK_VERB.test(word));
114
+ const remainder = words.filter((word) => !ACK_VERB.test(stripTrailingPunctuation(word)));
82
115
  if (remainder.length === 0) return { kind: 'bare_ack' };
83
116
 
84
117
  // More than one non-verb word is not a token with politeness around it, it
@@ -89,7 +122,7 @@ export function parseInbound(body: string): InboundIntent {
89
122
  // so is strictly better than doing the wrong thing quietly.
90
123
  if (remainder.length > 1) return { kind: 'unrecognised' };
91
124
 
92
- const token = normaliseToken(remainder[0]);
125
+ const token = normaliseToken(stripTrailingPunctuation(remainder[0]));
93
126
  if (!TOKEN_SHAPE.test(token)) return { kind: 'unrecognised' };
94
127
 
95
128
  return { kind: 'ack', token };