@matthesketh/fleet 1.8.1 → 1.11.1

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 (230) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/patch-systemd.d.ts +7 -1
  37. package/dist/commands/patch-systemd.js +71 -31
  38. package/dist/commands/remove.d.ts +3 -1
  39. package/dist/commands/remove.js +37 -26
  40. package/dist/commands/restart.d.ts +4 -1
  41. package/dist/commands/restart.js +17 -20
  42. package/dist/commands/rollback.d.ts +4 -1
  43. package/dist/commands/rollback.js +33 -42
  44. package/dist/commands/secrets.js +157 -9
  45. package/dist/commands/start.d.ts +4 -1
  46. package/dist/commands/start.js +17 -20
  47. package/dist/commands/status.d.ts +1 -1
  48. package/dist/commands/status.js +21 -26
  49. package/dist/commands/stop.d.ts +4 -1
  50. package/dist/commands/stop.js +17 -20
  51. package/dist/commands/testflight.d.ts +1 -0
  52. package/dist/commands/testflight.js +193 -0
  53. package/dist/commands/update.d.ts +16 -0
  54. package/dist/commands/update.js +95 -0
  55. package/dist/core/audit/cache.d.ts +4 -0
  56. package/dist/core/audit/cache.js +37 -0
  57. package/dist/core/audit/config.d.ts +5 -0
  58. package/dist/core/audit/config.js +35 -0
  59. package/dist/core/audit/greenlight.d.ts +11 -0
  60. package/dist/core/audit/greenlight.js +81 -0
  61. package/dist/core/audit/reporters/cli.d.ts +3 -0
  62. package/dist/core/audit/reporters/cli.js +68 -0
  63. package/dist/core/audit/suppress.d.ts +6 -0
  64. package/dist/core/audit/suppress.js +37 -0
  65. package/dist/core/audit/target.d.ts +5 -0
  66. package/dist/core/audit/target.js +26 -0
  67. package/dist/core/audit/types.d.ts +54 -0
  68. package/dist/core/audit/types.js +5 -0
  69. package/dist/core/backup/browser-api.d.ts +66 -0
  70. package/dist/core/backup/browser-api.js +197 -0
  71. package/dist/core/backup/browser-server.d.ts +11 -0
  72. package/dist/core/backup/browser-server.js +241 -0
  73. package/dist/core/backup/browser-ui.d.ts +5 -0
  74. package/dist/core/backup/browser-ui.js +268 -0
  75. package/dist/core/backup/cloudflare.d.ts +7 -0
  76. package/dist/core/backup/cloudflare.js +82 -0
  77. package/dist/core/backup/config.d.ts +9 -0
  78. package/dist/core/backup/config.js +80 -0
  79. package/dist/core/backup/detect.d.ts +11 -0
  80. package/dist/core/backup/detect.js +71 -0
  81. package/dist/core/backup/dump.d.ts +11 -0
  82. package/dist/core/backup/dump.js +82 -0
  83. package/dist/core/backup/index.d.ts +9 -0
  84. package/dist/core/backup/index.js +9 -0
  85. package/dist/core/backup/repo.d.ts +71 -0
  86. package/dist/core/backup/repo.js +256 -0
  87. package/dist/core/backup/schedule.d.ts +17 -0
  88. package/dist/core/backup/schedule.js +90 -0
  89. package/dist/core/backup/sensitive.d.ts +5 -0
  90. package/dist/core/backup/sensitive.js +37 -0
  91. package/dist/core/backup/status.d.ts +3 -0
  92. package/dist/core/backup/status.js +29 -0
  93. package/dist/core/backup/statuspage.d.ts +23 -0
  94. package/dist/core/backup/statuspage.js +145 -0
  95. package/dist/core/backup/system.d.ts +24 -0
  96. package/dist/core/backup/system.js +209 -0
  97. package/dist/core/backup/totp.d.ts +16 -0
  98. package/dist/core/backup/totp.js +116 -0
  99. package/dist/core/backup/types.d.ts +70 -0
  100. package/dist/core/backup/types.js +7 -0
  101. package/dist/core/backup/unlock.d.ts +19 -0
  102. package/dist/core/backup/unlock.js +69 -0
  103. package/dist/core/boot-refresh.d.ts +1 -1
  104. package/dist/core/boot-refresh.js +10 -9
  105. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  106. package/dist/core/deps/actors/pr-creator.js +71 -18
  107. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  108. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  109. package/dist/core/deps/collectors/npm.js +3 -1
  110. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  111. package/dist/core/deps/collectors/vulnerability.js +31 -2
  112. package/dist/core/deps/config.js +6 -0
  113. package/dist/core/deps/scanner.js +1 -1
  114. package/dist/core/deps/types.d.ts +8 -0
  115. package/dist/core/env.d.ts +3 -0
  116. package/dist/core/env.js +11 -0
  117. package/dist/core/exec.d.ts +1 -0
  118. package/dist/core/exec.js +4 -0
  119. package/dist/core/file-lock.d.ts +18 -0
  120. package/dist/core/file-lock.js +44 -0
  121. package/dist/core/git-onboard.js +10 -13
  122. package/dist/core/github.d.ts +3 -1
  123. package/dist/core/github.js +10 -7
  124. package/dist/core/logs-policy.d.ts +5 -0
  125. package/dist/core/logs-policy.js +20 -1
  126. package/dist/core/operator.d.ts +21 -0
  127. package/dist/core/operator.js +54 -0
  128. package/dist/core/registry.d.ts +18 -0
  129. package/dist/core/registry.js +26 -0
  130. package/dist/core/routines/schema.d.ts +11 -11
  131. package/dist/core/routines/schema.js +14 -3
  132. package/dist/core/routines/store.d.ts +8 -8
  133. package/dist/core/secrets-ops.d.ts +31 -6
  134. package/dist/core/secrets-ops.js +208 -102
  135. package/dist/core/secrets-providers.js +2 -2
  136. package/dist/core/secrets-rotation.d.ts +1 -1
  137. package/dist/core/secrets-rotation.js +58 -52
  138. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  139. package/dist/core/secrets-v2-cleanup.js +94 -0
  140. package/dist/core/secrets-v2-creds.d.ts +9 -0
  141. package/dist/core/secrets-v2-creds.js +44 -0
  142. package/dist/core/secrets-v2-install.d.ts +13 -0
  143. package/dist/core/secrets-v2-install.js +76 -0
  144. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  145. package/dist/core/secrets-v2-keypair.js +31 -0
  146. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  147. package/dist/core/secrets-v2-migrate.js +395 -0
  148. package/dist/core/secrets-v2-ops.d.ts +36 -0
  149. package/dist/core/secrets-v2-ops.js +184 -0
  150. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  151. package/dist/core/secrets-v2-protocol.js +60 -0
  152. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  153. package/dist/core/secrets-v2-snapshot.js +115 -0
  154. package/dist/core/secrets-v2.d.ts +21 -0
  155. package/dist/core/secrets-v2.js +249 -0
  156. package/dist/core/secrets.d.ts +39 -4
  157. package/dist/core/secrets.js +91 -11
  158. package/dist/core/self-update.d.ts +32 -11
  159. package/dist/core/self-update.js +52 -14
  160. package/dist/core/testflight/asc.d.ts +12 -0
  161. package/dist/core/testflight/asc.js +101 -0
  162. package/dist/core/testflight/credentials.d.ts +3 -0
  163. package/dist/core/testflight/credentials.js +35 -0
  164. package/dist/core/testflight/resolve.d.ts +6 -0
  165. package/dist/core/testflight/resolve.js +44 -0
  166. package/dist/core/testflight/types.d.ts +13 -0
  167. package/dist/core/testflight/types.js +3 -0
  168. package/dist/core/testflight/workflow.d.ts +17 -0
  169. package/dist/core/testflight/workflow.js +65 -0
  170. package/dist/core/validate.d.ts +1 -0
  171. package/dist/core/validate.js +8 -0
  172. package/dist/index.js +0 -0
  173. package/dist/mcp/audit-tools.d.ts +2 -0
  174. package/dist/mcp/audit-tools.js +94 -0
  175. package/dist/mcp/git-tools.js +1 -1
  176. package/dist/mcp/registry-bridge.d.ts +10 -0
  177. package/dist/mcp/registry-bridge.js +65 -0
  178. package/dist/mcp/secrets-tools.js +2 -2
  179. package/dist/mcp/server.js +16 -82
  180. package/dist/mcp/testflight-tools.d.ts +2 -0
  181. package/dist/mcp/testflight-tools.js +52 -0
  182. package/dist/registry/context.d.ts +7 -0
  183. package/dist/registry/context.js +37 -0
  184. package/dist/registry/index.d.ts +5 -0
  185. package/dist/registry/index.js +44 -0
  186. package/dist/registry/parse-args.d.ts +13 -0
  187. package/dist/registry/parse-args.js +74 -0
  188. package/dist/registry/registry.d.ts +24 -0
  189. package/dist/registry/registry.js +26 -0
  190. package/dist/registry/render.d.ts +3 -0
  191. package/dist/registry/render.js +29 -0
  192. package/dist/registry/types.d.ts +50 -0
  193. package/dist/registry/types.js +1 -0
  194. package/dist/templates/agent-unit.d.ts +5 -0
  195. package/dist/templates/agent-unit.js +40 -0
  196. package/dist/templates/app-unit-edit.d.ts +2 -0
  197. package/dist/templates/app-unit-edit.js +46 -0
  198. package/dist/templates/compose-edit.d.ts +2 -0
  199. package/dist/templates/compose-edit.js +156 -0
  200. package/dist/templates/nginx.js +11 -0
  201. package/dist/templates/systemd.js +6 -0
  202. package/dist/tui/components/ArgForm.d.ts +7 -0
  203. package/dist/tui/components/ArgForm.js +64 -0
  204. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  205. package/dist/tui/components/ArgForm.test.js +19 -0
  206. package/dist/tui/components/KeyHint.js +5 -0
  207. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  208. package/dist/tui/hooks/use-secrets.js +7 -7
  209. package/dist/tui/router.d.ts +1 -0
  210. package/dist/tui/router.js +26 -9
  211. package/dist/tui/router.test.d.ts +1 -0
  212. package/dist/tui/router.test.js +13 -0
  213. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  214. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  215. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  216. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  217. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  218. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  219. package/dist/tui/types.d.ts +1 -1
  220. package/dist/tui/views/CommandPalette.d.ts +5 -0
  221. package/dist/tui/views/CommandPalette.js +90 -0
  222. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  223. package/dist/tui/views/CommandPalette.test.js +117 -0
  224. package/dist/tui/views/Dashboard.js +9 -6
  225. package/dist/tui/views/HealthView.js +9 -4
  226. package/dist/tui/views/SecretEdit.js +15 -16
  227. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  228. package/dist/tui/views/SecretEdit.test.js +82 -0
  229. package/dist/tui/views/SecretsView.js +26 -16
  230. package/package.json +8 -5
