@marcusrbrown/infra 0.10.1 → 0.10.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/package.json
CHANGED
|
@@ -162,7 +162,7 @@ Commands:
|
|
|
162
162
|
--key [key] Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.
|
|
163
163
|
|
|
164
164
|
|
|
165
|
-
vpn client add <name> Add a new VPN client peer. Generates a keypair, assigns a tunnel IP,
|
|
165
|
+
vpn client add <name> Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).
|
|
166
166
|
|
|
167
167
|
--split-tunnel [cidrs] Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).
|
|
168
168
|
--dns [dns] DNS server for the client config.
|
|
@@ -173,7 +173,7 @@ Commands:
|
|
|
173
173
|
vpn client list List all VPN client peers from peers.json.
|
|
174
174
|
|
|
175
175
|
|
|
176
|
-
vpn client remove <name> Remove a VPN client peer from peers.json. Run \`infra vpn deploy\` to revoke access.
|
|
176
|
+
vpn client remove <name> Remove a VPN client peer from peers.json and sync the roster to the VPN_PEERS GitHub secret (best-effort). Run \`infra vpn deploy\` to revoke access.
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
status Show status of all deployments
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
-
import {readFile} from 'node:fs/promises'
|
|
2
|
+
import {readFile, stat} from 'node:fs/promises'
|
|
3
3
|
import {tmpdir} from 'node:os'
|
|
4
4
|
import {join, resolve} from 'node:path'
|
|
5
5
|
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
6
6
|
|
|
7
|
-
import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn} from './client'
|
|
7
|
+
import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn, type SpawnFn} from './client'
|
|
8
8
|
|
|
9
9
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -16,6 +16,33 @@ function makePeersJson(peers: {name: string; publicKey: string; tunnelIp: string
|
|
|
16
16
|
return `${JSON.stringify({peers}, null, 2)}\n`
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Inert spawn stub — never touches real processes.
|
|
21
|
+
* Returns a mock proc: stdin {write, end}, exited resolves 0, empty stdout/stderr.
|
|
22
|
+
* Inject this into every call that doesn't explicitly test sync behavior.
|
|
23
|
+
*/
|
|
24
|
+
function makeNoopSpawnFn(): SpawnFn {
|
|
25
|
+
return (_cmd, _opts) => ({
|
|
26
|
+
stdout: new ReadableStream({
|
|
27
|
+
start(c) {
|
|
28
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
29
|
+
c.close()
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
stderr: new ReadableStream({
|
|
33
|
+
start(c) {
|
|
34
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
35
|
+
c.close()
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
stdin: {
|
|
39
|
+
write: (_data: Uint8Array) => {},
|
|
40
|
+
end: () => {},
|
|
41
|
+
},
|
|
42
|
+
exited: Promise.resolve(0),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
// ─── generateKeypair ─────────────────────────────────────────────────────────
|
|
20
47
|
|
|
21
48
|
describe('generateKeypair', () => {
|
|
@@ -59,6 +86,7 @@ describe('generateKeypair', () => {
|
|
|
59
86
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
60
87
|
endpoint: '1.2.3.4',
|
|
61
88
|
keypairGen,
|
|
89
|
+
spawnFn: makeNoopSpawnFn(),
|
|
62
90
|
})
|
|
63
91
|
|
|
64
92
|
// The result must use the injected keypair values
|
|
@@ -109,6 +137,7 @@ describe('vpnClientAdd', () => {
|
|
|
109
137
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
110
138
|
endpoint: '1.2.3.4',
|
|
111
139
|
keypairGen,
|
|
140
|
+
spawnFn: makeNoopSpawnFn(),
|
|
112
141
|
})
|
|
113
142
|
|
|
114
143
|
// Check result
|
|
@@ -142,6 +171,7 @@ describe('vpnClientAdd', () => {
|
|
|
142
171
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
143
172
|
endpoint: '1.2.3.4',
|
|
144
173
|
keypairGen: keypairGen1,
|
|
174
|
+
spawnFn: makeNoopSpawnFn(),
|
|
145
175
|
})
|
|
146
176
|
|
|
147
177
|
const result2 = await vpnClientAdd('phone', {
|
|
@@ -150,6 +180,7 @@ describe('vpnClientAdd', () => {
|
|
|
150
180
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
151
181
|
endpoint: '1.2.3.4',
|
|
152
182
|
keypairGen: keypairGen2,
|
|
183
|
+
spawnFn: makeNoopSpawnFn(),
|
|
153
184
|
})
|
|
154
185
|
|
|
155
186
|
expect(result2.tunnelIp).toBe('10.8.0.3')
|
|
@@ -165,6 +196,7 @@ describe('vpnClientAdd', () => {
|
|
|
165
196
|
endpoint: '1.2.3.4',
|
|
166
197
|
keypairGen,
|
|
167
198
|
allowedIps: '10.0.0.0/8,192.168.0.0/16',
|
|
199
|
+
spawnFn: makeNoopSpawnFn(),
|
|
168
200
|
})
|
|
169
201
|
|
|
170
202
|
const confContent = await readFile(result.confPath, 'utf-8')
|
|
@@ -180,6 +212,7 @@ describe('vpnClientAdd', () => {
|
|
|
180
212
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
181
213
|
endpoint: '1.2.3.4',
|
|
182
214
|
keypairGen,
|
|
215
|
+
spawnFn: makeNoopSpawnFn(),
|
|
183
216
|
})
|
|
184
217
|
|
|
185
218
|
await expect(
|
|
@@ -189,6 +222,7 @@ describe('vpnClientAdd', () => {
|
|
|
189
222
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
190
223
|
endpoint: '1.2.3.4',
|
|
191
224
|
keypairGen,
|
|
225
|
+
spawnFn: makeNoopSpawnFn(),
|
|
192
226
|
}),
|
|
193
227
|
).rejects.toThrow('already exists')
|
|
194
228
|
})
|
|
@@ -204,6 +238,7 @@ describe('vpnClientAdd', () => {
|
|
|
204
238
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
205
239
|
endpoint: '1.2.3.4',
|
|
206
240
|
keypairGen,
|
|
241
|
+
spawnFn: makeNoopSpawnFn(),
|
|
207
242
|
}),
|
|
208
243
|
).rejects.toThrow()
|
|
209
244
|
})
|
|
@@ -217,6 +252,7 @@ describe('vpnClientAdd', () => {
|
|
|
217
252
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
218
253
|
endpoint: '1.2.3.4',
|
|
219
254
|
keypairGen,
|
|
255
|
+
spawnFn: makeNoopSpawnFn(),
|
|
220
256
|
})
|
|
221
257
|
|
|
222
258
|
// peers.json must NOT contain the private key
|
|
@@ -228,6 +264,76 @@ describe('vpnClientAdd', () => {
|
|
|
228
264
|
const confContent = await readFile(result.confPath, 'utf-8')
|
|
229
265
|
expect(confContent).toContain('SECRETPRIVATEKEY==')
|
|
230
266
|
})
|
|
267
|
+
|
|
268
|
+
// Fix #2: ENOENT bootstrap on fresh checkout
|
|
269
|
+
it('succeeds on fresh checkout (no peers.json) — creates first peer', async () => {
|
|
270
|
+
// Do NOT write peers.json — simulate fresh checkout
|
|
271
|
+
const freshDir = mkdtempSync(join(tmpdir(), 'vpn-fresh-test-'))
|
|
272
|
+
const freshPeersJsonPath = join(freshDir, 'peers.json')
|
|
273
|
+
const freshClientsDir = join(freshDir, 'clients')
|
|
274
|
+
mkdirSync(freshClientsDir, {recursive: true})
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
278
|
+
const result = await vpnClientAdd('firstpeer', {
|
|
279
|
+
peersJsonPath: freshPeersJsonPath,
|
|
280
|
+
clientsDir: freshClientsDir,
|
|
281
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
282
|
+
endpoint: '1.2.3.4',
|
|
283
|
+
keypairGen,
|
|
284
|
+
spawnFn: makeNoopSpawnFn(),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
288
|
+
|
|
289
|
+
// peers.json must now exist with the new peer
|
|
290
|
+
const peersContent = await readFile(freshPeersJsonPath, 'utf-8')
|
|
291
|
+
const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
|
|
292
|
+
expect(peersData.peers).toHaveLength(1)
|
|
293
|
+
expect(peersData.peers[0]?.name).toBe('firstpeer')
|
|
294
|
+
|
|
295
|
+
// client .conf must exist with private key
|
|
296
|
+
const confContent = await readFile(result.confPath, 'utf-8')
|
|
297
|
+
expect(confContent).toContain('PRIV==')
|
|
298
|
+
} finally {
|
|
299
|
+
rmSync(freshDir, {recursive: true, force: true})
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// Fix #4: write client .conf BEFORE gh sync
|
|
304
|
+
it('client .conf exists even when spawnFn throws (conf written before sync)', async () => {
|
|
305
|
+
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
306
|
+
const throwingSpawnFn: SpawnFn = () => {
|
|
307
|
+
throw new Error('gh not found')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const warnMessages: string[] = []
|
|
311
|
+
const originalWarn = console.warn
|
|
312
|
+
console.warn = (...args: unknown[]) => {
|
|
313
|
+
warnMessages.push(args.join(' '))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const result = await vpnClientAdd('laptop', {
|
|
318
|
+
peersJsonPath,
|
|
319
|
+
clientsDir,
|
|
320
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
321
|
+
endpoint: '1.2.3.4',
|
|
322
|
+
keypairGen,
|
|
323
|
+
spawnFn: throwingSpawnFn,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// .conf must exist with the private key
|
|
327
|
+
const confContent = await readFile(result.confPath, 'utf-8')
|
|
328
|
+
expect(confContent).toContain('PRIV==')
|
|
329
|
+
|
|
330
|
+
// A warning must have been printed (not a throw)
|
|
331
|
+
const allWarnings = warnMessages.join('\n')
|
|
332
|
+
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
333
|
+
} finally {
|
|
334
|
+
console.warn = originalWarn
|
|
335
|
+
}
|
|
336
|
+
})
|
|
231
337
|
})
|
|
232
338
|
|
|
233
339
|
// ─── vpnClientList ────────────────────────────────────────────────────────────
|
|
@@ -270,6 +376,13 @@ describe('vpnClientList', () => {
|
|
|
270
376
|
expect(peers[0]?.tunnelIp).toBe('10.8.0.2')
|
|
271
377
|
expect(peers[1]?.name).toBe('phone')
|
|
272
378
|
})
|
|
379
|
+
|
|
380
|
+
// Fix #2: list on fresh checkout (no peers.json) returns empty
|
|
381
|
+
it('returns empty array when peers.json does not exist (fresh checkout)', async () => {
|
|
382
|
+
// Do NOT write peers.json
|
|
383
|
+
const peers = await vpnClientList(peersJsonPath)
|
|
384
|
+
expect(peers).toHaveLength(0)
|
|
385
|
+
})
|
|
273
386
|
})
|
|
274
387
|
|
|
275
388
|
// ─── vpnClientRemove ──────────────────────────────────────────────────────────
|
|
@@ -296,7 +409,7 @@ describe('vpnClientRemove', () => {
|
|
|
296
409
|
]),
|
|
297
410
|
)
|
|
298
411
|
|
|
299
|
-
await vpnClientRemove('laptop', peersJsonPath)
|
|
412
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
|
|
300
413
|
|
|
301
414
|
const peersContent = await readFile(peersJsonPath, 'utf-8')
|
|
302
415
|
const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
|
|
@@ -307,7 +420,9 @@ describe('vpnClientRemove', () => {
|
|
|
307
420
|
it('throws when removing a non-existent peer', async () => {
|
|
308
421
|
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
309
422
|
|
|
310
|
-
await expect(vpnClientRemove('nonexistent', peersJsonPath)).rejects.toThrow(
|
|
423
|
+
await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
|
|
424
|
+
'not found',
|
|
425
|
+
)
|
|
311
426
|
})
|
|
312
427
|
|
|
313
428
|
it('after remove, list reflects the removal', async () => {
|
|
@@ -319,12 +434,428 @@ describe('vpnClientRemove', () => {
|
|
|
319
434
|
]),
|
|
320
435
|
)
|
|
321
436
|
|
|
322
|
-
await vpnClientRemove('laptop', peersJsonPath)
|
|
437
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: makeNoopSpawnFn()})
|
|
323
438
|
const peers = await vpnClientList(peersJsonPath)
|
|
324
439
|
|
|
325
440
|
expect(peers).toHaveLength(1)
|
|
326
441
|
expect(peers[0]?.name).toBe('phone')
|
|
327
442
|
})
|
|
443
|
+
|
|
444
|
+
// Fix #2: remove of nonexistent peer on fresh checkout gives not-found error, not ENOENT
|
|
445
|
+
it('throws not-found (not ENOENT) when removing from a nonexistent peers.json', async () => {
|
|
446
|
+
// Do NOT write peers.json — simulate fresh checkout
|
|
447
|
+
await expect(vpnClientRemove('nonexistent', peersJsonPath, {spawnFn: makeNoopSpawnFn()})).rejects.toThrow(
|
|
448
|
+
'not found',
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
// ─── gh secret sync after vpnClientAdd ───────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
describe('vpnClientAdd: gh secret sync', () => {
|
|
456
|
+
let tmpDir: string
|
|
457
|
+
let peersJsonPath: string
|
|
458
|
+
let clientsDir: string
|
|
459
|
+
|
|
460
|
+
beforeEach(() => {
|
|
461
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-sync-test-'))
|
|
462
|
+
peersJsonPath = join(tmpDir, 'peers.json')
|
|
463
|
+
clientsDir = join(tmpDir, 'clients')
|
|
464
|
+
mkdirSync(clientsDir, {recursive: true})
|
|
465
|
+
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
afterEach(() => {
|
|
469
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('spawns gh secret set with roster on STDIN after successful add', async () => {
|
|
473
|
+
const keypairGen = makeKeypairGen('PRIV==', 'NEWPUBKEY==')
|
|
474
|
+
const spawnCalls: {cmd: string[]; stdin: string}[] = []
|
|
475
|
+
|
|
476
|
+
const mockSpawnFn: SpawnFn = (cmd, _opts) => {
|
|
477
|
+
let stdinContent = ''
|
|
478
|
+
const stdinPipe = {
|
|
479
|
+
write: (data: Uint8Array) => {
|
|
480
|
+
stdinContent += new TextDecoder().decode(data)
|
|
481
|
+
},
|
|
482
|
+
end: () => {
|
|
483
|
+
spawnCalls.push({cmd, stdin: stdinContent})
|
|
484
|
+
},
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
stdout: new ReadableStream({
|
|
488
|
+
start(c) {
|
|
489
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
490
|
+
c.close()
|
|
491
|
+
},
|
|
492
|
+
}),
|
|
493
|
+
stderr: new ReadableStream({
|
|
494
|
+
start(c) {
|
|
495
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
496
|
+
c.close()
|
|
497
|
+
},
|
|
498
|
+
}),
|
|
499
|
+
stdin: stdinPipe,
|
|
500
|
+
exited: Promise.resolve(0),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
await vpnClientAdd('newpeer', {
|
|
505
|
+
peersJsonPath,
|
|
506
|
+
clientsDir,
|
|
507
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
508
|
+
endpoint: '1.2.3.4',
|
|
509
|
+
keypairGen,
|
|
510
|
+
spawnFn: mockSpawnFn,
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
// Must have spawned gh secret set
|
|
514
|
+
const ghCall = spawnCalls.find(c => c.cmd.includes('gh') && c.cmd.includes('secret') && c.cmd.includes('set'))
|
|
515
|
+
expect(ghCall).toBeDefined()
|
|
516
|
+
|
|
517
|
+
// The new peer's public key must appear in STDIN, not in argv
|
|
518
|
+
expect(ghCall?.stdin).toContain('NEWPUBKEY==')
|
|
519
|
+
const argvContainsPubkey = ghCall?.cmd.some(arg => arg.includes('NEWPUBKEY=='))
|
|
520
|
+
expect(argvContainsPubkey).toBe(false)
|
|
521
|
+
|
|
522
|
+
// Must pass --env vpn and --repo marcusrbrown/infra
|
|
523
|
+
expect(ghCall?.cmd).toContain('--env')
|
|
524
|
+
expect(ghCall?.cmd).toContain('vpn')
|
|
525
|
+
expect(ghCall?.cmd).toContain('--repo')
|
|
526
|
+
expect(ghCall?.cmd).toContain('marcusrbrown/infra')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('gh failure → command still succeeds, warning printed with remediation', async () => {
|
|
530
|
+
const keypairGen = makeKeypairGen('PRIV==', 'NEWPUBKEY==')
|
|
531
|
+
const warnMessages: string[] = []
|
|
532
|
+
const originalWarn = console.warn
|
|
533
|
+
console.warn = (...args: unknown[]) => {
|
|
534
|
+
warnMessages.push(args.join(' '))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const mockSpawnFn: SpawnFn = (_cmd, _opts) => {
|
|
538
|
+
return {
|
|
539
|
+
stdout: new ReadableStream({
|
|
540
|
+
start(c) {
|
|
541
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
542
|
+
c.close()
|
|
543
|
+
},
|
|
544
|
+
}),
|
|
545
|
+
stderr: new ReadableStream({
|
|
546
|
+
start(c) {
|
|
547
|
+
c.enqueue(new TextEncoder().encode('auth error'))
|
|
548
|
+
c.close()
|
|
549
|
+
},
|
|
550
|
+
}),
|
|
551
|
+
stdin: {
|
|
552
|
+
write: (_data: Uint8Array) => {},
|
|
553
|
+
end: () => {},
|
|
554
|
+
},
|
|
555
|
+
exited: Promise.resolve(1), // gh fails
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
// Must NOT throw even though gh fails
|
|
561
|
+
const result = await vpnClientAdd('newpeer', {
|
|
562
|
+
peersJsonPath,
|
|
563
|
+
clientsDir,
|
|
564
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
565
|
+
endpoint: '1.2.3.4',
|
|
566
|
+
keypairGen,
|
|
567
|
+
spawnFn: mockSpawnFn,
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
// The add itself must succeed
|
|
571
|
+
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
572
|
+
|
|
573
|
+
// A warning must have been printed with remediation
|
|
574
|
+
const allWarnings = warnMessages.join('\n')
|
|
575
|
+
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
576
|
+
expect(allWarnings).toMatch(/gh secret set/)
|
|
577
|
+
} finally {
|
|
578
|
+
console.warn = originalWarn
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// Fix #3: spawnFn throws synchronously → command still succeeds + warning printed
|
|
583
|
+
it('spawnFn throws synchronously → command still succeeds, warning printed with remediation', async () => {
|
|
584
|
+
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
585
|
+
const warnMessages: string[] = []
|
|
586
|
+
const originalWarn = console.warn
|
|
587
|
+
console.warn = (...args: unknown[]) => {
|
|
588
|
+
warnMessages.push(args.join(' '))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const throwingSpawnFn: SpawnFn = () => {
|
|
592
|
+
throw new Error('ENOENT: gh not found')
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const result = await vpnClientAdd('newpeer', {
|
|
597
|
+
peersJsonPath,
|
|
598
|
+
clientsDir,
|
|
599
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
600
|
+
endpoint: '1.2.3.4',
|
|
601
|
+
keypairGen,
|
|
602
|
+
spawnFn: throwingSpawnFn,
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Command must succeed
|
|
606
|
+
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
607
|
+
|
|
608
|
+
// Warning must be printed with remediation
|
|
609
|
+
const allWarnings = warnMessages.join('\n')
|
|
610
|
+
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
611
|
+
expect(allWarnings).toMatch(/gh secret set/)
|
|
612
|
+
} finally {
|
|
613
|
+
console.warn = originalWarn
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// Fix #3: timeout — spawnFn whose exited never resolves → warning after timeout
|
|
618
|
+
it('spawnFn whose exited never resolves → warning after timeout (injectable timeoutMs)', async () => {
|
|
619
|
+
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
620
|
+
const warnMessages: string[] = []
|
|
621
|
+
const originalWarn = console.warn
|
|
622
|
+
console.warn = (...args: unknown[]) => {
|
|
623
|
+
warnMessages.push(args.join(' '))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const hangingSpawnFn: SpawnFn = (_cmd, _opts) => ({
|
|
627
|
+
stdout: new ReadableStream({
|
|
628
|
+
start(c) {
|
|
629
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
630
|
+
c.close()
|
|
631
|
+
},
|
|
632
|
+
}),
|
|
633
|
+
stderr: new ReadableStream({
|
|
634
|
+
start(c) {
|
|
635
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
636
|
+
c.close()
|
|
637
|
+
},
|
|
638
|
+
}),
|
|
639
|
+
stdin: {
|
|
640
|
+
write: (_data: Uint8Array) => {},
|
|
641
|
+
end: () => {},
|
|
642
|
+
},
|
|
643
|
+
// Never resolves
|
|
644
|
+
exited: new Promise<number>(() => {}),
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
// Use a very short timeout (50ms) so the test doesn't hang
|
|
649
|
+
const result = await vpnClientAdd('newpeer', {
|
|
650
|
+
peersJsonPath,
|
|
651
|
+
clientsDir,
|
|
652
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
653
|
+
endpoint: '1.2.3.4',
|
|
654
|
+
keypairGen,
|
|
655
|
+
spawnFn: hangingSpawnFn,
|
|
656
|
+
syncTimeoutMs: 50,
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// Command must succeed
|
|
660
|
+
expect(result.tunnelIp).toBe('10.8.0.2')
|
|
661
|
+
|
|
662
|
+
// Warning must be printed
|
|
663
|
+
const allWarnings = warnMessages.join('\n')
|
|
664
|
+
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning|timeout/i)
|
|
665
|
+
} finally {
|
|
666
|
+
console.warn = originalWarn
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// Fix #5: remediation message must include --repo marcusrbrown/infra
|
|
671
|
+
it('remediation message includes --repo marcusrbrown/infra', async () => {
|
|
672
|
+
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
673
|
+
const warnMessages: string[] = []
|
|
674
|
+
const originalWarn = console.warn
|
|
675
|
+
console.warn = (...args: unknown[]) => {
|
|
676
|
+
warnMessages.push(args.join(' '))
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const failingSpawnFn: SpawnFn = (_cmd, _opts) => ({
|
|
680
|
+
stdout: new ReadableStream({
|
|
681
|
+
start(c) {
|
|
682
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
683
|
+
c.close()
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
stderr: new ReadableStream({
|
|
687
|
+
start(c) {
|
|
688
|
+
c.enqueue(new TextEncoder().encode('error'))
|
|
689
|
+
c.close()
|
|
690
|
+
},
|
|
691
|
+
}),
|
|
692
|
+
stdin: {write: (_data: Uint8Array) => {}, end: () => {}},
|
|
693
|
+
exited: Promise.resolve(1),
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
await vpnClientAdd('newpeer', {
|
|
698
|
+
peersJsonPath,
|
|
699
|
+
clientsDir,
|
|
700
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
701
|
+
endpoint: '1.2.3.4',
|
|
702
|
+
keypairGen,
|
|
703
|
+
spawnFn: failingSpawnFn,
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
const allWarnings = warnMessages.join('\n')
|
|
707
|
+
expect(allWarnings).toContain('--repo marcusrbrown/infra')
|
|
708
|
+
} finally {
|
|
709
|
+
console.warn = originalWarn
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
// ─── gh secret sync after vpnClientRemove ────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
describe('vpnClientRemove: gh secret sync', () => {
|
|
717
|
+
let tmpDir: string
|
|
718
|
+
let peersJsonPath: string
|
|
719
|
+
|
|
720
|
+
beforeEach(() => {
|
|
721
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-remove-sync-test-'))
|
|
722
|
+
peersJsonPath = join(tmpDir, 'peers.json')
|
|
723
|
+
writeFileSync(
|
|
724
|
+
peersJsonPath,
|
|
725
|
+
makePeersJson([
|
|
726
|
+
{name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
|
|
727
|
+
{name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
|
|
728
|
+
]),
|
|
729
|
+
)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
afterEach(() => {
|
|
733
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('spawns gh secret set with updated roster on STDIN after successful remove', async () => {
|
|
737
|
+
const spawnCalls: {cmd: string[]; stdin: string}[] = []
|
|
738
|
+
|
|
739
|
+
const mockSpawnFn: SpawnFn = (cmd, _opts) => {
|
|
740
|
+
let stdinContent = ''
|
|
741
|
+
const stdinPipe = {
|
|
742
|
+
write: (data: Uint8Array) => {
|
|
743
|
+
stdinContent += new TextDecoder().decode(data)
|
|
744
|
+
},
|
|
745
|
+
end: () => {
|
|
746
|
+
spawnCalls.push({cmd, stdin: stdinContent})
|
|
747
|
+
},
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
stdout: new ReadableStream({
|
|
751
|
+
start(c) {
|
|
752
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
753
|
+
c.close()
|
|
754
|
+
},
|
|
755
|
+
}),
|
|
756
|
+
stderr: new ReadableStream({
|
|
757
|
+
start(c) {
|
|
758
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
759
|
+
c.close()
|
|
760
|
+
},
|
|
761
|
+
}),
|
|
762
|
+
stdin: stdinPipe,
|
|
763
|
+
exited: Promise.resolve(0),
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: mockSpawnFn})
|
|
768
|
+
|
|
769
|
+
// Must have spawned gh secret set
|
|
770
|
+
const ghCall = spawnCalls.find(c => c.cmd.includes('gh') && c.cmd.includes('secret') && c.cmd.includes('set'))
|
|
771
|
+
expect(ghCall).toBeDefined()
|
|
772
|
+
|
|
773
|
+
// Removed peer must NOT appear in STDIN
|
|
774
|
+
expect(ghCall?.stdin).not.toContain('PUB1==')
|
|
775
|
+
// Remaining peer must appear in STDIN
|
|
776
|
+
expect(ghCall?.stdin).toContain('PUB2==')
|
|
777
|
+
|
|
778
|
+
// Roster bytes must not appear in argv
|
|
779
|
+
const argvContainsRoster = ghCall?.cmd.some(arg => arg.includes('PUB1==') || arg.includes('PUB2=='))
|
|
780
|
+
expect(argvContainsRoster).toBe(false)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('gh failure on remove → command still succeeds, warning printed', async () => {
|
|
784
|
+
const warnMessages: string[] = []
|
|
785
|
+
const originalWarn = console.warn
|
|
786
|
+
console.warn = (...args: unknown[]) => {
|
|
787
|
+
warnMessages.push(args.join(' '))
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const mockSpawnFn: SpawnFn = (_cmd, _opts) => {
|
|
791
|
+
return {
|
|
792
|
+
stdout: new ReadableStream({
|
|
793
|
+
start(c) {
|
|
794
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
795
|
+
c.close()
|
|
796
|
+
},
|
|
797
|
+
}),
|
|
798
|
+
stderr: new ReadableStream({
|
|
799
|
+
start(c) {
|
|
800
|
+
c.enqueue(new TextEncoder().encode('network error'))
|
|
801
|
+
c.close()
|
|
802
|
+
},
|
|
803
|
+
}),
|
|
804
|
+
stdin: {
|
|
805
|
+
write: (_data: Uint8Array) => {},
|
|
806
|
+
end: () => {},
|
|
807
|
+
},
|
|
808
|
+
exited: Promise.resolve(1), // gh fails
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
// Must NOT throw even though gh fails
|
|
814
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: mockSpawnFn})
|
|
815
|
+
|
|
816
|
+
// A warning must have been printed with remediation
|
|
817
|
+
const allWarnings = warnMessages.join('\n')
|
|
818
|
+
expect(allWarnings).toMatch(/VPN_PEERS|stale|warning/i)
|
|
819
|
+
expect(allWarnings).toMatch(/gh secret set/)
|
|
820
|
+
} finally {
|
|
821
|
+
console.warn = originalWarn
|
|
822
|
+
}
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
// Fix #5: remediation message must include --repo marcusrbrown/infra
|
|
826
|
+
it('remediation message on remove failure includes --repo marcusrbrown/infra', async () => {
|
|
827
|
+
const warnMessages: string[] = []
|
|
828
|
+
const originalWarn = console.warn
|
|
829
|
+
console.warn = (...args: unknown[]) => {
|
|
830
|
+
warnMessages.push(args.join(' '))
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const failingSpawnFn: SpawnFn = (_cmd, _opts) => ({
|
|
834
|
+
stdout: new ReadableStream({
|
|
835
|
+
start(c) {
|
|
836
|
+
c.enqueue(new TextEncoder().encode(''))
|
|
837
|
+
c.close()
|
|
838
|
+
},
|
|
839
|
+
}),
|
|
840
|
+
stderr: new ReadableStream({
|
|
841
|
+
start(c) {
|
|
842
|
+
c.enqueue(new TextEncoder().encode('error'))
|
|
843
|
+
c.close()
|
|
844
|
+
},
|
|
845
|
+
}),
|
|
846
|
+
stdin: {write: (_data: Uint8Array) => {}, end: () => {}},
|
|
847
|
+
exited: Promise.resolve(1),
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
await vpnClientRemove('laptop', peersJsonPath, {spawnFn: failingSpawnFn})
|
|
852
|
+
|
|
853
|
+
const allWarnings = warnMessages.join('\n')
|
|
854
|
+
expect(allWarnings).toContain('--repo marcusrbrown/infra')
|
|
855
|
+
} finally {
|
|
856
|
+
console.warn = originalWarn
|
|
857
|
+
}
|
|
858
|
+
})
|
|
328
859
|
})
|
|
329
860
|
|
|
330
861
|
// ─── Write-guard: client .conf must stay in clients/ ─────────────────────────
|
|
@@ -355,6 +886,7 @@ describe('write-guard: client .conf path safety', () => {
|
|
|
355
886
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
356
887
|
endpoint: '1.2.3.4',
|
|
357
888
|
keypairGen,
|
|
889
|
+
spawnFn: makeNoopSpawnFn(),
|
|
358
890
|
})
|
|
359
891
|
|
|
360
892
|
// The resolved path must be under clientsDir
|
|
@@ -373,7 +905,45 @@ describe('write-guard: client .conf path safety', () => {
|
|
|
373
905
|
serverPublicKey: 'SERVERPUBKEY==',
|
|
374
906
|
endpoint: '1.2.3.4',
|
|
375
907
|
keypairGen,
|
|
908
|
+
spawnFn: makeNoopSpawnFn(),
|
|
376
909
|
}),
|
|
377
910
|
).rejects.toThrow()
|
|
378
911
|
})
|
|
379
912
|
})
|
|
913
|
+
|
|
914
|
+
// ─── client .conf file permissions ───────────────────────────────────────────
|
|
915
|
+
|
|
916
|
+
describe('vpnClientAdd: client .conf file permissions', () => {
|
|
917
|
+
let tmpDir: string
|
|
918
|
+
let peersJsonPath: string
|
|
919
|
+
let clientsDir: string
|
|
920
|
+
|
|
921
|
+
beforeEach(() => {
|
|
922
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-perm-test-'))
|
|
923
|
+
peersJsonPath = join(tmpDir, 'peers.json')
|
|
924
|
+
clientsDir = join(tmpDir, 'clients')
|
|
925
|
+
mkdirSync(clientsDir, {recursive: true})
|
|
926
|
+
writeFileSync(peersJsonPath, makePeersJson([]))
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
afterEach(() => {
|
|
930
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
it('writes client .conf with mode 0o600 (owner read/write only)', async () => {
|
|
934
|
+
const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
|
|
935
|
+
|
|
936
|
+
const result = await vpnClientAdd('laptop', {
|
|
937
|
+
peersJsonPath,
|
|
938
|
+
clientsDir,
|
|
939
|
+
serverPublicKey: 'SERVERPUBKEY==',
|
|
940
|
+
endpoint: '1.2.3.4',
|
|
941
|
+
keypairGen,
|
|
942
|
+
spawnFn: makeNoopSpawnFn(),
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
const fileStat = await stat(result.confPath)
|
|
946
|
+
const permBits = fileStat.mode & 0o777
|
|
947
|
+
expect(permBits).toBe(0o600)
|
|
948
|
+
})
|
|
949
|
+
})
|
|
@@ -4,7 +4,7 @@ import {mkdir, writeFile} from 'node:fs/promises'
|
|
|
4
4
|
import {resolve} from 'node:path'
|
|
5
5
|
|
|
6
6
|
import {z} from 'zod'
|
|
7
|
-
import {addPeer,
|
|
7
|
+
import {addPeer, readPeersOrEmpty, removePeer, renderClientConfig, writePeers, type Peer} from './peers'
|
|
8
8
|
|
|
9
9
|
declare const process: {
|
|
10
10
|
env: Record<string, string | undefined>
|
|
@@ -20,14 +20,34 @@ export interface Keypair {
|
|
|
20
20
|
|
|
21
21
|
export type KeypairGenFn = () => Promise<Keypair>
|
|
22
22
|
|
|
23
|
+
/** Minimal subset of Bun.Subprocess used for gh secret sync. */
|
|
24
|
+
export interface SyncSpawnResult {
|
|
25
|
+
stdout: ReadableStream<Uint8Array>
|
|
26
|
+
stderr: ReadableStream<Uint8Array>
|
|
27
|
+
stdin?: {write: (data: Uint8Array) => void; end: () => void}
|
|
28
|
+
exited: Promise<number>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncSpawnOpts {
|
|
32
|
+
stdout: 'pipe'
|
|
33
|
+
stderr: 'pipe'
|
|
34
|
+
stdin: 'pipe'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Injectable spawn function for gh secret sync — defaults to Bun.spawn. */
|
|
38
|
+
export type SpawnFn = (cmd: string[], opts: SyncSpawnOpts) => SyncSpawnResult
|
|
39
|
+
|
|
23
40
|
export interface VpnClientAddOpts {
|
|
24
41
|
peersJsonPath: string
|
|
25
42
|
clientsDir: string
|
|
26
43
|
serverPublicKey: string
|
|
27
44
|
endpoint: string
|
|
28
45
|
keypairGen?: KeypairGenFn
|
|
46
|
+
spawnFn?: SpawnFn
|
|
29
47
|
allowedIps?: string
|
|
30
48
|
dns?: string
|
|
49
|
+
/** Timeout in ms for gh secret sync. Defaults to 15000. Injectable for tests. */
|
|
50
|
+
syncTimeoutMs?: number
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
export interface VpnClientAddResult {
|
|
@@ -92,6 +112,112 @@ export async function generateKeypair(): Promise<Keypair> {
|
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
114
|
|
|
115
|
+
// ─── gh secret sync ───────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const GH_SYNC_REPO = 'marcusrbrown/infra'
|
|
118
|
+
const GH_SYNC_ENV = 'vpn'
|
|
119
|
+
const GH_SYNC_SECRET = 'VPN_PEERS'
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The exact remediation command — mirrors the real spawn command exactly.
|
|
123
|
+
* Includes --repo so the operator can run it from any directory.
|
|
124
|
+
*/
|
|
125
|
+
const GH_SYNC_REMEDIATION = `gh secret set ${GH_SYNC_SECRET} --env ${GH_SYNC_ENV} --repo ${GH_SYNC_REPO} < apps/vpn/config/peers.json`
|
|
126
|
+
|
|
127
|
+
function defaultSpawnFn(cmd: string[], opts: SyncSpawnOpts): SyncSpawnResult {
|
|
128
|
+
return Bun.spawn(cmd, opts)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sync the updated peer roster to the VPN_PEERS GitHub Environment secret.
|
|
133
|
+
*
|
|
134
|
+
* Pipes the JSON via stdin to `gh secret set VPN_PEERS --env vpn --repo marcusrbrown/infra`.
|
|
135
|
+
* The roster bytes NEVER appear in argv — stdin only.
|
|
136
|
+
*
|
|
137
|
+
* On failure: prints a warning with the exact remediation command. Does NOT throw.
|
|
138
|
+
* The local peers.json write has already succeeded; the secret being stale is recoverable.
|
|
139
|
+
*
|
|
140
|
+
* Hardening:
|
|
141
|
+
* - Wraps the entire spawn + stdin write + exited await in try/catch.
|
|
142
|
+
* If spawnFn throws synchronously (e.g. ENOENT for missing gh), prints warning and returns.
|
|
143
|
+
* - Races proc.exited against timeoutMs (default 15s). On timeout, kills the proc and warns.
|
|
144
|
+
* - Drains stdout to avoid pipe-buffer deadlock.
|
|
145
|
+
*/
|
|
146
|
+
async function syncPeersSecret(peersJson: string, spawnFn: SpawnFn, timeoutMs = 15_000): Promise<void> {
|
|
147
|
+
const cmd = ['gh', 'secret', 'set', GH_SYNC_SECRET, '--env', GH_SYNC_ENV, '--repo', GH_SYNC_REPO]
|
|
148
|
+
|
|
149
|
+
let proc: SyncSpawnResult
|
|
150
|
+
try {
|
|
151
|
+
proc = spawnFn(cmd, {stdout: 'pipe', stderr: 'pipe', stdin: 'pipe'})
|
|
152
|
+
} catch {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[warn] VPN_PEERS secret sync failed: could not spawn gh. ` +
|
|
155
|
+
`The local peers.json was updated but the VPN_PEERS secret is stale. ` +
|
|
156
|
+
`Remediation: ${GH_SYNC_REMEDIATION}`,
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!proc.stdin) {
|
|
162
|
+
console.warn(
|
|
163
|
+
`[warn] VPN_PEERS secret sync skipped: spawn did not provide stdin pipe. ` +
|
|
164
|
+
`Remediation: ${GH_SYNC_REMEDIATION}`,
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
proc.stdin.write(new TextEncoder().encode(peersJson))
|
|
171
|
+
proc.stdin.end()
|
|
172
|
+
|
|
173
|
+
// Race proc.exited against timeout to avoid hanging indefinitely.
|
|
174
|
+
// Drain stdout concurrently to avoid pipe-buffer deadlock.
|
|
175
|
+
let timedOut = false
|
|
176
|
+
const timeoutHandle = {kill: () => {}}
|
|
177
|
+
|
|
178
|
+
const timeoutPromise = new Promise<number>(resolveTimeout => {
|
|
179
|
+
const id = setTimeout(() => {
|
|
180
|
+
timedOut = true
|
|
181
|
+
resolveTimeout(-1)
|
|
182
|
+
}, timeoutMs)
|
|
183
|
+
timeoutHandle.kill = () => clearTimeout(id)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const [exitCode] = await Promise.all([
|
|
187
|
+
Promise.race([proc.exited, timeoutPromise]),
|
|
188
|
+
// Drain stdout to prevent pipe-buffer deadlock
|
|
189
|
+
new Response(proc.stdout).text().catch(() => ''),
|
|
190
|
+
])
|
|
191
|
+
|
|
192
|
+
timeoutHandle.kill()
|
|
193
|
+
|
|
194
|
+
if (timedOut) {
|
|
195
|
+
console.warn(
|
|
196
|
+
`[warn] VPN_PEERS secret sync timed out after ${timeoutMs}ms. ` +
|
|
197
|
+
`The local peers.json was updated but the VPN_PEERS secret is stale. ` +
|
|
198
|
+
`Remediation: ${GH_SYNC_REMEDIATION}`,
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const stderr = await new Response(proc.stderr).text().catch(() => '')
|
|
204
|
+
|
|
205
|
+
if (exitCode !== 0) {
|
|
206
|
+
console.warn(
|
|
207
|
+
`[warn] VPN_PEERS secret sync failed (gh exited ${exitCode}${stderr.trim() ? `: ${stderr.trim()}` : ''}). ` +
|
|
208
|
+
`The local peers.json was updated but the VPN_PEERS secret is stale. ` +
|
|
209
|
+
`Remediation: ${GH_SYNC_REMEDIATION}`,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
console.warn(
|
|
214
|
+
`[warn] VPN_PEERS secret sync failed unexpectedly. ` +
|
|
215
|
+
`The local peers.json was updated but the VPN_PEERS secret is stale. ` +
|
|
216
|
+
`Remediation: ${GH_SYNC_REMEDIATION}`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
95
221
|
// ─── Write-guard ──────────────────────────────────────────────────────────────
|
|
96
222
|
|
|
97
223
|
/**
|
|
@@ -124,15 +250,22 @@ function assertPathUnderClientsDir(confPath: string, clientsDir: string): void {
|
|
|
124
250
|
* 2. Assigns the next available tunnel IP via addPeer.
|
|
125
251
|
* 3. Appends the PUBLIC key + tunnel IP to peers.json.
|
|
126
252
|
* 4. Writes the client .conf (with PRIVATE key) to clients/<name>.conf.
|
|
253
|
+
* 5. Best-effort syncs the updated roster to the VPN_PEERS GitHub secret.
|
|
127
254
|
*
|
|
128
255
|
* Security invariants:
|
|
129
256
|
* - The client PRIVATE key is written ONLY to the gitignored clients/ directory.
|
|
130
257
|
* - peers.json contains ONLY the public key — never the private key.
|
|
131
258
|
* - The output path is validated before writing (runtime guard, not gitignore-only).
|
|
259
|
+
*
|
|
260
|
+
* Ordering: local peers.json write → client .conf write → THEN best-effort sync.
|
|
261
|
+
* The .conf holds the user's ONLY copy of the private key; it must be written
|
|
262
|
+
* before the best-effort sync so a sync failure never loses the private key.
|
|
132
263
|
*/
|
|
133
264
|
export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promise<VpnClientAddResult> {
|
|
134
265
|
const {peersJsonPath, clientsDir, serverPublicKey, endpoint, allowedIps, dns} = opts
|
|
135
266
|
const keypairGen = opts.keypairGen ?? generateKeypair
|
|
267
|
+
const spawnFn = opts.spawnFn ?? defaultSpawnFn
|
|
268
|
+
const syncTimeoutMs = opts.syncTimeoutMs ?? 15_000
|
|
136
269
|
|
|
137
270
|
// Validate the output path before doing any work
|
|
138
271
|
const confPath = resolve(clientsDir, `${name}.conf`)
|
|
@@ -141,8 +274,8 @@ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promis
|
|
|
141
274
|
// Generate keypair
|
|
142
275
|
const keypair = await keypairGen()
|
|
143
276
|
|
|
144
|
-
// Read current peers
|
|
145
|
-
const peersFile = await
|
|
277
|
+
// Read current peers — returns {peers: []} on fresh checkout (ENOENT)
|
|
278
|
+
const peersFile = await readPeersOrEmpty(peersJsonPath)
|
|
146
279
|
|
|
147
280
|
// Add peer (throws if duplicate name)
|
|
148
281
|
const {peers: updatedPeers, tunnelIp} = addPeer(peersFile.peers, {
|
|
@@ -151,7 +284,8 @@ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promis
|
|
|
151
284
|
})
|
|
152
285
|
|
|
153
286
|
// Write updated peers.json (PUBLIC key only — never private key)
|
|
154
|
-
|
|
287
|
+
const updatedPeersFile = {peers: updatedPeers}
|
|
288
|
+
await writePeers(peersJsonPath, updatedPeersFile)
|
|
155
289
|
|
|
156
290
|
// Render client .conf (contains PRIVATE key)
|
|
157
291
|
const confContent = renderClientConfig({
|
|
@@ -167,30 +301,57 @@ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promis
|
|
|
167
301
|
await mkdir(clientsDir, {recursive: true})
|
|
168
302
|
|
|
169
303
|
// Write client .conf to the gitignored clients/ directory
|
|
304
|
+
// MUST happen BEFORE gh sync — this is the user's ONLY copy of the private key.
|
|
170
305
|
await writeFile(confPath, confContent, {mode: 0o600})
|
|
171
306
|
|
|
307
|
+
// Sync updated roster to VPN_PEERS GitHub secret (best-effort — failure is a warning, not fatal)
|
|
308
|
+
const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}\n`
|
|
309
|
+
await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs)
|
|
310
|
+
|
|
172
311
|
return {tunnelIp, confPath}
|
|
173
312
|
}
|
|
174
313
|
|
|
175
314
|
/**
|
|
176
315
|
* List all VPN client peers from peers.json.
|
|
316
|
+
* Returns an empty array when peers.json does not exist (fresh checkout).
|
|
177
317
|
*/
|
|
178
318
|
export async function vpnClientList(peersJsonPath: string): Promise<Peer[]> {
|
|
179
|
-
const peersFile = await
|
|
319
|
+
const peersFile = await readPeersOrEmpty(peersJsonPath)
|
|
180
320
|
return peersFile.peers
|
|
181
321
|
}
|
|
182
322
|
|
|
323
|
+
export interface VpnClientRemoveOpts {
|
|
324
|
+
spawnFn?: SpawnFn
|
|
325
|
+
syncTimeoutMs?: number
|
|
326
|
+
}
|
|
327
|
+
|
|
183
328
|
/**
|
|
184
329
|
* Remove a VPN client peer from peers.json.
|
|
185
330
|
*
|
|
186
331
|
* Note: The client .conf file in clients/ is NOT deleted — the operator
|
|
187
332
|
* should delete it manually. The peer is removed from peers.json so the
|
|
188
333
|
* next deploy will revoke access.
|
|
334
|
+
*
|
|
335
|
+
* After a successful local write, syncs the updated roster to the VPN_PEERS
|
|
336
|
+
* GitHub Environment secret (best-effort — failure is a warning, not fatal).
|
|
189
337
|
*/
|
|
190
|
-
export async function vpnClientRemove(
|
|
191
|
-
|
|
338
|
+
export async function vpnClientRemove(
|
|
339
|
+
name: string,
|
|
340
|
+
peersJsonPath: string,
|
|
341
|
+
opts: VpnClientRemoveOpts = {},
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
const spawnFn = opts.spawnFn ?? defaultSpawnFn
|
|
344
|
+
const syncTimeoutMs = opts.syncTimeoutMs ?? 15_000
|
|
345
|
+
|
|
346
|
+
// Read current peers — returns {peers: []} on fresh checkout (ENOENT)
|
|
347
|
+
const peersFile = await readPeersOrEmpty(peersJsonPath)
|
|
192
348
|
const {peers: updatedPeers} = removePeer(peersFile.peers, name)
|
|
193
|
-
|
|
349
|
+
const updatedPeersFile = {peers: updatedPeers}
|
|
350
|
+
await writePeers(peersJsonPath, updatedPeersFile)
|
|
351
|
+
|
|
352
|
+
// Sync updated roster to VPN_PEERS GitHub secret (best-effort — failure is a warning, not fatal)
|
|
353
|
+
const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}\n`
|
|
354
|
+
await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs)
|
|
194
355
|
}
|
|
195
356
|
|
|
196
357
|
// ─── Default paths ────────────────────────────────────────────────────────────
|
|
@@ -211,7 +372,7 @@ export function registerVpnClient(cli: ReturnType<typeof goke>): void {
|
|
|
211
372
|
cli
|
|
212
373
|
.command(
|
|
213
374
|
'vpn client add <name>',
|
|
214
|
-
'Add a new VPN client peer. Generates a keypair, assigns a tunnel IP,
|
|
375
|
+
'Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).',
|
|
215
376
|
)
|
|
216
377
|
.option(
|
|
217
378
|
'--split-tunnel [cidrs]',
|
|
@@ -289,7 +450,7 @@ export function registerVpnClient(cli: ReturnType<typeof goke>): void {
|
|
|
289
450
|
cli
|
|
290
451
|
.command(
|
|
291
452
|
'vpn client remove <name>',
|
|
292
|
-
'Remove a VPN client peer from peers.json. Run `infra vpn deploy` to revoke access.',
|
|
453
|
+
'Remove a VPN client peer from peers.json and sync the roster to the VPN_PEERS GitHub secret (best-effort). Run `infra vpn deploy` to revoke access.',
|
|
293
454
|
)
|
|
294
455
|
.example('# Remove a client named "laptop"')
|
|
295
456
|
.example('infra vpn client remove laptop')
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
|
+
import {mkdtempSync, rmSync, statSync, writeFileSync} from 'node:fs'
|
|
4
|
+
import {tmpdir} from 'node:os'
|
|
5
|
+
import {join} from 'node:path'
|
|
3
6
|
import {describe, expect, it} from 'bun:test'
|
|
4
7
|
|
|
5
8
|
import {
|
|
6
9
|
addPeer,
|
|
7
10
|
nextTunnelIp,
|
|
8
11
|
parsePeersJson,
|
|
12
|
+
readPeersOrEmpty,
|
|
9
13
|
removePeer,
|
|
10
14
|
renderClientConfig,
|
|
11
15
|
renderServerConfig,
|
|
16
|
+
writePeers,
|
|
12
17
|
type Peer,
|
|
13
18
|
type PeersFile,
|
|
14
19
|
} from './peers'
|
|
@@ -364,4 +369,110 @@ describe('parsePeersJson', () => {
|
|
|
364
369
|
expect(result.peers).toHaveLength(1)
|
|
365
370
|
expect(result.peers[0]?.name).toBe('laptop')
|
|
366
371
|
})
|
|
372
|
+
|
|
373
|
+
// Fix #7: duplicate tunnelIp / duplicate name validation
|
|
374
|
+
it('rejects a roster with duplicate tunnelIp values', () => {
|
|
375
|
+
expect(() =>
|
|
376
|
+
parsePeersJson({
|
|
377
|
+
peers: [
|
|
378
|
+
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
379
|
+
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.2'},
|
|
380
|
+
],
|
|
381
|
+
}),
|
|
382
|
+
).toThrow(/duplicate.*tunnel|tunnel.*duplicate/i)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('rejects a roster with duplicate name values', () => {
|
|
386
|
+
expect(() =>
|
|
387
|
+
parsePeersJson({
|
|
388
|
+
peers: [
|
|
389
|
+
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
390
|
+
{name: 'laptop', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
391
|
+
],
|
|
392
|
+
}),
|
|
393
|
+
).toThrow(/duplicate.*name|name.*duplicate/i)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('accepts a valid roster with unique names and tunnel IPs', () => {
|
|
397
|
+
const result: PeersFile = parsePeersJson({
|
|
398
|
+
peers: [
|
|
399
|
+
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
400
|
+
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
401
|
+
],
|
|
402
|
+
})
|
|
403
|
+
expect(result.peers).toHaveLength(2)
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ── readPeersOrEmpty ──────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
describe('readPeersOrEmpty', () => {
|
|
410
|
+
it('returns {peers: []} when the file does not exist (ENOENT)', async () => {
|
|
411
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
412
|
+
try {
|
|
413
|
+
const nonExistentPath = join(tmpDir, 'peers.json')
|
|
414
|
+
const result = await readPeersOrEmpty(nonExistentPath)
|
|
415
|
+
expect(result).toEqual({peers: []})
|
|
416
|
+
} finally {
|
|
417
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('returns parsed file when the file exists and is valid', async () => {
|
|
422
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
423
|
+
try {
|
|
424
|
+
const filePath = join(tmpDir, 'peers.json')
|
|
425
|
+
writeFileSync(
|
|
426
|
+
filePath,
|
|
427
|
+
JSON.stringify({peers: [{name: 'laptop', publicKey: 'pk==', tunnelIp: '10.8.0.2'}]}, null, 2),
|
|
428
|
+
)
|
|
429
|
+
const result = await readPeersOrEmpty(filePath)
|
|
430
|
+
expect(result.peers).toHaveLength(1)
|
|
431
|
+
expect(result.peers[0]?.name).toBe('laptop')
|
|
432
|
+
} finally {
|
|
433
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('throws on corrupt JSON (not ENOENT — corrupt file must NOT silently become empty roster)', async () => {
|
|
438
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
439
|
+
try {
|
|
440
|
+
const filePath = join(tmpDir, 'peers.json')
|
|
441
|
+
writeFileSync(filePath, 'not valid json {{{')
|
|
442
|
+
await expect(readPeersOrEmpty(filePath)).rejects.toThrow()
|
|
443
|
+
} finally {
|
|
444
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('throws on schema-invalid JSON (not ENOENT — schema errors must not silently become empty roster)', async () => {
|
|
449
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
450
|
+
try {
|
|
451
|
+
const filePath = join(tmpDir, 'peers.json')
|
|
452
|
+
writeFileSync(filePath, JSON.stringify({peers: [{name: 'laptop'}]}))
|
|
453
|
+
await expect(readPeersOrEmpty(filePath)).rejects.toThrow()
|
|
454
|
+
} finally {
|
|
455
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ── writePeers: restrictive permissions ──────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
describe('writePeers: file permissions', () => {
|
|
463
|
+
it('writes peers.json with mode 0o600 (owner read/write only)', async () => {
|
|
464
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-perm-test-'))
|
|
465
|
+
try {
|
|
466
|
+
const filePath = join(tmpDir, 'peers.json')
|
|
467
|
+
await writePeers(filePath, {peers: []})
|
|
468
|
+
|
|
469
|
+
const stat = statSync(filePath)
|
|
470
|
+
// On macOS/Linux: check that mode is 0o600 (owner rw, no group/other)
|
|
471
|
+
// stat.mode includes file type bits; mask with 0o777 for permission bits
|
|
472
|
+
const permBits = stat.mode & 0o777
|
|
473
|
+
expect(permBits).toBe(0o600)
|
|
474
|
+
} finally {
|
|
475
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
476
|
+
}
|
|
477
|
+
})
|
|
367
478
|
})
|
|
@@ -10,9 +10,39 @@ const peerSchema = z.object({
|
|
|
10
10
|
tunnelIp: z.string(),
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
-
const peersFileSchema = z
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const peersFileSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
peers: z.array(peerSchema),
|
|
16
|
+
})
|
|
17
|
+
.superRefine((data, ctx) => {
|
|
18
|
+
// Reject duplicate tunnelIp values — duplicated tunnel IPs break WireGuard routing silently
|
|
19
|
+
const seenIps = new Set<string>()
|
|
20
|
+
for (const peer of data.peers) {
|
|
21
|
+
if (seenIps.has(peer.tunnelIp)) {
|
|
22
|
+
ctx.addIssue({
|
|
23
|
+
code: z.ZodIssueCode.custom,
|
|
24
|
+
message: `Duplicate tunnel IP "${peer.tunnelIp}" — each peer must have a unique tunnel IP.`,
|
|
25
|
+
path: ['peers'],
|
|
26
|
+
})
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
seenIps.add(peer.tunnelIp)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reject duplicate name values
|
|
33
|
+
const seenNames = new Set<string>()
|
|
34
|
+
for (const peer of data.peers) {
|
|
35
|
+
if (seenNames.has(peer.name)) {
|
|
36
|
+
ctx.addIssue({
|
|
37
|
+
code: z.ZodIssueCode.custom,
|
|
38
|
+
message: `Duplicate name "${peer.name}" — each peer must have a unique name.`,
|
|
39
|
+
path: ['peers'],
|
|
40
|
+
})
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
seenNames.add(peer.name)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
16
46
|
|
|
17
47
|
export type Peer = z.infer<typeof peerSchema>
|
|
18
48
|
export type PeersFile = z.infer<typeof peersFileSchema>
|
|
@@ -35,9 +65,29 @@ export async function readPeers(filePath: string): Promise<PeersFile> {
|
|
|
35
65
|
return parsePeersJson(raw)
|
|
36
66
|
}
|
|
37
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Read peers from filePath, returning {peers: []} when the file is absent (ENOENT).
|
|
70
|
+
*
|
|
71
|
+
* ENOENT is the only error that is silently treated as an empty roster — this
|
|
72
|
+
* supports fresh checkouts where peers.json is gitignored and does not exist yet.
|
|
73
|
+
*
|
|
74
|
+
* All other errors (corrupt JSON, schema violations, permission errors) still throw
|
|
75
|
+
* so that a corrupt file is never silently treated as an empty roster.
|
|
76
|
+
*/
|
|
77
|
+
export async function readPeersOrEmpty(filePath: string): Promise<PeersFile> {
|
|
78
|
+
try {
|
|
79
|
+
return await readPeers(filePath)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
82
|
+
return {peers: []}
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
38
88
|
export async function writePeers(filePath: string, peersFile: PeersFile): Promise<void> {
|
|
39
89
|
const content = `${JSON.stringify(peersFile, null, 2)}\n`
|
|
40
|
-
await writeFile(filePath, content,
|
|
90
|
+
await writeFile(filePath, content, {mode: 0o600})
|
|
41
91
|
}
|
|
42
92
|
|
|
43
93
|
// ── IP allocation ─────────────────────────────────────────────────────────────
|