@celilo/cli 2.0.0 → 2.2.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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control plane's health check (celilo#1225).
|
|
3
|
+
*
|
|
4
|
+
* The mapping is what these pin. The failure that made this code exist was not
|
|
5
|
+
* a wrong assertion — the hook's `existsSync(db_path)` was a perfectly correct
|
|
6
|
+
* question — it was a correct question asked from inside a jail that does not
|
|
7
|
+
* bind the directory, so a present database read as absent. Nothing in a unit
|
|
8
|
+
* test can catch that shape; what a unit test CAN hold is that every finding
|
|
9
|
+
* reaches the operator with its status and its remediation intact, because the
|
|
10
|
+
* unattended path is the one nobody is watching.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { findingAsHealthCheck } from './control-plane-health';
|
|
15
|
+
import type { FleetFinding } from './fleet-checks';
|
|
16
|
+
|
|
17
|
+
function finding(over: Partial<FleetFinding> = {}): FleetFinding {
|
|
18
|
+
return {
|
|
19
|
+
id: 'dispatcher',
|
|
20
|
+
title: 'Event dispatcher',
|
|
21
|
+
status: 'ok',
|
|
22
|
+
summary: 'a dispatcher is live',
|
|
23
|
+
detail: [],
|
|
24
|
+
remediation: null,
|
|
25
|
+
autoFixable: false,
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('findingAsHealthCheck', () => {
|
|
31
|
+
test("ok becomes pass — celilo's vocabulary is not the health path's", () => {
|
|
32
|
+
expect(findingAsHealthCheck(finding({ status: 'ok' })).status).toBe('pass');
|
|
33
|
+
expect(findingAsHealthCheck(finding({ status: 'warn' })).status).toBe('warn');
|
|
34
|
+
expect(findingAsHealthCheck(finding({ status: 'fail' })).status).toBe('fail');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the check is named by the finding id, so an operator can grep for it', () => {
|
|
38
|
+
const item = findingAsHealthCheck(finding({ id: 'schema_drift' }));
|
|
39
|
+
expect(item.name).toBe('schema_drift');
|
|
40
|
+
expect(item.message).toBe('a dispatcher is live');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the remediation survives the mapping', () => {
|
|
44
|
+
// This is the line that tells an operator what to DO, and the health path
|
|
45
|
+
// is the one that runs unattended every fifteen minutes. Dropping it here
|
|
46
|
+
// would leave a failing check with no next step, quietly.
|
|
47
|
+
const item = findingAsHealthCheck(
|
|
48
|
+
finding({
|
|
49
|
+
status: 'fail',
|
|
50
|
+
detail: ['no live dispatcher — heartbeat absent'],
|
|
51
|
+
remediation: 'systemctl enable --now celilo-events.service',
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
expect(item.details).toContain('no live dispatcher — heartbeat absent');
|
|
55
|
+
expect(item.details).toContain('Fix: systemctl enable --now celilo-events.service');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a healthy finding carries no empty details field', () => {
|
|
59
|
+
expect(findingAsHealthCheck(finding()).details).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('detail lines survive without a remediation', () => {
|
|
63
|
+
const item = findingAsHealthCheck(finding({ status: 'warn', detail: ['two dispatchers'] }));
|
|
64
|
+
expect(item.details).toBe('two dispatchers');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control plane's health check, run by celilo rather than by a hook.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not a hook
|
|
5
|
+
*
|
|
6
|
+
* It was `modules/celilo-mgmt/scripts/health-check.ts` until celilo#1225, and
|
|
7
|
+
* it failed under the hook jail in the way that is worst: **it reported a
|
|
8
|
+
* healthy fleet's database missing.** The hook asked `existsSync(db_path)`,
|
|
9
|
+
* the jail does not bind celilo's data directory (deliberately — that is where
|
|
10
|
+
* `master.key` and `celilo.db` live), so the answer was `false` and the deploy
|
|
11
|
+
* failed on a file that was sitting right there. Its other two checks shelled
|
|
12
|
+
* out to the CLI, which a jailed hook cannot reach at all.
|
|
13
|
+
*
|
|
14
|
+
* Every one of those was celilo inspecting itself. Deleting `on_install` for
|
|
15
|
+
* that reason and leaving this behind would have fixed one hook and left the
|
|
16
|
+
* deploy failing on the next.
|
|
17
|
+
*
|
|
18
|
+
* ## What replaced it is stronger, not merely relocated
|
|
19
|
+
*
|
|
20
|
+
* The hook asked two questions: does a file exist, and is this hostname in
|
|
21
|
+
* `celilo machine list`. `runFleetChecks` is what celilo already runs for
|
|
22
|
+
* `celilo system doctor`, and it asks eight, including the four-part dispatcher
|
|
23
|
+
* probe whose whole point is that "a process is up" reports green on a
|
|
24
|
+
* dispatcher that is unsupervised, on stale code, and delivering nothing.
|
|
25
|
+
*
|
|
26
|
+
* So the module gets a better check by having none of its own. That is the
|
|
27
|
+
* outcome to keep in mind before anyone adds a hook back: a health_check hook
|
|
28
|
+
* here can only re-ask, worse, questions celilo can answer directly.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { DbClient } from '../db/client';
|
|
32
|
+
import { withCeliloBusAsync } from './control-plane-bootstrap';
|
|
33
|
+
import { type FleetFinding, runFleetChecks } from './fleet-checks';
|
|
34
|
+
import type { HealthCheckItem } from './health-runner';
|
|
35
|
+
|
|
36
|
+
/** `FleetFinding`'s three statuses in `HealthCheckItem`'s vocabulary. */
|
|
37
|
+
const STATUS: Record<FleetFinding['status'], HealthCheckItem['status']> = {
|
|
38
|
+
ok: 'pass',
|
|
39
|
+
warn: 'warn',
|
|
40
|
+
fail: 'fail',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One finding as a health item.
|
|
45
|
+
*
|
|
46
|
+
* `detail` and `remediation` are folded into `details` rather than dropped:
|
|
47
|
+
* the health path is what runs unattended every fifteen minutes, so the line
|
|
48
|
+
* telling an operator what to DO has to survive the mapping.
|
|
49
|
+
*/
|
|
50
|
+
export function findingAsHealthCheck(finding: FleetFinding): HealthCheckItem {
|
|
51
|
+
const details = [
|
|
52
|
+
...finding.detail,
|
|
53
|
+
...(finding.remediation ? [`Fix: ${finding.remediation}`] : []),
|
|
54
|
+
];
|
|
55
|
+
return {
|
|
56
|
+
name: finding.id,
|
|
57
|
+
status: STATUS[finding.status],
|
|
58
|
+
message: finding.summary,
|
|
59
|
+
...(details.length > 0 ? { details: details.join('\n') } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Run celilo's own fleet checks and report them as the control plane's health. */
|
|
64
|
+
export async function controlPlaneHealthChecks(db: DbClient): Promise<HealthCheckItem[]> {
|
|
65
|
+
const findings = await withCeliloBusAsync((bus) => runFleetChecks(bus, db));
|
|
66
|
+
return findings.map(findingAsHealthCheck);
|
|
67
|
+
}
|
|
@@ -174,8 +174,14 @@ export async function runPreflight(
|
|
|
174
174
|
for (const variable of manifest.variables.owns) {
|
|
175
175
|
if (!variable.required) continue;
|
|
176
176
|
|
|
177
|
-
// Infrastructure/terraform variables are auto-derived during deploy
|
|
178
|
-
|
|
177
|
+
// Infrastructure/terraform variables are auto-derived during deploy;
|
|
178
|
+
// hook-owned variables are discovered by the module's own hooks at
|
|
179
|
+
// runtime (hook-owned-state D2). None of the three is operator input.
|
|
180
|
+
if (
|
|
181
|
+
variable.source === 'infrastructure' ||
|
|
182
|
+
variable.source === 'terraform' ||
|
|
183
|
+
variable.source === 'hook'
|
|
184
|
+
) {
|
|
179
185
|
continue;
|
|
180
186
|
}
|
|
181
187
|
|
|
@@ -129,6 +129,28 @@ describe('findMissingRequiredVariables (deploy path)', () => {
|
|
|
129
129
|
expect(missing[0].value_label).toBeUndefined();
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Hook-owned-state task 2.3 (design D2): a `source: hook` variable is
|
|
133
|
+
// discovered by the module's own hook at runtime. It is never operator
|
|
134
|
+
// input, so an unwritten one must not read as missing configuration — not
|
|
135
|
+
// for the interview, and not as a deploy-blocking error.
|
|
136
|
+
test('a source: hook variable is never reported missing, even required and unwritten', async () => {
|
|
137
|
+
const manifest = {
|
|
138
|
+
celilo_contract: '1.0',
|
|
139
|
+
variables: {
|
|
140
|
+
owns: [
|
|
141
|
+
{
|
|
142
|
+
name: 'public_ip',
|
|
143
|
+
type: 'string',
|
|
144
|
+
required: true,
|
|
145
|
+
source: 'hook',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
} as unknown as ModuleManifest;
|
|
150
|
+
const missing = await findMissingRequiredVariables('testmod', manifest, db);
|
|
151
|
+
expect(missing).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
132
154
|
test('configured secrets are not reported as missing', async () => {
|
|
133
155
|
db.insert(secrets)
|
|
134
156
|
.values({
|
|
@@ -430,6 +430,14 @@ export async function findMissingRequiredVariables(
|
|
|
430
430
|
continue; // These are auto-populated from Terraform outputs
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// Hook-owned variables are discovered by the module's own hooks at
|
|
434
|
+
// runtime (hook-owned-state D2). An unwritten one is not a missing
|
|
435
|
+
// configuration value — prompting for it or failing validation would
|
|
436
|
+
// ask the operator to guess what a hook has not learned yet.
|
|
437
|
+
if (variable.source === 'hook') {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
433
441
|
// Check if variable is configured
|
|
434
442
|
let isConfigured = false;
|
|
435
443
|
|
|
@@ -48,6 +48,18 @@ function rowToSystem(row: typeof moduleSystems.$inferSelect): DeployedSystem {
|
|
|
48
48
|
* Returns [] for API-only modules (e.g. namecheap) — that is a modeled state,
|
|
49
49
|
* not an error.
|
|
50
50
|
*/
|
|
51
|
+
/**
|
|
52
|
+
* The module that IS celilo's control plane.
|
|
53
|
+
*
|
|
54
|
+
* celilo knows this module by name in several places — its privileged
|
|
55
|
+
* capability allow-list, the subnet it trusts, the fleet checks that ask where
|
|
56
|
+
* the control plane runs, and the deploy step that initialises the box's own
|
|
57
|
+
* state. The id lives here because every one of those already reaches for
|
|
58
|
+
* `getModuleSystems`, and three separate private copies of the string is how
|
|
59
|
+
* they drift.
|
|
60
|
+
*/
|
|
61
|
+
export const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt';
|
|
62
|
+
|
|
51
63
|
export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem[] {
|
|
52
64
|
const rows = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all();
|
|
53
65
|
return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moved from `modules/celilo-mgmt/scripts/discovery.test.ts` with celilo#1225.
|
|
3
|
+
*
|
|
4
|
+
* The parser cases came across unchanged. The `discoverDns` cases are new and
|
|
5
|
+
* could not have been written there: the module's version read the filesystem
|
|
6
|
+
* directly, so the file-preference rule — the part with the actual failure mode
|
|
7
|
+
* — had no seam to test through.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { type ResolverFileReader, chooseDns, discoverDns, parseNameservers } from './dns-discovery';
|
|
12
|
+
|
|
13
|
+
const SYSTEMD = '/run/systemd/resolve/resolv.conf';
|
|
14
|
+
const ETC = '/etc/resolv.conf';
|
|
15
|
+
|
|
16
|
+
/** A host offering exactly the files given, and nothing else. */
|
|
17
|
+
function host(files: Record<string, string>): ResolverFileReader {
|
|
18
|
+
return (path) => files[path] ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('parseNameservers', () => {
|
|
22
|
+
test('extracts nameserver IPs in order', () => {
|
|
23
|
+
const resolv = ['# comment', 'nameserver 9.9.9.9', 'nameserver 8.8.4.4', 'search lan'].join(
|
|
24
|
+
'\n',
|
|
25
|
+
);
|
|
26
|
+
expect(parseNameservers(resolv)).toEqual(['9.9.9.9', '8.8.4.4']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns empty for no nameservers', () => {
|
|
30
|
+
expect(parseNameservers('search lan\noptions edns0')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('chooseDns', () => {
|
|
35
|
+
test('drops loopback stubs (systemd-resolved 127.0.0.53)', () => {
|
|
36
|
+
expect(chooseDns(['127.0.0.53'])).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('uses the first two real upstreams', () => {
|
|
40
|
+
expect(chooseDns(['9.9.9.9', '8.8.4.4', '1.0.0.1'])).toEqual({
|
|
41
|
+
primary: '9.9.9.9',
|
|
42
|
+
fallback: '8.8.4.4',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('falls back to 1.1.1.1 for the second when only one upstream', () => {
|
|
47
|
+
expect(chooseDns(['9.9.9.9'])).toEqual({ primary: '9.9.9.9', fallback: '1.1.1.1' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('discoverDns', () => {
|
|
52
|
+
test('prefers the systemd upstream file over the stub in /etc/resolv.conf', () => {
|
|
53
|
+
// The case the file order exists for. Reading only /etc/resolv.conf here
|
|
54
|
+
// discovers 127.0.0.53, which no other box on the network can reach.
|
|
55
|
+
const servers = discoverDns(
|
|
56
|
+
host({
|
|
57
|
+
[SYSTEMD]: 'nameserver 9.9.9.9\nnameserver 8.8.4.4\n',
|
|
58
|
+
[ETC]: 'nameserver 127.0.0.53\n',
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(servers).toEqual({ primary: '9.9.9.9', fallback: '8.8.4.4' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('falls through to /etc/resolv.conf when the systemd file yields nothing real', () => {
|
|
65
|
+
const servers = discoverDns(
|
|
66
|
+
host({
|
|
67
|
+
[SYSTEMD]: 'nameserver 127.0.0.53\n',
|
|
68
|
+
[ETC]: 'nameserver 192.0.2.10\nnameserver 192.0.2.11\n',
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
expect(servers).toEqual({ primary: '192.0.2.10', fallback: '192.0.2.11' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('reads /etc/resolv.conf on a host with no systemd resolver file', () => {
|
|
75
|
+
expect(discoverDns(host({ [ETC]: 'nameserver 192.0.2.10\n' }))).toEqual({
|
|
76
|
+
primary: '192.0.2.10',
|
|
77
|
+
fallback: '1.1.1.1',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('accepts a loopback-only /etc/resolv.conf as the public fallback, not as an error', () => {
|
|
82
|
+
// Last resort: whatever it gives is taken, and a stub-only host yields the
|
|
83
|
+
// public fallback rather than an unreachable address.
|
|
84
|
+
expect(discoverDns(host({ [ETC]: 'nameserver 127.0.0.53\n' }))).toEqual({
|
|
85
|
+
primary: '1.1.1.1',
|
|
86
|
+
fallback: '1.1.1.1',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('returns the public fallback on a host with no resolver files at all', () => {
|
|
91
|
+
expect(discoverDns(host({}))).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// The recurrence gate for celilo#1239 (bead ce-dxr8). The dns-client-config
|
|
95
|
+
// aspect of a deployed dns_internal provider rewrites /etc/resolv.conf to
|
|
96
|
+
// name that provider. Discovery reading the file back adopts the fleet's own
|
|
97
|
+
// resolver as the fleet's upstream and writes it into dns.primary /
|
|
98
|
+
// dns.fallback, where it reaches every LXC's permanent birth nameserver
|
|
99
|
+
// line. Proven to fail against the accepting implementation first.
|
|
100
|
+
describe('refusing resolvers celilo itself installed', () => {
|
|
101
|
+
// technitium's advertised dns_internal addresses: its dmz zone address and
|
|
102
|
+
// (once advertised) the firewall ingress `internal`-zone systems use.
|
|
103
|
+
const FLEET = ['10.0.10.13', '192.168.0.151'];
|
|
104
|
+
|
|
105
|
+
test('refuses a resolv.conf naming a deployed dns_internal provider address', () => {
|
|
106
|
+
const servers = discoverDns(host({ [ETC]: `nameserver ${FLEET[0]}\n` }), {
|
|
107
|
+
fleetResolverIps: FLEET,
|
|
108
|
+
});
|
|
109
|
+
expect(servers).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('keeps the real upstreams beside a refused fleet resolver', () => {
|
|
113
|
+
const servers = discoverDns(host({ [ETC]: `nameserver ${FLEET[0]}\nnameserver 9.9.9.9\n` }), {
|
|
114
|
+
fleetResolverIps: FLEET,
|
|
115
|
+
});
|
|
116
|
+
expect(servers).toEqual({ primary: '9.9.9.9', fallback: '1.1.1.1' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('falls through to /etc/resolv.conf when the systemd file holds only fleet resolvers', () => {
|
|
120
|
+
const servers = discoverDns(
|
|
121
|
+
host({
|
|
122
|
+
[SYSTEMD]: `nameserver ${FLEET[0]}\n`,
|
|
123
|
+
[ETC]: 'nameserver 9.9.9.9\n',
|
|
124
|
+
}),
|
|
125
|
+
{ fleetResolverIps: FLEET },
|
|
126
|
+
);
|
|
127
|
+
expect(servers).toEqual({ primary: '9.9.9.9', fallback: '1.1.1.1' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('matches a capability address that carries a CIDR suffix', () => {
|
|
131
|
+
// Capability data advertises `192.168.0.151/24` (server.ip resolves from
|
|
132
|
+
// target_ip); the resolv.conf entry is the bare address.
|
|
133
|
+
const servers = discoverDns(host({ [ETC]: `nameserver ${FLEET[1]}\n` }), {
|
|
134
|
+
fleetResolverIps: [`${FLEET[1]}/24`],
|
|
135
|
+
});
|
|
136
|
+
expect(servers).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('accepts a private upstream that is not a fleet resolver', () => {
|
|
140
|
+
// A home-lab upstream on the operator's own network is legitimate. The
|
|
141
|
+
// refusal is scoped to resolvers celilo deployed, not to privateness.
|
|
142
|
+
expect(
|
|
143
|
+
discoverDns(host({ [ETC]: 'nameserver 192.168.0.1\n' }), { fleetResolverIps: FLEET }),
|
|
144
|
+
).toEqual({ primary: '192.168.0.1', fallback: '1.1.1.1' });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
/** Options for {@link discoverDns}. */
|
|
76
|
+
export interface DiscoverDnsOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Addresses of resolvers celilo itself deployed, as advertised in the
|
|
79
|
+
* capability data of providers that declare the `fleet_resolver` marker
|
|
80
|
+
* (`server.ip`, `server.internal_ip`). Entries may carry a CIDR suffix.
|
|
81
|
+
*/
|
|
82
|
+
fleetResolverIps?: readonly string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Strip a CIDR suffix: `192.168.0.151/24` → `192.168.0.151`. */
|
|
86
|
+
export function bareIp(address: string): string {
|
|
87
|
+
return address.split('/')[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Drop nameservers that are resolvers celilo itself installed.
|
|
92
|
+
*
|
|
93
|
+
* A deployed dns_internal provider's base-module aspect rewrites this box's
|
|
94
|
+
* `/etc/resolv.conf` to name that provider. Reading the file back afterwards
|
|
95
|
+
* adopts the fleet's own resolver as the fleet's upstream and writes it into
|
|
96
|
+
* `dns.primary` / `dns.fallback` (celilo#1239), where it reaches every LXC's
|
|
97
|
+
* permanent birth nameserver line. A resolver answering the fleet is not an
|
|
98
|
+
* upstream for the fleet; refusing it here is what keeps discovery honest
|
|
99
|
+
* once the aspect has run.
|
|
100
|
+
*/
|
|
101
|
+
export function excludeFleetResolvers(
|
|
102
|
+
nameservers: string[],
|
|
103
|
+
fleetResolverIps: readonly string[] = [],
|
|
104
|
+
): string[] {
|
|
105
|
+
if (fleetResolverIps.length === 0) return nameservers;
|
|
106
|
+
const fleet = new Set(fleetResolverIps.map(bareIp));
|
|
107
|
+
return nameservers.filter((ns) => !fleet.has(ns));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read this box's upstream resolvers.
|
|
112
|
+
*
|
|
113
|
+
* Loopback stubs and resolvers celilo itself installed ({@link DiscoverDnsOptions.fleetResolverIps})
|
|
114
|
+
* are refused, so a file holding only refused entries falls through to the
|
|
115
|
+
* next one. `/etc/resolv.conf` is the last resort and whatever survives the
|
|
116
|
+
* refusal is accepted, including the public fallback, because there is
|
|
117
|
+
* nothing further to consult.
|
|
118
|
+
*/
|
|
119
|
+
export function discoverDns(
|
|
120
|
+
read: ResolverFileReader = readResolverFile,
|
|
121
|
+
options: DiscoverDnsOptions = {},
|
|
122
|
+
): DnsServers {
|
|
123
|
+
for (const path of RESOLVER_FILES) {
|
|
124
|
+
const contents = read(path);
|
|
125
|
+
if (contents === null) continue;
|
|
126
|
+
const servers = chooseDns(
|
|
127
|
+
excludeFleetResolvers(parseNameservers(contents), options.fleetResolverIps),
|
|
128
|
+
);
|
|
129
|
+
if (servers.primary !== PUBLIC_FALLBACK || path === '/etc/resolv.conf') {
|
|
130
|
+
return servers;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { primary: PUBLIC_FALLBACK, fallback: PUBLIC_FALLBACK };
|
|
134
|
+
}
|
|
@@ -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
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
|
-
import {
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
10
|
import { tmpdir } from 'node:os';
|
|
10
11
|
import { join } from 'node:path';
|
|
11
|
-
import { ensureFleetKey, getFleetSshDir } from './fleet-key';
|
|
12
|
+
import { ensureFleetKey, findFleetPrivateKey, getFleetSshDir } from './fleet-key';
|
|
12
13
|
|
|
13
14
|
describe('ensureFleetKey', () => {
|
|
14
15
|
let dataDir: string;
|
|
@@ -45,3 +46,66 @@ describe('ensureFleetKey', () => {
|
|
|
45
46
|
expect(second.publicKey).toBe(first.publicKey);
|
|
46
47
|
});
|
|
47
48
|
});
|
|
49
|
+
|
|
50
|
+
describe('findFleetPrivateKey', () => {
|
|
51
|
+
let dataDir: string;
|
|
52
|
+
let homeDir: string;
|
|
53
|
+
let realHome: string | undefined;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-find-'));
|
|
57
|
+
homeDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-home-'));
|
|
58
|
+
process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
|
|
59
|
+
realHome = process.env.HOME;
|
|
60
|
+
process.env.HOME = homeDir;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
65
|
+
process.env.HOME = realHome;
|
|
66
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
67
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** A second, unrelated keypair in $HOME/.ssh, as every real box has. */
|
|
71
|
+
function plantHomeKey(): string {
|
|
72
|
+
const dir = join(homeDir, '.ssh');
|
|
73
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
const path = join(dir, 'id_ed25519');
|
|
75
|
+
execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', path, '-C', 'someone-else'], {
|
|
76
|
+
stdio: 'pipe',
|
|
77
|
+
});
|
|
78
|
+
return readFileSync(`${path}.pub`, 'utf-8').trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
it('finds the key celilo minted, even when $HOME holds a different one', () => {
|
|
82
|
+
// This is celilo#1240. `ensureFleetKey` mints into the data dir and
|
|
83
|
+
// `machine add` searched $HOME only, so a fleet whose key celilo minted
|
|
84
|
+
// could not add a machine without --ssh-key-file. The e2e management
|
|
85
|
+
// container has both directories populated with DIFFERENT keys, which is
|
|
86
|
+
// why it failed there and not on a developer's box.
|
|
87
|
+
const homePublicKey = plantHomeKey();
|
|
88
|
+
const fleet = ensureFleetKey();
|
|
89
|
+
expect(fleet.publicKey).not.toBe(homePublicKey);
|
|
90
|
+
|
|
91
|
+
const found = findFleetPrivateKey(fleet.publicKey);
|
|
92
|
+
|
|
93
|
+
expect(found).toBe(join(dataDir, '.ssh', 'id_ed25519'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still finds a key that only lives in $HOME', () => {
|
|
97
|
+
// The auto-detected case: `initializeSystem` reads $HOME/.ssh when
|
|
98
|
+
// ssh.public_key is unset, and that fleet must keep working.
|
|
99
|
+
const homePublicKey = plantHomeKey();
|
|
100
|
+
|
|
101
|
+
expect(findFleetPrivateKey(homePublicKey)).toBe(join(homeDir, '.ssh', 'id_ed25519'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns null when no private key matches the recorded public half', () => {
|
|
105
|
+
plantHomeKey();
|
|
106
|
+
const orphan =
|
|
107
|
+
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA nobody';
|
|
108
|
+
|
|
109
|
+
expect(findFleetPrivateKey(orphan)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -73,3 +73,57 @@ export function ensureFleetKey(): FleetKey {
|
|
|
73
73
|
|
|
74
74
|
return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every directory a fleet private key may sit in, in priority order.
|
|
79
|
+
*
|
|
80
|
+
* Exported so an error message names exactly what was searched. The message
|
|
81
|
+
* used to say "~/.ssh/" while the search had already been widened, which is a
|
|
82
|
+
* smaller version of the bug this whole helper exists to prevent.
|
|
83
|
+
*/
|
|
84
|
+
export function fleetKeySearchDirs(): string[] {
|
|
85
|
+
return [getFleetSshDir(), join(process.env.HOME ?? '~', '.ssh')];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The private key whose public half is `publicKey`, or null.
|
|
90
|
+
*
|
|
91
|
+
* Searches celilo's own key directory FIRST and `$HOME/.ssh` second, and the
|
|
92
|
+
* order is the point. Two different keys are normally present: the one
|
|
93
|
+
* `ensureFleetKey` minted, which is what celilo authorizes on managed
|
|
94
|
+
* machines, and whatever keypair the operator or the base image left in the
|
|
95
|
+
* home directory. Only the first can open a machine celilo provisioned.
|
|
96
|
+
*
|
|
97
|
+
* This lives beside `getFleetSshDir` for the reason that helper exists. The
|
|
98
|
+
* caller that searched for the key hand-derived its own `join(HOME, '.ssh')`
|
|
99
|
+
* and therefore could not see a minted key at all (celilo#1240).
|
|
100
|
+
*
|
|
101
|
+
* Matching ignores the trailing comment, because ssh-keygen writes
|
|
102
|
+
* `user@host` there and the recorded public half carries `celilo-fleet`.
|
|
103
|
+
*/
|
|
104
|
+
export function findFleetPrivateKey(publicKey: string): string | null {
|
|
105
|
+
const [type, material] = publicKey.trim().split(/\s+/);
|
|
106
|
+
if (!type || !material) return null;
|
|
107
|
+
|
|
108
|
+
const byType: Record<string, string[]> = {
|
|
109
|
+
'ssh-ed25519': ['id_ed25519'],
|
|
110
|
+
'ssh-rsa': ['id_rsa'],
|
|
111
|
+
'ecdsa-sha2-nistp256': ['id_ecdsa'],
|
|
112
|
+
'ecdsa-sha2-nistp384': ['id_ecdsa'],
|
|
113
|
+
'ecdsa-sha2-nistp521': ['id_ecdsa'],
|
|
114
|
+
};
|
|
115
|
+
const candidates = byType[type] ?? ['id_ed25519', 'id_rsa', 'id_ecdsa'];
|
|
116
|
+
|
|
117
|
+
for (const dir of fleetKeySearchDirs()) {
|
|
118
|
+
if (!existsSync(dir)) continue;
|
|
119
|
+
for (const name of candidates) {
|
|
120
|
+
const keyPath = join(dir, name);
|
|
121
|
+
const pubPath = `${keyPath}.pub`;
|
|
122
|
+
if (!existsSync(keyPath) || !existsSync(pubPath)) continue;
|
|
123
|
+
const [fileType, fileMaterial] = readFileSync(pubPath, 'utf-8').trim().split(/\s+/);
|
|
124
|
+
if (fileType === type && fileMaterial === material) return keyPath;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|