@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.
Files changed (230) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/patch-systemd.d.ts +7 -1
  37. package/dist/commands/patch-systemd.js +71 -31
  38. package/dist/commands/remove.d.ts +3 -1
  39. package/dist/commands/remove.js +37 -26
  40. package/dist/commands/restart.d.ts +4 -1
  41. package/dist/commands/restart.js +17 -20
  42. package/dist/commands/rollback.d.ts +4 -1
  43. package/dist/commands/rollback.js +33 -42
  44. package/dist/commands/secrets.js +157 -9
  45. package/dist/commands/start.d.ts +4 -1
  46. package/dist/commands/start.js +17 -20
  47. package/dist/commands/status.d.ts +1 -1
  48. package/dist/commands/status.js +21 -26
  49. package/dist/commands/stop.d.ts +4 -1
  50. package/dist/commands/stop.js +17 -20
  51. package/dist/commands/testflight.d.ts +1 -0
  52. package/dist/commands/testflight.js +193 -0
  53. package/dist/commands/update.d.ts +16 -0
  54. package/dist/commands/update.js +95 -0
  55. package/dist/core/audit/cache.d.ts +4 -0
  56. package/dist/core/audit/cache.js +37 -0
  57. package/dist/core/audit/config.d.ts +5 -0
  58. package/dist/core/audit/config.js +35 -0
  59. package/dist/core/audit/greenlight.d.ts +11 -0
  60. package/dist/core/audit/greenlight.js +81 -0
  61. package/dist/core/audit/reporters/cli.d.ts +3 -0
  62. package/dist/core/audit/reporters/cli.js +68 -0
  63. package/dist/core/audit/suppress.d.ts +6 -0
  64. package/dist/core/audit/suppress.js +37 -0
  65. package/dist/core/audit/target.d.ts +5 -0
  66. package/dist/core/audit/target.js +26 -0
  67. package/dist/core/audit/types.d.ts +54 -0
  68. package/dist/core/audit/types.js +5 -0
  69. package/dist/core/backup/browser-api.d.ts +66 -0
  70. package/dist/core/backup/browser-api.js +197 -0
  71. package/dist/core/backup/browser-server.d.ts +11 -0
  72. package/dist/core/backup/browser-server.js +241 -0
  73. package/dist/core/backup/browser-ui.d.ts +5 -0
  74. package/dist/core/backup/browser-ui.js +268 -0
  75. package/dist/core/backup/cloudflare.d.ts +7 -0
  76. package/dist/core/backup/cloudflare.js +82 -0
  77. package/dist/core/backup/config.d.ts +9 -0
  78. package/dist/core/backup/config.js +80 -0
  79. package/dist/core/backup/detect.d.ts +11 -0
  80. package/dist/core/backup/detect.js +71 -0
  81. package/dist/core/backup/dump.d.ts +11 -0
  82. package/dist/core/backup/dump.js +82 -0
  83. package/dist/core/backup/index.d.ts +9 -0
  84. package/dist/core/backup/index.js +9 -0
  85. package/dist/core/backup/repo.d.ts +71 -0
  86. package/dist/core/backup/repo.js +256 -0
  87. package/dist/core/backup/schedule.d.ts +17 -0
  88. package/dist/core/backup/schedule.js +90 -0
  89. package/dist/core/backup/sensitive.d.ts +5 -0
  90. package/dist/core/backup/sensitive.js +37 -0
  91. package/dist/core/backup/status.d.ts +3 -0
  92. package/dist/core/backup/status.js +29 -0
  93. package/dist/core/backup/statuspage.d.ts +23 -0
  94. package/dist/core/backup/statuspage.js +145 -0
  95. package/dist/core/backup/system.d.ts +24 -0
  96. package/dist/core/backup/system.js +209 -0
  97. package/dist/core/backup/totp.d.ts +16 -0
  98. package/dist/core/backup/totp.js +116 -0
  99. package/dist/core/backup/types.d.ts +70 -0
  100. package/dist/core/backup/types.js +7 -0
  101. package/dist/core/backup/unlock.d.ts +19 -0
  102. package/dist/core/backup/unlock.js +69 -0
  103. package/dist/core/boot-refresh.d.ts +1 -1
  104. package/dist/core/boot-refresh.js +10 -9
  105. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  106. package/dist/core/deps/actors/pr-creator.js +71 -18
  107. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  108. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  109. package/dist/core/deps/collectors/npm.js +3 -1
  110. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  111. package/dist/core/deps/collectors/vulnerability.js +31 -2
  112. package/dist/core/deps/config.js +6 -0
  113. package/dist/core/deps/scanner.js +1 -1
  114. package/dist/core/deps/types.d.ts +8 -0
  115. package/dist/core/env.d.ts +3 -0
  116. package/dist/core/env.js +11 -0
  117. package/dist/core/exec.d.ts +1 -0
  118. package/dist/core/exec.js +4 -0
  119. package/dist/core/file-lock.d.ts +18 -0
  120. package/dist/core/file-lock.js +44 -0
  121. package/dist/core/git-onboard.js +10 -13
  122. package/dist/core/github.d.ts +3 -1
  123. package/dist/core/github.js +10 -7
  124. package/dist/core/logs-policy.d.ts +5 -0
  125. package/dist/core/logs-policy.js +20 -1
  126. package/dist/core/operator.d.ts +21 -0
  127. package/dist/core/operator.js +54 -0
  128. package/dist/core/registry.d.ts +18 -0
  129. package/dist/core/registry.js +26 -0
  130. package/dist/core/routines/schema.d.ts +11 -11
  131. package/dist/core/routines/schema.js +14 -3
  132. package/dist/core/routines/store.d.ts +8 -8
  133. package/dist/core/secrets-ops.d.ts +31 -6
  134. package/dist/core/secrets-ops.js +208 -102
  135. package/dist/core/secrets-providers.js +2 -2
  136. package/dist/core/secrets-rotation.d.ts +1 -1
  137. package/dist/core/secrets-rotation.js +58 -52
  138. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  139. package/dist/core/secrets-v2-cleanup.js +94 -0
  140. package/dist/core/secrets-v2-creds.d.ts +9 -0
  141. package/dist/core/secrets-v2-creds.js +44 -0
  142. package/dist/core/secrets-v2-install.d.ts +13 -0
  143. package/dist/core/secrets-v2-install.js +76 -0
  144. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  145. package/dist/core/secrets-v2-keypair.js +31 -0
  146. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  147. package/dist/core/secrets-v2-migrate.js +395 -0
  148. package/dist/core/secrets-v2-ops.d.ts +36 -0
  149. package/dist/core/secrets-v2-ops.js +184 -0
  150. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  151. package/dist/core/secrets-v2-protocol.js +60 -0
  152. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  153. package/dist/core/secrets-v2-snapshot.js +115 -0
  154. package/dist/core/secrets-v2.d.ts +21 -0
  155. package/dist/core/secrets-v2.js +249 -0
  156. package/dist/core/secrets.d.ts +39 -4
  157. package/dist/core/secrets.js +91 -11
  158. package/dist/core/self-update.d.ts +32 -11
  159. package/dist/core/self-update.js +52 -14
  160. package/dist/core/testflight/asc.d.ts +12 -0
  161. package/dist/core/testflight/asc.js +101 -0
  162. package/dist/core/testflight/credentials.d.ts +3 -0
  163. package/dist/core/testflight/credentials.js +35 -0
  164. package/dist/core/testflight/resolve.d.ts +6 -0
  165. package/dist/core/testflight/resolve.js +44 -0
  166. package/dist/core/testflight/types.d.ts +13 -0
  167. package/dist/core/testflight/types.js +3 -0
  168. package/dist/core/testflight/workflow.d.ts +17 -0
  169. package/dist/core/testflight/workflow.js +65 -0
  170. package/dist/core/validate.d.ts +1 -0
  171. package/dist/core/validate.js +8 -0
  172. package/dist/index.js +0 -0
  173. package/dist/mcp/audit-tools.d.ts +2 -0
  174. package/dist/mcp/audit-tools.js +94 -0
  175. package/dist/mcp/git-tools.js +1 -1
  176. package/dist/mcp/registry-bridge.d.ts +10 -0
  177. package/dist/mcp/registry-bridge.js +65 -0
  178. package/dist/mcp/secrets-tools.js +2 -2
  179. package/dist/mcp/server.js +16 -82
  180. package/dist/mcp/testflight-tools.d.ts +2 -0
  181. package/dist/mcp/testflight-tools.js +52 -0
  182. package/dist/registry/context.d.ts +7 -0
  183. package/dist/registry/context.js +37 -0
  184. package/dist/registry/index.d.ts +5 -0
  185. package/dist/registry/index.js +44 -0
  186. package/dist/registry/parse-args.d.ts +13 -0
  187. package/dist/registry/parse-args.js +74 -0
  188. package/dist/registry/registry.d.ts +24 -0
  189. package/dist/registry/registry.js +26 -0
  190. package/dist/registry/render.d.ts +3 -0
  191. package/dist/registry/render.js +29 -0
  192. package/dist/registry/types.d.ts +50 -0
  193. package/dist/registry/types.js +1 -0
  194. package/dist/templates/agent-unit.d.ts +5 -0
  195. package/dist/templates/agent-unit.js +40 -0
  196. package/dist/templates/app-unit-edit.d.ts +2 -0
  197. package/dist/templates/app-unit-edit.js +46 -0
  198. package/dist/templates/compose-edit.d.ts +2 -0
  199. package/dist/templates/compose-edit.js +156 -0
  200. package/dist/templates/nginx.js +11 -0
  201. package/dist/templates/systemd.js +6 -0
  202. package/dist/tui/components/ArgForm.d.ts +7 -0
  203. package/dist/tui/components/ArgForm.js +64 -0
  204. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  205. package/dist/tui/components/ArgForm.test.js +19 -0
  206. package/dist/tui/components/KeyHint.js +5 -0
  207. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  208. package/dist/tui/hooks/use-secrets.js +7 -7
  209. package/dist/tui/router.d.ts +1 -0
  210. package/dist/tui/router.js +26 -9
  211. package/dist/tui/router.test.d.ts +1 -0
  212. package/dist/tui/router.test.js +13 -0
  213. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  214. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  215. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  216. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  217. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  218. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  219. package/dist/tui/types.d.ts +1 -1
  220. package/dist/tui/views/CommandPalette.d.ts +5 -0
  221. package/dist/tui/views/CommandPalette.js +90 -0
  222. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  223. package/dist/tui/views/CommandPalette.test.js +117 -0
  224. package/dist/tui/views/Dashboard.js +9 -6
  225. package/dist/tui/views/HealthView.js +9 -4
  226. package/dist/tui/views/SecretEdit.js +15 -16
  227. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  228. package/dist/tui/views/SecretEdit.test.js +82 -0
  229. package/dist/tui/views/SecretsView.js +26 -16
  230. package/package.json +8 -5
