@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.
- 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 +10 -7
- package/dist/tui/views/HealthView.js +14 -5
- 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 +9 -6
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { verifyTotp, signSession, verifySession } from './totp.js';
|
|
2
|
+
import { renderLoginPage, renderExplorerPage } from './browser-ui.js';
|
|
3
|
+
import { renderStatusHtml } from './statuspage.js';
|
|
4
|
+
import { classify } from './sensitive.js';
|
|
5
|
+
const SESSION_COOKIE = 'fleet_backup_session';
|
|
6
|
+
function json(status, body, setCookie) {
|
|
7
|
+
return { kind: 'json', status, body, setCookie };
|
|
8
|
+
}
|
|
9
|
+
function hasSession(req, ctx) {
|
|
10
|
+
const cookie = req.cookies[SESSION_COOKIE];
|
|
11
|
+
if (!cookie)
|
|
12
|
+
return false;
|
|
13
|
+
return verifySession(cookie, ctx.sessionSecret, ctx.now()) !== null;
|
|
14
|
+
}
|
|
15
|
+
/** /api/* must carry the csrf header, and write methods must carry an
|
|
16
|
+
* Origin header whose host matches our domain exactly.
|
|
17
|
+
*
|
|
18
|
+
* the custom `x-fleet-backup: 1` header is the primary barrier — modern
|
|
19
|
+
* browsers can't set it cross-origin without preflight, which a same-
|
|
20
|
+
* origin policy denies for any host that isn't our own. the Origin check
|
|
21
|
+
* is belt-and-braces, and matters specifically for POST / DELETE so the
|
|
22
|
+
* endsWith bug (where `evil-${domain}` would have been accepted) is
|
|
23
|
+
* closed and a missing Origin on a mutating request is rejected. read
|
|
24
|
+
* methods accept a missing Origin so health checks / curl probes keep
|
|
25
|
+
* working without the operator having to set an Origin manually. */
|
|
26
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
27
|
+
function csrfOk(req, domain) {
|
|
28
|
+
if (req.headers['x-fleet-backup'] !== '1')
|
|
29
|
+
return false;
|
|
30
|
+
const origin = req.headers['origin'];
|
|
31
|
+
const isWrite = !READ_METHODS.has(req.method.toUpperCase());
|
|
32
|
+
if (!origin)
|
|
33
|
+
return !isWrite;
|
|
34
|
+
let host;
|
|
35
|
+
try {
|
|
36
|
+
host = new URL(origin).host;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return host === domain;
|
|
42
|
+
}
|
|
43
|
+
export function handle(req, ctx) {
|
|
44
|
+
// public: the login page
|
|
45
|
+
if (req.path === '/login' && req.method === 'GET') {
|
|
46
|
+
return { kind: 'html', status: 200, body: renderLoginPage() };
|
|
47
|
+
}
|
|
48
|
+
// /api/* — the CSRF + same-origin check runs before auth so a cross-site
|
|
49
|
+
// probe is rejected (403) without ever reaching the session layer.
|
|
50
|
+
if (req.path.startsWith('/api/')) {
|
|
51
|
+
if (!csrfOk(req, ctx.domain))
|
|
52
|
+
return json(403, { error: 'csrf check failed' });
|
|
53
|
+
if (req.path === '/api/login' && req.method === 'POST') {
|
|
54
|
+
const code = req.body?.code ?? '';
|
|
55
|
+
if (!verifyTotp(ctx.totpSecret, code, ctx.now())) {
|
|
56
|
+
return json(401, { error: 'invalid code' });
|
|
57
|
+
}
|
|
58
|
+
const cookie = signSession({ exp: ctx.now() + ctx.sessionTtlMs }, ctx.sessionSecret);
|
|
59
|
+
const attrs = `Path=/backups; HttpOnly; Secure; SameSite=Strict; Max-Age=${Math.floor(ctx.sessionTtlMs / 1000)}`;
|
|
60
|
+
return json(200, { ok: true }, `${SESSION_COOKIE}=${cookie}; ${attrs}`);
|
|
61
|
+
}
|
|
62
|
+
if (!hasSession(req, ctx))
|
|
63
|
+
return json(401, { error: 'not authenticated' });
|
|
64
|
+
return handleApi(req, ctx);
|
|
65
|
+
}
|
|
66
|
+
// every non-api route requires a session
|
|
67
|
+
if (!hasSession(req, ctx)) {
|
|
68
|
+
return { kind: 'redirect', status: 302, location: '/backups/login' };
|
|
69
|
+
}
|
|
70
|
+
if (req.path === '/' && req.method === 'GET') {
|
|
71
|
+
return { kind: 'html', status: 200, body: renderStatusHtml(ctx.statusReport()) };
|
|
72
|
+
}
|
|
73
|
+
if (req.path === '/explore' && req.method === 'GET') {
|
|
74
|
+
return { kind: 'html', status: 200, body: renderExplorerPage() };
|
|
75
|
+
}
|
|
76
|
+
return json(404, { error: 'not found' });
|
|
77
|
+
}
|
|
78
|
+
const SNAP_RE = /^[0-9a-f]{8,64}$/;
|
|
79
|
+
const INLINE_TYPES = ['text/', 'image/', 'application/pdf', 'application/json'];
|
|
80
|
+
function validPath(p) {
|
|
81
|
+
if (!p || !p.startsWith('/'))
|
|
82
|
+
return false;
|
|
83
|
+
// reject any traversal segment in the raw path — checking a normalised
|
|
84
|
+
// path is useless here because normalisation collapses `..` away first.
|
|
85
|
+
return !p.split('/').includes('..');
|
|
86
|
+
}
|
|
87
|
+
/** maps a restic error to 503 when the backend is unreachable, else 500. */
|
|
88
|
+
function resticErrorStatus(message) {
|
|
89
|
+
return /unreach|connection refused|dial |timeout|no route to host/i.test(message)
|
|
90
|
+
? 503
|
|
91
|
+
: 500;
|
|
92
|
+
}
|
|
93
|
+
function contentTypeFor(path) {
|
|
94
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
|
95
|
+
const map = {
|
|
96
|
+
txt: 'text/plain', md: 'text/plain', log: 'text/plain', json: 'application/json',
|
|
97
|
+
js: 'text/plain', ts: 'text/plain', css: 'text/plain', html: 'text/plain',
|
|
98
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
99
|
+
svg: 'image/svg+xml', pdf: 'application/pdf',
|
|
100
|
+
};
|
|
101
|
+
return map[ext] ?? 'application/octet-stream';
|
|
102
|
+
}
|
|
103
|
+
function handleApi(req, ctx) {
|
|
104
|
+
const { path: route, query } = req;
|
|
105
|
+
if (route === '/api/apps' && req.method === 'GET') {
|
|
106
|
+
return json(200, ctx.statusReport());
|
|
107
|
+
}
|
|
108
|
+
if (route === '/api/snapshots' && req.method === 'GET') {
|
|
109
|
+
const app = query.app ?? '';
|
|
110
|
+
if (!ctx.listApps().includes(app))
|
|
111
|
+
return json(404, { error: 'unknown app' });
|
|
112
|
+
return json(200, { snapshots: ctx.snapshots(app) });
|
|
113
|
+
}
|
|
114
|
+
if (route === '/api/ls' && req.method === 'GET') {
|
|
115
|
+
const { app = '', snap = '', path = '/' } = query;
|
|
116
|
+
if (!ctx.listApps().includes(app))
|
|
117
|
+
return json(404, { error: 'unknown app' });
|
|
118
|
+
if (!SNAP_RE.test(snap))
|
|
119
|
+
return json(400, { error: 'bad snapshot id' });
|
|
120
|
+
if (!validPath(path))
|
|
121
|
+
return json(400, { error: 'bad path' });
|
|
122
|
+
try {
|
|
123
|
+
// sensitivity is derived from the path itself — no per-entry restic call.
|
|
124
|
+
const entries = ctx.lsTree(app, snap, path).map(e => ({
|
|
125
|
+
...e,
|
|
126
|
+
sensitive: classify(e.path) === 'sensitive',
|
|
127
|
+
}));
|
|
128
|
+
return json(200, { path, entries });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
const msg = e.message;
|
|
132
|
+
return json(resticErrorStatus(msg), { error: msg });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (route === '/api/staging' && req.method === 'GET') {
|
|
136
|
+
return json(200, { staging: ctx.listStaging() });
|
|
137
|
+
}
|
|
138
|
+
if (route === '/api/restore' && req.method === 'POST') {
|
|
139
|
+
const b = (req.body ?? {});
|
|
140
|
+
const app = b.app ?? '';
|
|
141
|
+
const snap = b.snap ?? '';
|
|
142
|
+
const path = b.path ?? '';
|
|
143
|
+
if (!ctx.listApps().includes(app))
|
|
144
|
+
return json(404, { error: 'unknown app' });
|
|
145
|
+
if (!SNAP_RE.test(snap))
|
|
146
|
+
return json(400, { error: 'bad snapshot id' });
|
|
147
|
+
if (!validPath(path))
|
|
148
|
+
return json(400, { error: 'bad path' });
|
|
149
|
+
try {
|
|
150
|
+
return json(200, ctx.restore(app, snap, path));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
const msg = e.message;
|
|
154
|
+
// doRestore throws a code-507 error when staging space is short
|
|
155
|
+
const status = e.code === 507 ? 507 : 500;
|
|
156
|
+
return json(status, { error: msg });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (route === '/api/staging' && req.method === 'DELETE') {
|
|
160
|
+
const p = query.path ?? '';
|
|
161
|
+
if (!p.startsWith('/var/restore/') || p.includes('..')) {
|
|
162
|
+
return json(400, { error: 'bad staging path' });
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
ctx.deleteStaging(p);
|
|
166
|
+
return json(200, { ok: true });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return json(500, { error: e.message });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (route === '/api/file' && req.method === 'GET') {
|
|
173
|
+
const { app = '', snap = '', path = '', dl } = query;
|
|
174
|
+
if (!ctx.listApps().includes(app))
|
|
175
|
+
return json(404, { error: 'unknown app' });
|
|
176
|
+
if (!SNAP_RE.test(snap))
|
|
177
|
+
return json(400, { error: 'bad snapshot id' });
|
|
178
|
+
if (!validPath(path))
|
|
179
|
+
return json(400, { error: 'bad path' });
|
|
180
|
+
const meta = ctx.fileMeta(app, snap, path);
|
|
181
|
+
if (!meta)
|
|
182
|
+
return json(404, { error: 'file not found' });
|
|
183
|
+
if (meta.sensitive)
|
|
184
|
+
return json(403, { error: 'sensitive path — view/download blocked' });
|
|
185
|
+
const filename = path.slice(path.lastIndexOf('/') + 1);
|
|
186
|
+
const ct = contentTypeFor(path);
|
|
187
|
+
const inlineable = INLINE_TYPES.some(t => ct.startsWith(t)) && meta.size <= 5 * 1024 * 1024;
|
|
188
|
+
return {
|
|
189
|
+
kind: 'stream',
|
|
190
|
+
status: 200,
|
|
191
|
+
app, snap, path, filename,
|
|
192
|
+
contentType: ct,
|
|
193
|
+
disposition: dl === '1' || !inlineable ? 'attachment' : 'inline',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return json(404, { error: 'not found' });
|
|
197
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Server } from 'node:http';
|
|
2
|
+
export interface ServeOptions {
|
|
3
|
+
port: number;
|
|
4
|
+
totpSecret: string;
|
|
5
|
+
sessionSecret: string;
|
|
6
|
+
sessionTtlMs?: number;
|
|
7
|
+
stagingRoot?: string;
|
|
8
|
+
}
|
|
9
|
+
/** starts the explorer http service, resolving once it is bound. the socket
|
|
10
|
+
* is localhost-only (nginx fronts it). */
|
|
11
|
+
export declare function startServer(opts: ServeOptions): Promise<Server>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, statfsSync, readdirSync, statSync, rmSync, appendFileSync, } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { loadOperator } from '../operator.js';
|
|
4
|
+
import { handle } from './browser-api.js';
|
|
5
|
+
import { listConfiguredApps } from './config.js';
|
|
6
|
+
import { listSnapshots, lsTree, dumpFileSpawn, restore } from './repo.js';
|
|
7
|
+
import { classify } from './sensitive.js';
|
|
8
|
+
import { buildStatusReport } from './status.js';
|
|
9
|
+
const MAX_INFLIGHT = 4;
|
|
10
|
+
let inFlight = 0;
|
|
11
|
+
const AUDIT_LOG = '/var/log/fleet-backup/audit.log';
|
|
12
|
+
/** appends a structured audit line to journald (via stdout) and a log file. */
|
|
13
|
+
function audit(action, detail) {
|
|
14
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), action, ...detail });
|
|
15
|
+
// stdout is captured by journald when run as a systemd unit
|
|
16
|
+
process.stdout.write(`[audit] ${line}\n`);
|
|
17
|
+
try {
|
|
18
|
+
appendFileSync(AUDIT_LOG, line + '\n');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* best effort — never fail a request because the audit file is unwritable */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** recursive byte size of a directory tree. */
|
|
25
|
+
function dirSize(dir) {
|
|
26
|
+
let total = 0;
|
|
27
|
+
for (const name of readdirSync(dir)) {
|
|
28
|
+
const full = `${dir}/${name}`;
|
|
29
|
+
const st = statSync(full);
|
|
30
|
+
total += st.isDirectory() ? dirSize(full) : st.size;
|
|
31
|
+
}
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
function humanAge(mtimeMs) {
|
|
35
|
+
const h = Math.floor((Date.now() - mtimeMs) / 3600_000);
|
|
36
|
+
return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
37
|
+
}
|
|
38
|
+
const STAGING_ROOT_DEFAULT = '/var/restore';
|
|
39
|
+
function parseCookies(header) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const part of (header ?? '').split(';')) {
|
|
42
|
+
const i = part.indexOf('=');
|
|
43
|
+
if (i > 0)
|
|
44
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
49
|
+
function readBody(req) {
|
|
50
|
+
return new Promise(resolve => {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
let aborted = false;
|
|
54
|
+
req.on('data', c => {
|
|
55
|
+
if (aborted)
|
|
56
|
+
return;
|
|
57
|
+
size += c.length;
|
|
58
|
+
if (size > MAX_BODY_BYTES) {
|
|
59
|
+
// cut the slow-loris off at the wire — keeping `req.on('data')`
|
|
60
|
+
// running for gigabytes just to drop chunks past the cap pins
|
|
61
|
+
// the handler open and gives the attacker a free socket.
|
|
62
|
+
aborted = true;
|
|
63
|
+
req.destroy();
|
|
64
|
+
resolve(undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(c);
|
|
68
|
+
});
|
|
69
|
+
req.on('end', () => {
|
|
70
|
+
if (aborted)
|
|
71
|
+
return;
|
|
72
|
+
if (chunks.length === 0) {
|
|
73
|
+
resolve(undefined);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
resolve(undefined);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
req.on('error', () => { if (!aborted)
|
|
84
|
+
resolve(undefined); });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** restores a single path into a fresh timestamped staging dir. */
|
|
88
|
+
function doRestore(app, snap, path, stagingRoot) {
|
|
89
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
90
|
+
const target = `${stagingRoot}/${app}-${snap.slice(0, 8)}-${ts}`;
|
|
91
|
+
// pre-flight: refuse if the filesystem is nearly full
|
|
92
|
+
const fs = statfsSync(stagingRoot);
|
|
93
|
+
if (fs.bavail * fs.bsize < 64 * 1024 * 1024) {
|
|
94
|
+
const e = new Error('insufficient staging space');
|
|
95
|
+
e.code = 507;
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
restore(app, { snapshotId: snap, target, include: [path] });
|
|
101
|
+
return { target, fileCount: 1, bytes: 0, durationMs: Date.now() - started };
|
|
102
|
+
}
|
|
103
|
+
function buildContext(opts) {
|
|
104
|
+
const stagingRoot = opts.stagingRoot ?? STAGING_ROOT_DEFAULT;
|
|
105
|
+
return {
|
|
106
|
+
now: () => Date.now(),
|
|
107
|
+
totpSecret: opts.totpSecret,
|
|
108
|
+
sessionSecret: opts.sessionSecret,
|
|
109
|
+
sessionTtlMs: opts.sessionTtlMs ?? 12 * 3600_000,
|
|
110
|
+
domain: loadOperator().domain,
|
|
111
|
+
listApps: () => listConfiguredApps(),
|
|
112
|
+
statusReport: () => buildStatusReport(),
|
|
113
|
+
snapshots: app => listSnapshots(app),
|
|
114
|
+
lsTree: (app, snap, path) => lsTree(app, snap, path),
|
|
115
|
+
fileMeta: (app, snap, path) => {
|
|
116
|
+
// size comes from the parent dir listing; sensitivity from the classifier.
|
|
117
|
+
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
|
|
118
|
+
const entry = lsTree(app, snap, parent).find(e => e.path === path);
|
|
119
|
+
if (!entry)
|
|
120
|
+
return null;
|
|
121
|
+
return { size: entry.size, sensitive: classify(path) === 'sensitive' };
|
|
122
|
+
},
|
|
123
|
+
restore: (app, snap, path) => doRestore(app, snap, path, stagingRoot),
|
|
124
|
+
listStaging: () => {
|
|
125
|
+
if (!existsSync(stagingRoot))
|
|
126
|
+
return [];
|
|
127
|
+
return readdirSync(stagingRoot)
|
|
128
|
+
.map(name => `${stagingRoot}/${name}`)
|
|
129
|
+
.filter(p => statSync(p).isDirectory())
|
|
130
|
+
.map(p => ({ path: p, bytes: dirSize(p), age: humanAge(statSync(p).mtimeMs) }));
|
|
131
|
+
},
|
|
132
|
+
deleteStaging: (path) => {
|
|
133
|
+
if (!path.startsWith(stagingRoot + '/')) {
|
|
134
|
+
throw new Error('refusing to delete outside the staging root');
|
|
135
|
+
}
|
|
136
|
+
rmSync(path, { recursive: true, force: true });
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function sendResponse(httpRes, apiRes) {
|
|
141
|
+
if (apiRes.kind === 'json') {
|
|
142
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
143
|
+
if (apiRes.setCookie)
|
|
144
|
+
headers['Set-Cookie'] = apiRes.setCookie;
|
|
145
|
+
httpRes.writeHead(apiRes.status, headers);
|
|
146
|
+
httpRes.end(JSON.stringify(apiRes.body));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (apiRes.kind === 'html') {
|
|
150
|
+
httpRes.writeHead(apiRes.status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
151
|
+
httpRes.end(apiRes.body);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (apiRes.kind === 'redirect') {
|
|
155
|
+
httpRes.writeHead(apiRes.status, { Location: apiRes.location });
|
|
156
|
+
httpRes.end();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// stream: pipe `restic dump` straight to the response
|
|
160
|
+
const child = dumpFileSpawn(apiRes.app, apiRes.snap, apiRes.path);
|
|
161
|
+
// strip CR / LF / NUL / other control chars in addition to quotes —
|
|
162
|
+
// filenames live inside restic-tracked content, so the operator can
|
|
163
|
+
// restore a path whose basename contains \r\n and inject arbitrary
|
|
164
|
+
// response headers if we only quote-strip.
|
|
165
|
+
// eslint-disable-next-line no-control-regex
|
|
166
|
+
const safeFilename = apiRes.filename.replace(/[\r\n\x00-\x1F"\\]/g, '');
|
|
167
|
+
httpRes.writeHead(apiRes.status, {
|
|
168
|
+
'Content-Type': apiRes.contentType,
|
|
169
|
+
'Content-Disposition': `${apiRes.disposition}; filename="${safeFilename}"`,
|
|
170
|
+
});
|
|
171
|
+
child.stdout.pipe(httpRes);
|
|
172
|
+
child.stderr.on('data', () => { });
|
|
173
|
+
child.on('error', () => { if (!httpRes.headersSent)
|
|
174
|
+
httpRes.writeHead(500); httpRes.end(); });
|
|
175
|
+
child.on('close', code => { if (code !== 0)
|
|
176
|
+
httpRes.end(); });
|
|
177
|
+
}
|
|
178
|
+
/** starts the explorer http service, resolving once it is bound. the socket
|
|
179
|
+
* is localhost-only (nginx fronts it). */
|
|
180
|
+
export function startServer(opts) {
|
|
181
|
+
const ctx = buildContext(opts);
|
|
182
|
+
const stagingRoot = opts.stagingRoot ?? STAGING_ROOT_DEFAULT;
|
|
183
|
+
if (!existsSync(stagingRoot))
|
|
184
|
+
mkdirSync(stagingRoot, { recursive: true, mode: 0o700 });
|
|
185
|
+
const server = createServer(async (httpReq, httpRes) => {
|
|
186
|
+
try {
|
|
187
|
+
const url = new URL(httpReq.url ?? '/', 'http://localhost');
|
|
188
|
+
// soft concurrency cap on the heavy routes (streaming a file, running a
|
|
189
|
+
// restore) — refuse rather than fork unbounded restic processes.
|
|
190
|
+
const heavy = url.pathname === '/api/file' || url.pathname === '/api/restore';
|
|
191
|
+
if (heavy) {
|
|
192
|
+
if (inFlight >= MAX_INFLIGHT) {
|
|
193
|
+
httpRes.writeHead(429, { 'Content-Type': 'application/json' });
|
|
194
|
+
httpRes.end(JSON.stringify({ error: 'too many concurrent operations' }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
inFlight++;
|
|
198
|
+
httpRes.on('close', () => { inFlight--; });
|
|
199
|
+
}
|
|
200
|
+
const query = {};
|
|
201
|
+
url.searchParams.forEach((v, k) => { query[k] = v; });
|
|
202
|
+
const headers = {};
|
|
203
|
+
for (const [k, v] of Object.entries(httpReq.headers)) {
|
|
204
|
+
if (typeof v === 'string')
|
|
205
|
+
headers[k.toLowerCase()] = v;
|
|
206
|
+
}
|
|
207
|
+
const apiReq = {
|
|
208
|
+
method: httpReq.method ?? 'GET',
|
|
209
|
+
path: url.pathname,
|
|
210
|
+
query,
|
|
211
|
+
headers,
|
|
212
|
+
cookies: parseCookies(httpReq.headers['cookie']),
|
|
213
|
+
body: httpReq.method === 'POST' ? await readBody(httpReq) : undefined,
|
|
214
|
+
};
|
|
215
|
+
const apiRes = handle(apiReq, ctx);
|
|
216
|
+
// audit the security-relevant actions (view/download, restore, delete).
|
|
217
|
+
if (apiRes.status < 400) {
|
|
218
|
+
if (url.pathname === '/api/file') {
|
|
219
|
+
audit('view', { app: query.app, snap: query.snap, path: query.path, dl: query.dl === '1' });
|
|
220
|
+
}
|
|
221
|
+
else if (url.pathname === '/api/restore') {
|
|
222
|
+
const b = (apiReq.body ?? {});
|
|
223
|
+
audit('restore', { app: b.app, snap: b.snap, path: b.path });
|
|
224
|
+
}
|
|
225
|
+
else if (url.pathname === '/api/staging' && apiReq.method === 'DELETE') {
|
|
226
|
+
audit('staging-delete', { path: query.path });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
sendResponse(httpRes, apiRes);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
if (!httpRes.headersSent)
|
|
233
|
+
httpRes.writeHead(500, { 'Content-Type': 'application/json' });
|
|
234
|
+
httpRes.end(JSON.stringify({ error: e.message }));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
server.once('error', reject);
|
|
239
|
+
server.listen(opts.port, '127.0.0.1', () => resolve(server));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** server-rendered html for the backup explorer. self-contained — inline
|
|
2
|
+
* css + vanilla js, no build step, no external assets. all routes are
|
|
3
|
+
* served under the /backups/ prefix by nginx. */
|
|
4
|
+
export declare function renderLoginPage(): string;
|
|
5
|
+
export declare function renderExplorerPage(): string;
|