@celilo/cli 0.19.0 → 0.21.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 (57) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
  4. package/drizzle/0021_dns_registration_consumers.sql +63 -0
  5. package/drizzle/0022_dns_registrations_companion.sql +15 -0
  6. package/drizzle/0023_public_dns_evidence.sql +19 -0
  7. package/drizzle/meta/_journal.json +29 -1
  8. package/package.json +4 -4
  9. package/schemas/system_config.json +22 -11
  10. package/src/api/remote-client.test.ts +34 -12
  11. package/src/api/serve.ts +234 -38
  12. package/src/api/sessions.test.ts +196 -0
  13. package/src/api/sessions.ts +278 -0
  14. package/src/cli/commands/backup-sweep.ts +25 -9
  15. package/src/cli/commands/dns.ts +8 -4
  16. package/src/cli/commands/events.ts +64 -5
  17. package/src/cli/commands/module-update.ts +34 -11
  18. package/src/cli/commands/system-audit.ts +15 -0
  19. package/src/cli/commands/system-migrate.test.ts +25 -4
  20. package/src/cli/commands/system-update.ts +5 -0
  21. package/src/cli/completion.ts +1 -0
  22. package/src/cli/index.ts +22 -2
  23. package/src/cli/tui/audit-state.ts +2 -0
  24. package/src/db/dns-registrations-migration.test.ts +205 -0
  25. package/src/db/schema.ts +77 -8
  26. package/src/hooks/define-hook.test.ts +3 -3
  27. package/src/hooks/executor.test.ts +58 -0
  28. package/src/hooks/executor.ts +67 -7
  29. package/src/hooks/run-named-hook.ts +7 -1
  30. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  31. package/src/module/packaging/build.ts +14 -0
  32. package/src/services/alerting/builtin-monitors.ts +3 -0
  33. package/src/services/alerting/builtin-source.ts +23 -0
  34. package/src/services/audit/index.test.ts +2 -0
  35. package/src/services/audit/index.ts +3 -0
  36. package/src/services/audit/public-dns-source.ts +55 -0
  37. package/src/services/audit/public-dns.test.ts +209 -0
  38. package/src/services/audit/public-dns.ts +286 -0
  39. package/src/services/audit/types.ts +1 -0
  40. package/src/services/backup-metadata.ts +17 -0
  41. package/src/services/backup-staging.test.ts +98 -0
  42. package/src/services/backup-staging.ts +73 -1
  43. package/src/services/backup-sweep.test.ts +15 -0
  44. package/src/services/backup-sweep.ts +17 -1
  45. package/src/services/bus-interview-park.test.ts +179 -0
  46. package/src/services/bus-interview.ts +13 -8
  47. package/src/services/dns-registrations.test.ts +78 -16
  48. package/src/services/dns-registrations.ts +107 -19
  49. package/src/services/fleet-checks.test.ts +47 -1
  50. package/src/services/fleet-checks.ts +36 -4
  51. package/src/services/interview-errors.ts +24 -7
  52. package/src/services/module-subscriptions.test.ts +9 -0
  53. package/src/services/public-dns-probe.test.ts +81 -0
  54. package/src/services/public-dns-probe.ts +156 -0
  55. package/src/services/remote-responder.test.ts +33 -20
  56. package/src/services/remote-responder.ts +10 -6
  57. package/src/services/update/orchestrator.test.ts +2 -0
@@ -25,12 +25,12 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
25
25
  - **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
26
26
  - **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
27
27
  - **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
28
- - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). **provides:** `dns_registrar`.
28
+ - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
29
29
  - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does, `greenwave` does not).
30
30
 
31
31
  ## Public edge (ingress / identity)
32
32
 
33
- - **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
33
+ - **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. Emits a 301 redirect block for each served name's `www`/apex companion (kept out of the served-hostname set, so a companion never blocks the ACME wait). Its `health_check`'s DNS item is `internal_dns_resolution` — it digs from the management host behind the split-horizon resolver and is evidence about the in-fleet view only; public reachability is the framework's `public_dns` check. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
34
34
  - **generic-cpanel-hosting-provider** — publishes static sites into a subfolder of a domain on a cPanel/SSH web host celilo does **not** govern (the host owns the domain, DNS, TLS and web server). Systemless like **namecheap** — no `requires.system`, no zone, no IPAM; it holds credentials for an external party. One provider serves many accounts, resolved by hostname; onboarding takes the account password once, `ssh-copy-id`s celilo's key, then discards it. **provides:** `external_web`.
