@celilo/cli 2.0.0 → 2.1.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_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +2 -2
- package/src/hooks/capability-loader.ts +1 -2
- package/src/hooks/executor.ts +6 -0
- package/src/hooks/hook-jail-toolchain-reach.test.ts +76 -27
- package/src/hooks/hook-jail-unreachability.test.ts +51 -26
- package/src/hooks/hook-trespass.test.ts +65 -9
- package/src/hooks/jail.ts +180 -25
- package/src/hooks/mount-set.test.ts +88 -1
- package/src/hooks/mount-set.ts +155 -0
- package/src/hooks/unjailed-lint.test.ts +5 -2
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +34 -3
- package/src/services/module-deploy.ts +51 -0
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the upstream DNS resolvers this box uses.
|
|
3
|
+
*
|
|
4
|
+
* This sits beside `network-discovery.ts` and for the same reason: celilo
|
|
5
|
+
* records facts about the management box itself, and a module is not the right
|
|
6
|
+
* place to read them. The network half moved first
|
|
7
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md),
|
|
8
|
+
* the fleet key followed (openspec/changes/hook-process-boundary, design D9b),
|
|
9
|
+
* and DNS is the last of the three. It came from
|
|
10
|
+
* `modules/celilo-mgmt/scripts/discovery.ts`, whose hook could only reach
|
|
11
|
+
* celilo by spawning the CLI — which a jailed hook cannot do at all
|
|
12
|
+
* (celilo#1225).
|
|
13
|
+
*
|
|
14
|
+
* Reading it here is not merely tidier, it is the only place it can be
|
|
15
|
+
* correct. celilo-mgmt is never deployed to a remote box (ruled 2026-09-02),
|
|
16
|
+
* so the host whose resolvers we want is always the host celilo runs on. A
|
|
17
|
+
* hook reading `/etc/resolv.conf` reads that same file, one process further
|
|
18
|
+
* out, for no benefit.
|
|
19
|
+
*
|
|
20
|
+
* Pure parsers are separated from the IO wrapper so they test without a host
|
|
21
|
+
* (Rule 2.3).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a host publishes its real upstream resolvers, most trustworthy first.
|
|
28
|
+
*
|
|
29
|
+
* systemd-resolved's file comes first deliberately: on such a host
|
|
30
|
+
* `/etc/resolv.conf` names the local 127.0.0.53 stub rather than the upstreams
|
|
31
|
+
* behind it, so reading only the second file discovers a loopback address and
|
|
32
|
+
* writes it into system config as the fleet's DNS.
|
|
33
|
+
*/
|
|
34
|
+
const RESOLVER_FILES = ['/run/systemd/resolve/resolv.conf', '/etc/resolv.conf'] as const;
|
|
35
|
+
|
|
36
|
+
/** Where we land when the host offers no usable upstream of its own. */
|
|
37
|
+
const PUBLIC_FALLBACK = '1.1.1.1';
|
|
38
|
+
|
|
39
|
+
export interface DnsServers {
|
|
40
|
+
primary: string;
|
|
41
|
+
fallback: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract `nameserver` IPs from resolv.conf-format text, in file order. */
|
|
45
|
+
export function parseNameservers(resolvConf: string): string[] {
|
|
46
|
+
return resolvConf
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((l) => l.trim())
|
|
49
|
+
.filter((l) => l.startsWith('nameserver '))
|
|
50
|
+
.map((l) => l.split(/\s+/)[1])
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Choose primary and fallback from discovered nameservers.
|
|
56
|
+
*
|
|
57
|
+
* Loopback entries are dropped rather than used. A stub resolver's address is
|
|
58
|
+
* a real answer to "what does this host query" and the wrong answer to "what
|
|
59
|
+
* should the fleet query", because nothing else on the network can reach it.
|
|
60
|
+
*/
|
|
61
|
+
export function chooseDns(nameservers: string[]): DnsServers {
|
|
62
|
+
const upstream = nameservers.filter((ns) => !ns.startsWith('127.') && ns !== '::1');
|
|
63
|
+
return {
|
|
64
|
+
primary: upstream[0] ?? PUBLIC_FALLBACK,
|
|
65
|
+
fallback: upstream[1] ?? PUBLIC_FALLBACK,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reads a resolver file, or returns null when it is not present. */
|
|
70
|
+
export type ResolverFileReader = (path: string) => string | null;
|
|
71
|
+
|
|
72
|
+
const readResolverFile: ResolverFileReader = (path) =>
|
|
73
|
+
existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read this box's upstream resolvers.
|
|
77
|
+
*
|
|
78
|
+
* Takes the systemd file only when it yielded a real server. `/etc/resolv.conf`
|
|
79
|
+
* is the last resort and whatever it gives is accepted, including the fallback,
|
|
80
|
+
* because there is nothing further to consult.
|
|
81
|
+
*/
|
|
82
|
+
export function discoverDns(read: ResolverFileReader = readResolverFile): DnsServers {
|
|
83
|
+
for (const path of RESOLVER_FILES) {
|
|
84
|
+
const contents = read(path);
|
|
85
|
+
if (contents === null) continue;
|
|
86
|
+
const servers = chooseDns(parseNameservers(contents));
|
|
87
|
+
if (servers.primary !== PUBLIC_FALLBACK || path === '/etc/resolv.conf') {
|
|
88
|
+
return servers;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { primary: PUBLIC_FALLBACK, fallback: PUBLIC_FALLBACK };
|
|
92
|
+
}
|
|
@@ -29,7 +29,11 @@ import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capabilit
|
|
|
29
29
|
import type { ModuleManifest } from '../manifest/schema';
|
|
30
30
|
|
|
31
31
|
import { getServiceCredentials, listContainerServices } from './container-service';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
CONTROL_PLANE_MODULE_ID,
|
|
34
|
+
getModuleSystems,
|
|
35
|
+
listAllModuleSystems,
|
|
36
|
+
} from './deployed-systems';
|
|
33
37
|
import { listDnsInternalRecords } from './dns-internal-records';
|
|
34
38
|
import {
|
|
35
39
|
SUPERVISOR_SCOPES,
|
|
@@ -43,7 +47,7 @@ import { describePausedModule, listPausedModules } from './module-pause';
|
|
|
43
47
|
import { resolveSubscription } from './module-subscriptions';
|
|
44
48
|
|
|
45
49
|
/** The module that IS celilo's control plane. */
|
|
46
|
-
const CONTROL_PLANE_MODULE =
|
|
50
|
+
const CONTROL_PLANE_MODULE = CONTROL_PLANE_MODULE_ID;
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* Zones reachable from the operator's LAN. A celilo placement zone other
|
|
@@ -17,7 +17,8 @@ import type { HookLogger, HookResult } from '../hooks/types';
|
|
|
17
17
|
import type { ModuleManifest } from '../manifest/schema';
|
|
18
18
|
import { decryptSecret } from '../secrets/encryption';
|
|
19
19
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
20
|
-
import {
|
|
20
|
+
import { controlPlaneHealthChecks } from './control-plane-health';
|
|
21
|
+
import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from './deployed-systems';
|
|
21
22
|
import { remoteAccessPolicy } from './remote-access';
|
|
22
23
|
|
|
23
24
|
export interface HealthCheckItem {
|
|
@@ -152,6 +153,19 @@ export async function runModuleHealthCheck(
|
|
|
152
153
|
return { moduleId, status: 'error', checks: [], error: 'Module not found' };
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
// The control plane checks itself, in celilo's own process (celilo#1225).
|
|
157
|
+
//
|
|
158
|
+
// This was a health_check hook and it failed under the jail in the worst
|
|
159
|
+
// possible way: it asked `existsSync(db_path)`, the jail deliberately does
|
|
160
|
+
// not bind celilo's data directory, and so it reported a healthy fleet's
|
|
161
|
+
// database MISSING and failed the deploy over a file that was right there.
|
|
162
|
+
// `runFleetChecks` is what `celilo system doctor` already runs and asks
|
|
163
|
+
// eight questions where the hook asked two.
|
|
164
|
+
if (moduleId === CONTROL_PLANE_MODULE_ID) {
|
|
165
|
+
const checks = await controlPlaneHealthChecks(db);
|
|
166
|
+
return finishHealthCheck(moduleId, module.state, checks, db, options);
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
const manifest = module.manifestData as ModuleManifest;
|
|
156
170
|
const hookDef = manifest.hooks?.health_check;
|
|
157
171
|
|
|
@@ -222,6 +236,23 @@ export async function runModuleHealthCheck(
|
|
|
222
236
|
};
|
|
223
237
|
|
|
224
238
|
const checks = outputs.checks || [];
|
|
239
|
+
return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Derive the verdict from the named checks and move the module's state.
|
|
244
|
+
*
|
|
245
|
+
* Shared by the hook path and the control plane's own path so the two cannot
|
|
246
|
+
* drift on what `degraded` means or on when a state transition is suppressed.
|
|
247
|
+
*/
|
|
248
|
+
function finishHealthCheck(
|
|
249
|
+
moduleId: string,
|
|
250
|
+
moduleState: ModuleState,
|
|
251
|
+
checks: HealthCheckItem[],
|
|
252
|
+
db: DbClient,
|
|
253
|
+
options: HealthCheckOptions,
|
|
254
|
+
artifactPaths?: string[],
|
|
255
|
+
): HealthCheckResult {
|
|
225
256
|
const hasFail = checks.some((c) => c.status === 'fail');
|
|
226
257
|
const hasWarn = checks.some((c) => c.status === 'warn');
|
|
227
258
|
|
|
@@ -231,12 +262,12 @@ export async function runModuleHealthCheck(
|
|
|
231
262
|
? 'degraded'
|
|
232
263
|
: 'healthy';
|
|
233
264
|
|
|
234
|
-
const nextState = nextModuleState(
|
|
265
|
+
const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
|
|
235
266
|
if (nextState) {
|
|
236
267
|
db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
|
|
237
268
|
}
|
|
238
269
|
|
|
239
|
-
return { moduleId, status, checks, artifactPaths
|
|
270
|
+
return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
|
|
240
271
|
}
|
|
241
272
|
|
|
242
273
|
/**
|
|
@@ -30,11 +30,13 @@ import {
|
|
|
30
30
|
interviewForMissingSecrets,
|
|
31
31
|
} from './config-interview';
|
|
32
32
|
import { getContainerService } from './container-service';
|
|
33
|
+
import { bootstrapControlPlane } from './control-plane-bootstrap';
|
|
33
34
|
import { executeAnsible } from './deploy-ansible';
|
|
34
35
|
import { planDeployment } from './deploy-planner';
|
|
35
36
|
import { waitForSSH } from './deploy-ssh';
|
|
36
37
|
import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
|
|
37
38
|
import { validateAndPrepareDeployment } from './deploy-validation';
|
|
39
|
+
import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
|
|
38
40
|
import { getModuleSystems } from './deployed-systems';
|
|
39
41
|
import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
|
|
40
42
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
@@ -1351,6 +1353,55 @@ async function deployModuleImpl(
|
|
|
1351
1353
|
// container ID/IP got assigned per module in the DB.
|
|
1352
1354
|
}
|
|
1353
1355
|
|
|
1356
|
+
// celilo initialises its own box, in its own process.
|
|
1357
|
+
//
|
|
1358
|
+
// This was `celilo-mgmt`'s `on_install` hook until celilo#1225. The hook
|
|
1359
|
+
// reached every one of these operations by spawning the `celilo` CLI,
|
|
1360
|
+
// which a jailed hook cannot do — the mount set binds no `/usr/bin`, no
|
|
1361
|
+
// `/usr/local/bin` and no shell — so the deploy ran Ansible to completion
|
|
1362
|
+
// and then died in its own install hook on any host with a jail backend.
|
|
1363
|
+
//
|
|
1364
|
+
// It runs at the point the hook used to, for the reason the hook ran
|
|
1365
|
+
// there: the Ansible role has just installed celilo and started the
|
|
1366
|
+
// dispatcher, and none of this can be read before that.
|
|
1367
|
+
if (moduleId === CONTROL_PLANE_MODULE_ID) {
|
|
1368
|
+
log.success('Initializing celilo management state');
|
|
1369
|
+
try {
|
|
1370
|
+
const bootstrap = await bootstrapControlPlane({ db });
|
|
1371
|
+
|
|
1372
|
+
log.info(` DNS: primary ${bootstrap.dns.primary}, fallback ${bootstrap.dns.fallback}`);
|
|
1373
|
+
for (const applied of bootstrap.network.applied) {
|
|
1374
|
+
log.info(` Network: ${applied}`);
|
|
1375
|
+
}
|
|
1376
|
+
if (bootstrap.network.skipped) {
|
|
1377
|
+
log.warn(` Network not recorded: ${bootstrap.network.skipped}`);
|
|
1378
|
+
}
|
|
1379
|
+
// The operator authorises this key on every machine celilo manages,
|
|
1380
|
+
// so it is printed rather than merely stored. It was the loudest line
|
|
1381
|
+
// the hook produced and it stays the loudest line here.
|
|
1382
|
+
log.success(
|
|
1383
|
+
`celilo fleet SSH key — add this public key to machines celilo will manage:\n${bootstrap.fleetKey.publicKey}`,
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
if (bootstrap.dispatcher.status === 'fail') {
|
|
1387
|
+
// A management plane with no dispatcher is broken: event-driven
|
|
1388
|
+
// reconciles never deliver. Fail the install, do not whisper.
|
|
1389
|
+
log.error(`Dispatcher check failed: ${bootstrap.dispatcher.summary}`);
|
|
1390
|
+
return {
|
|
1391
|
+
success: false,
|
|
1392
|
+
phases,
|
|
1393
|
+
error:
|
|
1394
|
+
`celilo-mgmt: ${bootstrap.dispatcher.summary}. ${bootstrap.dispatcher.remediation ?? ''}`.trim(),
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
log.success(`Event dispatcher is live (${bootstrap.dispatcher.summary})`);
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1400
|
+
log.error(`Initializing celilo management state failed: ${detail}`);
|
|
1401
|
+
return { success: false, phases, error: detail };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1354
1405
|
// Run on_install hook (post-deploy actions like port forwarding, DNS registration)
|
|
1355
1406
|
if (manifest.hooks?.on_install) {
|
|
1356
1407
|
const onInstallDef = manifest.hooks.on_install;
|
|
@@ -7,16 +7,27 @@
|
|
|
7
7
|
* 2. an explicit non-root user is allowed — the fleet key's authority is
|
|
8
8
|
* root on fleet systems, and a non-root account trusts it only where an
|
|
9
9
|
* operator installed it (the cPanel bootstrap);
|
|
10
|
-
* 3.
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
10
|
+
* 3. the target is a system the module or one of its instances provisioned;
|
|
11
|
+
* 4. or it is a machine in celilo's pool that this module's own config names
|
|
12
|
+
* — the case a config-only manager module needs, since it provisions
|
|
13
|
+
* nothing and so can never satisfy rule 3 (celilo#1225);
|
|
14
|
+
*
|
|
15
|
+
* and otherwise a refusal names the module, the target, and the capability
|
|
16
|
+
* route.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
20
|
import { existsSync } from 'node:fs';
|
|
17
21
|
import { rm } from 'node:fs/promises';
|
|
18
22
|
import { type DbClient, createDbClient } from '../db/client';
|
|
19
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
capabilities,
|
|
25
|
+
machines,
|
|
26
|
+
moduleConfigs,
|
|
27
|
+
moduleInstances,
|
|
28
|
+
moduleSystems,
|
|
29
|
+
modules,
|
|
30
|
+
} from '../db/schema';
|
|
20
31
|
import { remoteAccessPolicy } from './remote-access';
|
|
21
32
|
|
|
22
33
|
const TEST_DB_PATH = './test-remote-access.db';
|
|
@@ -48,6 +59,26 @@ function seedSystem(db: DbClient, moduleId: string, ipv4Address: string, hostnam
|
|
|
48
59
|
.run();
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
/** A box the operator admitted to celilo's pool. */
|
|
63
|
+
function seedPoolMachine(db: DbClient, ipAddress: string, hostname: string): void {
|
|
64
|
+
db.insert(machines)
|
|
65
|
+
.values({
|
|
66
|
+
id: `machine-${hostname}`,
|
|
67
|
+
hostname,
|
|
68
|
+
zone: 'internal',
|
|
69
|
+
ipAddress,
|
|
70
|
+
sshUser: 'root',
|
|
71
|
+
sshKeyEncrypted: 'x',
|
|
72
|
+
hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 8 },
|
|
73
|
+
})
|
|
74
|
+
.run();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A config value the operator set on a module. */
|
|
78
|
+
function seedConfig(db: DbClient, moduleId: string, key: string, value: string): void {
|
|
79
|
+
db.insert(moduleConfigs).values({ moduleId, key, value }).run();
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
describe('remoteAccessPolicy', () => {
|
|
52
83
|
let db: DbClient;
|
|
53
84
|
|
|
@@ -136,4 +167,57 @@ describe('remoteAccessPolicy', () => {
|
|
|
136
167
|
);
|
|
137
168
|
expect(verdict.allowed).toBe(false);
|
|
138
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('a config-only module managing a pool machine (celilo#1225)', () => {
|
|
172
|
+
// `iptables` is the archetype: it configures a pre-existing firewall and is
|
|
173
|
+
// deployed onto nothing, so `module_systems` holds no row for it EVER. Rule
|
|
174
|
+
// 3 is unsatisfiable for it by construction, not by timing.
|
|
175
|
+
const FIREWALL = '10.226.1.254';
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
seedModule(db, 'iptables');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("allows a pool machine this module's config names", () => {
|
|
182
|
+
seedPoolMachine(db, FIREWALL, 'fw-main');
|
|
183
|
+
seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
|
|
184
|
+
|
|
185
|
+
expect(
|
|
186
|
+
remoteAccessPolicy('iptables', db).checkTarget({ ipv4_address: FIREWALL }, false),
|
|
187
|
+
).toEqual({ allowed: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('pool membership ALONE is not authorization', () => {
|
|
191
|
+
// Otherwise any module could reach every box celilo knows about, which is
|
|
192
|
+
// far wider than the rule this policy states.
|
|
193
|
+
seedPoolMachine(db, FIREWALL, 'fw-main');
|
|
194
|
+
|
|
195
|
+
const result = remoteAccessPolicy('iptables', db).checkTarget(
|
|
196
|
+
{ ipv4_address: FIREWALL },
|
|
197
|
+
false,
|
|
198
|
+
);
|
|
199
|
+
expect(result.allowed).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('config ALONE is not authorization', () => {
|
|
203
|
+
// A module names its own config keys. If naming an address were enough, a
|
|
204
|
+
// module could point itself at any box in the world.
|
|
205
|
+
seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
|
|
206
|
+
|
|
207
|
+
const result = remoteAccessPolicy('iptables', db).checkTarget(
|
|
208
|
+
{ ipv4_address: FIREWALL },
|
|
209
|
+
false,
|
|
210
|
+
);
|
|
211
|
+
expect(result.allowed).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("one module's config does not unlock another module's box", () => {
|
|
215
|
+
seedPoolMachine(db, FIREWALL, 'fw-main');
|
|
216
|
+
seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
|
|
217
|
+
|
|
218
|
+
// caddy has its own systems and no config naming the firewall.
|
|
219
|
+
const result = remoteAccessPolicy('caddy', db).checkTarget({ ipv4_address: FIREWALL }, false);
|
|
220
|
+
expect(result.allowed).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
139
223
|
});
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import { eq } from 'drizzle-orm';
|
|
42
42
|
import type { DbClient } from '../db/client';
|
|
43
|
-
import { capabilities, moduleSystems } from '../db/schema';
|
|
43
|
+
import { capabilities, machines, moduleConfigs, moduleSystems } from '../db/schema';
|
|
44
44
|
import type { RemoteAccessPolicy } from '../hooks/remote-broker';
|
|
45
45
|
import { getModuleSystems } from './deployed-systems';
|
|
46
46
|
import { ownedSystemModuleIds } from './module-instances';
|
|
@@ -60,11 +60,62 @@ export function remoteAccessPolicy(moduleId: string, db: DbClient): RemoteAccess
|
|
|
60
60
|
return { allowed: true };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
if (managesPoolMachine(moduleId, target.ipv4_address, db)) {
|
|
64
|
+
return { allowed: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
63
67
|
return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
|
|
64
68
|
},
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A machine in celilo's pool that this module's own config names.
|
|
74
|
+
*
|
|
75
|
+
* ## The case this exists for
|
|
76
|
+
*
|
|
77
|
+
* A module that MANAGES a pre-existing box rather than being deployed onto one
|
|
78
|
+
* — `iptables` configuring the firewall is the archetype — provisions no system
|
|
79
|
+
* and therefore never owns one. `module_systems` records nothing for a
|
|
80
|
+
* config-only module at any point in its lifecycle, so the owned-systems branch
|
|
81
|
+
* above cannot ever pass for it. Without this, such a module has no sanctioned
|
|
82
|
+
* path to the one box it exists to configure, and no amount of waiting fixes it
|
|
83
|
+
* (celilo#1225).
|
|
84
|
+
*
|
|
85
|
+
* ## Why this is not a module granting itself reach
|
|
86
|
+
*
|
|
87
|
+
* Both halves are the OPERATOR's acts, and a module can supply neither:
|
|
88
|
+
*
|
|
89
|
+
* - The address is in the machine pool. `celilo machine add <ip> --ssh-key…`
|
|
90
|
+
* is the operator saying celilo may use its credential on that box. The
|
|
91
|
+
* fleet key's authority is exactly the set of boxes celilo was given, and
|
|
92
|
+
* this is that set — the same reasoning as the non-root carve-out above.
|
|
93
|
+
* - The module's own config names that address. `firewall_ip` is
|
|
94
|
+
* `source: user, required: true`; an operator typed it.
|
|
95
|
+
*
|
|
96
|
+
* A module declares which config KEYS exist, never their values, so it cannot
|
|
97
|
+
* point itself at a box the operator did not both admit to the pool and assign
|
|
98
|
+
* to it. Two independent operator decisions have to agree.
|
|
99
|
+
*
|
|
100
|
+
* ## What it deliberately does not do
|
|
101
|
+
*
|
|
102
|
+
* It does not allow every pool machine. That would let any module reach any box
|
|
103
|
+
* celilo knows, which is far wider than the rule this file states. The
|
|
104
|
+
* intersection is the point: pool membership alone is not authorization, and
|
|
105
|
+
* config alone is not either.
|
|
106
|
+
*/
|
|
107
|
+
function managesPoolMachine(moduleId: string, ipv4Address: string, db: DbClient): boolean {
|
|
108
|
+
const inPool = db.select().from(machines).where(eq(machines.ipAddress, ipv4Address)).get();
|
|
109
|
+
if (!inPool) return false;
|
|
110
|
+
|
|
111
|
+
return db
|
|
112
|
+
.select({ value: moduleConfigs.value })
|
|
113
|
+
.from(moduleConfigs)
|
|
114
|
+
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
115
|
+
.all()
|
|
116
|
+
.some((row) => row.value === ipv4Address);
|
|
117
|
+
}
|
|
118
|
+
|
|
68
119
|
/**
|
|
69
120
|
* Name the module, the target, and the capability route (task 5.2). The owner
|
|
70
121
|
* lookup is best-effort colour: the refusal stands whether or not celilo can
|