@celilo/cli 0.17.0 → 0.18.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 (52) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +35 -8
  3. package/drizzle/0019_backup_pid.sql +18 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +5 -5
  6. package/schemas/system_config.json +1 -1
  7. package/src/cli/command-tree-parser.ts +0 -1
  8. package/src/cli/commands/backup-sweep.ts +62 -0
  9. package/src/cli/commands/module-operations.test.ts +45 -1
  10. package/src/cli/commands/module-operations.ts +35 -12
  11. package/src/cli/commands/module-show.ts +1 -0
  12. package/src/cli/commands/system-audit.ts +2 -0
  13. package/src/cli/commands/system-migrate.ts +40 -0
  14. package/src/cli/commands/system-update.ts +5 -0
  15. package/src/cli/completion.ts +18 -0
  16. package/src/cli/fuel-gauge.ts +0 -1
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/tui/audit-state.ts +4 -0
  19. package/src/cli/tui/audit-tui.test.tsx +0 -1
  20. package/src/db/schema.ts +53 -9
  21. package/src/hooks/capability-loader.ts +30 -1
  22. package/src/ipam/allocator.ts +13 -3
  23. package/src/services/alerting/builtin-monitors.test.ts +42 -0
  24. package/src/services/alerting/builtin-monitors.ts +2 -0
  25. package/src/services/alerting/builtin-source.ts +15 -0
  26. package/src/services/audit/abandoned-operations.test.ts +73 -0
  27. package/src/services/audit/abandoned-operations.ts +0 -0
  28. package/src/services/audit/disk-space.test.ts +111 -0
  29. package/src/services/audit/disk-space.ts +114 -0
  30. package/src/services/audit/index.test.ts +1 -0
  31. package/src/services/audit/index.ts +9 -0
  32. package/src/services/audit/types.ts +2 -0
  33. package/src/services/backup-create.ts +4 -4
  34. package/src/services/backup-in-flight-refusal.test.ts +2 -0
  35. package/src/services/backup-metadata.ts +4 -0
  36. package/src/services/backup-staging.test.ts +134 -0
  37. package/src/services/backup-staging.ts +192 -0
  38. package/src/services/backup-sweep.test.ts +68 -0
  39. package/src/services/backup-sweep.ts +62 -0
  40. package/src/services/config-interview.ts +1 -1
  41. package/src/services/deploy-ansible.ts +0 -1
  42. package/src/services/disk-probe.test.ts +74 -0
  43. package/src/services/disk-probe.ts +145 -0
  44. package/src/services/fleet-checks.ts +15 -0
  45. package/src/services/module-operations.test.ts +22 -0
  46. package/src/services/module-operations.ts +48 -1
  47. package/src/services/module-subscriptions.test.ts +39 -6
  48. package/src/services/module-subscriptions.ts +6 -4
  49. package/src/services/module-types-generator.test.ts +6 -3
  50. package/src/services/module-types-generator.ts +12 -7
  51. package/src/services/update/orchestrator.test.ts +1 -0
  52. package/src/variables/context.ts +6 -1
@@ -26,7 +26,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
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
28
  - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). **provides:** `dns_registrar`.
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.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).
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
 
@@ -92,7 +92,7 @@ Module hooks reach a remote box ONLY through these typed primitives
92
92
  - **`runAppCommand` / `runAppCommandWithSecret`** — escape-hatch on-box command; the secret variant feeds the secret on **stdin** (`$SECRET`), never argv.
93
93
  - **`streamBackup` / `streamRestore` / `fetchFile` / `pushFile`** — binary-safe streaming via local shell redirect/pipe.
94
94
  - **`waitFor`** — predicate-poll combinator.
95
- - **`applyRenderedConfig`** — converge: write rendered config → validate → apply → rollback (one round-trip). Used by caddy (Caddyfile), knot (views), iptables (ruleset).
95
+ - **`applyRenderedConfig`** — converge: write rendered config → validate → apply → rollback (one round-trip). Used by caddy (Caddyfile), knot (views), iptables (ruleset). On success it RETAINS the file it replaced as `<path>.celilo-prev` — the durable "what celilo last rendered" record a whole-file converge diffs against.
96
96
  - **`installAuthorizedKey`** — ONE-TIME credential bootstrap for an off-fleet account: `ssh-copy-id` under a password taken from the child ENV (`SSH_ASKPASS_REQUIRE=force`, so no `sshpass` dependency and the password never lands in a command string). Idempotent; used by `external_web` onboarding so every later publish is key-based.
97
97
  - **`tailLog` / `grepLog`** — journald reads (regex / ignoreCase).
98
98
 
