@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.
- package/dist/cli.js +11965 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /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
|
-
}
|