@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,650 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
-
3
- import {restoreGatewayCa, validateBackupArchive, type RestoreSpawnFn} from './restore'
4
-
5
- // ─── SpawnFn helpers ──────────────────────────────────────────────────────────
6
-
7
- /**
8
- * Creates a multi-step spawn mock. Each call to the returned function pops the
9
- * next response from the queue. Throws if called more times than expected.
10
- */
11
- function makeSpawnSequence(responses: {stdout: string; stderr: string; exitCode: number}[]): RestoreSpawnFn {
12
- const queue = [...responses]
13
- return (_cmd, _opts) => {
14
- const next = queue.shift()
15
- if (!next) {
16
- throw new Error('Unexpected spawn call — queue exhausted')
17
- }
18
- const encoder = new TextEncoder()
19
- return {
20
- stdout: new ReadableStream({
21
- start(controller) {
22
- if (next.stdout) controller.enqueue(encoder.encode(next.stdout))
23
- controller.close()
24
- },
25
- }),
26
- stderr: new ReadableStream({
27
- start(controller) {
28
- if (next.stderr) controller.enqueue(encoder.encode(next.stderr))
29
- controller.close()
30
- },
31
- }),
32
- exited: Promise.resolve(next.exitCode),
33
- }
34
- }
35
- }
36
-
37
- // ─── Fixture helpers ──────────────────────────────────────────────────────────
38
-
39
- /**
40
- * Creates a minimal non-empty tar file at the given path and returns the path.
41
- * Content is arbitrary bytes — tests only care that the file is non-empty.
42
- */
43
- async function writeFakeTar(path: string): Promise<string> {
44
- await Bun.write(path, new Uint8Array([0x75, 0x73, 0x74, 0x61, 0x72])) // "ustar" magic
45
- return path
46
- }
47
-
48
- // ─── restoreGatewayCa — happy path ───────────────────────────────────────────
49
-
50
- describe('restoreGatewayCa — happy path', () => {
51
- let tmpInput: string
52
-
53
- beforeEach(async () => {
54
- tmpInput = `/tmp/test-restore-input-${Date.now()}.tar`
55
- await writeFakeTar(tmpInput)
56
- })
57
-
58
- afterEach(() => {
59
- Bun.spawnSync(['rm', '-f', tmpInput])
60
- })
61
-
62
- it('succeeds when SCP + docker copy + restart + confirmation all pass', async () => {
63
- // Build a real tar with the two CA files so local extraction works
64
- const certContent = '-----BEGIN CERTIFICATE-----\nFAKECERT\n-----END CERTIFICATE-----\n'
65
- const keyContent = '-----BEGIN PRIVATE KEY-----\nFAKEKEY\n-----END PRIVATE KEY-----\n'
66
-
67
- const tarDir = `/tmp/test-tar-dir-${Date.now()}`
68
- const realTar = `/tmp/test-real-tar-${Date.now()}.tar`
69
- Bun.spawnSync(['mkdir', '-p', tarDir])
70
- await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
71
- await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
72
- Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
73
-
74
- try {
75
- // Sequence: tar -tf (validate), mktemp (ssh), scp, chmod, docker run (extract),
76
- // docker compose restart, docker run read cert, docker run read key, rm cleanup
77
- const spawn = makeSpawnSequence([
78
- {stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n', stderr: '', exitCode: 0}, // tar -tf validate
79
- {stdout: '/tmp/gateway-ca-restore-abc123.tar\n', stderr: '', exitCode: 0}, // mktemp ssh
80
- {stdout: '', stderr: '', exitCode: 0}, // scp
81
- {stdout: '', stderr: '', exitCode: 0}, // chmod 600
82
- {stdout: '', stderr: '', exitCode: 0}, // docker run extract
83
- {stdout: '', stderr: '', exitCode: 0}, // docker compose restart
84
- {stdout: certContent, stderr: '', exitCode: 0}, // docker run read cert
85
- {stdout: keyContent, stderr: '', exitCode: 0}, // docker run read key
86
- {stdout: '', stderr: '', exitCode: 0}, // rm cleanup
87
- ])
88
-
89
- const result = await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawn)
90
-
91
- expect(result.ok).toBe(true)
92
- if (result.ok) {
93
- expect(result.confirmed).toBe(true)
94
- }
95
- } finally {
96
- Bun.spawnSync(['rm', '-rf', tarDir, realTar])
97
- }
98
- })
99
-
100
- it('deletes the tmp file from the droplet on success (cleanup)', async () => {
101
- const deletedPaths: string[] = []
102
-
103
- // Track which commands are called to verify cleanup
104
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
105
- const cmdStr = cmd.join(' ')
106
- // The rm -f is issued as an SSH command: ['ssh', ..., 'rm -f <path>']
107
- // The last element of the ssh command array is the remote command string
108
- if (cmdStr.includes('rm -f')) {
109
- const remoteCmd = cmd.at(-1) ?? ''
110
- const match = remoteCmd.match(/rm -f (.+)/)
111
- if (match?.[1]) deletedPaths.push(match[1])
112
- }
113
-
114
- const encoder = new TextEncoder()
115
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
116
- const isMktemp = cmdStr.includes('mktemp')
117
- return {
118
- stdout: new ReadableStream({
119
- start(controller) {
120
- if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'))
121
- else if (isMktemp) controller.enqueue(encoder.encode('/tmp/gateway-ca-restore-cleanup-test.tar\n'))
122
- controller.close()
123
- },
124
- }),
125
- stderr: new ReadableStream({
126
- start(controller) {
127
- controller.close()
128
- },
129
- }),
130
- exited: Promise.resolve(0),
131
- }
132
- }
133
-
134
- await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput, includeCa: true}, spawnCapture)
135
-
136
- // At least one rm -f was issued for the tmp file
137
- expect(deletedPaths.length).toBeGreaterThan(0)
138
- })
139
- })
140
-
141
- describe('restoreGatewayCa — tmp cleanup on failure', () => {
142
- let tmpInput: string
143
-
144
- beforeEach(async () => {
145
- tmpInput = `/tmp/test-restore-fail-${Date.now()}.tar`
146
- await writeFakeTar(tmpInput)
147
- })
148
-
149
- afterEach(() => {
150
- Bun.spawnSync(['rm', '-f', tmpInput])
151
- })
152
-
153
- it('deletes the tmp file from the droplet even when docker run fails', async () => {
154
- const deletedPaths: string[] = []
155
-
156
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
157
- const cmdStr = cmd.join(' ')
158
- // rm -f is issued as SSH remote command string
159
- if (cmdStr.includes('rm -f')) {
160
- const remoteCmd = cmd.at(-1) ?? ''
161
- const match = remoteCmd.match(/rm -f (.+)/)
162
- if (match?.[1]) deletedPaths.push(match[1])
163
- }
164
-
165
- const encoder = new TextEncoder()
166
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
167
- const isMktemp = cmdStr.includes('mktemp')
168
- const isDockerRun = cmdStr.includes('docker run') && cmdStr.includes('tar -xf')
169
- return {
170
- stdout: new ReadableStream({
171
- start(controller) {
172
- if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'))
173
- else if (isMktemp) controller.enqueue(encoder.encode('/tmp/gateway-ca-restore-fail-test.tar\n'))
174
- controller.close()
175
- },
176
- }),
177
- stderr: new ReadableStream({
178
- start(controller) {
179
- if (isDockerRun) controller.enqueue(encoder.encode('tar: error'))
180
- controller.close()
181
- },
182
- }),
183
- exited: Promise.resolve(isDockerRun ? 1 : 0),
184
- }
185
- }
186
-
187
- const result = await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput, includeCa: true}, spawnCapture)
188
-
189
- expect(result.ok).toBe(false)
190
- expect(deletedPaths.length).toBeGreaterThan(0)
191
- })
192
- })
193
-
194
- describe('restoreGatewayCa — error paths', () => {
195
- it('exits before SSH when --input points at an empty file', async () => {
196
- const emptyPath = `/tmp/test-empty-${Date.now()}.tar`
197
- await Bun.write(emptyPath, new Uint8Array(0))
198
-
199
- try {
200
- const neverSpawn: RestoreSpawnFn = () => {
201
- throw new Error('spawn must not be called for empty input')
202
- }
203
-
204
- const result = await restoreGatewayCa(
205
- {host: 'gateway.example.com', input: emptyPath, includeCa: true},
206
- neverSpawn,
207
- )
208
-
209
- expect(result.ok).toBe(false)
210
- if (!result.ok) {
211
- expect(result.error).toMatch(/empty/i)
212
- }
213
- } finally {
214
- Bun.spawnSync(['rm', '-f', emptyPath])
215
- }
216
- })
217
-
218
- it('rejects a malicious host and does not invoke ssh', async () => {
219
- const tmpInput2 = `/tmp/test-restore-sec-${Date.now()}.tar`
220
- await writeFakeTar(tmpInput2)
221
-
222
- try {
223
- const neverSpawn: RestoreSpawnFn = () => {
224
- throw new Error('spawn must not be called for an invalid host')
225
- }
226
-
227
- await expect(
228
- restoreGatewayCa({host: '-oProxyCommand=touch /tmp/sec5-pwned', input: tmpInput2, includeCa: true}, neverSpawn),
229
- ).rejects.toThrow('Invalid GATEWAY_HOST')
230
- } finally {
231
- Bun.spawnSync(['rm', '-f', tmpInput2])
232
- }
233
- })
234
-
235
- it('exits non-zero with mismatch diagnostic when confirmation fails', async () => {
236
- const tmpInput3 = `/tmp/test-restore-mismatch-${Date.now()}.tar`
237
- await writeFakeTar(tmpInput3)
238
-
239
- try {
240
- // Sequence: tar -tf validate, mktemp, scp, chmod, docker run extract, restart,
241
- // cert read returns WRONG content, key read, rm cleanup
242
- const spawn = makeSpawnSequence([
243
- {stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n', stderr: '', exitCode: 0}, // tar -tf validate
244
- {stdout: '/tmp/gateway-ca-restore-abc123.tar\n', stderr: '', exitCode: 0}, // mktemp
245
- {stdout: '', stderr: '', exitCode: 0}, // scp
246
- {stdout: '', stderr: '', exitCode: 0}, // chmod 600
247
- {stdout: '', stderr: '', exitCode: 0}, // docker run extract
248
- {stdout: '', stderr: '', exitCode: 0}, // docker compose restart
249
- {stdout: 'WRONG_CERT', stderr: '', exitCode: 0}, // docker exec cert — mismatch
250
- {stdout: 'WRONG_KEY', stderr: '', exitCode: 0}, // docker exec key — mismatch
251
- {stdout: '', stderr: '', exitCode: 0}, // rm cleanup
252
- ])
253
-
254
- const result = await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput3, includeCa: true}, spawn)
255
-
256
- expect(result.ok).toBe(false)
257
- if (!result.ok) {
258
- expect(result.error).toMatch(/mismatch/i)
259
- }
260
- } finally {
261
- Bun.spawnSync(['rm', '-f', tmpInput3])
262
- }
263
- })
264
- })
265
-
266
- describe('restoreGatewayCa — edge case: --no-include-ca', () => {
267
- it('refuses with a clear message when includeCa is false', async () => {
268
- const tmpInput4 = `/tmp/test-restore-noca-${Date.now()}.tar`
269
- await writeFakeTar(tmpInput4)
270
-
271
- try {
272
- const result = await restoreGatewayCa({host: 'gateway.example.com', input: tmpInput4, includeCa: false})
273
-
274
- expect(result.ok).toBe(false)
275
- if (!result.ok) {
276
- expect(result.error).toMatch(/only CA restore/i)
277
- }
278
- } finally {
279
- Bun.spawnSync(['rm', '-f', tmpInput4])
280
- }
281
- })
282
- })
283
-
284
- // ─── COR3: Cleanup failure preserves primary error ────────────────────────────
285
-
286
- describe('restoreGatewayCa — cleanup failure preserves primary error (COR3)', () => {
287
- it('rejects with the docker failure error when both docker run and rm cleanup fail', async () => {
288
- const tmpInput5 = `/tmp/test-restore-cor3-${Date.now()}.tar`
289
- await writeFakeTar(tmpInput5)
290
-
291
- try {
292
- const encoder = new TextEncoder()
293
- const spawnBothFail: RestoreSpawnFn = (cmd, _opts) => {
294
- const cmdStr = cmd.join(' ')
295
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
296
- const isMktemp = cmdStr.includes('mktemp')
297
- const isDockerExtract = cmdStr.includes('docker run') && cmdStr.includes('tar -xf')
298
- const isRmCleanup = cmdStr.includes('rm -f')
299
-
300
- // tar -tf and mktemp succeed; scp succeeds; docker extract fails; rm cleanup also fails
301
- const exitCode = isDockerExtract || isRmCleanup ? 1 : 0
302
- const stderrMsg = isDockerExtract ? 'docker: fatal error' : isRmCleanup ? 'rm: permission denied' : ''
303
-
304
- return {
305
- stdout: new ReadableStream({
306
- start(controller) {
307
- if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'))
308
- else if (isMktemp) controller.enqueue(encoder.encode('/tmp/gateway-ca-restore-cor3-test.tar\n'))
309
- controller.close()
310
- },
311
- }),
312
- stderr: new ReadableStream({
313
- start(controller) {
314
- if (stderrMsg) controller.enqueue(encoder.encode(stderrMsg))
315
- controller.close()
316
- },
317
- }),
318
- exited: Promise.resolve(exitCode),
319
- }
320
- }
321
-
322
- const result = await restoreGatewayCa(
323
- {host: 'gateway.example.com', input: tmpInput5, includeCa: true},
324
- spawnBothFail,
325
- )
326
-
327
- // Must reflect the docker failure, not the rm failure
328
- expect(result.ok).toBe(false)
329
- if (!result.ok) {
330
- expect(result.error).toContain('docker run extract failed')
331
- expect(result.error).not.toContain('rm: permission denied')
332
- }
333
- } finally {
334
- Bun.spawnSync(['rm', '-f', tmpInput5])
335
- }
336
- })
337
- })
338
-
339
- // ─── SEC2: mktemp-based unguessable remote path ───────────────────────────────
340
-
341
- describe('restoreGatewayCa — SEC2: unguessable remote tmp path via mktemp', () => {
342
- it('uses the mktemp-returned path as the SCP destination and for chmod before docker run', async () => {
343
- const tarDir = `/tmp/test-sec2-tar-dir-${Date.now()}`
344
- const realTar = `/tmp/test-sec2-tar-${Date.now()}.tar`
345
- const certContent = 'CERT'
346
- const keyContent = 'KEY'
347
- Bun.spawnSync(['mkdir', '-p', tarDir])
348
- await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
349
- await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
350
- Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
351
-
352
- const capturedCmds: string[][] = []
353
- const unguessablePath = '/tmp/gateway-ca-restore-X7k9mQ.tar'
354
-
355
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
356
- capturedCmds.push([...cmd])
357
- const cmdStr = cmd.join(' ')
358
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
359
- const isMktemp = cmdStr.includes('mktemp')
360
- let stdout: string
361
- if (isTarTf) {
362
- stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
363
- } else if (isMktemp) {
364
- stdout = `${unguessablePath}\n`
365
- } else {
366
- stdout = certContent
367
- }
368
- const encoder = new TextEncoder()
369
- return {
370
- stdout: new ReadableStream({
371
- start(controller) {
372
- controller.enqueue(encoder.encode(stdout))
373
- controller.close()
374
- },
375
- }),
376
- stderr: new ReadableStream({
377
- start(controller) {
378
- controller.close()
379
- },
380
- }),
381
- exited: Promise.resolve(0),
382
- }
383
- }
384
-
385
- await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
386
-
387
- // SCP destination must use the unguessable path
388
- const scpCmd = capturedCmds.find(c => c[0] === 'scp')
389
- expect(scpCmd).toBeDefined()
390
- expect(scpCmd?.at(-1)).toBe(`root@gateway.example.com:${unguessablePath}`)
391
-
392
- // chmod 600 must be called on the unguessable path before docker run
393
- const chmodIdx = capturedCmds.findIndex(c => c.join(' ').includes('chmod 600'))
394
- const dockerRunIdx = capturedCmds.findIndex(
395
- c => c.join(' ').includes('docker run') && c.join(' ').includes('tar -xf'),
396
- )
397
- expect(chmodIdx).toBeGreaterThan(-1)
398
- expect(dockerRunIdx).toBeGreaterThan(-1)
399
- expect(chmodIdx).toBeLessThan(dockerRunIdx)
400
- expect(capturedCmds[chmodIdx]?.join(' ')).toContain(unguessablePath)
401
-
402
- Bun.spawnSync(['rm', '-rf', tarDir, realTar])
403
- })
404
- })
405
-
406
- // ─── SSH and SCP commands include repo-pinned UserKnownHostsFile ─────────────
407
-
408
- describe('restoreGatewayCa — SSH and SCP commands include UserKnownHostsFile', () => {
409
- it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to every ssh invocation', async () => {
410
- const tarDir = `/tmp/test-kh-ssh-tar-dir-${Date.now()}`
411
- const realTar = `/tmp/test-kh-ssh-tar-${Date.now()}.tar`
412
- const certContent = 'CERT'
413
- const keyContent = 'KEY'
414
- Bun.spawnSync(['mkdir', '-p', tarDir])
415
- await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
416
- await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
417
- Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
418
-
419
- const capturedCmds: string[][] = []
420
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
421
- capturedCmds.push([...cmd])
422
- const cmdStr = cmd.join(' ')
423
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
424
- const isMktemp = cmdStr.includes('mktemp')
425
- const encoder = new TextEncoder()
426
- let stdout = ''
427
- if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
428
- else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-test.tar\n'
429
- else stdout = certContent
430
- return {
431
- stdout: new ReadableStream({
432
- start(controller) {
433
- controller.enqueue(encoder.encode(stdout))
434
- controller.close()
435
- },
436
- }),
437
- stderr: new ReadableStream({
438
- start(controller) {
439
- controller.close()
440
- },
441
- }),
442
- exited: Promise.resolve(0),
443
- }
444
- }
445
-
446
- await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
447
-
448
- // Every ssh invocation must include UserKnownHostsFile
449
- const sshCmds = capturedCmds.filter(c => c[0] === 'ssh')
450
- expect(sshCmds.length).toBeGreaterThan(0)
451
- for (const sshCmd of sshCmds) {
452
- const knownHostsIdx = sshCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
453
- expect(knownHostsIdx).toBeGreaterThan(-1)
454
- expect(sshCmd[knownHostsIdx - 1]).toBe('-o')
455
- expect(sshCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
456
- }
457
-
458
- Bun.spawnSync(['rm', '-rf', tarDir, realTar])
459
- })
460
-
461
- it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to the scp invocation', async () => {
462
- const tarDir = `/tmp/test-kh-scp-tar-dir-${Date.now()}`
463
- const realTar = `/tmp/test-kh-scp-tar-${Date.now()}.tar`
464
- const certContent = 'CERT'
465
- const keyContent = 'KEY'
466
- Bun.spawnSync(['mkdir', '-p', tarDir])
467
- await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
468
- await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
469
- Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
470
-
471
- const capturedCmds: string[][] = []
472
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
473
- capturedCmds.push([...cmd])
474
- const cmdStr = cmd.join(' ')
475
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
476
- const isMktemp = cmdStr.includes('mktemp')
477
- const encoder = new TextEncoder()
478
- let stdout = ''
479
- if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
480
- else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-scp-test.tar\n'
481
- else stdout = certContent
482
- return {
483
- stdout: new ReadableStream({
484
- start(controller) {
485
- controller.enqueue(encoder.encode(stdout))
486
- controller.close()
487
- },
488
- }),
489
- stderr: new ReadableStream({
490
- start(controller) {
491
- controller.close()
492
- },
493
- }),
494
- exited: Promise.resolve(0),
495
- }
496
- }
497
-
498
- await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
499
-
500
- const scpCmd = capturedCmds.find(c => c[0] === 'scp')
501
- expect(scpCmd).toBeDefined()
502
- const knownHostsIdx = scpCmd!.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
503
- expect(knownHostsIdx).toBeGreaterThan(-1)
504
- expect(scpCmd![knownHostsIdx - 1]).toBe('-o')
505
- expect(scpCmd![knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
506
-
507
- Bun.spawnSync(['rm', '-rf', tarDir, realTar])
508
- })
509
-
510
- it('does not weaken StrictHostKeyChecking in ssh or scp commands', async () => {
511
- const tarDir = `/tmp/test-kh-strict-tar-dir-${Date.now()}`
512
- const realTar = `/tmp/test-kh-strict-tar-${Date.now()}.tar`
513
- const certContent = 'CERT'
514
- const keyContent = 'KEY'
515
- Bun.spawnSync(['mkdir', '-p', tarDir])
516
- await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
517
- await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
518
- Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
519
-
520
- const capturedCmds: string[][] = []
521
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
522
- capturedCmds.push([...cmd])
523
- const cmdStr = cmd.join(' ')
524
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
525
- const isMktemp = cmdStr.includes('mktemp')
526
- const encoder = new TextEncoder()
527
- let stdout = ''
528
- if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
529
- else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-strict-test.tar\n'
530
- else stdout = certContent
531
- return {
532
- stdout: new ReadableStream({
533
- start(controller) {
534
- controller.enqueue(encoder.encode(stdout))
535
- controller.close()
536
- },
537
- }),
538
- stderr: new ReadableStream({
539
- start(controller) {
540
- controller.close()
541
- },
542
- }),
543
- exited: Promise.resolve(0),
544
- }
545
- }
546
-
547
- await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
548
-
549
- // All ssh and scp commands must have StrictHostKeyChecking=yes
550
- const sshAndScpCmds = capturedCmds.filter(c => c[0] === 'ssh' || c[0] === 'scp')
551
- expect(sshAndScpCmds.length).toBeGreaterThan(0)
552
- for (const cmd of sshAndScpCmds) {
553
- const strictIdx = cmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
554
- expect(strictIdx).toBeGreaterThan(-1)
555
- expect(cmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
556
- }
557
-
558
- Bun.spawnSync(['rm', '-rf', tarDir, realTar])
559
- })
560
- })
561
-
562
- // ─── COR1: validateBackupArchive ─────────────────────────────────────────────
563
-
564
- describe('validateBackupArchive — COR1', () => {
565
- const env = {PATH: '/usr/bin:/bin', HOME: '/tmp'}
566
-
567
- it('returns undefined for a valid archive with exactly the two expected files', async () => {
568
- const tarDir = `/tmp/test-cor1-valid-${Date.now()}`
569
- const tarPath = `/tmp/test-cor1-valid-${Date.now()}.tar`
570
- Bun.spawnSync(['mkdir', '-p', tarDir])
571
- await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, 'cert')
572
- await Bun.write(`${tarDir}/mitmproxy-ca.pem`, 'key')
573
- Bun.spawnSync(['tar', '-cf', tarPath, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
574
-
575
- try {
576
- const spawn = makeSpawnSequence([{stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n', stderr: '', exitCode: 0}])
577
- const result = await validateBackupArchive(tarPath, spawn, env)
578
- expect(result).toBeUndefined()
579
- } finally {
580
- Bun.spawnSync(['rm', '-rf', tarDir, tarPath])
581
- }
582
- })
583
-
584
- it('returns an error when only the cert is present (missing key)', async () => {
585
- const spawn = makeSpawnSequence([{stdout: 'mitmproxy-ca-cert.pem\n', stderr: '', exitCode: 0}])
586
- const result = await validateBackupArchive('/fake/path.tar', spawn, env)
587
- expect(result).toBeDefined()
588
- expect(result).toMatch(/malformed/i)
589
- expect(result).toContain('mitmproxy-ca.pem')
590
- })
591
-
592
- it('returns an error when extra files are present alongside the two expected files', async () => {
593
- const spawn = makeSpawnSequence([
594
- {stdout: 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\nextra-file.txt\n', stderr: '', exitCode: 0},
595
- ])
596
- const result = await validateBackupArchive('/fake/path.tar', spawn, env)
597
- expect(result).toBeDefined()
598
- expect(result).toMatch(/malformed/i)
599
- expect(result).toContain('extra-file.txt')
600
- })
601
-
602
- it('returns an error when filenames differ from expected (cert.pem, key.pem)', async () => {
603
- const spawn = makeSpawnSequence([{stdout: 'cert.pem\nkey.pem\n', stderr: '', exitCode: 0}])
604
- const result = await validateBackupArchive('/fake/path.tar', spawn, env)
605
- expect(result).toBeDefined()
606
- expect(result).toMatch(/malformed/i)
607
- })
608
-
609
- it('returns an error and does not proceed to SCP when archive is malformed', async () => {
610
- const tmpInput = `/tmp/test-cor1-malformed-${Date.now()}.tar`
611
- await writeFakeTar(tmpInput)
612
-
613
- try {
614
- let scpCalled = false
615
- const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
616
- if (cmd[0] === 'scp') scpCalled = true
617
- const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
618
- const encoder = new TextEncoder()
619
- return {
620
- stdout: new ReadableStream({
621
- start(controller) {
622
- // Only cert, no key — malformed
623
- if (isTarTf) controller.enqueue(encoder.encode('mitmproxy-ca-cert.pem\n'))
624
- controller.close()
625
- },
626
- }),
627
- stderr: new ReadableStream({
628
- start(controller) {
629
- controller.close()
630
- },
631
- }),
632
- exited: Promise.resolve(0),
633
- }
634
- }
635
-
636
- const result = await restoreGatewayCa(
637
- {host: 'gateway.example.com', input: tmpInput, includeCa: true},
638
- spawnCapture,
639
- )
640
-
641
- expect(result.ok).toBe(false)
642
- if (!result.ok) {
643
- expect(result.error).toMatch(/malformed/i)
644
- }
645
- expect(scpCalled).toBe(false)
646
- } finally {
647
- Bun.spawnSync(['rm', '-f', tmpInput])
648
- }
649
- })
650
- })