@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,144 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { findGreenlight, greenlightVersion, runPreflight, runGuidelines, GREENLIGHT_INSTALL_HINT, } from '../core/audit/greenlight.js';
|
|
4
|
+
import { resolveAuditTarget } from '../core/audit/target.js';
|
|
5
|
+
import { saveAuditRecord } from '../core/audit/cache.js';
|
|
6
|
+
import { loadAuditConfig, saveAuditConfig } from '../core/audit/config.js';
|
|
7
|
+
import { applySuppressions } from '../core/audit/suppress.js';
|
|
8
|
+
import { formatReport } from '../core/audit/reporters/cli.js';
|
|
9
|
+
import { FleetError } from '../core/errors.js';
|
|
10
|
+
import { heading, success, error, info } from '../ui/output.js';
|
|
11
|
+
// `fleet audit` — App Store compliance audits for mobile app projects, backed
|
|
12
|
+
// by the greenlight preflight scanner (RevylAI/greenlight).
|
|
13
|
+
export async function auditCommand(args) {
|
|
14
|
+
const sub = args[0];
|
|
15
|
+
switch (sub) {
|
|
16
|
+
case 'guidelines': return auditGuidelines(args.slice(1));
|
|
17
|
+
case 'doctor': return auditDoctor();
|
|
18
|
+
case 'ignore': return auditIgnore(args.slice(1));
|
|
19
|
+
case 'unignore': return auditUnignore(args.slice(1));
|
|
20
|
+
case 'ignores': return auditIgnores();
|
|
21
|
+
default: return auditRun(args);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function auditRun(args) {
|
|
25
|
+
const json = args.includes('--json');
|
|
26
|
+
const ipaFlag = extractFlag(args, '--ipa');
|
|
27
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
28
|
+
const target = positional[0] ?? '.';
|
|
29
|
+
if (!findGreenlight()) {
|
|
30
|
+
error('greenlight binary not found');
|
|
31
|
+
for (const line of GREENLIGHT_INSTALL_HINT.split('\n').slice(1))
|
|
32
|
+
info(line.trim());
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const { target: resolved, projectPath } = resolveAuditTarget(target);
|
|
36
|
+
let ipaPath;
|
|
37
|
+
if (ipaFlag) {
|
|
38
|
+
ipaPath = resolve(ipaFlag);
|
|
39
|
+
if (!existsSync(ipaPath))
|
|
40
|
+
throw new FleetError(`IPA file not found: ${ipaPath}`);
|
|
41
|
+
}
|
|
42
|
+
if (!json) {
|
|
43
|
+
heading(`App Store Audit: ${resolved}`);
|
|
44
|
+
info(`Scanning ${projectPath}${ipaPath ? ` (+ ${ipaPath})` : ''}`);
|
|
45
|
+
}
|
|
46
|
+
const raw = runPreflight(projectPath, { ipaPath });
|
|
47
|
+
const { report, suppressed } = applySuppressions(raw, resolved, loadAuditConfig().ignore);
|
|
48
|
+
const record = {
|
|
49
|
+
target: resolved,
|
|
50
|
+
projectPath,
|
|
51
|
+
...(ipaPath && { ipaPath }),
|
|
52
|
+
ranAt: new Date().toISOString(),
|
|
53
|
+
report,
|
|
54
|
+
};
|
|
55
|
+
saveAuditRecord(record);
|
|
56
|
+
if (json) {
|
|
57
|
+
process.stdout.write(JSON.stringify(record, null, 2) + '\n');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
process.stdout.write('\n');
|
|
61
|
+
for (const line of formatReport(report))
|
|
62
|
+
process.stdout.write(line + '\n');
|
|
63
|
+
if (suppressed > 0) {
|
|
64
|
+
info(`${suppressed} finding(s) suppressed by ignore rules — see: fleet audit ignores`);
|
|
65
|
+
}
|
|
66
|
+
process.stdout.write('\n');
|
|
67
|
+
}
|
|
68
|
+
async function auditGuidelines(args) {
|
|
69
|
+
if (!findGreenlight()) {
|
|
70
|
+
error('greenlight binary not found — run: fleet audit doctor');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
process.stdout.write(runGuidelines(args.length > 0 ? args : ['list']) + '\n');
|
|
74
|
+
}
|
|
75
|
+
async function auditDoctor() {
|
|
76
|
+
heading('Audit — greenlight status');
|
|
77
|
+
const bin = findGreenlight();
|
|
78
|
+
if (!bin) {
|
|
79
|
+
error('greenlight binary not found');
|
|
80
|
+
for (const line of GREENLIGHT_INSTALL_HINT.split('\n').slice(1))
|
|
81
|
+
info(line.trim());
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
success(`greenlight found: ${bin}`);
|
|
85
|
+
const version = greenlightVersion(bin);
|
|
86
|
+
if (version)
|
|
87
|
+
info(`version: ${version}`);
|
|
88
|
+
}
|
|
89
|
+
// `fleet audit ignore "<title>" --reason "..."` — suppress a confirmed
|
|
90
|
+
// greenlight false positive. matched by exact finding title, optionally
|
|
91
|
+
// narrowed to a target and to findings whose file/code contains a substring.
|
|
92
|
+
async function auditIgnore(args) {
|
|
93
|
+
const title = args.find(a => !a.startsWith('-'));
|
|
94
|
+
const reason = extractFlag(args, '--reason');
|
|
95
|
+
const target = extractFlag(args, '--target');
|
|
96
|
+
const contains = extractFlag(args, '--contains');
|
|
97
|
+
if (!title || !reason) {
|
|
98
|
+
error('Usage: fleet audit ignore "<finding title>" --reason "..." [--target <app>] [--contains <substr>]');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const config = loadAuditConfig();
|
|
102
|
+
config.ignore.push({
|
|
103
|
+
...(target && { target }),
|
|
104
|
+
title,
|
|
105
|
+
...(contains && { contains }),
|
|
106
|
+
reason,
|
|
107
|
+
addedAt: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
saveAuditConfig(config);
|
|
110
|
+
success(`Ignoring "${title}"${target ? ` for ${target}` : ''}: ${reason}`);
|
|
111
|
+
}
|
|
112
|
+
async function auditUnignore(args) {
|
|
113
|
+
const title = args.find(a => !a.startsWith('-'));
|
|
114
|
+
const target = extractFlag(args, '--target');
|
|
115
|
+
if (!title) {
|
|
116
|
+
error('Usage: fleet audit unignore "<finding title>" [--target <app>]');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
const config = loadAuditConfig();
|
|
120
|
+
const before = config.ignore.length;
|
|
121
|
+
config.ignore = config.ignore.filter(rule => !(rule.title === title && (!target || rule.target === target)));
|
|
122
|
+
saveAuditConfig(config);
|
|
123
|
+
success(`Removed ${before - config.ignore.length} ignore rule(s) for "${title}"`);
|
|
124
|
+
}
|
|
125
|
+
async function auditIgnores() {
|
|
126
|
+
heading('Audit — ignore rules');
|
|
127
|
+
const { ignore } = loadAuditConfig();
|
|
128
|
+
if (ignore.length === 0) {
|
|
129
|
+
info('No ignore rules configured.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const rule of ignore) {
|
|
133
|
+
process.stdout.write(` ${rule.title}` +
|
|
134
|
+
`${rule.target ? ` [${rule.target}]` : ''}` +
|
|
135
|
+
`${rule.contains ? ` (~${rule.contains})` : ''}\n` +
|
|
136
|
+
` ${rule.reason}\n`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function extractFlag(args, flag) {
|
|
140
|
+
const idx = args.indexOf(flag);
|
|
141
|
+
if (idx === -1 || idx + 1 >= args.length)
|
|
142
|
+
return undefined;
|
|
143
|
+
return args[idx + 1];
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function backupCommand(args: string[]): void;
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadConfig, saveConfig, listConfiguredApps, validateAppName, } from '../core/backup/config.js';
|
|
5
|
+
import { exportAllZones } from '../core/backup/cloudflare.js';
|
|
6
|
+
import { detectAppConfig } from '../core/backup/detect.js';
|
|
7
|
+
import { load as loadRegistry } from '../core/registry.js';
|
|
8
|
+
import { dumpStreamCommand, dumpFilename } from '../core/backup/dump.js';
|
|
9
|
+
import { initRepo, snapshot, listSnapshots, restore, prune, check, checkIntegrity, isAppendOnly, stats, } from '../core/backup/repo.js';
|
|
10
|
+
import { installScheduleUnits, disableSchedule } from '../core/backup/schedule.js';
|
|
11
|
+
import { systemConfig, rootHomeConfig, userHomeConfig, sharedPostgresConfig, sharedMysqlConfig, sharedMongoConfig, } from '../core/backup/system.js';
|
|
12
|
+
import { isPseudoApp } from '../core/backup/types.js';
|
|
13
|
+
import { renderStatusHtml } from '../core/backup/statuspage.js';
|
|
14
|
+
import { buildStatusReport } from '../core/backup/status.js';
|
|
15
|
+
import { startServer } from '../core/backup/browser-server.js';
|
|
16
|
+
import { generateSecret, totpUri } from '../core/backup/totp.js';
|
|
17
|
+
import { generateAndStorePassword, vaultPath, } from '../core/backup/unlock.js';
|
|
18
|
+
import { FleetError } from '../core/errors.js';
|
|
19
|
+
import { execSafe } from '../core/exec.js';
|
|
20
|
+
import { loadOperator } from '../core/operator.js';
|
|
21
|
+
import { c, heading, table, info, success, error, warn } from '../ui/output.js';
|
|
22
|
+
const HELP = `fleet backup - encrypted off-host backups via restic + age
|
|
23
|
+
|
|
24
|
+
Usage: fleet backup <subcommand> [args]
|
|
25
|
+
|
|
26
|
+
Subcommands:
|
|
27
|
+
init <app> generate password vault + restic repo for an app
|
|
28
|
+
init-system configure the three pseudo-apps (system, root-home, user-home)
|
|
29
|
+
register <app> [--dry-run] auto-detect and register a fleet-known app (paths, db dump, volumes)
|
|
30
|
+
register-all [--dry-run] run register for every app in the fleet registry
|
|
31
|
+
snapshot <app> [--dry-run] one-off backup now
|
|
32
|
+
list <app> list snapshots
|
|
33
|
+
restore <app> <snapshot> [opts] restore. opts: --to <dir>, --include <path>, --dry-run, --verify
|
|
34
|
+
prune <app> apply retention policy
|
|
35
|
+
verify <app> restic check (full repo integrity)
|
|
36
|
+
integrity <app> [--read N] restic check with --read-data-subset=N% (default 5)
|
|
37
|
+
schedule <app> <schedule> [--dry-run] set+enable systemd timer (hourly|daily|weekly)
|
|
38
|
+
schedule-all [--dry-run] schedule every configured app per its config
|
|
39
|
+
unschedule <app> disable + remove timer
|
|
40
|
+
status dashboard of all configured backups
|
|
41
|
+
serve [--setup-totp] run the /backups explorer service
|
|
42
|
+
test <app> e2e: snapshot, list, restore-to-tmp, diff, cleanup
|
|
43
|
+
`;
|
|
44
|
+
export function backupCommand(args) {
|
|
45
|
+
const [sub, ...rest] = args;
|
|
46
|
+
switch (sub) {
|
|
47
|
+
case 'init': return cmdInit(rest);
|
|
48
|
+
case 'init-system': return cmdInitSystem();
|
|
49
|
+
case 'register': return cmdRegister(rest);
|
|
50
|
+
case 'register-all': return cmdRegisterAll(rest);
|
|
51
|
+
case 'snapshot': return cmdSnapshot(rest);
|
|
52
|
+
case 'snapshot-all': return cmdSnapshotAll(rest);
|
|
53
|
+
case 'list': return cmdList(rest);
|
|
54
|
+
case 'restore': return cmdRestore(rest);
|
|
55
|
+
case 'prune': return cmdPrune(rest);
|
|
56
|
+
case 'verify': return cmdVerify(rest);
|
|
57
|
+
case 'schedule': return cmdSchedule(rest);
|
|
58
|
+
case 'schedule-all': return cmdScheduleAll(rest);
|
|
59
|
+
case 'unschedule': return cmdUnschedule(rest);
|
|
60
|
+
case 'integrity': return cmdIntegrity(rest);
|
|
61
|
+
case 'status': return cmdStatus(rest);
|
|
62
|
+
case 'serve': return cmdServe(rest);
|
|
63
|
+
case 'test': return cmdTest(rest);
|
|
64
|
+
case '--help':
|
|
65
|
+
case '-h':
|
|
66
|
+
case undefined:
|
|
67
|
+
process.stdout.write(HELP);
|
|
68
|
+
return;
|
|
69
|
+
default:
|
|
70
|
+
error(`unknown subcommand: ${sub}`);
|
|
71
|
+
process.stdout.write(HELP);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function cmdInit(args) {
|
|
76
|
+
const [app] = args;
|
|
77
|
+
validateAppName(app);
|
|
78
|
+
// idempotent: don't regenerate the per-app password if a vault entry
|
|
79
|
+
// already exists, because that would orphan all prior snapshots.
|
|
80
|
+
if (!existsSync(vaultPath(app))) {
|
|
81
|
+
generateAndStorePassword(app);
|
|
82
|
+
}
|
|
83
|
+
initRepo(app);
|
|
84
|
+
success(`vault + repo initialised for ${app}`);
|
|
85
|
+
}
|
|
86
|
+
function cmdInitSystem() {
|
|
87
|
+
const configs = [
|
|
88
|
+
systemConfig(),
|
|
89
|
+
rootHomeConfig(),
|
|
90
|
+
userHomeConfig(),
|
|
91
|
+
sharedPostgresConfig(),
|
|
92
|
+
sharedMysqlConfig(),
|
|
93
|
+
sharedMongoConfig(),
|
|
94
|
+
];
|
|
95
|
+
for (const cfg of configs) {
|
|
96
|
+
if (!loadConfig(cfg.app)) {
|
|
97
|
+
saveConfig(cfg);
|
|
98
|
+
info(`wrote default config for ${cfg.app}`);
|
|
99
|
+
}
|
|
100
|
+
if (!existsSync(vaultPath(cfg.app))) {
|
|
101
|
+
generateAndStorePassword(cfg.app);
|
|
102
|
+
info(`generated vault entry for ${cfg.app}`);
|
|
103
|
+
}
|
|
104
|
+
initRepo(cfg.app);
|
|
105
|
+
info(`initialised restic repo for ${cfg.app}`);
|
|
106
|
+
}
|
|
107
|
+
success(`pseudo-apps ready: 3 system + 3 shared-db safety nets`);
|
|
108
|
+
}
|
|
109
|
+
function cmdRegister(args) {
|
|
110
|
+
const dryRun = args.includes('--dry-run');
|
|
111
|
+
const [app] = args.filter(a => !a.startsWith('--'));
|
|
112
|
+
validateAppName(app);
|
|
113
|
+
if (isPseudoApp(app)) {
|
|
114
|
+
throw new FleetError(`use init-system for pseudo-app ${app}`);
|
|
115
|
+
}
|
|
116
|
+
const cfg = detectAppConfig(app);
|
|
117
|
+
if (!cfg)
|
|
118
|
+
throw new FleetError(`app not in fleet registry: ${app}`);
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
heading(`[dry-run] would register ${app}`);
|
|
121
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + '\n');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
saveConfig(cfg);
|
|
125
|
+
generateAndStorePassword(app);
|
|
126
|
+
initRepo(app);
|
|
127
|
+
success(`registered ${app}: ${cfg.paths.length} paths, ${cfg.volumes?.length ?? 0} volumes, dump=${cfg.preDump?.type ?? 'none'}, schedule=${cfg.schedule}`);
|
|
128
|
+
}
|
|
129
|
+
function cmdRegisterAll(args) {
|
|
130
|
+
const dryRun = args.includes('--dry-run');
|
|
131
|
+
const reg = loadRegistry();
|
|
132
|
+
if (reg.apps.length === 0) {
|
|
133
|
+
warn('fleet registry is empty');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
heading(`${dryRun ? '[dry-run] ' : ''}registering ${reg.apps.length} apps`);
|
|
137
|
+
const rows = [];
|
|
138
|
+
for (const app of reg.apps) {
|
|
139
|
+
try {
|
|
140
|
+
const cfg = detectAppConfig(app.name);
|
|
141
|
+
if (!cfg) {
|
|
142
|
+
rows.push([app.name, 'SKIP', 'not detectable']);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!dryRun) {
|
|
146
|
+
saveConfig(cfg);
|
|
147
|
+
generateAndStorePassword(cfg.app);
|
|
148
|
+
initRepo(cfg.app);
|
|
149
|
+
}
|
|
150
|
+
rows.push([
|
|
151
|
+
app.name,
|
|
152
|
+
dryRun ? 'plan' : 'done',
|
|
153
|
+
`paths=${cfg.paths.length} vols=${cfg.volumes?.length ?? 0} dump=${cfg.preDump?.type ?? '-'} sched=${cfg.schedule}`,
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
rows.push([app.name, 'FAIL', e.message]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
table(['app', 'status', 'plan'], rows);
|
|
161
|
+
}
|
|
162
|
+
function cmdSnapshot(args) {
|
|
163
|
+
const dryRun = args.includes('--dry-run');
|
|
164
|
+
const [app] = args.filter(a => !a.startsWith('--'));
|
|
165
|
+
const cfg = mustLoadConfig(app);
|
|
166
|
+
if (cfg.disabled) {
|
|
167
|
+
warn(`${app} is disabled; skipping`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const tags = [`app=${app}`];
|
|
171
|
+
let snapCount = 0;
|
|
172
|
+
const prefix = dryRun ? '[dry-run] ' : '';
|
|
173
|
+
// file-system snapshot
|
|
174
|
+
if (cfg.paths.length > 0) {
|
|
175
|
+
const existing = cfg.paths.filter(p => existsSync(p));
|
|
176
|
+
const skipped = cfg.paths.length - existing.length;
|
|
177
|
+
if (skipped > 0)
|
|
178
|
+
warn(`${skipped} configured path(s) missing on disk, skipping those`);
|
|
179
|
+
if (existing.length > 0) {
|
|
180
|
+
const snap = snapshot(app, {
|
|
181
|
+
paths: existing,
|
|
182
|
+
excludes: cfg.exclude,
|
|
183
|
+
tags: [...tags, 'kind=fs'],
|
|
184
|
+
dryRun,
|
|
185
|
+
});
|
|
186
|
+
info(`${prefix}fs snapshot ${snap.shortId} (${humanBytes(snap.sizeBytes ?? 0)} ${dryRun ? 'would be added' : 'added'})`);
|
|
187
|
+
snapCount++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// db dump — streamed through sh -c so multi-gb dumps don't hit
|
|
191
|
+
// spawnsync's 1mb buffer ceiling.
|
|
192
|
+
if (cfg.preDump) {
|
|
193
|
+
if (dryRun) {
|
|
194
|
+
info(`${prefix}would run ${cfg.preDump.type} dump on ${cfg.preDump.container} -> ${dumpFilename(cfg.preDump)}`);
|
|
195
|
+
snapCount++;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const fn = dumpFilename(cfg.preDump);
|
|
199
|
+
const snap = snapshot(app, {
|
|
200
|
+
paths: [],
|
|
201
|
+
tags: [...tags, 'kind=dump', `dump=${cfg.preDump.type}`],
|
|
202
|
+
stdinFilename: fn,
|
|
203
|
+
stdinCommand: dumpStreamCommand(cfg.preDump),
|
|
204
|
+
});
|
|
205
|
+
info(`db dump snapshot ${snap.shortId} (${humanBytes(snap.sizeBytes ?? 0)} added) -> ${fn}`);
|
|
206
|
+
snapCount++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// system-app extras: cf zone export. cf zones json is small (<1mb) so we
|
|
210
|
+
// pass it via in-memory stdinData rather than streaming.
|
|
211
|
+
if (app === 'system') {
|
|
212
|
+
if (dryRun) {
|
|
213
|
+
info(`${prefix}would export all cloudflare zones (api token required at /root/.secrets/cloudflare.ini)`);
|
|
214
|
+
snapCount++;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
try {
|
|
218
|
+
const zones = exportAllZones();
|
|
219
|
+
const snap = snapshot(app, {
|
|
220
|
+
paths: [],
|
|
221
|
+
tags: [...tags, 'kind=cf-zones'],
|
|
222
|
+
stdinFilename: 'cloudflare-zones.json',
|
|
223
|
+
stdinData: zones,
|
|
224
|
+
});
|
|
225
|
+
info(`cloudflare zones snapshot ${snap.shortId} (${humanBytes(snap.sizeBytes ?? 0)} added)`);
|
|
226
|
+
snapCount++;
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
warn(`cloudflare zone export failed: ${e.message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// apply retention. on an append-only backend (rest-server --append-only)
|
|
234
|
+
// primary cannot prune by design — pruning runs locally on the backup vps
|
|
235
|
+
// via its own cron, where admin-level access is gated separately. this
|
|
236
|
+
// means an intruder on primary cannot wipe snapshots with retention=0.
|
|
237
|
+
if (!dryRun) {
|
|
238
|
+
if (isAppendOnly()) {
|
|
239
|
+
info(`retention skipped (append-only backend; backup-vps handles prune locally)`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
prune(app, cfg.retention);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
success(`${prefix}${app}: ${snapCount} snapshot(s) ${dryRun ? 'planned' : 'added'}`);
|
|
246
|
+
}
|
|
247
|
+
function cmdSnapshotAll(args) {
|
|
248
|
+
const dryRun = args.includes('--dry-run');
|
|
249
|
+
const apps = listConfiguredApps();
|
|
250
|
+
if (apps.length === 0) {
|
|
251
|
+
warn('no configured backups; run init-system or register-all first');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
heading(`${dryRun ? '[dry-run] ' : ''}snapshotting ${apps.length} apps`);
|
|
255
|
+
for (const app of apps) {
|
|
256
|
+
try {
|
|
257
|
+
cmdSnapshot([app, ...(dryRun ? ['--dry-run'] : [])]);
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
error(`${app}: ${e.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function cmdList(args) {
|
|
265
|
+
const [app] = args;
|
|
266
|
+
validateAppName(app);
|
|
267
|
+
const snaps = listSnapshots(app);
|
|
268
|
+
if (snaps.length === 0) {
|
|
269
|
+
info(`no snapshots for ${app}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
heading(`snapshots for ${app}`);
|
|
273
|
+
const rows = snaps.map(s => [
|
|
274
|
+
s.shortId,
|
|
275
|
+
s.time.replace('T', ' ').slice(0, 19),
|
|
276
|
+
s.tags.join(','),
|
|
277
|
+
s.paths.join(', ').slice(0, 80),
|
|
278
|
+
]);
|
|
279
|
+
table(['id', 'time', 'tags', 'paths'], rows);
|
|
280
|
+
}
|
|
281
|
+
function cmdRestore(args) {
|
|
282
|
+
const positional = args.filter(a => !a.startsWith('--') && !isFlagValue(args, a));
|
|
283
|
+
const [app, snapId] = positional;
|
|
284
|
+
validateAppName(app);
|
|
285
|
+
if (!snapId)
|
|
286
|
+
throw new FleetError('snapshot id required');
|
|
287
|
+
let target = '';
|
|
288
|
+
const include = [];
|
|
289
|
+
const dryRun = args.includes('--dry-run');
|
|
290
|
+
const verify = args.includes('--verify');
|
|
291
|
+
for (let i = 0; i < args.length; i++) {
|
|
292
|
+
if (args[i] === '--to') {
|
|
293
|
+
target = args[++i] ?? '';
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (args[i] === '--include') {
|
|
297
|
+
include.push(args[++i] ?? '');
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!target)
|
|
302
|
+
throw new FleetError(`--to <dir> required (we never restore in place automatically)`);
|
|
303
|
+
if (!existsSync(target) && !dryRun)
|
|
304
|
+
mkdirSync(target, { recursive: true });
|
|
305
|
+
restore(app, { snapshotId: snapId, target, include, dryRun, verify });
|
|
306
|
+
if (dryRun) {
|
|
307
|
+
success(`[dry-run] would restore ${snapId} to ${target}`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (verify) {
|
|
311
|
+
success(`restored + verified ${snapId} to ${target}`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
success(`restored ${snapId} to ${target} (run with --verify to integrity-check)`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function isFlagValue(args, current) {
|
|
318
|
+
const i = args.indexOf(current);
|
|
319
|
+
return i > 0 && (args[i - 1] === '--to' || args[i - 1] === '--include');
|
|
320
|
+
}
|
|
321
|
+
function cmdPrune(args) {
|
|
322
|
+
const cfg = mustLoadConfig(args[0]);
|
|
323
|
+
prune(cfg.app, cfg.retention);
|
|
324
|
+
success(`retention applied for ${cfg.app}`);
|
|
325
|
+
}
|
|
326
|
+
function cmdVerify(args) {
|
|
327
|
+
const [app] = args;
|
|
328
|
+
validateAppName(app);
|
|
329
|
+
const ok = check(app);
|
|
330
|
+
if (ok) {
|
|
331
|
+
success(`verify ok: ${app}`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
error(`verify FAILED: ${app}`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
function cmdSchedule(args) {
|
|
338
|
+
const dryRun = args.includes('--dry-run');
|
|
339
|
+
const positional = args.filter(a => !a.startsWith('--'));
|
|
340
|
+
const [app, schedule] = positional;
|
|
341
|
+
const cfg = mustLoadConfig(app);
|
|
342
|
+
if (!schedule)
|
|
343
|
+
throw new FleetError('schedule required (hourly/daily/weekly)');
|
|
344
|
+
cfg.schedule = schedule;
|
|
345
|
+
if (dryRun) {
|
|
346
|
+
const plan = installScheduleUnits(cfg.app, cfg.schedule, { apply: false });
|
|
347
|
+
heading(`[dry-run] would install timer for ${cfg.app}`);
|
|
348
|
+
info(`timer: ${plan.timerPath}`);
|
|
349
|
+
process.stdout.write(plan.timerContent);
|
|
350
|
+
if (plan.sharedServiceWrote) {
|
|
351
|
+
info(`+ first-time shared service: ${plan.sharedServicePath}`);
|
|
352
|
+
process.stdout.write(plan.sharedServiceContent);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
saveConfig(cfg);
|
|
357
|
+
installScheduleUnits(cfg.app, cfg.schedule, { apply: true });
|
|
358
|
+
success(`schedule set: ${cfg.app} every ${schedule}`);
|
|
359
|
+
}
|
|
360
|
+
function cmdScheduleAll(args) {
|
|
361
|
+
const dryRun = args.includes('--dry-run');
|
|
362
|
+
const apps = listConfiguredApps();
|
|
363
|
+
if (apps.length === 0) {
|
|
364
|
+
warn('no configured backups');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
heading(`${dryRun ? '[dry-run] ' : ''}scheduling ${apps.length} apps`);
|
|
368
|
+
const rows = [];
|
|
369
|
+
for (const app of apps) {
|
|
370
|
+
const cfg = loadConfig(app);
|
|
371
|
+
if (!cfg) {
|
|
372
|
+
rows.push([app, 'SKIP', 'no config']);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
if (!dryRun)
|
|
377
|
+
installScheduleUnits(cfg.app, cfg.schedule, { apply: true });
|
|
378
|
+
rows.push([app, dryRun ? 'plan' : 'enabled', `OnCalendar=${cfg.schedule}`]);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
rows.push([app, 'FAIL', e.message]);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
table(['app', 'status', 'schedule'], rows);
|
|
385
|
+
}
|
|
386
|
+
function cmdUnschedule(args) {
|
|
387
|
+
const [app] = args;
|
|
388
|
+
validateAppName(app);
|
|
389
|
+
disableSchedule(app);
|
|
390
|
+
success(`schedule disabled: ${app}`);
|
|
391
|
+
}
|
|
392
|
+
function cmdIntegrity(args) {
|
|
393
|
+
const positional = args.filter(a => !a.startsWith('--'));
|
|
394
|
+
const [app] = positional;
|
|
395
|
+
validateAppName(app);
|
|
396
|
+
let pct = 5;
|
|
397
|
+
for (let i = 0; i < args.length; i++) {
|
|
398
|
+
if (args[i] === '--read')
|
|
399
|
+
pct = parseInt(args[++i] ?? '5', 10);
|
|
400
|
+
}
|
|
401
|
+
info(`integrity check ${app} (read ${pct}% of data; can take minutes for large repos)`);
|
|
402
|
+
const r = checkIntegrity(app, pct);
|
|
403
|
+
if (!r.ok) {
|
|
404
|
+
error(`integrity FAILED for ${app}:\n${r.output}`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
success(`integrity ok: ${app}`);
|
|
408
|
+
}
|
|
409
|
+
function cmdStatus(args = []) {
|
|
410
|
+
const apps = listConfiguredApps();
|
|
411
|
+
if (apps.length === 0) {
|
|
412
|
+
info(`no configured backups. start with: fleet backup init-system`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (args.includes('--json')) {
|
|
416
|
+
process.stdout.write(JSON.stringify(buildStatusReport(), null, 2) + '\n');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (args.includes('--html')) {
|
|
420
|
+
process.stdout.write(renderStatusHtml(buildStatusReport()));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
heading('fleet backups');
|
|
424
|
+
const rows = [];
|
|
425
|
+
for (const app of apps) {
|
|
426
|
+
const cfg = loadConfig(app);
|
|
427
|
+
if (!cfg)
|
|
428
|
+
continue;
|
|
429
|
+
const snaps = listSnapshots(app);
|
|
430
|
+
const last = snaps[snaps.length - 1];
|
|
431
|
+
const st = stats(app);
|
|
432
|
+
rows.push([
|
|
433
|
+
cfg.disabled ? `${c.dim}${app}${c.reset}` : app,
|
|
434
|
+
cfg.schedule,
|
|
435
|
+
String(snaps.length),
|
|
436
|
+
last ? last.time.replace('T', ' ').slice(0, 19) : '-',
|
|
437
|
+
st ? humanBytes(st.totalSize) : '-',
|
|
438
|
+
]);
|
|
439
|
+
}
|
|
440
|
+
table(['app', 'schedule', 'snaps', 'last', 'size'], rows);
|
|
441
|
+
}
|
|
442
|
+
function cmdTest(args) {
|
|
443
|
+
const [app] = args;
|
|
444
|
+
validateAppName(app);
|
|
445
|
+
// 1. snapshot a tiny dummy payload
|
|
446
|
+
const fakePath = join(tmpdir(), `fleet-backup-test-${process.pid}`);
|
|
447
|
+
mkdirSync(fakePath, { recursive: true });
|
|
448
|
+
const marker = `test ${Date.now()}`;
|
|
449
|
+
execSafe('sh', ['-c', `echo "${marker}" > ${shellEscape(fakePath)}/hello.txt`], { timeout: 2_000 });
|
|
450
|
+
const snap = snapshot(app, { paths: [fakePath], tags: ['kind=e2e-test'] });
|
|
451
|
+
info(`snapshot ${snap.shortId}`);
|
|
452
|
+
// 2. restore to a fresh tmp dir
|
|
453
|
+
const restoreDir = `${fakePath}-restore`;
|
|
454
|
+
mkdirSync(restoreDir, { recursive: true });
|
|
455
|
+
restore(app, { snapshotId: snap.shortId, target: restoreDir });
|
|
456
|
+
// 3. read back the marker
|
|
457
|
+
const r = execSafe('cat', [join(restoreDir, fakePath, 'hello.txt')], { timeout: 2_000 });
|
|
458
|
+
rmSync(fakePath, { recursive: true, force: true });
|
|
459
|
+
rmSync(restoreDir, { recursive: true, force: true });
|
|
460
|
+
if (r.stdout.trim() !== marker) {
|
|
461
|
+
error(`marker mismatch: ${r.stdout}`);
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
success(`e2e test passed for ${app}`);
|
|
465
|
+
}
|
|
466
|
+
function mustLoadConfig(app) {
|
|
467
|
+
validateAppName(app);
|
|
468
|
+
const cfg = loadConfig(app);
|
|
469
|
+
if (!cfg)
|
|
470
|
+
throw new FleetError(`no config for ${app}; run: fleet backup register ${app} (or init-system for pseudo-apps)`);
|
|
471
|
+
return cfg;
|
|
472
|
+
}
|
|
473
|
+
function humanBytes(n) {
|
|
474
|
+
if (n < 1024)
|
|
475
|
+
return `${n}B`;
|
|
476
|
+
if (n < 1024 ** 2)
|
|
477
|
+
return `${(n / 1024).toFixed(1)}K`;
|
|
478
|
+
if (n < 1024 ** 3)
|
|
479
|
+
return `${(n / 1024 ** 2).toFixed(1)}M`;
|
|
480
|
+
return `${(n / 1024 ** 3).toFixed(2)}G`;
|
|
481
|
+
}
|
|
482
|
+
function shellEscape(s) {
|
|
483
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
484
|
+
}
|
|
485
|
+
/** reads a systemd-provided credential, or throws a clear error. */
|
|
486
|
+
function readCredential(name) {
|
|
487
|
+
const dir = process.env.CREDENTIALS_DIRECTORY;
|
|
488
|
+
if (!dir)
|
|
489
|
+
throw new FleetError('CREDENTIALS_DIRECTORY not set — run via the systemd unit');
|
|
490
|
+
return readFileSync(`${dir}/${name}`, 'utf-8').trim();
|
|
491
|
+
}
|
|
492
|
+
function cmdServe(args) {
|
|
493
|
+
if (args.includes('--setup-totp')) {
|
|
494
|
+
const secret = generateSecret();
|
|
495
|
+
const uri = totpUri(secret, loadOperator().username, 'fleet-backups');
|
|
496
|
+
process.stdout.write(`1. seal this secret into the credstore (root):\n` +
|
|
497
|
+
` printf '%s' '${secret}' | systemd-creds encrypt --name=mx-totp - /etc/credstore.encrypted/mx-totp\n\n` +
|
|
498
|
+
`2. add this to 1Password / your authenticator:\n ${uri}\n`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const port = 4322;
|
|
502
|
+
const totpSecret = readCredential('mx-totp');
|
|
503
|
+
const sessionSecret = readCredential('mx-session');
|
|
504
|
+
startServer({ port, totpSecret, sessionSecret })
|
|
505
|
+
.then(() => info(`backup explorer listening on 127.0.0.1:${port}`))
|
|
506
|
+
.catch((e) => {
|
|
507
|
+
error(`backup explorer failed to start: ${e.message}`);
|
|
508
|
+
process.exitCode = 1;
|
|
509
|
+
});
|
|
510
|
+
}
|