@matthesketh/fleet 1.8.0 → 1.11.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 (233) 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/notify.d.ts +1 -0
  37. package/dist/commands/notify.js +51 -0
  38. package/dist/commands/patch-systemd.d.ts +7 -1
  39. package/dist/commands/patch-systemd.js +71 -31
  40. package/dist/commands/remove.d.ts +3 -1
  41. package/dist/commands/remove.js +37 -26
  42. package/dist/commands/restart.d.ts +4 -1
  43. package/dist/commands/restart.js +17 -20
  44. package/dist/commands/rollback.d.ts +4 -1
  45. package/dist/commands/rollback.js +33 -42
  46. package/dist/commands/secrets.js +157 -9
  47. package/dist/commands/start.d.ts +4 -1
  48. package/dist/commands/start.js +17 -20
  49. package/dist/commands/status.d.ts +1 -1
  50. package/dist/commands/status.js +21 -26
  51. package/dist/commands/stop.d.ts +4 -1
  52. package/dist/commands/stop.js +17 -20
  53. package/dist/commands/testflight.d.ts +1 -0
  54. package/dist/commands/testflight.js +193 -0
  55. package/dist/commands/update.d.ts +16 -0
  56. package/dist/commands/update.js +95 -0
  57. package/dist/core/audit/cache.d.ts +4 -0
  58. package/dist/core/audit/cache.js +37 -0
  59. package/dist/core/audit/config.d.ts +5 -0
  60. package/dist/core/audit/config.js +35 -0
  61. package/dist/core/audit/greenlight.d.ts +11 -0
  62. package/dist/core/audit/greenlight.js +81 -0
  63. package/dist/core/audit/reporters/cli.d.ts +3 -0
  64. package/dist/core/audit/reporters/cli.js +68 -0
  65. package/dist/core/audit/suppress.d.ts +6 -0
  66. package/dist/core/audit/suppress.js +37 -0
  67. package/dist/core/audit/target.d.ts +5 -0
  68. package/dist/core/audit/target.js +26 -0
  69. package/dist/core/audit/types.d.ts +54 -0
  70. package/dist/core/audit/types.js +5 -0
  71. package/dist/core/backup/browser-api.d.ts +66 -0
  72. package/dist/core/backup/browser-api.js +197 -0
  73. package/dist/core/backup/browser-server.d.ts +11 -0
  74. package/dist/core/backup/browser-server.js +241 -0
  75. package/dist/core/backup/browser-ui.d.ts +5 -0
  76. package/dist/core/backup/browser-ui.js +268 -0
  77. package/dist/core/backup/cloudflare.d.ts +7 -0
  78. package/dist/core/backup/cloudflare.js +82 -0
  79. package/dist/core/backup/config.d.ts +9 -0
  80. package/dist/core/backup/config.js +80 -0
  81. package/dist/core/backup/detect.d.ts +11 -0
  82. package/dist/core/backup/detect.js +71 -0
  83. package/dist/core/backup/dump.d.ts +11 -0
  84. package/dist/core/backup/dump.js +82 -0
  85. package/dist/core/backup/index.d.ts +9 -0
  86. package/dist/core/backup/index.js +9 -0
  87. package/dist/core/backup/repo.d.ts +71 -0
  88. package/dist/core/backup/repo.js +256 -0
  89. package/dist/core/backup/schedule.d.ts +17 -0
  90. package/dist/core/backup/schedule.js +90 -0
  91. package/dist/core/backup/sensitive.d.ts +5 -0
  92. package/dist/core/backup/sensitive.js +37 -0
  93. package/dist/core/backup/status.d.ts +3 -0
  94. package/dist/core/backup/status.js +29 -0
  95. package/dist/core/backup/statuspage.d.ts +23 -0
  96. package/dist/core/backup/statuspage.js +145 -0
  97. package/dist/core/backup/system.d.ts +24 -0
  98. package/dist/core/backup/system.js +209 -0
  99. package/dist/core/backup/totp.d.ts +16 -0
  100. package/dist/core/backup/totp.js +116 -0
  101. package/dist/core/backup/types.d.ts +70 -0
  102. package/dist/core/backup/types.js +7 -0
  103. package/dist/core/backup/unlock.d.ts +19 -0
  104. package/dist/core/backup/unlock.js +69 -0
  105. package/dist/core/boot-refresh.d.ts +1 -1
  106. package/dist/core/boot-refresh.js +10 -9
  107. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  108. package/dist/core/deps/actors/pr-creator.js +71 -18
  109. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  110. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  111. package/dist/core/deps/collectors/npm.js +3 -1
  112. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  113. package/dist/core/deps/collectors/vulnerability.js +31 -2
  114. package/dist/core/deps/config.js +6 -0
  115. package/dist/core/deps/scanner.js +1 -1
  116. package/dist/core/deps/types.d.ts +8 -0
  117. package/dist/core/env.d.ts +3 -0
  118. package/dist/core/env.js +11 -0
  119. package/dist/core/exec.d.ts +1 -0
  120. package/dist/core/exec.js +4 -0
  121. package/dist/core/file-lock.d.ts +18 -0
  122. package/dist/core/file-lock.js +44 -0
  123. package/dist/core/git-onboard.js +10 -13
  124. package/dist/core/github.d.ts +3 -1
  125. package/dist/core/github.js +10 -7
  126. package/dist/core/logs-policy.d.ts +5 -0
  127. package/dist/core/logs-policy.js +20 -1
  128. package/dist/core/operator.d.ts +21 -0
  129. package/dist/core/operator.js +54 -0
  130. package/dist/core/registry.d.ts +18 -0
  131. package/dist/core/registry.js +26 -0
  132. package/dist/core/routines/schema.d.ts +11 -11
  133. package/dist/core/routines/schema.js +14 -3
  134. package/dist/core/routines/store.d.ts +8 -8
  135. package/dist/core/secrets-ops.d.ts +31 -6
  136. package/dist/core/secrets-ops.js +208 -102
  137. package/dist/core/secrets-providers.js +2 -2
  138. package/dist/core/secrets-rotation.d.ts +1 -1
  139. package/dist/core/secrets-rotation.js +58 -52
  140. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  141. package/dist/core/secrets-v2-cleanup.js +94 -0
  142. package/dist/core/secrets-v2-creds.d.ts +9 -0
  143. package/dist/core/secrets-v2-creds.js +44 -0
  144. package/dist/core/secrets-v2-install.d.ts +13 -0
  145. package/dist/core/secrets-v2-install.js +76 -0
  146. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  147. package/dist/core/secrets-v2-keypair.js +31 -0
  148. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  149. package/dist/core/secrets-v2-migrate.js +395 -0
  150. package/dist/core/secrets-v2-ops.d.ts +36 -0
  151. package/dist/core/secrets-v2-ops.js +184 -0
  152. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  153. package/dist/core/secrets-v2-protocol.js +60 -0
  154. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  155. package/dist/core/secrets-v2-snapshot.js +115 -0
  156. package/dist/core/secrets-v2.d.ts +21 -0
  157. package/dist/core/secrets-v2.js +249 -0
  158. package/dist/core/secrets.d.ts +39 -4
  159. package/dist/core/secrets.js +91 -11
  160. package/dist/core/self-update.d.ts +32 -11
  161. package/dist/core/self-update.js +52 -14
  162. package/dist/core/testflight/asc.d.ts +12 -0
  163. package/dist/core/testflight/asc.js +101 -0
  164. package/dist/core/testflight/credentials.d.ts +3 -0
  165. package/dist/core/testflight/credentials.js +35 -0
  166. package/dist/core/testflight/eas.d.ts +4 -0
  167. package/dist/core/testflight/eas.js +38 -0
  168. package/dist/core/testflight/resolve.d.ts +6 -0
  169. package/dist/core/testflight/resolve.js +44 -0
  170. package/dist/core/testflight/types.d.ts +13 -0
  171. package/dist/core/testflight/types.js +3 -0
  172. package/dist/core/testflight/workflow.d.ts +17 -0
  173. package/dist/core/testflight/workflow.js +65 -0
  174. package/dist/core/validate.d.ts +1 -0
  175. package/dist/core/validate.js +8 -0
  176. package/dist/mcp/audit-tools.d.ts +2 -0
  177. package/dist/mcp/audit-tools.js +94 -0
  178. package/dist/mcp/git-tools.js +1 -1
  179. package/dist/mcp/registry-bridge.d.ts +10 -0
  180. package/dist/mcp/registry-bridge.js +65 -0
  181. package/dist/mcp/secrets-tools.js +2 -2
  182. package/dist/mcp/server.js +16 -82
  183. package/dist/mcp/testflight-tools.d.ts +2 -0
  184. package/dist/mcp/testflight-tools.js +52 -0
  185. package/dist/registry/context.d.ts +7 -0
  186. package/dist/registry/context.js +37 -0
  187. package/dist/registry/index.d.ts +5 -0
  188. package/dist/registry/index.js +44 -0
  189. package/dist/registry/parse-args.d.ts +13 -0
  190. package/dist/registry/parse-args.js +74 -0
  191. package/dist/registry/registry.d.ts +24 -0
  192. package/dist/registry/registry.js +26 -0
  193. package/dist/registry/render.d.ts +3 -0
  194. package/dist/registry/render.js +29 -0
  195. package/dist/registry/types.d.ts +50 -0
  196. package/dist/registry/types.js +1 -0
  197. package/dist/templates/agent-unit.d.ts +5 -0
  198. package/dist/templates/agent-unit.js +40 -0
  199. package/dist/templates/app-unit-edit.d.ts +2 -0
  200. package/dist/templates/app-unit-edit.js +46 -0
  201. package/dist/templates/compose-edit.d.ts +2 -0
  202. package/dist/templates/compose-edit.js +156 -0
  203. package/dist/templates/nginx.js +11 -0
  204. package/dist/templates/systemd.js +6 -0
  205. package/dist/tui/components/ArgForm.d.ts +7 -0
  206. package/dist/tui/components/ArgForm.js +64 -0
  207. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  208. package/dist/tui/components/ArgForm.test.js +19 -0
  209. package/dist/tui/components/KeyHint.js +5 -0
  210. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  211. package/dist/tui/hooks/use-secrets.js +7 -7
  212. package/dist/tui/router.d.ts +1 -0
  213. package/dist/tui/router.js +26 -9
  214. package/dist/tui/router.test.d.ts +1 -0
  215. package/dist/tui/router.test.js +13 -0
  216. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  217. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  218. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  219. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  220. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  221. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  222. package/dist/tui/types.d.ts +1 -1
  223. package/dist/tui/views/CommandPalette.d.ts +5 -0
  224. package/dist/tui/views/CommandPalette.js +90 -0
  225. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  226. package/dist/tui/views/CommandPalette.test.js +117 -0
  227. package/dist/tui/views/Dashboard.js +10 -7
  228. package/dist/tui/views/HealthView.js +14 -5
  229. package/dist/tui/views/SecretEdit.js +15 -16
  230. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  231. package/dist/tui/views/SecretEdit.test.js +82 -0
  232. package/dist/tui/views/SecretsView.js +26 -16
  233. package/package.json +9 -6
