@marcusrbrown/infra 0.9.17 → 0.10.0
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 -1
- package/src/__snapshots__/cli.test.ts.snap +34 -1
- package/src/cli.ts +2 -0
- package/src/commands/mcp.test.ts +17 -2
- package/src/commands/mcp.ts +6 -0
- package/src/commands/status.test.ts +46 -0
- package/src/commands/status.ts +12 -3
- package/src/commands/vpn/client.test.ts +379 -0
- package/src/commands/vpn/client.ts +313 -0
- package/src/commands/vpn/deploy.test.ts +208 -0
- package/src/commands/vpn/deploy.ts +143 -0
- package/src/commands/vpn/host.test.ts +89 -0
- package/src/commands/vpn/host.ts +36 -0
- package/src/commands/vpn/index.ts +15 -0
- package/src/commands/vpn/logs.test.ts +234 -0
- package/src/commands/vpn/logs.ts +136 -0
- package/src/commands/vpn/shared.test.ts +334 -0
- package/src/commands/vpn/shared.ts +158 -0
- package/src/commands/vpn/status.test.ts +142 -0
- package/src/commands/vpn/status.ts +78 -0
- package/src/conventions.test.ts +12 -1
- package/src/resources/known_hosts +18 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import {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
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {StatusSummary} from '../status'
|
|
2
|
+
|
|
3
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
4
|
+
import {redactHost} from '../../lib/redact'
|
|
5
|
+
import {validateVpnHost} from './host'
|
|
6
|
+
|
|
7
|
+
declare const process: {
|
|
8
|
+
env: Record<string, string | undefined>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface WgShowResult {
|
|
14
|
+
interfaceUp: boolean
|
|
15
|
+
serverPublicKey?: string
|
|
16
|
+
peerCount: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SpawnFn = (
|
|
20
|
+
cmd: string[],
|
|
21
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
22
|
+
) => {
|
|
23
|
+
stdout: ReadableStream<Uint8Array>
|
|
24
|
+
stderr: ReadableStream<Uint8Array>
|
|
25
|
+
exited: Promise<number>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse `wg show wg0` output into a structured result.
|
|
32
|
+
*
|
|
33
|
+
* Pure function — no IO.
|
|
34
|
+
*/
|
|
35
|
+
export function parseWgShowOutput(output: string): WgShowResult {
|
|
36
|
+
const trimmed = output.trim()
|
|
37
|
+
|
|
38
|
+
if (!trimmed || !trimmed.includes('interface:')) {
|
|
39
|
+
return {interfaceUp: false, peerCount: 0}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract server public key from " public key: <key>" line
|
|
43
|
+
// Use a possessive-style pattern to avoid backtracking: match leading spaces,
|
|
44
|
+
// then the literal label, then capture the key (non-space chars).
|
|
45
|
+
const pubKeyMatch = /^ +public key: +(\S+)/m.exec(trimmed)
|
|
46
|
+
const serverPublicKey = pubKeyMatch?.[1]?.trim()
|
|
47
|
+
|
|
48
|
+
// Count peer blocks
|
|
49
|
+
const peerCount = (trimmed.match(/^peer:/gm) ?? []).length
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
interfaceUp: true,
|
|
53
|
+
serverPublicKey,
|
|
54
|
+
peerCount,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── SSH-backed status fetch ──────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function defaultSpawn(
|
|
61
|
+
cmd: string[],
|
|
62
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
63
|
+
): ReturnType<SpawnFn> {
|
|
64
|
+
return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface VpnStatusResult {
|
|
68
|
+
ok: boolean
|
|
69
|
+
wgShow?: WgShowResult
|
|
70
|
+
error?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fetch VPN status via SSH + `wg show wg0`.
|
|
75
|
+
*
|
|
76
|
+
* SSH-only — never calls the Lightsail/AWS API.
|
|
77
|
+
* Validates host before constructing any SSH argv.
|
|
78
|
+
*/
|
|
79
|
+
export async function getVpnWgStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<VpnStatusResult> {
|
|
80
|
+
validateVpnHost(host)
|
|
81
|
+
|
|
82
|
+
const sshCmd = [
|
|
83
|
+
'ssh',
|
|
84
|
+
'-o',
|
|
85
|
+
'BatchMode=yes',
|
|
86
|
+
'-o',
|
|
87
|
+
'StrictHostKeyChecking=yes',
|
|
88
|
+
...buildKnownHostsArgs(),
|
|
89
|
+
`ubuntu@${host}`,
|
|
90
|
+
'sudo wg show wg0',
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
const env: Record<string, string> = {
|
|
94
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
95
|
+
HOME: process.env.HOME ?? '/tmp',
|
|
96
|
+
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
100
|
+
|
|
101
|
+
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
|
102
|
+
new Response(child.stdout).text(),
|
|
103
|
+
new Response(child.stderr).text(),
|
|
104
|
+
child.exited,
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
if (exitCode !== 0) {
|
|
108
|
+
const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: `SSH command failed (exit ${exitCode}): ${redacted}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const wgShow = parseWgShowOutput(stdoutText)
|
|
116
|
+
|
|
117
|
+
return {ok: wgShow.interfaceUp, wgShow}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Unified status aggregator export ────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get a structured VPN status summary for the unified status dashboard.
|
|
124
|
+
*
|
|
125
|
+
* SSH + `wg show` only — never calls the Lightsail/AWS API.
|
|
126
|
+
* The MCP runtime needs no AWS credentials to call this function.
|
|
127
|
+
*
|
|
128
|
+
* @param host - The VPN box hostname or IP (validated before SSH).
|
|
129
|
+
* @param spawn - Injectable spawn function (defaults to Bun.spawn).
|
|
130
|
+
*/
|
|
131
|
+
export async function getVpnStatusSummary(host: string, spawn?: SpawnFn): Promise<StatusSummary> {
|
|
132
|
+
const result = await getVpnWgStatus(host, spawn)
|
|
133
|
+
|
|
134
|
+
if (!result.ok || !result.wgShow) {
|
|
135
|
+
const errorMsg = result.error ?? 'WireGuard interface not up'
|
|
136
|
+
return {
|
|
137
|
+
app: 'vpn',
|
|
138
|
+
http: `ERROR: ${errorMsg}`,
|
|
139
|
+
lastDeploy: '—',
|
|
140
|
+
version: '—',
|
|
141
|
+
contentHash: '—',
|
|
142
|
+
usageStats: '—',
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const {wgShow} = result
|
|
147
|
+
const pubKeyInfo = wgShow.serverPublicKey ? ` pubkey:${wgShow.serverPublicKey}` : ''
|
|
148
|
+
const peerInfo = `${wgShow.peerCount} peer(s)`
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
app: 'vpn',
|
|
152
|
+
http: `OK: wg0 up,${pubKeyInfo} ${peerInfo}`,
|
|
153
|
+
lastDeploy: '—',
|
|
154
|
+
version: '—',
|
|
155
|
+
contentHash: '—',
|
|
156
|
+
usageStats: '—',
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
4
|
+
import {vpnStatusAction} from './status'
|
|
5
|
+
|
|
6
|
+
// ─── SpawnFn helpers ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
type SpawnFn = (
|
|
9
|
+
cmd: string[],
|
|
10
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
11
|
+
) => {
|
|
12
|
+
stdout: ReadableStream<Uint8Array>
|
|
13
|
+
stderr: ReadableStream<Uint8Array>
|
|
14
|
+
exited: Promise<number>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeSpawnOk(output: string): SpawnFn {
|
|
18
|
+
return (_cmd, _opts) => {
|
|
19
|
+
const encoder = new TextEncoder()
|
|
20
|
+
return {
|
|
21
|
+
stdout: new ReadableStream({
|
|
22
|
+
start(controller) {
|
|
23
|
+
controller.enqueue(encoder.encode(output))
|
|
24
|
+
controller.close()
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
stderr: new ReadableStream({
|
|
28
|
+
start(controller) {
|
|
29
|
+
controller.close()
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
exited: Promise.resolve(0),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeSpawnError(message: string): SpawnFn {
|
|
38
|
+
return (_cmd, _opts) => {
|
|
39
|
+
const encoder = new TextEncoder()
|
|
40
|
+
return {
|
|
41
|
+
stdout: new ReadableStream({
|
|
42
|
+
start(controller) {
|
|
43
|
+
controller.close()
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
stderr: new ReadableStream({
|
|
47
|
+
start(controller) {
|
|
48
|
+
controller.enqueue(encoder.encode(message))
|
|
49
|
+
controller.close()
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
exited: Promise.resolve(1),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── vpnStatusAction ctx capture ─────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('vpnStatusAction — ctx capture', () => {
|
|
60
|
+
let originalEnv: Record<string, string | undefined>
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
originalEnv = {VPN_HOST: process.env.VPN_HOST}
|
|
64
|
+
process.env.VPN_HOST = '1.2.3.4'
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (originalEnv.VPN_HOST === undefined) {
|
|
69
|
+
delete process.env.VPN_HOST
|
|
70
|
+
} else {
|
|
71
|
+
process.env.VPN_HOST = originalEnv.VPN_HOST
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('routes "Status: OK" to ctx.console.log when wg0 is up', async () => {
|
|
76
|
+
const {ctx, captured} = createCapturedCtx()
|
|
77
|
+
|
|
78
|
+
const wgOutput = [
|
|
79
|
+
'interface: wg0',
|
|
80
|
+
' public key: serverpubkey==',
|
|
81
|
+
' private key: (hidden)',
|
|
82
|
+
' listening port: 51820',
|
|
83
|
+
].join('\n')
|
|
84
|
+
|
|
85
|
+
await vpnStatusAction({key: undefined}, ctx, makeSpawnOk(wgOutput))
|
|
86
|
+
|
|
87
|
+
expect(expectCapturedToInclude(captured, 'Status: OK')).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('routes "VPN status" header to ctx.console.log', async () => {
|
|
91
|
+
const {ctx, captured} = createCapturedCtx()
|
|
92
|
+
|
|
93
|
+
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
94
|
+
|
|
95
|
+
await vpnStatusAction({key: undefined}, ctx, makeSpawnOk(wgOutput))
|
|
96
|
+
|
|
97
|
+
expect(expectCapturedToInclude(captured, 'VPN status')).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('surfaces the server public key in the output', async () => {
|
|
101
|
+
const {ctx, captured} = createCapturedCtx()
|
|
102
|
+
|
|
103
|
+
const wgOutput = 'interface: wg0\n public key: MYPUBLICKEY==\n private key: (hidden)\n listening port: 51820\n'
|
|
104
|
+
|
|
105
|
+
await vpnStatusAction({key: undefined}, ctx, makeSpawnOk(wgOutput))
|
|
106
|
+
|
|
107
|
+
expect(expectCapturedToInclude(captured, 'MYPUBLICKEY==')).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('routes error to ctx.console.error and calls ctx.process.exit(1) when SSH fails', async () => {
|
|
111
|
+
const {ctx, captured} = createCapturedCtx()
|
|
112
|
+
|
|
113
|
+
await expect(vpnStatusAction({key: undefined}, ctx, makeSpawnError('Connection refused'))).rejects.toBeInstanceOf(
|
|
114
|
+
MockProcessExit,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(captured.stderr.join('').includes('Error')).toBe(true)
|
|
118
|
+
expect(captured.exit?.code).toBe(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('routes error to ctx.console.error and calls ctx.process.exit(1) when VPN_HOST is unset', async () => {
|
|
122
|
+
delete process.env.VPN_HOST
|
|
123
|
+
const {ctx, captured} = createCapturedCtx()
|
|
124
|
+
|
|
125
|
+
await expect(vpnStatusAction({key: undefined}, ctx)).rejects.toBeInstanceOf(MockProcessExit)
|
|
126
|
+
|
|
127
|
+
expect(captured.stderr.join('').includes('VPN_HOST')).toBe(true)
|
|
128
|
+
expect(captured.exit?.code).toBe(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('routes "Status: DEGRADED" to ctx.console.log and exits 1 when wg0 is down', async () => {
|
|
132
|
+
const {ctx, captured} = createCapturedCtx()
|
|
133
|
+
|
|
134
|
+
// wg show output without "interface:" line means interface is down
|
|
135
|
+
await expect(
|
|
136
|
+
vpnStatusAction({key: undefined}, ctx, makeSpawnOk('wg: The requested operation is not possible')),
|
|
137
|
+
).rejects.toBeInstanceOf(MockProcessExit)
|
|
138
|
+
|
|
139
|
+
expect(expectCapturedToInclude(captured, 'DEGRADED')).toBe(true)
|
|
140
|
+
expect(captured.exit?.code).toBe(1)
|
|
141
|
+
})
|
|
142
|
+
})
|