@@ -0,0 +1,395 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { addAgentDependency } from '../templates/app-unit-edit.js';
4
+ import { generateAgentUnit } from '../templates/agent-unit.js';
5
+ import { migrateComposeToV2 } from '../templates/compose-edit.js';
6
+ import { credentialPathFor, encryptCredential, removeCredential } from './secrets-v2-creds.js';
7
+ import { generateKeypair, reencryptForRecipient } from './secrets-v2-keypair.js';
8
+ import { listSnapshots, restoreSnapshot, snapshotApp } from './secrets-v2-snapshot.js';
9
+ import { loadManifest, saveManifest, VAULT_DIR } from './secrets.js';
10
+ /** vault dir resolution. mirrors secrets-v2-cleanup.ts and
11
+ * secrets-v2-install.ts — env override takes precedence so the generated
12
+ * systemd unit never carries an operator-specific hardcoded path. */
13
+ function resolveVaultDir() {
14
+ return process.env.FLEET_VAULT_DIR ?? VAULT_DIR;
15
+ }
16
+ import { findApp, load } from './registry.js';
17
+ import { execSafe } from './exec.js';
18
+ import { SecretsError } from './errors.js';
19
+ import { validateApp } from './secrets-validate.js';
20
+ const AGENT_UNIT_PATH = '/etc/systemd/system/fleet-secrets-agent@.service';
21
+ const STEP_NAMES = {
22
+ 1: 'snapshot app state',
23
+ 2: 'generate per-app age keypair',
24
+ 3: 're-encrypt vault blob for new recipient',
25
+ 4: 'install fleet-secrets-agent@ systemd unit template',
26
+ 5: 'migrate compose file to v2 socket mode',
27
+ 6: 'add agent dependency to app systemd unit',
28
+ 7: 'update manifest: mode=socket, recipient=<pub>',
29
+ 8: 'encrypt private key as systemd credential',
30
+ 9: 'enable and verify fleet-secrets-agent@<app>.service',
31
+ 10: 'restart app container (docker compose up -d --force-recreate)',
32
+ 11: 'health check app via HTTP /health',
33
+ };
34
+ async function pollHealth(url, deadlineMs = 30_000) {
35
+ const deadline = Date.now() + deadlineMs;
36
+ while (Date.now() < deadline) {
37
+ if (execSafe('curl', ['-sf', '--max-time', '5', url]).ok)
38
+ return true;
39
+ await new Promise(r => setTimeout(r, 250));
40
+ }
41
+ return false;
42
+ }
43
+ async function waitForSocket(socketPath, timeoutMs = 5000) {
44
+ const deadline = Date.now() + timeoutMs;
45
+ while (Date.now() < deadline) {
46
+ if (existsSync(socketPath))
47
+ return true;
48
+ await new Promise(r => setTimeout(r, 100));
49
+ }
50
+ return false;
51
+ }
52
+ function doRollback(opts) {
53
+ try {
54
+ restoreSnapshot(opts.snapInput, opts.snap);
55
+ }
56
+ catch { /* best-effort */ }
57
+ const bakPath = join(VAULT_DIR, `${opts.app}.env.age.v1.bak`);
58
+ if (existsSync(bakPath)) {
59
+ try {
60
+ unlinkSync(bakPath);
61
+ }
62
+ catch { /* best-effort */ }
63
+ }
64
+ if (opts.credentialWritten) {
65
+ try {
66
+ removeCredential(opts.app);
67
+ }
68
+ catch { /* best-effort */ }
69
+ }
70
+ if (opts.agentEnabled) {
71
+ try {
72
+ execSafe('systemctl', ['disable', '--now', `fleet-secrets-agent@${opts.app}.service`]);
73
+ }
74
+ catch { /* best-effort */ }
75
+ }
76
+ try {
77
+ execSafe('systemctl', ['daemon-reload']);
78
+ }
79
+ catch { /* best-effort */ }
80
+ if (!opts.noRestartApp && opts.failedStep >= 10) {
81
+ try {
82
+ execSafe('docker', ['compose', 'up', '-d', '--force-recreate'], { cwd: opts.composePath });
83
+ }
84
+ catch { /* best-effort */ }
85
+ }
86
+ }
87
+ export async function migrateAppToV2(opts) {
88
+ const { app, noRestartApp = false, dryRun = false } = opts;
89
+ const registry = load();
90
+ const appEntry = findApp(registry, app);
91
+ if (!appEntry) {
92
+ throw new SecretsError(`app '${app}' not found in fleet registry`);
93
+ }
94
+ const manifest = loadManifest();
95
+ if (manifest.apps[app]?.mode === 'socket') {
96
+ return {
97
+ app,
98
+ snapshotDir: null,
99
+ steps: [{ step: 1, name: 'already migrated to v2', ok: true }],
100
+ rolledBack: false,
101
+ };
102
+ }
103
+ if (dryRun) {
104
+ return {
105
+ app,
106
+ snapshotDir: null,
107
+ steps: Object.entries(STEP_NAMES).map(([n, name]) => ({ step: Number(n), name, ok: true })),
108
+ rolledBack: false,
109
+ };
110
+ }
111
+ const snapInput = {
112
+ app,
113
+ backupRoot: join(VAULT_DIR, 'backups'),
114
+ vaultDir: VAULT_DIR,
115
+ encryptedFile: `${app}.env.age`,
116
+ composeDir: appEntry.composePath,
117
+ composeFile: appEntry.composeFile ?? 'docker-compose.yml',
118
+ appUnitFile: `/etc/systemd/system/${app}.service`,
119
+ };
120
+ const steps = [];
121
+ const push = (step, ok, detail) => steps.push({ step, name: STEP_NAMES[step] ?? `step ${step}`, ok, detail });
122
+ let snap = null;
123
+ let credentialWritten = false;
124
+ let agentEnabled = false;
125
+ const rb = (failedStep, err) => {
126
+ push(failedStep, false, err instanceof Error ? err.message : String(err));
127
+ if (snap) {
128
+ doRollback({
129
+ snapInput, snap, app, credentialWritten, agentEnabled,
130
+ noRestartApp, failedStep, composePath: appEntry.composePath,
131
+ });
132
+ }
133
+ };
134
+ // step 1
135
+ try {
136
+ snap = snapshotApp(snapInput);
137
+ push(1, true, snap.dir);
138
+ }
139
+ catch (err) {
140
+ push(1, false, err instanceof Error ? err.message : String(err));
141
+ return { app, snapshotDir: null, steps, rolledBack: false };
142
+ }
143
+ // step 2
144
+ let keypair;
145
+ try {
146
+ keypair = generateKeypair();
147
+ push(2, true);
148
+ }
149
+ catch (err) {
150
+ rb(2, err);
151
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
152
+ }
153
+ // step 3
154
+ try {
155
+ const oldCiphertext = readFileSync(join(VAULT_DIR, `${app}.env.age`), 'utf-8');
156
+ const newCiphertext = reencryptForRecipient({
157
+ ciphertext: oldCiphertext,
158
+ oldKeyPath: '/etc/fleet/age.key',
159
+ newRecipient: keypair.publicKey,
160
+ });
161
+ renameSync(join(VAULT_DIR, `${app}.env.age`), join(VAULT_DIR, `${app}.env.age.v1.bak`));
162
+ writeFileSync(join(VAULT_DIR, `${app}.env.age`), newCiphertext);
163
+ push(3, true);
164
+ }
165
+ catch (err) {
166
+ rb(3, err);
167
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
168
+ }
169
+ // step 4
170
+ try {
171
+ const unitContent = generateAgentUnit(resolveVaultDir());
172
+ const existing = existsSync(AGENT_UNIT_PATH) ? readFileSync(AGENT_UNIT_PATH, 'utf-8') : null;
173
+ if (existing !== unitContent) {
174
+ writeFileSync(AGENT_UNIT_PATH, unitContent, { mode: 0o644 });
175
+ }
176
+ push(4, true);
177
+ }
178
+ catch (err) {
179
+ rb(4, err);
180
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
181
+ }
182
+ // step 5
183
+ try {
184
+ const composePath = join(appEntry.composePath, appEntry.composeFile ?? 'docker-compose.yml');
185
+ writeFileSync(composePath, migrateComposeToV2(readFileSync(composePath, 'utf-8'), app, appEntry.serviceName));
186
+ push(5, true);
187
+ }
188
+ catch (err) {
189
+ rb(5, err);
190
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
191
+ }
192
+ // step 6
193
+ try {
194
+ const unitPath = `/etc/systemd/system/${app}.service`;
195
+ writeFileSync(unitPath, addAgentDependency(readFileSync(unitPath, 'utf-8'), app));
196
+ push(6, true);
197
+ }
198
+ catch (err) {
199
+ rb(6, err);
200
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
201
+ }
202
+ // step 7
203
+ try {
204
+ const mf = loadManifest();
205
+ mf.apps[app] = { ...mf.apps[app], mode: 'socket', recipient: keypair.publicKey };
206
+ saveManifest(mf);
207
+ push(7, true);
208
+ }
209
+ catch (err) {
210
+ rb(7, err);
211
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
212
+ }
213
+ // step 8
214
+ try {
215
+ encryptCredential({ name: `${app}-age-key`, plaintext: keypair.privateKey, outputPath: credentialPathFor(app) });
216
+ credentialWritten = true;
217
+ push(8, true);
218
+ }
219
+ catch (err) {
220
+ rb(8, err);
221
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
222
+ }
223
+ // step 9
224
+ try {
225
+ const reload = execSafe('systemctl', ['daemon-reload']);
226
+ if (!reload.ok)
227
+ throw new SecretsError(`systemctl daemon-reload failed: ${reload.stderr}`);
228
+ const enable = execSafe('systemctl', ['enable', '--now', `fleet-secrets-agent@${app}.service`]);
229
+ if (!enable.ok)
230
+ throw new SecretsError(`systemctl enable failed: ${enable.stderr}`);
231
+ agentEnabled = true;
232
+ const active = execSafe('systemctl', ['is-active', `fleet-secrets-agent@${app}.service`]);
233
+ if (active.stdout.trim() !== 'active') {
234
+ throw new SecretsError(`agent not active: ${active.stdout.trim()}`);
235
+ }
236
+ if (!await waitForSocket(`/run/fleet-secrets/${app}.sock`)) {
237
+ throw new SecretsError(`agent socket did not appear within 5s: /run/fleet-secrets/${app}.sock`);
238
+ }
239
+ push(9, true);
240
+ }
241
+ catch (err) {
242
+ rb(9, err);
243
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
244
+ }
245
+ // step 10
246
+ if (noRestartApp) {
247
+ push(10, true);
248
+ }
249
+ else {
250
+ try {
251
+ execSafe('docker', ['compose', 'up', '-d', '--force-recreate'], { cwd: appEntry.composePath });
252
+ push(10, true);
253
+ }
254
+ catch (err) {
255
+ rb(10, err);
256
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
257
+ }
258
+ }
259
+ // step 11
260
+ if (noRestartApp) {
261
+ push(11, true);
262
+ }
263
+ else {
264
+ try {
265
+ const port = appEntry.port;
266
+ const url = port ? `http://localhost:${port}/health` : 'http://localhost/health';
267
+ if (!await pollHealth(url))
268
+ throw new SecretsError(`health check timed out after 30s for ${url}`);
269
+ push(11, true);
270
+ }
271
+ catch (err) {
272
+ rb(11, err);
273
+ return { app, snapshotDir: snap.dir, steps, rolledBack: true };
274
+ }
275
+ }
276
+ return { app, snapshotDir: snap.dir, steps, rolledBack: false };
277
+ }
278
+ const REVERT_STEP_NAMES = {
279
+ 1: 'disable fleet-secrets-agent@<app>.service (best-effort)',
280
+ 2: 'remove systemd credential for app (best-effort)',
281
+ 3: 'remove .v1.bak file if present (best-effort)',
282
+ 4: 'restore snapshot (vault blob, manifest, compose, unit)',
283
+ 5: 'systemctl daemon-reload',
284
+ 6: 'restart app container (docker compose up -d --force-recreate)',
285
+ 7: 'validate v1 unseal-based secrets',
286
+ };
287
+ export async function revertAppFromV2(opts) {
288
+ const { app, snapshotTimestamp } = opts;
289
+ const registry = load();
290
+ const appEntry = findApp(registry, app);
291
+ if (!appEntry) {
292
+ throw new SecretsError(`app '${app}' not found in fleet registry`);
293
+ }
294
+ const manifest = loadManifest();
295
+ if (manifest.apps[app]?.mode !== 'socket') {
296
+ throw new SecretsError(`app '${app}' is not in v2 (socket) mode — nothing to revert`);
297
+ }
298
+ const snapshots = listSnapshots(join(VAULT_DIR, 'backups'), app);
299
+ if (snapshots.length === 0) {
300
+ throw new SecretsError(`no snapshots found for app '${app}' — cannot revert`);
301
+ }
302
+ let snap;
303
+ if (snapshotTimestamp !== undefined) {
304
+ const found = snapshots.find(s => s.timestamp === snapshotTimestamp);
305
+ if (!found) {
306
+ throw new SecretsError(`no snapshot with timestamp '${snapshotTimestamp}' found for app '${app}'`);
307
+ }
308
+ snap = found;
309
+ }
310
+ else {
311
+ snap = snapshots[0];
312
+ }
313
+ const snapInput = {
314
+ app,
315
+ backupRoot: join(VAULT_DIR, 'backups'),
316
+ vaultDir: VAULT_DIR,
317
+ encryptedFile: `${app}.env.age`,
318
+ composeDir: appEntry.composePath,
319
+ composeFile: appEntry.composeFile ?? 'docker-compose.yml',
320
+ appUnitFile: `/etc/systemd/system/${app}.service`,
321
+ };
322
+ const steps = [];
323
+ const push = (step, ok, detail) => steps.push({ step, name: REVERT_STEP_NAMES[step] ?? `step ${step}`, ok, detail });
324
+ // step 1 — best-effort: disable agent unit
325
+ const disableResult = execSafe('systemctl', ['disable', '--now', `fleet-secrets-agent@${app}.service`]);
326
+ if (!disableResult.ok) {
327
+ push(1, false, `disable failed (best-effort): ${disableResult.stderr}`);
328
+ }
329
+ else {
330
+ push(1, true, 'agent unit disabled');
331
+ }
332
+ // step 2 — best-effort: remove credential
333
+ try {
334
+ removeCredential(app);
335
+ push(2, true, 'credential removed');
336
+ }
337
+ catch (err) {
338
+ push(2, false, `removeCredential failed (best-effort): ${err.message}`);
339
+ }
340
+ // step 3 — best-effort: remove v1 backup file
341
+ const bakPath = join(VAULT_DIR, `${app}.env.age.v1.bak`);
342
+ try {
343
+ if (existsSync(bakPath)) {
344
+ unlinkSync(bakPath);
345
+ }
346
+ push(3, true);
347
+ }
348
+ catch (err) {
349
+ push(3, false, `.v1.bak removal failed (best-effort): ${err.message}`);
350
+ }
351
+ // step 4 — restore snapshot (mandatory)
352
+ try {
353
+ restoreSnapshot(snapInput, snap);
354
+ push(4, true);
355
+ }
356
+ catch (err) {
357
+ push(4, false, err instanceof Error ? err.message : String(err));
358
+ throw err;
359
+ }
360
+ // step 5 — daemon-reload (mandatory)
361
+ try {
362
+ const reload = execSafe('systemctl', ['daemon-reload']);
363
+ if (!reload.ok)
364
+ throw new SecretsError(`systemctl daemon-reload failed: ${reload.stderr}`);
365
+ push(5, true);
366
+ }
367
+ catch (err) {
368
+ push(5, false, err instanceof Error ? err.message : String(err));
369
+ throw err;
370
+ }
371
+ // step 6 — restart app (mandatory)
372
+ try {
373
+ execSafe('docker', ['compose', 'up', '-d', '--force-recreate'], { cwd: appEntry.composePath });
374
+ push(6, true);
375
+ }
376
+ catch (err) {
377
+ push(6, false, err instanceof Error ? err.message : String(err));
378
+ throw err;
379
+ }
380
+ // step 7 — validate v1 secrets (mandatory)
381
+ try {
382
+ const validation = validateApp(app);
383
+ if (!validation.ok) {
384
+ throw new SecretsError(`v1 secrets validation failed — missing keys: ${validation.missing.join(', ')}`);
385
+ }
386
+ push(7, true);
387
+ }
388
+ catch (err) {
389
+ push(7, false, err instanceof Error ? err.message : String(err));
390
+ throw err;
391
+ }
392
+ const mandatoryStepNums = new Set([4, 5, 6, 7]);
393
+ const ok = steps.filter(s => mandatoryStepNums.has(s.step)).every(s => s.ok);
394
+ return { app, snapshotUsed: snap.timestamp, steps, ok };
395
+ }
@@ -0,0 +1,36 @@
1
+ export interface V2AppStatus {
2
+ name: string;
3
+ mode: 'unseal' | 'socket';
4
+ agentActive: boolean;
5
+ socketOk: boolean;
6
+ lastSealedAt: string;
7
+ recipient?: string;
8
+ keyCount: number;
9
+ }
10
+ export interface V2StatusReport {
11
+ apps: V2AppStatus[];
12
+ v1Count: number;
13
+ v2Count: number;
14
+ }
15
+ export interface V2DriftCheck {
16
+ name: string;
17
+ ok: boolean;
18
+ detail?: string;
19
+ }
20
+ export interface V2DriftResult {
21
+ app: string;
22
+ ok: boolean;
23
+ checks: V2DriftCheck[];
24
+ }
25
+ /**
26
+ * Verify the deployed v2 state of an app matches what's recorded in the manifest.
27
+ * Each check returns ok/detail; the overall ok is true only if all checks pass.
28
+ *
29
+ * @param app The app name to check.
30
+ * @param socketPathOverride Test-only injection point. In production, the socket
31
+ * path is derived from the app name (`/run/fleet-secrets/<app>.sock`). Tests
32
+ * pass a temp-dir path to avoid mocking node:net. Do not set this in
33
+ * production code.
34
+ */
35
+ export declare function getV2Status(): V2StatusReport;
36
+ export declare function detectV2Drift(app: string, socketPathOverride?: string): Promise<V2DriftResult>;
@@ -0,0 +1,184 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { createConnection } from 'node:net';
3
+ import { credentialPathFor } from './secrets-v2-creds.js';
4
+ import { execSafe } from './exec.js';
5
+ import { loadManifest } from './secrets.js';
6
+ const AGE_PUBKEY_RE = /^age1[a-z0-9]+$/;
7
+ function checkRecipient(recipient) {
8
+ if (!recipient || !AGE_PUBKEY_RE.test(recipient)) {
9
+ return {
10
+ name: 'recipient_matches',
11
+ ok: false,
12
+ detail: `invalid age public key format: ${recipient ?? '(missing)'}`,
13
+ };
14
+ }
15
+ return {
16
+ name: 'recipient_matches',
17
+ ok: true,
18
+ detail: 'format only — full cryptographic check requires running agent',
19
+ };
20
+ }
21
+ function checkCredentialPresent(app) {
22
+ const credPath = credentialPathFor(app);
23
+ const present = existsSync(credPath);
24
+ return {
25
+ name: 'credential_present',
26
+ ok: present,
27
+ detail: present ? undefined : `not found: ${credPath}`,
28
+ };
29
+ }
30
+ function checkAgentActive(app) {
31
+ const unit = `fleet-secrets-agent@${app}.service`;
32
+ const result = execSafe('systemctl', ['is-active', unit]);
33
+ const active = result.stdout.trim() === 'active';
34
+ return {
35
+ name: 'agent_active',
36
+ ok: active,
37
+ detail: active ? undefined : `systemctl is-active: ${result.stdout.trim() || result.stderr.trim()}`,
38
+ };
39
+ }
40
+ function checkSocketPresent(socketPath) {
41
+ const present = existsSync(socketPath);
42
+ return {
43
+ name: 'socket_present',
44
+ ok: present,
45
+ detail: present ? undefined : `not found: ${socketPath}`,
46
+ };
47
+ }
48
+ function checkSocketPerms(socketPath) {
49
+ try {
50
+ const st = statSync(socketPath);
51
+ const perms = st.mode & 0o777;
52
+ const correct = perms === 0o660;
53
+ return {
54
+ name: 'socket_perms',
55
+ ok: correct,
56
+ detail: correct ? undefined : `expected 0o660, got 0o${perms.toString(8)}`,
57
+ };
58
+ }
59
+ catch (err) {
60
+ return {
61
+ name: 'socket_perms',
62
+ ok: false,
63
+ detail: `statSync failed: ${err.message}`,
64
+ };
65
+ }
66
+ }
67
+ function fetchFromSocket(socketPath, timeoutMs = 2000) {
68
+ return new Promise((resolve, reject) => {
69
+ const sock = createConnection(socketPath);
70
+ let response = '';
71
+ sock.setTimeout(timeoutMs, () => {
72
+ sock.destroy();
73
+ reject(new Error('socket fetch timed out'));
74
+ });
75
+ sock.on('error', (err) => reject(err));
76
+ sock.on('connect', () => {
77
+ sock.write('GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
78
+ });
79
+ sock.on('data', (chunk) => { response += chunk.toString(); });
80
+ sock.on('end', () => resolve(response));
81
+ sock.on('close', () => {
82
+ if (response)
83
+ resolve(response);
84
+ });
85
+ });
86
+ }
87
+ function parseJsonBody(raw) {
88
+ const sep = raw.indexOf('\r\n\r\n');
89
+ const body = sep === -1 ? raw : raw.slice(sep + 4);
90
+ return JSON.parse(body.trim());
91
+ }
92
+ async function checkSampleFetchKeys(app, socketPath, keyCount) {
93
+ try {
94
+ const raw = await fetchFromSocket(socketPath);
95
+ const data = parseJsonBody(raw);
96
+ if (data.app !== app) {
97
+ return {
98
+ name: 'sample_fetch_keys',
99
+ ok: false,
100
+ detail: `app mismatch: expected '${app}', got '${String(data.app)}'`,
101
+ };
102
+ }
103
+ const secrets = typeof data.secrets === 'number' ? data.secrets : -1;
104
+ if (keyCount > 0 && secrets <= 0) {
105
+ return {
106
+ name: 'sample_fetch_keys',
107
+ ok: false,
108
+ detail: `expected secrets > 0, got ${secrets}`,
109
+ };
110
+ }
111
+ return { name: 'sample_fetch_keys', ok: true };
112
+ }
113
+ catch (err) {
114
+ return {
115
+ name: 'sample_fetch_keys',
116
+ ok: false,
117
+ detail: err.message,
118
+ };
119
+ }
120
+ }
121
+ /**
122
+ * Verify the deployed v2 state of an app matches what's recorded in the manifest.
123
+ * Each check returns ok/detail; the overall ok is true only if all checks pass.
124
+ *
125
+ * @param app The app name to check.
126
+ * @param socketPathOverride Test-only injection point. In production, the socket
127
+ * path is derived from the app name (`/run/fleet-secrets/<app>.sock`). Tests
128
+ * pass a temp-dir path to avoid mocking node:net. Do not set this in
129
+ * production code.
130
+ */
131
+ export function getV2Status() {
132
+ const manifest = loadManifest();
133
+ const apps = [];
134
+ let v1Count = 0;
135
+ let v2Count = 0;
136
+ for (const [name, entry] of Object.entries(manifest.apps)) {
137
+ const mode = (entry.mode ?? 'unseal');
138
+ let agentActive = false;
139
+ let socketOk = false;
140
+ if (mode === 'socket') {
141
+ v2Count++;
142
+ const isActive = execSafe('systemctl', ['is-active', `fleet-secrets-agent@${name}.service`]);
143
+ agentActive = isActive.ok && isActive.stdout.trim() === 'active';
144
+ const socketPath = `/run/fleet-secrets/${name}.sock`;
145
+ if (existsSync(socketPath)) {
146
+ try {
147
+ const perms = statSync(socketPath).mode & 0o777;
148
+ socketOk = perms === 0o660;
149
+ }
150
+ catch { /* keep socketOk false */ }
151
+ }
152
+ }
153
+ else {
154
+ v1Count++;
155
+ }
156
+ apps.push({ name, mode, agentActive, socketOk, lastSealedAt: entry.lastSealedAt, recipient: entry.recipient, keyCount: entry.keyCount });
157
+ }
158
+ return { apps, v1Count, v2Count };
159
+ }
160
+ export async function detectV2Drift(app, socketPathOverride) {
161
+ const manifest = loadManifest();
162
+ const entry = manifest.apps[app];
163
+ if (!entry || entry.mode !== 'socket') {
164
+ return {
165
+ app,
166
+ ok: false,
167
+ checks: [{ name: 'mode', ok: false, detail: 'app not in v2 mode' }],
168
+ };
169
+ }
170
+ const socketPath = socketPathOverride ?? `/run/fleet-secrets/${app}.sock`;
171
+ const checks = [
172
+ checkRecipient(entry.recipient),
173
+ checkCredentialPresent(app),
174
+ checkAgentActive(app),
175
+ checkSocketPresent(socketPath),
176
+ checkSocketPerms(socketPath),
177
+ await checkSampleFetchKeys(app, socketPath, entry.keyCount),
178
+ ];
179
+ return {
180
+ app,
181
+ ok: checks.every(c => c.ok),
182
+ checks,
183
+ };
184
+ }
@@ -0,0 +1,19 @@
1
+ export declare class ProtocolError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export interface ParsedRequest {
5
+ method: 'GET' | 'POST';
6
+ path: string;
7
+ body: string;
8
+ }
9
+ /**
10
+ * Parse a single HTTP/1.1 request from a Unix-socket read buffer.
11
+ *
12
+ * Callers must enforce an upper bound on `buf` size BEFORE invoking. This
13
+ * function will scan the full buffer for the header terminator (`\r\n\r\n`)
14
+ * and so does O(n) work on n bytes — feeding it arbitrarily large input is
15
+ * a DoS vector. The socket server should cap reads at a small multiple of
16
+ * MAX_BODY (e.g., 8 KiB) and call this only on bounded buffers.
17
+ */
18
+ export declare function parseRequest(buf: Buffer): ParsedRequest;
19
+ export declare function writeResponse(status: number, body: unknown): Buffer;
@@ -0,0 +1,60 @@
1
+ export class ProtocolError extends Error {
2
+ constructor(message) { super(message); this.name = 'ProtocolError'; }
3
+ }
4
+ const MAX_BODY = 1024;
5
+ const ALLOWED_METHODS = new Set(['GET', 'POST']);
6
+ /**
7
+ * Parse a single HTTP/1.1 request from a Unix-socket read buffer.
8
+ *
9
+ * Callers must enforce an upper bound on `buf` size BEFORE invoking. This
10
+ * function will scan the full buffer for the header terminator (`\r\n\r\n`)
11
+ * and so does O(n) work on n bytes — feeding it arbitrarily large input is
12
+ * a DoS vector. The socket server should cap reads at a small multiple of
13
+ * MAX_BODY (e.g., 8 KiB) and call this only on bounded buffers.
14
+ */
15
+ export function parseRequest(buf) {
16
+ const TERM = Buffer.from('\r\n\r\n');
17
+ const headerEnd = buf.indexOf(TERM);
18
+ if (headerEnd < 0)
19
+ throw new ProtocolError('incomplete request: no header terminator');
20
+ const bodyStart = headerEnd + TERM.length;
21
+ const bodyBytes = buf.length - bodyStart;
22
+ if (bodyBytes > MAX_BODY) {
23
+ throw new ProtocolError(`body too large: ${bodyBytes} > ${MAX_BODY}`);
24
+ }
25
+ const headerBlock = buf.slice(0, headerEnd).toString('utf-8');
26
+ const body = buf.slice(bodyStart).toString('utf-8');
27
+ const first = headerBlock.split('\r\n')[0] ?? '';
28
+ const m = first.match(/^([A-Z]+) (\S+) HTTP\/1\.1$/);
29
+ if (!m)
30
+ throw new ProtocolError(`malformed request line: ${first}`);
31
+ const method = m[1];
32
+ const path = m[2];
33
+ if (!ALLOWED_METHODS.has(method)) {
34
+ throw new ProtocolError(`method not allowed: ${method}`);
35
+ }
36
+ if (path.includes('?')) {
37
+ throw new ProtocolError('query string not supported');
38
+ }
39
+ return { method: method, path, body };
40
+ }
41
+ const STATUS = {
42
+ 200: '200 OK',
43
+ 400: '400 Bad Request',
44
+ 404: '404 Not Found',
45
+ 405: '405 Method Not Allowed',
46
+ 413: '413 Payload Too Large',
47
+ 429: '429 Too Many Requests',
48
+ 500: '500 Internal Server Error',
49
+ };
50
+ export function writeResponse(status, body) {
51
+ const statusLine = STATUS[status] ?? `${status} Unknown`;
52
+ const json = JSON.stringify(body);
53
+ const buf = Buffer.from(json, 'utf-8');
54
+ const head = `HTTP/1.1 ${statusLine}\r\n` +
55
+ `Content-Type: application/json\r\n` +
56
+ `Content-Length: ${buf.length}\r\n` +
57
+ `Connection: close\r\n` +
58
+ `\r\n`;
59
+ return Buffer.concat([Buffer.from(head, 'utf-8'), buf]);
60
+ }