@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
|
@@ -109,15 +109,14 @@ declare const FileSchema: z.ZodObject<{
|
|
|
109
109
|
createdAt: z.ZodOptional<z.ZodString>;
|
|
110
110
|
updatedAt: z.ZodOptional<z.ZodString>;
|
|
111
111
|
}, "strip", z.ZodTypeAny, {
|
|
112
|
-
enabled: boolean;
|
|
113
112
|
name: string;
|
|
113
|
+
enabled: boolean;
|
|
114
114
|
id: string;
|
|
115
115
|
notify: {
|
|
116
116
|
config: Record<string, unknown>;
|
|
117
117
|
kind: "email" | "stdout" | "webhook" | "slack";
|
|
118
118
|
on: "always" | "failure" | "success";
|
|
119
119
|
}[];
|
|
120
|
-
description: string;
|
|
121
120
|
schedule: {
|
|
122
121
|
kind: "manual";
|
|
123
122
|
} | {
|
|
@@ -126,6 +125,8 @@ declare const FileSchema: z.ZodObject<{
|
|
|
126
125
|
randomizedDelaySec: number;
|
|
127
126
|
persistent: boolean;
|
|
128
127
|
};
|
|
128
|
+
tags: string[];
|
|
129
|
+
description: string;
|
|
129
130
|
targets: string[];
|
|
130
131
|
perTarget: boolean;
|
|
131
132
|
task: {
|
|
@@ -149,7 +150,6 @@ declare const FileSchema: z.ZodObject<{
|
|
|
149
150
|
tool: string;
|
|
150
151
|
args: Record<string, unknown>;
|
|
151
152
|
};
|
|
152
|
-
tags: string[];
|
|
153
153
|
updatedAt?: string | undefined;
|
|
154
154
|
createdAt?: string | undefined;
|
|
155
155
|
}, {
|
|
@@ -191,25 +191,24 @@ declare const FileSchema: z.ZodObject<{
|
|
|
191
191
|
config?: Record<string, unknown> | undefined;
|
|
192
192
|
on?: "always" | "failure" | "success" | undefined;
|
|
193
193
|
}[] | undefined;
|
|
194
|
+
tags?: string[] | undefined;
|
|
194
195
|
description?: string | undefined;
|
|
195
196
|
targets?: string[] | undefined;
|
|
196
197
|
perTarget?: boolean | undefined;
|
|
197
|
-
tags?: string[] | undefined;
|
|
198
198
|
createdAt?: string | undefined;
|
|
199
199
|
}>, "many">;
|
|
200
200
|
defaultsSeededAt: z.ZodOptional<z.ZodString>;
|
|
201
201
|
}, "strip", z.ZodTypeAny, {
|
|
202
202
|
version: 1;
|
|
203
203
|
routines: {
|
|
204
|
-
enabled: boolean;
|
|
205
204
|
name: string;
|
|
205
|
+
enabled: boolean;
|
|
206
206
|
id: string;
|
|
207
207
|
notify: {
|
|
208
208
|
config: Record<string, unknown>;
|
|
209
209
|
kind: "email" | "stdout" | "webhook" | "slack";
|
|
210
210
|
on: "always" | "failure" | "success";
|
|
211
211
|
}[];
|
|
212
|
-
description: string;
|
|
213
212
|
schedule: {
|
|
214
213
|
kind: "manual";
|
|
215
214
|
} | {
|
|
@@ -218,6 +217,8 @@ declare const FileSchema: z.ZodObject<{
|
|
|
218
217
|
randomizedDelaySec: number;
|
|
219
218
|
persistent: boolean;
|
|
220
219
|
};
|
|
220
|
+
tags: string[];
|
|
221
|
+
description: string;
|
|
221
222
|
targets: string[];
|
|
222
223
|
perTarget: boolean;
|
|
223
224
|
task: {
|
|
@@ -241,7 +242,6 @@ declare const FileSchema: z.ZodObject<{
|
|
|
241
242
|
tool: string;
|
|
242
243
|
args: Record<string, unknown>;
|
|
243
244
|
};
|
|
244
|
-
tags: string[];
|
|
245
245
|
updatedAt?: string | undefined;
|
|
246
246
|
createdAt?: string | undefined;
|
|
247
247
|
}[];
|
|
@@ -287,10 +287,10 @@ declare const FileSchema: z.ZodObject<{
|
|
|
287
287
|
config?: Record<string, unknown> | undefined;
|
|
288
288
|
on?: "always" | "failure" | "success" | undefined;
|
|
289
289
|
}[] | undefined;
|
|
290
|
+
tags?: string[] | undefined;
|
|
290
291
|
description?: string | undefined;
|
|
291
292
|
targets?: string[] | undefined;
|
|
292
293
|
perTarget?: boolean | undefined;
|
|
293
|
-
tags?: string[] | undefined;
|
|
294
294
|
createdAt?: string | undefined;
|
|
295
295
|
}[];
|
|
296
296
|
defaultsSeededAt?: string | undefined;
|
|
@@ -8,10 +8,15 @@ export declare function safeSealApp(app: string, content: string, sourceFile: st
|
|
|
8
8
|
export declare function safeSealDbSecrets(app: string, secretsMap: Record<string, string>, sourceDir: string): SealValidation;
|
|
9
9
|
export declare function setSecret(app: string, key: string, value: string, opts?: {
|
|
10
10
|
allowWeak?: boolean;
|
|
11
|
-
}): void
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
/** read a single secret value. deliberately does NOT hold the manifest
|
|
13
|
+
* lock — a concurrent setSecret/seal does an atomic file rename, so the
|
|
14
|
+
* worst-case race here is reading the immediate-pre-rotation vault blob.
|
|
15
|
+
* audit logging happens at the command layer so programmatic reads
|
|
16
|
+
* (background sync, validation) don't pollute the audit trail. */
|
|
12
17
|
export declare function getSecret(app: string, key: string): string | null;
|
|
13
|
-
export declare function importEnvFile(app: string, path: string): number
|
|
14
|
-
export declare function importDbSecrets(app: string, dir: string): number
|
|
18
|
+
export declare function importEnvFile(app: string, path: string): Promise<number>;
|
|
19
|
+
export declare function importDbSecrets(app: string, dir: string): Promise<number>;
|
|
15
20
|
export declare function exportApp(app: string): string;
|
|
16
21
|
export interface DriftResult {
|
|
17
22
|
app: string;
|
|
@@ -22,12 +27,32 @@ export interface DriftResult {
|
|
|
22
27
|
}
|
|
23
28
|
export declare function detectDrift(app?: string): DriftResult[];
|
|
24
29
|
export declare function unsealAll(): void;
|
|
25
|
-
export declare function sealFromRuntime(app?: string): string[]
|
|
26
|
-
|
|
30
|
+
export declare function sealFromRuntime(app?: string): Promise<string[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Rotate the age private key and re-encrypt every app's vault file with it.
|
|
33
|
+
*
|
|
34
|
+
* RECOVERY PROCEDURE (manual, only if rollback itself fails):
|
|
35
|
+
* 1. The previous private key is preserved at `<KEY_PATH>.old` while a
|
|
36
|
+
* rotation is in flight. If you see that file lying around, a rotation
|
|
37
|
+
* crashed mid-way.
|
|
38
|
+
* 2. Each app's pre-rotate encrypted file is preserved as
|
|
39
|
+
* `vault/<app>.{env,secrets}.age.bak-rotate-<ts>` for the duration of
|
|
40
|
+
* the rotation. They are removed automatically on success OR after a
|
|
41
|
+
* successful rollback.
|
|
42
|
+
* 3. To restore by hand: copy `<KEY_PATH>.old` back to `<KEY_PATH>`
|
|
43
|
+
* (chmod 0600), then for each `*.bak-rotate-<ts>` copy it over the
|
|
44
|
+
* matching encrypted file. This puts the vault back into the
|
|
45
|
+
* pre-rotation state.
|
|
46
|
+
*
|
|
47
|
+
* On a partial-failure path inside this function, that recovery is performed
|
|
48
|
+
* automatically before re-throwing. The whole rotation runs under the
|
|
49
|
+
* manifest's inter-process lock.
|
|
50
|
+
*/
|
|
51
|
+
export declare function rotateKey(): Promise<{
|
|
27
52
|
oldPubkey: string;
|
|
28
53
|
newPubkey: string;
|
|
29
54
|
appsRotated: string[];
|
|
30
|
-
}
|
|
55
|
+
}>;
|
|
31
56
|
export declare function getStatus(): {
|
|
32
57
|
initialized: boolean;
|
|
33
58
|
sealed: boolean;
|
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$/,
|