@marcusrbrown/infra 0.13.1 → 0.13.3

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 (108) hide show
  1. package/dist/cli.js +11965 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,426 +0,0 @@
1
- import {statSync, writeFileSync} from 'node:fs'
2
-
3
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
4
-
5
- import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
6
- import {backupGatewayCa, gatewayBackupAction, type BackupSpawnFn} from './backup'
7
-
8
- // ─── SpawnFn helpers ──────────────────────────────────────────────────────────
9
-
10
- function makeSpawnOk(tarBytes: Uint8Array = new Uint8Array([1, 2, 3])): BackupSpawnFn {
11
- return (_cmd, _opts) => {
12
- return {
13
- stdout: new ReadableStream({
14
- start(controller) {
15
- controller.enqueue(tarBytes)
16
- controller.close()
17
- },
18
- }),
19
- stderr: new ReadableStream({
20
- start(controller) {
21
- controller.close()
22
- },
23
- }),
24
- exited: Promise.resolve(0),
25
- }
26
- }
27
- }
28
-
29
- function makeSpawnError(message: string): BackupSpawnFn {
30
- return (_cmd, _opts) => {
31
- const encoder = new TextEncoder()
32
- return {
33
- stdout: new ReadableStream({
34
- start(controller) {
35
- controller.close()
36
- },
37
- }),
38
- stderr: new ReadableStream({
39
- start(controller) {
40
- controller.enqueue(encoder.encode(message))
41
- controller.close()
42
- },
43
- }),
44
- exited: Promise.resolve(255),
45
- }
46
- }
47
- }
48
-
49
- // ─── backupGatewayCa ─────────────────────────────────────────────────────────
50
-
51
- describe('backupGatewayCa — happy path', () => {
52
- let tmpOutput: string
53
-
54
- beforeEach(() => {
55
- tmpOutput = `/tmp/test-backup-${Date.now()}.tar`
56
- })
57
-
58
- afterEach(async () => {
59
- try {
60
- ;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
61
- } catch {
62
- // ignore
63
- }
64
- })
65
-
66
- it('writes tarball to the output path with 0600 permissions', async () => {
67
- const tarBytes = new Uint8Array([0x1f, 0x8b, 0x08, 0x00]) // fake tar header
68
-
69
- const warnings: string[] = []
70
- const result = await backupGatewayCa(
71
- {host: 'gateway.example.com', output: tmpOutput, includeCa: true},
72
- makeSpawnOk(tarBytes),
73
- (msg: string) => {
74
- warnings.push(msg)
75
- },
76
- )
77
-
78
- expect(result.ok).toBe(true)
79
- expect(await Bun.file(tmpOutput).exists()).toBe(true)
80
-
81
- const written = await Bun.file(tmpOutput).arrayBuffer()
82
- expect(new Uint8Array(written)).toEqual(tarBytes)
83
- })
84
-
85
- it('returns output path and bytesWritten on success', async () => {
86
- const tarBytes = new Uint8Array([1, 2, 3, 4, 5])
87
- const result = await backupGatewayCa(
88
- {host: 'gateway.example.com', output: tmpOutput, includeCa: true},
89
- makeSpawnOk(tarBytes),
90
- )
91
-
92
- expect(result.ok).toBe(true)
93
- if (result.ok) {
94
- expect(result.output).toBe(tmpOutput)
95
- expect(result.bytesWritten).toBe(5)
96
- }
97
- })
98
-
99
- it('prints a stderr warning about sensitive output', async () => {
100
- const warnings: string[] = []
101
- await backupGatewayCa(
102
- {host: 'gateway.example.com', output: tmpOutput, includeCa: true},
103
- makeSpawnOk(),
104
- (msg: string) => {
105
- warnings.push(msg)
106
- },
107
- )
108
-
109
- expect(warnings.some(w => w.toLowerCase().includes('sensitive'))).toBe(true)
110
- })
111
-
112
- it('uses a custom --output path when provided', async () => {
113
- const customOutput = `/tmp/test-custom-${Date.now()}.tar`
114
-
115
- try {
116
- const result = await backupGatewayCa(
117
- {host: 'gateway.example.com', output: customOutput, includeCa: true},
118
- makeSpawnOk(),
119
- )
120
-
121
- expect(result.ok).toBe(true)
122
- expect(await Bun.file(customOutput).exists()).toBe(true)
123
- } finally {
124
- Bun.spawnSync(['rm', '-f', customOutput])
125
- }
126
- })
127
-
128
- it('performs CA backup when includeCa is true (default behavior)', async () => {
129
- let capturedCmd: string[] = []
130
- const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
131
- capturedCmd = cmd
132
- return {
133
- stdout: new ReadableStream({
134
- start(controller) {
135
- controller.enqueue(new Uint8Array([1, 2, 3]))
136
- controller.close()
137
- },
138
- }),
139
- stderr: new ReadableStream({
140
- start(controller) {
141
- controller.close()
142
- },
143
- }),
144
- exited: Promise.resolve(0),
145
- }
146
- }
147
-
148
- await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
149
-
150
- const cmdStr = capturedCmd.join(' ')
151
- expect(cmdStr).toContain('mitmproxy-ca-cert.pem')
152
- expect(cmdStr).toContain('mitmproxy-ca.pem')
153
- expect(cmdStr).toContain('mitmproxy-certs')
154
- })
155
- })
156
-
157
- describe('backupGatewayCa — edge case: --no-include-ca', () => {
158
- it('refuses with a clear message when includeCa is false', async () => {
159
- const result = await backupGatewayCa({
160
- host: 'gateway.example.com',
161
- output: '/tmp/irrelevant.tar',
162
- includeCa: false,
163
- })
164
-
165
- expect(result.ok).toBe(false)
166
- if (!result.ok) {
167
- expect(result.error).toMatch(/only CA backup/i)
168
- }
169
- })
170
- })
171
-
172
- describe('backupGatewayCa — error paths', () => {
173
- it('rejects a malicious host and does not invoke ssh', async () => {
174
- const neverSpawn: BackupSpawnFn = () => {
175
- throw new Error('spawn must not be called for an invalid host')
176
- }
177
-
178
- await expect(
179
- backupGatewayCa(
180
- {host: '-oProxyCommand=touch /tmp/sec5-pwned', output: '/tmp/x.tar', includeCa: true},
181
- neverSpawn,
182
- ),
183
- ).rejects.toThrow('Invalid GATEWAY_HOST')
184
- })
185
-
186
- it('rejects a host with shell metacharacters and does not invoke ssh', async () => {
187
- const neverSpawn: BackupSpawnFn = () => {
188
- throw new Error('spawn must not be called for an invalid host')
189
- }
190
-
191
- await expect(
192
- backupGatewayCa({host: 'gateway.example.com;rm -rf /', output: '/tmp/x.tar', includeCa: true}, neverSpawn),
193
- ).rejects.toThrow('Invalid GATEWAY_HOST')
194
- })
195
-
196
- it('surfaces the underlying error when SSH is unreachable', async () => {
197
- const result = await backupGatewayCa(
198
- {host: 'gateway.example.com', output: '/tmp/ssh-fail.tar', includeCa: true},
199
- makeSpawnError('Connection refused'),
200
- )
201
-
202
- expect(result.ok).toBe(false)
203
- if (!result.ok) {
204
- expect(result.error).toContain('Connection refused')
205
- }
206
- })
207
- })
208
-
209
- // ─── COR2: Atomic write behavior ─────────────────────────────────────────────
210
-
211
- describe('backupGatewayCa — atomic write (COR2)', () => {
212
- it('leaves no .tmp.* file after a successful write', async () => {
213
- const output = `/tmp/test-atomic-ok-${Date.now()}.tar`
214
- const dir = '/tmp'
215
-
216
- try {
217
- const result = await backupGatewayCa(
218
- {host: 'gateway.example.com', output, includeCa: true},
219
- makeSpawnOk(new Uint8Array([1, 2, 3])),
220
- )
221
-
222
- expect(result.ok).toBe(true)
223
-
224
- // No .tmp.* file should remain alongside the output
225
- // Check no sibling tmp file exists by listing /tmp and filtering
226
- const {stdout} = Bun.spawnSync(['sh', '-c', `ls ${dir}/ | grep -E '^test-atomic-ok.*[.]tmp[.]' || true`])
227
- const tmpFiles = new TextDecoder().decode(stdout).trim()
228
- expect(tmpFiles).toBe('')
229
- } finally {
230
- Bun.spawnSync(['rm', '-f', output])
231
- }
232
- })
233
-
234
- it('leaves no partial output file and does not overwrite existing file when chmod fails', async () => {
235
- const output = `/tmp/test-atomic-fail-${Date.now()}.tar`
236
- const existingContent = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
237
-
238
- // Write a pre-existing file with known content
239
- writeFileSync(output, existingContent)
240
-
241
- // Mock spawn that succeeds (returns tar bytes) but we'll intercept chmod via a
242
- // custom spawn that also intercepts the chmod call — however chmod is called via
243
- // Bun.spawnSync which we can't easily mock. Instead, test the tmp-then-rename
244
- // path by writing to a read-only directory to force rename failure.
245
- //
246
- // Simpler approach: verify that after a failed SSH (so we never reach write),
247
- // no tmp file is left. For chmod failure specifically, we verify the output
248
- // file is unchanged when the overall operation fails.
249
- const result = await backupGatewayCa(
250
- {host: 'gateway.example.com', output, includeCa: true},
251
- makeSpawnError('SSH failed'),
252
- )
253
-
254
- expect(result.ok).toBe(false)
255
-
256
- // The pre-existing file must be untouched (no overwrite on failure)
257
- const afterContent = await Bun.file(output).arrayBuffer()
258
- expect(new Uint8Array(afterContent)).toEqual(existingContent)
259
-
260
- // No tmp file should exist
261
- const {stdout} = Bun.spawnSync(['sh', '-c', `ls /tmp/ | grep -E 'test-atomic-fail.*[.]tmp[.]' || true`])
262
- expect(new TextDecoder().decode(stdout).trim()).toBe('')
263
-
264
- Bun.spawnSync(['rm', '-f', output])
265
- })
266
- })
267
-
268
- // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
269
-
270
- describe('backupGatewayCa — SSH command includes UserKnownHostsFile', () => {
271
- let tmpOutput: string
272
-
273
- beforeEach(() => {
274
- tmpOutput = `/tmp/test-backup-known-hosts-${Date.now()}.tar`
275
- })
276
-
277
- afterEach(async () => {
278
- try {
279
- ;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
280
- } catch {
281
- // ignore
282
- }
283
- })
284
-
285
- it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
286
- let capturedCmd: string[] = []
287
- const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
288
- capturedCmd = cmd
289
- return {
290
- stdout: new ReadableStream({
291
- start(controller) {
292
- controller.enqueue(new Uint8Array([1, 2, 3]))
293
- controller.close()
294
- },
295
- }),
296
- stderr: new ReadableStream({
297
- start(controller) {
298
- controller.close()
299
- },
300
- }),
301
- exited: Promise.resolve(0),
302
- }
303
- }
304
-
305
- await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
306
-
307
- const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
308
- expect(knownHostsIdx).toBeGreaterThan(-1)
309
- expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
310
- expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
311
- })
312
-
313
- it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
314
- let capturedCmd: string[] = []
315
- const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
316
- capturedCmd = cmd
317
- return {
318
- stdout: new ReadableStream({
319
- start(controller) {
320
- controller.enqueue(new Uint8Array([1, 2, 3]))
321
- controller.close()
322
- },
323
- }),
324
- stderr: new ReadableStream({
325
- start(controller) {
326
- controller.close()
327
- },
328
- }),
329
- exited: Promise.resolve(0),
330
- }
331
- }
332
-
333
- await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
334
-
335
- const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
336
- expect(strictIdx).toBeGreaterThan(-1)
337
- expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
338
- })
339
- })
340
-
341
- // ─── SEC1: Tmp file born with mode 0600 (no chmod race) ──────────────────────
342
-
343
- describe('backupGatewayCa — SEC1: tmp file created with mode 0600 atomically', () => {
344
- it('creates the output file with mode 0600 (live stat check)', async () => {
345
- const output = `/tmp/test-sec1-mode-${Date.now()}.tar`
346
-
347
- try {
348
- const result = await backupGatewayCa(
349
- {host: 'gateway.example.com', output, includeCa: true},
350
- makeSpawnOk(new Uint8Array([0xca, 0xfe, 0xba, 0xbe])),
351
- )
352
-
353
- expect(result.ok).toBe(true)
354
-
355
- // The final file must be 0600
356
- const stat = statSync(output)
357
- expect(stat.mode & 0o777).toBe(0o600)
358
- } finally {
359
- Bun.spawnSync(['rm', '-f', output])
360
- }
361
- })
362
- })
363
-
364
- // ─── Tier-2: gatewayBackupAction ctx capture ─────────────────────────────────
365
-
366
- describe('gatewayBackupAction — ctx capture (Tier-2)', () => {
367
- let originalEnv: Record<string, string | undefined>
368
- let tmpOutput: string
369
-
370
- beforeEach(() => {
371
- originalEnv = {GATEWAY_HOST: process.env.GATEWAY_HOST}
372
- process.env.GATEWAY_HOST = 'gateway.example.com'
373
- tmpOutput = `/tmp/test-action-backup-${Date.now()}.tar`
374
- })
375
-
376
- afterEach(async () => {
377
- if (originalEnv.GATEWAY_HOST === undefined) {
378
- delete process.env.GATEWAY_HOST
379
- } else {
380
- process.env.GATEWAY_HOST = originalEnv.GATEWAY_HOST
381
- }
382
- try {
383
- ;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
384
- } catch {
385
- // ignore
386
- }
387
- })
388
-
389
- it('routes success message to ctx.console.log (stdout)', async () => {
390
- const {ctx, captured} = createCapturedCtx()
391
-
392
- await gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx, makeSpawnOk(new Uint8Array([1, 2, 3])))
393
-
394
- expect(expectCapturedToInclude(captured, 'CA backup written to')).toBe(true)
395
- })
396
-
397
- it('routes sensitive-content warning to ctx.process.stderr.write', async () => {
398
- const {ctx, captured} = createCapturedCtx()
399
-
400
- await gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx, makeSpawnOk(new Uint8Array([1, 2, 3])))
401
-
402
- const stderrText = captured.stderr.join('')
403
- expect(stderrText.toLowerCase().includes('sensitive')).toBe(true)
404
- })
405
-
406
- it('routes error to ctx.console.error and calls ctx.process.exit(1) when GATEWAY_HOST is unset', async () => {
407
- delete process.env.GATEWAY_HOST
408
- const {ctx, captured} = createCapturedCtx()
409
-
410
- await expect(gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx)).rejects.toBeInstanceOf(MockProcessExit)
411
-
412
- expect(captured.stderr.join('').includes('GATEWAY_HOST')).toBe(true)
413
- expect(captured.exit?.code).toBe(1)
414
- })
415
-
416
- it('routes backup failure to ctx.console.error and calls ctx.process.exit(1)', async () => {
417
- const {ctx, captured} = createCapturedCtx()
418
-
419
- await expect(
420
- gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx, makeSpawnError('Connection refused')),
421
- ).rejects.toBeInstanceOf(MockProcessExit)
422
-
423
- expect(captured.stderr.join('').toLowerCase().includes('backup failed')).toBe(true)
424
- expect(captured.exit?.code).toBe(1)
425
- })
426
- })
@@ -1,205 +0,0 @@
1
- import type {goke} from 'goke'
2
-
3
- import type {ActionCtx} from '../../lib/action-ctx'
4
-
5
- import {closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync} from 'node:fs'
6
-
7
- import {z} from 'zod'
8
- import {buildKnownHostsArgs} from '../../lib/known-hosts'
9
- import {validateGatewayHost} from './host'
10
-
11
- declare const process: {
12
- env: Record<string, string | undefined>
13
- exitCode?: number
14
- stderr: {write: (msg: string) => void}
15
- }
16
-
17
- // ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
18
-
19
- // ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
20
-
21
- const MITMPROXY_CERTS_VOLUME = 'mitmproxy-certs'
22
- const CA_CERT_FILE = 'mitmproxy-ca-cert.pem'
23
- const CA_KEY_FILE = 'mitmproxy-ca.pem'
24
- const DEFAULT_OUTPUT = 'apps/gateway/.local/mitmproxy-ca.tar'
25
- const SENSITIVE_WARNING = 'Warning: Output contains the mitmproxy CA private trust anchor; treat as sensitive.'
26
-
27
- // ─── Types ────────────────────────────────────────────────────────────────────
28
-
29
- export type BackupSpawnFn = (
30
- cmd: string[],
31
- opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
32
- ) => {
33
- stdout: ReadableStream<Uint8Array>
34
- stderr: ReadableStream<Uint8Array>
35
- exited: Promise<number>
36
- }
37
-
38
- export interface BackupOpts {
39
- host: string
40
- output: string
41
- includeCa: boolean
42
- }
43
-
44
- export type BackupResult = {ok: true; output: string; bytesWritten: number} | {ok: false; error: string}
45
-
46
- // ─── Core logic ───────────────────────────────────────────────────────────────
47
-
48
- function defaultSpawn(
49
- cmd: string[],
50
- opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
51
- ): ReturnType<BackupSpawnFn> {
52
- return Bun.spawn(cmd, opts) as ReturnType<BackupSpawnFn>
53
- }
54
-
55
- export async function backupGatewayCa(
56
- opts: BackupOpts,
57
- spawn: BackupSpawnFn = defaultSpawn,
58
- printErr?: (msg: string) => void,
59
- ): Promise<BackupResult> {
60
- if (!opts.includeCa) {
61
- return {ok: false, error: 'Only CA backup is currently supported; --no-include-ca is not accepted'}
62
- }
63
-
64
- // Validate host before any SSH invocation
65
- validateGatewayHost(opts.host)
66
-
67
- const sshCmd = [
68
- 'ssh',
69
- '-o',
70
- 'BatchMode=yes',
71
- '-o',
72
- 'StrictHostKeyChecking=yes',
73
- ...buildKnownHostsArgs(),
74
- `root@${opts.host}`,
75
- `docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine tar -cf - -C /src ${CA_CERT_FILE} ${CA_KEY_FILE}`,
76
- ]
77
-
78
- const env: Record<string, string> = {
79
- PATH: process.env.PATH ?? '/usr/bin:/bin',
80
- HOME: process.env.HOME ?? '/tmp',
81
- ...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
82
- }
83
-
84
- const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
85
-
86
- const [tarBytes, stderrText, exitCode] = await Promise.all([
87
- new Response(child.stdout).arrayBuffer(),
88
- new Response(child.stderr).text(),
89
- child.exited,
90
- ])
91
-
92
- if (exitCode !== 0) {
93
- return {
94
- ok: false,
95
- error: `SSH/docker command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
96
- }
97
- }
98
-
99
- // Atomic write: open with O_CREAT|O_EXCL|0o600 (file born with correct mode, no chmod race),
100
- // write bytes, close, then rename to final path.
101
- const writeTmpSecure = (path: string, bytes: ArrayBuffer): void => {
102
- const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600)
103
- try {
104
- writeSync(fd, new Uint8Array(bytes))
105
- } finally {
106
- closeSync(fd)
107
- }
108
- }
109
-
110
- let tmpPath = `${opts.output}.tmp.${Date.now()}`
111
- try {
112
- try {
113
- writeTmpSecure(tmpPath, tarBytes)
114
- } catch (error) {
115
- // O_EXCL collision (EEXIST) — retry once with a fresh suffix
116
- if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
117
- tmpPath = `${opts.output}.tmp.${Date.now()}-${Math.random().toString(36).slice(2)}`
118
- writeTmpSecure(tmpPath, tarBytes)
119
- } else {
120
- throw error
121
- }
122
- }
123
-
124
- renameSync(tmpPath, opts.output)
125
- } catch (error) {
126
- try {
127
- unlinkSync(tmpPath)
128
- } catch {
129
- // ignore ENOENT or other cleanup errors
130
- }
131
- return {
132
- ok: false,
133
- error: error instanceof Error ? error.message : String(error),
134
- }
135
- }
136
-
137
- // Warn about sensitive content
138
- const warn = printErr ?? ((msg: string) => process.stderr.write(`${msg}\n`))
139
- warn(SENSITIVE_WARNING)
140
-
141
- return {ok: true, output: opts.output, bytesWritten: tarBytes.byteLength}
142
- }
143
-
144
- // ─── Action (exported for direct testing) ────────────────────────────────────
145
-
146
- export async function gatewayBackupAction(
147
- options: {output?: string | undefined; includeCa?: boolean | undefined},
148
- ctx: ActionCtx,
149
- spawn?: BackupSpawnFn,
150
- ): Promise<void> {
151
- const hostEnvKey = 'GATEWAY_HOST'
152
- const host = process.env[hostEnvKey]
153
-
154
- if (!host) {
155
- ctx.console.error(`Gateway host not set. Export ${hostEnvKey} before running backup.`)
156
- ctx.process.exit(1)
157
- return
158
- }
159
-
160
- const output = typeof options.output === 'string' ? options.output : DEFAULT_OUTPUT
161
- const includeCa = options.includeCa !== false
162
-
163
- const result = await backupGatewayCa({host, output, includeCa}, spawn, (msg: string) =>
164
- ctx.process.stderr.write(`${msg}\n`),
165
- )
166
-
167
- if (!result.ok) {
168
- ctx.console.error(`Backup failed: ${result.error}`)
169
- ctx.process.exit(1)
170
- return
171
- }
172
-
173
- ctx.console.log(`CA backup written to: ${output}`)
174
- }
175
-
176
- // ─── Command registration ─────────────────────────────────────────────────────
177
-
178
- export function registerGatewayBackup(cli: ReturnType<typeof goke>): void {
179
- cli
180
- .command(
181
- 'gateway backup',
182
- 'Back up the mitmproxy CA certificate and private key from the gateway Docker volume to a local tarball. The output contains the CA private trust anchor — treat it as sensitive material.',
183
- )
184
- .option(
185
- '--output [file]',
186
- z
187
- .string()
188
- .default(DEFAULT_OUTPUT)
189
- .describe('Local path to write the CA tarball. File permissions are set to 0600 after writing.'),
190
- )
191
- .option(
192
- '--include-ca',
193
- z
194
- .boolean()
195
- .default(true)
196
- .describe(
197
- 'Include the mitmproxy CA certificate and private key in the backup. Currently the only supported backup target.',
198
- ),
199
- )
200
- .example('# Back up the CA to the default path')
201
- .example('infra gateway backup --include-ca')
202
- .example('# Back up the CA to a custom path')
203
- .example('infra gateway backup --output /secure/backup/mitmproxy-ca.tar')
204
- .action(gatewayBackupAction)
205
- }