@matthesketh/fleet 1.8.1 → 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.
- package/README.md +186 -16
- package/dist/bin/fleet-agent.d.ts +2 -0
- package/dist/bin/fleet-agent.js +7 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +73 -31
- package/dist/commands/add.d.ts +2 -1
- package/dist/commands/add.js +66 -59
- package/dist/commands/audit.d.ts +1 -0
- package/dist/commands/audit.js +144 -0
- package/dist/commands/backup.d.ts +1 -0
- package/dist/commands/backup.js +510 -0
- package/dist/commands/boot-start.d.ts +3 -1
- package/dist/commands/boot-start.js +39 -47
- package/dist/commands/completions.d.ts +6 -0
- package/dist/commands/completions.js +83 -0
- package/dist/commands/config.d.ts +16 -0
- package/dist/commands/config.js +96 -0
- package/dist/commands/deploy.js +3 -2
- package/dist/commands/deps.js +5 -1
- package/dist/commands/doctor.d.ts +32 -0
- package/dist/commands/doctor.js +186 -0
- package/dist/commands/egress.d.ts +1 -1
- package/dist/commands/egress.js +13 -10
- package/dist/commands/freeze.d.ts +8 -4
- package/dist/commands/freeze.js +77 -59
- package/dist/commands/git.js +2 -2
- package/dist/commands/health.d.ts +2 -1
- package/dist/commands/health.js +38 -56
- package/dist/commands/init.d.ts +2 -1
- package/dist/commands/init.js +83 -73
- package/dist/commands/install-mcp.d.ts +3 -1
- package/dist/commands/install-mcp.js +53 -34
- package/dist/commands/list.d.ts +2 -1
- package/dist/commands/list.js +22 -19
- package/dist/commands/logs.js +1 -1
- package/dist/commands/notify.d.ts +1 -0
- package/dist/commands/notify.js +51 -0
- package/dist/commands/patch-systemd.d.ts +7 -1
- package/dist/commands/patch-systemd.js +71 -31
- package/dist/commands/remove.d.ts +3 -1
- package/dist/commands/remove.js +37 -26
- package/dist/commands/restart.d.ts +4 -1
- package/dist/commands/restart.js +17 -20
- package/dist/commands/rollback.d.ts +4 -1
- package/dist/commands/rollback.js +33 -42
- package/dist/commands/secrets.js +157 -9
- package/dist/commands/start.d.ts +4 -1
- package/dist/commands/start.js +17 -20
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.js +21 -26
- package/dist/commands/stop.d.ts +4 -1
- package/dist/commands/stop.js +17 -20
- package/dist/commands/testflight.d.ts +1 -0
- package/dist/commands/testflight.js +193 -0
- package/dist/commands/update.d.ts +16 -0
- package/dist/commands/update.js +95 -0
- package/dist/core/audit/cache.d.ts +4 -0
- package/dist/core/audit/cache.js +37 -0
- package/dist/core/audit/config.d.ts +5 -0
- package/dist/core/audit/config.js +35 -0
- package/dist/core/audit/greenlight.d.ts +11 -0
- package/dist/core/audit/greenlight.js +81 -0
- package/dist/core/audit/reporters/cli.d.ts +3 -0
- package/dist/core/audit/reporters/cli.js +68 -0
- package/dist/core/audit/suppress.d.ts +6 -0
- package/dist/core/audit/suppress.js +37 -0
- package/dist/core/audit/target.d.ts +5 -0
- package/dist/core/audit/target.js +26 -0
- package/dist/core/audit/types.d.ts +54 -0
- package/dist/core/audit/types.js +5 -0
- package/dist/core/backup/browser-api.d.ts +66 -0
- package/dist/core/backup/browser-api.js +197 -0
- package/dist/core/backup/browser-server.d.ts +11 -0
- package/dist/core/backup/browser-server.js +241 -0
- package/dist/core/backup/browser-ui.d.ts +5 -0
- package/dist/core/backup/browser-ui.js +268 -0
- package/dist/core/backup/cloudflare.d.ts +7 -0
- package/dist/core/backup/cloudflare.js +82 -0
- package/dist/core/backup/config.d.ts +9 -0
- package/dist/core/backup/config.js +80 -0
- package/dist/core/backup/detect.d.ts +11 -0
- package/dist/core/backup/detect.js +71 -0
- package/dist/core/backup/dump.d.ts +11 -0
- package/dist/core/backup/dump.js +82 -0
- package/dist/core/backup/index.d.ts +9 -0
- package/dist/core/backup/index.js +9 -0
- package/dist/core/backup/repo.d.ts +71 -0
- package/dist/core/backup/repo.js +256 -0
- package/dist/core/backup/schedule.d.ts +17 -0
- package/dist/core/backup/schedule.js +90 -0
- package/dist/core/backup/sensitive.d.ts +5 -0
- package/dist/core/backup/sensitive.js +37 -0
- package/dist/core/backup/status.d.ts +3 -0
- package/dist/core/backup/status.js +29 -0
- package/dist/core/backup/statuspage.d.ts +23 -0
- package/dist/core/backup/statuspage.js +145 -0
- package/dist/core/backup/system.d.ts +24 -0
- package/dist/core/backup/system.js +209 -0
- package/dist/core/backup/totp.d.ts +16 -0
- package/dist/core/backup/totp.js +116 -0
- package/dist/core/backup/types.d.ts +70 -0
- package/dist/core/backup/types.js +7 -0
- package/dist/core/backup/unlock.d.ts +19 -0
- package/dist/core/backup/unlock.js +69 -0
- package/dist/core/boot-refresh.d.ts +1 -1
- package/dist/core/boot-refresh.js +10 -9
- package/dist/core/deps/actors/pr-creator.d.ts +5 -3
- package/dist/core/deps/actors/pr-creator.js +71 -18
- package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
- package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
- package/dist/core/deps/collectors/npm.js +3 -1
- package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
- package/dist/core/deps/collectors/vulnerability.js +31 -2
- package/dist/core/deps/config.js +6 -0
- package/dist/core/deps/scanner.js +1 -1
- package/dist/core/deps/types.d.ts +8 -0
- package/dist/core/env.d.ts +3 -0
- package/dist/core/env.js +11 -0
- package/dist/core/exec.d.ts +1 -0
- package/dist/core/exec.js +4 -0
- package/dist/core/file-lock.d.ts +18 -0
- package/dist/core/file-lock.js +44 -0
- package/dist/core/git-onboard.js +10 -13
- package/dist/core/github.d.ts +3 -1
- package/dist/core/github.js +10 -7
- package/dist/core/logs-policy.d.ts +5 -0
- package/dist/core/logs-policy.js +20 -1
- package/dist/core/operator.d.ts +21 -0
- package/dist/core/operator.js +54 -0
- package/dist/core/registry.d.ts +18 -0
- package/dist/core/registry.js +26 -0
- package/dist/core/routines/schema.d.ts +11 -11
- package/dist/core/routines/schema.js +14 -3
- package/dist/core/routines/store.d.ts +8 -8
- package/dist/core/secrets-ops.d.ts +31 -6
- package/dist/core/secrets-ops.js +208 -102
- package/dist/core/secrets-providers.js +2 -2
- package/dist/core/secrets-rotation.d.ts +1 -1
- package/dist/core/secrets-rotation.js +58 -52
- package/dist/core/secrets-v2-cleanup.d.ts +19 -0
- package/dist/core/secrets-v2-cleanup.js +94 -0
- package/dist/core/secrets-v2-creds.d.ts +9 -0
- package/dist/core/secrets-v2-creds.js +44 -0
- package/dist/core/secrets-v2-install.d.ts +13 -0
- package/dist/core/secrets-v2-install.js +76 -0
- package/dist/core/secrets-v2-keypair.d.ts +10 -0
- package/dist/core/secrets-v2-keypair.js +31 -0
- package/dist/core/secrets-v2-migrate.d.ts +29 -0
- package/dist/core/secrets-v2-migrate.js +395 -0
- package/dist/core/secrets-v2-ops.d.ts +36 -0
- package/dist/core/secrets-v2-ops.js +184 -0
- package/dist/core/secrets-v2-protocol.d.ts +19 -0
- package/dist/core/secrets-v2-protocol.js +60 -0
- package/dist/core/secrets-v2-snapshot.d.ts +36 -0
- package/dist/core/secrets-v2-snapshot.js +115 -0
- package/dist/core/secrets-v2.d.ts +21 -0
- package/dist/core/secrets-v2.js +249 -0
- package/dist/core/secrets.d.ts +39 -4
- package/dist/core/secrets.js +91 -11
- package/dist/core/self-update.d.ts +32 -11
- package/dist/core/self-update.js +52 -14
- package/dist/core/testflight/asc.d.ts +12 -0
- package/dist/core/testflight/asc.js +101 -0
- package/dist/core/testflight/credentials.d.ts +3 -0
- package/dist/core/testflight/credentials.js +35 -0
- package/dist/core/testflight/eas.d.ts +4 -0
- package/dist/core/testflight/eas.js +38 -0
- package/dist/core/testflight/resolve.d.ts +6 -0
- package/dist/core/testflight/resolve.js +44 -0
- package/dist/core/testflight/types.d.ts +13 -0
- package/dist/core/testflight/types.js +3 -0
- package/dist/core/testflight/workflow.d.ts +17 -0
- package/dist/core/testflight/workflow.js +65 -0
- package/dist/core/validate.d.ts +1 -0
- package/dist/core/validate.js +8 -0
- package/dist/mcp/audit-tools.d.ts +2 -0
- package/dist/mcp/audit-tools.js +94 -0
- package/dist/mcp/git-tools.js +1 -1
- package/dist/mcp/registry-bridge.d.ts +10 -0
- package/dist/mcp/registry-bridge.js +65 -0
- package/dist/mcp/secrets-tools.js +2 -2
- package/dist/mcp/server.js +16 -82
- package/dist/mcp/testflight-tools.d.ts +2 -0
- package/dist/mcp/testflight-tools.js +52 -0
- package/dist/registry/context.d.ts +7 -0
- package/dist/registry/context.js +37 -0
- package/dist/registry/index.d.ts +5 -0
- package/dist/registry/index.js +44 -0
- package/dist/registry/parse-args.d.ts +13 -0
- package/dist/registry/parse-args.js +74 -0
- package/dist/registry/registry.d.ts +24 -0
- package/dist/registry/registry.js +26 -0
- package/dist/registry/render.d.ts +3 -0
- package/dist/registry/render.js +29 -0
- package/dist/registry/types.d.ts +50 -0
- package/dist/registry/types.js +1 -0
- package/dist/templates/agent-unit.d.ts +5 -0
- package/dist/templates/agent-unit.js +40 -0
- package/dist/templates/app-unit-edit.d.ts +2 -0
- package/dist/templates/app-unit-edit.js +46 -0
- package/dist/templates/compose-edit.d.ts +2 -0
- package/dist/templates/compose-edit.js +156 -0
- package/dist/templates/nginx.js +11 -0
- package/dist/templates/systemd.js +6 -0
- package/dist/tui/components/ArgForm.d.ts +7 -0
- package/dist/tui/components/ArgForm.js +64 -0
- package/dist/tui/components/ArgForm.test.d.ts +1 -0
- package/dist/tui/components/ArgForm.test.js +19 -0
- package/dist/tui/components/KeyHint.js +5 -0
- package/dist/tui/hooks/use-secrets.d.ts +8 -8
- package/dist/tui/hooks/use-secrets.js +7 -7
- package/dist/tui/router.d.ts +1 -0
- package/dist/tui/router.js +26 -9
- package/dist/tui/router.test.d.ts +1 -0
- package/dist/tui/router.test.js +13 -0
- package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
- package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
- package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
- package/dist/tui/tests/redaction-rerender.test.js +53 -0
- package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
- package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
- package/dist/tui/types.d.ts +1 -1
- package/dist/tui/views/CommandPalette.d.ts +5 -0
- package/dist/tui/views/CommandPalette.js +90 -0
- package/dist/tui/views/CommandPalette.test.d.ts +1 -0
- package/dist/tui/views/CommandPalette.test.js +117 -0
- package/dist/tui/views/Dashboard.js +9 -6
- package/dist/tui/views/HealthView.js +9 -4
- package/dist/tui/views/SecretEdit.js +15 -16
- package/dist/tui/views/SecretEdit.test.d.ts +1 -0
- package/dist/tui/views/SecretEdit.test.js +82 -0
- package/dist/tui/views/SecretsView.js +26 -16
- package/package.json +8 -5
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
3
|
+
function base32Encode(buf) {
|
|
4
|
+
let bits = 0;
|
|
5
|
+
let value = 0;
|
|
6
|
+
let out = '';
|
|
7
|
+
for (const byte of buf) {
|
|
8
|
+
value = (value << 8) | byte;
|
|
9
|
+
bits += 8;
|
|
10
|
+
while (bits >= 5) {
|
|
11
|
+
out += B32[(value >>> (bits - 5)) & 31];
|
|
12
|
+
bits -= 5;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (bits > 0)
|
|
16
|
+
out += B32[(value << (5 - bits)) & 31];
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
function base32Decode(s) {
|
|
20
|
+
const clean = s.replace(/=+$/, '').toUpperCase().replace(/\s/g, '');
|
|
21
|
+
let bits = 0;
|
|
22
|
+
let value = 0;
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const c of clean) {
|
|
25
|
+
const idx = B32.indexOf(c);
|
|
26
|
+
if (idx === -1)
|
|
27
|
+
throw new Error(`invalid base32 char: ${c}`);
|
|
28
|
+
value = (value << 5) | idx;
|
|
29
|
+
bits += 5;
|
|
30
|
+
if (bits >= 8) {
|
|
31
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
32
|
+
bits -= 8;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Buffer.from(out);
|
|
36
|
+
}
|
|
37
|
+
function hotp(secret, counter) {
|
|
38
|
+
const buf = Buffer.alloc(8);
|
|
39
|
+
buf.writeBigUInt64BE(BigInt(counter));
|
|
40
|
+
const hmac = createHmac('sha1', secret).update(buf).digest();
|
|
41
|
+
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
42
|
+
const bin = ((hmac[offset] & 0x7f) << 24) |
|
|
43
|
+
((hmac[offset + 1] & 0xff) << 16) |
|
|
44
|
+
((hmac[offset + 2] & 0xff) << 8) |
|
|
45
|
+
(hmac[offset + 3] & 0xff);
|
|
46
|
+
return (bin % 1_000_000).toString().padStart(6, '0');
|
|
47
|
+
}
|
|
48
|
+
function timingSafeEqualStr(a, b) {
|
|
49
|
+
const ba = Buffer.from(a);
|
|
50
|
+
const bb = Buffer.from(b);
|
|
51
|
+
if (ba.length !== bb.length)
|
|
52
|
+
return false;
|
|
53
|
+
return timingSafeEqual(ba, bb);
|
|
54
|
+
}
|
|
55
|
+
/** new random 20-byte secret, base32-encoded for authenticator apps. */
|
|
56
|
+
export function generateSecret() {
|
|
57
|
+
return base32Encode(randomBytes(20));
|
|
58
|
+
}
|
|
59
|
+
/** the 6-digit TOTP code for a base32 secret at a given epoch-ms time. */
|
|
60
|
+
export function totpCode(secretB32, atMs = Date.now()) {
|
|
61
|
+
const counter = Math.floor(atMs / 1000 / 30);
|
|
62
|
+
return hotp(base32Decode(secretB32), counter);
|
|
63
|
+
}
|
|
64
|
+
/** true if `code` is valid for the secret within a +/-1 step window. */
|
|
65
|
+
export function verifyTotp(secretB32, code, atMs = Date.now()) {
|
|
66
|
+
if (!/^\d{6}$/.test(code))
|
|
67
|
+
return false;
|
|
68
|
+
const secret = base32Decode(secretB32);
|
|
69
|
+
const counter = Math.floor(atMs / 1000 / 30);
|
|
70
|
+
for (let w = -1; w <= 1; w++) {
|
|
71
|
+
if (timingSafeEqualStr(hotp(secret, counter + w), code))
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/** otpauth:// enrolment URI — paste into 1Password or an authenticator app. */
|
|
77
|
+
export function totpUri(secretB32, label, issuer) {
|
|
78
|
+
const l = encodeURIComponent(label);
|
|
79
|
+
const i = encodeURIComponent(issuer);
|
|
80
|
+
return `otpauth://totp/${i}:${l}?secret=${secretB32}&issuer=${i}&period=30&digits=6&algorithm=SHA1`;
|
|
81
|
+
}
|
|
82
|
+
function b64url(buf) {
|
|
83
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
84
|
+
}
|
|
85
|
+
function b64urlDecode(s) {
|
|
86
|
+
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
87
|
+
}
|
|
88
|
+
/** signs a session payload as `<body>.<hmac>` (hmac-sha256). */
|
|
89
|
+
export function signSession(payload, secret) {
|
|
90
|
+
const body = b64url(Buffer.from(JSON.stringify(payload)));
|
|
91
|
+
const sig = b64url(createHmac('sha256', secret).update(body).digest());
|
|
92
|
+
return `${body}.${sig}`;
|
|
93
|
+
}
|
|
94
|
+
/** verifies a session cookie; returns the payload or null if invalid/expired. */
|
|
95
|
+
export function verifySession(cookie, secret, nowMs = Date.now()) {
|
|
96
|
+
const parts = cookie.split('.');
|
|
97
|
+
if (parts.length !== 2)
|
|
98
|
+
return null;
|
|
99
|
+
const [body, sig] = parts;
|
|
100
|
+
const expected = b64url(createHmac('sha256', secret).update(body).digest());
|
|
101
|
+
if (!timingSafeEqualStr(sig, expected))
|
|
102
|
+
return null;
|
|
103
|
+
let payload;
|
|
104
|
+
try {
|
|
105
|
+
payload = JSON.parse(b64urlDecode(body).toString('utf-8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (payload === null || typeof payload !== 'object')
|
|
111
|
+
return null;
|
|
112
|
+
const exp = payload.exp;
|
|
113
|
+
if (typeof exp !== 'number' || exp < nowMs)
|
|
114
|
+
return null;
|
|
115
|
+
return { exp };
|
|
116
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type Retention = {
|
|
2
|
+
hourly?: number;
|
|
3
|
+
daily?: number;
|
|
4
|
+
weekly?: number;
|
|
5
|
+
monthly?: number;
|
|
6
|
+
yearly?: number;
|
|
7
|
+
};
|
|
8
|
+
export type Schedule = 'hourly' | '*-*-* 00/3:00:00' | '*-*-* 00/6:00:00' | '*-*-* 00/12:00:00' | 'daily' | 'weekly';
|
|
9
|
+
export type DumpType = 'postgres' | 'mysql' | 'mongo' | 'redis';
|
|
10
|
+
export interface DumpHook {
|
|
11
|
+
type: DumpType;
|
|
12
|
+
container: string;
|
|
13
|
+
/** for postgres/mysql: database name. for mongo: optional db filter. for redis: ignored. */
|
|
14
|
+
db?: string;
|
|
15
|
+
/** literal user value passed to the dump tool. takes precedence over userEnv.
|
|
16
|
+
* defaults sensibly per type (postgres: $POSTGRES_USER, mysql: root, mongo: root). */
|
|
17
|
+
user?: string;
|
|
18
|
+
/** port the db listens on inside the container. only used for redis (which
|
|
19
|
+
* has a default of 6379 but glitchtip et al re-bind to non-standard ports). */
|
|
20
|
+
port?: number;
|
|
21
|
+
/** path inside the container that holds the password (docker secrets pattern).
|
|
22
|
+
* preferred over passwordEnv because shared-* containers use _FILE secrets. */
|
|
23
|
+
passwordFile?: string;
|
|
24
|
+
/** shell command executed on the HOST (outside the container) whose stdout
|
|
25
|
+
* becomes the password. used when the secret lives in a host .env file that
|
|
26
|
+
* was consumed by docker-compose at startup but is no longer present in
|
|
27
|
+
* the container. fleet runs as root so it can read those files. */
|
|
28
|
+
passwordHostCommand?: string;
|
|
29
|
+
/** env var name in the container that holds the user. legacy. */
|
|
30
|
+
userEnv?: string;
|
|
31
|
+
/** env var name in the container that holds the password. legacy. */
|
|
32
|
+
passwordEnv?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface AppBackupConfig {
|
|
35
|
+
/** the app name as known to fleet (or `system`, `root-home`, `user-home` for pseudo-apps). */
|
|
36
|
+
app: string;
|
|
37
|
+
/** systemd OnCalendar expression. */
|
|
38
|
+
schedule: Schedule;
|
|
39
|
+
/** filesystem paths to include. */
|
|
40
|
+
paths: string[];
|
|
41
|
+
/** glob patterns to exclude. */
|
|
42
|
+
exclude: string[];
|
|
43
|
+
/** named docker volumes to dump alongside fs paths. */
|
|
44
|
+
volumes?: string[];
|
|
45
|
+
/** db dump to run before snapshot. */
|
|
46
|
+
preDump?: DumpHook;
|
|
47
|
+
/** post-snapshot cmd to run inside the host (rare). */
|
|
48
|
+
postHook?: string;
|
|
49
|
+
/** retention policy applied after every snapshot via restic forget --prune. */
|
|
50
|
+
retention: Retention;
|
|
51
|
+
/** disable: skip this app entirely. */
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface SnapshotInfo {
|
|
55
|
+
id: string;
|
|
56
|
+
shortId: string;
|
|
57
|
+
time: string;
|
|
58
|
+
hostname: string;
|
|
59
|
+
paths: string[];
|
|
60
|
+
tags: string[];
|
|
61
|
+
sizeBytes?: number;
|
|
62
|
+
}
|
|
63
|
+
export interface RepoStats {
|
|
64
|
+
totalSize: number;
|
|
65
|
+
totalFileCount: number;
|
|
66
|
+
snapshotCount: number;
|
|
67
|
+
}
|
|
68
|
+
export declare const PSEUDO_APPS: readonly ["system", "root-home", "user-home", "shared-postgres", "shared-mysql", "shared-mongodb"];
|
|
69
|
+
export type PseudoApp = typeof PSEUDO_APPS[number];
|
|
70
|
+
export declare function isPseudoApp(name: string): name is PseudoApp;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FleetError } from '../errors.js';
|
|
2
|
+
/** absolute path to the age recipient public key. */
|
|
3
|
+
export declare function agePubPath(): string;
|
|
4
|
+
/** systemd credstore entry holding the age identity. */
|
|
5
|
+
export declare function ageKeyCred(): string;
|
|
6
|
+
/** absolute path to the age unlock helper script. */
|
|
7
|
+
export declare function unlockScript(): string;
|
|
8
|
+
export declare class UnlockError extends FleetError {
|
|
9
|
+
}
|
|
10
|
+
/** path to the per-app age-encrypted restic password file. */
|
|
11
|
+
export declare function vaultPath(app: string): string;
|
|
12
|
+
/** path the running fleet binary points restic at as RESTIC_PASSWORD_COMMAND. */
|
|
13
|
+
export declare function passwordCommandFor(app: string): string;
|
|
14
|
+
/** returns the age public key (the recipient we encrypt restic passwords to). */
|
|
15
|
+
export declare function readPubKey(): string;
|
|
16
|
+
/** generate a random base64 password and age-encrypt it to the vault file. */
|
|
17
|
+
export declare function generateAndStorePassword(app: string): void;
|
|
18
|
+
/** read+decrypt the restic password for an app (held in memory only). */
|
|
19
|
+
export declare function fetchPassword(app: string): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { requireEnv } from '../env.js';
|
|
4
|
+
import { FleetError } from '../errors.js';
|
|
5
|
+
import { execSafe } from '../exec.js';
|
|
6
|
+
import { backupVaultDir } from './config.js';
|
|
7
|
+
/** absolute path to the age recipient public key. */
|
|
8
|
+
export function agePubPath() { return requireEnv('FLEET_BACKUP_AGE_PUB'); }
|
|
9
|
+
/** systemd credstore entry holding the age identity. */
|
|
10
|
+
export function ageKeyCred() { return requireEnv('FLEET_BACKUP_AGE_KEY_CRED'); }
|
|
11
|
+
/** absolute path to the age unlock helper script. */
|
|
12
|
+
export function unlockScript() { return requireEnv('FLEET_BACKUP_UNLOCK_SCRIPT'); }
|
|
13
|
+
export class UnlockError extends FleetError {
|
|
14
|
+
}
|
|
15
|
+
/** path to the per-app age-encrypted restic password file. */
|
|
16
|
+
export function vaultPath(app) {
|
|
17
|
+
return join(backupVaultDir(), `${app}.age`);
|
|
18
|
+
}
|
|
19
|
+
/** path the running fleet binary points restic at as RESTIC_PASSWORD_COMMAND. */
|
|
20
|
+
export function passwordCommandFor(app) {
|
|
21
|
+
return `/usr/local/sbin/fleet-restic-app-key.sh ${app}`;
|
|
22
|
+
}
|
|
23
|
+
/** returns the age public key (the recipient we encrypt restic passwords to). */
|
|
24
|
+
export function readPubKey() {
|
|
25
|
+
const pubPath = agePubPath();
|
|
26
|
+
if (!existsSync(pubPath)) {
|
|
27
|
+
throw new UnlockError(`age public key not found at ${pubPath}. run fleet backup init.`);
|
|
28
|
+
}
|
|
29
|
+
const r = execSafe('cat', [pubPath], { timeout: 2_000 });
|
|
30
|
+
if (!r.ok)
|
|
31
|
+
throw new UnlockError(`failed reading ${pubPath}: ${r.stderr}`);
|
|
32
|
+
const pub = r.stdout.trim();
|
|
33
|
+
if (!pub.startsWith('age1'))
|
|
34
|
+
throw new UnlockError(`malformed age pubkey at ${pubPath}`);
|
|
35
|
+
return pub;
|
|
36
|
+
}
|
|
37
|
+
/** generate a random base64 password and age-encrypt it to the vault file. */
|
|
38
|
+
export function generateAndStorePassword(app) {
|
|
39
|
+
const dir = backupVaultDir();
|
|
40
|
+
if (!existsSync(dir)) {
|
|
41
|
+
execSafe('mkdir', ['-p', '--mode=700', dir], { timeout: 2_000 });
|
|
42
|
+
}
|
|
43
|
+
const pub = readPubKey();
|
|
44
|
+
// pipe: openssl rand -base64 48 | tr -d \n | age -e -r <pub> > vault
|
|
45
|
+
const out = vaultPath(app);
|
|
46
|
+
const r = execSafe('sh', ['-c', `openssl rand -base64 48 | tr -d '\\n' | age -e -r ${pub} > ${shellEscape(out)} && chmod 600 ${shellEscape(out)}`], { timeout: 10_000 });
|
|
47
|
+
if (!r.ok)
|
|
48
|
+
throw new UnlockError(`vault write failed: ${r.stderr}`);
|
|
49
|
+
}
|
|
50
|
+
/** read+decrypt the restic password for an app (held in memory only). */
|
|
51
|
+
export function fetchPassword(app) {
|
|
52
|
+
if (!existsSync(vaultPath(app))) {
|
|
53
|
+
throw new UnlockError(`no vault entry for app ${app}. run: fleet backup init ${app}`);
|
|
54
|
+
}
|
|
55
|
+
const keyCred = ageKeyCred();
|
|
56
|
+
if (!existsSync(keyCred)) {
|
|
57
|
+
throw new UnlockError(`age key credential not found at ${keyCred}. setup incomplete.`);
|
|
58
|
+
}
|
|
59
|
+
const r = execSafe('sh', ['-c', `${shellEscape(unlockScript())} | age -d -i /dev/stdin ${shellEscape(vaultPath(app))}`], { timeout: 5_000 });
|
|
60
|
+
if (!r.ok)
|
|
61
|
+
throw new UnlockError(`password decrypt failed: ${r.stderr}`);
|
|
62
|
+
const pass = r.stdout;
|
|
63
|
+
if (!pass)
|
|
64
|
+
throw new UnlockError(`decrypted password empty`);
|
|
65
|
+
return pass;
|
|
66
|
+
}
|
|
67
|
+
function shellEscape(s) {
|
|
68
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
69
|
+
}
|