@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,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;