@celilo/cli 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,9 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
26
26
  - **IPAM (IP/VMID allocation)** — `apps/celilo/src/ipam/allocator.ts` — `allocateIPFromSubnet`, `allocateVMID`, `reserveIP`/`unreserveIP`, `inferZoneFromIP`, `getAllocation`. Auto-wrapper: `apps/celilo/src/ipam/auto-allocator.ts` — `allocateForModule` / `deallocateForModule`.
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.
29
+ - **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.
30
+ - **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.
31
+ - **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
29
32
  - **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
30
33
 
31
34
  ## Capability system (cross-module data & functions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@aws-sdk/client-s3": "^3.1024.0",
59
- "@celilo/capabilities": "^0.7.0",
59
+ "@celilo/capabilities": "^0.7.1",
60
60
  "@celilo/cli-display": "^0.1.9",
61
61
  "@celilo/core": "^0.1.0",
62
62
  "@celilo/event-bus": "^0.1.8",
@@ -12,10 +12,17 @@
12
12
  * - app: Internal services in home lab (VLAN 20, e.g., 10.0.20.0/24)
13
13
  * - secure: Auth/DB in home lab (VLAN 30, e.g., 10.0.30.0/24)
14
14
  *
15
+ * Control Plane:
16
+ * - secure-mgmt: Where celilo's own management server and management-plane modules
17
+ * run. A placement zone AND the control-plane tier: it sits outside the
18
+ * dmz->app->secure data-plane chain and reaches every data-plane tier by trust
19
+ * instead. Modules placed here inherit that reach, so placement is a privilege
20
+ * decision. celilo-mgmt may equally run in `internal`.
21
+ *
15
22
  * External Zone (Cloud/VPS):
16
23
  * - external: Services hosted outside home network (no VLAN, e.g., VPS on internet)
17
24
  */
18
- export type NetworkZone = 'internal' | 'dmz' | 'app' | 'secure' | 'external';
25
+ export type NetworkZone = 'internal' | 'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'external';
19
26
 
20
27
  export interface WellKnownCapability {
21
28
  canonical_hostname: string;
@@ -309,7 +309,13 @@ export async function handleIpamIpReserve(
309
309
  };
310
310
  }
311
311
 
