@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.
Files changed (233) 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/notify.d.ts +1 -0
  37. package/dist/commands/notify.js +51 -0
  38. package/dist/commands/patch-systemd.d.ts +7 -1
  39. package/dist/commands/patch-systemd.js +71 -31
  40. package/dist/commands/remove.d.ts +3 -1
  41. package/dist/commands/remove.js +37 -26
  42. package/dist/commands/restart.d.ts +4 -1
  43. package/dist/commands/restart.js +17 -20
  44. package/dist/commands/rollback.d.ts +4 -1
  45. package/dist/commands/rollback.js +33 -42
  46. package/dist/commands/secrets.js +157 -9
  47. package/dist/commands/start.d.ts +4 -1
  48. package/dist/commands/start.js +17 -20
  49. package/dist/commands/status.d.ts +1 -1
  50. package/dist/commands/status.js +21 -26
  51. package/dist/commands/stop.d.ts +4 -1
  52. package/dist/commands/stop.js +17 -20
  53. package/dist/commands/testflight.d.ts +1 -0
  54. package/dist/commands/testflight.js +193 -0
  55. package/dist/commands/update.d.ts +16 -0
  56. package/dist/commands/update.js +95 -0
  57. package/dist/core/audit/cache.d.ts +4 -0
  58. package/dist/core/audit/cache.js +37 -0
  59. package/dist/core/audit/config.d.ts +5 -0
  60. package/dist/core/audit/config.js +35 -0
  61. package/dist/core/audit/greenlight.d.ts +11 -0
  62. package/dist/core/audit/greenlight.js +81 -0
  63. package/dist/core/audit/reporters/cli.d.ts +3 -0
  64. package/dist/core/audit/reporters/cli.js +68 -0
  65. package/dist/core/audit/suppress.d.ts +6 -0
  66. package/dist/core/audit/suppress.js +37 -0
  67. package/dist/core/audit/target.d.ts +5 -0
  68. package/dist/core/audit/target.js +26 -0
  69. package/dist/core/audit/types.d.ts +54 -0
  70. package/dist/core/audit/types.js +5 -0
  71. package/dist/core/backup/browser-api.d.ts +66 -0
  72. package/dist/core/backup/browser-api.js +197 -0
  73. package/dist/core/backup/browser-server.d.ts +11 -0
  74. package/dist/core/backup/browser-server.js +241 -0
  75. package/dist/core/backup/browser-ui.d.ts +5 -0
  76. package/dist/core/backup/browser-ui.js +268 -0
  77. package/dist/core/backup/cloudflare.d.ts +7 -0
  78. package/dist/core/backup/cloudflare.js +82 -0
  79. package/dist/core/backup/config.d.ts +9 -0
  80. package/dist/core/backup/config.js +80 -0
  81. package/dist/core/backup/detect.d.ts +11 -0
  82. package/dist/core/backup/detect.js +71 -0
  83. package/dist/core/backup/dump.d.ts +11 -0
  84. package/dist/core/backup/dump.js +82 -0
  85. package/dist/core/backup/index.d.ts +9 -0
  86. package/dist/core/backup/index.js +9 -0
  87. package/dist/core/backup/repo.d.ts +71 -0
  88. package/dist/core/backup/repo.js +256 -0
  89. package/dist/core/backup/schedule.d.ts +17 -0
  90. package/dist/core/backup/schedule.js +90 -0
  91. package/dist/core/backup/sensitive.d.ts +5 -0
  92. package/dist/core/backup/sensitive.js +37 -0
  93. package/dist/core/backup/status.d.ts +3 -0
  94. package/dist/core/backup/status.js +29 -0
  95. package/dist/core/backup/statuspage.d.ts +23 -0
  96. package/dist/core/backup/statuspage.js +145 -0
  97. package/dist/core/backup/system.d.ts +24 -0
  98. package/dist/core/backup/system.js +209 -0
  99. package/dist/core/backup/totp.d.ts +16 -0
  100. package/dist/core/backup/totp.js +116 -0
  101. package/dist/core/backup/types.d.ts +70 -0
  102. package/dist/core/backup/types.js +7 -0
  103. package/dist/core/backup/unlock.d.ts +19 -0
  104. package/dist/core/backup/unlock.js +69 -0
  105. package/dist/core/boot-refresh.d.ts +1 -1
  106. package/dist/core/boot-refresh.js +10 -9
  107. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  108. package/dist/core/deps/actors/pr-creator.js +71 -18
  109. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  110. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  111. package/dist/core/deps/collectors/npm.js +3 -1
  112. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  113. package/dist/core/deps/collectors/vulnerability.js +31 -2
  114. package/dist/core/deps/config.js +6 -0
  115. package/dist/core/deps/scanner.js +1 -1
  116. package/dist/core/deps/types.d.ts +8 -0
  117. package/dist/core/env.d.ts +3 -0
  118. package/dist/core/env.js +11 -0
  119. package/dist/core/exec.d.ts +1 -0
  120. package/dist/core/exec.js +4 -0
  121. package/dist/core/file-lock.d.ts +18 -0
  122. package/dist/core/file-lock.js +44 -0
  123. package/dist/core/git-onboard.js +10 -13
  124. package/dist/core/github.d.ts +3 -1
  125. package/dist/core/github.js +10 -7
  126. package/dist/core/logs-policy.d.ts +5 -0
  127. package/dist/core/logs-policy.js +20 -1
  128. package/dist/core/operator.d.ts +21 -0
  129. package/dist/core/operator.js +54 -0
  130. package/dist/core/registry.d.ts +18 -0
  131. package/dist/core/registry.js +26 -0
  132. package/dist/core/routines/schema.d.ts +11 -11
  133. package/dist/core/routines/schema.js +14 -3
  134. package/dist/core/routines/store.d.ts +8 -8
  135. package/dist/core/secrets-ops.d.ts +31 -6
  136. package/dist/core/secrets-ops.js +208 -102
  137. package/dist/core/secrets-providers.js +2 -2
  138. package/dist/core/secrets-rotation.d.ts +1 -1
  139. package/dist/core/secrets-rotation.js +58 -52
  140. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  141. package/dist/core/secrets-v2-cleanup.js +94 -0
  142. package/dist/core/secrets-v2-creds.d.ts +9 -0
  143. package/dist/core/secrets-v2-creds.js +44 -0
  144. package/dist/core/secrets-v2-install.d.ts +13 -0
  145. package/dist/core/secrets-v2-install.js +76 -0
  146. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  147. package/dist/core/secrets-v2-keypair.js +31 -0
  148. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  149. package/dist/core/secrets-v2-migrate.js +395 -0
  150. package/dist/core/secrets-v2-ops.d.ts +36 -0
  151. package/dist/core/secrets-v2-ops.js +184 -0
  152. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  153. package/dist/core/secrets-v2-protocol.js +60 -0
  154. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  155. package/dist/core/secrets-v2-snapshot.js +115 -0
  156. package/dist/core/secrets-v2.d.ts +21 -0
  157. package/dist/core/secrets-v2.js +249 -0
  158. package/dist/core/secrets.d.ts +39 -4
  159. package/dist/core/secrets.js +91 -11
  160. package/dist/core/self-update.d.ts +32 -11
  161. package/dist/core/self-update.js +52 -14
  162. package/dist/core/testflight/asc.d.ts +12 -0
  163. package/dist/core/testflight/asc.js +101 -0
  164. package/dist/core/testflight/credentials.d.ts +3 -0
  165. package/dist/core/testflight/credentials.js +35 -0
  166. package/dist/core/testflight/eas.d.ts +4 -0
  167. package/dist/core/testflight/eas.js +38 -0
  168. package/dist/core/testflight/resolve.d.ts +6 -0
  169. package/dist/core/testflight/resolve.js +44 -0
  170. package/dist/core/testflight/types.d.ts +13 -0
  171. package/dist/core/testflight/types.js +3 -0
  172. package/dist/core/testflight/workflow.d.ts +17 -0
  173. package/dist/core/testflight/workflow.js +65 -0
  174. package/dist/core/validate.d.ts +1 -0
  175. package/dist/core/validate.js +8 -0
  176. package/dist/mcp/audit-tools.d.ts +2 -0
  177. package/dist/mcp/audit-tools.js +94 -0
  178. package/dist/mcp/git-tools.js +1 -1
  179. package/dist/mcp/registry-bridge.d.ts +10 -0
  180. package/dist/mcp/registry-bridge.js +65 -0
  181. package/dist/mcp/secrets-tools.js +2 -2
  182. package/dist/mcp/server.js +16 -82
  183. package/dist/mcp/testflight-tools.d.ts +2 -0
  184. package/dist/mcp/testflight-tools.js +52 -0
  185. package/dist/registry/context.d.ts +7 -0
  186. package/dist/registry/context.js +37 -0
  187. package/dist/registry/index.d.ts +5 -0
  188. package/dist/registry/index.js +44 -0
  189. package/dist/registry/parse-args.d.ts +13 -0
  190. package/dist/registry/parse-args.js +74 -0
  191. package/dist/registry/registry.d.ts +24 -0
  192. package/dist/registry/registry.js +26 -0
  193. package/dist/registry/render.d.ts +3 -0
  194. package/dist/registry/render.js +29 -0
  195. package/dist/registry/types.d.ts +50 -0
  196. package/dist/registry/types.js +1 -0
  197. package/dist/templates/agent-unit.d.ts +5 -0
  198. package/dist/templates/agent-unit.js +40 -0
  199. package/dist/templates/app-unit-edit.d.ts +2 -0
  200. package/dist/templates/app-unit-edit.js +46 -0
  201. package/dist/templates/compose-edit.d.ts +2 -0
  202. package/dist/templates/compose-edit.js +156 -0
  203. package/dist/templates/nginx.js +11 -0
  204. package/dist/templates/systemd.js +6 -0
  205. package/dist/tui/components/ArgForm.d.ts +7 -0
  206. package/dist/tui/components/ArgForm.js +64 -0
  207. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  208. package/dist/tui/components/ArgForm.test.js +19 -0
  209. package/dist/tui/components/KeyHint.js +5 -0
  210. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  211. package/dist/tui/hooks/use-secrets.js +7 -7
  212. package/dist/tui/router.d.ts +1 -0
  213. package/dist/tui/router.js +26 -9
  214. package/dist/tui/router.test.d.ts +1 -0
  215. package/dist/tui/router.test.js +13 -0
  216. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  217. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  218. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  219. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  220. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  221. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  222. package/dist/tui/types.d.ts +1 -1
  223. package/dist/tui/views/CommandPalette.d.ts +5 -0
  224. package/dist/tui/views/CommandPalette.js +90 -0
  225. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  226. package/dist/tui/views/CommandPalette.test.js +117 -0
  227. package/dist/tui/views/Dashboard.js +9 -6
  228. package/dist/tui/views/HealthView.js +9 -4
  229. package/dist/tui/views/SecretEdit.js +15 -16
  230. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  231. package/dist/tui/views/SecretEdit.test.js +82 -0
  232. package/dist/tui/views/SecretsView.js +26 -16
  233. package/package.json +8 -5
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ export interface SnapshotInput {
2
+ app: string;
3
+ backupRoot: string;
4
+ vaultDir: string;
5
+ encryptedFile: string;
6
+ composeDir: string;
7
+ composeFile: string;
8
+ appUnitFile: string;
9
+ }
10
+ export interface Snapshot {
11
+ app: string;
12
+ timestamp: string;
13
+ dir: string;
14
+ manifestEntry: unknown;
15
+ }
16
+ /**
17
+ * capture a timestamped backup of all four artefacts for an app:
18
+ * - encrypted vault blob
19
+ * - manifest entry (as manifest.json in the snapshot dir)
20
+ * - compose file
21
+ * - systemd unit (if it exists)
22
+ */
23
+ export declare function snapshotApp(input: SnapshotInput): Snapshot;
24
+ /**
25
+ * restore all four artefacts from a snapshot back to their original locations.
26
+ * the manifest is merged: only apps[app] is replaced, other apps are untouched.
27
+ * if the unit file wasn't captured (didn't exist at snapshot time), no unit is written.
28
+ */
29
+ export declare function restoreSnapshot(input: SnapshotInput, snap: Snapshot): void;
30
+ /**
31
+ * walk backupRoot looking for <timestamp>/<app>/ directories.
32
+ * returns snapshots sorted newest-first by timestamp string (lexicographic, which
33
+ * works correctly for the ISO-safe format with dashes instead of colons/dots).
34
+ * returns [] if backupRoot doesn't exist.
35
+ */
36
+ export declare function listSnapshots(backupRoot: string, app: string): Snapshot[];
@@ -0,0 +1,115 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ // callers must serialise migration operations: same-millisecond invocations
4
+ // would write to the same backup dir and silently overwrite each other.
5
+ // migrate-v2 (Task 21) is the only caller and is invoked one app at a time.
6
+ function makeTimestamp() {
7
+ return new Date().toISOString().replace(/[:.]/g, '-');
8
+ }
9
+ /**
10
+ * capture a timestamped backup of all four artefacts for an app:
11
+ * - encrypted vault blob
12
+ * - manifest entry (as manifest.json in the snapshot dir)
13
+ * - compose file
14
+ * - systemd unit (if it exists)
15
+ */
16
+ export function snapshotApp(input) {
17
+ const { app, backupRoot, vaultDir, encryptedFile, composeDir, composeFile, appUnitFile } = input;
18
+ // ensure backupRoot exists at 0700 — mkdirSync only sets mode on newly
19
+ // created dirs; if it pre-existed with a looser mode, chmod it explicitly.
20
+ if (!existsSync(backupRoot)) {
21
+ mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
22
+ }
23
+ else {
24
+ chmodSync(backupRoot, 0o700);
25
+ }
26
+ const timestamp = makeTimestamp();
27
+ const snapDir = join(backupRoot, timestamp, app);
28
+ mkdirSync(snapDir, { recursive: true, mode: 0o700 });
29
+ // 1. encrypted vault blob
30
+ copyFileSync(join(vaultDir, encryptedFile), join(snapDir, encryptedFile));
31
+ // 2. manifest entry for this app only
32
+ const manifestPath = join(vaultDir, 'manifest.json');
33
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
34
+ const manifestEntry = manifest.apps[app] ?? {};
35
+ writeFileSync(join(snapDir, 'manifest.json'), JSON.stringify(manifestEntry, null, 2));
36
+ // 3. compose file
37
+ copyFileSync(join(composeDir, composeFile), join(snapDir, composeFile));
38
+ // 4. systemd unit (omitted if it doesn't exist yet)
39
+ if (existsSync(appUnitFile)) {
40
+ copyFileSync(appUnitFile, join(snapDir, basename(appUnitFile)));
41
+ }
42
+ return { app, timestamp, dir: snapDir, manifestEntry };
43
+ }
44
+ /**
45
+ * restore all four artefacts from a snapshot back to their original locations.
46
+ * the manifest is merged: only apps[app] is replaced, other apps are untouched.
47
+ * if the unit file wasn't captured (didn't exist at snapshot time), no unit is written.
48
+ */
49
+ export function restoreSnapshot(input, snap) {
50
+ const { app, vaultDir, encryptedFile, composeDir, composeFile, appUnitFile } = input;
51
+ const { dir: snapDir } = snap;
52
+ // 1. encrypted vault blob
53
+ copyFileSync(join(snapDir, encryptedFile), join(vaultDir, encryptedFile));
54
+ // 2. manifest entry — merge back into live manifest, leaving other apps untouched
55
+ const snapEntryRaw = readFileSync(join(snapDir, 'manifest.json'), 'utf-8');
56
+ const snapEntry = JSON.parse(snapEntryRaw);
57
+ const manifestPath = join(vaultDir, 'manifest.json');
58
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
59
+ manifest.apps[app] = snapEntry;
60
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
61
+ // 3. compose file
62
+ copyFileSync(join(snapDir, composeFile), join(composeDir, composeFile));
63
+ // 4. systemd unit — only write back if it was captured in the snapshot
64
+ const unitBasename = basename(appUnitFile);
65
+ const snapUnit = join(snapDir, unitBasename);
66
+ if (existsSync(snapUnit)) {
67
+ copyFileSync(snapUnit, appUnitFile);
68
+ }
69
+ }
70
+ /**
71
+ * walk backupRoot looking for <timestamp>/<app>/ directories.
72
+ * returns snapshots sorted newest-first by timestamp string (lexicographic, which
73
+ * works correctly for the ISO-safe format with dashes instead of colons/dots).
74
+ * returns [] if backupRoot doesn't exist.
75
+ */
76
+ export function listSnapshots(backupRoot, app) {
77
+ if (!existsSync(backupRoot))
78
+ return [];
79
+ const results = [];
80
+ for (const entry of readdirSync(backupRoot)) {
81
+ const tsDir = join(backupRoot, entry);
82
+ try {
83
+ if (!statSync(tsDir).isDirectory())
84
+ continue;
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ const appDir = join(tsDir, app);
90
+ try {
91
+ if (!statSync(appDir).isDirectory())
92
+ continue;
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ let manifestEntry = null;
98
+ const mPath = join(appDir, 'manifest.json');
99
+ if (existsSync(mPath)) {
100
+ try {
101
+ manifestEntry = JSON.parse(readFileSync(mPath, 'utf-8'));
102
+ }
103
+ catch { /* leave null */ }
104
+ }
105
+ results.push({
106
+ app,
107
+ timestamp: entry,
108
+ dir: appDir,
109
+ manifestEntry,
110
+ });
111
+ }
112
+ // Sort newest-first — the timestamp format is lexicographically sortable
113
+ results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
114
+ return results;
115
+ }
@@ -0,0 +1,21 @@
1
+ export declare const IDLE_TIMEOUT_MS = 30000;
2
+ export declare function _resetRateLimit(initialTokens?: number): void;
3
+ export declare function decryptVaultBlob(privateKeyPath: string, blobPath: string): Record<string, string>;
4
+ export interface AgentDeps {
5
+ app: string;
6
+ getSecrets: () => Record<string, string>;
7
+ refresh: () => void;
8
+ }
9
+ export interface Server {
10
+ listen(path: string): Promise<void>;
11
+ close(): Promise<void>;
12
+ }
13
+ export declare function createServer(deps: AgentDeps): Server;
14
+ export interface AgentArgs {
15
+ app: string;
16
+ vault: string;
17
+ socket: string;
18
+ credential?: string;
19
+ }
20
+ export declare function parseArgs(argv: string[]): AgentArgs;
21
+ export declare function main(argv: string[]): Promise<void>;
@@ -0,0 +1,249 @@
1
+ import { createServer as netCreateServer } from 'node:net';
2
+ import { existsSync, unlinkSync, chmodSync } from 'node:fs';
3
+ import { execSafe } from './exec.js';
4
+ import { SecretsError } from './errors.js';
5
+ import { parseRequest, writeResponse, ProtocolError } from './secrets-v2-protocol.js';
6
+ export const IDLE_TIMEOUT_MS = 30_000;
7
+ const TERM = Buffer.from('\r\n\r\n');
8
+ // module-level token bucket — limits total throughput to 100 req/sec across all connections
9
+ let _tokens = 100;
10
+ let _lastRefill = Date.now();
11
+ function takeToken() {
12
+ const now = Date.now();
13
+ const elapsed = (now - _lastRefill) / 1000;
14
+ if (elapsed > 0) {
15
+ _tokens = Math.min(100, _tokens + elapsed * 100);
16
+ _lastRefill = now;
17
+ }
18
+ if (_tokens < 1)
19
+ return false;
20
+ _tokens -= 1;
21
+ return true;
22
+ }
23
+ export function _resetRateLimit(initialTokens = 100) {
24
+ _tokens = initialTokens;
25
+ _lastRefill = Date.now();
26
+ }
27
+ export function decryptVaultBlob(privateKeyPath, blobPath) {
28
+ if (!existsSync(blobPath))
29
+ throw new SecretsError(`vault blob not found: ${blobPath}`);
30
+ if (!existsSync(privateKeyPath))
31
+ throw new SecretsError(`private key not found: ${privateKeyPath}`);
32
+ const r = execSafe('age', ['-d', '-i', privateKeyPath, blobPath]);
33
+ if (!r.ok)
34
+ throw new SecretsError(`age decrypt failed: ${r.stderr}`);
35
+ return parseEnvFormat(r.stdout);
36
+ }
37
+ /**
38
+ * Parse plaintext env content into a key/value map.
39
+ *
40
+ * Note: callers receive `content` after `execSafe` has applied `.trim()` to
41
+ * the full stdout. Leading/trailing whitespace at the start of the first
42
+ * line and end of the last line is consumed before parsing. Secret values
43
+ * with deliberate edge whitespace will be subtly altered. This is a known
44
+ * project-wide gotcha (also affects v1 secrets); see brain note q5YkhSmRVx9m.
45
+ */
46
+ function parseEnvFormat(content) {
47
+ const map = {};
48
+ for (const rawLine of content.split('\n')) {
49
+ if (!rawLine.trim() || rawLine.startsWith('#'))
50
+ continue;
51
+ const i = rawLine.indexOf('=');
52
+ if (i > 0) {
53
+ map[rawLine.slice(0, i)] = rawLine.slice(i + 1);
54
+ }
55
+ }
56
+ return map;
57
+ }
58
+ const MAX_REQUEST_BYTES = 8192;
59
+ export function createServer(deps) {
60
+ const server = netCreateServer((sock) => handleConnection(sock, deps));
61
+ let socketPath = '';
62
+ return {
63
+ listen: (path) => new Promise((resolve, reject) => {
64
+ socketPath = path;
65
+ if (existsSync(path)) {
66
+ try {
67
+ unlinkSync(path);
68
+ }
69
+ catch { /* race; let listen fail naturally */ }
70
+ }
71
+ const onError = (err) => reject(err);
72
+ server.once('error', onError);
73
+ server.listen(path, () => {
74
+ server.off('error', onError);
75
+ // 0o660 (not 0o600) is deliberate: the systemd unit runs the agent
76
+ // as DynamicUser, and the consumer container bind-mounts this
77
+ // socket and connects as a separate uid sharing the systemd-
78
+ // allocated group. tightening to 0o600 would break that. if the
79
+ // consumer pattern ever changes, drop to 0o600.
80
+ try {
81
+ chmodSync(path, 0o660);
82
+ }
83
+ catch (err) {
84
+ process.stderr.write(`[fleet-agent] WARNING: chmod 0660 on ${path} failed: ${err.message}\n`);
85
+ }
86
+ resolve();
87
+ });
88
+ }),
89
+ close: () => new Promise((resolve) => {
90
+ server.close(() => {
91
+ if (socketPath && existsSync(socketPath)) {
92
+ try {
93
+ unlinkSync(socketPath);
94
+ }
95
+ catch { /* ignore */ }
96
+ }
97
+ resolve();
98
+ });
99
+ }),
100
+ };
101
+ }
102
+ function handleConnection(sock, deps) {
103
+ const chunks = [];
104
+ let totalBytes = 0;
105
+ let handled = false;
106
+ let searchedUpTo = 0;
107
+ sock.setTimeout(IDLE_TIMEOUT_MS, () => sock.destroy());
108
+ const handle = () => {
109
+ if (handled)
110
+ return;
111
+ handled = true;
112
+ if (!takeToken()) {
113
+ sock.end(writeResponse(429, { error: 'rate_limited' }));
114
+ return;
115
+ }
116
+ const buf = Buffer.concat(chunks);
117
+ try {
118
+ const req = parseRequest(buf);
119
+ const resp = dispatch(req, deps);
120
+ sock.end(resp);
121
+ }
122
+ catch (err) {
123
+ const isProto = err instanceof ProtocolError;
124
+ const status = isProto ? 400 : 500;
125
+ const message = isProto ? err.message : 'internal';
126
+ sock.end(writeResponse(status, { error: message }));
127
+ }
128
+ };
129
+ sock.on('data', (chunk) => {
130
+ chunks.push(chunk);
131
+ totalBytes += chunk.length;
132
+ if (totalBytes > MAX_REQUEST_BYTES) {
133
+ handled = true;
134
+ sock.end(writeResponse(413, { error: 'request too large' }));
135
+ return;
136
+ }
137
+ const buf = Buffer.concat(chunks);
138
+ const idx = buf.indexOf(TERM, Math.max(0, searchedUpTo - 3));
139
+ if (idx >= 0) {
140
+ handle();
141
+ }
142
+ else {
143
+ searchedUpTo = buf.length;
144
+ }
145
+ });
146
+ sock.on('end', () => { if (!handled)
147
+ handle(); });
148
+ sock.on('error', () => { });
149
+ }
150
+ const KNOWN_FLAGS = new Set(['--app', '--vault', '--socket', '--credential']);
151
+ const USAGE = 'Usage: fleet-agent --app <name> --vault <dir> --socket <path> [--credential <path>]';
152
+ export function parseArgs(argv) {
153
+ const parsed = {};
154
+ let i = 0;
155
+ while (i < argv.length) {
156
+ const flag = argv[i];
157
+ if (!flag.startsWith('--')) {
158
+ throw new SecretsError(`unexpected argument: ${flag}\n${USAGE}`);
159
+ }
160
+ if (!KNOWN_FLAGS.has(flag)) {
161
+ throw new SecretsError(`unknown flag: ${flag}\n${USAGE}`);
162
+ }
163
+ i++;
164
+ if (i >= argv.length || argv[i].startsWith('--')) {
165
+ throw new SecretsError(`flag ${flag} requires a value\n${USAGE}`);
166
+ }
167
+ parsed[flag.slice(2)] = argv[i];
168
+ i++;
169
+ }
170
+ for (const required of ['app', 'vault', 'socket']) {
171
+ if (!parsed[required]) {
172
+ throw new SecretsError(`missing required flag --${required}\n${USAGE}`);
173
+ }
174
+ }
175
+ return {
176
+ app: parsed['app'],
177
+ vault: parsed['vault'],
178
+ socket: parsed['socket'],
179
+ ...(parsed['credential'] !== undefined ? { credential: parsed['credential'] } : {}),
180
+ };
181
+ }
182
+ export async function main(argv) {
183
+ const args = parseArgs(argv);
184
+ const credentialPath = args.credential
185
+ ?? (process.env.CREDENTIALS_DIRECTORY
186
+ ? `${process.env.CREDENTIALS_DIRECTORY}/age-key`
187
+ : undefined);
188
+ if (!credentialPath) {
189
+ throw new SecretsError('no credential path: pass --credential or run under systemd with LoadCredential');
190
+ }
191
+ const vaultBlobPath = `${args.vault}/${args.app}.env.age`;
192
+ let secrets = decryptVaultBlob(credentialPath, vaultBlobPath);
193
+ const deps = {
194
+ app: args.app,
195
+ getSecrets: () => secrets,
196
+ refresh: () => {
197
+ secrets = decryptVaultBlob(credentialPath, vaultBlobPath);
198
+ },
199
+ };
200
+ const server = createServer(deps);
201
+ await server.listen(args.socket);
202
+ const notify = process.env.NOTIFY_SOCKET;
203
+ if (notify) {
204
+ const r = execSafe('systemd-notify', ['--ready'], {});
205
+ if (!r.ok) {
206
+ process.stderr.write(`[fleet-agent ${args.app}] systemd-notify failed: ${r.stderr}\n`);
207
+ }
208
+ }
209
+ return new Promise((resolve) => {
210
+ let shuttingDown = false;
211
+ const handleSignal = async (sig) => {
212
+ if (shuttingDown)
213
+ return;
214
+ shuttingDown = true;
215
+ process.stderr.write(`[fleet-agent ${args.app}] ${sig}, shutting down\n`);
216
+ try {
217
+ await server.close();
218
+ }
219
+ catch { /* ignore */ }
220
+ resolve();
221
+ };
222
+ process.once('SIGTERM', () => handleSignal('SIGTERM'));
223
+ process.once('SIGINT', () => handleSignal('SIGINT'));
224
+ });
225
+ }
226
+ function dispatch(req, deps) {
227
+ if (req.method === 'GET' && req.path === '/health') {
228
+ const m = deps.getSecrets();
229
+ return writeResponse(200, { app: deps.app, secrets: Object.keys(m).length });
230
+ }
231
+ if (req.method === 'POST' && req.path === '/refresh') {
232
+ deps.refresh();
233
+ return writeResponse(200, { reloaded: true });
234
+ }
235
+ if (req.method === 'GET' && req.path === '/secrets') {
236
+ return writeResponse(200, deps.getSecrets());
237
+ }
238
+ if (req.method === 'GET' && req.path.startsWith('/secrets/')) {
239
+ const key = req.path.slice('/secrets/'.length);
240
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
241
+ return writeResponse(400, { error: 'invalid_key' });
242
+ }
243
+ const m = deps.getSecrets();
244
+ if (key in m)
245
+ return writeResponse(200, { value: m[key] });
246
+ return writeResponse(404, { error: 'not_found' });
247
+ }
248
+ return writeResponse(404, { error: 'not_found' });
249
+ }