@@ -112,10 +112,34 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
112
112
  (`renderRuleset`): registry + firewall state → a complete `iptables-restore`
113
113
  file. Default-DROP FORWARD + established/related + egress + the coarse
114
114
  zone-tier matrix (dmz→app, app→secure from `network.<zone>.subnet`) + per-service DNAT allows.
115
+ Egress permission and egress TRANSLATION are emitted together or neither: a
116
+ blanket `-o <wan>` MASQUERADE on a leaf, one `-s <subnet> -o <uplink>` rule per
117
+ network on a DOWNSTREAM firewall (no external edge). An upstream does not
118
+ translate on a downstream firewall's behalf.
119
+ `subnetsNeedingTranslation(state)` is that set — EVERY network behind the
120
+ firewall (tiers ∪ every declared zone subnet ∪ control plane ∪ registered
121
+ trusted sources) minus whichever of them CONTAINS the egress interface's own
122
+ address (`egressIp`, read off the box). Not just the data-plane tiers: a
123
+ downstream firewall's egress leg IS its LAN leg, so an untranslated network is
124
+ unreachable from a LAN host as well as from the internet.
115
125
  - **Converge** — `modules/iptables/scripts/firewall-functions.ts` (`converge`):
116
- `exposeService`/`unexposeService` register into the store, then render + apply
117
- atomically via `applyRenderedConfig` (`iptables-restore`). Replaces the old
118
- per-rule `iptables -A`; the registry (not `iptables -L`) is the source of truth.
126
+ `exposeService`/`unexposeService` register into the store, then render, CHECK,
127
+ DIFF, and only then apply atomically via `applyRenderedConfig`
128
+ (`iptables-restore`). Replaces the old per-rule `iptables -A`; the registry
129
+ (not `iptables -L`) is the source of truth. It does NOT apply unconditionally:
130
+ a failing check or an unexplainable removal throws, leaving the working
131
+ ruleset in place (`config.force` overrides, on the record).
132
+ - **Render-time completeness checks (pure)** — `modules/iptables/scripts/ruleset-checks.ts`
133
+ (`checkRenderedSet`, `blockingFindings`, `formatConvergeRefusal`): the SEMANTIC
134
+ counterpart to `iptables-restore --test`, which only validates syntax. Reports
135
+ a zone permitted to egress with no translation covering it, a control plane
136
+ that cannot reach a zone it manages, and an unknown control plane (a warning,
137
+ distinct from "known and absent"). Runs under `config.dryRun` too.
138
+ - **Pre-apply ruleset diff (pure)** — `modules/iptables/scripts/ruleset-diff.ts`
139
+ (`parseRuleset`, `diffRuleset`, `driftAgainstLive`, `snapshotCommand`,
140
+ `parseSnapshot`): what the converge would remove, and whether a registry change
141
+ explains it. Exact set algebra against the retained previous render, not a
142
+ heuristic. Live-vs-persisted drift is reported, never decided on.
119
143
  - **Trusted-source registry (desired state)** — `trusted_sources` DB table
120
144
  (migration `0016`) + `apps/celilo/src/services/trusted-sources.ts`
121
145
  (`buildTrustedSourceStore`, `composeTrustedSubnets`). The sibling primitive to
@@ -144,7 +168,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
144
168
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
145
169
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
146
170
  - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium).
147
- - **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`).
171
+ - **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
148
172
  - **Backup artifact encryption** — `apps/celilo/src/services/backup-cipher.ts` — `encryptFileToFile`/`decryptFileToFile`, file-in/file-out and streamed, used by every backup writer (`backup-create.ts`) and reader (`backup-restore.ts`, `restore-from-file.ts`). Do NOT route artifacts through `secrets/encryption.ts`: that API is string-in/string-out for short DB values, and feeding it a tar cost base64 (1.33x) then hex (2x) then `JSON.stringify` — ~9x the artifact in memory, which OOM-killed forgejo's 774 MB backup, and a hard ~805 MB ceiling from the max string length that no amount of RAM raises. On-disk format is `magic "CELILOBK" (8) | version (1) | iv (16) | ciphertext | GCM tag (16)`; the tag is a trailer because it does not exist until the last byte is encrypted. `decryptFileToFile` still reads the pre-2026-07 JSON-envelope artifacts, discriminating on the magic bytes — the envelope's own `schemaVersion` cannot serve, as it lives inside the encrypted tar.
149
173
 
150
174
  ## Generation & templating
@@ -174,7 +198,8 @@ is currently wrong, and routes carry the message to a person's phone. Design:
174
198
  - **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`.
175
199
  - **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.
176
200
  - **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).