@@ -0,0 +1,71 @@
1
+ import { ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import { FleetError } from '../errors.js';
3
+ import { Retention, SnapshotInfo, RepoStats } from './types.js';
4
+ export declare const SFTP_HOST_ALIAS: string;
5
+ export declare class ResticError extends FleetError {
6
+ }
7
+ /** true when the backend rejects deletions/rewrites (rest-server --append-only
8
+ * in particular). primary-side prune must skip on such backends — pruning
9
+ * happens locally on the backup vps via its own cron. */
10
+ export declare function isAppendOnly(): boolean;
11
+ export declare function initRepo(app: string): void;
12
+ export interface BackupOptions {
13
+ paths: string[];
14
+ excludes?: string[];
15
+ tags?: string[];
16
+ /** add a host=... override (useful for restoring tests). */
17
+ hostname?: string;
18
+ /** when set, restic gets --stdin --stdin-filename and paths are ignored
19
+ * (restic does not accept both). */
20
+ stdinFilename?: string;
21
+ /** small in-memory payloads piped via process stdin (~1mb safe). */
22
+ stdinData?: string;
23
+ /** for large/streaming payloads: a shell command whose stdout is piped
24
+ * into `restic backup --stdin` via `sh -c "<cmd> | restic ..."`. avoids
25
+ * the spawnSync 1mb buffer ceiling so multi-gb dumps work. */
26
+ stdinCommand?: string;
27
+ /** dry-run: restic walks paths and reports what would be added, no upload. */
28
+ dryRun?: boolean;
29
+ }
30
+ export declare function snapshot(app: string, opts: BackupOptions, timeoutMs?: number): SnapshotInfo;
31
+ export declare function listSnapshots(app: string): SnapshotInfo[];
32
+ export interface RestoreOptions {
33
+ snapshotId: string;
34
+ target: string;
35
+ /** restore only these subpaths (restic --include). */
36
+ include?: string[];
37
+ /** dry-run: list files that would be restored without writing. */
38
+ dryRun?: boolean;
39
+ /** post-restore integrity check: re-walks the restored tree and confirms
40
+ * file contents match the snapshot's chunk hashes (restic --verify). */
41
+ verify?: boolean;
42
+ }
43
+ export interface TreeEntry {
44
+ name: string;
45
+ type: 'dir' | 'file';
46
+ path: string;
47
+ size: number;
48
+ mtime: string;
49
+ }
50
+ /** parses `restic ls --json` output into the direct children of dirPath.
51
+ * restic ls recurses, so deeper descendants are filtered out here. */
52
+ export declare function parseLsOutput(stdout: string, dirPath: string): TreeEntry[];
53
+ /** lists the immediate children of dirPath within a snapshot. */
54
+ export declare function lsTree(app: string, snapshotId: string, dirPath: string): TreeEntry[];
55
+ /** the argv (after `restic`) for dumping a file from a snapshot. */
56
+ export declare function dumpFileArgs(app: string, snapshotId: string, filePath: string): string[];
57
+ /** spawns `restic dump`; caller pipes child.stdout to an http response and
58
+ * must handle 'error' / non-zero 'close'. streaming avoids buffering a
59
+ * potentially multi-gb file in node memory. */
60
+ export declare function dumpFileSpawn(app: string, snapshotId: string, filePath: string): ChildProcessWithoutNullStreams;
61
+ export declare function restore(app: string, opts: RestoreOptions, timeoutMs?: number): void;
62
+ /** orthogonal integrity check: walks the restic repo, recomputes chunk
63
+ * hashes, asserts the index is consistent. catches bit-rot on the
64
+ * backup-vps disk. callable independently or right after a restore. */
65
+ export declare function checkIntegrity(app: string, readDataPercent?: number): {
66
+ ok: boolean;
67
+ output: string;
68
+ };
69
+ export declare function prune(app: string, retention: Retention): void;
70
+ export declare function check(app: string): boolean;
71
+ export declare function stats(app: string): RepoStats | null;
@@ -0,0 +1,256 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { FleetError } from '../errors.js';
3
+ import { execSafe } from '../exec.js';
4
+ import { passwordCommandFor } from './unlock.js';
5
+ export const SFTP_HOST_ALIAS = process.env.FLEET_BACKUP_SFTP_ALIAS ?? 'backup-vps-sftp';
6
+ export class ResticError extends FleetError {
7
+ }
8
+ /** when fleet_backup_base_url is set we use it as the backend base — that
9
+ * enables rest:// + append-only protection. when unset we fall back to the
10
+ * legacy sftp alias. the systemd unit loads the url + rest creds from
11
+ * encrypted credstore so they never sit on disk in plaintext. */
12
+ function repoUri(app) {
13
+ const base = process.env.FLEET_BACKUP_BASE_URL;
14
+ if (base) {
15
+ return base.endsWith('/') ? `${base}${app}` : `${base}/${app}`;
16
+ }
17
+ return `sftp:${SFTP_HOST_ALIAS}:${app}`;
18
+ }
19
+ /** true when the backend rejects deletions/rewrites (rest-server --append-only
20
+ * in particular). primary-side prune must skip on such backends — pruning
21
+ * happens locally on the backup vps via its own cron. */
22
+ export function isAppendOnly() {
23
+ return (process.env.FLEET_BACKUP_BASE_URL ?? '').startsWith('rest:');
24
+ }
25
+ function resticEnv(app) {
26
+ // restic respects restic_rest_username/password env vars; the wrapper
27
+ // populates them from the encrypted credstore.
28
+ const env = { RESTIC_PASSWORD_COMMAND: passwordCommandFor(app) };
29
+ if (process.env.RESTIC_REST_USERNAME)
30
+ env.RESTIC_REST_USERNAME = process.env.RESTIC_REST_USERNAME;
31
+ if (process.env.RESTIC_REST_PASSWORD)
32
+ env.RESTIC_REST_PASSWORD = process.env.RESTIC_REST_PASSWORD;
33
+ return env;
34
+ }
35
+ function runRestic(app, args, timeoutMs = 60_000) {
36
+ const r = execSafe('restic', ['-r', repoUri(app), ...args], {
37
+ timeout: timeoutMs,
38
+ env: resticEnv(app),
39
+ });
40
+ return { stdout: r.stdout, stderr: r.stderr, ok: r.ok };
41
+ }
42
+ export function initRepo(app) {
43
+ // if it already exists, snapshots --no-lock succeeds; only init when it doesn't.
44
+ const probe = runRestic(app, ['snapshots', '--no-lock', '--quiet'], 15_000);
45
+ if (probe.ok)
46
+ return;
47
+ const init = runRestic(app, ['init'], 30_000);
48
+ if (!init.ok)
49
+ throw new ResticError(`restic init failed: ${init.stderr}`);
50
+ }
51
+ /** sh single-quote escape — wraps s in '...' with embedded quotes as '\''. */
52
+ function shellQuote(s) {
53
+ return `'${s.replace(/'/g, "'\\''")}'`;
54
+ }
55
+ export function snapshot(app, opts, timeoutMs = 30 * 60_000) {
56
+ const args = ['backup'];
57
+ if (opts.dryRun)
58
+ args.push('--dry-run');
59
+ for (const ex of opts.excludes ?? []) {
60
+ args.push('--exclude', ex);
61
+ }
62
+ for (const tag of opts.tags ?? []) {
63
+ args.push('--tag', tag);
64
+ }
65
+ if (opts.hostname) {
66
+ args.push('--host', opts.hostname);
67
+ }
68
+ if (opts.stdinFilename) {
69
+ // restic --stdin reads from stdin only; positional paths must be omitted.
70
+ args.push('--stdin', '--stdin-filename', opts.stdinFilename);
71
+ }
72
+ else {
73
+ args.push(...opts.paths);
74
+ }
75
+ args.push('--json');
76
+ let r;
77
+ if (opts.stdinCommand) {
78
+ // stream via bash — kernel pipes between the dump cmd and restic, so
79
+ // the dump bytes never enter node's spawnSync buffer. bash is used
80
+ // explicitly (rather than /bin/sh which is dash on ubuntu) because
81
+ // we need `pipefail` to surface dump errors that would otherwise be
82
+ // masked by restic's exit code.
83
+ const resticInvocation = ['restic', '-r', repoUri(app), ...args]
84
+ .map(shellQuote)
85
+ .join(' ');
86
+ const fullCmd = `set -eo pipefail; ${opts.stdinCommand} | ${resticInvocation}`;
87
+ if (process.env.FLEET_DEBUG_DUMP === '1') {
88
+ process.stderr.write(`[fleet-debug] bash -c: ${fullCmd}\n`);
89
+ }
90
+ r = execSafe('bash', ['-c', fullCmd], {
91
+ timeout: timeoutMs,
92
+ env: resticEnv(app),
93
+ });
94
+ }
95
+ else {
96
+ r = execSafe('restic', ['-r', repoUri(app), ...args], {
97
+ timeout: timeoutMs,
98
+ env: resticEnv(app),
99
+ input: opts.stdinData,
100
+ });
101
+ }
102
+ if (!r.ok)
103
+ throw new ResticError(`restic backup failed: ${r.stderr || r.stdout}`);
104
+ // last json line is the summary with snapshot_id (or counters for dry-run)
105
+ const lines = r.stdout.split('\n').filter(Boolean);
106
+ for (let i = lines.length - 1; i >= 0; i--) {
107
+ try {
108
+ const obj = JSON.parse(lines[i]);
109
+ if (obj.message_type === 'summary') {
110
+ return {
111
+ id: obj.snapshot_id ?? 'dry-run',
112
+ shortId: (obj.snapshot_id ?? 'dry-run').slice(0, 8),
113
+ time: new Date().toISOString(),
114
+ hostname: opts.hostname ?? '',
115
+ paths: opts.paths,
116
+ tags: opts.tags ?? [],
117
+ sizeBytes: obj.data_added,
118
+ };
119
+ }
120
+ }
121
+ catch { /* not json, skip */ }
122
+ }
123
+ throw new ResticError(`could not parse restic snapshot summary`);
124
+ }
125
+ export function listSnapshots(app) {
126
+ const r = runRestic(app, ['snapshots', '--json'], 15_000);
127
+ if (!r.ok) {
128
+ if (r.stderr.includes('does not exist'))
129
+ return [];
130
+ throw new ResticError(`restic snapshots failed: ${r.stderr}`);
131
+ }
132
+ if (!r.stdout)
133
+ return [];
134
+ const arr = JSON.parse(r.stdout);
135
+ return arr.map(s => ({
136
+ id: s.id,
137
+ shortId: s.short_id,
138
+ time: s.time,
139
+ hostname: s.hostname,
140
+ paths: s.paths,
141
+ tags: s.tags ?? [],
142
+ }));
143
+ }
144
+ /** parses `restic ls --json` output into the direct children of dirPath.
145
+ * restic ls recurses, so deeper descendants are filtered out here. */
146
+ export function parseLsOutput(stdout, dirPath) {
147
+ const base = dirPath === '/' ? '' : dirPath.replace(/\/+$/, '');
148
+ const entries = [];
149
+ for (const line of stdout.split('\n')) {
150
+ if (!line)
151
+ continue;
152
+ let node;
153
+ try {
154
+ node = JSON.parse(line);
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ if (node.struct_type !== 'node')
160
+ continue;
161
+ const path = typeof node.path === 'string' ? node.path : '';
162
+ if (!path || !path.startsWith(base + '/'))
163
+ continue;
164
+ const rest = path.slice(base.length + 1);
165
+ if (rest.length === 0 || rest.includes('/'))
166
+ continue;
167
+ entries.push({
168
+ name: typeof node.name === 'string' ? node.name : rest,
169
+ type: node.type === 'dir' ? 'dir' : 'file',
170
+ path,
171
+ size: typeof node.size === 'number' ? node.size : 0,
172
+ mtime: typeof node.mtime === 'string' ? node.mtime : '',
173
+ });
174
+ }
175
+ entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1);
176
+ return entries;
177
+ }
178
+ /** lists the immediate children of dirPath within a snapshot. */
179
+ export function lsTree(app, snapshotId, dirPath) {
180
+ const r = runRestic(app, ['ls', snapshotId, '--json', dirPath], 60_000);
181
+ if (!r.ok) {
182
+ throw new ResticError(`restic ls failed for ${dirPath}: ${r.stderr || r.stdout}`);
183
+ }
184
+ return parseLsOutput(r.stdout, dirPath);
185
+ }
186
+ /** the argv (after `restic`) for dumping a file from a snapshot. */
187
+ export function dumpFileArgs(app, snapshotId, filePath) {
188
+ return ['-r', repoUri(app), 'dump', snapshotId, filePath];
189
+ }
190
+ /** spawns `restic dump`; caller pipes child.stdout to an http response and
191
+ * must handle 'error' / non-zero 'close'. streaming avoids buffering a
192
+ * potentially multi-gb file in node memory. */
193
+ export function dumpFileSpawn(app, snapshotId, filePath) {
194
+ return spawn('restic', dumpFileArgs(app, snapshotId, filePath), {
195
+ env: { ...process.env, ...resticEnv(app) },
196
+ });
197
+ }
198
+ export function restore(app, opts, timeoutMs = 60 * 60_000) {
199
+ const args = ['restore', opts.snapshotId, '--target', opts.target];
200
+ if (opts.dryRun)
201
+ args.push('--dry-run');
202
+ if (opts.verify)
203
+ args.push('--verify');
204
+ for (const inc of opts.include ?? []) {
205
+ args.push('--include', inc);
206
+ }
207
+ const r = runRestic(app, args, timeoutMs);
208
+ if (!r.ok)
209
+ throw new ResticError(`restic restore failed: ${r.stderr}`);
210
+ }
211
+ /** orthogonal integrity check: walks the restic repo, recomputes chunk
212
+ * hashes, asserts the index is consistent. catches bit-rot on the
213
+ * backup-vps disk. callable independently or right after a restore. */
214
+ export function checkIntegrity(app, readDataPercent = 5) {
215
+ const r = runRestic(app, ['check', `--read-data-subset=${readDataPercent}%`], 30 * 60_000);
216
+ return { ok: r.ok, output: r.ok ? r.stdout : `${r.stdout}\n${r.stderr}`.trim() };
217
+ }
218
+ export function prune(app, retention) {
219
+ const args = ['forget', '--prune'];
220
+ if (retention.hourly)
221
+ args.push('--keep-hourly', String(retention.hourly));
222
+ if (retention.daily)
223
+ args.push('--keep-daily', String(retention.daily));
224
+ if (retention.weekly)
225
+ args.push('--keep-weekly', String(retention.weekly));
226
+ if (retention.monthly)
227
+ args.push('--keep-monthly', String(retention.monthly));
228
+ if (retention.yearly)
229
+ args.push('--keep-yearly', String(retention.yearly));
230
+ if (args.length === 2) {
231
+ throw new ResticError('retention is empty — refusing to forget all snapshots');
232
+ }
233
+ const r = runRestic(app, args, 5 * 60_000);
234
+ if (!r.ok)
235
+ throw new ResticError(`restic forget failed: ${r.stderr}`);
236
+ }
237
+ export function check(app) {
238
+ const r = runRestic(app, ['check', '--read-data-subset=5%'], 10 * 60_000);
239
+ return r.ok;
240
+ }
241
+ export function stats(app) {
242
+ const r = runRestic(app, ['stats', 'latest', '--json'], 15_000);
243
+ if (!r.ok || !r.stdout)
244
+ return null;
245
+ try {
246
+ const obj = JSON.parse(r.stdout);
247
+ return {
248
+ totalSize: obj.total_size ?? 0,
249
+ totalFileCount: obj.total_file_count ?? 0,
250
+ snapshotCount: obj.snapshots_count ?? 0,
251
+ };
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
@@ -0,0 +1,17 @@
1
+ import { Schedule } from './types.js';
2
+ export declare const SYSTEMD_UNIT_DIR: string;
3
+ export declare function timerUnitName(app: string): string;
4
+ export declare function serviceUnitName(app: string): string;
5
+ export interface ScheduleInstallResult {
6
+ timerPath: string;
7
+ timerContent: string;
8
+ sharedServicePath: string;
9
+ sharedServiceContent: string;
10
+ sharedServiceWrote: boolean;
11
+ }
12
+ /** plans the timer + service units. returns the unit file contents so callers
13
+ * can show a dry-run. set apply=true to actually write+enable. */
14
+ export declare function installScheduleUnits(app: string, schedule: Schedule, opts?: {
15
+ apply?: boolean;
16
+ }): ScheduleInstallResult;
17
+ export declare function disableSchedule(app: string): void;
@@ -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;