@marcusrbrown/infra 0.13.0 → 0.13.2
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 +4977 -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,454 +0,0 @@
|
|
|
1
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
-
|
|
3
|
-
import {getVpnStatusSummary, parseWgShowOutput, type WgShowResult} from './shared'
|
|
4
|
-
|
|
5
|
-
// ─── parseWgShowOutput ────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
describe('parseWgShowOutput', () => {
|
|
8
|
-
it('parses wg show output with interface and peers', () => {
|
|
9
|
-
const output = [
|
|
10
|
-
'interface: wg0',
|
|
11
|
-
' public key: abc123pubkey==',
|
|
12
|
-
' private key: (hidden)',
|
|
13
|
-
' listening port: 51820',
|
|
14
|
-
'',
|
|
15
|
-
'peer: PEER1PUBKEY==',
|
|
16
|
-
' endpoint: 10.0.0.1:12345',
|
|
17
|
-
' allowed ips: 10.8.0.2/32',
|
|
18
|
-
' latest handshake: 1 minute, 30 seconds ago',
|
|
19
|
-
' transfer: 1.23 MiB received, 4.56 MiB sent',
|
|
20
|
-
].join('\n')
|
|
21
|
-
|
|
22
|
-
const result = parseWgShowOutput(output)
|
|
23
|
-
|
|
24
|
-
expect(result.interfaceUp).toBe(true)
|
|
25
|
-
expect(result.serverPublicKey).toBe('abc123pubkey==')
|
|
26
|
-
expect(result.peerCount).toBe(1)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('parses wg show output with no peers', () => {
|
|
30
|
-
const output = [
|
|
31
|
-
'interface: wg0',
|
|
32
|
-
' public key: serverpubkey==',
|
|
33
|
-
' private key: (hidden)',
|
|
34
|
-
' listening port: 51820',
|
|
35
|
-
].join('\n')
|
|
36
|
-
|
|
37
|
-
const result = parseWgShowOutput(output)
|
|
38
|
-
|
|
39
|
-
expect(result.interfaceUp).toBe(true)
|
|
40
|
-
expect(result.serverPublicKey).toBe('serverpubkey==')
|
|
41
|
-
expect(result.peerCount).toBe(0)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('parses wg show output with multiple peers', () => {
|
|
45
|
-
const output = [
|
|
46
|
-
'interface: wg0',
|
|
47
|
-
' public key: serverpubkey==',
|
|
48
|
-
' private key: (hidden)',
|
|
49
|
-
' listening port: 51820',
|
|
50
|
-
'',
|
|
51
|
-
'peer: PEER1PUBKEY==',
|
|
52
|
-
' allowed ips: 10.8.0.2/32',
|
|
53
|
-
'',
|
|
54
|
-
'peer: PEER2PUBKEY==',
|
|
55
|
-
' allowed ips: 10.8.0.3/32',
|
|
56
|
-
'',
|
|
57
|
-
'peer: PEER3PUBKEY==',
|
|
58
|
-
' allowed ips: 10.8.0.4/32',
|
|
59
|
-
].join('\n')
|
|
60
|
-
|
|
61
|
-
const result = parseWgShowOutput(output)
|
|
62
|
-
|
|
63
|
-
expect(result.interfaceUp).toBe(true)
|
|
64
|
-
expect(result.peerCount).toBe(3)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('returns interfaceUp=false for empty output', () => {
|
|
68
|
-
const result = parseWgShowOutput('')
|
|
69
|
-
|
|
70
|
-
expect(result.interfaceUp).toBe(false)
|
|
71
|
-
expect(result.serverPublicKey).toBeUndefined()
|
|
72
|
-
expect(result.peerCount).toBe(0)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('returns interfaceUp=false when output has no interface: line', () => {
|
|
76
|
-
const result = parseWgShowOutput('some random output without interface line')
|
|
77
|
-
|
|
78
|
-
expect(result.interfaceUp).toBe(false)
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
// ─── getVpnStatusSummary — SSH-only (no AWS client) ──────────────────────────
|
|
83
|
-
|
|
84
|
-
type SpawnFn = (
|
|
85
|
-
cmd: string[],
|
|
86
|
-
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
87
|
-
) => {
|
|
88
|
-
stdout: ReadableStream<Uint8Array>
|
|
89
|
-
stderr: ReadableStream<Uint8Array>
|
|
90
|
-
exited: Promise<number>
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function makeSpawnOk(output: string): SpawnFn {
|
|
94
|
-
return (_cmd, _opts) => {
|
|
95
|
-
const encoder = new TextEncoder()
|
|
96
|
-
return {
|
|
97
|
-
stdout: new ReadableStream({
|
|
98
|
-
start(controller) {
|
|
99
|
-
controller.enqueue(encoder.encode(output))
|
|
100
|
-
controller.close()
|
|
101
|
-
},
|
|
102
|
-
}),
|
|
103
|
-
stderr: new ReadableStream({
|
|
104
|
-
start(controller) {
|
|
105
|
-
controller.close()
|
|
106
|
-
},
|
|
107
|
-
}),
|
|
108
|
-
exited: Promise.resolve(0),
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function makeSpawnError(message: string): SpawnFn {
|
|
114
|
-
return (_cmd, _opts) => {
|
|
115
|
-
const encoder = new TextEncoder()
|
|
116
|
-
return {
|
|
117
|
-
stdout: new ReadableStream({
|
|
118
|
-
start(controller) {
|
|
119
|
-
controller.close()
|
|
120
|
-
},
|
|
121
|
-
}),
|
|
122
|
-
stderr: new ReadableStream({
|
|
123
|
-
start(controller) {
|
|
124
|
-
controller.enqueue(encoder.encode(message))
|
|
125
|
-
controller.close()
|
|
126
|
-
},
|
|
127
|
-
}),
|
|
128
|
-
exited: Promise.resolve(1),
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
describe('getVpnStatusSummary', () => {
|
|
134
|
-
it('returns a structured summary with app=vpn when wg show succeeds', async () => {
|
|
135
|
-
const wgOutput = [
|
|
136
|
-
'interface: wg0',
|
|
137
|
-
' public key: serverpubkey==',
|
|
138
|
-
' private key: (hidden)',
|
|
139
|
-
' listening port: 51820',
|
|
140
|
-
'',
|
|
141
|
-
'peer: PEER1PUBKEY==',
|
|
142
|
-
' allowed ips: 10.8.0.2/32',
|
|
143
|
-
].join('\n')
|
|
144
|
-
|
|
145
|
-
const summary = await getVpnStatusSummary('1.2.3.4', makeSpawnOk(wgOutput))
|
|
146
|
-
|
|
147
|
-
expect(summary.app).toBe('vpn')
|
|
148
|
-
expect(summary.http).toContain('OK')
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('includes the server public key in the http field', async () => {
|
|
152
|
-
const wgOutput = [
|
|
153
|
-
'interface: wg0',
|
|
154
|
-
' public key: MYPUBLICKEY==',
|
|
155
|
-
' private key: (hidden)',
|
|
156
|
-
' listening port: 51820',
|
|
157
|
-
].join('\n')
|
|
158
|
-
|
|
159
|
-
const summary = await getVpnStatusSummary('1.2.3.4', makeSpawnOk(wgOutput))
|
|
160
|
-
|
|
161
|
-
expect(summary.http).toContain('MYPUBLICKEY==')
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('returns error summary when SSH fails', async () => {
|
|
165
|
-
const summary = await getVpnStatusSummary('1.2.3.4', makeSpawnError('Connection refused'))
|
|
166
|
-
|
|
167
|
-
expect(summary.app).toBe('vpn')
|
|
168
|
-
expect(summary.http).toContain('ERROR')
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('rejects an invalid host before spawning SSH', async () => {
|
|
172
|
-
const neverSpawn: SpawnFn = () => {
|
|
173
|
-
throw new Error('spawn must not be called for an invalid host')
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
await expect(getVpnStatusSummary('-oProxyCommand=evil', neverSpawn)).rejects.toThrow('Invalid VPN_HOST')
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('rejects a host with shell metacharacters before spawning SSH', async () => {
|
|
180
|
-
const neverSpawn: SpawnFn = () => {
|
|
181
|
-
throw new Error('spawn must not be called for an invalid host')
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
await expect(getVpnStatusSummary('vpn.example.com;rm -rf', neverSpawn)).rejects.toThrow('Invalid VPN_HOST')
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it('does NOT import or construct a Lightsail/AWS client (SSH-only)', async () => {
|
|
188
|
-
// This test verifies the module source does not reference AWS SDK.
|
|
189
|
-
// We import the module and check it doesn't have AWS client construction.
|
|
190
|
-
// The real assertion is structural: getVpnStatusSummary only uses SSH.
|
|
191
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
192
|
-
const summary = await getVpnStatusSummary('1.2.3.4', makeSpawnOk(wgOutput))
|
|
193
|
-
|
|
194
|
-
// If this resolves without AWS credentials, it's SSH-only.
|
|
195
|
-
expect(summary.app).toBe('vpn')
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('SSH user contract: connects as ubuntu@host, not root@host', async () => {
|
|
199
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
200
|
-
let capturedCmd: string[] = []
|
|
201
|
-
|
|
202
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
203
|
-
capturedCmd = cmd
|
|
204
|
-
const encoder = new TextEncoder()
|
|
205
|
-
return {
|
|
206
|
-
stdout: new ReadableStream({
|
|
207
|
-
start(controller) {
|
|
208
|
-
controller.enqueue(encoder.encode(wgOutput))
|
|
209
|
-
controller.close()
|
|
210
|
-
},
|
|
211
|
-
}),
|
|
212
|
-
stderr: new ReadableStream({
|
|
213
|
-
start(c) {
|
|
214
|
-
c.close()
|
|
215
|
-
},
|
|
216
|
-
}),
|
|
217
|
-
exited: Promise.resolve(0),
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
await getVpnStatusSummary('1.2.3.4', capturingSpawn)
|
|
222
|
-
|
|
223
|
-
const destination = capturedCmd.find(arg => arg.includes('@'))
|
|
224
|
-
expect(destination).toBeDefined()
|
|
225
|
-
expect(destination).toMatch(/^ubuntu@/)
|
|
226
|
-
expect(destination).not.toMatch(/^root@/)
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
it('privilege contract: wg show wg0 is prefixed with sudo', async () => {
|
|
230
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
231
|
-
let capturedCmd: string[] = []
|
|
232
|
-
|
|
233
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
234
|
-
capturedCmd = cmd
|
|
235
|
-
const encoder = new TextEncoder()
|
|
236
|
-
return {
|
|
237
|
-
stdout: new ReadableStream({
|
|
238
|
-
start(controller) {
|
|
239
|
-
controller.enqueue(encoder.encode(wgOutput))
|
|
240
|
-
controller.close()
|
|
241
|
-
},
|
|
242
|
-
}),
|
|
243
|
-
stderr: new ReadableStream({
|
|
244
|
-
start(c) {
|
|
245
|
-
c.close()
|
|
246
|
-
},
|
|
247
|
-
}),
|
|
248
|
-
exited: Promise.resolve(0),
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
await getVpnStatusSummary('1.2.3.4', capturingSpawn)
|
|
253
|
-
|
|
254
|
-
// The last element of the SSH command is the remote command to run
|
|
255
|
-
const remoteCmd = capturedCmd.at(-1)
|
|
256
|
-
expect(remoteCmd).toMatch(/^sudo wg show wg0/)
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
|
|
260
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
261
|
-
let capturedCmd: string[] = []
|
|
262
|
-
|
|
263
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
264
|
-
capturedCmd = cmd
|
|
265
|
-
const encoder = new TextEncoder()
|
|
266
|
-
return {
|
|
267
|
-
stdout: new ReadableStream({
|
|
268
|
-
start(controller) {
|
|
269
|
-
controller.enqueue(encoder.encode(wgOutput))
|
|
270
|
-
controller.close()
|
|
271
|
-
},
|
|
272
|
-
}),
|
|
273
|
-
stderr: new ReadableStream({
|
|
274
|
-
start(c) {
|
|
275
|
-
c.close()
|
|
276
|
-
},
|
|
277
|
-
}),
|
|
278
|
-
exited: Promise.resolve(0),
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
await getVpnStatusSummary('1.2.3.4', capturingSpawn)
|
|
283
|
-
|
|
284
|
-
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
285
|
-
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
286
|
-
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
287
|
-
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
it('does not weaken StrictHostKeyChecking', async () => {
|
|
291
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
292
|
-
let capturedCmd: string[] = []
|
|
293
|
-
|
|
294
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
295
|
-
capturedCmd = cmd
|
|
296
|
-
const encoder = new TextEncoder()
|
|
297
|
-
return {
|
|
298
|
-
stdout: new ReadableStream({
|
|
299
|
-
start(controller) {
|
|
300
|
-
controller.enqueue(encoder.encode(wgOutput))
|
|
301
|
-
controller.close()
|
|
302
|
-
},
|
|
303
|
-
}),
|
|
304
|
-
stderr: new ReadableStream({
|
|
305
|
-
start(c) {
|
|
306
|
-
c.close()
|
|
307
|
-
},
|
|
308
|
-
}),
|
|
309
|
-
exited: Promise.resolve(0),
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
await getVpnStatusSummary('1.2.3.4', capturingSpawn)
|
|
314
|
-
|
|
315
|
-
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
316
|
-
expect(strictIdx).toBeGreaterThan(-1)
|
|
317
|
-
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
318
|
-
})
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
// ─── WgShowResult type export check ──────────────────────────────────────────
|
|
322
|
-
|
|
323
|
-
describe('WgShowResult type', () => {
|
|
324
|
-
it('has the expected shape', () => {
|
|
325
|
-
const result: WgShowResult = {
|
|
326
|
-
interfaceUp: true,
|
|
327
|
-
serverPublicKey: 'key==',
|
|
328
|
-
peerCount: 2,
|
|
329
|
-
}
|
|
330
|
-
expect(result.interfaceUp).toBe(true)
|
|
331
|
-
expect(result.serverPublicKey).toBe('key==')
|
|
332
|
-
expect(result.peerCount).toBe(2)
|
|
333
|
-
})
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
// ─── SSH identity injection (VPN_SSH_KEY) ─────────────────────────────────────
|
|
337
|
-
|
|
338
|
-
describe('getVpnWgStatus — SSH identity injection', () => {
|
|
339
|
-
let originalEnv: Record<string, string | undefined>
|
|
340
|
-
|
|
341
|
-
beforeEach(() => {
|
|
342
|
-
originalEnv = {VPN_SSH_KEY: process.env.VPN_SSH_KEY}
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
afterEach(() => {
|
|
346
|
-
if (originalEnv.VPN_SSH_KEY === undefined) {
|
|
347
|
-
delete process.env.VPN_SSH_KEY
|
|
348
|
-
} else {
|
|
349
|
-
process.env.VPN_SSH_KEY = originalEnv.VPN_SSH_KEY
|
|
350
|
-
}
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it('includes -i <path> and IdentitiesOnly=yes in ssh argv when VPN_SSH_KEY is set', async () => {
|
|
354
|
-
const {getVpnWgStatus} = await import('./shared')
|
|
355
|
-
process.env.VPN_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
356
|
-
|
|
357
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
358
|
-
let capturedCmd: string[] = []
|
|
359
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
360
|
-
capturedCmd = cmd
|
|
361
|
-
const enc = new TextEncoder()
|
|
362
|
-
return {
|
|
363
|
-
stdout: new ReadableStream({
|
|
364
|
-
start(c) {
|
|
365
|
-
c.enqueue(enc.encode(wgOutput))
|
|
366
|
-
c.close()
|
|
367
|
-
},
|
|
368
|
-
}),
|
|
369
|
-
stderr: new ReadableStream({
|
|
370
|
-
start(c) {
|
|
371
|
-
c.close()
|
|
372
|
-
},
|
|
373
|
-
}),
|
|
374
|
-
exited: Promise.resolve(0),
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
await getVpnWgStatus('1.2.3.4', capturingSpawn)
|
|
379
|
-
|
|
380
|
-
const iIdx = capturedCmd.indexOf('-i')
|
|
381
|
-
expect(iIdx).toBeGreaterThan(-1)
|
|
382
|
-
expect(capturedCmd[iIdx + 1]).toBeTruthy()
|
|
383
|
-
|
|
384
|
-
const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
|
|
385
|
-
expect(identitiesOnlyIdx).toBeGreaterThan(-1)
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
it('does not include -i or IdentitiesOnly=yes when VPN_SSH_KEY is absent', async () => {
|
|
389
|
-
const {getVpnWgStatus} = await import('./shared')
|
|
390
|
-
delete process.env.VPN_SSH_KEY
|
|
391
|
-
|
|
392
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
393
|
-
let capturedCmd: string[] = []
|
|
394
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
395
|
-
capturedCmd = cmd
|
|
396
|
-
const enc = new TextEncoder()
|
|
397
|
-
return {
|
|
398
|
-
stdout: new ReadableStream({
|
|
399
|
-
start(c) {
|
|
400
|
-
c.enqueue(enc.encode(wgOutput))
|
|
401
|
-
c.close()
|
|
402
|
-
},
|
|
403
|
-
}),
|
|
404
|
-
stderr: new ReadableStream({
|
|
405
|
-
start(c) {
|
|
406
|
-
c.close()
|
|
407
|
-
},
|
|
408
|
-
}),
|
|
409
|
-
exited: Promise.resolve(0),
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
await getVpnWgStatus('1.2.3.4', capturingSpawn)
|
|
414
|
-
|
|
415
|
-
expect(capturedCmd.indexOf('-i')).toBe(-1)
|
|
416
|
-
expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
it('cleans up the temp key file after the SSH command completes', async () => {
|
|
420
|
-
const {getVpnWgStatus} = await import('./shared')
|
|
421
|
-
const {statSync} = await import('node:fs')
|
|
422
|
-
process.env.VPN_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
423
|
-
|
|
424
|
-
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
425
|
-
let capturedKeyPath: string | undefined
|
|
426
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
427
|
-
const iIdx = cmd.indexOf('-i')
|
|
428
|
-
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
429
|
-
const enc = new TextEncoder()
|
|
430
|
-
return {
|
|
431
|
-
stdout: new ReadableStream({
|
|
432
|
-
start(c) {
|
|
433
|
-
c.enqueue(enc.encode(wgOutput))
|
|
434
|
-
c.close()
|
|
435
|
-
},
|
|
436
|
-
}),
|
|
437
|
-
stderr: new ReadableStream({
|
|
438
|
-
start(c) {
|
|
439
|
-
c.close()
|
|
440
|
-
},
|
|
441
|
-
}),
|
|
442
|
-
exited: Promise.resolve(0),
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
await getVpnWgStatus('1.2.3.4', capturingSpawn)
|
|
447
|
-
|
|
448
|
-
expect(capturedKeyPath).toBeTruthy()
|
|
449
|
-
const keyPath = capturedKeyPath
|
|
450
|
-
if (keyPath) {
|
|
451
|
-
expect(() => statSync(keyPath)).toThrow()
|
|
452
|
-
}
|
|
453
|
-
})
|
|
454
|
-
})
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import type {StatusSummary} from '../status'
|
|
2
|
-
|
|
3
|
-
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
4
|
-
import {redactHost} from '../../lib/redact'
|
|
5
|
-
import {buildIdentityArgs} from '../../lib/ssh-identity'
|
|
6
|
-
import {validateVpnHost} from './host'
|
|
7
|
-
|
|
8
|
-
declare const process: {
|
|
9
|
-
env: Record<string, string | undefined>
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
export interface WgShowResult {
|
|
15
|
-
interfaceUp: boolean
|
|
16
|
-
serverPublicKey?: string
|
|
17
|
-
peerCount: number
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type SpawnFn = (
|
|
21
|
-
cmd: string[],
|
|
22
|
-
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
23
|
-
) => {
|
|
24
|
-
stdout: ReadableStream<Uint8Array>
|
|
25
|
-
stderr: ReadableStream<Uint8Array>
|
|
26
|
-
exited: Promise<number>
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Parse `wg show wg0` output into a structured result.
|
|
33
|
-
*
|
|
34
|
-
* Pure function — no IO.
|
|
35
|
-
*/
|
|
36
|
-
export function parseWgShowOutput(output: string): WgShowResult {
|
|
37
|
-
const trimmed = output.trim()
|
|
38
|
-
|
|
39
|
-
if (!trimmed || !trimmed.includes('interface:')) {
|
|
40
|
-
return {interfaceUp: false, peerCount: 0}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Extract server public key from " public key: <key>" line
|
|
44
|
-
// Use a possessive-style pattern to avoid backtracking: match leading spaces,
|
|
45
|
-
// then the literal label, then capture the key (non-space chars).
|
|
46
|
-
const pubKeyMatch = /^ +public key: +(\S+)/m.exec(trimmed)
|
|
47
|
-
const serverPublicKey = pubKeyMatch?.[1]?.trim()
|
|
48
|
-
|
|
49
|
-
// Count peer blocks
|
|
50
|
-
const peerCount = (trimmed.match(/^peer:/gm) ?? []).length
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
interfaceUp: true,
|
|
54
|
-
serverPublicKey,
|
|
55
|
-
peerCount,
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ─── SSH-backed status fetch ──────────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
function defaultSpawn(
|
|
62
|
-
cmd: string[],
|
|
63
|
-
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
64
|
-
): ReturnType<SpawnFn> {
|
|
65
|
-
return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface VpnStatusResult {
|
|
69
|
-
ok: boolean
|
|
70
|
-
wgShow?: WgShowResult
|
|
71
|
-
error?: string
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Fetch VPN status via SSH + `wg show wg0`.
|
|
76
|
-
*
|
|
77
|
-
* SSH-only — never calls the Lightsail/AWS API.
|
|
78
|
-
* Validates host before constructing any SSH argv.
|
|
79
|
-
*/
|
|
80
|
-
export async function getVpnWgStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<VpnStatusResult> {
|
|
81
|
-
validateVpnHost(host)
|
|
82
|
-
|
|
83
|
-
const knownHostsArgs = buildKnownHostsArgs()
|
|
84
|
-
const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.VPN_SSH_KEY)
|
|
85
|
-
|
|
86
|
-
const sshCmd = [
|
|
87
|
-
'ssh',
|
|
88
|
-
'-o',
|
|
89
|
-
'BatchMode=yes',
|
|
90
|
-
'-o',
|
|
91
|
-
'StrictHostKeyChecking=yes',
|
|
92
|
-
...knownHostsArgs,
|
|
93
|
-
...identityArgs,
|
|
94
|
-
`ubuntu@${host}`,
|
|
95
|
-
'sudo wg show wg0',
|
|
96
|
-
]
|
|
97
|
-
|
|
98
|
-
const env: Record<string, string> = {
|
|
99
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
100
|
-
HOME: process.env.HOME ?? '/tmp',
|
|
101
|
-
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let stdoutText: string
|
|
105
|
-
let stderrText: string
|
|
106
|
-
let exitCode: number
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
110
|
-
;[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
111
|
-
new Response(child.stdout).text(),
|
|
112
|
-
new Response(child.stderr).text(),
|
|
113
|
-
child.exited,
|
|
114
|
-
])
|
|
115
|
-
} finally {
|
|
116
|
-
cleanup()
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (exitCode !== 0) {
|
|
120
|
-
const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
|
|
121
|
-
return {
|
|
122
|
-
ok: false,
|
|
123
|
-
error: `SSH command failed (exit ${exitCode}): ${redacted}`,
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const wgShow = parseWgShowOutput(stdoutText)
|
|
128
|
-
|
|
129
|
-
return {ok: wgShow.interfaceUp, wgShow}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ─── Unified status aggregator export ────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Get a structured VPN status summary for the unified status dashboard.
|
|
136
|
-
*
|
|
137
|
-
* SSH + `wg show` only — never calls the Lightsail/AWS API.
|
|
138
|
-
* The MCP runtime needs no AWS credentials to call this function.
|
|
139
|
-
*
|
|
140
|
-
* @param host - The VPN box hostname or IP (validated before SSH).
|
|
141
|
-
* @param spawn - Injectable spawn function (defaults to Bun.spawn).
|
|
142
|
-
*/
|
|
143
|
-
export async function getVpnStatusSummary(host: string, spawn?: SpawnFn): Promise<StatusSummary> {
|
|
144
|
-
const result = await getVpnWgStatus(host, spawn)
|
|
145
|
-
|
|
146
|
-
if (!result.ok || !result.wgShow) {
|
|
147
|
-
const errorMsg = result.error ?? 'WireGuard interface not up'
|
|
148
|
-
return {
|
|
149
|
-
app: 'vpn',
|
|
150
|
-
http: `ERROR: ${errorMsg}`,
|
|
151
|
-
lastDeploy: '—',
|
|
152
|
-
version: '—',
|
|
153
|
-
contentHash: '—',
|
|
154
|
-
usageStats: '—',
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const {wgShow} = result
|
|
159
|
-
const pubKeyInfo = wgShow.serverPublicKey ? ` pubkey:${wgShow.serverPublicKey}` : ''
|
|
160
|
-
const peerInfo = `${wgShow.peerCount} peer(s)`
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
app: 'vpn',
|
|
164
|
-
http: `OK: wg0 up,${pubKeyInfo} ${peerInfo}`,
|
|
165
|
-
lastDeploy: '—',
|
|
166
|
-
version: '—',
|
|
167
|
-
contentHash: '—',
|
|
168
|
-
usageStats: '—',
|
|
169
|
-
}
|
|
170
|
-
}
|