35
35
  - **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
36
36
 
@@ -38,6 +38,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
38
38
  - **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Public surface: `packages/capabilities/src/index.ts`.
39
39
  - **Loader (wires provider factories into hook contexts)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadCapabilityFunctions`, `resolveFirewallNatIp`.
40
40
  - **Ledger wrappers (stateful capabilities)** — `apps/celilo/src/services/dns-registrations.ts` (`withDnsRegistrationLedger`), `apps/celilo/src/services/dns-internal-records.ts` (`withDnsInternalLedger`).
41
+ - **Public DNS ledger (`dns_registrations` + `dns_registration_consumers`)** — `apps/celilo/src/services/dns-registrations.ts` — records WHICH MODULE ASKED FOR WHICH NAME, and deliberately **no address**: the published value is source-detected on every assert and observable on demand from public DNS, and a stored copy is one careless read away from becoming an instruction again (celilo#626 — the 15-minute `refresh_registrations` replayed addresses written by an older celilo and took five public names dark for nine days while every in-fleet check stayed green). Consumers are a SET, not a column: the FK cascade on a single `consumer_module_id` decided when a LIVE record was forgotten, so a row now survives until its LAST consumer is removed (`openspec/changes/public-dns-reachability/design.md` D5). Orphaned rows are pruned on read (`pruneOrphanedRegistrations`) rather than by trigger — SQLite fires delete triggers for FK-cascaded deletes only with `recursive_triggers` on. `companion: true` marks a name celilo claimed on a module's behalf (`www.<domain>` ↔ `<domain>`) rather than one a module asked for; the provider reports it as `outputs.companion_fqdn` whether or not the claim reported success, because a provider's success response is not evidence of publication. Operator surface: `celilo dns registrations`.
41
42
 
42
43
  ### Known capabilities (impl → provider module)
43
44
 
@@ -45,7 +46,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
45
46
  |---|---|---|
46
47
  | `public_web` | `packages/capabilities/src/public-web.ts` (`createPublicWeb`). **Route lifecycle** is framework-owned at both ends, not per-module: removing a consumer runs `apps/celilo/src/services/web-route-cleanup.ts` (delete rows → reclaim `/srv/www/<slug>` → emit `routes_changed`) whether or not the module has an `on_uninstall`; deploying a provider runs `apps/celilo/src/services/public-web-republish.ts` (re-run every static consumer's `on_install`) so a provider rebuild refills the web roots it destroyed. | caddy |
47
48
  | `idp` | `packages/capabilities/src/idp.ts` | authentik |
48
- | `dns_registrar` | `packages/capabilities/src/dns-registrar.ts` (`registerHost`) | namecheap |
49
+ | `dns_registrar` | `packages/capabilities/src/dns-registrar.ts` (`registerHost({ fqdn })` — **there is no way to supply an address**; the provider re-derives it from its own update's source IP on every assert) | namecheap |
49
50
  | `external_web` | `packages/capabilities/src/external-web.ts` (`publishStaticSite`). **OFF-FLEET static publishing** — the counterpart to `public_web`. See the in-fleet vs off-fleet note below. | generic-cpanel-hosting-provider |
50
51
  | `firewall` | `packages/capabilities/src/firewall.ts` (`exposeService`, `unexposeService`, `listExposedServices`). **Converge model**: register into the shared-core port-forward registry → render the complete ruleset → apply atomically (see the firewall converge note below). | greenwave, iptables |
51
52
  | `dns_internal` | `packages/capabilities/src/dns-internal.ts` | knot-unbound-internal, technitium |
@@ -198,8 +199,9 @@ is currently wrong, and routes carry the message to a person's phone. Design:
198
199
  - **Alert identity** — `apps/celilo/src/services/alerting/keys.ts` — the key grammar (`module:<id>[/check:<name>]`, `builtin:<check>[/<kind>:<target>]`) that makes "the same problem" the same alert across runs. `moduleAlertKey`, `moduleCheckAlertKey`, `builtinAlertKey`, `parseAlertKey`.
199
200
  - **Reconciliation** — `apps/celilo/src/services/alerting/reconcile.ts` (`reconcile`) — a successful run's failing-key set is authoritative and resolution is by SET DIFFERENCE (absent ⇒ resolved). A run whose outcome is `error` resolves NOTHING and fires a module-level alert instead: the false-all-clear guard.
200
201
  - **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` (the built-in checks and the "module with no health check" coverage check).
