@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,68 @@
|
|
|
1
|
+
import { c, icon } from '../../../ui/output.js';
|
|
2
|
+
const SEVERITY_ORDER = ['CRITICAL', 'WARN', 'INFO'];
|
|
3
|
+
export function severityIcon(severity) {
|
|
4
|
+
switch (severity) {
|
|
5
|
+
case 'CRITICAL': return icon.err;
|
|
6
|
+
case 'WARN': return icon.warn;
|
|
7
|
+
default: return icon.info;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function severityHeading(severity) {
|
|
11
|
+
switch (severity) {
|
|
12
|
+
case 'CRITICAL': return `${c.red}${c.bold}Critical — will be rejected`;
|
|
13
|
+
case 'WARN': return `${c.yellow}${c.bold}Warning — high rejection risk`;
|
|
14
|
+
default: return `${c.dim}${c.bold}Info — best practice`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// render a parsed greenlight report into printable terminal lines, grouped by
|
|
18
|
+
// severity with a verdict footer. ansi colours come from ui/output.
|
|
19
|
+
export function formatReport(report) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
if (report.app_name)
|
|
22
|
+
lines.push(` ${c.dim}App${c.reset} ${report.app_name}`);
|
|
23
|
+
if (report.bundle_id)
|
|
24
|
+
lines.push(` ${c.dim}Bundle${c.reset} ${report.bundle_id}`);
|
|
25
|
+
lines.push(` ${c.dim}Privacy manifest${c.reset} ` +
|
|
26
|
+
(report.has_privacy_info ? `${icon.ok} present` : `${icon.warn} missing`));
|
|
27
|
+
if (report.tracking_sdks && report.tracking_sdks.length > 0) {
|
|
28
|
+
lines.push(` ${c.dim}Tracking SDKs${c.reset} ${report.tracking_sdks.join(', ')}`);
|
|
29
|
+
}
|
|
30
|
+
if (report.detected_apis && report.detected_apis.length > 0) {
|
|
31
|
+
lines.push(` ${c.dim}Required-reason APIs${c.reset} ${report.detected_apis.join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
for (const severity of SEVERITY_ORDER) {
|
|
34
|
+
const group = report.findings.filter(f => f.severity === severity);
|
|
35
|
+
if (group.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
lines.push('');
|
|
38
|
+
lines.push(`${severityHeading(severity)} (${group.length})${c.reset}`);
|
|
39
|
+
for (const finding of group)
|
|
40
|
+
lines.push(...formatFinding(finding));
|
|
41
|
+
}
|
|
42
|
+
const s = report.summary;
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(` ${c.dim}${'-'.repeat(48)}${c.reset}`);
|
|
45
|
+
if (s.passed) {
|
|
46
|
+
lines.push(` ${c.green}${c.bold}GREENLIT${c.reset} — no critical issues found`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lines.push(` ${c.red}${c.bold}NOT READY${c.reset} — ${s.critical} critical issue(s) must be fixed`);
|
|
50
|
+
}
|
|
51
|
+
lines.push(` ${c.dim}${s.total} findings: ${s.critical} critical, ${s.warns} warn, ` +
|
|
52
|
+
`${s.infos} info — scanned in ${report.elapsed}${c.reset}`);
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
function formatFinding(finding) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const ref = finding.guideline ? `${c.cyan}§${finding.guideline}${c.reset} ` : '';
|
|
58
|
+
out.push(` ${severityIcon(finding.severity)} ${c.dim}[${finding.source}]${c.reset} ` +
|
|
59
|
+
`${ref}${c.bold}${finding.title}${c.reset}`);
|
|
60
|
+
if (finding.file) {
|
|
61
|
+
const loc = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
62
|
+
out.push(` ${c.dim}${loc}${c.reset}`);
|
|
63
|
+
}
|
|
64
|
+
out.push(` ${c.dim}${finding.detail}${c.reset}`);
|
|
65
|
+
if (finding.fix)
|
|
66
|
+
out.push(` ${c.green}fix:${c.reset} ${finding.fix}`);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AuditIgnoreRule, GreenlightReport } from './types.js';
|
|
2
|
+
export interface SuppressionResult {
|
|
3
|
+
report: GreenlightReport;
|
|
4
|
+
suppressed: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function applySuppressions(report: GreenlightReport, target: string, rules: AuditIgnoreRule[]): SuppressionResult;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// true when `rule` dismisses `finding` for the audit target `target`.
|
|
2
|
+
function ruleCovers(rule, finding, target) {
|
|
3
|
+
if (rule.target && rule.target !== target)
|
|
4
|
+
return false;
|
|
5
|
+
if (rule.title !== finding.title)
|
|
6
|
+
return false;
|
|
7
|
+
if (rule.contains) {
|
|
8
|
+
const haystack = `${finding.file ?? ''}\n${finding.code ?? ''}`;
|
|
9
|
+
if (!haystack.includes(rule.contains))
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function recomputeSummary(findings) {
|
|
15
|
+
return {
|
|
16
|
+
total: findings.length,
|
|
17
|
+
critical: findings.filter(f => f.severity === 'CRITICAL').length,
|
|
18
|
+
warns: findings.filter(f => f.severity === 'WARN').length,
|
|
19
|
+
infos: findings.filter(f => f.severity === 'INFO').length,
|
|
20
|
+
passed: findings.every(f => f.severity !== 'CRITICAL'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// drops findings covered by an active ignore rule and recomputes the summary
|
|
24
|
+
// so the verdict reflects what is left. this is how confirmed scanner false
|
|
25
|
+
// positives are dismissed — each rule carries a written reason.
|
|
26
|
+
export function applySuppressions(report, target, rules) {
|
|
27
|
+
if (rules.length === 0)
|
|
28
|
+
return { report, suppressed: 0 };
|
|
29
|
+
const kept = report.findings.filter(finding => !rules.some(rule => ruleCovers(rule, finding, target)));
|
|
30
|
+
const suppressed = report.findings.length - kept.length;
|
|
31
|
+
if (suppressed === 0)
|
|
32
|
+
return { report, suppressed: 0 };
|
|
33
|
+
return {
|
|
34
|
+
report: { ...report, findings: kept, summary: recomputeSummary(kept) },
|
|
35
|
+
suppressed,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { load, findApp } from '../registry.js';
|
|
4
|
+
import { FleetError } from '../errors.js';
|
|
5
|
+
// resolve an audit target to the mobile project directory to scan.
|
|
6
|
+
//
|
|
7
|
+
// a target is either an existing directory path (used as-is) or a registered
|
|
8
|
+
// fleet app name. for a registered app the convention is a `mobile/` subdir of
|
|
9
|
+
// its compose root — that is where an expo / ios project lives in a repo whose
|
|
10
|
+
// root holds docker-compose — falling back to the compose root when there is
|
|
11
|
+
// no such subdir.
|
|
12
|
+
export function resolveAuditTarget(target) {
|
|
13
|
+
const asPath = resolve(target);
|
|
14
|
+
if (existsSync(asPath) && statSync(asPath).isDirectory()) {
|
|
15
|
+
return { target, projectPath: asPath };
|
|
16
|
+
}
|
|
17
|
+
const app = findApp(load(), target);
|
|
18
|
+
if (!app) {
|
|
19
|
+
throw new FleetError(`Audit target "${target}" is neither an existing directory nor a registered app.`);
|
|
20
|
+
}
|
|
21
|
+
const mobileDir = join(app.composePath, 'mobile');
|
|
22
|
+
return {
|
|
23
|
+
target: app.name,
|
|
24
|
+
projectPath: existsSync(mobileDir) ? mobileDir : app.composePath,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type GreenlightSeverity = 'CRITICAL' | 'WARN' | 'INFO';
|
|
2
|
+
export type GreenlightSource = 'metadata' | 'codescan' | 'privacy' | 'ipa';
|
|
3
|
+
export interface GreenlightFinding {
|
|
4
|
+
source: GreenlightSource | string;
|
|
5
|
+
severity: GreenlightSeverity | string;
|
|
6
|
+
guideline?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
fix?: string;
|
|
10
|
+
file?: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
code?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface GreenlightSummary {
|
|
15
|
+
total: number;
|
|
16
|
+
critical: number;
|
|
17
|
+
warns: number;
|
|
18
|
+
infos: number;
|
|
19
|
+
passed: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface GreenlightReport {
|
|
22
|
+
project_path: string;
|
|
23
|
+
ipa_path?: string;
|
|
24
|
+
app_name?: string;
|
|
25
|
+
bundle_id?: string;
|
|
26
|
+
has_privacy_info: boolean;
|
|
27
|
+
detected_apis?: string[];
|
|
28
|
+
tracking_sdks?: string[];
|
|
29
|
+
findings: GreenlightFinding[];
|
|
30
|
+
summary: GreenlightSummary;
|
|
31
|
+
elapsed: string;
|
|
32
|
+
}
|
|
33
|
+
export interface AuditRecord {
|
|
34
|
+
target: string;
|
|
35
|
+
projectPath: string;
|
|
36
|
+
ipaPath?: string;
|
|
37
|
+
ranAt: string;
|
|
38
|
+
report: GreenlightReport;
|
|
39
|
+
}
|
|
40
|
+
export interface AuditCache {
|
|
41
|
+
version: 1;
|
|
42
|
+
audits: Record<string, AuditRecord>;
|
|
43
|
+
}
|
|
44
|
+
export interface AuditIgnoreRule {
|
|
45
|
+
target?: string;
|
|
46
|
+
title: string;
|
|
47
|
+
contains?: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
addedAt: string;
|
|
50
|
+
}
|
|
51
|
+
export interface AuditConfig {
|
|
52
|
+
version: 1;
|
|
53
|
+
ignore: AuditIgnoreRule[];
|
|
54
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// shapes for the App Store compliance audit feature. the report types mirror
|
|
2
|
+
// the json emitted by `greenlight preflight --format json` (RevylAI/greenlight,
|
|
3
|
+
// internal/cli/preflight.go writePreflightJSON) — kept structurally identical
|
|
4
|
+
// so the binary's output deserialises straight into these.
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { StatusReport } from './statuspage.js';
|
|
2
|
+
import { SnapshotInfo } from './types.js';
|
|
3
|
+
import { TreeEntry } from './repo.js';
|
|
4
|
+
export interface RestoreResult {
|
|
5
|
+
target: string;
|
|
6
|
+
fileCount: number;
|
|
7
|
+
bytes: number;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface StagingDir {
|
|
11
|
+
path: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
age: string;
|
|
14
|
+
}
|
|
15
|
+
/** everything the router needs, injected so handlers stay pure + testable. */
|
|
16
|
+
export interface ApiContext {
|
|
17
|
+
now(): number;
|
|
18
|
+
totpSecret: string;
|
|
19
|
+
sessionSecret: string;
|
|
20
|
+
sessionTtlMs: number;
|
|
21
|
+
/** the deployment domain — the same-origin check accepts only this host. */
|
|
22
|
+
domain: string;
|
|
23
|
+
listApps(): string[];
|
|
24
|
+
statusReport(): StatusReport;
|
|
25
|
+
snapshots(app: string): SnapshotInfo[];
|
|
26
|
+
lsTree(app: string, snap: string, path: string): TreeEntry[];
|
|
27
|
+
fileMeta(app: string, snap: string, path: string): {
|
|
28
|
+
size: number;
|
|
29
|
+
sensitive: boolean;
|
|
30
|
+
} | null;
|
|
31
|
+
restore(app: string, snap: string, path: string): RestoreResult;
|
|
32
|
+
listStaging(): StagingDir[];
|
|
33
|
+
deleteStaging(path: string): void;
|
|
34
|
+
}
|
|
35
|
+
export interface ApiRequest {
|
|
36
|
+
method: string;
|
|
37
|
+
path: string;
|
|
38
|
+
query: Record<string, string>;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
body?: unknown;
|
|
41
|
+
cookies: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export type ApiResponse = {
|
|
44
|
+
kind: 'json';
|
|
45
|
+
status: number;
|
|
46
|
+
body: unknown;
|
|
47
|
+
setCookie?: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'html';
|
|
50
|
+
status: number;
|
|
51
|
+
body: string;
|
|
52
|
+
} | {
|
|
53
|
+
kind: 'stream';
|
|
54
|
+
status: number;
|
|
55
|
+
app: string;
|
|
56
|
+
snap: string;
|
|
57
|
+
path: string;
|
|
58
|
+
filename: string;
|
|
59
|
+
contentType: string;
|
|
60
|
+
disposition: 'inline' | 'attachment';
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'redirect';
|
|
63
|
+
status: number;
|
|
64
|
+
location: string;
|
|
65
|
+
};
|
|
66
|
+
export declare function handle(req: ApiRequest, ctx: ApiContext): ApiResponse;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { verifyTotp, signSession, verifySession } from './totp.js';
|
|
2
|
+
import { renderLoginPage, renderExplorerPage } from './browser-ui.js';
|
|
3
|
+
import { renderStatusHtml } from './statuspage.js';
|
|
4
|
+
import { classify } from './sensitive.js';
|
|
5
|
+
const SESSION_COOKIE = 'fleet_backup_session';
|
|
6
|
+
function json(status, body, setCookie) {
|
|
7
|
+
return { kind: 'json', status, body, setCookie };
|
|
8
|
+
}
|
|
9
|
+
function hasSession(req, ctx) {
|
|
10
|
+
const cookie = req.cookies[SESSION_COOKIE];
|
|
11
|
+
if (!cookie)
|
|
12
|
+
return false;
|
|
13
|
+
return verifySession(cookie, ctx.sessionSecret, ctx.now()) !== null;
|
|
14
|
+
}
|
|
15
|
+
/** /api/* must carry the csrf header, and write methods must carry an
|
|
16
|
+
* Origin header whose host matches our domain exactly.
|
|
17
|
+
*
|
|
18
|
+
* the custom `x-fleet-backup: 1` header is the primary barrier — modern
|
|
19
|
+
* browsers can't set it cross-origin without preflight, which a same-
|
|
20
|
+
* origin policy denies for any host that isn't our own. the Origin check
|
|
21
|
+
* is belt-and-braces, and matters specifically for POST / DELETE so the
|
|
22
|
+
* endsWith bug (where `evil-${domain}` would have been accepted) is
|
|
23
|
+
* closed and a missing Origin on a mutating request is rejected. read
|
|
24
|
+
* methods accept a missing Origin so health checks / curl probes keep
|
|
25
|
+
* working without the operator having to set an Origin manually. */
|
|
26
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
27
|
+
function csrfOk(req, domain) {
|
|
28
|
+
if (req.headers['x-fleet-backup'] !== '1')
|
|
29
|
+
return false;
|
|
30
|
+
const origin = req.headers['origin'];
|
|
31
|
+
const isWrite = !READ_METHODS.has(req.method.toUpperCase());
|
|
32
|
+
if (!origin)
|
|
33
|
+
return !isWrite;
|
|
34
|
+
let host;
|
|
35
|
+
try {
|
|
36
|
+
host = new URL(origin).host;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return host === domain;
|
|
42
|
+
}
|
|
43
|
+
export function handle(req, ctx) {
|
|
44
|
+
// public: the login page
|
|
45
|
+
if (req.path === '/login' && req.method === 'GET') {
|
|
46
|
+
return { kind: 'html', status: 200, body: renderLoginPage() };
|
|
47
|
+
}
|
|
48
|
+
// /api/* — the CSRF + same-origin check runs before auth so a cross-site
|
|
49
|
+
// probe is rejected (403) without ever reaching the session layer.
|
|
50
|
+
if (req.path.startsWith('/api/')) {
|
|
51
|
+
if (!csrfOk(req, ctx.domain))
|
|
52
|
+
return json(403, { error: 'csrf check failed' });
|
|
53
|
+
if (req.path === '/api/login' && req.method === 'POST') {
|
|
54
|
+
const code = req.body?.code ?? '';
|
|
55
|
+
if (!verifyTotp(ctx.totpSecret, code, ctx.now())) {
|
|
56
|
+
return json(401, { error: 'invalid code' });
|
|
57
|
+
}
|
|
58
|
+
const cookie = signSession({ exp: ctx.now() + ctx.sessionTtlMs }, ctx.sessionSecret);
|
|
59
|
+
const attrs = `Path=/backups; HttpOnly; Secure; SameSite=Strict; Max-Age=${Math.floor(ctx.sessionTtlMs / 1000)}`;
|
|
60
|
+
return json(200, { ok: true }, `${SESSION_COOKIE}=${cookie}; ${attrs}`);
|
|
61
|
+
}
|
|
62
|
+
if (!hasSession(req, ctx))
|
|
63
|
+
return json(401, { error: 'not authenticated' });
|
|
64
|
+
return handleApi(req, ctx);
|
|
65
|
+
}
|
|
66
|
+
// every non-api route requires a session
|
|
67
|
+
if (!hasSession(req, ctx)) {
|
|
68
|
+
return { kind: 'redirect', status: 302, location: '/backups/login' };
|
|
69
|
+
}
|
|
70
|
+
if (req.path === '/' && req.method === 'GET') {
|
|
71
|
+
return { kind: 'html', status: 200, body: renderStatusHtml(ctx.statusReport()) };
|
|
72
|
+
}
|
|
73
|
+
if (req.path === '/explore' && req.method === 'GET') {
|
|
74
|
+
return { kind: 'html', status: 200, body: renderExplorerPage() };
|
|
75
|
+
}
|
|
76
|
+
return json(404, { error: 'not found' });
|
|
77
|
+
}
|
|
78
|
+
const SNAP_RE = /^[0-9a-f]{8,64}$/;
|
|
79
|
+
const INLINE_TYPES = ['text/', 'image/', 'application/pdf', 'application/json'];
|
|
80
|
+
function validPath(p) {
|
|
81
|
+
if (!p || !p.startsWith('/'))
|
|
82
|
+
return false;
|
|
83
|
+
// reject any traversal segment in the raw path — checking a normalised
|
|
84
|
+
// path is useless here because normalisation collapses `..` away first.
|
|
85
|
+
return !p.split('/').includes('..');
|
|
86
|
+
}
|
|
87
|
+
/** maps a restic error to 503 when the backend is unreachable, else 500. */
|
|
88
|
+
function resticErrorStatus(message) {
|
|
89
|
+
return /unreach|connection refused|dial |timeout|no route to host/i.test(message)
|
|
90
|
+
? 503
|
|
91
|
+
: 500;
|
|
92
|
+
}
|
|
93
|
+
function contentTypeFor(path) {
|
|
94
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
|
95
|
+
const map = {
|
|
96
|
+
txt: 'text/plain', md: 'text/plain', log: 'text/plain', json: 'application/json',
|
|
97
|
+
js: 'text/plain', ts: 'text/plain', css: 'text/plain', html: 'text/plain',
|
|
98
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
99
|
+
svg: 'image/svg+xml', pdf: 'application/pdf',
|
|
100
|
+
};
|
|
101
|
+
return map[ext] ?? 'application/octet-stream';
|
|
102
|
+
}
|
|
103
|
+
function handleApi(req, ctx) {
|
|
104
|
+
const { path: route, query } = req;
|
|
105
|
+
if (route === '/api/apps' && req.method === 'GET') {
|
|
106
|
+
return json(200, ctx.statusReport());
|
|
107
|
+
}
|
|
108
|
+
if (route === '/api/snapshots' && req.method === 'GET') {
|
|
109
|
+
const app = query.app ?? '';
|
|
110
|
+
if (!ctx.listApps().includes(app))
|
|
111
|
+
return json(404, { error: 'unknown app' });
|
|
112
|
+
return json(200, { snapshots: ctx.snapshots(app) });
|
|
113
|
+
}
|
|
114
|
+
if (route === '/api/ls' && req.method === 'GET') {
|
|
115
|
+
const { app = '', snap = '', path = '/' } = query;
|
|
116
|
+
if (!ctx.listApps().includes(app))
|
|
117
|
+
return json(404, { error: 'unknown app' });
|
|
118
|
+
if (!SNAP_RE.test(snap))
|
|
119
|
+
return json(400, { error: 'bad snapshot id' });
|
|
120
|
+
if (!validPath(path))
|
|
121
|
+
return json(400, { error: 'bad path' });
|
|
122
|
+
try {
|
|
123
|
+
// sensitivity is derived from the path itself — no per-entry restic call.
|
|
124
|
+
const entries = ctx.lsTree(app, snap, path).map(e => ({
|
|
125
|
+
...e,
|
|
126
|
+
sensitive: classify(e.path) === 'sensitive',
|
|
127
|
+
}));
|
|
128
|
+
return json(200, { path, entries });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
const msg = e.message;
|
|
132
|
+
return json(resticErrorStatus(msg), { error: msg });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (route === '/api/staging' && req.method === 'GET') {
|
|
136
|
+
return json(200, { staging: ctx.listStaging() });
|
|
137
|
+
}
|
|
138
|
+
if (route === '/api/restore' && req.method === 'POST') {
|
|
139
|
+
const b = (req.body ?? {});
|
|
140
|
+
const app = b.app ?? '';
|
|
141
|
+
const snap = b.snap ?? '';
|
|
142
|
+
const path = b.path ?? '';
|
|
143
|
+
if (!ctx.listApps().includes(app))
|
|
144
|
+
return json(404, { error: 'unknown app' });
|
|
145
|
+
if (!SNAP_RE.test(snap))
|
|
146
|
+
return json(400, { error: 'bad snapshot id' });
|
|
147
|
+
if (!validPath(path))
|
|
148
|
+
return json(400, { error: 'bad path' });
|
|
149
|
+
try {
|
|
150
|
+
return json(200, ctx.restore(app, snap, path));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
const msg = e.message;
|
|
154
|
+
// doRestore throws a code-507 error when staging space is short
|
|
155
|
+
const status = e.code === 507 ? 507 : 500;
|
|
156
|
+
return json(status, { error: msg });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (route === '/api/staging' && req.method === 'DELETE') {
|
|
160
|
+
const p = query.path ?? '';
|
|
161
|
+
if (!p.startsWith('/var/restore/') || p.includes('..')) {
|
|
162
|
+
return json(400, { error: 'bad staging path' });
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
ctx.deleteStaging(p);
|
|
166
|
+
return json(200, { ok: true });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return json(500, { error: e.message });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (route === '/api/file' && req.method === 'GET') {
|
|
173
|
+
const { app = '', snap = '', path = '', dl } = query;
|
|
174
|
+
if (!ctx.listApps().includes(app))
|
|
175
|
+
return json(404, { error: 'unknown app' });
|
|
176
|
+
if (!SNAP_RE.test(snap))
|
|
177
|
+
return json(400, { error: 'bad snapshot id' });
|
|
178
|
+
if (!validPath(path))
|
|
179
|
+
return json(400, { error: 'bad path' });
|
|
180
|
+
const meta = ctx.fileMeta(app, snap, path);
|
|
181
|
+
if (!meta)
|
|
182
|
+
return json(404, { error: 'file not found' });
|
|
183
|
+
if (meta.sensitive)
|
|
184
|
+
return json(403, { error: 'sensitive path — view/download blocked' });
|
|
185
|
+
const filename = path.slice(path.lastIndexOf('/') + 1);
|
|
186
|
+
const ct = contentTypeFor(path);
|
|
187
|
+
const inlineable = INLINE_TYPES.some(t => ct.startsWith(t)) && meta.size <= 5 * 1024 * 1024;
|
|
188
|
+
return {
|
|
189
|
+
kind: 'stream',
|
|
190
|
+
status: 200,
|
|
191
|
+
app, snap, path, filename,
|
|
192
|
+
contentType: ct,
|
|
193
|
+
disposition: dl === '1' || !inlineable ? 'attachment' : 'inline',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return json(404, { error: 'not found' });
|
|
197
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Server } from 'node:http';
|
|
2
|
+
export interface ServeOptions {
|
|
3
|
+
port: number;
|
|
4
|
+
totpSecret: string;
|
|
5
|
+
sessionSecret: string;
|
|
6
|
+
sessionTtlMs?: number;
|
|
7
|
+
stagingRoot?: string;
|
|
8
|
+
}
|
|
9
|
+
/** starts the explorer http service, resolving once it is bound. the socket
|
|
10
|
+
* is localhost-only (nginx fronts it). */
|
|
11
|
+
export declare function startServer(opts: ServeOptions): Promise<Server>;
|