@marcusrbrown/infra 0.13.0 → 0.13.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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,467 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {mkdir, writeFile} from 'node:fs/promises'
|
|
4
|
-
import {resolve} from 'node:path'
|
|
5
|
-
|
|
6
|
-
import {z} from 'zod'
|
|
7
|
-
import {addPeer, readPeersOrEmpty, removePeer, renderClientConfig, writePeers, type Peer} from './peers'
|
|
8
|
-
|
|
9
|
-
declare const process: {
|
|
10
|
-
env: Record<string, string | undefined>
|
|
11
|
-
exitCode?: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export interface Keypair {
|
|
17
|
-
privateKey: string
|
|
18
|
-
publicKey: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type KeypairGenFn = () => Promise<Keypair>
|
|
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
|
-
|
|
40
|
-
export interface VpnClientAddOpts {
|
|
41
|
-
peersJsonPath: string
|
|
42
|
-
clientsDir: string
|
|
43
|
-
serverPublicKey: string
|
|
44
|
-
endpoint: string
|
|
45
|
-
keypairGen?: KeypairGenFn
|
|
46
|
-
spawnFn?: SpawnFn
|
|
47
|
-
allowedIps?: string
|
|
48
|
-
dns?: string
|
|
49
|
-
/** Timeout in ms for gh secret sync. Defaults to 15000. Injectable for tests. */
|
|
50
|
-
syncTimeoutMs?: number
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface VpnClientAddResult {
|
|
54
|
-
tunnelIp: string
|
|
55
|
-
confPath: string
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ─── WireGuard keypair generation ─────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Generate a WireGuard keypair using the local `wg` binary.
|
|
62
|
-
*
|
|
63
|
-
* Assumption: `wg` is installed locally (wireguard-tools package).
|
|
64
|
-
* The private key is generated locally and written ONLY to the gitignored
|
|
65
|
-
* clients/ directory — it never leaves the local machine.
|
|
66
|
-
*/
|
|
67
|
-
export async function generateKeypair(): Promise<Keypair> {
|
|
68
|
-
// Step 1: generate private key
|
|
69
|
-
const genkeyProc = Bun.spawn(['wg', 'genkey'], {
|
|
70
|
-
stdout: 'pipe',
|
|
71
|
-
stderr: 'pipe',
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
const [privateKeyRaw, genkeyStderr, genkeyExit] = await Promise.all([
|
|
75
|
-
new Response(genkeyProc.stdout).text(),
|
|
76
|
-
new Response(genkeyProc.stderr).text(),
|
|
77
|
-
genkeyProc.exited,
|
|
78
|
-
])
|
|
79
|
-
|
|
80
|
-
if (genkeyExit !== 0) {
|
|
81
|
-
throw new Error(`wg genkey failed (exit ${genkeyExit}): ${genkeyStderr.trim()}`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const privateKey = privateKeyRaw.trim()
|
|
85
|
-
|
|
86
|
-
// Step 2: derive public key from private key
|
|
87
|
-
const encoder = new TextEncoder()
|
|
88
|
-
const pubkeyProc = Bun.spawn(['wg', 'pubkey'], {
|
|
89
|
-
stdin: new ReadableStream({
|
|
90
|
-
start(controller) {
|
|
91
|
-
controller.enqueue(encoder.encode(`${privateKey}\n`))
|
|
92
|
-
controller.close()
|
|
93
|
-
},
|
|
94
|
-
}),
|
|
95
|
-
stdout: 'pipe',
|
|
96
|
-
stderr: 'pipe',
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
const [publicKeyRaw, pubkeyStderr, pubkeyExit] = await Promise.all([
|
|
100
|
-
new Response(pubkeyProc.stdout).text(),
|
|
101
|
-
new Response(pubkeyProc.stderr).text(),
|
|
102
|
-
pubkeyProc.exited,
|
|
103
|
-
])
|
|
104
|
-
|
|
105
|
-
if (pubkeyExit !== 0) {
|
|
106
|
-
throw new Error(`wg pubkey failed (exit ${pubkeyExit}): ${pubkeyStderr.trim()}`)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
privateKey,
|
|
111
|
-
publicKey: publicKeyRaw.trim(),
|
|
112
|
-
}
|
|
113
|
-
}
|
|
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
|
-
|
|
221
|
-
// ─── Write-guard ──────────────────────────────────────────────────────────────
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Validate that the resolved output path is under the expected clients directory.
|
|
225
|
-
*
|
|
226
|
-
* This is a runtime guard (not gitignore-only) that refuses to write client
|
|
227
|
-
* .conf files (which contain private keys) outside the gitignored clients/ dir.
|
|
228
|
-
*
|
|
229
|
-
* @throws {Error} if the resolved path escapes the clients directory.
|
|
230
|
-
*/
|
|
231
|
-
function assertPathUnderClientsDir(confPath: string, clientsDir: string): void {
|
|
232
|
-
const resolvedClientsDir = resolve(clientsDir)
|
|
233
|
-
const resolvedConfPath = resolve(confPath)
|
|
234
|
-
|
|
235
|
-
if (!resolvedConfPath.startsWith(`${resolvedClientsDir}/`) && resolvedConfPath !== resolvedClientsDir) {
|
|
236
|
-
throw new Error(
|
|
237
|
-
`Security: refusing to write client .conf outside the clients directory. ` +
|
|
238
|
-
`Resolved path "${resolvedConfPath}" is not under "${resolvedClientsDir}". ` +
|
|
239
|
-
`Client private keys must only be written to the gitignored clients/ directory.`,
|
|
240
|
-
)
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ─── Core operations ──────────────────────────────────────────────────────────
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Add a new VPN client peer.
|
|
248
|
-
*
|
|
249
|
-
* 1. Generates a WireGuard keypair locally (via `wg genkey` / `wg pubkey`).
|
|
250
|
-
* 2. Assigns the next available tunnel IP via addPeer.
|
|
251
|
-
* 3. Appends the PUBLIC key + tunnel IP to peers.json.
|
|
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.
|
|
254
|
-
*
|
|
255
|
-
* Security invariants:
|
|
256
|
-
* - The client PRIVATE key is written ONLY to the gitignored clients/ directory.
|
|
257
|
-
* - peers.json contains ONLY the public key — never the private key.
|
|
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.
|
|
263
|
-
*/
|
|
264
|
-
export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promise<VpnClientAddResult> {
|
|
265
|
-
const {peersJsonPath, clientsDir, serverPublicKey, endpoint, allowedIps, dns} = opts
|
|
266
|
-
const keypairGen = opts.keypairGen ?? generateKeypair
|
|
267
|
-
const spawnFn = opts.spawnFn ?? defaultSpawnFn
|
|
268
|
-
const syncTimeoutMs = opts.syncTimeoutMs ?? 15_000
|
|
269
|
-
|
|
270
|
-
// Validate the output path before doing any work
|
|
271
|
-
const confPath = resolve(clientsDir, `${name}.conf`)
|
|
272
|
-
assertPathUnderClientsDir(confPath, clientsDir)
|
|
273
|
-
|
|
274
|
-
// Generate keypair
|
|
275
|
-
const keypair = await keypairGen()
|
|
276
|
-
|
|
277
|
-
// Read current peers — returns {peers: []} on fresh checkout (ENOENT)
|
|
278
|
-
const peersFile = await readPeersOrEmpty(peersJsonPath)
|
|
279
|
-
|
|
280
|
-
// Add peer (throws if duplicate name)
|
|
281
|
-
const {peers: updatedPeers, tunnelIp} = addPeer(peersFile.peers, {
|
|
282
|
-
name,
|
|
283
|
-
publicKey: keypair.publicKey,
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// Write updated peers.json (PUBLIC key only — never private key)
|
|
287
|
-
const updatedPeersFile = {peers: updatedPeers}
|
|
288
|
-
await writePeers(peersJsonPath, updatedPeersFile)
|
|
289
|
-
|
|
290
|
-
// Render client .conf (contains PRIVATE key)
|
|
291
|
-
const confContent = renderClientConfig({
|
|
292
|
-
clientPrivateKey: keypair.privateKey,
|
|
293
|
-
serverPublicKey,
|
|
294
|
-
endpoint,
|
|
295
|
-
tunnelIp,
|
|
296
|
-
allowedIps,
|
|
297
|
-
dns,
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
// Ensure clients directory exists
|
|
301
|
-
await mkdir(clientsDir, {recursive: true})
|
|
302
|
-
|
|
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.
|
|
305
|
-
await writeFile(confPath, confContent, {mode: 0o600})
|
|
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
|
-
|
|
311
|
-
return {tunnelIp, confPath}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* List all VPN client peers from peers.json.
|
|
316
|
-
* Returns an empty array when peers.json does not exist (fresh checkout).
|
|
317
|
-
*/
|
|
318
|
-
export async function vpnClientList(peersJsonPath: string): Promise<Peer[]> {
|
|
319
|
-
const peersFile = await readPeersOrEmpty(peersJsonPath)
|
|
320
|
-
return peersFile.peers
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export interface VpnClientRemoveOpts {
|
|
324
|
-
spawnFn?: SpawnFn
|
|
325
|
-
syncTimeoutMs?: number
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Remove a VPN client peer from peers.json.
|
|
330
|
-
*
|
|
331
|
-
* Note: The client .conf file in clients/ is NOT deleted — the operator
|
|
332
|
-
* should delete it manually. The peer is removed from peers.json so the
|
|
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).
|
|
337
|
-
*/
|
|
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)
|
|
348
|
-
const {peers: updatedPeers} = removePeer(peersFile.peers, name)
|
|
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)
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ─── Default paths ────────────────────────────────────────────────────────────
|
|
358
|
-
|
|
359
|
-
function resolveDefaultPeersJsonPath(): string {
|
|
360
|
-
// packages/cli/src/commands/vpn/ → repo root → apps/vpn/config/peers.json
|
|
361
|
-
return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'config', 'peers.json')
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function resolveDefaultClientsDir(): string {
|
|
365
|
-
// packages/cli/src/commands/vpn/ → repo root → apps/vpn/clients/
|
|
366
|
-
return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'clients')
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// ─── Command registration ─────────────────────────────────────────────────────
|
|
370
|
-
|
|
371
|
-
export function registerVpnClient(cli: ReturnType<typeof goke>): void {
|
|
372
|
-
cli
|
|
373
|
-
.command(
|
|
374
|
-
'vpn client add <name>',
|
|
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).',
|
|
376
|
-
)
|
|
377
|
-
.option(
|
|
378
|
-
'--split-tunnel [cidrs]',
|
|
379
|
-
z.string().describe('Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).'),
|
|
380
|
-
)
|
|
381
|
-
.option('--dns [dns]', z.string().describe('DNS server for the client config.'))
|
|
382
|
-
.option('--server-pubkey [key]', z.string().describe('Server public key. Falls back to VPN_SERVER_PUBKEY env var.'))
|
|
383
|
-
.option(
|
|
384
|
-
'--endpoint [host]',
|
|
385
|
-
z.string().describe('VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.'),
|
|
386
|
-
)
|
|
387
|
-
.example('# Add a new client named "laptop"')
|
|
388
|
-
.example('infra vpn client add laptop')
|
|
389
|
-
.example('# Add a split-tunnel client')
|
|
390
|
-
.example('infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16')
|
|
391
|
-
.action(async (name, options) => {
|
|
392
|
-
const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY
|
|
393
|
-
const endpoint = options.endpoint ?? process.env.VPN_HOST
|
|
394
|
-
|
|
395
|
-
if (!serverPublicKey) {
|
|
396
|
-
console.error('Server public key is required. Pass --server-pubkey or set VPN_SERVER_PUBKEY.')
|
|
397
|
-
process.exitCode = 1
|
|
398
|
-
return
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (!endpoint) {
|
|
402
|
-
console.error('VPN endpoint is required. Pass --endpoint or set VPN_HOST.')
|
|
403
|
-
process.exitCode = 1
|
|
404
|
-
return
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
const result = await vpnClientAdd(name as string, {
|
|
409
|
-
peersJsonPath: resolveDefaultPeersJsonPath(),
|
|
410
|
-
clientsDir: resolveDefaultClientsDir(),
|
|
411
|
-
serverPublicKey,
|
|
412
|
-
endpoint,
|
|
413
|
-
allowedIps: options.splitTunnel ?? undefined,
|
|
414
|
-
dns: options.dns ?? undefined,
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
console.log(`Client "${name as string}" added.`)
|
|
418
|
-
console.log(` Tunnel IP: ${result.tunnelIp}`)
|
|
419
|
-
console.log(` Config: ${result.confPath}`)
|
|
420
|
-
console.log('')
|
|
421
|
-
console.log('Next step: run `infra vpn deploy` to apply the new peer to the VPN box.')
|
|
422
|
-
} catch (error) {
|
|
423
|
-
console.error(error instanceof Error ? error.message : String(error))
|
|
424
|
-
process.exitCode = 1
|
|
425
|
-
}
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
cli.command('vpn client list', 'List all VPN client peers from peers.json.').action(async () => {
|
|
429
|
-
try {
|
|
430
|
-
const peers = await vpnClientList(resolveDefaultPeersJsonPath())
|
|
431
|
-
|
|
432
|
-
if (peers.length === 0) {
|
|
433
|
-
console.log('No peers configured.')
|
|
434
|
-
return
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
console.log('Name Tunnel IP Public Key')
|
|
438
|
-
console.log('─────────────────────────────────────────────────────────')
|
|
439
|
-
for (const peer of peers) {
|
|
440
|
-
const name = peer.name.padEnd(16)
|
|
441
|
-
const ip = peer.tunnelIp.padEnd(15)
|
|
442
|
-
console.log(`${name} ${ip} ${peer.publicKey}`)
|
|
443
|
-
}
|
|
444
|
-
} catch (error) {
|
|
445
|
-
console.error(error instanceof Error ? error.message : String(error))
|
|
446
|
-
process.exitCode = 1
|
|
447
|
-
}
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
cli
|
|
451
|
-
.command(
|
|
452
|
-
'vpn client remove <name>',
|
|
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.',
|
|
454
|
-
)
|
|
455
|
-
.example('# Remove a client named "laptop"')
|
|
456
|
-
.example('infra vpn client remove laptop')
|
|
457
|
-
.action(async name => {
|
|
458
|
-
try {
|
|
459
|
-
await vpnClientRemove(name as string, resolveDefaultPeersJsonPath())
|
|
460
|
-
console.log(`Peer "${name as string}" removed from peers.json.`)
|
|
461
|
-
console.log('Next step: run `infra vpn deploy` to revoke access on the VPN box.')
|
|
462
|
-
} catch (error) {
|
|
463
|
-
console.error(error instanceof Error ? error.message : String(error))
|
|
464
|
-
process.exitCode = 1
|
|
465
|
-
}
|
|
466
|
-
})
|
|
467
|
-
}
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import {resolve} from 'node:path'
|
|
2
|
-
import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
|
|
3
|
-
|
|
4
|
-
import {getVpnDeployEnv, validateVpnRemotePreconditions} from './deploy'
|
|
5
|
-
|
|
6
|
-
const cliDir = resolve(import.meta.dir, '../../..')
|
|
7
|
-
|
|
8
|
-
const envKeys = ['HOME', 'PATH', 'SSH_AUTH_SOCK', 'VPN_HOST'] as const
|
|
9
|
-
|
|
10
|
-
type ManagedEnvKey = (typeof envKeys)[number]
|
|
11
|
-
|
|
12
|
-
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
13
|
-
|
|
14
|
-
function restoreManagedEnv(): void {
|
|
15
|
-
for (const key of envKeys) {
|
|
16
|
-
const value = originalEnv[key]
|
|
17
|
-
if (value === undefined) {
|
|
18
|
-
delete process.env[key]
|
|
19
|
-
continue
|
|
20
|
-
}
|
|
21
|
-
process.env[key] = value
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
|
|
26
|
-
restoreManagedEnv()
|
|
27
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
28
|
-
if (value === undefined) {
|
|
29
|
-
delete process.env[key]
|
|
30
|
-
continue
|
|
31
|
-
}
|
|
32
|
-
process.env[key as ManagedEnvKey] = value
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function runDeployCommand(
|
|
37
|
-
args: string[],
|
|
38
|
-
envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
|
|
39
|
-
): Promise<{stdout: string; stderr: string; exitCode: number}> {
|
|
40
|
-
const env: Record<string, string> = {}
|
|
41
|
-
|
|
42
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
43
|
-
if (value !== undefined) {
|
|
44
|
-
env[key] = value
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const [key, value] of Object.entries(envOverrides)) {
|
|
49
|
-
if (value === undefined) {
|
|
50
|
-
delete env[key]
|
|
51
|
-
} else {
|
|
52
|
-
env[key] = value
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!Object.prototype.hasOwnProperty.call(envOverrides, 'HOME') && !env.HOME) {
|
|
57
|
-
env.HOME = '/tmp/test-home'
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (!Object.prototype.hasOwnProperty.call(envOverrides, 'PATH') && !env.PATH) {
|
|
61
|
-
env.PATH = '/usr/bin:/bin'
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (!Object.prototype.hasOwnProperty.call(envOverrides, 'SSH_AUTH_SOCK') && !env.SSH_AUTH_SOCK) {
|
|
65
|
-
env.SSH_AUTH_SOCK = '/tmp/test-sock'
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
env.NO_COLOR = '1'
|
|
69
|
-
|
|
70
|
-
const proc = Bun.spawn(['bun', 'src/cli.ts', 'vpn', 'deploy', ...args], {
|
|
71
|
-
cwd: cliDir,
|
|
72
|
-
env,
|
|
73
|
-
stdout: 'pipe',
|
|
74
|
-
stderr: 'pipe',
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
78
|
-
new Response(proc.stdout).text(),
|
|
79
|
-
new Response(proc.stderr).text(),
|
|
80
|
-
proc.exited,
|
|
81
|
-
])
|
|
82
|
-
|
|
83
|
-
return {stdout, stderr, exitCode}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
describe('vpn deploy', () => {
|
|
87
|
-
beforeEach(() => {
|
|
88
|
-
originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
afterEach(() => {
|
|
92
|
-
restoreManagedEnv()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
describe('getVpnDeployEnv', () => {
|
|
96
|
-
it('returns the expected deploy environment when required variables are present', () => {
|
|
97
|
-
setManagedEnv({
|
|
98
|
-
VPN_HOST: '1.2.3.4',
|
|
99
|
-
HOME: '/tmp/test-home',
|
|
100
|
-
PATH: '/usr/bin:/bin',
|
|
101
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
const env = getVpnDeployEnv()
|
|
105
|
-
|
|
106
|
-
expect(env.VPN_HOST).toBe('1.2.3.4')
|
|
107
|
-
expect(env.HOME).toBe('/tmp/test-home')
|
|
108
|
-
expect(env.PATH).toBe('/usr/bin:/bin')
|
|
109
|
-
expect(env.SSH_AUTH_SOCK).toBe('/tmp/test-sock')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('throws when SSH_AUTH_SOCK is missing', () => {
|
|
113
|
-
setManagedEnv({
|
|
114
|
-
VPN_HOST: '1.2.3.4',
|
|
115
|
-
HOME: '/tmp/test-home',
|
|
116
|
-
PATH: '/usr/bin:/bin',
|
|
117
|
-
SSH_AUTH_SOCK: undefined,
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
expect(() => getVpnDeployEnv()).toThrow('SSH_AUTH_SOCK')
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('throws when PATH is missing', () => {
|
|
124
|
-
setManagedEnv({
|
|
125
|
-
VPN_HOST: '1.2.3.4',
|
|
126
|
-
HOME: '/tmp/test-home',
|
|
127
|
-
PATH: undefined,
|
|
128
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
expect(() => getVpnDeployEnv()).toThrow('PATH')
|
|
132
|
-
})
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
describe('validateVpnRemotePreconditions', () => {
|
|
136
|
-
it('throws when gh is not available', () => {
|
|
137
|
-
const spy = spyOn(Bun, 'which').mockReturnValue(null)
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
expect(() => validateVpnRemotePreconditions()).toThrow('gh')
|
|
141
|
-
} finally {
|
|
142
|
-
spy.mockRestore()
|
|
143
|
-
}
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it('does not throw when gh is available', () => {
|
|
147
|
-
const spy = spyOn(Bun, 'which').mockReturnValue('/usr/bin/gh')
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
expect(() => validateVpnRemotePreconditions()).not.toThrow()
|
|
151
|
-
} finally {
|
|
152
|
-
spy.mockRestore()
|
|
153
|
-
}
|
|
154
|
-
})
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
describe('--remote (default) dry-run', () => {
|
|
158
|
-
it('prints planned actions without invoking gh', async () => {
|
|
159
|
-
const {stdout, exitCode} = await runDeployCommand(['--dry-run'], {
|
|
160
|
-
HOME: '/tmp/test-home',
|
|
161
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
162
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
expect(exitCode).toBe(0)
|
|
166
|
-
expect(stdout).toContain('Dry run')
|
|
167
|
-
expect(stdout).toContain('Deploy VPN')
|
|
168
|
-
})
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
describe('--local --dry-run', () => {
|
|
172
|
-
it('prints planned local actions without spawning bun run', async () => {
|
|
173
|
-
const {stdout, exitCode} = await runDeployCommand(['--local', '--dry-run'], {
|
|
174
|
-
HOME: '/tmp/test-home',
|
|
175
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
176
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
expect(exitCode).toBe(0)
|
|
180
|
-
expect(stdout).toContain('Dry run')
|
|
181
|
-
expect(stdout).toContain('local')
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
it('succeeds even when SSH_AUTH_SOCK is unset', async () => {
|
|
185
|
-
const {stdout, exitCode} = await runDeployCommand(['--local', '--dry-run'], {
|
|
186
|
-
HOME: '/tmp/test-home',
|
|
187
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
188
|
-
SSH_AUTH_SOCK: undefined,
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
expect(exitCode).toBe(0)
|
|
192
|
-
expect(stdout).toContain('Dry run')
|
|
193
|
-
})
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
describe('--force-server-key --dry-run', () => {
|
|
197
|
-
it('includes --force-server-key in the planned command', async () => {
|
|
198
|
-
const {stdout, exitCode} = await runDeployCommand(['--local', '--force-server-key', '--dry-run'], {
|
|
199
|
-
HOME: '/tmp/test-home',
|
|
200
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
201
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
expect(exitCode).toBe(0)
|
|
205
|
-
expect(stdout).toContain('--force-server-key')
|
|
206
|
-
})
|
|
207
|
-
})
|
|
208
|
-
})
|