312
- if (zone !== 'dmz' && zone !== 'app' && zone !== 'secure' && zone !== 'internal') {
312
+ if (
313
+ zone !== 'dmz' &&
314
+ zone !== 'app' &&
315
+ zone !== 'secure' &&
316
+ zone !== 'secure-mgmt' &&
317
+ zone !== 'internal'
318
+ ) {
313
319
  return {
314
320
  success: false,
315
321
  error: `Invalid zone: ${zone}. Must be internal, dmz, app, or secure`,
@@ -376,7 +382,13 @@ export async function handleIpamIpUnreserve(
376
382
  };
377
383
  }
378
384
 
379
- if (zone !== 'dmz' && zone !== 'app' && zone !== 'secure' && zone !== 'internal') {
385
+ if (
386
+ zone !== 'dmz' &&
387
+ zone !== 'app' &&
388
+ zone !== 'secure' &&
389
+ zone !== 'secure-mgmt' &&
390
+ zone !== 'internal'
391
+ ) {
380
392
  return {
381
393
  success: false,
382
394
  error: `Invalid zone: ${zone}. Must be internal, dmz, app, or secure`,
@@ -489,7 +501,7 @@ export async function handleIpamShow(
489
501
  lines.push('');
490
502
 
491
503
  // IP section - group by zone
492
- const zones = ['internal', 'dmz', 'app', 'secure'];
504
+ const zones = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
493
505
  for (const zone of zones) {
494
506
  const zoneAllocations = allocations.filter((a) => a.zone === zone);
495
507
  const zoneReservations = ipReservations.filter((r) => r.zone === zone);
@@ -31,6 +31,8 @@ export function reachabilityHint(zone: NetworkZone | string): string {
31
31
  case 'app':
32
32
  case 'secure':
33
33
  return `firewall-segmented (${zone}) — reach via the firewall's natIp DNAT, not routable directly from the LAN`;
34
+ case 'secure-mgmt':
35
+ return "celilo's control plane — firewall-segmented inbound like the other zones, but trusted OUTBOUND to every segmented tier";
34
36
  default:
35
37
  return `zone ${zone}`;
36
38
  }
@@ -50,6 +50,7 @@ export async function handleServiceAddDigitalOcean(
50
50
  { value: 'dmz', label: 'dmz' },
51
51
  { value: 'app', label: 'app' },
52
52
  { value: 'secure', label: 'secure' },
53
+ { value: 'secure-mgmt', label: 'secure-mgmt' },
53
54
  { value: 'external', label: 'external' },
54
55
  ],
55
56
  required: true,
@@ -73,6 +73,7 @@ export async function handleServiceAddProxmox(
73
73
  { value: 'dmz', label: 'dmz' },
74
74
  { value: 'app', label: 'app' },
75
75
  { value: 'secure', label: 'secure' },
76
+ { value: 'secure-mgmt', label: 'secure-mgmt' },
76
77
  { value: 'external', label: 'external' },
77
78
  ],
78
79
  required: true,
@@ -78,7 +78,9 @@ export async function handleServiceConfigSet(args: string[]): Promise<CommandRes
78
78
 
79
79
  if (key === 'zones') {
80
80
  // Parse as JSON array of NetworkZone
81
- const ZonesSchema = z.array(z.enum(['internal', 'dmz', 'app', 'secure', 'external']));
81
+ const ZonesSchema = z.array(
82
+ z.enum(['internal', 'dmz', 'app', 'secure', 'secure-mgmt', 'external']),
83
+ );
82
84
  const parsed = JSON.parse(valueRaw);
83
85
  const zones = ZonesSchema.parse(parsed) as NetworkZone[];
84
86
 
package/src/db/schema.ts CHANGED
@@ -197,7 +197,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
197
197
  .references(() => modules.id, { onDelete: 'cascade' }),
198
198
  vmid: integer('vmid').notNull().unique(),
199
199
  containerIp: text('container_ip').notNull().unique(), // CIDR format (e.g., "10.0.10.10/24")
200
- zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'internal'>().notNull(),
200
+ zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'>().notNull(),
201
201
  allocatedAt: integer('allocated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
202
202
  });
203
203
 
@@ -210,7 +210,7 @@ export const ipReservations = sqliteTable('ip_reservations', {
210
210
  id: integer('id').primaryKey({ autoIncrement: true }),
211
211
  ipStart: text('ip_start').notNull(), // Single IP or range start
212
212
  ipEnd: text('ip_end'), // NULL for single IP, end IP for range
213
- zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'internal'>().notNull(),
213
+ zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'>().notNull(),
214
214
  reason: text('reason').notNull(),
215
215
  reservedAt: integer('reserved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
216
216
  });
@@ -253,9 +253,26 @@ export const moduleBuilds = sqliteTable('module_builds', {
253
253
  * - dmz: Public-facing services in home lab (10.0.10.0/24)
254
254
  * - app: Internal application services (10.0.20.0/24)
255
255
  * - secure: Authentication and database services (10.0.30.0/24)
256
+ * - secure-mgmt: celilo's own control plane (management server + management-plane
257
+ * modules). Outside the data-plane tier chain; reaches every tier by trust.
256
258
  * - external: Internet-hosted services (outside home network)
257
259
  */
258
- export type NetworkZone = 'internal' | 'dmz' | 'app' | 'secure' | 'external';
260
+ export const NETWORK_ZONES = [
261
+ 'internal',
262
+ 'dmz',
263
+ 'app',
264
+ 'secure',
265
+ 'secure-mgmt',
266
+ 'external',
267
+ ] as const;
268
+
269
+ /**
270
+ * Derived from NETWORK_ZONES on purpose: a hand-maintained runtime copy of this
271
+ * list silently dropped `secure-mgmt`, so zone validation returned null for it
272
+ * and callers fell back to a wrong-but-valid zone. Deriving the type from the
273
+ * single array means a new zone cannot be added to one and missed by the other.
274
+ */
275
+ export type NetworkZone = (typeof NETWORK_ZONES)[number];
259
276
 
260
277
  /**
261
278
  * Container services table
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Control-plane network derivation.
3
+ *
4
+ * `trustedSubnets` is what lets celilo reach every segmented tier through a
5
+ * default-DROP FORWARD chain — it is the control plane. It used to be hardcoded
6
+ * to `network.internal.subnet`, which silently assumed the management server
7
+ * lives on the internal LAN. When it doesn't, celilo's own control plane is
8
+ * trusted by nothing and its network is absent from the resolver's split-horizon
9
+ * views (internal names then resolve publicly and can't be hairpinned).
10
+ *
11
+ * These tests pin the derivation: the trusted subnet follows wherever
12
+ * `celilo-mgmt` is actually deployed, and an install that predates
13
+ * celilo-mgmt-as-a-module keeps exactly today's behaviour.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import type { DbClient } from '../db/client';
18
+ import { moduleSystems, modules, systemConfig } from '../db/schema';
19
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
20
+ import { loadControlPlaneSubnet } from './capability-loader';
21
+
22
+ const INTERNAL = '192.168.0.0/24';
23
+ const DMZ = '10.0.10.0/24';
24
+ const SECURE_MGMT = '10.0.120.0/24';
25
+
26
+ describe('control-plane network derivation', () => {
27
+ let db: DbClient;
28
+
29
+ function setSubnet(zone: string, cidr: string) {
30
+ db.insert(systemConfig)
31
+ .values({ key: `network.${zone}.subnet`, value: cidr })
32
+ .run();
33
+ }
34
+
35
+ function deployCeliloMgmt(zone: string, ip: string) {
36
+ db.insert(modules)
37
+ .values({
38
+ id: 'celilo-mgmt',
39
+ name: 'celilo-mgmt',
40
+ version: '1.0.0',
41
+ manifestData: {},
42
+ sourcePath: '/tmp/celilo-mgmt',
43
+ })
44
+ .run();
45
+ db.insert(moduleSystems)
46
+ .values({
47
+ moduleId: 'celilo-mgmt',
48
+ name: 'main',
49
+ hostname: 'celilo-mgr',
50
+ ipv4Address: ip,
51
+ // biome-ignore lint/suspicious/noExplicitAny: zone is a NetworkZone literal
52
+ zone: zone as any,
53
+ infraType: 'machine',
54
+ })
55
+ .run();
56
+ }
57
+
58
+ beforeEach(async () => {
59
+ db = await setupTestDatabase();
60
+ });
61
+
62
+ afterEach(async () => {
63
+ await cleanupTestDatabase(db);
64
+ });
65
+
66
+ test('celilo-mgmt on the internal network → the internal subnet', () => {
67
+ setSubnet('internal', INTERNAL);
68
+ setSubnet('dmz', DMZ);
69
+ deployCeliloMgmt('internal', '192.168.0.10');
70
+
71
+ expect(loadControlPlaneSubnet(db)).toBe(INTERNAL);
72
+ });
73
+
74
+ test('celilo-mgmt on secure-mgmt → the secure-mgmt subnet, NOT internal', () => {
75
+ setSubnet('internal', INTERNAL);
76
+ setSubnet('secure-mgmt', SECURE_MGMT);
77
+ deployCeliloMgmt('secure-mgmt', '10.0.120.10');
78
+
79
+ // The production case: hardcoding internal here is exactly the bug.
80
+ expect(loadControlPlaneSubnet(db)).toBe(SECURE_MGMT);
81
+ });
82
+
83
+ test('celilo-mgmt deployed but its zone has no configured subnet → undefined', () => {
84
+ setSubnet('internal', INTERNAL);
85
+ deployCeliloMgmt('secure-mgmt', '10.0.120.10'); // no network.secure-mgmt.subnet
86
+
87
+ expect(loadControlPlaneSubnet(db)).toBeUndefined();
88
+ });
89
+
90
+ test('celilo-mgmt not deployed as a module → undefined (caller falls back)', () => {
91
+ setSubnet('internal', INTERNAL);
92
+
93
+ expect(loadControlPlaneSubnet(db)).toBeUndefined();
94
+ });
95
+
96
+ test('nothing configured at all → undefined, not a throw', () => {
97
+ expect(loadControlPlaneSubnet(db)).toBeUndefined();
98
+ });
99
+ });
@@ -661,10 +661,13 @@ const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const;
661
661
  interface FirewallZones {
662
662
  /** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */
663
663
  zoneTiers: Array<{ name: string; subnet: string }>;
664
- /** Trusted LAN subnets (internal) that reach every tier — celilo's control plane. */
664
+ /** Subnets that reach EVERY tier — celilo's control plane (see loadControlPlaneSubnet). */
665
665
  trustedSubnets: string[];
666
666
  }
667
667
 
668
+ /** The module that IS celilo's control plane; its network is what we trust. */
669
+ const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt';
670
+
668
671
  function readZoneSubnet(db: DbClient, zone: string): string | undefined {
669
672
  const row = db
670
673
  .select()
@@ -674,10 +677,33 @@ function readZoneSubnet(db: DbClient, zone: string): string | undefined {
674
677
  return row?.value ?? undefined;
675
678
  }
676
679
 
680
+ /**
681
+ * The subnet celilo's control plane occupies: the zone `celilo-mgmt` is actually
682
+ * deployed in — NOT a hardcoded zone.
683
+ *
684
+ * This used to read `network.internal.subnet` outright, on the assumption that the
685
+ * management server lives on the internal LAN. When it doesn't, celilo's own
686
+ * control plane is trusted by nothing: default-DROP then blocks the SSH it uses to
687
+ * run hooks/converges/Ansible on every deployed box, and the same unrecognized
688
+ * network is absent from the resolver's split-horizon views (so internal names
689
+ * resolve publicly and can't be hairpinned).
690
+ *
691
+ * `celilo-mgmt` may legitimately run in `internal` OR in `secure-mgmt`; deriving
692
+ * from where it landed supports both without hardcoding either.
693
+ */
694
+ export function loadControlPlaneSubnet(db: DbClient): string | undefined {
695
+ for (const system of getModuleSystems(CONTROL_PLANE_MODULE_ID, db)) {
696
+ const subnet = system.zone ? readZoneSubnet(db, system.zone) : undefined;
697
+ if (subnet) return subnet;
698
+ }
699
+ return undefined;
700
+ }
701
+
677
702
  /**
678
703
  * Read the firewall zone matrix inputs from system config (network.<zone>.subnet):
679
- * the segmented tiers [dmz, app, secure] and the trusted internal LAN. Zones with
680
- * no configured subnet are omitted — their traffic stays denied (fail-closed).
704
+ * the segmented tiers [dmz, app, secure] and the control-plane subnet that reaches
705
+ * all of them. Zones with no configured subnet are omitted — their traffic stays
706
+ * denied (fail-closed).
681
707
  */
682
708
  function loadFirewallZones(db: DbClient): FirewallZones {
683
709
  const zoneTiers: Array<{ name: string; subnet: string }> = [];
@@ -685,8 +711,12 @@ function loadFirewallZones(db: DbClient): FirewallZones {
685
711
  const subnet = readZoneSubnet(db, zone);
686
712
  if (subnet) zoneTiers.push({ name: zone, subnet });
687
713
  }
688
- const internal = readZoneSubnet(db, 'internal');
689
- return { zoneTiers, trustedSubnets: internal ? [internal] : [] };
714
+ // Fall back to the internal subnet when celilo-mgmt's location can't be
715
+ // determined (e.g. installs predating celilo-mgmt-as-a-module). Losing control-
716
+ // plane trust outright would brick celilo's own fleet management, so absent
717
+ // information preserves today's behaviour; the gap is REPORTED separately.
718
+ const controlPlane = loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal');
719
+ return { zoneTiers, trustedSubnets: controlPlane ? [controlPlane] : [] };
690
720
  }
691
721
 
692
722
  async function buildFirewallChain(
@@ -16,9 +16,9 @@ import { generateIPsInSubnet, isIPInRange, isInSubnet, stripCIDR } from './subne
16
16
  type DbOrTransaction = BunSQLiteDatabase<typeof schema> | DbClient;
17
17
 
18
18
  /** Zones that support IPAM auto-allocation of VMID and container IP */
19
- export type IpamZone = 'dmz' | 'app' | 'secure' | 'internal';
19
+ export type IpamZone = 'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal';
20
20
 
21
- const IPAM_ZONES: IpamZone[] = ['internal', 'dmz', 'app', 'secure'];
21
+ const IPAM_ZONES: IpamZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
22
22
 
23
23
  /**
24
24
  * Infer which zone an IP address belongs to by checking configured zone subnets.
@@ -321,7 +321,7 @@ export const SystemResourceSchema = z.object({
321
321
  'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.',
322
322
  ),
323
323
  zone: z
324
- .enum(['internal', 'dmz', 'app', 'secure', 'external'])
324
+ .enum(['internal', 'dmz', 'app', 'secure', 'secure-mgmt', 'external'])
325
325
  .describe('Required security zone for this module'),
326
326
  });
327
327
 
@@ -225,12 +225,20 @@ export async function materializeAspectAnsible(args: {
225
225
  // ambient operator key — same as a normal LXC deploy (ISS-0028).
226
226
  const inventoryHosts: InventoryHost[] = [];
227
227
  for (const t of targetSystems) {
228
- const keyPath = t.machineId ? await writeTemporarySshKey(t.machineId) : undefined;
228
+ // The management box registers itself in the machine pool as 127.0.0.1 and
229
+ // stores NO ssh key -- it does not need one, because Ansible reaches it with
230
+ // the local connection. Mirrors the module-deploy inventory path
231
+ // (ansible/inventory.ts). Without this the fan-out tried `ssh root@127.0.0.1`
232
+ // with an empty key file and the host failed UNREACHABLE with an opaque
233
+ // "error in libcrypto", so celilo could not configure its own resolv.conf.
234
+ const isLocal = t.ipAddress === '127.0.0.1';
235
+ const keyPath = t.machineId && !isLocal ? await writeTemporarySshKey(t.machineId) : undefined;
229
236
  inventoryHosts.push({
230
237
  hostname: t.hostname,
231
238
  ansibleHost: t.ipAddress,
232
239
  ansibleUser: t.sshUser,
233
240
  groups: ['aspect_targets', t.zone],
241
+ local: isLocal,
234
242
  ansibleSshPrivateKeyFile: keyPath,
235
243
  });
236
244
 
@@ -11,7 +11,13 @@ import {
11
11
  moduleSystems,
12
12
  modules,
13
13
  } from '../db/schema';
14
- import { backfillModuleSystems, getModuleSystems, upsertDeployedSystem } from './deployed-systems';
14
+ import type { ModuleManifest } from '../manifest/schema';
15
+ import {
16
+ backfillModuleSystems,
17
+ getModuleSystems,
18
+ recordDeployedSystemForModule,
19
+ upsertDeployedSystem,
20
+ } from './deployed-systems';
15
21
 
16
22
  const TEST_DB_PATH = './test-deployed-systems.db';
17
23
 
@@ -209,6 +215,109 @@ describe('backfillModuleSystems', () => {
209
215
  expect(systems[0].infrastructure.vmid).toBeUndefined();
210
216
  });
211
217
 
218
+ // The control plane is recorded by THIS path, not the deploy path, so the two
219
+ // must agree on zone precedence. They didn't: the deploy path took the machine's
220
+ // zone while backfill still took the manifest's, so celilo-mgmt was persisted as
221
+ // `internal` while sitting on `secure-mgmt` — and the firewall trusted the wrong
222
+ // subnet, silently dropping celilo's own SSH to the fleet.
223
+ test('backfill records the MACHINE zone, matching the deploy path', () => {
224
+ db.insert(machines)
225
+ .values({
226
+ id: 'm-mgmt-bf',
227
+ hostname: 'celilo-mgr',
228
+ ipAddress: '10.0.120.100',
229
+ sshUser: 'root',
230
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
231
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
232
+ zone: 'secure-mgmt',
233
+ })
234
+ .run();
235
+ db.insert(modules)
236
+ .values({
237
+ id: 'celilo-mgmt',
238
+ name: 'celilo-mgmt',
239
+ version: '1.0.0',
240
+ manifestData: { requires: { system: { zone: 'internal' } } },
241
+ sourcePath: '/tmp/celilo-mgmt',
242
+ state: 'VERIFIED',
243
+ })
244
+ .run();
245
+ db.insert(moduleInfrastructure)
246
+ .values({
247
+ id: 'infra-celilo-mgmt',
248
+ moduleId: 'celilo-mgmt',
249
+ infrastructureType: 'machine',
250
+ machineId: 'm-mgmt-bf',
251
+ })
252
+ .run();
253
+ db.insert(moduleConfigs)
254
+ .values({
255
+ moduleId: 'celilo-mgmt',
256
+ key: 'hostname',
257
+ value: 'celilo-mgr',
258
+ valueJson: '"celilo-mgr"',
259
+ })
260
+ .run();
261
+
262
+ expect(backfillModuleSystems(db)).toEqual(['celilo-mgmt']);
263
+ expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({
264
+ zone: 'secure-mgmt',
265
+ ipv4_address: '10.0.120.100',
266
+ });
267
+ });
268
+
269
+ // The control-plane case. celilo-mgmt declares `internal` (it bootstraps before
270
+ // any firewall exists) but in a segmented fleet the operator earmarks a box on
271
+ // `secure-mgmt`. What gets RECORDED must be where the system actually is, since
272
+ // the firewall's trusted source is derived from it — recording the manifest's
273
+ // zone silently trusts the wrong subnet and drops celilo's own SSH to the fleet.
274
+ //
275
+ // This fails two distinct ways before the fix: the machine's zone was never
276
+ // consulted, AND a hand-maintained zone list omitted `secure-mgmt`, so validating
277
+ // it returned null and fell back to a wrong-but-valid zone.
278
+ test('records the zone of the MACHINE it landed on, not the manifest minimum', async () => {
279
+ db.insert(machines)
280
+ .values({
281
+ id: 'm-mgmt',
282
+ hostname: 'celilo-mgr',
283
+ ipAddress: '10.0.120.100',
284
+ sshUser: 'root',
285
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
286
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
287
+ zone: 'secure-mgmt',
288
+ earmarkedModule: 'celilo-mgmt',
289
+ })
290
+ .run();
291
+ db.insert(modules)
292
+ .values({
293
+ id: 'celilo-mgmt',
294
+ name: 'celilo-mgmt',
295
+ version: '1.0.0',
296
+ manifestData: { requires: { system: { zone: 'internal' } } },
297
+ sourcePath: '/tmp/celilo-mgmt',
298
+ state: 'VERIFIED',
299
+ })
300
+ .run();
301
+ db.insert(moduleConfigs)
302
+ .values({
303
+ moduleId: 'celilo-mgmt',
304
+ key: 'hostname',
305
+ value: 'celilo-mgr',
306
+ valueJson: '"celilo-mgr"',
307
+ })
308
+ .run();
309
+
310
+ const recorded = await recordDeployedSystemForModule(
311
+ 'celilo-mgmt',
312
+ { requires: { system: { zone: 'internal' } } } as ModuleManifest,
313
+ { type: 'machine', machineId: 'm-mgmt' },
314
+ db,
315
+ );
316
+
317
+ expect(recorded[0]).toMatchObject({ zone: 'secure-mgmt', ipv4_address: '10.0.120.100' });
318
+ expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({ zone: 'secure-mgmt' });
319
+ });
320
+
212
321
  test('skips an API-only module (no declared systems)', () => {
213
322
  ensureProxmoxService(db);
214
323
  db.insert(modules)
@@ -2,6 +2,7 @@ import type { DeployedSystem } from '@celilo/capabilities';
2
2
  import { and, eq, inArray } from 'drizzle-orm';
3
3
  import type { DbClient } from '../db/client';
4
4
  import {
5
+ NETWORK_ZONES,
5
6
  type NetworkZone,
6
7
  machines,
7
8
  moduleConfigs,
@@ -198,9 +199,7 @@ export function upsertDeployedSystem(
198
199
  .run();
199
200
  }
200
201
 
201
- const NETWORK_ZONES: readonly NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'external'];
202
-
203
- function asZone(value: string | undefined): NetworkZone | null {
202
+ function asZone(value: string | null | undefined): NetworkZone | null {
204
203
  return value && (NETWORK_ZONES as readonly string[]).includes(value)
205
204
  ? (value as NetworkZone)
206
205
  : null;
@@ -238,19 +237,21 @@ export async function recordDeployedSystemForModule(
238
237
  // IP: the module's resolved target_ip, else ip.primary, else the assigned
239
238
  // machine's own IP (machine-pool deploys where neither was written).
240
239
  let ip = cfg('target_ip') ?? cfg('ip.primary');
241
- if (!ip && infrastructure?.machineId) {
242
- const machine = db
243
- .select()
244
- .from(machines)
245
- .where(eq(machines.id, infrastructure.machineId))
246
- .get();
247
- ip = machine?.ipAddress;
248
- }
240
+ const machine = infrastructure?.machineId
241
+ ? db.select().from(machines).where(eq(machines.id, infrastructure.machineId)).get()
242
+ : undefined;
243
+ if (!ip) ip = machine?.ipAddress;
249
244
  if (!ip) return [];
250
245
 
251
246
  // Single-system transition: take the first declared system's name + zone.
252
247
  const decl = declared[0];
253
- const zone = asZone(decl.resources.zone) ?? asZone(cfg('zone'));
248
+ // A machine-pool deploy lands on a specific box, and THAT box's zone is where
249
+ // the system actually is. `requires.system.zone` is the minimum used to select
250
+ // a host — not a description of the result — exactly as `requires.system.memory`
251
+ // is a floor rather than the deployed size. Recording the manifest's zone here
252
+ // would report the control plane as living on `internal` while it sits on
253
+ // `secure-mgmt`, which is the misreading this whole change exists to remove.
254
+ const zone = asZone(machine?.zone) ?? asZone(decl.resources.zone) ?? asZone(cfg('zone'));
254
255
  if (!zone) {
255
256
  throw new Error(
256
257
  `Cannot record deployed system for '${moduleId}': no resolvable network zone (checked requires.systems[].resources.zone and config.zone).`,
@@ -324,13 +325,19 @@ export function backfillModuleSystems(db: DbClient): string[] {
324
325
  // IP (machine-pool deploy where neither was written). Mirrors
325
326
  // recordDeployedSystemForModule. CIDR is stripped by upsertDeployedSystem.
326
327
  let ip = cfg('target_ip') ?? cfg('ip.primary');
327
- if (!ip && infra.machineId) {
328
- ip = db.select().from(machines).where(eq(machines.id, infra.machineId)).get()?.ipAddress;
329
- }
328
+ const machine = infra.machineId
329
+ ? db.select().from(machines).where(eq(machines.id, infra.machineId)).get()
330
+ : undefined;
331
+ if (!ip) ip = machine?.ipAddress;
330
332
  if (!ip) continue;
331
333
 
332
334
  const decl = declared[0];
333
- const zone = asZone(decl.resources.zone) ?? asZone(cfg('zone'));
335
+ // Same precedence as recordDeployedSystemForModule: the machine a deploy
336
+ // landed on is where the system IS; `requires.system.zone` is only the
337
+ // minimum used to pick a host. These two paths must agree — the control
338
+ // plane is recorded HERE, not by the deploy path, so a divergence between
339
+ // them is invisible until the firewall trusts the wrong subnet.
340
+ const zone = asZone(machine?.zone) ?? asZone(decl.resources.zone) ?? asZone(cfg('zone'));
334
341
  // Can't address a system without a zone; skip rather than abort the whole
335
342
  // backfill (a re-deploy will record it properly).
336
343
  if (!zone) continue;
@@ -10,12 +10,14 @@ import {
10
10
  moduleConfigs,
11
11
  moduleSystems,
12
12
  modules,
13
+ systemConfig,
13
14
  } from '../db/schema';
14
15
  import type { ModuleManifest } from '../manifest/schema';
15
16
  import { setupTestDatabase } from '../test-utils/setup-test-db';
16
17
  import { getDaemonUnitPath } from './events-daemon';
17
18
  import {
18
19
  checkCapabilityProviders,
20
+ checkControlPlaneNetwork,
19
21
  checkDispatcher,
20
22
  checkSchemaDrift,
21
23
  checkServiceDns,
@@ -508,3 +510,96 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
508
510
  expect(broken[0].reason).toBe('empty-value');
509
511
  });
510
512
  });
513
+
514
+ describe('checkControlPlaneNetwork', () => {
515
+ let db: DbClient;
516
+ let cpDir: string;
517
+
518
+ function setSubnet(zone: string, cidr: string) {
519
+ db.insert(systemConfig)
520
+ .values({ key: `network.${zone}.subnet`, value: cidr })
521
+ .run();
522
+ }
523
+
524
+ function deployCeliloMgmt(zone: string, ip: string) {
525
+ db.insert(modules)
526
+ .values({
527
+ id: 'celilo-mgmt',
528
+ name: 'celilo-mgmt',
529
+ version: '1.0.0',
530
+ manifestData: {},
531
+ sourcePath: '/tmp/celilo-mgmt',
532
+ })
533
+ .run();
534
+ db.insert(moduleSystems)
535
+ .values({
536
+ moduleId: 'celilo-mgmt',
537
+ name: 'main',
538
+ hostname: 'celilo-mgr',
539
+ ipv4Address: ip,
540
+ zone: zone as 'internal' | 'secure-mgmt',
541
+ infraType: 'machine',
542
+ })
543
+ .run();
544
+ }
545
+
546
+ beforeEach(async () => {
547
+ cpDir = mkdtempSync(join(tmpdir(), 'fleet-cpn-'));
548
+ db = await setupTestDatabase(join(cpDir, 'celilo.db'));
549
+ });
550
+
551
+ afterEach(() => {
552
+ try {
553
+ rmSync(cpDir, { recursive: true, force: true });
554
+ } catch {
555
+ /* ignore */
556
+ }
557
+ });
558
+
559
+ it('is ok when the control-plane network is a configured zone', () => {
560
+ setSubnet('secure-mgmt', '10.0.120.0/24');
561
+ deployCeliloMgmt('secure-mgmt', '10.0.120.10');
562
+
563
+ const finding = checkControlPlaneNetwork(db);
564
+ expect(finding.status).toBe('ok');
565
+ expect(finding.summary).toContain('10.0.120.0/24');
566
+ expect(finding.remediation).toBeNull();
567
+ });
568
+
569
+ it('warns — naming BOTH consequences — when the network is unrecognized', () => {
570
+ // The production case: celilo-mgr on a network with no configured subnet.
571
+ deployCeliloMgmt('secure-mgmt', '10.0.120.10');
572
+
573
+ const finding = checkControlPlaneNetwork(db);
574
+ expect(finding.status).toBe('warn');
575
+ const detail = finding.detail.join(' ');
576
+ // Silence is the bug; the report must name what actually breaks.
577
+ expect(detail).toContain('firewall');
578
+ expect(detail).toContain('resolver');
579
+ expect(detail).toContain('hairpin');
580
+ // ...and where it is, so the operator can act.
581
+ expect(detail).toContain('10.0.120.10');
582
+ });
583
+
584
+ it('remediation names the concrete config key to set', () => {
585
+ deployCeliloMgmt('secure-mgmt', '10.0.120.10');
586
+
587
+ const finding = checkControlPlaneNetwork(db);
588
+ expect(finding.remediation).toContain('network.secure-mgmt.subnet');
589
+ });
590
+
591
+ it('warns when celilo-mgmt is not deployed at all', () => {
592
+ const finding = checkControlPlaneNetwork(db);
593
+ expect(finding.status).toBe('warn');
594
+ expect(finding.detail.join(' ')).toContain('no deployed systems');
595
+ });
596
+
597
+ it('is ok for the common case: control plane on the internal LAN', () => {
598
+ setSubnet('internal', '192.168.0.0/24');
599
+ deployCeliloMgmt('internal', '192.168.0.10');
600
+
601
+ const finding = checkControlPlaneNetwork(db);
602
+ expect(finding.status).toBe('ok');
603
+ expect(finding.summary).toContain('192.168.0.0/24');
604
+ });
605
+ });
@@ -23,8 +23,11 @@ import { getModuleStoragePath } from '../config/paths';
23
23
  import type { DbClient } from '../db/client';
24
24
  import { capabilities as capabilitiesTable, modules } from '../db/schema';
25
25
  import { findSchemaDrift } from '../db/schema-introspection';
26
- import { resolveFirewallNatIp } from '../hooks/capability-loader';
26
+ import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
27
27
  import type { ModuleManifest } from '../manifest/schema';
28
+
29
+ /** The module that IS celilo's control plane. */
30
+ const CONTROL_PLANE_MODULE = 'celilo-mgmt';
28
31
  import { getModuleSystems } from './deployed-systems';
29
32
  import { listDnsInternalRecords } from './dns-internal-records';
30
33
  import { type SupervisorPlatform, readInstalledUnit } from './events-daemon';
@@ -663,6 +666,68 @@ export interface RunFleetChecksOptions {
663
666
  * gating (skip when there's no celilo DB) and rendering; this just
664
667
  * returns the findings, in the order they're shown.
665
668
  */
669
+ /**
670
+ * celilo's control plane must sit on a network celilo RECOGNIZES. When it doesn't,
671
+ * two things break silently and in different subsystems:
672
+ *
673
+ * 1. the firewall's trusted sources don't cover it, so a default-DROP FORWARD
674
+ * chain blocks celilo's own SSH to every deployed box;
675
+ * 2. the internal resolver has no split-horizon view for it, so it answers
676
+ * NOERROR with ZERO records — indistinguishable from "no such name". The name
677
+ * then falls through to public DNS and the box tries to hairpin off the WAN IP.
678
+ *
679
+ * Neither surfaces where the cause is. In production this presented as
680
+ * `apt-get update` timing out against the celilo-hosted apt repo — three layers
681
+ * from the actual misconfiguration, and it took a multi-step investigation to
682
+ * locate precisely because every layer degraded politely. Hence this check.
683
+ */
684
+ export function checkControlPlaneNetwork(db: DbClient): FleetFinding {
685
+ const systems = getModuleSystems(CONTROL_PLANE_MODULE, db);
686
+ const subnet = loadControlPlaneSubnet(db);
687
+
688
+ if (subnet) {
689
+ return {
690
+ id: 'control-plane-network',
691
+ title: "celilo's own network is recognized (firewall trust + internal DNS)",
692
+ status: 'ok',
693
+ summary: `control plane on ${subnet}`,
694
+ detail: [],
695
+ remediation: null,
696
+ autoFixable: false,
697
+ };
698
+ }
699
+
700
+ const notDeployed = systems.length === 0;
701
+ const zones = [...new Set(systems.map((sys) => sys.zone).filter(Boolean))];
702
+ const addresses = systems.map((sys) => sys.ipv4_address).filter(Boolean);
703
+
704
+ const detail = notDeployed
705
+ ? [`no deployed systems found for '${CONTROL_PLANE_MODULE}'`]
706
+ : [
707
+ `'${CONTROL_PLANE_MODULE}' is in zone(s): ${zones.join(', ') || '(unset)'}`,
708
+ `address(es): ${addresses.join(', ') || '(unknown)'}`,
709
+ 'no network.<zone>.subnet is configured for that zone',
710
+ ];
711
+ detail.push(
712
+ 'consequence 1: firewall trusted sources fall back to network.internal.subnet — celilo may be unable to reach segmented zones',
713
+ 'consequence 2: the internal resolver has no view for this network — internal names resolve publicly and cannot be hairpinned',
714
+ );
715
+
716
+ return {
717
+ id: 'control-plane-network',
718
+ title: "celilo's own network is recognized (firewall trust + internal DNS)",
719
+ status: 'warn',
720
+ summary: notDeployed
721
+ ? "cannot determine celilo's control-plane network — falling back to the internal subnet"
722
+ : "celilo's control-plane network is not a configured zone",
723
+ detail,
724
+ remediation: notDeployed
725
+ ? `deploy '${CONTROL_PLANE_MODULE}' so celilo knows where its control plane runs, or set network.internal.subnet if it lives on the internal LAN`
726
+ : `run \`celilo system config set network.${zones[0] ?? '<zone>'}.subnet <cidr>\` for the control plane's network, then reconcile the firewall and the internal resolver`,
727
+ autoFixable: false,
728
+ };
729
+ }
730
+
666
731
  export async function runFleetChecks(
667
732
  bus: Bus,
668
733
  db: DbClient,
@@ -673,6 +738,7 @@ export async function runFleetChecks(
673
738
  checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
674
739
  checkSubscribers(bus, db),
675
740
  checkCapabilityProviders(db),
741
+ checkControlPlaneNetwork(db),
676
742
  await checkServiceDns(db),
677
743
  ];
678
744
  }
@@ -130,9 +130,16 @@ export async function selectInfrastructure(module: Module): Promise<Infrastructu
130
130
  const zone = requirements.zone;
131
131
  const moduleId = module.id;
132
132
 
133
- // 0. Check for earmarked machines first (highest priority)
134
- const earmarkedMachines = await listMachines({ zone });
135
- const earmarked = earmarkedMachines.find((m) => m.earmarkedModule === moduleId);
133
+ // 0. Check for earmarked machines first (highest priority).
134
+ // Deliberately NOT zone-filtered: an earmark is the operator stating outright
135
+ // which box a module goes on, so it outranks the manifest's zone requirement
136
+ // (which exists to *pick* a host when nobody said). Filtering by zone made an
137
+ // earmarked machine silently invisible and produced a "no infrastructure in
138
+ // zone X" error that never mentioned the box the operator had named. The
139
+ // control plane is the case that forced this: celilo-mgmt declares `internal`
140
+ // (it bootstraps before any firewall exists) but legitimately lives on
141
+ // `secure-mgmt` in a segmented fleet. Role validation below still applies.
142
+ const earmarked = (await listMachines()).find((m) => m.earmarkedModule === moduleId);
136
143
  if (earmarked) {
137
144
  validateMachineRoleForModule(earmarked, module);
138
145
  return {
@@ -16,6 +16,51 @@ import {
16
16
  writeTemporarySshKey,
17
17
  } from './ssh-key-manager';
18
18
 
19
+ describe('a machine with no stored SSH key fails loudly, not silently', () => {
20
+ // Regression: the management box registers itself in the machine pool as
21
+ // 127.0.0.1 with NO ssh key -- it does not need one, because Ansible reaches
22
+ // it over the local connection. writeTemporarySshKey happily wrote a 0-byte
23
+ // file, and the failure only surfaced much later inside Ansible as
24
+ // Load key "/tmp/celilo-ansible-keys/machine-<uuid>.key": error in libcrypto
25
+ // root@127.0.0.1: Permission denied (publickey)
26
+ // naming neither the machine nor the missing key. The host went UNREACHABLE
27
+ // and celilo could not configure its own resolv.conf.
28
+ let testDir2: string;
29
+
30
+ beforeEach(async () => {
31
+ testDir2 = mkdtempSync(join(tmpdir(), 'celilo-nokey-'));
32
+ process.env.CELILO_DB_PATH = join(testDir2, 'test.db');
33
+ process.env.CELILO_DATA_DIR = join(testDir2, 'data');
34
+ await runMigrations(join(testDir2, 'test.db'));
35
+ const masterKeyPath = join(testDir2, 'data', 'master.key');
36
+ process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
37
+ const fs = await import('node:fs/promises');
38
+ await fs.mkdir(join(testDir2, 'data'), { recursive: true });
39
+ await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8');
40
+ });
41
+
42
+ afterEach(() => {
43
+ closeDb();
44
+ rmSync(testDir2, { recursive: true, force: true });
45
+ });
46
+
47
+ it('throws instead of writing a 0-byte key file', async () => {
48
+ const machine = await addMachine({
49
+ hostname: 'celilo-mgr',
50
+ zone: 'internal',
51
+ ipAddress: '127.0.0.1',
52
+ sshUser: 'root',
53
+ sshKey: '',
54
+ hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 100 },
55
+ role: 'host',
56
+ interfaces: [],
57
+ assignedModuleIds: [],
58
+ });
59
+
60
+ await expect(writeTemporarySshKey(machine.id)).rejects.toThrow(/has no SSH key stored/);
61
+ });
62
+ });
63
+
19
64
  describe('ssh-key-manager', () => {
20
65
  let testDbPath: string;
21
66
  let testDir: string;
@@ -47,6 +47,18 @@ export async function writeTemporarySshKey(machineId: string): Promise<string> {
47
47
  // Get decrypted SSH key from database
48
48
  const keyContent = await getMachineSshKey(machineId);
49
49
 
50
+ // Refuse to write an empty key. A machine with no stored key (e.g. the
51
+ // management box, which registers itself as 127.0.0.1 and is reached by
52
+ // Ansible's LOCAL connection) previously produced a 0-byte file here; ssh
53
+ // then failed far downstream with "Load key ...: error in libcrypto" and the
54
+ // host went UNREACHABLE, naming neither the machine nor the missing key.
55
+ // Callers that legitimately have no key must not ask for one.
56
+ if (keyContent.trim() === '') {
57
+ throw new Error(
58
+ `Machine ${machineId} has no SSH key stored, so no key file can be written. If this is the local management box, use an Ansible local connection (InventoryHost.local) instead of pinning a key.`,
59
+ );
60
+ }
61
+
50
62
  // Write to temporary file with restrictive permissions
51
63
  const keyPath = getTempKeyPath(machineId);
52
64
  writeFileSync(keyPath, keyContent, { mode: 0o600 });
@@ -73,7 +73,7 @@ async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
73
73
  */
74
74
  export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
75
75
  // Check each zone's subnet
76
- const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure'];
76
+ const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
77
77
 
78
78
  for (const zone of zones) {
79
79
  const subnet = await getZoneSubnet(zone);
@@ -368,7 +368,14 @@ export async function buildResolutionContext(
368
368
  name: decl.name,
369
369
  hostname,
370
370
  ipv4Address: machineRow.ipAddress,
371
- zone,
371
+ // The machine's own zone, matching recordDeployedSystemForModule and
372
+ // backfillModuleSystems. This is the THIRD place that decides a
373
+ // deployed system's zone, and it ran last — so while the other two
374
+ // took the machine's zone, this one kept overwriting it with the
375
+ // manifest's, and the control plane stayed recorded as `internal`.
376
+ // `requires.system.zone` is the minimum used to SELECT a host; the
377
+ // machine we selected is where the system actually is.
378
+ zone: machineRow.zone ?? zone,
372
379
  infraType: 'machine',
373
380
  machineId: infraSelection.machineId,
374
381
  });