@celilo/cli 0.13.3 → 0.14.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.
Files changed (85) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/cli/commands/alerts-act.ts +107 -0
  11. package/src/cli/commands/alerts-list.ts +62 -0
  12. package/src/cli/commands/alerts-poll.ts +129 -0
  13. package/src/cli/commands/alerts-sweep.ts +156 -0
  14. package/src/cli/commands/module-list.ts +50 -3
  15. package/src/cli/commands/monitor.ts +178 -0
  16. package/src/cli/commands/notify-config.ts +453 -0
  17. package/src/cli/commands/system-audit.ts +2 -0
  18. package/src/cli/commands/system-update.ts +1 -0
  19. package/src/cli/completion.ts +26 -0
  20. package/src/cli/generate-zsh-completion.ts +2 -0
  21. package/src/cli/index.ts +58 -0
  22. package/src/cli/tui/audit-state.ts +2 -0
  23. package/src/db/schema.ts +358 -0
  24. package/src/hooks/capability-loader.ts +158 -46
  25. package/src/hooks/capability-map-coverage.test.ts +101 -0
  26. package/src/manifest/schema.ts +60 -1
  27. package/src/services/alerting/ack.test.ts +212 -0
  28. package/src/services/alerting/ack.ts +119 -0
  29. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  30. package/src/services/alerting/builtin-monitors.ts +84 -0
  31. package/src/services/alerting/builtin-source.ts +82 -0
  32. package/src/services/alerting/coverage-source.ts +38 -0
  33. package/src/services/alerting/deferral.test.ts +161 -0
  34. package/src/services/alerting/delivery-loop.test.ts +396 -0
  35. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  36. package/src/services/alerting/deploy-hooks.ts +111 -0
  37. package/src/services/alerting/escalation.test.ts +207 -0
  38. package/src/services/alerting/escalation.ts +151 -0
  39. package/src/services/alerting/format.test.ts +193 -0
  40. package/src/services/alerting/format.ts +150 -0
  41. package/src/services/alerting/health-coverage.ts +81 -0
  42. package/src/services/alerting/inbound-poller.test.ts +298 -0
  43. package/src/services/alerting/inbound-poller.ts +236 -0
  44. package/src/services/alerting/inbound.test.ts +201 -0
  45. package/src/services/alerting/inbound.ts +112 -0
  46. package/src/services/alerting/interview-responder.test.ts +169 -0
  47. package/src/services/alerting/interview-responder.ts +158 -0
  48. package/src/services/alerting/keys.test.ts +155 -0
  49. package/src/services/alerting/keys.ts +190 -0
  50. package/src/services/alerting/monitors.ts +185 -0
  51. package/src/services/alerting/notification-responder.test.ts +290 -0
  52. package/src/services/alerting/notification-responder.ts +260 -0
  53. package/src/services/alerting/notifier.ts +219 -0
  54. package/src/services/alerting/people.ts +178 -0
  55. package/src/services/alerting/quiet-hours.test.ts +140 -0
  56. package/src/services/alerting/quiet-hours.ts +99 -0
  57. package/src/services/alerting/reconcile.test.ts +190 -0
  58. package/src/services/alerting/reconcile.ts +166 -0
  59. package/src/services/alerting/run-monitor.test.ts +185 -0
  60. package/src/services/alerting/run-monitor.ts +177 -0
  61. package/src/services/alerting/store.test.ts +222 -0
  62. package/src/services/alerting/store.ts +289 -0
  63. package/src/services/alerting/suppression.test.ts +228 -0
  64. package/src/services/alerting/suppression.ts +142 -0
  65. package/src/services/alerting/sweep-runner.test.ts +229 -0
  66. package/src/services/alerting/sweep-runner.ts +204 -0
  67. package/src/services/alerting/sweep.test.ts +61 -0
  68. package/src/services/alerting/sweep.ts +41 -0
  69. package/src/services/alerting/tokens.test.ts +152 -0
  70. package/src/services/alerting/tokens.ts +119 -0
  71. package/src/services/alerting/transport-loader.ts +48 -0
  72. package/src/services/audit/index.test.ts +1 -0
  73. package/src/services/audit/index.ts +3 -0
  74. package/src/services/audit/trusted-sources.test.ts +137 -0
  75. package/src/services/audit/trusted-sources.ts +124 -0
  76. package/src/services/audit/types.ts +2 -1
  77. package/src/services/firewall-reach.ts +83 -0
  78. package/src/services/health-runner.test.ts +50 -0
  79. package/src/services/health-runner.ts +116 -82
  80. package/src/services/module-deploy.ts +17 -0
  81. package/src/services/system-config-validator.test.ts +31 -1
  82. package/src/services/trusted-sources.test.ts +221 -0
  83. package/src/services/trusted-sources.ts +159 -0
  84. package/src/services/update/orchestrator.test.ts +1 -0
  85. package/src/templates/generator.ts +6 -29
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Reply tokens — how an inbound message identifies what it is answering.
3
+ *
4
+ * Minted per DELIVERY, not per alert. The token therefore identifies WHO
5
+ * replied as well as what they replied to, which is what "an ack from the
6
+ * secondary is broadcast to everyone already paged" needs, and it doubles as
7
+ * the audit trail. A per-alert token could not distinguish two people
8
+ * answering the same page.
9
+ *
10
+ * The same table serves alert acknowledgements and interview answers; only
11
+ * `targetId` differs in meaning. That is the unification the design is built
12
+ * around (D10).
13
+ */
14
+
15
+ import { randomInt, randomUUID } from 'node:crypto';
16
+ import { and, eq, gt, isNull } from 'drizzle-orm';
17
+ import type { DbClient } from '../../db/client';
18
+ import { type NotificationDelivery, notificationDeliveries } from '../../db/schema';
19
+
20
+ /**
21
+ * Crockford-style base32 without I, L, O, U — the characters people misread or
22
+ * mistype when copying a code off a phone screen. Six of these is ~1e9
23
+ * possibilities, which combined with expiry and the sender check is ample: the
24
+ * token is one of two factors, not a secret on its own.
25
+ */
26
+ const TOKEN_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
27
+ const TOKEN_LENGTH = 6;
28
+
29
+ export function generateToken(): string {
30
+ let token = '';
31
+ for (let i = 0; i < TOKEN_LENGTH; i++) {
32
+ token += TOKEN_ALPHABET[randomInt(TOKEN_ALPHABET.length)];
33
+ }
34
+ return token;
35
+ }
36
+
37
+ /** Normalise operator input: case-insensitive, and forgiving of separators. */
38
+ export function normaliseToken(raw: string): string {
39
+ return raw.trim().toUpperCase().replace(/[\s-]/g, '');
40
+ }
41
+
42
+ export interface MintTokenInput {
43
+ kind: 'alert' | 'interview';
44
+ /** `alerts.id` for an alert; a BUS event id for an interview. */
45
+ targetId: string;
46
+ routeId: string;
47
+ now: Date;
48
+ ttlMs: number;
49
+ }
50
+
51
+ /**
52
+ * Mint a delivery record with a unique token.
53
+ *
54
+ * Retries on collision rather than trusting randomness: a six-character token
55
+ * WILL collide eventually, and a collision that silently reassigned an
56
+ * outstanding token would route someone's ack to the wrong alert.
57
+ */
58
+ export function mintDelivery(db: DbClient, input: MintTokenInput): NotificationDelivery {
59
+ for (let attempt = 0; attempt < 8; attempt++) {
60
+ const token = generateToken();
61
+ if (findLiveDelivery(db, token, input.now)) continue;
62
+
63
+ const id = randomUUID();
64
+ db.insert(notificationDeliveries)
65
+ .values({
66
+ id,
67
+ token,
68
+ kind: input.kind,
69
+ targetId: input.targetId,
70
+ routeId: input.routeId,
71
+ sentAt: input.now,
72
+ expiresAt: new Date(input.now.getTime() + input.ttlMs),
73
+ })
74
+ .run();
75
+ return db
76
+ .select()
77
+ .from(notificationDeliveries)
78
+ .where(eq(notificationDeliveries.id, id))
79
+ .get() as NotificationDelivery;
80
+ }
81
+ throw new Error('Could not mint a unique reply token after 8 attempts');
82
+ }
83
+
84
+ /** An unexpired, unconsumed delivery for this token. */
85
+ export function findLiveDelivery(
86
+ db: DbClient,
87
+ token: string,
88
+ now: Date,
89
+ ): NotificationDelivery | undefined {
90
+ return db
91
+ .select()
92
+ .from(notificationDeliveries)
93
+ .where(
94
+ and(
95
+ eq(notificationDeliveries.token, normaliseToken(token)),
96
+ isNull(notificationDeliveries.consumedAt),
97
+ gt(notificationDeliveries.expiresAt, now),
98
+ ),
99
+ )
100
+ .get();
101
+ }
102
+
103
+ export function consumeDelivery(db: DbClient, deliveryId: string, now: Date): void {
104
+ db.update(notificationDeliveries)
105
+ .set({ consumedAt: now })
106
+ .where(eq(notificationDeliveries.id, deliveryId))
107
+ .run();
108
+ }
109
+
110
+ /** Every delivery made for an alert — who to send the all-clear to. */
111
+ export function deliveriesForAlert(db: DbClient, alertId: string): NotificationDelivery[] {
112
+ return db
113
+ .select()
114
+ .from(notificationDeliveries)
115
+ .where(
116
+ and(eq(notificationDeliveries.kind, 'alert'), eq(notificationDeliveries.targetId, alertId)),
117
+ )
118
+ .all();
119
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Loading a transport's `send` from the module that provides it.
3
+ *
4
+ * A notification transport is an ordinary module providing the `notification`
5
+ * capability, so reaching it goes through the same capability-loader every
6
+ * other cross-module call uses. Nothing here knows what Signal is.
7
+ *
8
+ * A transport that cannot be loaded throws at send time rather than returning
9
+ * a no-op. Silently swallowing it would mean the sweep records "notified"
10
+ * for a page that never left the building — the one outcome an alerting
11
+ * system must never produce.
12
+ */
13
+
14
+ import type { NotificationCapability } from '@celilo/capabilities';
15
+ import type { DbClient } from '../../db/client';
16
+ import { loadCapabilityFunctions } from '../../hooks/capability-loader';
17
+ import { createCapturingLogger } from '../../hooks/logger';
18
+ import type { NotificationTransport } from './notifier';
19
+
20
+ /**
21
+ * A transport bound to one module.
22
+ *
23
+ * Resolution is deferred to the first `send` because a sweep may evaluate many
24
+ * alerts and page for none of them — loading a module's capability functions
25
+ * for every alert would be work done to reach nobody.
26
+ */
27
+ export function loadNotificationTransport(
28
+ db: DbClient,
29
+ transportModuleId: string,
30
+ ): NotificationTransport {
31
+ return {
32
+ async send(request) {
33
+ const { logger } = createCapturingLogger();
34
+ const capabilities = await loadCapabilityFunctions(transportModuleId, db, logger);
35
+ const notification = (capabilities as Record<string, unknown>).notification as
36
+ | NotificationCapability
37
+ | undefined;
38
+
39
+ if (!notification) {
40
+ throw new Error(
41
+ `Module "${transportModuleId}" does not provide the notification capability. Deploy it, or point the route at a module that does.`,
42
+ );
43
+ }
44
+
45
+ return notification.send(request);
46
+ },
47
+ };
48
+ }
@@ -32,6 +32,7 @@ const emptyDeps = {
32
32
  secretsDecryptable: { results: [] },
33
33
  servicesReachable: { results: [] },
34
34
  machinesReachable: { results: [] },
35
+ trustedSources: { firewalls: [] },
35
36
  };
36
37
 
37
38
  describe('runAudit', () => {
@@ -25,6 +25,7 @@ import {
25
25
  } from './services-credentials';
26
26
  import { type ServicesReachableAuditDeps, auditServicesReachable } from './services-reachable';
27
27
  import { type TerraformPlanAuditDeps, auditTerraformPlan } from './terraform-plan';
28
+ import { type TrustedSourcesAuditDeps, auditTrustedSources } from './trusted-sources';
28
29
  import {
29
30
  type DriftCategory,
30
31
  type DriftFinding,
@@ -52,6 +53,7 @@ export interface AuditDeps {
52
53
  secretsDecryptable: SecretsDecryptableAuditDeps;
53
54
  servicesReachable: ServicesReachableAuditDeps;
54
55
  machinesReachable: MachinesReachableAuditDeps;
56
+ trustedSources: TrustedSourcesAuditDeps;
55
57
  /** Defaults to `Date.now()`-based ISO string. */
56
58
  now?: () => Date;
57
59
  }
@@ -101,6 +103,7 @@ export async function runAudit(
101
103
  wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
102
104
  wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
103
105
  wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
106
+ wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
104
107
  ]);
105
108
 
106
109
  const findings: DriftFinding[] = groups.flat();
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { auditTrustedSources, parseLiveReachRules } from './trusted-sources';
3
+
4
+ const ZONES = ['10.0.10.0/24', '10.0.20.0/24', '10.0.30.0/24'];
5
+ const CONTROL_PLANE = '192.168.0.0/24';
6
+ const VPN = '10.255.255.0/24';
7
+
8
+ /** A live `iptables-save -t filter` dump with a hand-added VPN reach rule. */
9
+ const LIVE_WITH_UNOWNED = [
10
+ '*filter',
11
+ ':INPUT ACCEPT [0:0]',
12
+ ':FORWARD DROP [0:0]',
13
+ ':OUTPUT ACCEPT [0:0]',
14
+ '-A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT',
15
+ '-A FORWARD -o eth1 -j ACCEPT',
16
+ `-A FORWARD -s ${CONTROL_PLANE} -d 10.0.10.0/24 -j ACCEPT`,
17
+ `-A FORWARD -s ${CONTROL_PLANE} -d 10.0.20.0/24 -j ACCEPT`,
18
+ `-A FORWARD -s ${CONTROL_PLANE} -d 10.0.30.0/24 -j ACCEPT`,
19
+ `-A FORWARD -s ${VPN} -d 10.0.10.0/24 -j ACCEPT`,
20
+ 'COMMIT',
21
+ ].join('\n');
22
+
23
+ describe('parsing reach rules from a live ruleset', () => {
24
+ it('finds subnet-scoped reach into a managed zone', () => {
25
+ expect(parseLiveReachRules(LIVE_WITH_UNOWNED, ZONES)).toContainEqual({
26
+ origin: { kind: 'subnet', value: VPN },
27
+ destSubnet: '10.0.10.0/24',
28
+ });
29
+ });
30
+
31
+ it('finds interface-scoped reach, which celilo cannot model at all', () => {
32
+ // The shape fw-keeper.sh actually maintained: `-i wg0 -d <zone>`.
33
+ const rules = parseLiveReachRules(
34
+ '*filter\n-A FORWARD -i wg0 -d 10.0.20.0/24 -j ACCEPT\nCOMMIT',
35
+ ZONES,
36
+ );
37
+ expect(rules).toEqual([
38
+ { origin: { kind: 'interface', value: 'wg0' }, destSubnet: '10.0.20.0/24' },
39
+ ]);
40
+ });
41
+
42
+ it('ignores rules that do not reach a managed zone', () => {
43
+ const rules = parseLiveReachRules(
44
+ [
45
+ '*filter',
46
+ '-A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT',
47
+ '-A FORWARD -o eth1 -j ACCEPT',
48
+ '-A FORWARD -s 10.99.0.0/24 -d 172.16.0.0/24 -j ACCEPT',
49
+ '-A INPUT -s 10.99.0.0/24 -d 10.0.10.0/24 -j ACCEPT',
50
+ '-A FORWARD -s 10.99.0.0/24 -d 10.0.10.0/24 -j DROP',
51
+ 'COMMIT',
52
+ ].join('\n'),
53
+ ZONES,
54
+ );
55
+ expect(rules).toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe('unowned-trusted-network audit', () => {
60
+ it('reports a network celilo does not recognise', async () => {
61
+ const findings = await auditTrustedSources({
62
+ firewalls: [
63
+ {
64
+ firewallIp: '192.168.0.254',
65
+ live: parseLiveReachRules(LIVE_WITH_UNOWNED, ZONES),
66
+ known: [CONTROL_PLANE],
67
+ },
68
+ ],
69
+ });
70
+
71
+ expect(findings).toHaveLength(1);
72
+ expect(findings[0].code).toBe('unowned_trusted_network');
73
+ expect(findings[0].message).toContain(VPN);
74
+ expect(findings[0].severity).toBe('drift');
75
+ });
76
+
77
+ it('is silent once that network is registered', async () => {
78
+ // The point of the change: once a module owns the VPN and registers its
79
+ // subnet, the rule IS the rendered output and there is nothing to report.
80
+ const findings = await auditTrustedSources({
81
+ firewalls: [
82
+ {
83
+ firewallIp: '192.168.0.254',
84
+ live: parseLiveReachRules(LIVE_WITH_UNOWNED, ZONES),
85
+ known: [CONTROL_PLANE, VPN],
86
+ },
87
+ ],
88
+ });
89
+
90
+ expect(findings).toEqual([]);
91
+ });
92
+
93
+ it('reports an unrecognised network once, not once per zone', async () => {
94
+ const live = parseLiveReachRules(
95
+ ZONES.map((z) => `-A FORWARD -s ${VPN} -d ${z} -j ACCEPT`).join('\n'),
96
+ ZONES,
97
+ );
98
+ const findings = await auditTrustedSources({
99
+ firewalls: [{ firewallIp: '192.168.0.254', live, known: [CONTROL_PLANE] }],
100
+ });
101
+
102
+ expect(findings).toHaveLength(1);
103
+ });
104
+
105
+ it('names the interface when trust is granted by interface, and says why it cannot be registered as-is', async () => {
106
+ const findings = await auditTrustedSources({
107
+ firewalls: [
108
+ {
109
+ firewallIp: '192.168.0.254',
110
+ live: [{ origin: { kind: 'interface', value: 'wg0' }, destSubnet: '10.0.10.0/24' }],
111
+ known: [CONTROL_PLANE],
112
+ },
113
+ ],
114
+ });
115
+
116
+ expect(findings).toHaveLength(1);
117
+ expect(findings[0].message).toContain('wg0');
118
+ expect(findings[0].details).toContain('SUBNET');
119
+ });
120
+
121
+ it('a firewall with no live reach beyond what celilo composes is clean', async () => {
122
+ const findings = await auditTrustedSources({
123
+ firewalls: [
124
+ {
125
+ firewallIp: '192.168.0.254',
126
+ live: parseLiveReachRules(
127
+ ZONES.map((z) => `-A FORWARD -s ${CONTROL_PLANE} -d ${z} -j ACCEPT`).join('\n'),
128
+ ZONES,
129
+ ),
130
+ known: [CONTROL_PLANE],
131
+ },
132
+ ],
133
+ });
134
+
135
+ expect(findings).toEqual([]);
136
+ });
137
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Unowned-trusted-network check.
3
+ *
4
+ * A converge renders the firewall's whole ruleset from the registry and applies
5
+ * it atomically, so a rule nobody registered is a rule nobody owns — and it is
6
+ * removed. That is correct behaviour, but it is silent, and silence is what
7
+ * produced `/tmp/fw-keeper.sh`: a shell script re-adding the admin VPN's rules
8
+ * every second because the VPN had no module to register on its behalf, and
9
+ * nothing anywhere said so.
10
+ *
11
+ * This check reports reach granted on the live firewall to a network celilo does
12
+ * not recognise, so the next unowned thing surfaces as an actionable gap instead
13
+ * of as an unexplained outage.
14
+ *
15
+ * Pure and dependency-injected: the CLI adapter reads the live ruleset off each
16
+ * firewall and hands the parsed result in.
17
+ */
18
+
19
+ import type { DriftFinding } from './types';
20
+
21
+ /** How a live FORWARD rule grants reach into a managed zone. */
22
+ export type ReachOrigin = { kind: 'subnet'; value: string } | { kind: 'interface'; value: string };
23
+
24
+ export interface LiveReachRule {
25
+ origin: ReachOrigin;
26
+ /** The managed zone subnet the rule permits reaching. */
27
+ destSubnet: string;
28
+ }
29
+
30
+ export interface FirewallReachState {
31
+ firewallIp: string;
32
+ /** Reach rules parsed from the firewall's LIVE ruleset (`iptables-save`). */
33
+ live: LiveReachRule[];
34
+ /** The subnets celilo composes for this firewall (derived + registered + override). */
35
+ known: string[];
36
+ }
37
+
38
+ export interface TrustedSourcesAuditDeps {
39
+ firewalls: FirewallReachState[];
40
+ }
41
+
42
+ /**
43
+ * Extract reach-granting FORWARD rules from an `iptables-save` dump: any ACCEPT
44
+ * into one of the managed zone subnets, keyed by what grants it.
45
+ *
46
+ * Both shapes matter. celilo renders `-s <subnet> -d <zone>`; a hand-added rule
47
+ * (or a keeper script) is as likely to be `-i <iface> -d <zone>`, which celilo
48
+ * cannot model at all — trust in celilo is by subnet, never by interface.
49
+ */
50
+ export function parseLiveReachRules(rulesetText: string, zoneSubnets: string[]): LiveReachRule[] {
51
+ const zones = new Set(zoneSubnets);
52
+ const rules: LiveReachRule[] = [];
53
+
54
+ for (const line of rulesetText.split('\n')) {
55
+ const trimmed = line.trim();
56
+ if (!trimmed.startsWith('-A FORWARD')) continue;
57
+ if (!/-j\s+ACCEPT\b/.test(trimmed)) continue;
58
+
59
+ const dest = trimmed.match(/-d\s+(\S+)/)?.[1];
60
+ if (!dest || !zones.has(dest)) continue;
61
+
62
+ const source = trimmed.match(/-s\s+(\S+)/)?.[1];
63
+ if (source) {
64
+ rules.push({ origin: { kind: 'subnet', value: source }, destSubnet: dest });
65
+ continue;
66
+ }
67
+ const iface = trimmed.match(/-i\s+(\S+)/)?.[1];
68
+ if (iface) {
69
+ rules.push({ origin: { kind: 'interface', value: iface }, destSubnet: dest });
70
+ }
71
+ }
72
+ return rules;
73
+ }
74
+
75
+ function originKey(origin: ReachOrigin): string {
76
+ return `${origin.kind}:${origin.value}`;
77
+ }
78
+
79
+ export async function auditTrustedSources(deps: TrustedSourcesAuditDeps): Promise<DriftFinding[]> {
80
+ const findings: DriftFinding[] = [];
81
+
82
+ for (const firewall of deps.firewalls) {
83
+ const known = new Set(firewall.known);
84
+ const reported = new Set<string>();
85
+
86
+ for (const rule of firewall.live) {
87
+ // A subnet celilo composes is owned by definition — that rule IS the
88
+ // rendered output.
89
+ if (rule.origin.kind === 'subnet' && known.has(rule.origin.value)) continue;
90
+ if (reported.has(originKey(rule.origin))) continue;
91
+ reported.add(originKey(rule.origin));
92
+
93
+ const byInterface = rule.origin.kind === 'interface';
94
+ findings.push({
95
+ category: 'trusted_sources',
96
+ severity: 'drift',
97
+ code: 'unowned_trusted_network',
98
+ message: byInterface
99
+ ? `${firewall.firewallIp}: interface ${rule.origin.value} reaches managed zones, but celilo models no such trust`
100
+ : `${firewall.firewallIp}: ${rule.origin.value} reaches managed zones, but celilo does not recognise that network`,
101
+ details: [
102
+ 'The live ruleset permits this reach; celilo does not render it.',
103
+ 'The next converge rebuilds the ruleset from the registry and will',
104
+ 'remove it, with no error and no explanation at the point of failure.',
105
+ ...(byInterface
106
+ ? [
107
+ '',
108
+ 'celilo expresses trust by SUBNET, never by interface — an',
109
+ 'interface-scoped rule cannot be registered as-is. Find the',
110
+ 'network behind that interface and register THAT.',
111
+ ]
112
+ : []),
113
+ ].join('\n'),
114
+ remediation: byInterface
115
+ ? 'Deploy a module that owns this network and registers its client subnet as a trusted source (the wireguard module does this for the admin VPN), or record it with `celilo system config set firewall.trusted_subnets <cidr>`'
116
+ : `Deploy a module that registers ${rule.origin.value} as a trusted source, or record it with \`celilo system config set firewall.trusted_subnets ${rule.origin.value}\``,
117
+ actionable: false,
118
+ subject: firewall.firewallIp,
119
+ });
120
+ }
121
+ }
122
+
123
+ return findings;
124
+ }
@@ -35,7 +35,8 @@ export type DriftCategory =
35
35
  | 'services_credentials'
36
36
  | 'secrets_decryptable'
37
37
  | 'services_reachable'
38
- | 'machines_reachable';
38
+ | 'machines_reachable'
39
+ | 'trusted_sources';
39
40
 
40
41
  export type DriftSeverity = 'todo' | 'drift' | 'blocked';
41
42
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Collect what each firewall's LIVE ruleset permits to reach the managed zones,
3
+ * alongside what celilo composes for it — the input to the unowned-trusted-
4
+ * network audit check.
5
+ *
6
+ * The live read is `iptables-save`, not celilo's own `rules.v4`: a rule added
7
+ * out of band (the `fw-keeper.sh` case) exists in the running table and not in
8
+ * the file celilo renders, and that difference is exactly the finding.
9
+ */
10
+
11
+ import { type Runner, execRunner, runAppCommand } from '@celilo/capabilities';
12
+ import { eq } from 'drizzle-orm';
13
+ import type { DbClient } from '../db/client';
14
+ import { capabilities, moduleConfigs, systemConfig } from '../db/schema';
15
+ import { loadTrustedSubnets } from '../hooks/capability-loader';
16
+ import type { FirewallReachState } from './audit/trusted-sources';
17
+ import { parseLiveReachRules } from './audit/trusted-sources';
18
+
19
+ /** The segmented tiers the renderer builds its zone matrix from. */
20
+ const ZONE_TIERS = ['dmz', 'app', 'secure'] as const;
21
+
22
+ const IPTABLES_SAVE_TIMEOUT_MS = 15_000;
23
+
24
+ /** Firewall hosts celilo knows about, by the `firewall_ip` in their module config. */
25
+ export function listFirewallIps(db: DbClient): string[] {
26
+ const providers = db
27
+ .select()
28
+ .from(capabilities)
29
+ .where(eq(capabilities.capabilityName, 'firewall'))
30
+ .all();
31
+
32
+ const ips: string[] = [];
33
+ for (const provider of providers) {
34
+ const row = db
35
+ .select()
36
+ .from(moduleConfigs)
37
+ .where(eq(moduleConfigs.moduleId, provider.moduleId))
38
+ .all()
39
+ .find((c) => c.key === 'firewall_ip');
40
+ if (row?.value) ips.push(row.value);
41
+ }
42
+ return [...new Set(ips)];
43
+ }
44
+
45
+ export function loadZoneSubnets(db: DbClient): string[] {
46
+ const subnets: string[] = [];
47
+ for (const zone of ZONE_TIERS) {
48
+ const row = db
49
+ .select()
50
+ .from(systemConfig)
51
+ .where(eq(systemConfig.key, `network.${zone}.subnet`))
52
+ .get();
53
+ if (row?.value) subnets.push(row.value);
54
+ }
55
+ return subnets;
56
+ }
57
+
58
+ /**
59
+ * Read each firewall's live ruleset and pair it with celilo's composed trusted
60
+ * set. A firewall that can't be reached contributes nothing rather than a false
61
+ * "no unowned reach" — an unreadable box is unknown, not clean.
62
+ */
63
+ export function collectFirewallReach(db: DbClient, run: Runner = execRunner): FirewallReachState[] {
64
+ const zoneSubnets = loadZoneSubnets(db);
65
+ if (zoneSubnets.length === 0) return [];
66
+
67
+ const states: FirewallReachState[] = [];
68
+ for (const firewallIp of listFirewallIps(db)) {
69
+ // escape-hatch: reads the running filter table (`iptables-save`), which is
70
+ // the only place an out-of-band rule appears. Read-only, no capability.
71
+ const result = runAppCommand({ ipv4_address: firewallIp }, 'iptables-save -t filter', run, {
72
+ timeoutMs: IPTABLES_SAVE_TIMEOUT_MS,
73
+ });
74
+ if (!result.ok) continue;
75
+
76
+ states.push({
77
+ firewallIp,
78
+ live: parseLiveReachRules(result.stdout, zoneSubnets),
79
+ known: loadTrustedSubnets(db, firewallIp).map((e) => e.subnet),
80
+ });
81
+ }
82
+ return states;
83
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { nextModuleState } from './health-runner';
3
+
4
+ /**
5
+ * The D15 rule: a scheduled (monitor-driven) health check observes only and
6
+ * never moves module lifecycle state; an operator-invoked one still does.
7
+ *
8
+ * These are the cases that regress invisibly — a wrong boolean here does not
9
+ * throw, it just makes `celilo module list` flicker between INSTALLED and
10
+ * VERIFIED every monitor interval.
11
+ */
12
+ describe('nextModuleState', () => {
13
+ describe('operator-invoked run (unattended = false)', () => {
14
+ test('passing check verifies an INSTALLED module', () => {
15
+ expect(nextModuleState('INSTALLED', 'healthy', false)).toBe('VERIFIED');
16
+ });
17
+
18
+ test('a warning still verifies — degraded is not failure', () => {
19
+ expect(nextModuleState('INSTALLED', 'degraded', false)).toBe('VERIFIED');
20
+ });
21
+
22
+ test('failing check un-verifies a VERIFIED module', () => {
23
+ expect(nextModuleState('VERIFIED', 'unhealthy', false)).toBe('INSTALLED');
24
+ });
25
+
26
+ test('failing check on an already-INSTALLED module changes nothing', () => {
27
+ expect(nextModuleState('INSTALLED', 'unhealthy', false)).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe('scheduled run (unattended = true)', () => {
32
+ test('failing check does NOT un-verify a VERIFIED module', () => {
33
+ expect(nextModuleState('VERIFIED', 'unhealthy', true)).toBeNull();
34
+ });
35
+
36
+ test('passing check does NOT verify an INSTALLED module', () => {
37
+ expect(nextModuleState('INSTALLED', 'healthy', true)).toBeNull();
38
+ });
39
+
40
+ test('no status moves state, whatever the module is currently in', () => {
41
+ const states = ['INSTALLED', 'VERIFIED', 'ERROR', 'CONFIGURED'] as const;
42
+ const statuses = ['healthy', 'degraded', 'unhealthy'] as const;
43
+ for (const state of states) {
44
+ for (const status of statuses) {
45
+ expect(nextModuleState(state, status, true)).toBeNull();
46
+ }
47
+ }
48
+ });
49
+ });
50
+ });