@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.
- package/CELILO_SUBSYSTEMS.md +3 -0
- package/package.json +2 -2
- package/src/capabilities/well-known.ts +8 -1
- package/src/cli/commands/ipam.ts +15 -3
- package/src/cli/commands/module-where.ts +2 -0
- package/src/cli/commands/service-add-digitalocean.ts +1 -0
- package/src/cli/commands/service-add-proxmox.ts +1 -0
- package/src/cli/commands/service-config-set.ts +3 -1
- package/src/db/schema.ts +20 -3
- package/src/hooks/capability-loader-control-plane.test.ts +99 -0
- package/src/hooks/capability-loader.ts +35 -5
- package/src/ipam/allocator.ts +2 -2
- package/src/manifest/schema.ts +1 -1
- package/src/services/aspect-runner.ts +9 -1
- package/src/services/deployed-systems.test.ts +110 -1
- package/src/services/deployed-systems.ts +23 -16
- package/src/services/fleet-checks.test.ts +95 -0
- package/src/services/fleet-checks.ts +67 -1
- package/src/services/infrastructure-selector.ts +10 -3
- package/src/services/ssh-key-manager.test.ts +45 -0
- package/src/services/ssh-key-manager.ts +12 -0
- package/src/services/zone-detector.ts +1 -1
- package/src/variables/context.ts +8 -1
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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;
|
package/src/cli/commands/ipam.ts
CHANGED
|
@@ -309,7 +309,13 @@ export async function handleIpamIpReserve(
|
|
|
309
309
|
};
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
if (
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
680
|
-
* no configured subnet are omitted — their traffic stays
|
|
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
|
-
|
|
689
|
-
|
|
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(
|
package/src/ipam/allocator.ts
CHANGED
|
@@ -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.
|
package/src/manifest/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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);
|
package/src/variables/context.ts
CHANGED
|
@@ -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
|
});
|