201
- - **Scheduled audit categories (`builtin_check` monitors)** — `apps/celilo/src/services/alerting/builtin-source.ts` — `SCHEDULABLE_BUILTIN_CHECKS` is the list of `celilo system audit` categories cheap enough to run every sweep: `machines_reachable`, `backups`, `disk_space`, and `abandoned_operations` (`apps/celilo/src/services/audit/abandoned-operations.ts` — ≥3 abandonments of the same (module, operation) in 7d, the fingerprint of an operation being killed mid-flight). Everything else in the audit needs the whole world injected (proxmox, terraform, registry) and is not schedulable. Enable one with `celilo monitor add backups --interval 1h`. Targets are tab-completable — `completion.ts` reads `SCHEDULABLE_BUILTIN_CHECKS` directly rather than a hand-copied list, so a newly-schedulable check is completable immediately.
202
+ - **Scheduled audit categories (`builtin_check` monitors)** — `apps/celilo/src/services/alerting/builtin-source.ts` — `SCHEDULABLE_BUILTIN_CHECKS` is the list of `celilo system audit` categories cheap enough to run every sweep: `machines_reachable`, `backups`, `disk_space`, `abandoned_operations`, and `public_dns` (`apps/celilo/src/services/audit/abandoned-operations.ts` — ≥3 abandonments of the same (module, operation) in 7d, the fingerprint of an operation being killed mid-flight). Everything else in the audit needs the whole world injected (proxmox, terraform, registry) and is not schedulable. Enable one with `celilo monitor add backups --interval 1h`. Targets are tab-completable — `completion.ts` reads `SCHEDULABLE_BUILTIN_CHECKS` directly rather than a hand-copied list, so a newly-schedulable check is completable immediately.
202
203
  - **Disk-space check** — `apps/celilo/src/services/audit/disk-space.ts` (`auditDiskSpace`, pure over measurements) + `apps/celilo/src/services/disk-probe.ts` (`probeDiskUsage`). Thresholds: `drift` at 85%, `blocked` at 95% — early enough to act on, since a check that fires at exhaustion reports an outage rather than preventing one. ⚠️ **The local management box is MEASURED, not exempted.** `probeMachines()` deliberately reports the local box reachable without probing it (celilo has no SSH key for itself, and the question is meaningless there); copying that shortcut into a disk check would skip the host most likely to fill — the one that stages backups, caches modules and writes the logs, and the one that DID fill. Local reads `statfs`; remote runs `df -P /` over the same bounded SSH. `percentUsed` matches `df`'s capacity semantics (excludes root-reserved blocks) so an alert and an operator's own `df` agree. An unmeasurable host yields a `todo` finding — recorded, never paged, because `machines_reachable` is already paging for that host. Findings are subjected on the **hostname**, not the machine UUID, because suppression resolves a machine's ancestor key from the hostname (see #596, where `machines_reachable` gets this wrong and its alerts therefore never suppress anything). The `backups` roster comes from `apps/celilo/src/services/audit/backup-source.ts` (`loadBackupAuditInfo`), shared with `celilo system audit` so both judge the same fleet.