@@ -0,0 +1,90 @@
1
+ import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { FleetError } from '../errors.js';
4
+ import { execSafe } from '../exec.js';
5
+ export const SYSTEMD_UNIT_DIR = process.env.FLEET_SYSTEMD_UNIT_DIR ?? '/etc/systemd/system';
6
+ function onCalendarFor(schedule) {
7
+ if (schedule === 'hourly')
8
+ return 'hourly';
9
+ if (schedule === 'daily')
10
+ return 'daily';
11
+ if (schedule === 'weekly')
12
+ return 'weekly';
13
+ return schedule;
14
+ }
15
+ export function timerUnitName(app) {
16
+ return `fleet-backup@${app}.timer`;
17
+ }
18
+ export function serviceUnitName(app) {
19
+ return `fleet-backup@${app}.service`;
20
+ }
21
+ /** plans the timer + service units. returns the unit file contents so callers
22
+ * can show a dry-run. set apply=true to actually write+enable. */
23
+ export function installScheduleUnits(app, schedule, opts = {}) {
24
+ const sharedServicePath = join(SYSTEMD_UNIT_DIR, 'fleet-backup@.service');
25
+ const sharedServiceContent = sharedServiceUnit();
26
+ const timerPath = join(SYSTEMD_UNIT_DIR, timerUnitName(app));
27
+ const timerContent = perAppTimerUnit(app, schedule);
28
+ const sharedServiceWrote = !existsSync(sharedServicePath);
29
+ if (!opts.apply) {
30
+ return { timerPath, timerContent, sharedServicePath, sharedServiceContent, sharedServiceWrote };
31
+ }
32
+ if (!existsSync(SYSTEMD_UNIT_DIR)) {
33
+ mkdirSync(SYSTEMD_UNIT_DIR, { recursive: true });
34
+ }
35
+ if (sharedServiceWrote) {
36
+ writeFileSync(sharedServicePath, sharedServiceContent, { mode: 0o644 });
37
+ }
38
+ writeFileSync(timerPath, timerContent, { mode: 0o644 });
39
+ const reload = execSafe('systemctl', ['daemon-reload'], { timeout: 5_000 });
40
+ if (!reload.ok)
41
+ throw new FleetError(`systemctl daemon-reload failed: ${reload.stderr}`);
42
+ const enable = execSafe('systemctl', ['enable', '--now', timerUnitName(app)], { timeout: 5_000 });
43
+ if (!enable.ok)
44
+ throw new FleetError(`enable timer failed: ${enable.stderr}`);
45
+ return { timerPath, timerContent, sharedServicePath, sharedServiceContent, sharedServiceWrote };
46
+ }
47
+ export function disableSchedule(app) {
48
+ execSafe('systemctl', ['disable', '--now', timerUnitName(app)], { timeout: 5_000 });
49
+ }
50
+ function sharedServiceUnit() {
51
+ // the wrapper loads the rest backend url (with embedded user:pass) from
52
+ // systemd-creds (host-key sealed at rest) and exports it as
53
+ // FLEET_BACKUP_BASE_URL. fleet writes via the append-only rest backend.
54
+ // legacy sftp backend still works if the wrapper / credstore entry are
55
+ // absent.
56
+ return `[Unit]
57
+ Description=fleet backup for %i
58
+ Documentation=fleet backup --help
59
+ After=network-online.target docker.service wg-quick@wg0.service
60
+ Wants=network-online.target wg-quick@wg0.service
61
+
62
+ [Service]
63
+ Type=oneshot
64
+ LoadCredentialEncrypted=mx-url:/etc/credstore.encrypted/mx-url
65
+ ExecStart=/usr/local/sbin/fleet-backup-wrapper %i
66
+ # big snapshots (multi-gb dumps) need headroom; default 90s would kill them.
67
+ TimeoutStartSec=4h
68
+ TimeoutStopSec=5min
69
+ Nice=10
70
+ IOSchedulingClass=best-effort
71
+ IOSchedulingPriority=7
72
+
73
+ [Install]
74
+ WantedBy=multi-user.target
75
+ `;
76
+ }
77
+ function perAppTimerUnit(app, schedule) {
78
+ return `[Unit]
79
+ Description=fleet backup timer for ${app}
80
+
81
+ [Timer]
82
+ OnCalendar=${onCalendarFor(schedule)}
83
+ Persistent=true
84
+ RandomizedDelaySec=10m
85
+ Unit=${serviceUnitName(app)}
86
+
87
+ [Install]
88
+ WantedBy=timers.target
89
+ `;
90
+ }
@@ -0,0 +1,5 @@
1
+ /** classifies a snapshot-relative path as holding secret material or not.
2
+ * used by the backup explorer to refuse view/download of sensitive files
3
+ * while still allowing them to be restored to a staging dir. */
4
+ export type Sensitivity = 'sensitive' | 'normal';
5
+ export declare function classify(path: string): Sensitivity;
@@ -0,0 +1,37 @@
1
+ /** classifies a snapshot-relative path as holding secret material or not.
2
+ * used by the backup explorer to refuse view/download of sensitive files
3
+ * while still allowing them to be restored to a staging dir. */
4
+ // matched case-insensitively against the full path.
5
+ const SENSITIVE_PATTERNS = [
6
+ // key material
7
+ /\/\.ssh\//,
8
+ /\/\.gnupg\//,
9
+ /^\/etc\/ssh\//,
10
+ /^\/etc\/letsencrypt\//,
11
+ /\.pem$/,
12
+ /\.key$/,
13
+ // cloud + credential stores
14
+ /\/\.aws\//,
15
+ /\/\.gcloud\//,
16
+ /\/\.azure\//,
17
+ /\/\.kube\//,
18
+ /\/\.docker\//,
19
+ /\/\.secrets\//,
20
+ /\/credentials/,
21
+ /\/\.token/,
22
+ /\/\.npmrc$/,
23
+ /\/\.git-credentials$/,
24
+ // fleet + agent secrets
25
+ /\/\.claude/,
26
+ /^\/var\/lib\/fleet\//,
27
+ // database dumps
28
+ /\.pg\.sql$/,
29
+ /\.mysql\.sql$/,
30
+ /\.mongo\.archive$/,
31
+ /\.rdb$/,
32
+ /\.sql$/,
33
+ ];
34
+ export function classify(path) {
35
+ const p = path.toLowerCase();
36
+ return SENSITIVE_PATTERNS.some(re => re.test(p)) ? 'sensitive' : 'normal';
37
+ }
@@ -0,0 +1,3 @@
1
+ import { StatusReport } from './statuspage.js';
2
+ /** gathers per-app snapshot counts, last-snapshot time and repo size. */
3
+ export declare function buildStatusReport(): StatusReport;
@@ -0,0 +1,29 @@
1
+ import { listConfiguredApps, loadConfig } from './config.js';
2
+ import { listSnapshots, stats, isAppendOnly } from './repo.js';
3
+ /** gathers per-app snapshot counts, last-snapshot time and repo size. */
4
+ export function buildStatusReport() {
5
+ const apps = listConfiguredApps();
6
+ const entries = [];
7
+ for (const app of apps) {
8
+ const cfg = loadConfig(app);
9
+ if (!cfg)
10
+ continue;
11
+ const snaps = listSnapshots(app);
12
+ const last = snaps[snaps.length - 1];
13
+ const st = stats(app);
14
+ entries.push({
15
+ app,
16
+ schedule: cfg.schedule,
17
+ disabled: !!cfg.disabled,
18
+ snapshotCount: snaps.length,
19
+ lastSnapshotAt: last?.time ?? null,
20
+ totalSize: st?.totalSize ?? null,
21
+ });
22
+ }
23
+ return {
24
+ generatedAt: new Date().toISOString(),
25
+ backend: (process.env.FLEET_BACKUP_BASE_URL ?? '').startsWith('rest:') ? 'rest' : 'sftp',
26
+ appendOnly: isAppendOnly(),
27
+ apps: entries,
28
+ };
29
+ }
@@ -0,0 +1,23 @@
1
+ /** read-only backup status dashboard. rendered to static html by a systemd
2
+ * timer and served at the /backups route (ip-restricted in nginx). */
3
+ export interface StatusEntry {
4
+ app: string;
5
+ schedule: string;
6
+ disabled: boolean;
7
+ snapshotCount: number;
8
+ lastSnapshotAt: string | null;
9
+ totalSize: number | null;
10
+ }
11
+ export interface StatusReport {
12
+ generatedAt: string;
13
+ backend: 'rest' | 'sftp';
14
+ appendOnly: boolean;
15
+ apps: StatusEntry[];
16
+ }
17
+ export type Health = 'ok' | 'stale' | 'missing' | 'disabled';
18
+ export declare function healthOf(entry: StatusEntry, now?: number): Health;
19
+ export declare function humanBytes(n: number | null): string;
20
+ export declare function relativeTime(iso: string | null, now?: number): string;
21
+ /** renders the full standalone html page. no external assets — inline css so
22
+ * it works as a flat file behind nginx. */
23
+ export declare function renderStatusHtml(report: StatusReport, now?: number): string;
@@ -0,0 +1,145 @@
1
+ /** read-only backup status dashboard. rendered to static html by a systemd
2
+ * timer and served at the /backups route (ip-restricted in nginx). */
3
+ /** how long after the expected cadence a backup is considered stale.
4
+ * generous — covers the timer's randomised delay plus a missed run. */
5
+ function stalenessThresholdMs(schedule) {
6
+ const hour = 3_600_000;
7
+ if (schedule === 'hourly')
8
+ return 3 * hour;
9
+ if (schedule === 'weekly')
10
+ return 8.5 * 24 * hour;
11
+ if (schedule.includes('00/3'))
12
+ return 7 * hour;
13
+ if (schedule.includes('00/6'))
14
+ return 13 * hour;
15
+ if (schedule.includes('00/12'))
16
+ return 25 * hour;
17
+ // daily and anything unrecognised
18
+ return 28 * hour;
19
+ }
20
+ export function healthOf(entry, now = Date.now()) {
21
+ if (entry.disabled)
22
+ return 'disabled';
23
+ if (!entry.lastSnapshotAt || entry.snapshotCount === 0)
24
+ return 'missing';
25
+ const age = now - new Date(entry.lastSnapshotAt).getTime();
26
+ return age > stalenessThresholdMs(entry.schedule) ? 'stale' : 'ok';
27
+ }
28
+ export function humanBytes(n) {
29
+ if (n === null)
30
+ return '—';
31
+ if (n < 1024)
32
+ return `${n} B`;
33
+ if (n < 1024 ** 2)
34
+ return `${(n / 1024).toFixed(1)} KB`;
35
+ if (n < 1024 ** 3)
36
+ return `${(n / 1024 ** 2).toFixed(1)} MB`;
37
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
38
+ }
39
+ export function relativeTime(iso, now = Date.now()) {
40
+ if (!iso)
41
+ return 'never';
42
+ const diff = now - new Date(iso).getTime();
43
+ const min = Math.floor(diff / 60_000);
44
+ if (min < 1)
45
+ return 'just now';
46
+ if (min < 60)
47
+ return `${min}m ago`;
48
+ const hr = Math.floor(min / 60);
49
+ if (hr < 48)
50
+ return `${hr}h ago`;
51
+ return `${Math.floor(hr / 24)}d ago`;
52
+ }
53
+ function esc(s) {
54
+ return s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
55
+ }
56
+ /** renders the full standalone html page. no external assets — inline css so
57
+ * it works as a flat file behind nginx. */
58
+ export function renderStatusHtml(report, now = Date.now()) {
59
+ const apps = [...report.apps].sort((a, b) => a.app.localeCompare(b.app));
60
+ const counts = { ok: 0, stale: 0, missing: 0, disabled: 0 };
61
+ for (const a of apps)
62
+ counts[healthOf(a, now)]++;
63
+ const totalBytes = apps.reduce((s, a) => s + (a.totalSize ?? 0), 0);
64
+ const rows = apps.map(a => {
65
+ const h = healthOf(a, now);
66
+ return ` <tr class="h-${h}">
67
+ <td class="dot"><span class="d d-${h}" title="${h}"></span></td>
68
+ <td class="app">${esc(a.app)}</td>
69
+ <td>${esc(a.schedule)}</td>
70
+ <td class="num">${a.snapshotCount}</td>
71
+ <td>${esc(relativeTime(a.lastSnapshotAt, now))}</td>
72
+ <td class="num">${humanBytes(a.totalSize)}</td>
73
+ </tr>`;
74
+ }).join('\n');
75
+ return `<!doctype html>
76
+ <html lang="en">
77
+ <head>
78
+ <meta charset="utf-8">
79
+ <meta name="viewport" content="width=device-width, initial-scale=1">
80
+ <meta http-equiv="refresh" content="300">
81
+ <title>fleet backups</title>
82
+ <style>
83
+ :root { color-scheme: dark; }
84
+ * { box-sizing: border-box; }
85
+ body {
86
+ margin: 0; padding: 2rem;
87
+ background: #0d1117; color: #c9d1d9;
88
+ font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
89
+ }
90
+ h1 { font-size: 1.1rem; margin: 0 0 0.25rem; color: #e6edf3; }
91
+ .meta { color: #8b949e; margin-bottom: 1.25rem; font-size: 0.8rem; }
92
+ .badges { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
93
+ .badge {
94
+ padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.8rem;
95
+ background: #161b22; border: 1px solid #30363d;
96
+ }
97
+ .badge b { color: #e6edf3; }
98
+ .badge.ok b { color: #3fb950; }
99
+ .badge.stale b { color: #d29922; }
100
+ .badge.missing b { color: #f85149; }
101
+ table { border-collapse: collapse; width: 100%; max-width: 880px; }
102
+ th, td { text-align: left; padding: 0.45rem 0.8rem; border-bottom: 1px solid #21262d; }
103
+ th { color: #8b949e; font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
104
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
105
+ td.app { color: #e6edf3; }
106
+ td.dot { width: 1.5rem; }
107
+ .d { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
108
+ .d-ok { background: #3fb950; }
109
+ .d-stale { background: #d29922; }
110
+ .d-missing { background: #f85149; }
111
+ .d-disabled { background: #484f58; }
112
+ tr.h-stale td.app { color: #d29922; }
113
+ tr.h-missing td.app { color: #f85149; }
114
+ tr.h-disabled { opacity: 0.5; }
115
+ tfoot td { border-top: 2px solid #30363d; border-bottom: none; color: #8b949e; padding-top: 0.7rem; }
116
+ </style>
117
+ </head>
118
+ <body>
119
+ <h1>fleet backups</h1>
120
+ <div class="meta">
121
+ generated ${esc(report.generatedAt)} ·
122
+ backend <b>${esc(report.backend)}</b> ·
123
+ ${report.appendOnly ? 'append-only enforced' : 'append-only OFF'}
124
+ </div>
125
+ <div class="badges">
126
+ <span class="badge ok"><b>${counts.ok}</b> ok</span>
127
+ <span class="badge stale"><b>${counts.stale}</b> stale</span>
128
+ <span class="badge missing"><b>${counts.missing}</b> missing</span>
129
+ <span class="badge"><b>${counts.disabled}</b> disabled</span>
130
+ </div>
131
+ <table>
132
+ <thead>
133
+ <tr><th></th><th>app</th><th>schedule</th><th>snaps</th><th>last</th><th>size</th></tr>
134
+ </thead>
135
+ <tbody>
136
+ ${rows}
137
+ </tbody>
138
+ <tfoot>
139
+ <tr><td colspan="3">${apps.length} apps</td><td class="num"></td><td></td><td class="num">${humanBytes(totalBytes)}</td></tr>
140
+ </tfoot>
141
+ </table>
142
+ </body>
143
+ </html>
144
+ `;
145
+ }
@@ -0,0 +1,24 @@
1
+ import { AppBackupConfig } from './types.js';
2
+ /** the `system` pseudo-app: os, infra config, all the things needed to rebuild
3
+ * this host from a fresh ubuntu install + the data on the backup vps. */
4
+ export declare const SYSTEM_PATHS: string[];
5
+ export declare const ROOT_HOME_PATHS: string[];
6
+ /** excludes for root-home and user-home: claude code regeneratable state
7
+ * (sessions, plugin caches, telemetry) and tool caches that take GBs but
8
+ * contain nothing the user authored. credentials.json, settings, hooks,
9
+ * skills, plans, mcp.json all stay. */
10
+ export declare const HOME_EXCLUDES: string[];
11
+ /** the operator's home-dir paths worth backing up — dotfiles and agent
12
+ * state, never app working directories. derived from the configured home. */
13
+ export declare function userHomePaths(homeDir: string): string[];
14
+ export declare const USER_HOME_EXCLUDES: string[];
15
+ export declare function systemConfig(): AppBackupConfig;
16
+ export declare function rootHomeConfig(): AppBackupConfig;
17
+ /** shared-cluster dumps. these are safety nets — they capture every database
18
+ * in the shared container as one big stream. per-app preDump entries are
19
+ * preferred for routine restore granularity (run hourly), but these run
20
+ * daily and catch DBs the per-app list doesn't enumerate. */
21
+ export declare function sharedPostgresConfig(): AppBackupConfig;
22
+ export declare function sharedMysqlConfig(): AppBackupConfig;
23
+ export declare function sharedMongoConfig(): AppBackupConfig;
24
+ export declare function userHomeConfig(): AppBackupConfig;
@@ -0,0 +1,209 @@
1
+ import { loadOperator } from '../operator.js';
2
+ import { DEFAULT_RETENTION } from './config.js';
3
+ /** the `system` pseudo-app: os, infra config, all the things needed to rebuild
4
+ * this host from a fresh ubuntu install + the data on the backup vps. */
5
+ export const SYSTEM_PATHS = [
6
+ '/etc/nginx',
7
+ '/etc/letsencrypt',
8
+ '/etc/systemd/system',
9
+ '/etc/systemd/resolved.conf.d',
10
+ '/etc/fleet',
11
+ '/etc/iptables',
12
+ '/etc/truewaf',
13
+ '/etc/modsecurity',
14
+ '/etc/ssh',
15
+ '/etc/sudoers',
16
+ '/etc/sudoers.d',
17
+ '/etc/fail2ban',
18
+ '/etc/cron.d',
19
+ '/etc/crontab',
20
+ '/etc/netplan',
21
+ '/etc/hosts',
22
+ '/etc/hostname',
23
+ '/etc/timezone',
24
+ '/etc/apt/sources.list',
25
+ '/etc/apt/sources.list.d',
26
+ '/etc/apt/keyrings',
27
+ '/etc/sysctl.conf',
28
+ '/etc/sysctl.d',
29
+ '/etc/security',
30
+ '/etc/php',
31
+ '/etc/guardian',
32
+ '/etc/cloud',
33
+ '/etc/default',
34
+ '/etc/ntpsec',
35
+ '/etc/logrotate.d',
36
+ '/etc/docker/daemon.json',
37
+ '/etc/nftables.conf',
38
+ '/var/lib/fleet',
39
+ '/var/lib/letsencrypt',
40
+ '/var/lib/fail2ban',
41
+ '/usr/local/bin',
42
+ '/usr/local/sbin',
43
+ '/opt/coreruleset',
44
+ '/root/firewall',
45
+ ];
46
+ export const ROOT_HOME_PATHS = [
47
+ '/root/.ssh',
48
+ '/root/.docker/config.json',
49
+ '/root/.docker/.token_seed',
50
+ '/root/.secrets',
51
+ '/root/.gnupg',
52
+ '/root/.aws',
53
+ '/root/.gcloud',
54
+ '/root/.azure',
55
+ '/root/.kube',
56
+ '/root/.gitconfig',
57
+ '/root/.npmrc',
58
+ '/root/.bashrc',
59
+ '/root/.bash_profile',
60
+ '/root/.bash_history',
61
+ '/root/.claude',
62
+ '/root/.claude.json',
63
+ '/root/.mcp.json',
64
+ '/root/.gmail-mcp/tokens',
65
+ '/root/.cargo/credentials.toml',
66
+ '/root/.cargo/config.toml',
67
+ '/root/.pm2/dump.pm2',
68
+ '/root/.pm2/module_conf.json',
69
+ ];
70
+ /** excludes for root-home and user-home: claude code regeneratable state
71
+ * (sessions, plugin caches, telemetry) and tool caches that take GBs but
72
+ * contain nothing the user authored. credentials.json, settings, hooks,
73
+ * skills, plans, mcp.json all stay. */
74
+ export const HOME_EXCLUDES = [
75
+ '**/.claude/projects',
76
+ '**/.claude/plugins/data',
77
+ '**/.claude/plugins/cache',
78
+ '**/.claude/plugins/marketplaces',
79
+ '**/.claude/plugins/install-counts-cache.json',
80
+ '**/.claude/file-history',
81
+ '**/.claude/paste-cache',
82
+ '**/.claude/telemetry',
83
+ '**/.claude/todos',
84
+ '**/.claude/tasks',
85
+ '**/.claude/backups',
86
+ '**/.claude/session-env',
87
+ '**/.claude/sessions',
88
+ '**/.claude/cache',
89
+ '**/.claude/debug',
90
+ '**/.claude/shell-snapshots',
91
+ '**/.claude/usage-data',
92
+ '**/.claude/cc-counter',
93
+ '**/.claude/statsig',
94
+ '**/.claude/stats-cache.json',
95
+ '**/.claude/history.jsonl',
96
+ '**/.claude/ide',
97
+ '**/.claude/downloads',
98
+ '**/.claude/mcp-needs-auth-cache.json',
99
+ '**/.claude/.last-cleanup',
100
+ '**/.claude/hooks/.last_test_run_*',
101
+ '*.log',
102
+ ];
103
+ /** the operator's home-dir paths worth backing up — dotfiles and agent
104
+ * state, never app working directories. derived from the configured home. */
105
+ export function userHomePaths(homeDir) {
106
+ return [
107
+ `${homeDir}/.ssh`,
108
+ `${homeDir}/.gitconfig`,
109
+ `${homeDir}/.docker/config.json`,
110
+ `${homeDir}/.config/gh`,
111
+ `${homeDir}/.config/op`,
112
+ `${homeDir}/.aws`,
113
+ `${homeDir}/.gnupg`,
114
+ `${homeDir}/.terraform.d`,
115
+ `${homeDir}/.claude`,
116
+ `${homeDir}/.claude.json`,
117
+ `${homeDir}/.mcp.json`,
118
+ `${homeDir}/.bashrc`,
119
+ `${homeDir}/.bash_history`,
120
+ `${homeDir}/.profile`,
121
+ `${homeDir}/.local/bin`,
122
+ ];
123
+ }
124
+ export const USER_HOME_EXCLUDES = [
125
+ ...HOME_EXCLUDES,
126
+ '*.cache',
127
+ '.npm',
128
+ '.yarn',
129
+ '.cargo/registry',
130
+ '.cargo/git',
131
+ '.gradle/caches',
132
+ '.local/share',
133
+ 'node_modules',
134
+ ];
135
+ export function systemConfig() {
136
+ return {
137
+ app: 'system',
138
+ schedule: 'daily',
139
+ paths: SYSTEM_PATHS,
140
+ exclude: ['*.log', '*.pid', '.cache'],
141
+ retention: DEFAULT_RETENTION,
142
+ };
143
+ }
144
+ export function rootHomeConfig() {
145
+ return {
146
+ app: 'root-home',
147
+ schedule: 'daily',
148
+ paths: ROOT_HOME_PATHS,
149
+ exclude: HOME_EXCLUDES,
150
+ retention: DEFAULT_RETENTION,
151
+ };
152
+ }
153
+ /** shared-cluster dumps. these are safety nets — they capture every database
154
+ * in the shared container as one big stream. per-app preDump entries are
155
+ * preferred for routine restore granularity (run hourly), but these run
156
+ * daily and catch DBs the per-app list doesn't enumerate. */
157
+ export function sharedPostgresConfig() {
158
+ return {
159
+ app: 'shared-postgres',
160
+ schedule: 'daily',
161
+ paths: [],
162
+ exclude: [],
163
+ // postgres_user=postgres is set in compose env; pg_dumpall uses unix-socket
164
+ // peer auth so no password needed inside the container.
165
+ preDump: { type: 'postgres', container: 'shared-postgres', user: 'postgres' },
166
+ retention: { daily: 14, weekly: 8, monthly: 12 },
167
+ };
168
+ }
169
+ export function sharedMysqlConfig() {
170
+ return {
171
+ app: 'shared-mysql',
172
+ schedule: 'daily',
173
+ paths: [],
174
+ exclude: [],
175
+ // shared-mysql uses docker secrets — password lives at the file path below,
176
+ // not in an env var.
177
+ preDump: {
178
+ type: 'mysql',
179
+ container: 'shared-mysql',
180
+ user: 'root',
181
+ passwordFile: '/run/secrets/mysql_root_password',
182
+ },
183
+ retention: { daily: 14, weekly: 8, monthly: 12 },
184
+ };
185
+ }
186
+ export function sharedMongoConfig() {
187
+ return {
188
+ app: 'shared-mongodb',
189
+ schedule: 'daily',
190
+ paths: [],
191
+ exclude: [],
192
+ preDump: {
193
+ type: 'mongo',
194
+ container: 'shared-mongodb',
195
+ user: 'root',
196
+ passwordFile: '/run/secrets/mongo_root_password',
197
+ },
198
+ retention: { daily: 14, weekly: 8, monthly: 12 },
199
+ };
200
+ }
201
+ export function userHomeConfig() {
202
+ return {
203
+ app: 'user-home',
204
+ schedule: 'daily',
205
+ paths: userHomePaths(loadOperator().homeDir),
206
+ exclude: USER_HOME_EXCLUDES,
207
+ retention: DEFAULT_RETENTION,
208
+ };
209
+ }
@@ -0,0 +1,16 @@
1
+ /** new random 20-byte secret, base32-encoded for authenticator apps. */
2
+ export declare function generateSecret(): string;
3
+ /** the 6-digit TOTP code for a base32 secret at a given epoch-ms time. */
4
+ export declare function totpCode(secretB32: string, atMs?: number): string;
5
+ /** true if `code` is valid for the secret within a +/-1 step window. */
6
+ export declare function verifyTotp(secretB32: string, code: string, atMs?: number): boolean;
7
+ /** otpauth:// enrolment URI — paste into 1Password or an authenticator app. */
8
+ export declare function totpUri(secretB32: string, label: string, issuer: string): string;
9
+ export interface SessionPayload {
10
+ /** epoch-ms expiry. */
11
+ exp: number;
12
+ }
13
+ /** signs a session payload as `<body>.<hmac>` (hmac-sha256). */
14
+ export declare function signSession(payload: SessionPayload, secret: string): string;
15
+ /** verifies a session cookie; returns the payload or null if invalid/expired. */
16
+ export declare function verifySession(cookie: string, secret: string, nowMs?: number): SessionPayload | null;