@marcusrbrown/infra 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +11965 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,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
- }