204
+ - **Public-DNS reachability check (the only check with an OFF-FLEET vantage)** — `apps/celilo/src/services/audit/public-dns.ts` (`auditPublicDns`, pure over an injected probe and the previous run's counters) + `apps/celilo/src/services/public-dns-probe.ts` (the probe) + `audit/public-dns-source.ts` (ledger names + the `public_dns_evidence` counters). Every other check in celilo looks from INSIDE, behind a split-horizon resolver that deliberately answers with an in-zone address — correct for its purpose, and why all of them reported healthy for the nine days of celilo#626. This one resolves every `dns_registrations` FQDN through an **off-fleet resolver** (`public_dns.resolver`, default `1.1.1.1`) and compares it against the address the fleet appears to come from per an independent **echo service** (`public_dns.echo_url`, default `https://api.ipify.org`). Three properties are load-bearing: `assertOffFleetResolver` REFUSES a resolver matching `dns.primary`/`dns.fallback` (a check that quietly used the fleet's resolver would pass forever — the original bug one layer up); the expectation never comes from the registrar's own response (self-agreement, and Namecheap returns `ErrCount 0` for `www` updates it does not apply); and a divergence is a finding only once it OUTLIVES the record's own TTL, measured from the last assert, or it would page on every ISP re-lease. Missing evidence is counted rather than read as success — one undetermined run is silent, N consecutive ones are their own finding (`public_dns_evidence`), which is the hole celilo-website's isitup.org probe demonstrated live. Codes: `public_dns_stale`, `public_dns_missing`, `public_dns_companion_unclaimed`, `public_dns_unverifiable`. Spec: `openspec/specs/public-dns-reachability/spec.md`.
203
205
  - **The sweep** — `apps/celilo/src/services/alerting/sweep-runner.ts` (`runSweep`) — the ordered pass that makes alerting run by itself: run due monitors → promote past-grace alerts → re-evaluate suppression → flush quiet-hours deferrals → notify. Driven by `celilo alerts sweep` on `timer.tick.5m`. Never throws for one bad monitor.
204
206
  - **Suppression (topology-derived, never configured)** — `apps/celilo/src/services/alerting/suppression.ts` — `ancestorKeysFor`/`findSuppressor`/`machineAlertKey`. A firing machine explains its modules' failures; a firing capability provider explains its zone's consumers. Derived from `module_systems`, so it cannot drift from reality. Deploy windows: `deploy-hooks.ts` (`openDeployWindow`/`closeDeployWindows` — closed by module, so a crashed deploy self-heals).
205
207
  - **Escalation & quiet hours** — `escalation.ts` (`decideEscalation`, every reason to stay silent enumerated) + `quiet-hours.ts` (`isWithinQuietHours`, Intl-based and DST-safe). Quiet hours defer the MESSAGE while the escalation clock keeps running.
