@celilo/cli 0.24.1 → 0.25.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.
@@ -354,6 +354,68 @@ export const SystemResourceSchema = z.object({
354
354
  zone: z.enum(NETWORK_ZONES).describe('Required security zone for this module'),
355
355
  });
356
356
 
357
+ /**
358
+ * A network a module depends on, declared under `requires.networks`
359
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
360
+ *
361
+ * It names the network and stops there. celilo owns the network namespace: the
362
+ * range belongs to the operator's topology, not to whichever module happens to
363
+ * arrive first and need it. A module that carried the value here would be
364
+ * defining the network by another route, which is the authority this change
365
+ * reverses — so `.strict()` is load-bearing rather than tidiness, and its
366
+ * message says why.
367
+ *
368
+ * When the named network has no `network.<name>.subnet` in system config, the
369
+ * deploy asks for one BEFORE anything is generated or any hook runs
370
+ * ([[services/network-ensure.ts]]).
371
+ *
372
+ * ── Two forms, because some requirements are not knowable at authoring time ──
373
+ *
374
+ * `name:` is a literal — `wireguard` always needs `control-plane-vpn`, and says
375
+ * so once. `from:` names a config array whose VALUES are the network names, for
376
+ * a module whose set is decided per install: a firewall requires the networks it
377
+ * has legs on, and which legs it has is a property of the box it lands on.
378
+ *
379
+ * `from:` is not a loophole in "a requirement carries no value". It still names
380
+ * networks and still carries none — it just names them indirectly. What it fixes
381
+ * is the split that `firewall-interface-classification` §3 already diagnosed from
382
+ * the harness side: the legs a firewall has and the networks it declares were two
383
+ * hand-maintained lists, and every one of the ~20 call sites that had to keep
384
+ * them in step got it wrong. A leg whose network has no declared subnet
385
+ * classifies `alien`, so under-declaring is not cosmetic — it is how an interface
386
+ * gets isolated. One list makes that structurally impossible.
387
+ */
388
+ export const NetworkRequirementSchema = z
389
+ .object({
390
+ name: z
391
+ .string()
392
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'network name must be kebab-case')
393
+ .describe('Name of a network this module needs defined before it deploys')
394
+ .optional(),
395
+ from: z
396
+ .string()
397
+ .regex(
398
+ /^\$self:[a-zA-Z_][a-zA-Z0-9_]*$/,
399
+ 'from must reference one of this module\'s own config values, e.g. "$self:zones"',
400
+ )
401
+ .describe('A config array whose values are network names, e.g. "$self:zones"')
402
+ .optional(),
403
+ })
404
+ .strict(
405
+ 'A network requirement NAMES networks; it never carries their values. celilo owns ' +
406
+ 'the network namespace — the range is supplied before the deploy runs — so a ' +
407
+ 'requirement takes `name:` (a literal) or `from:` (a $self: config array of names) ' +
408
+ 'and nothing else. Read the range with `source: system` / ' +
409
+ '`derive_from: "$system:network.<name>.subnet"`.',
410
+ )
411
+ .refine((requirement) => Boolean(requirement.name) !== Boolean(requirement.from), {
412
+ message:
413
+ 'A network requirement declares exactly one of `name:` (a literal network) or ' +
414
+ '`from:` (a $self: config array whose values are network names)',
415
+ });
416
+
417
+ export type NetworkRequirement = z.infer<typeof NetworkRequirementSchema>;
418
+
357
419
  /**
358
420
  * One system a module deploys (openspec/specs/module-systems-addressing/spec.md). A module
359
421
  * declares 0..N of these under `requires.systems`. `name` is the stable
@@ -505,6 +567,11 @@ export const ModuleManifestSchema = z
505
567
  * `systems` gets one host per entry, each addressable as `$infra:<name>`.
506
568
  */
507
569
  systems: z.array(SystemDeclarationSchema).optional(),
