@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,90 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FleetError } from '../errors.js';
|
|
4
|
+
import { execSafe } from '../exec.js';
|
|
5
|
+
export const SYSTEMD_UNIT_DIR = process.env.FLEET_SYSTEMD_UNIT_DIR ?? '/etc/systemd/system';
|
|
6
|
+
function onCalendarFor(schedule) {
|
|
7
|
+
if (schedule === 'hourly')
|
|
8
|
+
return 'hourly';
|
|
9
|
+
if (schedule === 'daily')
|
|
10
|
+
return 'daily';
|
|
11
|
+
if (schedule === 'weekly')
|
|
12
|
+
return 'weekly';
|
|
13
|
+
return schedule;
|
|
14
|
+
}
|
|
15
|
+
export function timerUnitName(app) {
|
|
16
|
+
return `fleet-backup@${app}.timer`;
|
|
17
|
+
}
|
|
18
|
+
export function serviceUnitName(app) {
|
|
19
|
+
return `fleet-backup@${app}.service`;
|
|
20
|
+
}
|
|
21
|
+
/** plans the timer + service units. returns the unit file contents so callers
|
|
22
|
+
* can show a dry-run. set apply=true to actually write+enable. */
|
|
23
|
+
export function installScheduleUnits(app, schedule, opts = {}) {
|
|
24
|
+
const sharedServicePath = join(SYSTEMD_UNIT_DIR, 'fleet-backup@.service');
|
|
25
|
+
const sharedServiceContent = sharedServiceUnit();
|
|
26
|
+
const timerPath = join(SYSTEMD_UNIT_DIR, timerUnitName(app));
|
|
27
|
+
const timerContent = perAppTimerUnit(app, schedule);
|
|
28
|
+
const sharedServiceWrote = !existsSync(sharedServicePath);
|
|
29
|
+
if (!opts.apply) {
|
|
30
|
+
return { timerPath, timerContent, sharedServicePath, sharedServiceContent, sharedServiceWrote };
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(SYSTEMD_UNIT_DIR)) {
|
|
33
|
+
mkdirSync(SYSTEMD_UNIT_DIR, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
if (sharedServiceWrote) {
|
|
36
|
+
writeFileSync(sharedServicePath, sharedServiceContent, { mode: 0o644 });
|
|
37
|
+
}
|
|
38
|
+
writeFileSync(timerPath, timerContent, { mode: 0o644 });
|
|
39
|
+
const reload = execSafe('systemctl', ['daemon-reload'], { timeout: 5_000 });
|
|
40
|
+
if (!reload.ok)
|
|
41
|
+
throw new FleetError(`systemctl daemon-reload failed: ${reload.stderr}`);
|
|
42
|
+
const enable = execSafe('systemctl', ['enable', '--now', timerUnitName(app)], { timeout: 5_000 });
|
|
43
|
+
if (!enable.ok)
|
|
44
|
+
throw new FleetError(`enable timer failed: ${enable.stderr}`);
|
|
45
|
+
return { timerPath, timerContent, sharedServicePath, sharedServiceContent, sharedServiceWrote };
|
|
46
|
+
}
|
|
47
|
+
export function disableSchedule(app) {
|
|
48
|
+
execSafe('systemctl', ['disable', '--now', timerUnitName(app)], { timeout: 5_000 });
|
|
49
|
+
}
|
|
50
|
+
function sharedServiceUnit() {
|
|
51
|
+
// the wrapper loads the rest backend url (with embedded user:pass) from
|
|
52
|
+
// systemd-creds (host-key sealed at rest) and exports it as
|
|
53
|
+
// FLEET_BACKUP_BASE_URL. fleet writes via the append-only rest backend.
|
|
54
|
+
// legacy sftp backend still works if the wrapper / credstore entry are
|
|
55
|
+
// absent.
|
|
56
|
+
return `[Unit]
|
|
57
|
+
Description=fleet backup for %i
|
|
58
|
+
Documentation=fleet backup --help
|
|
59
|
+
After=network-online.target docker.service wg-quick@wg0.service
|
|
60
|
+
Wants=network-online.target wg-quick@wg0.service
|
|
61
|
+
|
|
62
|
+
[Service]
|
|
63
|
+
Type=oneshot
|
|
64
|
+
LoadCredentialEncrypted=mx-url:/etc/credstore.encrypted/mx-url
|
|
65
|
+
ExecStart=/usr/local/sbin/fleet-backup-wrapper %i
|
|
66
|
+
# big snapshots (multi-gb dumps) need headroom; default 90s would kill them.
|
|
67
|
+
TimeoutStartSec=4h
|
|
68
|
+
TimeoutStopSec=5min
|
|
69
|
+
Nice=10
|
|
70
|
+
IOSchedulingClass=best-effort
|
|
71
|
+
IOSchedulingPriority=7
|
|
72
|
+
|
|
73
|
+
[Install]
|
|
74
|
+
WantedBy=multi-user.target
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
function perAppTimerUnit(app, schedule) {
|
|
78
|
+
return `[Unit]
|
|
79
|
+
Description=fleet backup timer for ${app}
|
|
80
|
+
|
|
81
|
+
[Timer]
|
|
82
|
+
OnCalendar=${onCalendarFor(schedule)}
|
|
83
|
+
Persistent=true
|
|
84
|
+
RandomizedDelaySec=10m
|
|
85
|
+
Unit=${serviceUnitName(app)}
|
|
86
|
+
|
|
87
|
+
[Install]
|
|
88
|
+
WantedBy=timers.target
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** classifies a snapshot-relative path as holding secret material or not.
|
|
2
|
+
* used by the backup explorer to refuse view/download of sensitive files
|
|
3
|
+
* while still allowing them to be restored to a staging dir. */
|
|
4
|
+
export type Sensitivity = 'sensitive' | 'normal';
|
|
5
|
+
export declare function classify(path: string): Sensitivity;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** classifies a snapshot-relative path as holding secret material or not.
|
|
2
|
+
* used by the backup explorer to refuse view/download of sensitive files
|
|
3
|
+
* while still allowing them to be restored to a staging dir. */
|
|
4
|
+
// matched case-insensitively against the full path.
|
|
5
|
+
const SENSITIVE_PATTERNS = [
|
|
6
|
+
// key material
|
|
7
|
+
/\/\.ssh\//,
|
|
8
|
+
/\/\.gnupg\//,
|
|
9
|
+
/^\/etc\/ssh\//,
|
|
10
|
+
/^\/etc\/letsencrypt\//,
|
|
11
|
+
/\.pem$/,
|
|
12
|
+
/\.key$/,
|
|
13
|
+
// cloud + credential stores
|
|
14
|
+
/\/\.aws\//,
|
|
15
|
+
/\/\.gcloud\//,
|
|
16
|
+
/\/\.azure\//,
|
|
17
|
+
/\/\.kube\//,
|
|
18
|
+
/\/\.docker\//,
|
|
19
|
+
/\/\.secrets\//,
|
|
20
|
+
/\/credentials/,
|
|
21
|
+
/\/\.token/,
|
|
22
|
+
/\/\.npmrc$/,
|
|
23
|
+
/\/\.git-credentials$/,
|
|
24
|
+
// fleet + agent secrets
|
|
25
|
+
/\/\.claude/,
|
|
26
|
+
/^\/var\/lib\/fleet\//,
|
|
27
|
+
// database dumps
|
|
28
|
+
/\.pg\.sql$/,
|
|
29
|
+
/\.mysql\.sql$/,
|
|
30
|
+
/\.mongo\.archive$/,
|
|
31
|
+
/\.rdb$/,
|
|
32
|
+
/\.sql$/,
|
|
33
|
+
];
|
|
34
|
+
export function classify(path) {
|
|
35
|
+
const p = path.toLowerCase();
|
|
36
|
+
return SENSITIVE_PATTERNS.some(re => re.test(p)) ? 'sensitive' : 'normal';
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { listConfiguredApps, loadConfig } from './config.js';
|
|
2
|
+
import { listSnapshots, stats, isAppendOnly } from './repo.js';
|
|
3
|
+
/** gathers per-app snapshot counts, last-snapshot time and repo size. */
|
|
4
|
+
export function buildStatusReport() {
|
|
5
|
+
const apps = listConfiguredApps();
|
|
6
|
+
const entries = [];
|
|
7
|
+
for (const app of apps) {
|
|
8
|
+
const cfg = loadConfig(app);
|
|
9
|
+
if (!cfg)
|
|
10
|
+
continue;
|
|
11
|
+
const snaps = listSnapshots(app);
|
|
12
|
+
const last = snaps[snaps.length - 1];
|
|
13
|
+
const st = stats(app);
|
|
14
|
+
entries.push({
|
|
15
|
+
app,
|
|
16
|
+
schedule: cfg.schedule,
|
|
17
|
+
disabled: !!cfg.disabled,
|
|
18
|
+
snapshotCount: snaps.length,
|
|
19
|
+
lastSnapshotAt: last?.time ?? null,
|
|
20
|
+
totalSize: st?.totalSize ?? null,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
generatedAt: new Date().toISOString(),
|
|
25
|
+
backend: (process.env.FLEET_BACKUP_BASE_URL ?? '').startsWith('rest:') ? 'rest' : 'sftp',
|
|
26
|
+
appendOnly: isAppendOnly(),
|
|
27
|
+
apps: entries,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** read-only backup status dashboard. rendered to static html by a systemd
|
|
2
|
+
* timer and served at the /backups route (ip-restricted in nginx). */
|
|
3
|
+
export interface StatusEntry {
|
|
4
|
+
app: string;
|
|
5
|
+
schedule: string;
|
|
6
|
+
disabled: boolean;
|
|
7
|
+
snapshotCount: number;
|
|
8
|
+
lastSnapshotAt: string | null;
|
|
9
|
+
totalSize: number | null;
|
|
10
|
+
}
|
|
11
|
+
export interface StatusReport {
|
|
12
|
+
generatedAt: string;
|
|
13
|
+
backend: 'rest' | 'sftp';
|
|
14
|
+
appendOnly: boolean;
|
|
15
|
+
apps: StatusEntry[];
|
|
16
|
+
}
|
|
17
|
+
export type Health = 'ok' | 'stale' | 'missing' | 'disabled';
|
|
18
|
+
export declare function healthOf(entry: StatusEntry, now?: number): Health;
|
|
19
|
+
export declare function humanBytes(n: number | null): string;
|
|
20
|
+
export declare function relativeTime(iso: string | null, now?: number): string;
|
|
21
|
+
/** renders the full standalone html page. no external assets — inline css so
|
|
22
|
+
* it works as a flat file behind nginx. */
|
|
23
|
+
export declare function renderStatusHtml(report: StatusReport, now?: number): string;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** read-only backup status dashboard. rendered to static html by a systemd
|
|
2
|
+
* timer and served at the /backups route (ip-restricted in nginx). */
|
|
3
|
+
/** how long after the expected cadence a backup is considered stale.
|
|
4
|
+
* generous — covers the timer's randomised delay plus a missed run. */
|
|
5
|
+
function stalenessThresholdMs(schedule) {
|
|
6
|
+
const hour = 3_600_000;
|
|
7
|
+
if (schedule === 'hourly')
|
|
8
|
+
return 3 * hour;
|
|
9
|
+
if (schedule === 'weekly')
|
|
10
|
+
return 8.5 * 24 * hour;
|
|
11
|
+
if (schedule.includes('00/3'))
|
|
12
|
+
return 7 * hour;
|
|
13
|
+
if (schedule.includes('00/6'))
|
|
14
|
+
return 13 * hour;
|
|
15
|
+
if (schedule.includes('00/12'))
|
|
16
|
+
return 25 * hour;
|
|
17
|
+
// daily and anything unrecognised
|
|
18
|
+
return 28 * hour;
|
|
19
|
+
}
|
|
20
|
+
export function healthOf(entry, now = Date.now()) {
|
|
21
|
+
if (entry.disabled)
|
|
22
|
+
return 'disabled';
|
|
23
|
+
if (!entry.lastSnapshotAt || entry.snapshotCount === 0)
|
|
24
|
+
return 'missing';
|
|
25
|
+
const age = now - new Date(entry.lastSnapshotAt).getTime();
|
|
26
|
+
return age > stalenessThresholdMs(entry.schedule) ? 'stale' : 'ok';
|
|
27
|
+
}
|
|
28
|
+
export function humanBytes(n) {
|
|
29
|
+
if (n === null)
|
|
30
|
+
return '—';
|
|
31
|
+
if (n < 1024)
|
|
32
|
+
return `${n} B`;
|
|
33
|
+
if (n < 1024 ** 2)
|
|
34
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
35
|
+
if (n < 1024 ** 3)
|
|
36
|
+
return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
37
|
+
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
|
38
|
+
}
|
|
39
|
+
export function relativeTime(iso, now = Date.now()) {
|
|
40
|
+
if (!iso)
|
|
41
|
+
return 'never';
|
|
42
|
+
const diff = now - new Date(iso).getTime();
|
|
43
|
+
const min = Math.floor(diff / 60_000);
|
|
44
|
+
if (min < 1)
|
|
45
|
+
return 'just now';
|
|
46
|
+
if (min < 60)
|
|
47
|
+
return `${min}m ago`;
|
|
48
|
+
const hr = Math.floor(min / 60);
|
|
49
|
+
if (hr < 48)
|
|
50
|
+
return `${hr}h ago`;
|
|
51
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
52
|
+
}
|
|
53
|
+
function esc(s) {
|
|
54
|
+
return s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
55
|
+
}
|
|
56
|
+
/** renders the full standalone html page. no external assets — inline css so
|
|
57
|
+
* it works as a flat file behind nginx. */
|
|
58
|
+
export function renderStatusHtml(report, now = Date.now()) {
|
|
59
|
+
const apps = [...report.apps].sort((a, b) => a.app.localeCompare(b.app));
|
|
60
|
+
const counts = { ok: 0, stale: 0, missing: 0, disabled: 0 };
|
|
61
|
+
for (const a of apps)
|
|
62
|
+
counts[healthOf(a, now)]++;
|
|
63
|
+
const totalBytes = apps.reduce((s, a) => s + (a.totalSize ?? 0), 0);
|
|
64
|
+
const rows = apps.map(a => {
|
|
65
|
+
const h = healthOf(a, now);
|
|
66
|
+
return ` <tr class="h-${h}">
|
|
67
|
+
<td class="dot"><span class="d d-${h}" title="${h}"></span></td>
|
|
68
|
+
<td class="app">${esc(a.app)}</td>
|
|
69
|
+
<td>${esc(a.schedule)}</td>
|
|
70
|
+
<td class="num">${a.snapshotCount}</td>
|
|
71
|
+
<td>${esc(relativeTime(a.lastSnapshotAt, now))}</td>
|
|
72
|
+
<td class="num">${humanBytes(a.totalSize)}</td>
|
|
73
|
+
</tr>`;
|
|
74
|
+
}).join('\n');
|
|
75
|
+
return `<!doctype html>
|
|
76
|
+
<html lang="en">
|
|
77
|
+
<head>
|
|
78
|
+
<meta charset="utf-8">
|
|
79
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
80
|
+
<meta http-equiv="refresh" content="300">
|
|
81
|
+
<title>fleet backups</title>
|
|
82
|
+
<style>
|
|
83
|
+
:root { color-scheme: dark; }
|
|
84
|
+
* { box-sizing: border-box; }
|
|
85
|
+
body {
|
|
86
|
+
margin: 0; padding: 2rem;
|
|
87
|
+
background: #0d1117; color: #c9d1d9;
|
|
88
|
+
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
89
|
+
}
|
|
90
|
+
h1 { font-size: 1.1rem; margin: 0 0 0.25rem; color: #e6edf3; }
|
|
91
|
+
.meta { color: #8b949e; margin-bottom: 1.25rem; font-size: 0.8rem; }
|
|
92
|
+
.badges { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
|
|
93
|
+
.badge {
|
|
94
|
+
padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.8rem;
|
|
95
|
+
background: #161b22; border: 1px solid #30363d;
|
|
96
|
+
}
|
|
97
|
+
.badge b { color: #e6edf3; }
|
|
98
|
+
.badge.ok b { color: #3fb950; }
|
|
99
|
+
.badge.stale b { color: #d29922; }
|
|
100
|
+
.badge.missing b { color: #f85149; }
|
|
101
|
+
table { border-collapse: collapse; width: 100%; max-width: 880px; }
|
|
102
|
+
th, td { text-align: left; padding: 0.45rem 0.8rem; border-bottom: 1px solid #21262d; }
|
|
103
|
+
th { color: #8b949e; font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
104
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
105
|
+
td.app { color: #e6edf3; }
|
|
106
|
+
td.dot { width: 1.5rem; }
|
|
107
|
+
.d { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
|
108
|
+
.d-ok { background: #3fb950; }
|
|
109
|
+
.d-stale { background: #d29922; }
|
|
110
|
+
.d-missing { background: #f85149; }
|
|
111
|
+
.d-disabled { background: #484f58; }
|
|
112
|
+
tr.h-stale td.app { color: #d29922; }
|
|
113
|
+
tr.h-missing td.app { color: #f85149; }
|
|
114
|
+
tr.h-disabled { opacity: 0.5; }
|
|
115
|
+
tfoot td { border-top: 2px solid #30363d; border-bottom: none; color: #8b949e; padding-top: 0.7rem; }
|
|
116
|
+
</style>
|
|
117
|
+
</head>
|
|
118
|
+
<body>
|
|
119
|
+
<h1>fleet backups</h1>
|
|
120
|
+
<div class="meta">
|
|
121
|
+
generated ${esc(report.generatedAt)} ·
|
|
122
|
+
backend <b>${esc(report.backend)}</b> ·
|
|
123
|
+
${report.appendOnly ? 'append-only enforced' : 'append-only OFF'}
|
|
124
|
+
</div>
|
|
125
|
+
<div class="badges">
|
|
126
|
+
<span class="badge ok"><b>${counts.ok}</b> ok</span>
|
|
127
|
+
<span class="badge stale"><b>${counts.stale}</b> stale</span>
|
|
128
|
+
<span class="badge missing"><b>${counts.missing}</b> missing</span>
|
|
129
|
+
<span class="badge"><b>${counts.disabled}</b> disabled</span>
|
|
130
|
+
</div>
|
|
131
|
+
<table>
|
|
132
|
+
<thead>
|
|
133
|
+
<tr><th></th><th>app</th><th>schedule</th><th>snaps</th><th>last</th><th>size</th></tr>
|
|
134
|
+
</thead>
|
|
135
|
+
<tbody>
|
|
136
|
+
${rows}
|
|
137
|
+
</tbody>
|
|
138
|
+
<tfoot>
|
|
139
|
+
<tr><td colspan="3">${apps.length} apps</td><td class="num"></td><td></td><td class="num">${humanBytes(totalBytes)}</td></tr>
|
|
140
|
+
</tfoot>
|
|
141
|
+
</table>
|
|
142
|
+
</body>
|
|
143
|
+
</html>
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AppBackupConfig } from './types.js';
|
|
2
|
+
/** the `system` pseudo-app: os, infra config, all the things needed to rebuild
|
|
3
|
+
* this host from a fresh ubuntu install + the data on the backup vps. */
|
|
4
|
+
export declare const SYSTEM_PATHS: string[];
|
|
5
|
+
export declare const ROOT_HOME_PATHS: string[];
|
|
6
|
+
/** excludes for root-home and user-home: claude code regeneratable state
|
|
7
|
+
* (sessions, plugin caches, telemetry) and tool caches that take GBs but
|
|
8
|
+
* contain nothing the user authored. credentials.json, settings, hooks,
|
|
9
|
+
* skills, plans, mcp.json all stay. */
|
|
10
|
+
export declare const HOME_EXCLUDES: string[];
|
|
11
|
+
/** the operator's home-dir paths worth backing up — dotfiles and agent
|
|
12
|
+
* state, never app working directories. derived from the configured home. */
|
|
13
|
+
export declare function userHomePaths(homeDir: string): string[];
|
|
14
|
+
export declare const USER_HOME_EXCLUDES: string[];
|
|
15
|
+
export declare function systemConfig(): AppBackupConfig;
|
|
16
|
+
export declare function rootHomeConfig(): AppBackupConfig;
|
|
17
|
+
/** shared-cluster dumps. these are safety nets — they capture every database
|
|
18
|
+
* in the shared container as one big stream. per-app preDump entries are
|
|
19
|
+
* preferred for routine restore granularity (run hourly), but these run
|
|
20
|
+
* daily and catch DBs the per-app list doesn't enumerate. */
|
|
21
|
+
export declare function sharedPostgresConfig(): AppBackupConfig;
|
|
22
|
+
export declare function sharedMysqlConfig(): AppBackupConfig;
|
|
23
|
+
export declare function sharedMongoConfig(): AppBackupConfig;
|
|
24
|
+
export declare function userHomeConfig(): AppBackupConfig;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { loadOperator } from '../operator.js';
|
|
2
|
+
import { DEFAULT_RETENTION } from './config.js';
|
|
3
|
+
/** the `system` pseudo-app: os, infra config, all the things needed to rebuild
|
|
4
|
+
* this host from a fresh ubuntu install + the data on the backup vps. */
|
|
5
|
+
export const SYSTEM_PATHS = [
|
|
6
|
+
'/etc/nginx',
|
|
7
|
+
'/etc/letsencrypt',
|
|
8
|
+
'/etc/systemd/system',
|
|
9
|
+
'/etc/systemd/resolved.conf.d',
|
|
10
|
+
'/etc/fleet',
|
|
11
|
+
'/etc/iptables',
|
|
12
|
+
'/etc/truewaf',
|
|
13
|
+
'/etc/modsecurity',
|
|
14
|
+
'/etc/ssh',
|
|
15
|
+
'/etc/sudoers',
|
|
16
|
+
'/etc/sudoers.d',
|
|
17
|
+
'/etc/fail2ban',
|
|
18
|
+
'/etc/cron.d',
|
|
19
|
+
'/etc/crontab',
|
|
20
|
+
'/etc/netplan',
|
|
21
|
+
'/etc/hosts',
|
|
22
|
+
'/etc/hostname',
|
|
23
|
+
'/etc/timezone',
|
|
24
|
+
'/etc/apt/sources.list',
|
|
25
|
+
'/etc/apt/sources.list.d',
|
|
26
|
+
'/etc/apt/keyrings',
|
|
27
|
+
'/etc/sysctl.conf',
|
|
28
|
+
'/etc/sysctl.d',
|
|
29
|
+
'/etc/security',
|
|
30
|
+
'/etc/php',
|
|
31
|
+
'/etc/guardian',
|
|
32
|
+
'/etc/cloud',
|
|
33
|
+
'/etc/default',
|
|
34
|
+
'/etc/ntpsec',
|
|
35
|
+
'/etc/logrotate.d',
|
|
36
|
+
'/etc/docker/daemon.json',
|
|
37
|
+
'/etc/nftables.conf',
|
|
38
|
+
'/var/lib/fleet',
|
|
39
|
+
'/var/lib/letsencrypt',
|
|
40
|
+
'/var/lib/fail2ban',
|
|
41
|
+
'/usr/local/bin',
|
|
42
|
+
'/usr/local/sbin',
|
|
43
|
+
'/opt/coreruleset',
|
|
44
|
+
'/root/firewall',
|
|
45
|
+
];
|
|
46
|
+
export const ROOT_HOME_PATHS = [
|
|
47
|
+
'/root/.ssh',
|
|
48
|
+
'/root/.docker/config.json',
|
|
49
|
+
'/root/.docker/.token_seed',
|
|
50
|
+
'/root/.secrets',
|
|
51
|
+
'/root/.gnupg',
|
|
52
|
+
'/root/.aws',
|
|
53
|
+
'/root/.gcloud',
|
|
54
|
+
'/root/.azure',
|
|
55
|
+
'/root/.kube',
|
|
56
|
+
'/root/.gitconfig',
|
|
57
|
+
'/root/.npmrc',
|
|
58
|
+
'/root/.bashrc',
|
|
59
|
+
'/root/.bash_profile',
|
|
60
|
+
'/root/.bash_history',
|
|
61
|
+
'/root/.claude',
|
|
62
|
+
'/root/.claude.json',
|
|
63
|
+
'/root/.mcp.json',
|
|
64
|
+
'/root/.gmail-mcp/tokens',
|
|
65
|
+
'/root/.cargo/credentials.toml',
|
|
66
|
+
'/root/.cargo/config.toml',
|
|
67
|
+
'/root/.pm2/dump.pm2',
|
|
68
|
+
'/root/.pm2/module_conf.json',
|
|
69
|
+
];
|
|
70
|
+
/** excludes for root-home and user-home: claude code regeneratable state
|
|
71
|
+
* (sessions, plugin caches, telemetry) and tool caches that take GBs but
|
|
72
|
+
* contain nothing the user authored. credentials.json, settings, hooks,
|
|
73
|
+
* skills, plans, mcp.json all stay. */
|
|
74
|
+
export const HOME_EXCLUDES = [
|
|
75
|
+
'**/.claude/projects',
|
|
76
|
+
'**/.claude/plugins/data',
|
|
77
|
+
'**/.claude/plugins/cache',
|
|
78
|
+
'**/.claude/plugins/marketplaces',
|
|
79
|
+
'**/.claude/plugins/install-counts-cache.json',
|
|
80
|
+
'**/.claude/file-history',
|
|
81
|
+
'**/.claude/paste-cache',
|
|
82
|
+
'**/.claude/telemetry',
|
|
83
|
+
'**/.claude/todos',
|
|
84
|
+
'**/.claude/tasks',
|
|
85
|
+
'**/.claude/backups',
|
|
86
|
+
'**/.claude/session-env',
|
|
87
|
+
'**/.claude/sessions',
|
|
88
|
+
'**/.claude/cache',
|
|
89
|
+
'**/.claude/debug',
|
|
90
|
+
'**/.claude/shell-snapshots',
|
|
91
|
+
'**/.claude/usage-data',
|
|
92
|
+
'**/.claude/cc-counter',
|
|
93
|
+
'**/.claude/statsig',
|
|
94
|
+
'**/.claude/stats-cache.json',
|
|
95
|
+
'**/.claude/history.jsonl',
|
|
96
|
+
'**/.claude/ide',
|
|
97
|
+
'**/.claude/downloads',
|
|
98
|
+
'**/.claude/mcp-needs-auth-cache.json',
|
|
99
|
+
'**/.claude/.last-cleanup',
|
|
100
|
+
'**/.claude/hooks/.last_test_run_*',
|
|
101
|
+
'*.log',
|
|
102
|
+
];
|
|
103
|
+
/** the operator's home-dir paths worth backing up — dotfiles and agent
|
|
104
|
+
* state, never app working directories. derived from the configured home. */
|
|
105
|
+
export function userHomePaths(homeDir) {
|
|
106
|
+
return [
|
|
107
|
+
`${homeDir}/.ssh`,
|
|
108
|
+
`${homeDir}/.gitconfig`,
|
|
109
|
+
`${homeDir}/.docker/config.json`,
|
|
110
|
+
`${homeDir}/.config/gh`,
|
|
111
|
+
`${homeDir}/.config/op`,
|
|
112
|
+
`${homeDir}/.aws`,
|
|
113
|
+
`${homeDir}/.gnupg`,
|
|
114
|
+
`${homeDir}/.terraform.d`,
|
|
115
|
+
`${homeDir}/.claude`,
|
|
116
|
+
`${homeDir}/.claude.json`,
|
|
117
|
+
`${homeDir}/.mcp.json`,
|
|
118
|
+
`${homeDir}/.bashrc`,
|
|
119
|
+
`${homeDir}/.bash_history`,
|
|
120
|
+
`${homeDir}/.profile`,
|
|
121
|
+
`${homeDir}/.local/bin`,
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
export const USER_HOME_EXCLUDES = [
|
|
125
|
+
...HOME_EXCLUDES,
|
|
126
|
+
'*.cache',
|
|
127
|
+
'.npm',
|
|
128
|
+
'.yarn',
|
|
129
|
+
'.cargo/registry',
|
|
130
|
+
'.cargo/git',
|
|
131
|
+
'.gradle/caches',
|
|
132
|
+
'.local/share',
|
|
133
|
+
'node_modules',
|
|
134
|
+
];
|
|
135
|
+
export function systemConfig() {
|
|
136
|
+
return {
|
|
137
|
+
app: 'system',
|
|
138
|
+
schedule: 'daily',
|
|
139
|
+
paths: SYSTEM_PATHS,
|
|
140
|
+
exclude: ['*.log', '*.pid', '.cache'],
|
|
141
|
+
retention: DEFAULT_RETENTION,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function rootHomeConfig() {
|
|
145
|
+
return {
|
|
146
|
+
app: 'root-home',
|
|
147
|
+
schedule: 'daily',
|
|
148
|
+
paths: ROOT_HOME_PATHS,
|
|
149
|
+
exclude: HOME_EXCLUDES,
|
|
150
|
+
retention: DEFAULT_RETENTION,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** shared-cluster dumps. these are safety nets — they capture every database
|
|
154
|
+
* in the shared container as one big stream. per-app preDump entries are
|
|
155
|
+
* preferred for routine restore granularity (run hourly), but these run
|
|
156
|
+
* daily and catch DBs the per-app list doesn't enumerate. */
|
|
157
|
+
export function sharedPostgresConfig() {
|
|
158
|
+
return {
|
|
159
|
+
app: 'shared-postgres',
|
|
160
|
+
schedule: 'daily',
|
|
161
|
+
paths: [],
|
|
162
|
+
exclude: [],
|
|
163
|
+
// postgres_user=postgres is set in compose env; pg_dumpall uses unix-socket
|
|
164
|
+
// peer auth so no password needed inside the container.
|
|
165
|
+
preDump: { type: 'postgres', container: 'shared-postgres', user: 'postgres' },
|
|
166
|
+
retention: { daily: 14, weekly: 8, monthly: 12 },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function sharedMysqlConfig() {
|
|
170
|
+
return {
|
|
171
|
+
app: 'shared-mysql',
|
|
172
|
+
schedule: 'daily',
|
|
173
|
+
paths: [],
|
|
174
|
+
exclude: [],
|
|
175
|
+
// shared-mysql uses docker secrets — password lives at the file path below,
|
|
176
|
+
// not in an env var.
|
|
177
|
+
preDump: {
|
|
178
|
+
type: 'mysql',
|
|
179
|
+
container: 'shared-mysql',
|
|
180
|
+
user: 'root',
|
|
181
|
+
passwordFile: '/run/secrets/mysql_root_password',
|
|
182
|
+
},
|
|
183
|
+
retention: { daily: 14, weekly: 8, monthly: 12 },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export function sharedMongoConfig() {
|
|
187
|
+
return {
|
|
188
|
+
app: 'shared-mongodb',
|
|
189
|
+
schedule: 'daily',
|
|
190
|
+
paths: [],
|
|
191
|
+
exclude: [],
|
|
192
|
+
preDump: {
|
|
193
|
+
type: 'mongo',
|
|
194
|
+
container: 'shared-mongodb',
|
|
195
|
+
user: 'root',
|
|
196
|
+
passwordFile: '/run/secrets/mongo_root_password',
|
|
197
|
+
},
|
|
198
|
+
retention: { daily: 14, weekly: 8, monthly: 12 },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export function userHomeConfig() {
|
|
202
|
+
return {
|
|
203
|
+
app: 'user-home',
|
|
204
|
+
schedule: 'daily',
|
|
205
|
+
paths: userHomePaths(loadOperator().homeDir),
|
|
206
|
+
exclude: USER_HOME_EXCLUDES,
|
|
207
|
+
retention: DEFAULT_RETENTION,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** new random 20-byte secret, base32-encoded for authenticator apps. */
|
|
2
|
+
export declare function generateSecret(): string;
|
|
3
|
+
/** the 6-digit TOTP code for a base32 secret at a given epoch-ms time. */
|
|
4
|
+
export declare function totpCode(secretB32: string, atMs?: number): string;
|
|
5
|
+
/** true if `code` is valid for the secret within a +/-1 step window. */
|
|
6
|
+
export declare function verifyTotp(secretB32: string, code: string, atMs?: number): boolean;
|
|
7
|
+
/** otpauth:// enrolment URI — paste into 1Password or an authenticator app. */
|
|
8
|
+
export declare function totpUri(secretB32: string, label: string, issuer: string): string;
|
|
9
|
+
export interface SessionPayload {
|
|
10
|
+
/** epoch-ms expiry. */
|
|
11
|
+
exp: number;
|
|
12
|
+
}
|
|
13
|
+
/** signs a session payload as `<body>.<hmac>` (hmac-sha256). */
|
|
14
|
+
export declare function signSession(payload: SessionPayload, secret: string): string;
|
|
15
|
+
/** verifies a session cookie; returns the payload or null if invalid/expired. */
|
|
16
|
+
export declare function verifySession(cookie: string, secret: string, nowMs?: number): SessionPayload | null;
|