@@ -0,0 +1,25 @@
1
+ -- Drop `dns_registrations.ip`.
2
+ --
3
+ -- THIS MIGRATION IS THE SWEEP, not the tidying after one.
4
+ --
5
+ -- A row's stored `ip` was replayed by `refresh_registrations` every 15
6
+ -- minutes as the address to publish. Rows written before #464/#466 carry the
7
+ -- external address the caller passed at the time, so once the ISP re-leased,
8
+ -- the refresh re-published a dead address on a timer, indefinitely, reporting
9
+ -- success every tick. celilo#626: five public names dark for nine days,
10
+ -- including apt.celilo.computer and the module registry, while every in-fleet
11
+ -- check stayed green because the split-horizon resolver answers with a
12
+ -- reachable internal address.
13
+ --
14
+ -- Fixing only the write path leaves every armed legacy row armed until
15
+ -- somebody happens to redeploy the module that owns it. Dropping the column
16
+ -- disarms all of them at once, and makes the whole class unrepresentable
17
+ -- rather than merely unlikely — there is no longer a value to go stale.
18
+ --
19
+ -- The address is not migrated anywhere. It is observable on demand from
20
+ -- public DNS (that is what the new `public_dns` check does), and a stored
21
+ -- copy is one careless read away from becoming an instruction again.
22
+ --
23
+ -- Plain DROP COLUMN: `ip` is in no index and no FOREIGN KEY clause.
24
+
25
+ ALTER TABLE `dns_registrations` DROP COLUMN `ip`;
@@ -0,0 +1,63 @@
1
+ -- Replace `dns_registrations.consumer_module_id` with a consumer SET.
2
+ --
3
+ -- The column was overwritten by every re-assert, and it is not a label: the
4
+ -- FK cascade on it decides when a LIVE record is forgotten. Recovering the
5
+ -- fleet with `run-hook caddy on_install` re-attributed auth.lunacycle.net,
6
+ -- buildyourowninternet.dev, dj-incarnadine.org and peterbanka.org to caddy,
7
+ -- so removing caddy would have cascade-deleted registrations that authentik
8
+ -- and the site modules still serve. Keep-first has the mirror failure: if
9
+ -- module A introduces a name and B later needs it too, removing A drops the
10
+ -- row out from under B. Both encode a single owner where there may be several.
11
+ --
12
+ -- With a set, the row survives until its LAST consumer is removed — which is
13
+ -- also the only basis on which "can this record be deleted at the registrar?"
14
+ -- could ever be answered. See design.md D5.
15
+ --
16
+ -- Existing rows are seeded from the single value they carry. The pre-recovery
17
+ -- attribution for the four names above is NOT recoverable — it was overwritten
18
+ -- in place before this migration existed. Seeding preserves what the table
19
+ -- actually holds rather than pretending to reconstruct what it used to.
20
+ --
21
+ -- Order matters: `dns_registrations` is rebuilt (SQLite refuses to drop a
22
+ -- column named in a FOREIGN KEY clause) and `foreign_keys` is ON, so a child
23
+ -- table created before the rebuild would be cascade-emptied by the DROP.
24
+ -- The seed is parked in an FK-free scratch table across it.
25
+
26
+ CREATE TABLE `__dns_consumer_seed` (
27
+ `registration_id` integer NOT NULL,
28
+ `module_id` text NOT NULL,
29
+ `first_seen_at` integer NOT NULL
30
+ );
31
+ --> statement-breakpoint
32
+ INSERT INTO `__dns_consumer_seed` SELECT `id`, `consumer_module_id`, `registered_at` FROM `dns_registrations`;
33
+ --> statement-breakpoint
34
+ CREATE TABLE `__new_dns_registrations` (
35
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
36
+ `provider_module_id` text NOT NULL,
37
+ `fqdn` text NOT NULL,
38
+ `registered_at` integer DEFAULT (unixepoch()) NOT NULL,
39
+ `refreshed_at` integer,
40
+ FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
41
+ );
42
+ --> statement-breakpoint
43
+ INSERT INTO `__new_dns_registrations` SELECT `id`, `provider_module_id`, `fqdn`, `registered_at`, `refreshed_at` FROM `dns_registrations`;--> statement-breakpoint
44
+ DROP TABLE `dns_registrations`;--> statement-breakpoint
45
+ ALTER TABLE `__new_dns_registrations` RENAME TO `dns_registrations`;--> statement-breakpoint
46
+ CREATE UNIQUE INDEX `dns_registrations_provider_fqdn_idx` ON `dns_registrations` (`provider_module_id`,`fqdn`);
47
+ --> statement-breakpoint
48
+ CREATE TABLE `dns_registration_consumers` (
49
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
50
+ `registration_id` integer NOT NULL,
51
+ `module_id` text NOT NULL,
52
+ `first_seen_at` integer DEFAULT (unixepoch()) NOT NULL,
53
+ FOREIGN KEY (`registration_id`) REFERENCES `dns_registrations`(`id`) ON UPDATE no action ON DELETE cascade,
54
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
55
+ );
56
+ --> statement-breakpoint
57
+ CREATE UNIQUE INDEX `dns_registration_consumers_unique_idx` ON `dns_registration_consumers` (`registration_id`,`module_id`);
58
+ --> statement-breakpoint
59
+ INSERT INTO `dns_registration_consumers` (`registration_id`, `module_id`, `first_seen_at`)
60
+ SELECT `registration_id`, `module_id`, `first_seen_at` FROM `__dns_consumer_seed`
61
+ WHERE `module_id` IN (SELECT `id` FROM `modules`);
62
+ --> statement-breakpoint
63
+ DROP TABLE `__dns_consumer_seed`;
@@ -0,0 +1,15 @@
1
+ -- `dns_registrations.companion` — the name celilo claimed on a module's
2
+ -- behalf rather than one a module asked for.
3
+ --
4
+ -- `www.<domain>` and `<domain>` are one name pair: declaring either claims the
5
+ -- other. The declared name fails the deploy if it cannot be published; the
6
+ -- companion is best effort, because failing a deploy over a name nobody
7
+ -- requested is punitive. Best effort must not mean silent, though — a working
8
+ -- www beside a dead apex is celilo#626 in miniature — so companion rows are
9
+ -- watched by the `public_dns` check like any other, and carry the manual
10
+ -- registrar remediation when they diverge.
11
+ --
12
+ -- Existing rows are all declared names: nothing claimed a companion before
13
+ -- this migration.
14
+
15
+ ALTER TABLE `dns_registrations` ADD COLUMN `companion` integer DEFAULT false NOT NULL;
@@ -0,0 +1,19 @@
1
+ -- `public_dns_evidence` — consecutive runs the public-reachability check could
2
+ -- obtain no evidence about a subject.
3
+ --
4
+ -- A probe that could not run is not a pass. celilo-website's external check
5
+ -- already got this half right and half wrong: it records an unreachable prober
6
+ -- as *undetermined* rather than a failure (correct — a prober blip once paged
7
+ -- three modules at once) but then emits no check item at all, so a real outage
8
+ -- produced complete silence for nine days.
9
+ --
10
+ -- Counting the absences closes that without reintroducing the noise: one
11
+ -- undetermined run is still silent, N consecutive ones are their own finding,
12
+ -- distinguishable from "the site is unreachable". A subject that answers has
13
+ -- its row dropped, which is what makes the count consecutive.
14
+
15
+ CREATE TABLE `public_dns_evidence` (
16
+ `subject` text PRIMARY KEY NOT NULL,
17
+ `undetermined_runs` integer DEFAULT 0 NOT NULL,
18
+ `last_checked_at` integer DEFAULT (unixepoch()) NOT NULL
19
+ );
@@ -141,6 +141,34 @@
141
141
  "when": 1783500000000,