570
+ /**
571
+ * Networks that must be DEFINED in celilo's own config before this
572
+ * module deploys. Names only — see `NetworkRequirementSchema`.
573
+ */
574
+ networks: z.array(NetworkRequirementSchema).optional(),
508
575
  /**
509
576
  * Singular form — sugar for a single unnamed system. Normalized to one
510
577
  * `systems` entry (name `main`) by `getDeclaredSystems`. Optional because
@@ -857,3 +924,60 @@ export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[
857
924
  export function getSingularSystemSpec(manifest: ModuleManifest): SystemResource | undefined {
858
925
  return manifest.requires?.system;
859
926
  }
927
+
928
+ /**
929
+ * The network names a module requires
930
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
931
+ * Deduplicated and in declaration order. Empty for the modules that depend on no
932
+ * network, which is most of them.
933
+ *
934
+ * `from:` entries are resolved against `config` — the module's own values, as
935
+ * answered by the config interview. A module whose required set is decided per
936
+ * install (a firewall requires the networks it has legs on) therefore needs its
937
+ * config populated before this is meaningful, which is why the ensure runs after
938
+ * the config interview and before generation.
939
+ *
940
+ * `external` is dropped wherever it appears. It is the RESIDUAL — an interface is
941
+ * `external` because it is publicly routable and matched no declared zone, never
942
+ * because it is contained in a subnet — so giving it one would reintroduce the
943
+ * overload that `readDeclaredNetworks` and `deployFirewall` both already exclude.
944
+ */
945
+ export function getRequiredNetworkNames(
946
+ manifest: ModuleManifest,
947
+ config: Record<string, unknown> = {},
948
+ ): string[] {
949
+ const names: string[] = [];
950
+ for (const requirement of manifest.requires?.networks ?? []) {
951
+ if (requirement.name) {
952
+ names.push(requirement.name);
953
+ continue;
954
+ }
955
+ if (!requirement.from) continue;
956
+ const key = requirement.from.slice('$self:'.length);
957
+ names.push(...asNetworkNameArray(config[key]));
958
+ }
959
+ return [...new Set(names)].filter((name) => name !== 'external');
960
+ }
961
+
962
+ /**
963
+ * Narrow a `from:`-referenced config value to network names.
964
+ *
965
+ * Tolerates the JSON-string form as well as a real array: an array config value
966
+ * arrives as either depending on how it was written, and a requirement that
967
+ * silently resolved to nothing would leave a leg undeclared — which is the
968
+ * failure this whole mechanism exists to prevent.
969
+ */
970
+ function asNetworkNameArray(raw: unknown): string[] {
971
+ const value =
972
+ typeof raw === 'string' && raw.trim().startsWith('[')
973
+ ? (() => {
974
+ try {
975
+ return JSON.parse(raw) as unknown;
976
+ } catch {
977
+ return undefined;
978
+ }
979
+ })()
980
+ : raw;
981
+ if (!Array.isArray(value)) return [];
982
+ return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
983
+ }
@@ -38,6 +38,7 @@ import { getModuleSystems } from './deployed-systems';
38
38
  import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
39
39
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
40
40
  import { findMachineForModule } from './machine-pool';
41
+ import { ensureRequiredNetworks } from './network-ensure';
41
42
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
42
43
  import { republishStaticWebConsumers } from './public-web-republish';
43
44
  import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
@@ -367,6 +368,32 @@ async function deployModuleImpl(
367
368
  return { success: false, error: E2E_CONFLICT_MESSAGE, phases };
368
369
  }
369
370
 
371
+ // Every network this module REQUIRES must be defined before ANYTHING else
372
+ // (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
373
+ //
374
+ // First, not merely "before the hooks". `validateAndPrepareDeployment`
375
+ // generates templates itself when no variable is missing, and generation is
376
+ // where `$system:` derivations resolve and get persisted. A module reading
377
+ // `network.<name>.subnet` would derive it BEFORE the network existed, get
378
+ // nothing, and — because generation does not run twice — never get it. The
379
+ // hook would then fail on an unset value the deploy had just been told to
380
+ // supply. Ensuring the network up front is what makes "the value is
381
+ // available before any hook runs" true rather than nearly true.
382
+ const declaringModule = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
383
+ if (declaringModule?.manifestData) {
384
+ const networksEnsured = await ensureRequiredNetworks(
385
+ moduleId,
386
+ declaringModule.manifestData as ModuleManifest,
387
+ db,
388
+ );
389
+ if (!networksEnsured.success) {
390
+ return { success: false, error: networksEnsured.error, phases };
391
+ }
392
+ for (const line of networksEnsured.applied) {
393
+ log.success(`network defined: ${line}`);
394
+ }
395
+ }
396
+
370
397
  const validation = await validateAndPrepareDeployment(moduleId, db);
