@marcusrbrown/infra 0.10.1 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -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, and writes the client .conf to apps/vpn/clients/.
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('not found')
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, readPeers, removePeer, renderClientConfig, writePeers, type Peer} from './peers'
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 readPeers(peersJsonPath)
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
- await writePeers(peersJsonPath, {peers: updatedPeers})
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 readPeers(peersJsonPath)
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(name: string, peersJsonPath: string): Promise<void> {
191
- const peersFile = await readPeers(peersJsonPath)
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
- await writePeers(peersJsonPath, {peers: updatedPeers})
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, and writes the client .conf to apps/vpn/clients/.',
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.object({
14
- peers: z.array(peerSchema),
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, 'utf-8')
90
+ await writeFile(filePath, content, {mode: 0o600})
41
91
  }
42
92
 
43
93
  // ── IP allocation ─────────────────────────────────────────────────────────────