142
142
  "tag": "0019_backup_pid",
143
143
  "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "6",
148
+ "when": 1783600000000,
149
+ "tag": "0020_dns_registrations_drop_ip",
150
+ "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "6",
155
+ "when": 1783700000000,
156
+ "tag": "0021_dns_registration_consumers",
157
+ "breakpoints": true
158
+ },
159
+ {
160
+ "idx": 22,
161
+ "version": "6",
162
+ "when": 1783800000000,
163
+ "tag": "0022_dns_registrations_companion",
164
+ "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "6",
169
+ "when": 1783900000000,
170
+ "tag": "0023_public_dns_evidence",
171
+ "breakpoints": true
144
172
  }
145
173
  ]
146
- }
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,10 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^0.10.0",
60
+ "@celilo/capabilities": "^1.0.0",
61
61
  "@celilo/cli-display": "^0.1.10",
62
- "@celilo/core": "^0.4.0",
63
- "@celilo/event-bus": "^0.2.0",
62
+ "@celilo/core": "^0.5.0",
63
+ "@celilo/event-bus": "^0.3.0",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -13,7 +13,7 @@
13
13
  "network.dmz.subnet": {
14
14
  "type": "string",
15
15
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
16
- "description": "DMZ subnet CIDR (not defaulted appears only when a firewall module provides this zone; see openspec/specs/progressive-zone-disclosure/spec.md)"
16
+ "description": "DMZ subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone; see openspec/specs/progressive-zone-disclosure/spec.md)"
17
17
  },
