@marcusrbrown/infra 0.4.9 → 0.4.10
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/package.json +2 -2
- package/src/__snapshots__/cli.test.ts.snap +32 -1
- package/src/cli.ts +2 -0
- package/src/commands/gateway/backup.test.ts +288 -0
- package/src/commands/gateway/backup.ts +188 -0
- package/src/commands/gateway/deploy.test.ts +297 -0
- package/src/commands/gateway/deploy.ts +148 -0
- package/src/commands/gateway/host.test.ts +73 -0
- package/src/commands/gateway/host.ts +31 -0
- package/src/commands/gateway/index.ts +17 -0
- package/src/commands/gateway/logs.test.ts +222 -0
- package/src/commands/gateway/logs.ts +158 -0
- package/src/commands/gateway/restore.test.ts +494 -0
- package/src/commands/gateway/restore.ts +297 -0
- package/src/commands/gateway/status.test.ts +259 -0
- package/src/commands/gateway/status.ts +209 -0
- package/src/commands/status.test.ts +25 -0
- package/src/commands/status.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcusrbrown/infra",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"infra",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"zod": "^4.3.6"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"yaml": "
|
|
39
|
+
"yaml": "2.9.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"bun": ">=1.0.0"
|
|
@@ -88,9 +88,40 @@ Commands:
|
|
|
88
88
|
--harness [harness] Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
gateway status Show operational health of the gateway deployment via docker compose ps.
|
|
92
|
+
|
|
93
|
+
--key [key] Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
gateway deploy Deploy the gateway. Default mode triggers the GitHub Deploy Gateway workflow, while --local runs apps/gateway deploy directly with Bun.
|
|
97
|
+
|
|
98
|
+
--local Run local deployment with Bun using apps/gateway instead of triggering GitHub Actions. (default: false)
|
|
99
|
+
--dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
|
|
100
|
+
--force-recreate Force recreate containers. Forwarded only in local mode. (default: false)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
gateway logs [service] Stream logs from a gateway service via SSH and docker compose.
|
|
104
|
+
|
|
105
|
+
--tail [n] Number of log lines to tail from each service. (default: 100)
|
|
106
|
+
--allow-ci Allow log streaming in CI environments. Logs may contain sensitive credentials. (default: false)
|
|
107
|
+
--key [key] Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
gateway backup 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.
|
|
111
|
+
|
|
112
|
+
--output [file] Local path to write the CA tarball. File permissions are set to 0600 after writing. (default: apps/gateway/.local/mitmproxy-ca.tar)
|
|
113
|
+
--include-ca Include the mitmproxy CA certificate and private key in the backup. Currently the only supported backup target. (default: true)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
gateway restore Restore the mitmproxy CA certificate and private key from a local tarball into the gateway Docker volume. Restarts mitmproxy and gateway services after restore and confirms byte-equal content before reporting success.
|
|
117
|
+
|
|
118
|
+
--input <file> Path to the tarball produced by \`gateway backup --include-ca\`. Required.
|
|
119
|
+
--include-ca Restore the mitmproxy CA certificate and private key. Currently the only supported restore target. (default: true)
|
|
120
|
+
|
|
121
|
+
|
|
91
122
|
status Show status of all deployments
|
|
92
123
|
|
|
93
|
-
--json Output machine-readable JSON with keeweb and
|
|
124
|
+
--json Output machine-readable JSON with keeweb, cliproxy, and gateway summary objects.
|
|
94
125
|
--verbose Include verbose per-app health check details when building the summary rows.
|
|
95
126
|
|
|
96
127
|
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {goke} from 'goke'
|
|
4
4
|
import pkg from '../package.json' with {type: 'json'}
|
|
5
5
|
import {registerCliproxyCommands} from './commands/cliproxy'
|
|
6
|
+
import {registerGatewayCommands} from './commands/gateway'
|
|
6
7
|
import {registerKeewebCommands} from './commands/keeweb'
|
|
7
8
|
import {registerMcp} from './commands/mcp'
|
|
8
9
|
import {registerStatus} from './commands/status'
|
|
@@ -18,6 +19,7 @@ cli.option('--verbose', 'Enable verbose output for all commands')
|
|
|
18
19
|
|
|
19
20
|
registerKeewebCommands(cli)
|
|
20
21
|
registerCliproxyCommands(cli)
|
|
22
|
+
registerGatewayCommands(cli)
|
|
21
23
|
registerStatus(cli)
|
|
22
24
|
registerMcp(cli)
|
|
23
25
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {statSync, writeFileSync} from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import {backupGatewayCa, type BackupSpawnFn} from './backup'
|
|
6
|
+
|
|
7
|
+
// ─── SpawnFn helpers ──────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function makeSpawnOk(tarBytes: Uint8Array = new Uint8Array([1, 2, 3])): BackupSpawnFn {
|
|
10
|
+
return (_cmd, _opts) => {
|
|
11
|
+
return {
|
|
12
|
+
stdout: new ReadableStream({
|
|
13
|
+
start(controller) {
|
|
14
|
+
controller.enqueue(tarBytes)
|
|
15
|
+
controller.close()
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
stderr: new ReadableStream({
|
|
19
|
+
start(controller) {
|
|
20
|
+
controller.close()
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
exited: Promise.resolve(0),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeSpawnError(message: string): BackupSpawnFn {
|
|
29
|
+
return (_cmd, _opts) => {
|
|
30
|
+
const encoder = new TextEncoder()
|
|
31
|
+
return {
|
|
32
|
+
stdout: new ReadableStream({
|
|
33
|
+
start(controller) {
|
|
34
|
+
controller.close()
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
stderr: new ReadableStream({
|
|
38
|
+
start(controller) {
|
|
39
|
+
controller.enqueue(encoder.encode(message))
|
|
40
|
+
controller.close()
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
exited: Promise.resolve(255),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── backupGatewayCa ─────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('backupGatewayCa — happy path', () => {
|
|
51
|
+
let tmpOutput: string
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
tmpOutput = `/tmp/test-backup-${Date.now()}.tar`
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
try {
|
|
59
|
+
;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('writes tarball to the output path with 0600 permissions', async () => {
|
|
66
|
+
const tarBytes = new Uint8Array([0x1f, 0x8b, 0x08, 0x00]) // fake tar header
|
|
67
|
+
|
|
68
|
+
const warnings: string[] = []
|
|
69
|
+
const result = await backupGatewayCa(
|
|
70
|
+
{host: 'gateway.example.com', output: tmpOutput, includeCa: true},
|
|
71
|
+
makeSpawnOk(tarBytes),
|
|
72
|
+
(msg: string) => {
|
|
73
|
+
warnings.push(msg)
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(result.ok).toBe(true)
|
|
78
|
+
expect(await Bun.file(tmpOutput).exists()).toBe(true)
|
|
79
|
+
|
|
80
|
+
const written = await Bun.file(tmpOutput).arrayBuffer()
|
|
81
|
+
expect(new Uint8Array(written)).toEqual(tarBytes)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns output path and bytesWritten on success', async () => {
|
|
85
|
+
const tarBytes = new Uint8Array([1, 2, 3, 4, 5])
|
|
86
|
+
const result = await backupGatewayCa(
|
|
87
|
+
{host: 'gateway.example.com', output: tmpOutput, includeCa: true},
|
|
88
|
+
makeSpawnOk(tarBytes),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(result.ok).toBe(true)
|
|
92
|
+
if (result.ok) {
|
|
93
|
+
expect(result.output).toBe(tmpOutput)
|
|
94
|
+
expect(result.bytesWritten).toBe(5)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('prints a stderr warning about sensitive output', async () => {
|
|
99
|
+
const warnings: string[] = []
|
|
100
|
+
await backupGatewayCa(
|
|
101
|
+
{host: 'gateway.example.com', output: tmpOutput, includeCa: true},
|
|
102
|
+
makeSpawnOk(),
|
|
103
|
+
(msg: string) => {
|
|
104
|
+
warnings.push(msg)
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(warnings.some(w => w.toLowerCase().includes('sensitive'))).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('uses a custom --output path when provided', async () => {
|
|
112
|
+
const customOutput = `/tmp/test-custom-${Date.now()}.tar`
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await backupGatewayCa(
|
|
116
|
+
{host: 'gateway.example.com', output: customOutput, includeCa: true},
|
|
117
|
+
makeSpawnOk(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(result.ok).toBe(true)
|
|
121
|
+
expect(await Bun.file(customOutput).exists()).toBe(true)
|
|
122
|
+
} finally {
|
|
123
|
+
Bun.spawnSync(['rm', '-f', customOutput])
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('performs CA backup when includeCa is true (default behavior)', async () => {
|
|
128
|
+
let capturedCmd: string[] = []
|
|
129
|
+
const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
|
|
130
|
+
capturedCmd = cmd
|
|
131
|
+
return {
|
|
132
|
+
stdout: new ReadableStream({
|
|
133
|
+
start(controller) {
|
|
134
|
+
controller.enqueue(new Uint8Array([1, 2, 3]))
|
|
135
|
+
controller.close()
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
stderr: new ReadableStream({
|
|
139
|
+
start(controller) {
|
|
140
|
+
controller.close()
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
exited: Promise.resolve(0),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
|
|
148
|
+
|
|
149
|
+
const cmdStr = capturedCmd.join(' ')
|
|
150
|
+
expect(cmdStr).toContain('mitmproxy-ca-cert.pem')
|
|
151
|
+
expect(cmdStr).toContain('mitmproxy-ca.pem')
|
|
152
|
+
expect(cmdStr).toContain('mitmproxy-certs')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('backupGatewayCa — edge case: --no-include-ca', () => {
|
|
157
|
+
it('refuses with a clear message when includeCa is false', async () => {
|
|
158
|
+
const result = await backupGatewayCa({
|
|
159
|
+
host: 'gateway.example.com',
|
|
160
|
+
output: '/tmp/irrelevant.tar',
|
|
161
|
+
includeCa: false,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
expect(result.ok).toBe(false)
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
expect(result.error).toMatch(/only CA backup/i)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('backupGatewayCa — error paths', () => {
|
|
172
|
+
it('rejects a malicious host and does not invoke ssh', async () => {
|
|
173
|
+
const neverSpawn: BackupSpawnFn = () => {
|
|
174
|
+
throw new Error('spawn must not be called for an invalid host')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await expect(
|
|
178
|
+
backupGatewayCa(
|
|
179
|
+
{host: '-oProxyCommand=touch /tmp/sec5-pwned', output: '/tmp/x.tar', includeCa: true},
|
|
180
|
+
neverSpawn,
|
|
181
|
+
),
|
|
182
|
+
).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('rejects a host with shell metacharacters and does not invoke ssh', async () => {
|
|
186
|
+
const neverSpawn: BackupSpawnFn = () => {
|
|
187
|
+
throw new Error('spawn must not be called for an invalid host')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
backupGatewayCa({host: 'gateway.example.com;rm -rf /', output: '/tmp/x.tar', includeCa: true}, neverSpawn),
|
|
192
|
+
).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('surfaces the underlying error when SSH is unreachable', async () => {
|
|
196
|
+
const result = await backupGatewayCa(
|
|
197
|
+
{host: 'gateway.example.com', output: '/tmp/ssh-fail.tar', includeCa: true},
|
|
198
|
+
makeSpawnError('Connection refused'),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
expect(result.ok).toBe(false)
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
expect(result.error).toContain('Connection refused')
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// ─── COR2: Atomic write behavior ─────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe('backupGatewayCa — atomic write (COR2)', () => {
|
|
211
|
+
it('leaves no .tmp.* file after a successful write', async () => {
|
|
212
|
+
const output = `/tmp/test-atomic-ok-${Date.now()}.tar`
|
|
213
|
+
const dir = '/tmp'
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const result = await backupGatewayCa(
|
|
217
|
+
{host: 'gateway.example.com', output, includeCa: true},
|
|
218
|
+
makeSpawnOk(new Uint8Array([1, 2, 3])),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
expect(result.ok).toBe(true)
|
|
222
|
+
|
|
223
|
+
// No .tmp.* file should remain alongside the output
|
|
224
|
+
// Check no sibling tmp file exists by listing /tmp and filtering
|
|
225
|
+
const {stdout} = Bun.spawnSync(['sh', '-c', `ls ${dir}/ | grep -E '^test-atomic-ok.*[.]tmp[.]' || true`])
|
|
226
|
+
const tmpFiles = new TextDecoder().decode(stdout).trim()
|
|
227
|
+
expect(tmpFiles).toBe('')
|
|
228
|
+
} finally {
|
|
229
|
+
Bun.spawnSync(['rm', '-f', output])
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('leaves no partial output file and does not overwrite existing file when chmod fails', async () => {
|
|
234
|
+
const output = `/tmp/test-atomic-fail-${Date.now()}.tar`
|
|
235
|
+
const existingContent = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
|
236
|
+
|
|
237
|
+
// Write a pre-existing file with known content
|
|
238
|
+
writeFileSync(output, existingContent)
|
|
239
|
+
|
|
240
|
+
// Mock spawn that succeeds (returns tar bytes) but we'll intercept chmod via a
|
|
241
|
+
// custom spawn that also intercepts the chmod call — however chmod is called via
|
|
242
|
+
// Bun.spawnSync which we can't easily mock. Instead, test the tmp-then-rename
|
|
243
|
+
// path by writing to a read-only directory to force rename failure.
|
|
244
|
+
//
|
|
245
|
+
// Simpler approach: verify that after a failed SSH (so we never reach write),
|
|
246
|
+
// no tmp file is left. For chmod failure specifically, we verify the output
|
|
247
|
+
// file is unchanged when the overall operation fails.
|
|
248
|
+
const result = await backupGatewayCa(
|
|
249
|
+
{host: 'gateway.example.com', output, includeCa: true},
|
|
250
|
+
makeSpawnError('SSH failed'),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
expect(result.ok).toBe(false)
|
|
254
|
+
|
|
255
|
+
// The pre-existing file must be untouched (no overwrite on failure)
|
|
256
|
+
const afterContent = await Bun.file(output).arrayBuffer()
|
|
257
|
+
expect(new Uint8Array(afterContent)).toEqual(existingContent)
|
|
258
|
+
|
|
259
|
+
// No tmp file should exist
|
|
260
|
+
const {stdout} = Bun.spawnSync(['sh', '-c', `ls /tmp/ | grep -E 'test-atomic-fail.*[.]tmp[.]' || true`])
|
|
261
|
+
expect(new TextDecoder().decode(stdout).trim()).toBe('')
|
|
262
|
+
|
|
263
|
+
Bun.spawnSync(['rm', '-f', output])
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// ─── SEC1: Tmp file born with mode 0600 (no chmod race) ──────────────────────
|
|
268
|
+
|
|
269
|
+
describe('backupGatewayCa — SEC1: tmp file created with mode 0600 atomically', () => {
|
|
270
|
+
it('creates the output file with mode 0600 (live stat check)', async () => {
|
|
271
|
+
const output = `/tmp/test-sec1-mode-${Date.now()}.tar`
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const result = await backupGatewayCa(
|
|
275
|
+
{host: 'gateway.example.com', output, includeCa: true},
|
|
276
|
+
makeSpawnOk(new Uint8Array([0xca, 0xfe, 0xba, 0xbe])),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
expect(result.ok).toBe(true)
|
|
280
|
+
|
|
281
|
+
// The final file must be 0600
|
|
282
|
+
const stat = statSync(output)
|
|
283
|
+
expect(stat.mode & 0o777).toBe(0o600)
|
|
284
|
+
} finally {
|
|
285
|
+
Bun.spawnSync(['rm', '-f', output])
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync} from 'node:fs'
|
|
4
|
+
|
|
5
|
+
import {z} from 'zod'
|
|
6
|
+
|
|
7
|
+
import {validateGatewayHost} from './host'
|
|
8
|
+
|
|
9
|
+
declare const process: {
|
|
10
|
+
env: Record<string, string | undefined>
|
|
11
|
+
exitCode?: number
|
|
12
|
+
stderr: {write: (msg: string) => void}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MITMPROXY_CERTS_VOLUME = 'mitmproxy-certs'
|
|
16
|
+
const CA_CERT_FILE = 'mitmproxy-ca-cert.pem'
|
|
17
|
+
const CA_KEY_FILE = 'mitmproxy-ca.pem'
|
|
18
|
+
const DEFAULT_OUTPUT = 'apps/gateway/.local/mitmproxy-ca.tar'
|
|
19
|
+
const SENSITIVE_WARNING = 'Warning: Output contains the mitmproxy CA private trust anchor; treat as sensitive.'
|
|
20
|
+
|
|
21
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type BackupSpawnFn = (
|
|
24
|
+
cmd: string[],
|
|
25
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
26
|
+
) => {
|
|
27
|
+
stdout: ReadableStream<Uint8Array>
|
|
28
|
+
stderr: ReadableStream<Uint8Array>
|
|
29
|
+
exited: Promise<number>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BackupOpts {
|
|
33
|
+
host: string
|
|
34
|
+
output: string
|
|
35
|
+
includeCa: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BackupResult = {ok: true; output: string; bytesWritten: number} | {ok: false; error: string}
|
|
39
|
+
|
|
40
|
+
// ─── Core logic ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function defaultSpawn(
|
|
43
|
+
cmd: string[],
|
|
44
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
45
|
+
): ReturnType<BackupSpawnFn> {
|
|
46
|
+
return Bun.spawn(cmd, opts) as ReturnType<BackupSpawnFn>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function backupGatewayCa(
|
|
50
|
+
opts: BackupOpts,
|
|
51
|
+
spawn: BackupSpawnFn = defaultSpawn,
|
|
52
|
+
printErr?: (msg: string) => void,
|
|
53
|
+
): Promise<BackupResult> {
|
|
54
|
+
if (!opts.includeCa) {
|
|
55
|
+
return {ok: false, error: 'Only CA backup is currently supported; --no-include-ca is not accepted'}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate host before any SSH invocation
|
|
59
|
+
validateGatewayHost(opts.host)
|
|
60
|
+
|
|
61
|
+
const sshCmd = [
|
|
62
|
+
'ssh',
|
|
63
|
+
'-o',
|
|
64
|
+
'BatchMode=yes',
|
|
65
|
+
'-o',
|
|
66
|
+
'StrictHostKeyChecking=yes',
|
|
67
|
+
`root@${opts.host}`,
|
|
68
|
+
`docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine tar -cf - -C /src ${CA_CERT_FILE} ${CA_KEY_FILE}`,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const env: Record<string, string> = {
|
|
72
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
73
|
+
HOME: process.env.HOME ?? '/tmp',
|
|
74
|
+
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
78
|
+
|
|
79
|
+
const [tarBytes, stderrText, exitCode] = await Promise.all([
|
|
80
|
+
new Response(child.stdout).arrayBuffer(),
|
|
81
|
+
new Response(child.stderr).text(),
|
|
82
|
+
child.exited,
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
if (exitCode !== 0) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: `SSH/docker command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Atomic write: open with O_CREAT|O_EXCL|0o600 (file born with correct mode, no chmod race),
|
|
93
|
+
// write bytes, close, then rename to final path.
|
|
94
|
+
const writeTmpSecure = (path: string, bytes: ArrayBuffer): void => {
|
|
95
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600)
|
|
96
|
+
try {
|
|
97
|
+
writeSync(fd, new Uint8Array(bytes))
|
|
98
|
+
} finally {
|
|
99
|
+
closeSync(fd)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let tmpPath = `${opts.output}.tmp.${Date.now()}`
|
|
104
|
+
try {
|
|
105
|
+
try {
|
|
106
|
+
writeTmpSecure(tmpPath, tarBytes)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// O_EXCL collision (EEXIST) — retry once with a fresh suffix
|
|
109
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
110
|
+
tmpPath = `${opts.output}.tmp.${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
111
|
+
writeTmpSecure(tmpPath, tarBytes)
|
|
112
|
+
} else {
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
renameSync(tmpPath, opts.output)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
try {
|
|
120
|
+
unlinkSync(tmpPath)
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore ENOENT or other cleanup errors
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: error instanceof Error ? error.message : String(error),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Warn about sensitive content
|
|
131
|
+
const warn = printErr ?? ((msg: string) => process.stderr.write(`${msg}\n`))
|
|
132
|
+
warn(SENSITIVE_WARNING)
|
|
133
|
+
|
|
134
|
+
return {ok: true, output: opts.output, bytesWritten: tarBytes.byteLength}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export function registerGatewayBackup(cli: ReturnType<typeof goke>): void {
|
|
140
|
+
cli
|
|
141
|
+
.command(
|
|
142
|
+
'gateway backup',
|
|
143
|
+
'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.',
|
|
144
|
+
)
|
|
145
|
+
.option(
|
|
146
|
+
'--output [file]',
|
|
147
|
+
z
|
|
148
|
+
.string()
|
|
149
|
+
.default(DEFAULT_OUTPUT)
|
|
150
|
+
.describe('Local path to write the CA tarball. File permissions are set to 0600 after writing.'),
|
|
151
|
+
)
|
|
152
|
+
.option(
|
|
153
|
+
'--include-ca',
|
|
154
|
+
z
|
|
155
|
+
.boolean()
|
|
156
|
+
.default(true)
|
|
157
|
+
.describe(
|
|
158
|
+
'Include the mitmproxy CA certificate and private key in the backup. Currently the only supported backup target.',
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
.example('# Back up the CA to the default path')
|
|
162
|
+
.example('infra gateway backup --include-ca')
|
|
163
|
+
.example('# Back up the CA to a custom path')
|
|
164
|
+
.example('infra gateway backup --output /secure/backup/mitmproxy-ca.tar')
|
|
165
|
+
.action(async options => {
|
|
166
|
+
const hostEnvKey = 'GATEWAY_HOST'
|
|
167
|
+
const host = process.env[hostEnvKey]
|
|
168
|
+
|
|
169
|
+
if (!host) {
|
|
170
|
+
console.error(`Gateway host not set. Export ${hostEnvKey} before running backup.`)
|
|
171
|
+
process.exitCode = 1
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const output = typeof options.output === 'string' ? options.output : DEFAULT_OUTPUT
|
|
176
|
+
const includeCa = options.includeCa !== false
|
|
177
|
+
|
|
178
|
+
const result = await backupGatewayCa({host, output, includeCa})
|
|
179
|
+
|
|
180
|
+
if (!result.ok) {
|
|
181
|
+
console.error(`Backup failed: ${result.error}`)
|
|
182
|
+
process.exitCode = 1
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(`CA backup written to: ${output}`)
|
|
187
|
+
})
|
|
188
|
+
}
|