@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
|
|
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
|
-
|
|
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
|
|
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(
|
|
198
|
-
|
|
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
|
-
|
|
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,
|
|
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')
|