@marcusrbrown/infra 0.10.0 → 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.
@@ -3,15 +3,8 @@ import type {goke} from 'goke'
3
3
  import {mkdir, writeFile} from 'node:fs/promises'
4
4
  import {resolve} from 'node:path'
5
5
 
6
- import {
7
- addPeer,
8
- readPeers,
9
- removePeer,
10
- renderClientConfig,
11
- writePeers,
12
- type Peer,
13
- } from '@marcusrbrown/infra-shared/vpn/peers'
14
6
  import {z} from 'zod'
7
+ import {addPeer, readPeersOrEmpty, removePeer, renderClientConfig, writePeers, type Peer} from './peers'
15
8
 
16
9
  declare const process: {
17
10
  env: Record<string, string | undefined>
@@ -27,14 +20,34 @@ export interface Keypair {
27
20
 
28
21
  export type KeypairGenFn = () => Promise<Keypair>
29
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
+
30
40
  export interface VpnClientAddOpts {
31
41
  peersJsonPath: string
32
42
  clientsDir: string
33
43
  serverPublicKey: string
34
44
  endpoint: string
35
45
  keypairGen?: KeypairGenFn
46
+ spawnFn?: SpawnFn
36
47
  allowedIps?: string
37
48
  dns?: string
49
+ /** Timeout in ms for gh secret sync. Defaults to 15000. Injectable for tests. */
50
+ syncTimeoutMs?: number
38
51
  }
39
52
 
40
53
  export interface VpnClientAddResult {
@@ -99,6 +112,112 @@ export async function generateKeypair(): Promise<Keypair> {
99
112
  }
100
113
  }
101
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
+
102
221
  // ─── Write-guard ──────────────────────────────────────────────────────────────
103
222
 
104
223
  /**
@@ -131,15 +250,22 @@ function assertPathUnderClientsDir(confPath: string, clientsDir: string): void {
131
250
  * 2. Assigns the next available tunnel IP via addPeer.
132
251
  * 3. Appends the PUBLIC key + tunnel IP to peers.json.
133
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.
134
254
  *
135
255
  * Security invariants:
136
256
  * - The client PRIVATE key is written ONLY to the gitignored clients/ directory.
137
257
  * - peers.json contains ONLY the public key — never the private key.
138
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.
139
263
  */
140
264
  export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promise<VpnClientAddResult> {
141
265
  const {peersJsonPath, clientsDir, serverPublicKey, endpoint, allowedIps, dns} = opts
142
266
  const keypairGen = opts.keypairGen ?? generateKeypair
267
+ const spawnFn = opts.spawnFn ?? defaultSpawnFn
268
+ const syncTimeoutMs = opts.syncTimeoutMs ?? 15_000
143
269
 
144
270
  // Validate the output path before doing any work
145
271
  const confPath = resolve(clientsDir, `${name}.conf`)
@@ -148,8 +274,8 @@ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promis
148
274
  // Generate keypair
149
275
  const keypair = await keypairGen()
150
276
 
151
- // Read current peers
152
- const peersFile = await readPeers(peersJsonPath)
277
+ // Read current peers — returns {peers: []} on fresh checkout (ENOENT)
278
+ const peersFile = await readPeersOrEmpty(peersJsonPath)
153
279
 
154
280
  // Add peer (throws if duplicate name)
155
281
  const {peers: updatedPeers, tunnelIp} = addPeer(peersFile.peers, {
@@ -158,7 +284,8 @@ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promis
158
284
  })
159
285
 
160
286
  // Write updated peers.json (PUBLIC key only — never private key)
161
- await writePeers(peersJsonPath, {peers: updatedPeers})
287
+ const updatedPeersFile = {peers: updatedPeers}
288
+ await writePeers(peersJsonPath, updatedPeersFile)
162
289
 
163
290
  // Render client .conf (contains PRIVATE key)
164
291
  const confContent = renderClientConfig({
@@ -174,30 +301,57 @@ export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promis
174
301
  await mkdir(clientsDir, {recursive: true})
175
302
 
176
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.
177
305
  await writeFile(confPath, confContent, {mode: 0o600})
178
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
+
179
311
  return {tunnelIp, confPath}
180
312
  }
181
313
 
182
314
  /**
183
315
  * List all VPN client peers from peers.json.
316
+ * Returns an empty array when peers.json does not exist (fresh checkout).
184
317
  */
185
318
  export async function vpnClientList(peersJsonPath: string): Promise<Peer[]> {
186
- const peersFile = await readPeers(peersJsonPath)
319
+ const peersFile = await readPeersOrEmpty(peersJsonPath)
187
320
  return peersFile.peers
188
321
  }
189
322
 
323
+ export interface VpnClientRemoveOpts {
324
+ spawnFn?: SpawnFn
325
+ syncTimeoutMs?: number
326
+ }
327
+
190
328
  /**
191
329
  * Remove a VPN client peer from peers.json.
192
330
  *
193
331
  * Note: The client .conf file in clients/ is NOT deleted — the operator
194
332
  * should delete it manually. The peer is removed from peers.json so the
195
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).
196
337
  */
197
- export async function vpnClientRemove(name: string, peersJsonPath: string): Promise<void> {
198
- 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)
199
348
  const {peers: updatedPeers} = removePeer(peersFile.peers, name)
200
- 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)
201
355
  }
202
356
 
203
357
  // ─── Default paths ────────────────────────────────────────────────────────────
@@ -218,7 +372,7 @@ export function registerVpnClient(cli: ReturnType<typeof goke>): void {
218
372
  cli
219
373
  .command(
220
374
  'vpn client add <name>',
221
- '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).',
222
376
  )
223
377
  .option(
224
378
  '--split-tunnel [cidrs]',
@@ -296,7 +450,7 @@ export function registerVpnClient(cli: ReturnType<typeof goke>): void {
296
450
  cli
297
451
  .command(
298
452
  'vpn client remove <name>',
299
- '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.',
300
454
  )
301
455
  .example('# Remove a client named "laptop"')
302
456
  .example('infra vpn client remove laptop')