177
- - **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` and `backups`. 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`. 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.
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
+ - **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.
178
203
  - **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.
179
204
  - **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).
180
205
  - **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.
@@ -194,7 +219,9 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
194
219
 
195
220
  - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules`, `isBackupDue`. Storage destinations: `backup-storage.ts`. Retention: `backup-retention.ts` (`pruneBackupsForModule`). Restore: `backup-restore.ts`.
196
221
  - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest)`. An absent `backup.schedule` means `daily`, NOT `manual`; opting out takes an explicit `manual`. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
197
- - **The sweep** — `apps/celilo/src/services/backup-sweep.ts` — `runBackupSweep` (the pass that makes backups run by themselves: for each eligible module, is its declared cadence due → back it up → apply declared retention) + `ensureBackupSweepSubscriber`. Driven by `celilo backup sweep` on `timer.tick.1h` — the coarsest tick that can still serve an `hourly` cadence. Armed automatically: `registerModuleSubscriptions` registers the subscriber for any module declaring an `on_backup` hook, so it appears on install or `module update`. A run refused by the in-flight operation lock is a skip retried next tick, never a failure.
222
+ - **The sweep** — `apps/celilo/src/services/backup-sweep.ts` — `runBackupSweep` (the pass that makes backups run by themselves: reclaim orphaned staging → for each eligible module, is its declared cadence due → back it up → apply declared retention) + `ensureBackupSweepSubscriber`. Driven by `celilo backup sweep` on `timer.tick.1h` — the coarsest tick that can still serve an `hourly` cadence. Armed from BOTH `registerModuleSubscriptions` (any module declaring an `on_backup` hook, so it appears on install or `module update`) AND `celilo system migrate`, which the `.deb` postinst runs on every apt upgrade — registering only from module install/update meant a corrected budget never reached an existing fleet, since the row already exists and module updates can be weeks apart. A run refused by the in-flight operation lock is a skip retried next tick, never a failure.
223
+ - **⚠️ The sweep's budget is stated, never inherited** — `BACKUP_SWEEP_TIMEOUT_MS` (4h) and `BACKUP_SWEEP_MAX_ATTEMPTS` (1) in `backup-sweep.ts`. The event bus defaults to `timeout_ms: 60000` / `max_attempts: 3`, and inheriting them made scheduled backups structurally impossible: one forgejo backup measured ~5.5 minutes (1.3 GB result, 3.9 GB peak staging), so the dispatcher SIGTERMed it at 60s — three times an hour, for days. The retry count is half the bug, not a detail: an impossible pass retried 3x strands 3x the staging (27 GB in 5.7 hours on celilo-mgr). The hourly tick IS the retry.
224
+ - **Staging reclamation** — `apps/celilo/src/services/backup-staging.ts` — `reapOrphanedStaging`, `stagingDirFor` (the single source of truth for `/tmp/celilo-backup-<record.id>`, shared with `backup-create.ts` so writer and reaper cannot drift). `backup-create.ts` removes its staging in a `finally`, which is correct and NOT enough: a `finally` never runs when the process is killed by a signal — dispatcher timeout, OOM, Ctrl-C, reboot — and those strand the LARGEST directories. So reclamation is kill-mode agnostic by construction: it asks "is anyone still using this?", answered from the `backups.pid` column plus `isPidRunnable`, both of which outlive the process. A directory is removed only when its owner is provably gone (record absent, record terminal, pid dead, or past `STAGING_TTL_MS` = 6h — the TTL is the only check surviving pid reuse). A live backup is always kept; an unrecognised name is ignored, never deleted. Reclaiming a record that still claimed `in_progress` also marks it failed with `ABANDONED_BACKUP_MESSAGE`, so `celilo backup list` stops showing phantom in-flight backups and the `backups` drift check cannot read a dead attempt as a fresh backup.
198
225
  - **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`) — `backup_missing` / `backup_stale` drift findings against the same declared cadence.
199
226
  - **CLI** — `apps/celilo/src/cli/commands/` — `backup-sweep.ts`, `backup-create.ts` (also `celilo module backup`), `backup-list.ts`, `backup-restore.ts`, `backup-prune.ts`, `backup-delete.ts`, `backup-import.ts`, `backup-pull.ts`, `backup-name.ts`.
200
227
  - **Storage destinations CLI** — `storage-add-local.ts`, `storage-add-s3.ts`, `storage-list.ts`, `storage-verify.ts`, `storage-set-default.ts`, `storage-set-path.ts` (relocate a local destination, migrating existing archives unless `--no-migrate`), `storage-remove.ts`. Any credential change goes through `updateStorageCredentials` in `backup-storage.ts`, which clears the verification stamp — a `✓ Verified` must never describe a destination it was not measured against (#566).
@@ -205,7 +232,7 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
205
232
 
206
233
  ## Events
207
234
 
208
- - **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`).
235
+ - **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`). **Exactly one dispatcher per bus**: `runDispatcher` refuses to start while another dispatcher's process is alive (`assertSoleDispatcher` in `dispatcher.ts`, liveness via `bus.liveDispatchers()` — `kill(pid,0)`, not heartbeat age, since a tick blocks for as long as its slowest handler). The exclusion is here rather than in the systemd unit because a stranded dispatcher can sit outside the unit's cgroup where `KillMode` cannot reach it (#580). `bus.health()` reports `dispatcherCount`/`dispatchers` and a `duplicate_dispatcher` status; `checkDispatcher` (`services/fleet-checks.ts`) fails on more than one.
209
236
 
210
237
  ## Remote API (drive the CLI over the wire)
211
238
 
@@ -0,0 +1,18 @@
1
+ -- Record which process owns a backup's staging directory.
2
+ --
3
+ -- `backup-create.ts` assembles every envelope under `/tmp/celilo-backup-<id>`
4
+ -- and removes it in a `finally`. A `finally` does not run when the process is
5
+ -- killed by a signal — a dispatcher timeout, an OOM, a reboot — and those are
6
+ -- exactly the cases that strand the largest directories. On celilo-mgr the
7
+ -- scheduled sweep was killed at 60s against a backup needing ~5.5 minutes,
8
+ -- three times an hour, stranding 27 GB in under six hours.
9
+ --
10
+ -- Reclamation therefore cannot depend on how a backup ends. The staging reaper
11
+ -- (services/backup-staging.ts) asks "is anyone still using this directory?"
12
+ -- instead, and this column is what lets it answer: the directory names its
13
+ -- record, and the record names its process.
14
+ --
15
+ -- Nullable on purpose. Rows written before this column exists have no pid; the
16
+ -- reaper keeps their staging until the TTL expires rather than guessing.
17
+
18
+ ALTER TABLE `backups` ADD `pid` integer;
@@ -134,6 +134,13 @@
134
134
  "when": 1783400000000,
135
135
  "tag": "0018_drop_alert_policy_snapshot",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "6",
141
+ "when": 1783500000000,
142
+ "tag": "0019_backup_pid",
143
+ "breakpoints": true
137
144
  }
138
145
  ]
139
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.9.1",
61
- "@celilo/cli-display": "^0.1.9",
62
- "@celilo/core": "^0.3.1",
63
- "@celilo/event-bus": "^0.1.10",
60
+ "@celilo/capabilities": "^0.10.0",
61
+ "@celilo/cli-display": "^0.1.10",
62
+ "@celilo/core": "^0.3.2",
63
+ "@celilo/event-bus": "^0.2.0",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -74,7 +74,7 @@
74
74
  "maximum": 4094,
75
75
  "description": "VLAN tag for internal zone (not defaulted; internal is untagged)"
76
76
  },
77
- "network.vpn.subnet": {
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
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)"
@@ -163,7 +163,6 @@ export class CommandTreeParser {
163
163
  * Strip ANSI color codes from text
164
164
  */
165
165
  private stripAnsiCodes(text: string): string {
166
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters
167
166
  return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
168
167
  }
169
168
 
@@ -9,17 +9,68 @@
9
9
  * report counts.
10
10
  */
11
11
 
12
+ import { readdirSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
12
15
  import {
13
16
  createModuleBackup,
14
17
  findBackupEligibleModules,
15
18
  isBackupDue,
16
19
  } from '../../services/backup-create';
20
+ import { failBackup, getBackup } from '../../services/backup-metadata';
17
21
  import { pruneBackupsForModule } from '../../services/backup-retention';
22
+ import {
23
+ ABANDONED_BACKUP_MESSAGE,
24
+ STAGING_PREFIX,
25
+ impliesAbandonedRecord,
26
+ reapOrphanedStaging,
27
+ } from '../../services/backup-staging';
18
28
  import { type BackupSweepReport, runBackupSweep } from '../../services/backup-sweep';
29
+ import { isPidRunnable } from '../../services/module-operations';
19
30
  import type { CommandResult } from '../types';
20
31
 
32
+ /**
33
+ * Reclaim orphaned staging, then correct the records that were still claiming
34
+ * to be running.
35
+ *
36
+ * The record fix-up lives here rather than inside the reaper because the reaper
37
+ * is the filesystem decision and this is a database write; both follow from the
38
+ * one liveness lookup, so neither repeats it.
39
+ */
40
+ function reapStaging() {
41
+ const report = reapOrphanedStaging({
42
+ listStagingDirs: () => {
43
+ // A missing or unreadable temp dir is not a reason to fail the sweep.
44
+ try {
45
+ return readdirSync(tmpdir())
46
+ .filter((name) => name.startsWith(STAGING_PREFIX))
47
+ .map((name) => join(tmpdir(), name));
48
+ } catch {
49
+ return [];
50
+ }
51
+ },
52
+ lookupOwner: (recordId) => {
53
+ const record = getBackup(recordId);
54
+ if (!record) return null;
55
+ return { status: record.status, pid: record.pid, startedAt: record.startedAt };
56
+ },
57
+ isPidRunnable,
58
+ remove: (path) => rmSync(path, { recursive: true, force: true }),
59
+ now: () => Date.now(),
60
+ });
61
+
62
+ for (const reclaimed of report.reclaimed) {
63
+ if (impliesAbandonedRecord(reclaimed.reason)) {
64
+ failBackup(reclaimed.recordId, ABANDONED_BACKUP_MESSAGE);
65
+ }
66
+ }
67
+
68
+ return report;
69
+ }
70
+
21
71
  export async function handleBackupSweep(): Promise<CommandResult> {
22
72
  const report = await runBackupSweep({
73
+ reapStaging,
23
74
  listEligible: () =>
24
75
  findBackupEligibleModules().map(({ module, manifest }) => ({ id: module.id, manifest })),
25
76
  isDue: (moduleId, schedule) => isBackupDue(moduleId, schedule),
@@ -55,6 +106,17 @@ function formatReport(report: BackupSweepReport): string {
55
106
  if (report.failures.length > 0) parts.push(`${report.failures.length} FAILED`);
56
107
 
57
108
  const lines = [`backup sweep: ${parts.join(', ')}`];
109
+ // Reclamation is reported even though it is housekeeping: it is the only
110
+ // visible evidence that backups have been dying, and a silent reaper would
111
+ // hide the very failure it exists to clean up after.
112
+ if (report.staging.reclaimed.length > 0) {
113
+ lines.push(
114
+ ` reclaimed ${report.staging.reclaimed.length} orphaned staging dir(s) from backups that were killed before cleanup`,
115
+ );
116
+ for (const reclaimed of report.staging.reclaimed) {
117
+ lines.push(` ${reclaimed.recordId} (${reclaimed.reason})`);
118
+ }
119
+ }
58
120
  for (const moduleId of report.skippedLocked) {
59
121
  lines.push(` skipped ${moduleId}: another operation is in flight — retrying next tick`);
60
122
  }
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
6
6
  import { closeDb, getDb } from '../../db/client';
7
7
  import { runMigrations } from '../../db/migrate';
8
8
  import { moduleOperations } from '../../db/schema';
9
- import { OPERATION_TTL_MS } from '../../services/module-operations';
9
+ import { ABANDONED_RELEASE_MESSAGE, OPERATION_TTL_MS } from '../../services/module-operations';
10
10
  import { handleModuleOperations } from './module-operations';
11
11
 
12
12
  describe('celilo module operations', () => {
@@ -85,6 +85,50 @@ describe('celilo module operations', () => {
85
85
  expect(statusOf('expired')).toBe('in_progress');
86
86
  });
87
87
 
88
+ // The recurrence gate for #581: the sweep runs on every hourly tick, so an
89
+ // abandoned row must be reclaimed once and then stop being work. Before
90
+ // this, rows only ever accumulated — 85 of them, the oldest 62 days old.
91
+ it('reclaims an abandoned row and does not re-collect it on the next sweep', () => {
92
+ insert('expired', process.pid, OPERATION_TTL_MS + 60_000);
93
+
94
+ const first = handleModuleOperations(['clear'], {});
95
+ if (!first.success) throw new Error(`expected success, got: ${first.error}`);
96
+ expect(first.message).toContain('Released 1');
97
+ expect(statusOf('expired')).toBe('failed');
98
+
99
+ const second = handleModuleOperations(['clear'], {});
100
+ if (!second.success) throw new Error(`expected success, got: ${second.error}`);
101
+ expect(second.message).toContain('no operations in progress');
102
+
103
+ // Reclaimed, not deleted: the released row is the evidence the
104
+ // abandoned-operations audit reads.
105
+ const row = getDb()
106
+ .select()
107
+ .from(moduleOperations)
108
+ .where(eq(moduleOperations.id, 'expired'))
109
+ .get();
110
+ expect(row?.errorMessage).toBe(ABANDONED_RELEASE_MESSAGE);
111
+ });
112
+
113
+ it('hides abandoned rows from list by default and shows them with --abandoned', () => {
114
+ insert('expired', process.pid, OPERATION_TTL_MS + 60_000);
115
+
116
+ const lines: string[] = [];
117
+ const original = console.log;
118
+ console.log = (msg?: unknown) => lines.push(String(msg));
119
+ try {
120
+ handleModuleOperations(['list'], {});
121
+ expect(lines.join('\n')).not.toContain('pid');
122
+ expect(lines.join('\n')).toContain('1 abandoned row(s) hidden');
123
+
124
+ lines.length = 0;
125
+ handleModuleOperations(['list'], { abandoned: true });
126
+ expect(lines.join('\n')).toContain('abandoned (expired)');
127
+ } finally {
128
+ console.log = original;
129
+ }
130
+ });
131
+
88
132
  it('rejects an unknown action rather than silently listing', () => {
89
133
  const result = handleModuleOperations(['nuke'], {});
90
134
  if (result.success) throw new Error('expected an unknown action to fail');
@@ -10,12 +10,22 @@
10
10
  *
11
11
  * `clear` marks rows failed rather than deleting them — the history of
12
12
  * what was abandoned, and when, is worth more than a tidy table.
13
+ *
14
+ * That is load-bearing now, not merely tidy: `clear` runs hourly off the
15
+ * bus, and `services/audit/abandoned-operations.ts` reads exactly these
16
+ * released rows to notice that one module's backup is being killed over
17
+ * and over. Turning this into a DELETE would tidy the table and silently
18
+ * destroy the only signal that a repeatedly-dying operation ever leaves.
13
19
  */
14
20
 
15
21
  import { and, eq } from 'drizzle-orm';
16
22
  import { getDb } from '../../db/client';
17
23
  import { type ModuleOperation, moduleOperations } from '../../db/schema';
18
- import { OPERATION_TTL_MS, isPidRunnable } from '../../services/module-operations';
24
+ import {
25
+ ABANDONED_RELEASE_MESSAGE,
26
+ OPERATION_TTL_MS,
27
+ isPidRunnable,
28
+ } from '../../services/module-operations';
19
29
  import type { CommandResult } from '../types';
20
30
 
21
31
  /** Why a row is not holding the lock, or null when it still is. */
@@ -41,36 +51,49 @@ function inProgressRows(): ModuleOperation[] {
41
51
  .all();
42
52
  }
43
53
 
44
- function handleList(): CommandResult {
54
+ /**
55
+ * Abandoned rows are summarised, not listed, unless `--abandoned` asks for
56
+ * them. The question this command answers is "what holds the lock right
57
+ * now", and on a fleet where something is dying repeatedly the answer was
58
+ * buried under 85 corpses. The count still prints, so they never become
59
+ * invisible — the audit is what reads them as a symptom
60
+ * (`services/audit/abandoned-operations.ts`).
61
+ */
62
+ function handleList(flags: Record<string, boolean | string>): CommandResult {
45
63
  const now = Date.now();
46
64
  const rows = inProgressRows();
65
+ const showAbandoned = flags.abandoned === true || flags.all === true;
47
66
 
48
67
  if (rows.length === 0) {
49
68
  console.log('\nNo module operations in progress.\n');
50
69
  return { success: true, message: 'no operations in progress' };
51
70
  }
52
71
 
72
+ const holding = rows.filter((row) => abandonedReason(row, now) === null);
73
+ const abandoned = rows.length - holding.length;
74
+ const shown = showAbandoned ? rows : holding;
75
+
53
76
  console.log('\nModule operations in progress:\n');
54
- let holding = 0;
55
- for (const row of rows) {
77
+ for (const row of shown) {
56
78
  const reason = abandonedReason(row, now);
57
- if (!reason) holding++;
58
79
  const age = formatAge(now - row.startedAt.getTime());
59
80
  const status = reason ? `abandoned (${reason})` : 'HOLDING LOCK';
60
81
  console.log(
61
82
  ` ${row.operation.padEnd(9)} ${row.moduleId.padEnd(16)} pid ${String(row.pid).padEnd(8)} ${age.padStart(4)} ago ${status}`,
62
83
  );
63
84
  }
85
+ if (shown.length === 0) console.log(' (nothing is holding the lock)');
64
86
 
65
- const abandoned = rows.length - holding;
66
87
  console.log('');
67
- if (abandoned > 0) {
68
- console.log(`${abandoned} abandoned row(s) — "celilo module operations clear" sweeps them.\n`);
88
+ if (abandoned > 0 && !showAbandoned) {
89
+ console.log(
90
+ `${abandoned} abandoned row(s) hidden — "--abandoned" lists them, the hourly sweep clears them.\n`,
91
+ );
69
92
  }
70
93
 
71
94
  return {
72
95
  success: true,
73
- message: `${rows.length} in progress (${holding} holding the lock, ${abandoned} abandoned)`,
96
+ message: `${rows.length} in progress (${holding.length} holding the lock, ${abandoned} abandoned)`,
74
97
  };
75
98
  }
76
99
 
@@ -108,7 +131,7 @@ function handleClear(flags: Record<string, boolean | string>): CommandResult {
108
131
  .set({
109
132
  status: 'failed',
110
133
  completedAt: new Date(),
111
- errorMessage: 'abandoned — released by "celilo module operations clear"',
134
+ errorMessage: ABANDONED_RELEASE_MESSAGE,
112
135
  })
113
136
  .where(and(eq(moduleOperations.id, row.id), eq(moduleOperations.status, 'in_progress')))
114
137
  .run();
@@ -124,11 +147,11 @@ export function handleModuleOperations(
124
147
  ): CommandResult {
125
148
  const action = args[0];
126
149
 
127
- if (!action || action === 'list') return handleList();
150
+ if (!action || action === 'list') return handleList(flags);
128
151
  if (action === 'clear') return handleClear(flags);
129
152
 
130
153
  return {
131
154
  success: false,
132
- error: `Unknown action "${action}"\n\nUsage: celilo module operations [list|clear] [--all]`,
155
+ error: `Unknown action "${action}"\n\nUsage: celilo module operations [list|clear] [--abandoned] [--all]`,
133
156
  };
134
157
  }
@@ -171,6 +171,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
171
171
  secure: 'Secure (Authentication/Database)',
172
172
  'secure-mgmt': "Secure-Mgmt (celilo's own control plane)",
173
173
  external: 'External (VPS/Cloud)',
174
+ 'control-plane-vpn': 'Control-plane VPN (administrative remote access)',
174
175
  };
175
176
 
176
177
  // Cast at the lookup, not the declaration: `zone` comes from config and may be
@@ -38,6 +38,7 @@ import { getOrCreateMasterKey } from '../../secrets/master-key';
38
38
  import { readAllTransportStatuses } from '../../services/alerting/read-records';
39
39
  import { runAudit } from '../../services/audit';
40
40
  import type { DriftFinding, SystemAuditReport } from '../../services/audit';
41
+ import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
41
42
  import { loadBackupAuditInfo } from '../../services/audit/backup-source';
42
43
  import {
43
44
  type LatestCliVersionFetcher,
@@ -363,6 +364,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
363
364
  moduleConfigs: { modules: installedConfigs },
364
365
  health: { results: healthResults },
365
366
  backups: { modules: installedBackupInfo },
367
+ abandonedOperations: { records: loadAbandonedOperations(db) },
366
368
  undeployedModules: {
367
369
  modules: installed.map((m) => ({ id: m.id, state: m.state })),
368
370
  },
@@ -8,11 +8,49 @@
8
8
  */
9
9
 
10
10
  import type { Database } from 'bun:sqlite';
11
+ import { defineEvents, openBus } from '@celilo/event-bus';
12
+ import { getEventBusPath } from '../../config/paths';
11
13
  import { getDb } from '../../db/client';
12
14
  import { runMigrationsOn } from '../../db/migrate';
13
15
  import { findSchemaDrift } from '../../db/schema-introspection';
16
+ import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
17
+ import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
14
18
  import type { CommandResult } from '../types';
15
19
 
20
+ /**
21
+ * Arm celilo's own housekeeping subscribers.
22
+ *
23
+ * Here because this command is what the .deb postinst runs on every apt
24
+ * upgrade — the one moment guaranteed to happen after new CLI code lands.
25
+ * A subscriber registered only from module install/update would not appear
26
+ * until some module happened to be touched next, which can be weeks and
27
+ * looks exactly like a feature that shipped and silently does nothing.
28
+ *
29
+ * Best-effort: a bus that can't be opened must not fail a schema migration.
30
+ */
31
+ function ensureCoreSubscribers(): void {
32
+ try {
33
+ const bus = openBus({ dbPath: getEventBusPath(), events: defineEvents({}) });
34
+ try {
35
+ ensureOperationsSweepSubscriber(bus);
36
+ // The backup sweep is armed here for the same reason, plus a sharper one:
37
+ // its row already exists on every deployed fleet, carrying the 60s bus
38
+ // default that made scheduled backups impossible. Correcting the default
39
+ // in code does nothing until something re-registers, and module
40
+ // install/update can be weeks away. This is the upgrade path.
41
+ //
42
+ // This also re-arms a sweep an operator paused by hand. Deliberate: the
43
+ // pause exists only because staging leaked, and the reaper that stops it
44
+ // leaking ships in this same binary.
45
+ ensureBackupSweepSubscriber(bus);
46
+ } finally {
47
+ bus.close();
48
+ }
49
+ } catch {
50
+ // Nothing to do — the next module install/update arms it.
51
+ }
52
+ }
53
+
16
54
  function countApplied(sqlite: Database): number {
17
55
  try {
18
56
  const row = sqlite
@@ -57,6 +95,8 @@ export async function handleSystemMigrate(): Promise<CommandResult> {
57
95
  };
58
96
  }
59
97
 
98
+ ensureCoreSubscribers();
99
+
60
100
  const lines = [
61
101
  applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
62
102
  `Schema current: ${drift.tableCount} tables.`,
@@ -26,6 +26,7 @@ import { backups, moduleConfigs as moduleConfigsTbl, modules } from '../../db/sc
26
26
  import type { ModuleManifest } from '../../manifest/schema';
27
27
  import { RegistryClient } from '../../registry/client';
28
28
  import { runAudit } from '../../services/audit';
29
+ import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
29
30
  import { fetchLatestCliVersion } from '../../services/audit/cli-version';
30
31
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
31
32
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
@@ -567,6 +568,7 @@ export async function handleSystemUpdate(
567
568
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
568
569
  })),
569
570
  },
571
+ abandonedOperations: { records: loadAbandonedOperations(db) },
570
572
  undeployedModules: {
571
573
  modules: installed.map((m) => ({ id: m.id, state: m.state })),
572
574
  },
@@ -778,6 +780,9 @@ export function rebuildAuditDepsForRerun(
778
780
  configs: configsByModule.get(m.id) ?? {},
779
781
  })),
780
782
  },
783
+ // Unchanged across an orchestrator run — an upgrade does not reclaim
784
+ // abandoned operations, so re-reading them would be the same rows.
785
+ abandonedOperations: original.abandonedOperations,
781
786
  backups: {
782
787
  ...original.backups,
783
788
  modules: upgradable.map((m) => ({
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../db/client';
8
8
  import { capabilities, modules } from '../db/schema';
9
9
  import type { ModuleManifest } from '../manifest/schema';
10
+ import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
10
11
  import { listPrincipals } from '../services/api-access';
11
12
  import { listBackups } from '../services/backup-metadata';
12
13
  import { listBackupStorages } from '../services/backup-storage';
@@ -479,6 +480,23 @@ export async function getCompletions(words: string[], current: number): Promise<
479
480
  return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
480
481
  }
481
482
 
483
+ // Monitor targets - a module ID or one of celilo's own schedulable checks.
484
+ // Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
485
+ // a new built-in check is completable the moment it is schedulable.
486
+ if (
487
+ command === 'monitor' &&
488
+ (args[1] === 'add' || args[1] === 'run' || args[1] === 'enable' || args[1] === 'disable') &&
489
+ currentIndex === 2
490
+ ) {
491
+ const db = getDb();
492
+ const moduleIds = db
493
+ .select({ id: modules.id })
494
+ .from(modules)
495
+ .all()
496
+ .map((m) => m.id);
497
+ return filterSuggestions([...SCHEDULABLE_BUILTIN_CHECKS, ...moduleIds], args[2] || '');
498
+ }
499
+
482
500
  // Alerts subcommands
483
501
  if (command === 'alerts' && currentIndex === 1) {
484
502
  return filterSuggestions(['list', 'ack', 'silence', 'resolve', 'sweep', 'poll'], args[1] || '');
@@ -334,7 +334,6 @@ export class FuelGauge {
334
334
  * Strip ANSI codes from text
335
335
  */
336
336
  private stripAnsi(text: string): string {
337
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character needed for ANSI code matching
338
337
  return text.replace(/\u001b\[[0-9;]*m/g, '');
339
338
  }
340
339
 
@@ -361,7 +361,7 @@ _celilo_system_config_keys() {
361
361
  'network.internal.gateway:Internal gateway IP'
362
362
  'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
363
363
  'network.secure-mgmt.gateway:Control-plane gateway IP'
364
- 'network.vpn.subnet:VPN client subnet (WireGuard remote access)'
364
+ 'network.control-plane-vpn.subnet:Administrative VPN client subnet (WireGuard remote access)'
365
365
  'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
366
366
  'dns.primary:Primary DNS server'
367
367
  'dns.fallback:Fallback DNS servers'