@matthesketh/fleet 1.8.1 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/notify.d.ts +1 -0
- package/dist/commands/notify.js +51 -0
- 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/eas.d.ts +4 -0
- package/dist/core/testflight/eas.js +38 -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/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
package/dist/core/secrets-ops.js
CHANGED
|
@@ -28,7 +28,7 @@ function tryTightenPerms(envPath, app) {
|
|
|
28
28
|
process.stderr.write(`[fleet-unseal] perm tightening skipped for ${app}: ${err}\n`);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, } from './secrets.js';
|
|
31
|
+
import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, lockManifest, } from './secrets.js';
|
|
32
32
|
// --- helpers ---
|
|
33
33
|
function safeEqual(a, b) {
|
|
34
34
|
const bufA = Buffer.from(a);
|
|
@@ -72,15 +72,24 @@ export function validateBeforeSeal(app, newContent) {
|
|
|
72
72
|
return { added, removed, unchanged };
|
|
73
73
|
}
|
|
74
74
|
// --- Phase 8: Safe seal wrappers ---
|
|
75
|
+
//
|
|
76
|
+
// safeSealApp / safeSealDbSecrets are the "validate + backup + seal +
|
|
77
|
+
// cleanup" primitives. They do NOT take the manifest lock themselves because
|
|
78
|
+
// they're called from inside already-locked outer flows (setSecret,
|
|
79
|
+
// sealFromRuntime). External callers that need concurrency safety should go
|
|
80
|
+
// via setSecret / importEnvFile / importDbSecrets / sealFromRuntime instead;
|
|
81
|
+
// those wrap the whole flow in lockManifest().
|
|
75
82
|
export function safeSealApp(app, content, sourceFile) {
|
|
76
83
|
const validation = validateBeforeSeal(app, content);
|
|
77
|
-
backupVaultFile(app);
|
|
84
|
+
const bak = backupVaultFile(app);
|
|
78
85
|
try {
|
|
79
86
|
sealApp(app, content, sourceFile);
|
|
80
|
-
|
|
87
|
+
if (bak)
|
|
88
|
+
removeBackup(app, bak);
|
|
81
89
|
}
|
|
82
90
|
catch (err) {
|
|
83
|
-
|
|
91
|
+
if (bak)
|
|
92
|
+
restoreVaultFile(app, bak);
|
|
84
93
|
throw err;
|
|
85
94
|
}
|
|
86
95
|
return validation;
|
|
@@ -92,18 +101,20 @@ export function safeSealDbSecrets(app, secretsMap, sourceDir) {
|
|
|
92
101
|
const parts = filenames.map(f => `${SECRET_DELIMITER}${f}---\n${secretsMap[f]}`);
|
|
93
102
|
const bundleContent = parts.join('\n');
|
|
94
103
|
const validation = validateBeforeSeal(app, bundleContent);
|
|
95
|
-
backupVaultFile(app);
|
|
104
|
+
const bak = backupVaultFile(app);
|
|
96
105
|
try {
|
|
97
106
|
sealDbSecrets(app, secretsMap, sourceDir);
|
|
98
|
-
|
|
107
|
+
if (bak)
|
|
108
|
+
removeBackup(app, bak);
|
|
99
109
|
}
|
|
100
110
|
catch (err) {
|
|
101
|
-
|
|
111
|
+
if (bak)
|
|
112
|
+
restoreVaultFile(app, bak);
|
|
102
113
|
throw err;
|
|
103
114
|
}
|
|
104
115
|
return validation;
|
|
105
116
|
}
|
|
106
|
-
export function setSecret(app, key, value, opts = {}) {
|
|
117
|
+
export async function setSecret(app, key, value, opts = {}) {
|
|
107
118
|
assertAppName(app);
|
|
108
119
|
assertSecretKey(key);
|
|
109
120
|
// Entropy / placeholder check unless explicitly bypassed.
|
|
@@ -114,26 +125,37 @@ export function setSecret(app, key, value, opts = {}) {
|
|
|
114
125
|
throw new SecretsError(`${entropyErr}. Pass --allow-weak to override (not recommended).`);
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
// Hold the manifest lock for the entire decrypt → mutate → re-seal cycle so
|
|
129
|
+
// a parallel CLI/cron writer can't insert a stale write between our read
|
|
130
|
+
// and our seal. safeSealApp does its own loadManifest/saveManifest inside —
|
|
131
|
+
// those reads/writes happen under our lock.
|
|
132
|
+
await lockManifest(() => {
|
|
133
|
+
const plaintext = decryptApp(app);
|
|
134
|
+
const manifest = loadManifest();
|
|
135
|
+
const entry = manifest.apps[app];
|
|
136
|
+
if (entry.type !== 'env')
|
|
137
|
+
throw new SecretsError(`Cannot set key/value on secrets-dir type for ${app}`);
|
|
138
|
+
const lines = plaintext.split('\n');
|
|
139
|
+
let found = false;
|
|
140
|
+
const updated = lines.map(line => {
|
|
141
|
+
const eqIdx = line.indexOf('=');
|
|
142
|
+
if (eqIdx > 0 && line.substring(0, eqIdx) === key) {
|
|
143
|
+
found = true;
|
|
144
|
+
return `${key}=${value}`;
|
|
145
|
+
}
|
|
146
|
+
return line;
|
|
147
|
+
});
|
|
148
|
+
if (!found)
|
|
149
|
+
updated.push(`${key}=${value}`);
|
|
150
|
+
safeSealApp(app, updated.join('\n'), entry.sourceFile);
|
|
151
|
+
auditLog({ op: 'set', app, secret: key, ok: true });
|
|
131
152
|
});
|
|
132
|
-
if (!found)
|
|
133
|
-
updated.push(`${key}=${value}`);
|
|
134
|
-
safeSealApp(app, updated.join('\n'), entry.sourceFile);
|
|
135
|
-
auditLog({ op: 'set', app, secret: key, ok: true });
|
|
136
153
|
}
|
|
154
|
+
/** read a single secret value. deliberately does NOT hold the manifest
|
|
155
|
+
* lock — a concurrent setSecret/seal does an atomic file rename, so the
|
|
156
|
+
* worst-case race here is reading the immediate-pre-rotation vault blob.
|
|
157
|
+
* audit logging happens at the command layer so programmatic reads
|
|
158
|
+
* (background sync, validation) don't pollute the audit trail. */
|
|
137
159
|
export function getSecret(app, key) {
|
|
138
160
|
const plaintext = decryptApp(app);
|
|
139
161
|
const manifest = loadManifest();
|
|
@@ -154,26 +176,30 @@ export function getSecret(app, key) {
|
|
|
154
176
|
// human-driven reads (set/get/import/export). Programmatic reads done by
|
|
155
177
|
// other fleet operations (sealing, validation, drift) are not audited to
|
|
156
178
|
// avoid log noise.
|
|
157
|
-
export function importEnvFile(app, path) {
|
|
179
|
+
export async function importEnvFile(app, path) {
|
|
158
180
|
if (!existsSync(path))
|
|
159
181
|
throw new SecretsError(`File not found: ${path}`);
|
|
160
182
|
const content = readFileSync(path, 'utf-8');
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
return await lockManifest(() => {
|
|
184
|
+
// importEnvFile is an explicit replace — bypass validation, but still backup
|
|
185
|
+
const bak = backupVaultFile(app);
|
|
186
|
+
try {
|
|
187
|
+
sealApp(app, content, path);
|
|
188
|
+
if (bak)
|
|
189
|
+
removeBackup(app, bak);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (bak)
|
|
193
|
+
restoreVaultFile(app, bak);
|
|
194
|
+
auditLog({ op: 'import', app, ok: false, details: `${path}: ${err}` });
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
const manifest = loadManifest();
|
|
198
|
+
auditLog({ op: 'import', app, ok: true, details: `${path}: ${manifest.apps[app].keyCount} keys` });
|
|
199
|
+
return manifest.apps[app].keyCount;
|
|
200
|
+
});
|
|
175
201
|
}
|
|
176
|
-
export function importDbSecrets(app, dir) {
|
|
202
|
+
export async function importDbSecrets(app, dir) {
|
|
177
203
|
if (!existsSync(dir))
|
|
178
204
|
throw new SecretsError(`Directory not found: ${dir}`);
|
|
179
205
|
const stat = statSync(dir);
|
|
@@ -184,17 +210,21 @@ export function importDbSecrets(app, dir) {
|
|
|
184
210
|
for (const file of files) {
|
|
185
211
|
secretsMap[file] = readFileSync(join(dir, file), 'utf-8');
|
|
186
212
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
return await lockManifest(() => {
|
|
214
|
+
// importDbSecrets is an explicit replace — bypass validation, but still backup
|
|
215
|
+
const bak = backupVaultFile(app);
|
|
216
|
+
try {
|
|
217
|
+
sealDbSecrets(app, secretsMap, dir);
|
|
218
|
+
if (bak)
|
|
219
|
+
removeBackup(app, bak);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (bak)
|
|
223
|
+
restoreVaultFile(app, bak);
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
return files.length;
|
|
227
|
+
});
|
|
198
228
|
}
|
|
199
229
|
export function exportApp(app) {
|
|
200
230
|
auditLog({ op: 'export', app, ok: true });
|
|
@@ -322,58 +352,134 @@ export function unsealAll() {
|
|
|
322
352
|
}
|
|
323
353
|
}
|
|
324
354
|
}
|
|
325
|
-
export function sealFromRuntime(app) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
355
|
+
export async function sealFromRuntime(app) {
|
|
356
|
+
return await lockManifest(() => {
|
|
357
|
+
const manifest = loadManifest();
|
|
358
|
+
const apps = app ? [app] : Object.keys(manifest.apps);
|
|
359
|
+
const sealed = [];
|
|
360
|
+
for (const a of apps) {
|
|
361
|
+
const entry = manifest.apps[a];
|
|
362
|
+
if (!entry)
|
|
363
|
+
throw new SecretsError(`No secrets found for app: ${a}`);
|
|
364
|
+
if (entry.type === 'env') {
|
|
365
|
+
const runtimePath = join(RUNTIME_DIR, a, '.env');
|
|
366
|
+
if (!existsSync(runtimePath))
|
|
367
|
+
throw new SecretsError(`Runtime file not found: ${runtimePath}`);
|
|
368
|
+
const content = readFileSync(runtimePath, 'utf-8');
|
|
369
|
+
safeSealApp(a, content, entry.sourceFile);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
const runtimeDir = join(RUNTIME_DIR, a, 'secrets');
|
|
373
|
+
if (!existsSync(runtimeDir))
|
|
374
|
+
throw new SecretsError(`Runtime dir not found: ${runtimeDir}`);
|
|
375
|
+
const dirFiles = readdirSync(runtimeDir);
|
|
376
|
+
const secretsMap = {};
|
|
377
|
+
for (const f of dirFiles) {
|
|
378
|
+
secretsMap[f] = readFileSync(join(runtimeDir, f), 'utf-8');
|
|
379
|
+
}
|
|
380
|
+
safeSealDbSecrets(a, secretsMap, entry.sourceFile);
|
|
348
381
|
}
|
|
349
|
-
|
|
382
|
+
sealed.push(a);
|
|
350
383
|
}
|
|
351
|
-
sealed
|
|
352
|
-
}
|
|
353
|
-
return sealed;
|
|
384
|
+
return sealed;
|
|
385
|
+
});
|
|
354
386
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return
|
|
387
|
+
/**
|
|
388
|
+
* Rotate the age private key and re-encrypt every app's vault file with it.
|
|
389
|
+
*
|
|
390
|
+
* RECOVERY PROCEDURE (manual, only if rollback itself fails):
|
|
391
|
+
* 1. The previous private key is preserved at `<KEY_PATH>.old` while a
|
|
392
|
+
* rotation is in flight. If you see that file lying around, a rotation
|
|
393
|
+
* crashed mid-way.
|
|
394
|
+
* 2. Each app's pre-rotate encrypted file is preserved as
|
|
395
|
+
* `vault/<app>.{env,secrets}.age.bak-rotate-<ts>` for the duration of
|
|
396
|
+
* the rotation. They are removed automatically on success OR after a
|
|
397
|
+
* successful rollback.
|
|
398
|
+
* 3. To restore by hand: copy `<KEY_PATH>.old` back to `<KEY_PATH>`
|
|
399
|
+
* (chmod 0600), then for each `*.bak-rotate-<ts>` copy it over the
|
|
400
|
+
* matching encrypted file. This puts the vault back into the
|
|
401
|
+
* pre-rotation state.
|
|
402
|
+
*
|
|
403
|
+
* On a partial-failure path inside this function, that recovery is performed
|
|
404
|
+
* automatically before re-throwing. The whole rotation runs under the
|
|
405
|
+
* manifest's inter-process lock.
|
|
406
|
+
*/
|
|
407
|
+
export async function rotateKey() {
|
|
408
|
+
return await lockManifest(() => {
|
|
409
|
+
const manifest = loadManifest();
|
|
410
|
+
const oldPubkey = getPublicKey();
|
|
411
|
+
// 1. Decrypt all apps with the OLD key (still on disk at KEY_PATH).
|
|
412
|
+
const decrypted = {};
|
|
413
|
+
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
414
|
+
decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
|
|
415
|
+
}
|
|
416
|
+
// 2. Backup the old key so we can roll back if anything below throws.
|
|
417
|
+
const backupPath = KEY_PATH + '.old';
|
|
418
|
+
copyFileSync(KEY_PATH, backupPath);
|
|
419
|
+
// 3. Generate the new key in place. If keygen fails BEFORE we've mutated
|
|
420
|
+
// any vault file, the old key on disk is still good — clean up the
|
|
421
|
+
// sidecar backup and bail without touching the vault.
|
|
422
|
+
const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
|
|
423
|
+
if (!keygen.ok) {
|
|
424
|
+
rmSync(backupPath, { force: true });
|
|
425
|
+
throw new SecretsError(`Failed to generate new key: ${keygen.stderr}`);
|
|
426
|
+
}
|
|
427
|
+
chmodSync(KEY_PATH, 0o600);
|
|
428
|
+
const newPubkey = getPublicKey();
|
|
429
|
+
// 4. Snapshot every app's CURRENT (still old-key-encrypted) vault file
|
|
430
|
+
// BEFORE we start rewriting anything. Same rotation tag for all of them
|
|
431
|
+
// so a human can grep for `bak-rotate-<ts>` if they need to recover.
|
|
432
|
+
const rotateTag = `rotate-${Date.now()}`;
|
|
433
|
+
const backups = [];
|
|
434
|
+
try {
|
|
435
|
+
for (const app of Object.keys(manifest.apps)) {
|
|
436
|
+
const b = backupVaultFile(app, rotateTag);
|
|
437
|
+
if (b)
|
|
438
|
+
backups.push({ app, bak: b });
|
|
439
|
+
}
|
|
440
|
+
// 5. Re-encrypt each app's plaintext under the NEW key and overwrite
|
|
441
|
+
// its vault file. If any encryption or write throws partway through,
|
|
442
|
+
// we land in the catch block below.
|
|
443
|
+
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
444
|
+
const encrypted = ageEncrypt(decrypted[app]);
|
|
445
|
+
writeFileSync(join(VAULT_DIR, entry.encryptedFile), encrypted);
|
|
446
|
+
entry.lastSealedAt = new Date().toISOString();
|
|
447
|
+
}
|
|
448
|
+
saveManifest(manifest);
|
|
449
|
+
// 6. Success — drop the per-app rotation backups and the old-key sidecar.
|
|
450
|
+
for (const b of backups)
|
|
451
|
+
rmSync(b.bak, { force: true });
|
|
452
|
+
rmSync(backupPath, { force: true });
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
// Rollback: put the old key back, then restore every vault file from the
|
|
456
|
+
// matching pre-rotate backup. If rollback itself fails we deliberately
|
|
457
|
+
// leak the .bak-rotate-* files and KEY_PATH.old so a human has the
|
|
458
|
+
// pieces needed to recover by hand (see comment at top of function).
|
|
459
|
+
try {
|
|
460
|
+
copyFileSync(backupPath, KEY_PATH);
|
|
461
|
+
chmodSync(KEY_PATH, 0o600);
|
|
462
|
+
for (const b of backups) {
|
|
463
|
+
const entry = manifest.apps[b.app];
|
|
464
|
+
if (!entry)
|
|
465
|
+
continue;
|
|
466
|
+
copyFileSync(b.bak, join(VAULT_DIR, entry.encryptedFile));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch (rollbackErr) {
|
|
470
|
+
throw new SecretsError(`rotateKey failed AND rollback failed: ${err.message}; ` +
|
|
471
|
+
`rollback: ${rollbackErr.message}; ` +
|
|
472
|
+
`manual recovery needed (KEY_PATH.old + vault/*.bak-${rotateTag} files preserved)`);
|
|
473
|
+
}
|
|
474
|
+
// Rollback succeeded — vault is back where it started under the old key.
|
|
475
|
+
// Safe to clean up the per-app backups and the old-key sidecar.
|
|
476
|
+
for (const b of backups)
|
|
477
|
+
rmSync(b.bak, { force: true });
|
|
478
|
+
rmSync(backupPath, { force: true });
|
|
479
|
+
throw new SecretsError(`rotateKey failed (rolled back): ${err.message}`);
|
|
480
|
+
}
|
|
481
|
+
return { oldPubkey, newPubkey, appsRotated: Object.keys(manifest.apps) };
|
|
482
|
+
});
|
|
377
483
|
}
|
|
378
484
|
export function getStatus() {
|
|
379
485
|
const init = isInitialized();
|
|
@@ -124,7 +124,7 @@ export const PROVIDERS = [
|
|
|
124
124
|
matches: /^(EMAIL_SERVER_PASSWORD|GMAIL_APP_PASSWORD|SMTP_PASS|SMTP_PASSWORD)$/,
|
|
125
125
|
name: 'Gmail App Password / SMTP Password',
|
|
126
126
|
url: 'https://myaccount.google.com/apppasswords',
|
|
127
|
-
instructions: '1. Sign in and create a new App Password (e.g. "
|
|
127
|
+
instructions: '1. Sign in and create a new App Password (e.g. "poolside-2026")\n' +
|
|
128
128
|
'2. Copy the 16-character value and paste below WITHOUT spaces\n' +
|
|
129
129
|
'3. Revoke the old App Password from the same page',
|
|
130
130
|
// Gmail app passwords are 16 lowercase alphanumeric chars. Google
|
|
@@ -193,7 +193,7 @@ export const PROVIDERS = [
|
|
|
193
193
|
rotationFrequencyDays: 365,
|
|
194
194
|
strategy: 'at-rest-key',
|
|
195
195
|
},
|
|
196
|
-
// ── Bookwhen (used by
|
|
196
|
+
// ── Bookwhen (used by poolside) ───────────────────────────────────────────
|
|
197
197
|
{
|
|
198
198
|
id: 'bookwhen-token',
|
|
199
199
|
matches: /^BOOKWHEN_API_TOKEN$/,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* gate, and rolls back on failure. Pure functions where possible — I/O isolated
|
|
5
5
|
* to thin wrappers so tests can run without a real vault.
|
|
6
6
|
*/
|
|
7
|
-
import { decryptApp, sealApp, loadManifest } from './secrets.js';
|
|
7
|
+
import { decryptApp, sealApp, loadManifest, lockManifest } from './secrets.js';
|
|
8
8
|
import { snapshotApp, restoreSnapshot } from './secrets-snapshots.js';
|
|
9
9
|
import { auditLog } from './secrets-audit.js';
|
|
10
10
|
import { markRotated } from './secrets-metadata.js';
|
|
@@ -108,58 +108,64 @@ export function applyRotation(plaintext, key, newValue, strategy) {
|
|
|
108
108
|
* snapshot → seal → audit. Restart + health-gate are caller's responsibility
|
|
109
109
|
* (we want the engine pure-ish so it's easy to test).
|
|
110
110
|
*/
|
|
111
|
-
export function performRotation(app, key, newValue, opts = {}) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
// 3. Stamp metadata.
|
|
142
|
-
markRotated(app, key, { strategy, notes: opts.notes });
|
|
143
|
-
auditLog({ op: 'rotate', app, secret: key, ok: true, details: `strategy=${strategy}` });
|
|
144
|
-
return { app, key, strategy, snapshot, rolledBack: false };
|
|
145
|
-
}
|
|
146
|
-
catch (err) {
|
|
147
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
148
|
-
// Restore from snapshot on any failure.
|
|
111
|
+
export async function performRotation(app, key, newValue, opts = {}) {
|
|
112
|
+
// Hold the manifest lock for the whole snapshot → seal → markRotated cycle
|
|
113
|
+
// so a concurrent CLI/cron writer can't slip a stale write between our
|
|
114
|
+
// seal and our metadata stamp. markRotated calls saveManifest internally;
|
|
115
|
+
// that write happens under our lock.
|
|
116
|
+
return await lockManifest(() => {
|
|
117
|
+
const manifest = loadManifest();
|
|
118
|
+
const entry = manifest.apps[app];
|
|
119
|
+
if (!entry)
|
|
120
|
+
throw new SecretsError(`No app in manifest: ${app}`);
|
|
121
|
+
if (entry.type !== 'env') {
|
|
122
|
+
throw new SecretsError(`Rotation only supports env-type apps, got ${entry.type}`);
|
|
123
|
+
}
|
|
124
|
+
const provider = classifySecret(key);
|
|
125
|
+
const strategy = provider?.strategy ?? 'immediate';
|
|
126
|
+
if (strategy === 'user-issued') {
|
|
127
|
+
throw new SecretsError(`${key} is a user-issued token. Rotating yours doesn't help — invalidate per-user instead.`);
|
|
128
|
+
}
|
|
129
|
+
// Strict typed opt — was previously a substring match on opts.notes which
|
|
130
|
+
// could be bypassed by any caller embedding the flag in free-text notes.
|
|
131
|
+
if (strategy === 'at-rest-key' && !opts.dataMigrated) {
|
|
132
|
+
throw new SecretsError(`${key} encrypts data at rest. Re-encrypt your data first, then pass --data-migrated.`);
|
|
133
|
+
}
|
|
134
|
+
if (opts.dryRun) {
|
|
135
|
+
auditLog({ op: 'rotate-attempted', app, secret: key, ok: true, details: 'dry-run' });
|
|
136
|
+
return { app, key, strategy, snapshot: '(dry-run)', rolledBack: false };
|
|
137
|
+
}
|
|
138
|
+
// 1. Snapshot before any change.
|
|
139
|
+
const snapshot = snapshotApp(app);
|
|
140
|
+
auditLog({ op: 'snapshot', app, secret: key, ok: true, details: snapshot });
|
|
149
141
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
142
|
+
// 2. Decrypt, apply rotation, re-encrypt.
|
|
143
|
+
const plaintext = decryptApp(app);
|
|
144
|
+
const updated = applyRotation(plaintext, key, newValue, strategy);
|
|
145
|
+
sealApp(app, updated, entry.sourceFile);
|
|
146
|
+
// 3. Stamp metadata.
|
|
147
|
+
markRotated(app, key, { strategy, notes: opts.notes });
|
|
148
|
+
auditLog({ op: 'rotate', app, secret: key, ok: true, details: `strategy=${strategy}` });
|
|
149
|
+
return { app, key, strategy, snapshot, rolledBack: false };
|
|
152
150
|
}
|
|
153
|
-
catch (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
ok:
|
|
159
|
-
|
|
160
|
-
|
|
151
|
+
catch (err) {
|
|
152
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
153
|
+
// Restore from snapshot on any failure.
|
|
154
|
+
try {
|
|
155
|
+
restoreSnapshot(app);
|
|
156
|
+
auditLog({ op: 'rollback', app, secret: key, ok: true, details: `auto: ${reason}` });
|
|
157
|
+
}
|
|
158
|
+
catch (rollbackErr) {
|
|
159
|
+
auditLog({
|
|
160
|
+
op: 'rollback',
|
|
161
|
+
app,
|
|
162
|
+
secret: key,
|
|
163
|
+
ok: false,
|
|
164
|
+
details: `auto rollback also failed: ${rollbackErr}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
auditLog({ op: 'rotate-failed', app, secret: key, ok: false, details: reason });
|
|
168
|
+
return { app, key, strategy, snapshot, rolledBack: true, reason };
|
|
161
169
|
}
|
|
162
|
-
|
|
163
|
-
return { app, key, strategy, snapshot, rolledBack: true, reason };
|
|
164
|
-
}
|
|
170
|
+
});
|
|
165
171
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CleanupOpts {
|
|
2
|
+
app: string;
|
|
3
|
+
retentionDays?: number;
|
|
4
|
+
dryRun?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface CleanupResult {
|
|
7
|
+
app: string;
|
|
8
|
+
removedBak: boolean;
|
|
9
|
+
removedSnapshots: string[];
|
|
10
|
+
keptSnapshots: string[];
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* parse a filesystem-safe snapshot timestamp back to a Date.
|
|
15
|
+
* input: '2026-05-06T12-00-00-000Z' (colons and dots replaced with dashes)
|
|
16
|
+
* output: Date('2026-05-06T12:00:00.000Z'), or null if unparseable
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseSnapshotTimestamp(ts: string): Date | null;
|
|
19
|
+
export declare function cleanupV2Backups(opts: CleanupOpts): Promise<CleanupResult>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { findApp, load } from './registry.js';
|
|
6
|
+
import { listSnapshots } from './secrets-v2-snapshot.js';
|
|
7
|
+
import { SecretsError } from './errors.js';
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
/** resolve vault dir: env override (used in tests) or the default computed path */
|
|
10
|
+
function resolveVaultDir() {
|
|
11
|
+
return process.env.FLEET_VAULT_DIR ?? join(__dirname, '..', '..', 'vault');
|
|
12
|
+
}
|
|
13
|
+
/** read manifest directly without calling requireInit() */
|
|
14
|
+
function readManifest(vaultDir) {
|
|
15
|
+
const p = join(vaultDir, 'manifest.json');
|
|
16
|
+
if (!existsSync(p))
|
|
17
|
+
return { version: 1, apps: {} };
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { version: 1, apps: {} };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* parse a filesystem-safe snapshot timestamp back to a Date.
|
|
27
|
+
* input: '2026-05-06T12-00-00-000Z' (colons and dots replaced with dashes)
|
|
28
|
+
* output: Date('2026-05-06T12:00:00.000Z'), or null if unparseable
|
|
29
|
+
*/
|
|
30
|
+
export function parseSnapshotTimestamp(ts) {
|
|
31
|
+
const m = ts.match(/^(\d{4}-\d{2}-\d{2}T)(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/);
|
|
32
|
+
if (!m)
|
|
33
|
+
return null;
|
|
34
|
+
return new Date(`${m[1]}${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
|
|
35
|
+
}
|
|
36
|
+
export async function cleanupV2Backups(opts) {
|
|
37
|
+
const { app, retentionDays = 30, dryRun = false } = opts;
|
|
38
|
+
const registry = load();
|
|
39
|
+
const appEntry = findApp(registry, app);
|
|
40
|
+
if (!appEntry) {
|
|
41
|
+
throw new SecretsError(`app '${app}' not found in fleet registry`);
|
|
42
|
+
}
|
|
43
|
+
const vaultDir = resolveVaultDir();
|
|
44
|
+
const manifest = readManifest(vaultDir);
|
|
45
|
+
const entry = manifest.apps[app];
|
|
46
|
+
if (!entry || entry.mode !== 'socket') {
|
|
47
|
+
throw new SecretsError(`app '${app}' is not in v2 mode; cleanup is for post-v2-migration apps only`);
|
|
48
|
+
}
|
|
49
|
+
const cutoff = Date.now() - retentionDays * 86_400_000;
|
|
50
|
+
const backupRoot = join(vaultDir, 'backups');
|
|
51
|
+
const snapshots = listSnapshots(backupRoot, app);
|
|
52
|
+
const removedSnapshots = [];
|
|
53
|
+
const keptSnapshots = [];
|
|
54
|
+
for (const snap of snapshots) {
|
|
55
|
+
const ts = parseSnapshotTimestamp(snap.timestamp);
|
|
56
|
+
if (!ts) {
|
|
57
|
+
// unparseable timestamp — keep rather than risk destroying unknown content
|
|
58
|
+
keptSnapshots.push(snap.timestamp);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ts.getTime() < cutoff) {
|
|
62
|
+
removedSnapshots.push(snap.timestamp);
|
|
63
|
+
if (!dryRun) {
|
|
64
|
+
rmSync(snap.dir, { recursive: true, force: true });
|
|
65
|
+
// best-effort: remove the parent timestamp dir if it's now empty
|
|
66
|
+
const parentDir = dirname(snap.dir);
|
|
67
|
+
try {
|
|
68
|
+
const remaining = readdirSync(parentDir);
|
|
69
|
+
if (remaining.length === 0)
|
|
70
|
+
rmSync(parentDir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
catch { /* ignore */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
keptSnapshots.push(snap.timestamp);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let removedBak = false;
|
|
80
|
+
const bakPath = join(vaultDir, `${app}.env.age.v1.bak`);
|
|
81
|
+
if (existsSync(bakPath)) {
|
|
82
|
+
if (!dryRun) {
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(bakPath);
|
|
85
|
+
removedBak = true;
|
|
86
|
+
}
|
|
87
|
+
catch { /* best-effort */ }
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
removedBak = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { app, removedBak, removedSnapshots, keptSnapshots, dryRun };
|
|
94
|
+
}
|