18
18
  "network.dmz.gateway": {
19
19
  "type": "string",
@@ -29,7 +29,7 @@
29
29
  "network.app.subnet": {
30
30
  "type": "string",
31
31
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
32
- "description": "App subnet CIDR (not defaulted appears only when a firewall module provides this zone)"
32
+ "description": "App subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)"
33
33
  },
34
34
  "network.app.gateway": {
35
35
  "type": "string",
@@ -45,7 +45,7 @@
45
45
  "network.secure.subnet": {
46
46
  "type": "string",
47
47
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
48
- "description": "Secure subnet CIDR (not defaulted appears only when a firewall module provides this zone)"
48
+ "description": "Secure subnet CIDR (not defaulted \u2014 appears only when a firewall module provides this zone)"
49
49
  },
50
50
  "network.secure.gateway": {
51
51
  "type": "string",
@@ -61,12 +61,12 @@
61
61
  "network.internal.subnet": {
62
62
  "type": "string",
63
63
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
64
- "description": "Internal subnet CIDR (home devices, trusted; not defaulted discovered from the management box's primary interface at celilo-mgmt install)"
64
+ "description": "Internal subnet CIDR (home devices, trusted; not defaulted \u2014 discovered from the management box's primary interface at celilo-mgmt install)"
65
65
  },
66
66
  "network.internal.gateway": {
67
67
  "type": "string",
68
68
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
69
- "description": "Internal gateway IP address (not defaulted discovered from the default route)"
69
+ "description": "Internal gateway IP address (not defaulted \u2014 discovered from the default route)"
70
70
  },
71
71
  "network.internal.vlan": {
72
72
  "type": "integer",
@@ -77,17 +77,17 @@
77
77
  "network.control-plane-vpn.subnet": {
78
78
  "type": "string",
79
79
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
80
- "description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)"
80
+ "description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon \u2192 in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)"
81
81
  },
82
82
  "network.secure-mgmt.subnet": {
83
83
  "type": "string",
84
84
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
85
- "description": "Control-plane subnet CIDR the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic."
85
+ "description": "Control-plane subnet CIDR \u2014 the network celilo-mgr itself occupies when it does not sit on the internal LAN (not defaulted \u2014 discovered at celilo-mgmt install, or recorded by firewall onboarding). Derives the firewall's trusted sources and the internal resolver's split-horizon view for celilo's own traffic."
86
86
  },
87
87
  "network.secure-mgmt.gateway": {
88
88
  "type": "string",
89
89
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
90
- "description": "Control-plane gateway IP address (not defaulted discovered from the default route)"
90
+ "description": "Control-plane gateway IP address (not defaulted \u2014 discovered from the default route)"
91
91
  },
92
92
  "network.secure-mgmt.vlan": {
93
93
  "type": "integer",
@@ -97,20 +97,31 @@
97
97
  },
98
98
  "firewall.trusted_subnets": {
99
99
  "type": "string",
100
- "description": "Operator-declared trusted source subnets (comma-separated CIDRs) permitted to reach every managed zone. ADDITIVE to the control-plane subnet celilo derives and to the sources modules register it cannot drop control-plane trust, which would block the SSH every hook and converge runs over."
100
+ "description": "Operator-declared trusted source subnets (comma-separated CIDRs) permitted to reach every managed zone. ADDITIVE to the control-plane subnet celilo derives and to the sources modules register \u2014 it cannot drop control-plane trust, which would block the SSH every hook and converge runs over."
101
101
  },
102
102
  "dns.primary": {
103
103
  "type": "string",
104
104
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
105
- "description": "Primary DNS server IP address (not defaulted discovered from the host resolver at celilo-mgmt install; repointed at a dns_internal provider when one deploys)"
105
+ "description": "Primary DNS server IP address (not defaulted \u2014 discovered from the host resolver at celilo-mgmt install; repointed at a dns_internal provider when one deploys)"
106
106
  },
107
107
  "dns.fallback": {
108
108
  "type": "string",
109
- "description": "Fallback DNS servers (comma-separated IP addresses; not defaulted discovered, with 1.1.1.1 as last resort)"
109
+ "description": "Fallback DNS servers (comma-separated IP addresses; not defaulted \u2014 discovered, with 1.1.1.1 as last resort)"
110
110
  },
111
111
  "ssh.public_key": {
112
112
  "type": "string",
113
113
  "description": "SSH public key for container access"
114
+ },
115
+ "public_dns.resolver": {
116
+ "type": "string",
117
+ "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
118
+ "default": "1.1.1.1",
119
+ "description": "Resolver the public_dns check asks what the INTERNET resolves for the fleet's names. Must not be a resolver the fleet itself uses \u2014 the fleet resolver runs split-horizon and would make the check pass whatever the public internet sees (celilo#626). celilo refuses a value matching dns.primary / dns.fallback."
120
+ },
121
+ "public_dns.echo_url": {
122
+ "type": "string",
123
+ "default": "https://api.ipify.org",
124
+ "description": "Echo service reporting the address the fleet appears to come from \u2014 the expectation public DNS is compared against. Deliberately not the registrar's own response, which is self-agreement (and which Namecheap returns successfully for updates it does not apply)."
114
125
  }
115
126
  },
116
127
  "additionalProperties": true
@@ -72,7 +72,7 @@ test('renders a forwarded interview and sends the answer back', async () => {
72
72
  push({ type: 'interview', id: 'q1', kind: 'text', message: 'Hostname?' });
73
73
 
74
74
  const seen: Array<{ id: string }> = [];
75
- const code = await runRemoteClient('ignored', ['module', 'deploy', 'site'], {
75
+ const outcome = await runRemoteClient('ignored', ['module', 'deploy', 'site'], {
76
76
  openTransport: () => transport,
77
77
  out: { write() {} },
78
78
  renderInterview: async (iv) => {
@@ -81,7 +81,7 @@ test('renders a forwarded interview and sends the answer back', async () => {
81
81
  },
82
82
  });
83
83
 
84
- expect(code).toBe(0);
84
+ expect(outcome).toEqual({ status: 'result', exitCode: 0 });
85
85
  expect(seen).toHaveLength(1);
86
86
  expect(seen[0].id).toBe('q1');
87
87
  expect(writes.some((w) => w.includes('"command"'))).toBe(true);
@@ -98,8 +98,12 @@ test('renders a forwarded interview and sends the answer back', async () => {
98
98
  * stream) the next newline submitted the prompt at its `initialValue` — the
99
99
  * question's `defaultValue` — so a breaking update nobody saw came back as a
100
100
  * considered "no". With no terminal the client must say it cannot answer.
101
+ *
102
+ * And it must say so as `unanswerable`, NOT as an `answer` of any shape: an
103
+ * `answer` is what consumes the query and destroys a question nobody decided
104
+ * (celilo#609). The server parks and replies `blocked`.
101
105
  */
102
- test('no TTY and no renderer → answers with an error, never the default', async () => {
106
+ test('no TTY and no renderer → sends unanswerable and returns blocked, never an answer', async () => {
103
107
  const writes: string[] = [];
104
108
  const encoder = new TextEncoder();
105
109
  let controller!: ReadableStreamDefaultController<Uint8Array>;
@@ -114,8 +118,15 @@ test('no TTY and no renderer → answers with an error, never the default', asyn
114
118
  stdin: {
115
119
  write(chunk: string) {
116
120
  writes.push(chunk);
117
- if (chunk.includes('"answer"')) {
118
- push({ type: 'result', success: false, exitCode: 1 });
121
+ // What a #609 server does with "I can't decide": park, don't resolve.
122
+ if (chunk.includes('"unanswerable"')) {
123
+ push({
124
+ type: 'blocked',
125
+ sessionId: 'sess-1',
126
+ eventId: '42',
127
+ question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
128
+ key: 'module-upgrade:iptables.apply_breaking',
129
+ });
119
130
  controller.close();
120
131
  }
121
132
  },
@@ -139,15 +150,26 @@ test('no TTY and no renderer → answers with an error, never the default', asyn
139
150
  // No `renderInterview` — exactly what runRemoteCapture used to do. bun test
140
151
  // runs with a piped stdin, i.e. the MCP server's situation.
141
152
  expect(process.stdin.isTTY).toBeFalsy();
142
- const code = await runRemoteClient('ignored', ['module', 'update'], {
153
+ const outcome = await runRemoteClient('ignored', ['module', 'update'], {
143
154
  openTransport: () => transport,
144
155
  out: { write() {} },
145
156
  });
146
157
 
147
- expect(code).toBe(1);
148
- const answer = writes.find((w) => w.includes('"answer"'));
149
- expect(answer).toBeDefined();
150
- const parsed = JSON.parse(answer as string) as { value: unknown; error?: string };
151
- expect(parsed.value).not.toBe(false);
152
- expect(parsed.error).toContain('module-upgrade:iptables.apply_breaking');
158
+ // Never an answer — that would consume the query.
159
+ expect(writes.find((w) => w.includes('"answer"'))).toBeUndefined();
160
+
161
+ const unanswerable = writes.find((w) => w.includes('"unanswerable"'));
162
+ expect(unanswerable).toBeDefined();
163
+ const parsed = JSON.parse(unanswerable as string) as { id: string; reason: string };
164
+ expect(parsed.id).toBe('q1');
165
+ expect(parsed.reason).toContain('module-upgrade:iptables.apply_breaking');
166
+
167
+ // And the caller is told where it stands rather than being handed a decline.
168
+ expect(outcome).toEqual({
169
+ status: 'blocked',
170
+ sessionId: 'sess-1',
171
+ eventId: '42',
172
+ question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
173
+ key: 'module-upgrade:iptables.apply_breaking',
174
+ });
153
175
  });