371
398
  phases.validation = validation.success;
372
399
  phases.autoGenerated = validation.autoGenerated;
@@ -517,9 +544,10 @@ async function deployModuleImpl(
517
544
  // The bus-mediated interview has fired and any responders have
518
545
  // answered; the operator can inspect the encrypted store and
519
546
  // module config to confirm what landed without spinning up
520
- // terraform / ansible / actual hooks. Cross-module `ensure`
521
- // events fire later from hook execution, so they're NOT
522
- // exercised here that requires a real run.
547
+ // terraform / ansible / actual hooks. The required-network
548
+ // interview above HAS run by this point; the cross-module
549
+ // `ensure` events fire later from hook execution, so those are
550
+ // NOT exercised here — that requires a real run.
523
551
  if (options.stopAfterInterview) {
524
552
  log.info('--stop-after-interview: exiting before infrastructure phase.');
525
553
  return {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * celilo discovering the network of the box it is installed on.
3
+ *
4
+ * These tests moved here with the code, from
5
+ * `modules/celilo-mgmt/scripts/discovery.test.ts`. They were always claims about
6
+ * CELILO — that it reflects whatever network it finds and bakes in no range of
7
+ * its own — being made in a module's test file because that is where the code
8
+ * happened to live
9
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
10
+ *
11
+ * The headline test feeds a RANDOM RFC1918 /24 and asserts celilo uses exactly
12
+ * that: the product-level guarantee that no specific address range is baked in.
13
+ */
14
+
15
+ import { beforeEach, describe, expect, test } from 'bun:test';
16
+ import { eq } from 'drizzle-orm';
17
+ import type { DbClient } from '../db/client';
18
+ import { systemConfig } from '../db/schema';
19
+ import { setupTestDatabase } from '../test-utils/database';
20
+ import {
21
+ discoverAndRecordNetwork,
22
+ discoveredNetworkKeys,
23
+ parseInternalNetwork,
24
+ } from './network-discovery';
25
+
26
+ /** Build an `ip route` output for a given /24 base, gateway, and host IP. */
27
+ function ipRouteFor(base: string, gateway: string, hostIp: string, dev = 'eth0'): string {
28
+ return [
29
+ `default via ${gateway} dev ${dev} proto dhcp src ${hostIp} metric 100`,
30
+ `${base}/24 dev ${dev} proto kernel scope link src ${hostIp} metric 100`,
31
+ '',
32
+ ].join('\n');
33
+ }
34
+
35
+ /** Pick a random RFC1918 /24 base + a host IP + gateway within it. */
36
+ function randomRfc1918Slash24(): { base: string; gateway: string; hostIp: string } {
37
+ const r = Math.random();
38
+ let a: number;
39
+ let b: number;
40
+ if (r < 0.34) {
41
+ a = 10;
42
+ b = Math.floor(Math.random() * 256);
43
+ } else if (r < 0.67) {
44
+ a = 172;
45
+ b = 16 + Math.floor(Math.random() * 16); // 172.16–172.31
46
+ } else {
47
+ a = 192;
48
+ b = 168;
49
+ }
50
+ const c = Math.floor(Math.random() * 256);
51
+ return {
52
+ base: `${a}.${b}.${c}.0`,
53
+ gateway: `${a}.${b}.${c}.1`,
54
+ hostIp: `${a}.${b}.${c}.${10 + Math.floor(Math.random() * 200)}`,
55
+ };
56
+ }
57
+
58
+ describe('parseInternalNetwork', () => {
59
+ test('uses whatever /24 the host is actually on — no hard-coded range', () => {
60
+ for (let i = 0; i < 50; i++) {
61
+ const { base, gateway, hostIp } = randomRfc1918Slash24();
62
+ const result = parseInternalNetwork(ipRouteFor(base, gateway, hostIp));
63
+ expect(result).toEqual({ subnet: `${base}/24`, gateway });
64
+ // Guard against any latent assumption of the old default.
65
+ if (base !== '192.168.0.0') {
66
+ expect(result?.subnet).not.toBe('192.168.0.0/24');
67
+ }
68
+ }
69
+ });
70
+
71
+ test('parses a typical dhcp default route + connected subnet', () => {
72
+ const out = ipRouteFor('10.37.42.0', '10.37.42.1', '10.37.42.50');
73
+ expect(parseInternalNetwork(out)).toEqual({ subnet: '10.37.42.0/24', gateway: '10.37.42.1' });
74
+ });
75
+
76
+ test('honors a non-/24 prefix length', () => {
77
+ const out = [
78
+ 'default via 172.20.0.1 dev ens3',
79
+ '172.20.0.0/16 dev ens3 proto kernel scope link src 172.20.5.9',
80
+ ].join('\n');
81
+ expect(parseInternalNetwork(out)).toEqual({ subnet: '172.20.0.0/16', gateway: '172.20.0.1' });
82
+ });
83
+
84
+ test('returns null when there is no default route', () => {
85
+ expect(parseInternalNetwork('10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.5')).toBe(
86
+ null,
87
+ );
88
+ });
89
+
90
+ test('returns null when the connected subnet route is missing', () => {
91
+ expect(parseInternalNetwork('default via 10.0.0.1 dev eth0')).toBe(null);
92
+ });
93
+ });
94
+
95
+ describe('discoveredNetworkKeys (issue #300 redeploy idempotency)', () => {
96
+ const internal = { subnet: '192.168.1.0/24', gateway: '192.168.1.1' };
97
+
98
+ test('writes discovered subnet+gateway on first install (unset)', () => {
99
+ expect(discoveredNetworkKeys(internal, false)).toEqual({
100
+ 'network.internal.subnet': '192.168.1.0/24',
101
+ 'network.internal.gateway': '192.168.1.1',
102
+ });
103
+ });
104
+
105
+ test('preserves an already-set subnet on redeploy (no override)', () => {
106
+ // The regression: a redeploy on a box off the internal network discovers the
107
+ // WRONG subnet — it must not clobber the operator's value.
108
+ expect(discoveredNetworkKeys(internal, true)).toEqual({});
109
+ });
110
+
111
+ test('box IS on the configured internal network → nothing to record', () => {
112
+ expect(discoveredNetworkKeys(internal, true, { internalSubnet: '192.168.1.0/24' })).toEqual({});
113
+ });
114
+
115
+ test('box is OFF the internal network → records it as secure-mgmt, not discarded', () => {
116
+ // #300 discarded this value to protect `internal`. Correct, but it left celilo
117
+ // blind to its own network: untrusted by the firewall, and absent from the
118
+ // resolver's split-horizon views (NOERROR/0-records → public DNS → hairpin).
119
+ const mgmtBox = { subnet: '10.0.120.0/24', gateway: '10.0.120.1' };
120
+ expect(discoveredNetworkKeys(mgmtBox, true, { internalSubnet: '192.168.0.0/24' })).toEqual({
121
+ 'network.secure-mgmt.subnet': '10.0.120.0/24',
122
+ 'network.secure-mgmt.gateway': '10.0.120.1',
123
+ });
124
+ });
125
+
126
+ test('never clobbers an already-set secure-mgmt subnet', () => {
127
+ const mgmtBox = { subnet: '10.0.120.0/24', gateway: '10.0.120.1' };
128
+ expect(
129
+ discoveredNetworkKeys(mgmtBox, true, {
130
+ internalSubnet: '192.168.0.0/24',
131
+ secureMgmtAlreadySet: true,
132
+ }),
133
+ ).toEqual({});
134
+ });
135
+
136
+ test('internal set but its value unreadable → stays conservative, writes nothing', () => {
137
+ // Without knowing the configured subnet we cannot tell whether this box is on
138
+ // it; guessing wrong would mislabel the LAN. Preserve #300's behaviour.
139
+ expect(discoveredNetworkKeys(internal, true, {})).toEqual({});
140
+ });
141
+
142
+ test('writes nothing when discovery failed and nothing is set', () => {
143
+ expect(discoveredNetworkKeys(null, false)).toEqual({});
144
+ });
145
+ });
146
+
147
+ describe('discoverAndRecordNetwork', () => {
148
+ let db: DbClient;
149
+
150
+ beforeEach(async () => {
151
+ db = await setupTestDatabase();
152
+ });
153
+
154
+ function read(key: string): string | undefined {
155
+ return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value;
156
+ }
157
+
158
+ test('records the discovered network on a box that has none', () => {
159
+ const result = discoverAndRecordNetwork(db, () =>
160
+ ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20'),
161
+ );
162
+
163
+ expect(result.applied).toHaveLength(2);
164
+ expect(read('network.internal.subnet')).toBe('10.31.7.0/24');
165
+ expect(read('network.internal.gateway')).toBe('10.31.7.1');
166
+ });
167
+
168
+ test('a redeploy on the same box changes nothing and says so', () => {
169
+ const routes = () => ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20');
170
+ discoverAndRecordNetwork(db, routes);
171
+ const second = discoverAndRecordNetwork(db, routes);
172
+
173
+ expect(second.applied).toEqual([]);
174
+ expect(second.skipped).toContain('already accounts for');
175
+ expect(read('network.internal.subnet')).toBe('10.31.7.0/24');
176
+ });
177
+
178
+ test('a box off the internal LAN records its own network as secure-mgmt', () => {
179
+ db.insert(systemConfig)
180
+ .values({ key: 'network.internal.subnet', value: '192.168.0.0/24' })
181
+ .run();
182
+
183
+ discoverAndRecordNetwork(db, () => ipRouteFor('10.0.120.0', '10.0.120.1', '10.0.120.10'));
184
+
185
+ // The operator's internal value is untouched, and celilo is no longer blind
186
+ // to the network its own control plane occupies.
187
+ expect(read('network.internal.subnet')).toBe('192.168.0.0/24');
188
+ expect(read('network.secure-mgmt.subnet')).toBe('10.0.120.0/24');
189
+ });
190
+
191
+ test('an unreadable routing table is reported, not guessed at', () => {
192
+ const result = discoverAndRecordNetwork(db, () => null);
193
+
194
+ expect(result.applied).toEqual([]);
195
+ expect(result.skipped).toContain('ip route');
196
+ expect(read('network.internal.subnet')).toBeUndefined();
197
+ });
198
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * celilo discovering the network of the box it is installed on.
3
+ *
4
+ * This used to live in `modules/celilo-mgmt/scripts/discovery.ts`, which parsed
5
+ * `ip route` and then shelled `celilo system apply-config network.internal.…`.
6
+ * That made the management module the author of a network definition — and
7
+ * networks are celilo's
8
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
9
+ *
10
+ * The distinction is worth stating, because it is the reason this moved rather
11
+ * than being exempted. A module writing `network.<name>.subnet` from its own
12
+ * config is CHOOSING a range: an authority it should not have. celilo-mgmt was
13
+ * not doing that — it was reading the kernel's routing table and reporting what
14
+ * it found. The value was never the module's opinion. But the mechanism was
15
+ * identical to the one being closed, and an exemption for "this caller is
16
+ * trustworthy" is not enforceable: nothing stopped any other module from making
17
+ * the same call. Moving the code makes the module's authority disappear instead
18
+ * of being promised away.
19
+ *
20
+ * So the discovery is celilo's, the write is celilo's, and celilo-mgmt asks for
21
+ * it by name.
22
+ */
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import { eq } from 'drizzle-orm';
26
+ import type { DbClient } from '../db/client';
27
+ import { systemConfig } from '../db/schema';
28
+
29
+ export interface DiscoveredNetwork {
30
+ subnet: string;
31
+ gateway: string;
32
+ }
33
+
34
+ /**
35
+ * The connected subnet + gateway of the interface carrying the default route.
36
+ *
37
+ * Pure, so it can be tested against real `ip route` output without a host.
38
+ * Returns null when either the default route or its connected (kernel/link)
39
+ * route is absent — celilo says it could not discover, rather than guessing.
40
+ */
41
+ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork | null {
42
+ const lines = ipRouteOutput.split('\n').map((l) => l.trim());
43
+ const defaultLine = lines.find((l) => l.startsWith('default '));
44
+ const match = defaultLine?.match(/^default via (\S+) dev (\S+)/);
45
+ if (!match) return null;
46
+ const gateway = match[1];
47
+ const dev = match[2];
48
+
49
+ const cidr = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/;
50
+ const subnetLine = lines.find(
51
+ (l) => l.includes(`dev ${dev}`) && l.includes('proto kernel') && cidr.test(l.split(/\s+/)[0]),
52
+ );
53
+ if (!subnetLine) return null;
54
+ return { subnet: subnetLine.split(/\s+/)[0], gateway };
55
+ }
56
+
57
+ /**
58
+ * WHICH zone the discovered network is, which depends on the topology:
59
+ *
60
+ * - Single-network deployment (the common case): the box sits on the internal
61
+ * LAN, so what it discovered IS `internal`. First install records it there.
62
+ * - Segmented deployment: the box sits on a dedicated control-plane network.
63
+ * `internal` is then someone else's subnet — the semi-trusted LAN — and the
64
+ * discovered value belongs under `secure-mgmt`.
65
+ *
66
+ * Issue #300 spotted the second case as "discovery returns the WRONG subnet" and
67
+ * mitigated it by DISCARDING the value whenever `internal` was already set. That
68
+ * kept `internal` correct and left celilo blind to its own network, which is what
69
+ * breaks control-plane firewall trust and split-horizon DNS: the resolver has no
70
+ * view for an unrecognized source, so it answers NOERROR with zero records and
71
+ * the name falls through to public DNS. Recorded as `secure-mgmt` instead.
72
+ *
73
+ * Never clobbers an already-set value in either zone: discovery is a first-install
74
+ * fallback, not an override.
75
+ */
76
+ export function discoveredNetworkKeys(
77
+ host: DiscoveredNetwork | null,
78
+ internalAlreadySet: boolean,
79
+ opts: { internalSubnet?: string; secureMgmtAlreadySet?: boolean } = {},
80
+ ): Record<string, string> {
81
+ if (!host) return {};
82
+
83
+ if (!internalAlreadySet) {
84
+ return {
85
+ 'network.internal.subnet': host.subnet,
86
+ 'network.internal.gateway': host.gateway,
87
+ };
88
+ }
89
+
90
+ const onInternal = opts.internalSubnet === undefined || opts.internalSubnet === host.subnet;
91
+ if (onInternal || opts.secureMgmtAlreadySet) return {};
92
+
93
+ return {
94
+ 'network.secure-mgmt.subnet': host.subnet,
95
+ 'network.secure-mgmt.gateway': host.gateway,
96
+ };
97
+ }
98
+
99
+ /** Read the host's routing table. Injectable so the command is testable. */
100
+ export type RouteReader = () => string | null;
101
+
102
+ const readRoutes: RouteReader = () => {
103
+ try {
104
+ return execFileSync('ip', ['route'], { encoding: 'utf-8' });
105
+ } catch {
106
+ return null;
107
+ }
108
+ };
109
+
110
+ export interface NetworkDiscoveryResult {
111
+ /** Keys written, `<key> = <value>`, for the operator to read back. */
112
+ applied: string[];
113
+ /** Set when nothing could be discovered, with the reason. */
114
+ skipped?: string;
115
+ }
116
+
117
+ function readValue(db: DbClient, key: string): string | undefined {
118
+ return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined;
119
+ }
120
+
121
+ /**
122
+ * Discover the network this box sits on and record it. Idempotent, and never
123
+ * overwrites a value that is already set.
124
+ */
125
+ export function discoverAndRecordNetwork(
126
+ db: DbClient,
127
+ readRoutesImpl: RouteReader = readRoutes,
128
+ ): NetworkDiscoveryResult {
129
+ const routes = readRoutesImpl();
130
+ if (routes === null) {
131
+ return { applied: [], skipped: 'could not read the routing table (`ip route` failed)' };
132
+ }
133
+
134
+ const host = parseInternalNetwork(routes);
135
+ if (!host) {
136
+ return {
137
+ applied: [],
138
+ skipped: 'no default route with a connected subnet was found in `ip route`',
139
+ };
140
+ }
141
+
142
+ const internalSubnet = readValue(db, 'network.internal.subnet');
143
+ const keys = discoveredNetworkKeys(host, internalSubnet !== undefined, {
144
+ internalSubnet,
145
+ secureMgmtAlreadySet: readValue(db, 'network.secure-mgmt.subnet') !== undefined,
146
+ });
147
+
148
+ const applied: string[] = [];
149
+ for (const [key, value] of Object.entries(keys)) {
150
+ db.insert(systemConfig)
151
+ .values({ key, value })
152
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
153
+ .run();
154
+ applied.push(`${key} = ${value}`);
155
+ }
156
+
157
+ if (applied.length === 0) {
158
+ return {
159
+ applied,
160
+ skipped: `this box is on ${host.subnet}, which celilo already accounts for — nothing to record`,
161
+ };
162
+ }
163
+ return { applied };
164
+ }