@celilo/cli 0.14.2 → 0.14.4
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/package.json +2 -2
- package/src/capabilities/public-web-publish.test.ts +15 -15
- package/src/cli/commands/system-audit.ts +6 -38
- package/src/hooks/capability-loader.ts +0 -12
- package/src/services/alerting/builtin-source.ts +1 -49
- package/src/services/machine-probe.test.ts +50 -0
- package/src/services/machine-probe.ts +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
|
-
"@celilo/capabilities": "^0.9.
|
|
60
|
+
"@celilo/capabilities": "^0.9.1",
|
|
61
61
|
"@celilo/cli-display": "^0.1.9",
|
|
62
62
|
"@celilo/core": "^0.1.0",
|
|
63
63
|
"@celilo/event-bus": "^0.1.8",
|
|
@@ -516,7 +516,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
516
516
|
};
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
-
test('registers the public A record
|
|
519
|
+
test('registers the public A record for a NEW hostname without supplying an IP', async () => {
|
|
520
520
|
const { ops } = makeRouteOps();
|
|
521
521
|
const { registrar, calls } = makeRegistrar();
|
|
522
522
|
const cap = createPublicWeb({
|
|
@@ -530,7 +530,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
530
530
|
dnsManagedDomains: ['example.com'],
|
|
531
531
|
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
532
532
|
dnsRegistrar: registrar as any,
|
|
533
|
-
externalIp: '100.100.0.100',
|
|
534
533
|
});
|
|
535
534
|
|
|
536
535
|
await cap.register_route({
|
|
@@ -539,7 +538,9 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
539
538
|
hostname: 'nexus.example.com',
|
|
540
539
|
});
|
|
541
540
|
|
|
542
|
-
|
|
541
|
+
// No `ip` — the registrar publishes the source address of the update
|
|
542
|
+
// request, which is by construction the address the internet must dial.
|
|
543
|
+
expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]);
|
|
543
544
|
});
|
|
544
545
|
|
|
545
546
|
test('skips the default hostname (caddy already registered it at install)', async () => {
|
|
@@ -556,7 +557,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
556
557
|
dnsManagedDomains: ['example.com'],
|
|
557
558
|
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
558
559
|
dnsRegistrar: registrar as any,
|
|
559
|
-
externalIp: '100.100.0.100',
|
|
560
560
|
});
|
|
561
561
|
|
|
562
562
|
await cap.register_route({ type: 'static', path: '/', hostname: 'www.example.com' });
|
|
@@ -568,7 +568,13 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
568
568
|
// hostname that can't be wired FAILS the deploy instead of silently
|
|
569
569
|
// reporting success (the "served but unreachable" anti-pattern).
|
|
570
570
|
|
|
571
|
-
|
|
571
|
+
// The regression guard for #464. This case used to THROW "caddy has no known
|
|
572
|
+
// external IP" — and it threw on every real fleet, because the stored copy it
|
|
573
|
+
// demanded was never populated: caddy produced a `public_ip` hook output that
|
|
574
|
+
// its manifest never declared, so the framework discarded it. A correct fleet
|
|
575
|
+
// with correct public DNS could not register a new hostname. Registration must
|
|
576
|
+
// not depend on knowing the external IP at all.
|
|
577
|
+
test('registers a NEW hostname even though no external IP is known anywhere', async () => {
|
|
572
578
|
const { ops } = makeRouteOps();
|
|
573
579
|
const { registrar, calls } = makeRegistrar();
|
|
574
580
|
const cap = createPublicWeb({
|
|
@@ -582,13 +588,11 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
582
588
|
dnsManagedDomains: ['example.com'],
|
|
583
589
|
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
584
590
|
dnsRegistrar: registrar as any,
|
|
585
|
-
// externalIp omitted
|
|
586
591
|
});
|
|
587
592
|
|
|
588
|
-
await
|
|
589
|
-
|
|
590
|
-
).
|
|
591
|
-
expect(calls).toHaveLength(0);
|
|
593
|
+
await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' });
|
|
594
|
+
|
|
595
|
+
expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]);
|
|
592
596
|
});
|
|
593
597
|
|
|
594
598
|
test('fails loudly when no dns_registrar is available for a NEW hostname', async () => {
|
|
@@ -602,8 +606,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
602
606
|
hostnames: ['www.example.com'],
|
|
603
607
|
caddyModuleId: 'caddy',
|
|
604
608
|
dnsManagedDomains: ['example.com'],
|
|
605
|
-
// dnsRegistrar
|
|
606
|
-
externalIp: '100.100.0.100',
|
|
609
|
+
// dnsRegistrar omitted
|
|
607
610
|
});
|
|
608
611
|
|
|
609
612
|
await expect(
|
|
@@ -629,7 +632,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
629
632
|
dnsManagedDomains: ['example.com'],
|
|
630
633
|
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
631
634
|
dnsRegistrar: registrar as any,
|
|
632
|
-
externalIp: '100.100.0.100',
|
|
633
635
|
});
|
|
634
636
|
|
|
635
637
|
await expect(
|
|
@@ -656,7 +658,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
656
658
|
dnsManagedDomains: ['example.com'],
|
|
657
659
|
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
658
660
|
dnsRegistrar: registrar as any,
|
|
659
|
-
externalIp: '100.100.0.100',
|
|
660
661
|
// biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
|
|
661
662
|
dnsInternal: dnsInternal as any,
|
|
662
663
|
});
|
|
@@ -687,7 +688,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
|
687
688
|
dnsManagedDomains: ['example.com'],
|
|
688
689
|
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
689
690
|
dnsRegistrar: registrar as any,
|
|
690
|
-
externalIp: '100.100.0.100',
|
|
691
691
|
firewallNatIp: '192.168.0.253',
|
|
692
692
|
// biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
|
|
693
693
|
dnsInternal: dnsInternal as any,
|
|
@@ -52,7 +52,7 @@ import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
|
52
52
|
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
53
53
|
import { collectFirewallReach } from '../../services/firewall-reach';
|
|
54
54
|
import { runAllHealthChecks } from '../../services/health-runner';
|
|
55
|
-
import {
|
|
55
|
+
import { probeMachines } from '../../services/machine-probe';
|
|
56
56
|
import { parseStoredConfigValue } from '../../services/module-config';
|
|
57
57
|
import { buildTerraformEnvForModule } from '../../services/terraform-env';
|
|
58
58
|
import { hasFlag } from '../parser';
|
|
@@ -353,43 +353,11 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
353
353
|
}),
|
|
354
354
|
);
|
|
355
355
|
|
|
356
|
-
// Machines-reachable:
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
const
|
|
361
|
-
const machineReachableResults: MachineReachableResult[] = await Promise.all(
|
|
362
|
-
allMachines.map(async (m): Promise<MachineReachableResult> => {
|
|
363
|
-
try {
|
|
364
|
-
await execFileAsync(
|
|
365
|
-
'ssh',
|
|
366
|
-
[
|
|
367
|
-
'-o',
|
|
368
|
-
'BatchMode=yes',
|
|
369
|
-
'-o',
|
|
370
|
-
'ConnectTimeout=5',
|
|
371
|
-
'-o',
|
|
372
|
-
'StrictHostKeyChecking=no',
|
|
373
|
-
'-o',
|
|
374
|
-
'UserKnownHostsFile=/dev/null',
|
|
375
|
-
`${m.sshUser}@${m.ipAddress}`,
|
|
376
|
-
'true',
|
|
377
|
-
],
|
|
378
|
-
{ timeout: 8000 },
|
|
379
|
-
);
|
|
380
|
-
return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
381
|
-
} catch (err) {
|
|
382
|
-
const e = err as { stderr?: string; message?: string };
|
|
383
|
-
return {
|
|
384
|
-
id: m.id,
|
|
385
|
-
hostname: m.hostname,
|
|
386
|
-
ipAddress: m.ipAddress,
|
|
387
|
-
reachable: false,
|
|
388
|
-
message: (e.stderr || e.message || 'unknown error').slice(0, 200),
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
}),
|
|
392
|
-
);
|
|
356
|
+
// Machines-reachable: probe each pool machine. Shared with the monitor sweep
|
|
357
|
+
// (services/machine-probe.ts) — this block used to be a second copy of the
|
|
358
|
+
// same SSH logic, and both copies reported the local management box as
|
|
359
|
+
// unreachable because celilo does not hold an SSH key for itself.
|
|
360
|
+
const machineReachableResults: MachineReachableResult[] = await probeMachines();
|
|
393
361
|
|
|
394
362
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
395
363
|
|
|
@@ -361,17 +361,6 @@ export async function loadCapabilityFunctions(
|
|
|
361
361
|
debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
// Caddy's external (WAN) IP for the D1 public A record (M1 / #328). Caddy
|
|
365
|
-
// learns it from its own firewall.exposeService at install and persists it
|
|
366
|
-
// as the `public_ip` hook output (stored as a caddy secret). register_route
|
|
367
|
-
// points a new hostname's PUBLIC record here, so it's reachable from one
|
|
368
|
-
// deploy — no per-hostname DNAT (shared :443 ingress).
|
|
369
|
-
const caddyExternalIp =
|
|
370
|
-
typeof providerSecrets.public_ip === 'string' ? providerSecrets.public_ip : undefined;
|
|
371
|
-
if (caddyExternalIp) {
|
|
372
|
-
debugLog(`public_web: using caddy externalIp ${caddyExternalIp} for public DNS`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
364
|
// Caddy's configured hostnames — public_web rejects routes for any
|
|
376
365
|
// hostname not in this list, throwing a structured error that runs
|
|
377
366
|
// caddy's `managed_hostname` ensure interview. Empty list means
|
|
@@ -484,7 +473,6 @@ export async function loadCapabilityFunctions(
|
|
|
484
473
|
dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
|
|
485
474
|
firewallNatIp,
|
|
486
475
|
dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined,
|
|
487
|
-
externalIp: caddyExternalIp,
|
|
488
476
|
hostnames: caddyHostnames,
|
|
489
477
|
caddyModuleId: provider.moduleId,
|
|
490
478
|
dnsManagedDomains,
|
|
@@ -12,14 +12,9 @@
|
|
|
12
12
|
* as "nothing is wrong".
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { execFile } from 'node:child_process';
|
|
16
|
-
import { promisify } from 'node:util';
|
|
17
15
|
import { auditMachinesReachable } from '../audit/machines-reachable';
|
|
18
|
-
import type { MachineReachableResult } from '../audit/machines-reachable';
|
|
19
16
|
import type { DriftCategory, DriftFinding } from '../audit/types';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
const execFileAsync = promisify(execFile);
|
|
17
|
+
import { probeMachines } from '../machine-probe';
|
|
23
18
|
|
|
24
19
|
/** Categories a monitor can currently schedule. */
|
|
25
20
|
export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = ['machines_reachable'];
|
|
@@ -28,49 +23,6 @@ export function isSchedulableBuiltin(category: string): category is DriftCategor
|
|
|
28
23
|
return (SCHEDULABLE_BUILTIN_CHECKS as readonly string[]).includes(category);
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
/**
|
|
32
|
-
* SSH-probe every pool machine.
|
|
33
|
-
*
|
|
34
|
-
* `BatchMode=yes` prevents a password prompt from hanging the probe forever,
|
|
35
|
-
* and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
|
|
36
|
-
* condition this check exists to detect must not be the one that wedges it.
|
|
37
|
-
*/
|
|
38
|
-
async function probeMachines(): Promise<MachineReachableResult[]> {
|
|
39
|
-
const machines = await listMachines();
|
|
40
|
-
return Promise.all(
|
|
41
|
-
machines.map(async (m): Promise<MachineReachableResult> => {
|
|
42
|
-
try {
|
|
43
|
-
await execFileAsync(
|
|
44
|
-
'ssh',
|
|
45
|
-
[
|
|
46
|
-
'-o',
|
|
47
|
-
'BatchMode=yes',
|
|
48
|
-
'-o',
|
|
49
|
-
'ConnectTimeout=5',
|
|
50
|
-
'-o',
|
|
51
|
-
'StrictHostKeyChecking=no',
|
|
52
|
-
'-o',
|
|
53
|
-
'UserKnownHostsFile=/dev/null',
|
|
54
|
-
`${m.sshUser}@${m.ipAddress}`,
|
|
55
|
-
'true',
|
|
56
|
-
],
|
|
57
|
-
{ timeout: 8000 },
|
|
58
|
-
);
|
|
59
|
-
return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
60
|
-
} catch (err) {
|
|
61
|
-
const e = err as { stderr?: string; message?: string };
|
|
62
|
-
return {
|
|
63
|
-
id: m.id,
|
|
64
|
-
hostname: m.hostname,
|
|
65
|
-
ipAddress: m.ipAddress,
|
|
66
|
-
reachable: false,
|
|
67
|
-
message: e.stderr?.trim() || e.message || 'SSH probe failed',
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
26
|
export async function runBuiltinCheckForMonitor(category: DriftCategory): Promise<DriftFinding[]> {
|
|
75
27
|
if (category === 'machines_reachable') {
|
|
76
28
|
return auditMachinesReachable({ results: await probeMachines() });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local management box must never be reported unreachable.
|
|
3
|
+
*
|
|
4
|
+
* celilo runs there as the `celilo` user and deliberately holds no SSH key for
|
|
5
|
+
* itself, so probing `root@127.0.0.1` fails with `Permission denied (publickey)`
|
|
6
|
+
* on a completely healthy host. That produced a permanently firing
|
|
7
|
+
* `machines_reachable` alert against celilo-mgr — and that monitor is
|
|
8
|
+
* unsuppressible by design, so nothing could explain it away.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { auditMachinesReachable } from './audit/machines-reachable';
|
|
13
|
+
import { LOCAL_MACHINE_IP } from './ssh-key-manager';
|
|
14
|
+
|
|
15
|
+
describe('local machine reachability', () => {
|
|
16
|
+
test('LOCAL_MACHINE_IP is the loopback address the machine pool records', () => {
|
|
17
|
+
// The probe's skip is keyed on this exact value, so pin it.
|
|
18
|
+
expect(LOCAL_MACHINE_IP).toBe('127.0.0.1');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a reachable local box produces no finding', async () => {
|
|
22
|
+
const findings = await auditMachinesReachable({
|
|
23
|
+
results: [
|
|
24
|
+
{ id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
|
|
25
|
+
{ id: 'briq', hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(findings).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('an unreachable remote machine still produces a finding', async () => {
|
|
33
|
+
// The skip must not blunt the check for the machines it exists to watch.
|
|
34
|
+
const findings = await auditMachinesReachable({
|
|
35
|
+
results: [
|
|
36
|
+
{ id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
|
|
37
|
+
{
|
|
38
|
+
id: 'briq',
|
|
39
|
+
hostname: 'briq',
|
|
40
|
+
ipAddress: '192.168.0.254',
|
|
41
|
+
reachable: false,
|
|
42
|
+
message: 'connection refused',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(findings).toHaveLength(1);
|
|
48
|
+
expect(findings[0].message).toContain('briq');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reachability probe for the machine pool.
|
|
3
|
+
*
|
|
4
|
+
* One implementation, deliberately. This SSH probe previously existed twice —
|
|
5
|
+
* once in `alerting/builtin-source.ts` for the monitor sweep and once in
|
|
6
|
+
* `cli/commands/system-audit.ts` for `celilo system audit` — as identical
|
|
7
|
+
* copy-pasted blocks. Both carried the same bug, and fixing one would have left
|
|
8
|
+
* the other reporting the management server as unreachable forever.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import type { MachineReachableResult } from './audit/machines-reachable';
|
|
14
|
+
import { listMachines } from './machine-pool';
|
|
15
|
+
import { LOCAL_MACHINE_IP } from './ssh-key-manager';
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Probe every machine in the pool.
|
|
21
|
+
*
|
|
22
|
+
* `BatchMode=yes` prevents a password prompt from hanging the probe forever,
|
|
23
|
+
* and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
|
|
24
|
+
* condition this check exists to detect must not be the one that wedges it.
|
|
25
|
+
*
|
|
26
|
+
* The local management box is reported reachable WITHOUT probing it. celilo
|
|
27
|
+
* runs there as the `celilo` user and deliberately does not materialize an SSH
|
|
28
|
+
* key for itself, so `ssh root@127.0.0.1` fails with `Permission denied
|
|
29
|
+
* (publickey)` on a perfectly healthy host. Left unhandled that produced a
|
|
30
|
+
* permanently firing `machines_reachable` alert against celilo-mgr — and since
|
|
31
|
+
* that monitor is unsuppressible by design, nothing could explain it away.
|
|
32
|
+
*
|
|
33
|
+
* The check is also meaningless there: if the box running this code were
|
|
34
|
+
* unreachable, this code would not be running.
|
|
35
|
+
*/
|
|
36
|
+
export async function probeMachines(): Promise<MachineReachableResult[]> {
|
|
37
|
+
const machines = await listMachines();
|
|
38
|
+
return Promise.all(
|
|
39
|
+
machines.map(async (m): Promise<MachineReachableResult> => {
|
|
40
|
+
if (m.ipAddress === LOCAL_MACHINE_IP) {
|
|
41
|
+
return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await execFileAsync(
|
|
45
|
+
'ssh',
|
|
46
|
+
[
|
|
47
|
+
'-o',
|
|
48
|
+
'BatchMode=yes',
|
|
49
|
+
'-o',
|
|
50
|
+
'ConnectTimeout=5',
|
|
51
|
+
'-o',
|
|
52
|
+
'StrictHostKeyChecking=no',
|
|
53
|
+
'-o',
|
|
54
|
+
'UserKnownHostsFile=/dev/null',
|
|
55
|
+
`${m.sshUser}@${m.ipAddress}`,
|
|
56
|
+
'true',
|
|
57
|
+
],
|
|
58
|
+
{ timeout: 8000 },
|
|
59
|
+
);
|
|
60
|
+
return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const e = err as { stderr?: string; message?: string };
|
|
63
|
+
return {
|
|
64
|
+
id: m.id,
|
|
65
|
+
hostname: m.hostname,
|
|
66
|
+
ipAddress: m.ipAddress,
|
|
67
|
+
reachable: false,
|
|
68
|
+
message: e.stderr?.trim() || e.message || 'SSH probe failed',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|