@matthesketh/fleet 1.8.1 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/index.js +0 -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,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;
|
|
@@ -0,0 +1,268 @@
|
|
|
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
|
+
const SHARED_CSS = `
|
|
5
|
+
:root { color-scheme: dark; }
|
|
6
|
+
* { box-sizing: border-box; }
|
|
7
|
+
body { margin: 0; padding: 2rem; background: #0d1117; color: #c9d1d9;
|
|
8
|
+
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
9
|
+
a { color: #58a6ff; text-decoration: none; }
|
|
10
|
+
h1 { font-size: 1.1rem; margin: 0 0 1rem; color: #e6edf3; }
|
|
11
|
+
button, select, input {
|
|
12
|
+
font: inherit; background: #161b22; color: #c9d1d9;
|
|
13
|
+
border: 1px solid #30363d; border-radius: 6px; padding: 0.35rem 0.6rem; }
|
|
14
|
+
button { cursor: pointer; }
|
|
15
|
+
button:hover { border-color: #58a6ff; }
|
|
16
|
+
.err { background: #3d1418; border: 1px solid #f85149; color: #ffa198;
|
|
17
|
+
padding: 0.5rem 0.8rem; border-radius: 6px; margin: 0.75rem 0; }
|
|
18
|
+
`;
|
|
19
|
+
export function renderLoginPage() {
|
|
20
|
+
return `<!doctype html>
|
|
21
|
+
<html lang="en"><head>
|
|
22
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
23
|
+
<title>fleet backups — login</title>
|
|
24
|
+
<style>${SHARED_CSS}
|
|
25
|
+
.box { max-width: 320px; margin: 12vh auto; }
|
|
26
|
+
#code { width: 100%; text-align: center; letter-spacing: 0.3em; font-size: 1.4rem; margin: 0.75rem 0; }
|
|
27
|
+
</style></head><body>
|
|
28
|
+
<div class="box">
|
|
29
|
+
<h1>fleet backups</h1>
|
|
30
|
+
<p>enter your authenticator code</p>
|
|
31
|
+
<input id="code" inputmode="numeric" maxlength="6" autocomplete="one-time-code" autofocus>
|
|
32
|
+
<button id="go" style="width:100%">unlock</button>
|
|
33
|
+
<div id="err" class="err" style="display:none"></div>
|
|
34
|
+
</div>
|
|
35
|
+
<script>
|
|
36
|
+
const code = document.getElementById('code');
|
|
37
|
+
const err = document.getElementById('err');
|
|
38
|
+
async function submit() {
|
|
39
|
+
err.style.display = 'none';
|
|
40
|
+
const res = await fetch('/backups/api/login', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json', 'X-Fleet-Backup': '1' },
|
|
43
|
+
body: JSON.stringify({ code: code.value.trim() }),
|
|
44
|
+
});
|
|
45
|
+
if (res.ok) { location.href = '/backups/'; return; }
|
|
46
|
+
err.textContent = 'invalid code';
|
|
47
|
+
err.style.display = 'block';
|
|
48
|
+
code.value = '';
|
|
49
|
+
code.focus();
|
|
50
|
+
}
|
|
51
|
+
document.getElementById('go').onclick = submit;
|
|
52
|
+
code.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
|
|
53
|
+
</script>
|
|
54
|
+
</body></html>`;
|
|
55
|
+
}
|
|
56
|
+
export function renderExplorerPage() {
|
|
57
|
+
return `<!doctype html>
|
|
58
|
+
<html lang="en"><head>
|
|
59
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
60
|
+
<title>fleet backups — explorer</title>
|
|
61
|
+
<style>${SHARED_CSS}
|
|
62
|
+
.bar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
63
|
+
.crumbs { margin: 0.5rem 0; color: #8b949e; }
|
|
64
|
+
.crumbs a { margin: 0 0.15rem; }
|
|
65
|
+
table { border-collapse: collapse; width: 100%; max-width: 960px; }
|
|
66
|
+
th, td { text-align: left; padding: 0.4rem 0.7rem; border-bottom: 1px solid #21262d; }
|
|
67
|
+
th { color: #8b949e; font-size: 0.78rem; text-transform: uppercase; }
|
|
68
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
69
|
+
tr.locked td.name { color: #6e7681; }
|
|
70
|
+
.lock { color: #d29922; }
|
|
71
|
+
.acts button { padding: 0.15rem 0.45rem; font-size: 0.8rem; }
|
|
72
|
+
#staging { margin-top: 1.5rem; max-width: 960px; }
|
|
73
|
+
#staging summary { cursor: pointer; color: #8b949e; }
|
|
74
|
+
#viewer { margin-top: 1rem; max-width: 960px; }
|
|
75
|
+
#viewer pre { background: #161b22; border: 1px solid #30363d; padding: 0.8rem;
|
|
76
|
+
border-radius: 6px; overflow: auto; max-height: 60vh; }
|
|
77
|
+
.spin { color: #8b949e; }
|
|
78
|
+
</style></head><body>
|
|
79
|
+
<h1>fleet backups — explorer</h1>
|
|
80
|
+
<div class="bar">
|
|
81
|
+
<select id="app"></select>
|
|
82
|
+
<select id="snap"></select>
|
|
83
|
+
</div>
|
|
84
|
+
<div id="crumbs" class="crumbs"></div>
|
|
85
|
+
<div id="err" class="err" style="display:none"></div>
|
|
86
|
+
<div id="tree"><span class="spin">loading…</span></div>
|
|
87
|
+
<div id="viewer"></div>
|
|
88
|
+
<details id="staging"><summary>staging restores</summary><div id="stagingBody"></div></details>
|
|
89
|
+
<script>
|
|
90
|
+
const API = '/backups/api/';
|
|
91
|
+
const H = { 'X-Fleet-Backup': '1' };
|
|
92
|
+
const $ = id => document.getElementById(id);
|
|
93
|
+
const err = msg => { const e = $('err'); e.textContent = msg; e.style.display = 'block'; };
|
|
94
|
+
const clearErr = () => { $('err').style.display = 'none'; };
|
|
95
|
+
let state = { app: '', snap: '', path: '/' };
|
|
96
|
+
|
|
97
|
+
async function api(path, opts) {
|
|
98
|
+
const res = await fetch(API + path, { headers: H, ...opts });
|
|
99
|
+
if (res.status === 401) { location.href = '/backups/login'; throw new Error('auth'); }
|
|
100
|
+
return res;
|
|
101
|
+
}
|
|
102
|
+
const fmtSize = n => n < 1024 ? n + ' B'
|
|
103
|
+
: n < 1048576 ? (n/1024).toFixed(1) + ' KB'
|
|
104
|
+
: n < 1073741824 ? (n/1048576).toFixed(1) + ' MB'
|
|
105
|
+
: (n/1073741824).toFixed(2) + ' GB';
|
|
106
|
+
|
|
107
|
+
async function loadApps() {
|
|
108
|
+
const r = await api('apps');
|
|
109
|
+
const data = await r.json();
|
|
110
|
+
const sel = $('app');
|
|
111
|
+
sel.innerHTML = '';
|
|
112
|
+
for (const a of data.apps) {
|
|
113
|
+
const o = document.createElement('option');
|
|
114
|
+
o.value = a.app; o.textContent = a.app;
|
|
115
|
+
sel.appendChild(o);
|
|
116
|
+
}
|
|
117
|
+
const qp = new URLSearchParams(location.search).get('app');
|
|
118
|
+
if (qp) sel.value = qp;
|
|
119
|
+
state.app = sel.value;
|
|
120
|
+
await loadSnapshots();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadSnapshots() {
|
|
124
|
+
const r = await api('snapshots?app=' + encodeURIComponent(state.app));
|
|
125
|
+
const data = await r.json();
|
|
126
|
+
const sel = $('snap');
|
|
127
|
+
sel.innerHTML = '';
|
|
128
|
+
for (const s of data.snapshots) {
|
|
129
|
+
const o = document.createElement('option');
|
|
130
|
+
o.value = s.shortId;
|
|
131
|
+
o.textContent = s.shortId + ' ' + (s.time || '').slice(0, 19) + ' ' + (s.tags || []).join(',');
|
|
132
|
+
sel.appendChild(o);
|
|
133
|
+
}
|
|
134
|
+
state.snap = sel.value || '';
|
|
135
|
+
state.path = '/';
|
|
136
|
+
await loadTree();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderCrumbs() {
|
|
140
|
+
const c = $('crumbs');
|
|
141
|
+
c.innerHTML = '';
|
|
142
|
+
const parts = state.path.split('/').filter(Boolean);
|
|
143
|
+
const root = document.createElement('a');
|
|
144
|
+
root.textContent = '/'; root.href = '#';
|
|
145
|
+
root.onclick = e => { e.preventDefault(); state.path = '/'; loadTree(); };
|
|
146
|
+
c.appendChild(root);
|
|
147
|
+
let acc = '';
|
|
148
|
+
for (const p of parts) {
|
|
149
|
+
acc += '/' + p;
|
|
150
|
+
const seg = acc;
|
|
151
|
+
const a = document.createElement('a');
|
|
152
|
+
a.textContent = p; a.href = '#';
|
|
153
|
+
a.onclick = e => { e.preventDefault(); state.path = seg; loadTree(); };
|
|
154
|
+
c.appendChild(document.createTextNode(' / '));
|
|
155
|
+
c.appendChild(a);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function loadTree() {
|
|
160
|
+
clearErr();
|
|
161
|
+
$('viewer').innerHTML = '';
|
|
162
|
+
$('tree').innerHTML = '<span class="spin">loading…</span>';
|
|
163
|
+
renderCrumbs();
|
|
164
|
+
let data;
|
|
165
|
+
try {
|
|
166
|
+
const r = await api('ls?app=' + encodeURIComponent(state.app)
|
|
167
|
+
+ '&snap=' + encodeURIComponent(state.snap)
|
|
168
|
+
+ '&path=' + encodeURIComponent(state.path));
|
|
169
|
+
if (!r.ok) { err((await r.json()).error || 'ls failed'); $('tree').innerHTML=''; return; }
|
|
170
|
+
data = await r.json();
|
|
171
|
+
} catch (e) { return; }
|
|
172
|
+
const rows = data.entries.map(e => {
|
|
173
|
+
const lock = e.sensitive ? '<span class="lock" title="locked — restore only">🔒</span> ' : '';
|
|
174
|
+
const nameCell = e.type === 'dir'
|
|
175
|
+
? '<a href="#" data-dir="' + encodeURIComponent(e.path) + '">' + lock + e.name + '/</a>'
|
|
176
|
+
: (e.sensitive ? lock + e.name
|
|
177
|
+
: '<a href="#" data-file="' + encodeURIComponent(e.path) + '">' + lock + e.name + '</a>');
|
|
178
|
+
return '<tr class="' + (e.sensitive ? 'locked' : '') + '">'
|
|
179
|
+
+ '<td class="name">' + nameCell + '</td>'
|
|
180
|
+
+ '<td>' + e.type + '</td>'
|
|
181
|
+
+ '<td class="num">' + (e.type === 'file' ? fmtSize(e.size) : '') + '</td>'
|
|
182
|
+
+ '<td>' + (e.mtime || '').slice(0, 19) + '</td>'
|
|
183
|
+
+ '<td class="acts">'
|
|
184
|
+
+ (e.type === 'file' && !e.sensitive
|
|
185
|
+
? '<button data-dl="' + encodeURIComponent(e.path) + '">download</button> ' : '')
|
|
186
|
+
+ '<button data-restore="' + encodeURIComponent(e.path) + '">restore</button>'
|
|
187
|
+
+ '</td></tr>';
|
|
188
|
+
}).join('');
|
|
189
|
+
$('tree').innerHTML = '<table><thead><tr><th>name</th><th>type</th>'
|
|
190
|
+
+ '<th class="num">size</th><th>modified</th><th>actions</th></tr></thead><tbody>'
|
|
191
|
+
+ rows + '</tbody></table>';
|
|
192
|
+
bindRows();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function bindRows() {
|
|
196
|
+
document.querySelectorAll('[data-dir]').forEach(a => a.onclick = e => {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
state.path = decodeURIComponent(a.dataset.dir);
|
|
199
|
+
loadTree();
|
|
200
|
+
});
|
|
201
|
+
document.querySelectorAll('[data-file]').forEach(a => a.onclick = e => {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
viewFile(decodeURIComponent(a.dataset.file));
|
|
204
|
+
});
|
|
205
|
+
document.querySelectorAll('[data-dl]').forEach(b => b.onclick = () => {
|
|
206
|
+
const p = decodeURIComponent(b.dataset.dl);
|
|
207
|
+
location.href = API + 'file?app=' + encodeURIComponent(state.app)
|
|
208
|
+
+ '&snap=' + encodeURIComponent(state.snap)
|
|
209
|
+
+ '&path=' + encodeURIComponent(p) + '&dl=1';
|
|
210
|
+
});
|
|
211
|
+
document.querySelectorAll('[data-restore]').forEach(b => b.onclick = () =>
|
|
212
|
+
doRestore(decodeURIComponent(b.dataset.restore)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function viewFile(path) {
|
|
216
|
+
const url = API + 'file?app=' + encodeURIComponent(state.app)
|
|
217
|
+
+ '&snap=' + encodeURIComponent(state.snap)
|
|
218
|
+
+ '&path=' + encodeURIComponent(path);
|
|
219
|
+
const r = await api(url.replace(API, ''));
|
|
220
|
+
if (!r.ok) { err((await r.json()).error || 'view failed'); return; }
|
|
221
|
+
const ct = r.headers.get('Content-Type') || '';
|
|
222
|
+
const v = $('viewer');
|
|
223
|
+
if (ct.startsWith('image/')) {
|
|
224
|
+
v.innerHTML = '<img src="' + url + '" style="max-width:100%">';
|
|
225
|
+
} else if (ct.startsWith('text/') || ct.startsWith('application/json')) {
|
|
226
|
+
const txt = await r.text();
|
|
227
|
+
v.innerHTML = '<pre></pre>';
|
|
228
|
+
v.querySelector('pre').textContent = txt;
|
|
229
|
+
} else {
|
|
230
|
+
v.innerHTML = '<embed src="' + url + '" style="width:100%;height:60vh">';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function doRestore(path) {
|
|
235
|
+
if (!confirm('Restore to a staging dir?\\n\\nsnapshot: ' + state.snap + '\\npath: ' + path)) return;
|
|
236
|
+
clearErr();
|
|
237
|
+
const r = await api('restore', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { ...H, 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({ app: state.app, snap: state.snap, path }),
|
|
241
|
+
});
|
|
242
|
+
const data = await r.json();
|
|
243
|
+
if (!r.ok) { err(data.error || 'restore failed'); return; }
|
|
244
|
+
alert('restored ' + data.fileCount + ' file(s) to:\\n' + data.target);
|
|
245
|
+
loadStaging();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function loadStaging() {
|
|
249
|
+
const r = await api('staging');
|
|
250
|
+
const data = await r.json();
|
|
251
|
+
const body = $('stagingBody');
|
|
252
|
+
const dirs = data.staging || [];
|
|
253
|
+
body.innerHTML = dirs.length
|
|
254
|
+
? dirs.map(d => '<div>' + d.path + ' — ' + fmtSize(d.bytes) + ' — ' + d.age
|
|
255
|
+
+ ' <button data-del="' + encodeURIComponent(d.path) + '">delete</button></div>').join('')
|
|
256
|
+
: '<div class="spin">none</div>';
|
|
257
|
+
body.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
|
|
258
|
+
await api('staging?path=' + encodeURIComponent(decodeURIComponent(b.dataset.del)), { method: 'DELETE' });
|
|
259
|
+
loadStaging();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
$('app').onchange = () => { state.app = $('app').value; loadSnapshots(); };
|
|
264
|
+
$('snap').onchange = () => { state.snap = $('snap').value; state.path = '/'; loadTree(); };
|
|
265
|
+
loadApps().then(loadStaging).catch(() => {});
|
|
266
|
+
</script>
|
|
267
|
+
</body></html>`;
|
|
268
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FleetError } from '../errors.js';
|
|
2
|
+
export declare class CloudflareError extends FleetError {
|
|
3
|
+
}
|
|
4
|
+
/** export every zone's DNS records + page rules. returns a json blob suitable
|
|
5
|
+
* for restic stdin. used by the `system` backup so we can rebuild dns from
|
|
6
|
+
* a single file even if cloudflare account access is lost. */
|
|
7
|
+
export declare function exportAllZones(): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { requireEnv } from '../env.js';
|
|
3
|
+
import { FleetError } from '../errors.js';
|
|
4
|
+
import { execSafe } from '../exec.js';
|
|
5
|
+
/** path to the cloudflare credentials ini. */
|
|
6
|
+
function cfCredIni() { return requireEnv('FLEET_CF_CRED'); }
|
|
7
|
+
export class CloudflareError extends FleetError {
|
|
8
|
+
}
|
|
9
|
+
function loadCreds() {
|
|
10
|
+
const credPath = cfCredIni();
|
|
11
|
+
if (!existsSync(credPath))
|
|
12
|
+
return {};
|
|
13
|
+
const lines = readFileSync(credPath, 'utf-8').split('\n');
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
const t = line.trim();
|
|
17
|
+
if (!t || t.startsWith('#') || t.startsWith('['))
|
|
18
|
+
continue;
|
|
19
|
+
const eq = t.indexOf('=');
|
|
20
|
+
if (eq < 1)
|
|
21
|
+
continue;
|
|
22
|
+
const k = t.slice(0, eq).trim().toLowerCase().replace(/[_-]/g, '');
|
|
23
|
+
const v = t.slice(eq + 1).trim();
|
|
24
|
+
if (k === 'dnscloudflareapitoken' || k === 'cfapitoken' || k === 'apitoken') {
|
|
25
|
+
out.apiToken = v;
|
|
26
|
+
}
|
|
27
|
+
else if (k === 'dnscloudflareemail' || k === 'email') {
|
|
28
|
+
out.email = v;
|
|
29
|
+
}
|
|
30
|
+
else if (k === 'dnscloudflareapikey' || k === 'apikey') {
|
|
31
|
+
out.apiKey = v;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function cfCurl(path, creds) {
|
|
37
|
+
const headers = [];
|
|
38
|
+
if (creds.apiToken) {
|
|
39
|
+
headers.push('-H', `Authorization: Bearer ${creds.apiToken}`);
|
|
40
|
+
}
|
|
41
|
+
else if (creds.email && creds.apiKey) {
|
|
42
|
+
headers.push('-H', `X-Auth-Email: ${creds.email}`);
|
|
43
|
+
headers.push('-H', `X-Auth-Key: ${creds.apiKey}`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return { ok: false, stdout: '', stderr: 'no cloudflare credentials found' };
|
|
47
|
+
}
|
|
48
|
+
const r = execSafe('curl', [
|
|
49
|
+
'-sS', '-m', '30',
|
|
50
|
+
...headers,
|
|
51
|
+
`https://api.cloudflare.com/client/v4${path}`,
|
|
52
|
+
], { timeout: 35_000 });
|
|
53
|
+
return r;
|
|
54
|
+
}
|
|
55
|
+
/** export every zone's DNS records + page rules. returns a json blob suitable
|
|
56
|
+
* for restic stdin. used by the `system` backup so we can rebuild dns from
|
|
57
|
+
* a single file even if cloudflare account access is lost. */
|
|
58
|
+
export function exportAllZones() {
|
|
59
|
+
const creds = loadCreds();
|
|
60
|
+
if (!creds.apiToken && !(creds.email && creds.apiKey)) {
|
|
61
|
+
throw new CloudflareError(`no cloudflare credentials at ${cfCredIni()}`);
|
|
62
|
+
}
|
|
63
|
+
const zonesResp = cfCurl('/zones?per_page=200', creds);
|
|
64
|
+
if (!zonesResp.ok)
|
|
65
|
+
throw new CloudflareError(`zone list failed: ${zonesResp.stderr}`);
|
|
66
|
+
const zones = JSON.parse(zonesResp.stdout).result;
|
|
67
|
+
const out = { exportedAt: new Date().toISOString(), zones: [] };
|
|
68
|
+
const zonesOut = out.zones;
|
|
69
|
+
for (const z of zones) {
|
|
70
|
+
const dns = cfCurl(`/zones/${z.id}/dns_records?per_page=1000`, creds);
|
|
71
|
+
const pageRules = cfCurl(`/zones/${z.id}/pagerules?per_page=200`, creds);
|
|
72
|
+
const settings = cfCurl(`/zones/${z.id}/settings`, creds);
|
|
73
|
+
zonesOut.push({
|
|
74
|
+
id: z.id,
|
|
75
|
+
name: z.name,
|
|
76
|
+
dnsRecords: dns.ok ? JSON.parse(dns.stdout).result : { error: dns.stderr },
|
|
77
|
+
pageRules: pageRules.ok ? JSON.parse(pageRules.stdout).result : { error: pageRules.stderr },
|
|
78
|
+
settings: settings.ok ? JSON.parse(settings.stdout).result : { error: settings.stderr },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return JSON.stringify(out, null, 2);
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AppBackupConfig, Retention } from './types.js';
|
|
2
|
+
export declare function backupConfigDir(): string;
|
|
3
|
+
export declare function backupVaultDir(): string;
|
|
4
|
+
export declare const DEFAULT_RETENTION: Retention;
|
|
5
|
+
export declare const DEFAULT_EXCLUDES: string[];
|
|
6
|
+
export declare function loadConfig(app: string): AppBackupConfig | null;
|
|
7
|
+
export declare function saveConfig(cfg: AppBackupConfig): void;
|
|
8
|
+
export declare function listConfiguredApps(): string[];
|
|
9
|
+
export declare function validateAppName(app: string): void;
|