@marcusrbrown/infra 0.9.18 → 0.10.0
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 +2 -1
- package/src/__snapshots__/cli.test.ts.snap +34 -1
- package/src/cli.ts +2 -0
- package/src/commands/mcp.test.ts +17 -2
- package/src/commands/mcp.ts +6 -0
- package/src/commands/status.test.ts +46 -0
- package/src/commands/status.ts +12 -3
- package/src/commands/vpn/client.test.ts +379 -0
- package/src/commands/vpn/client.ts +313 -0
- package/src/commands/vpn/deploy.test.ts +208 -0
- package/src/commands/vpn/deploy.ts +143 -0
- package/src/commands/vpn/host.test.ts +89 -0
- package/src/commands/vpn/host.ts +36 -0
- package/src/commands/vpn/index.ts +15 -0
- package/src/commands/vpn/logs.test.ts +234 -0
- package/src/commands/vpn/logs.ts +136 -0
- package/src/commands/vpn/shared.test.ts +334 -0
- package/src/commands/vpn/shared.ts +158 -0
- package/src/commands/vpn/status.test.ts +142 -0
- package/src/commands/vpn/status.ts +78 -0
- package/src/conventions.test.ts +12 -1
- package/src/resources/known_hosts +18 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {mkdir, writeFile} from 'node:fs/promises'
|
|
4
|
+
import {resolve} from 'node:path'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
addPeer,
|
|
8
|
+
readPeers,
|
|
9
|
+
removePeer,
|
|
10
|
+
renderClientConfig,
|
|
11
|
+
writePeers,
|
|
12
|
+
type Peer,
|
|
13
|
+
} from '@marcusrbrown/infra-shared/vpn/peers'
|
|
14
|
+
import {z} from 'zod'
|
|
15
|
+
|
|
16
|
+
declare const process: {
|
|
17
|
+
env: Record<string, string | undefined>
|
|
18
|
+
exitCode?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface Keypair {
|
|
24
|
+
privateKey: string
|
|
25
|
+
publicKey: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type KeypairGenFn = () => Promise<Keypair>
|
|
29
|
+
|
|
30
|
+
export interface VpnClientAddOpts {
|
|
31
|
+
peersJsonPath: string
|
|
32
|
+
clientsDir: string
|
|
33
|
+
serverPublicKey: string
|
|
34
|
+
endpoint: string
|
|
35
|
+
keypairGen?: KeypairGenFn
|
|
36
|
+
allowedIps?: string
|
|
37
|
+
dns?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VpnClientAddResult {
|
|
41
|
+
tunnelIp: string
|
|
42
|
+
confPath: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── WireGuard keypair generation ─────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a WireGuard keypair using the local `wg` binary.
|
|
49
|
+
*
|
|
50
|
+
* Assumption: `wg` is installed locally (wireguard-tools package).
|
|
51
|
+
* The private key is generated locally and written ONLY to the gitignored
|
|
52
|
+
* clients/ directory — it never leaves the local machine.
|
|
53
|
+
*/
|
|
54
|
+
export async function generateKeypair(): Promise<Keypair> {
|
|
55
|
+
// Step 1: generate private key
|
|
56
|
+
const genkeyProc = Bun.spawn(['wg', 'genkey'], {
|
|
57
|
+
stdout: 'pipe',
|
|
58
|
+
stderr: 'pipe',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const [privateKeyRaw, genkeyStderr, genkeyExit] = await Promise.all([
|
|
62
|
+
new Response(genkeyProc.stdout).text(),
|
|
63
|
+
new Response(genkeyProc.stderr).text(),
|
|
64
|
+
genkeyProc.exited,
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
if (genkeyExit !== 0) {
|
|
68
|
+
throw new Error(`wg genkey failed (exit ${genkeyExit}): ${genkeyStderr.trim()}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const privateKey = privateKeyRaw.trim()
|
|
72
|
+
|
|
73
|
+
// Step 2: derive public key from private key
|
|
74
|
+
const encoder = new TextEncoder()
|
|
75
|
+
const pubkeyProc = Bun.spawn(['wg', 'pubkey'], {
|
|
76
|
+
stdin: new ReadableStream({
|
|
77
|
+
start(controller) {
|
|
78
|
+
controller.enqueue(encoder.encode(`${privateKey}\n`))
|
|
79
|
+
controller.close()
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
stdout: 'pipe',
|
|
83
|
+
stderr: 'pipe',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const [publicKeyRaw, pubkeyStderr, pubkeyExit] = await Promise.all([
|
|
87
|
+
new Response(pubkeyProc.stdout).text(),
|
|
88
|
+
new Response(pubkeyProc.stderr).text(),
|
|
89
|
+
pubkeyProc.exited,
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
if (pubkeyExit !== 0) {
|
|
93
|
+
throw new Error(`wg pubkey failed (exit ${pubkeyExit}): ${pubkeyStderr.trim()}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
privateKey,
|
|
98
|
+
publicKey: publicKeyRaw.trim(),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Write-guard ──────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate that the resolved output path is under the expected clients directory.
|
|
106
|
+
*
|
|
107
|
+
* This is a runtime guard (not gitignore-only) that refuses to write client
|
|
108
|
+
* .conf files (which contain private keys) outside the gitignored clients/ dir.
|
|
109
|
+
*
|
|
110
|
+
* @throws {Error} if the resolved path escapes the clients directory.
|
|
111
|
+
*/
|
|
112
|
+
function assertPathUnderClientsDir(confPath: string, clientsDir: string): void {
|
|
113
|
+
const resolvedClientsDir = resolve(clientsDir)
|
|
114
|
+
const resolvedConfPath = resolve(confPath)
|
|
115
|
+
|
|
116
|
+
if (!resolvedConfPath.startsWith(`${resolvedClientsDir}/`) && resolvedConfPath !== resolvedClientsDir) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Security: refusing to write client .conf outside the clients directory. ` +
|
|
119
|
+
`Resolved path "${resolvedConfPath}" is not under "${resolvedClientsDir}". ` +
|
|
120
|
+
`Client private keys must only be written to the gitignored clients/ directory.`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Core operations ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Add a new VPN client peer.
|
|
129
|
+
*
|
|
130
|
+
* 1. Generates a WireGuard keypair locally (via `wg genkey` / `wg pubkey`).
|
|
131
|
+
* 2. Assigns the next available tunnel IP via addPeer.
|
|
132
|
+
* 3. Appends the PUBLIC key + tunnel IP to peers.json.
|
|
133
|
+
* 4. Writes the client .conf (with PRIVATE key) to clients/<name>.conf.
|
|
134
|
+
*
|
|
135
|
+
* Security invariants:
|
|
136
|
+
* - The client PRIVATE key is written ONLY to the gitignored clients/ directory.
|
|
137
|
+
* - peers.json contains ONLY the public key — never the private key.
|
|
138
|
+
* - The output path is validated before writing (runtime guard, not gitignore-only).
|
|
139
|
+
*/
|
|
140
|
+
export async function vpnClientAdd(name: string, opts: VpnClientAddOpts): Promise<VpnClientAddResult> {
|
|
141
|
+
const {peersJsonPath, clientsDir, serverPublicKey, endpoint, allowedIps, dns} = opts
|
|
142
|
+
const keypairGen = opts.keypairGen ?? generateKeypair
|
|
143
|
+
|
|
144
|
+
// Validate the output path before doing any work
|
|
145
|
+
const confPath = resolve(clientsDir, `${name}.conf`)
|
|
146
|
+
assertPathUnderClientsDir(confPath, clientsDir)
|
|
147
|
+
|
|
148
|
+
// Generate keypair
|
|
149
|
+
const keypair = await keypairGen()
|
|
150
|
+
|
|
151
|
+
// Read current peers
|
|
152
|
+
const peersFile = await readPeers(peersJsonPath)
|
|
153
|
+
|
|
154
|
+
// Add peer (throws if duplicate name)
|
|
155
|
+
const {peers: updatedPeers, tunnelIp} = addPeer(peersFile.peers, {
|
|
156
|
+
name,
|
|
157
|
+
publicKey: keypair.publicKey,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// Write updated peers.json (PUBLIC key only — never private key)
|
|
161
|
+
await writePeers(peersJsonPath, {peers: updatedPeers})
|
|
162
|
+
|
|
163
|
+
// Render client .conf (contains PRIVATE key)
|
|
164
|
+
const confContent = renderClientConfig({
|
|
165
|
+
clientPrivateKey: keypair.privateKey,
|
|
166
|
+
serverPublicKey,
|
|
167
|
+
endpoint,
|
|
168
|
+
tunnelIp,
|
|
169
|
+
allowedIps,
|
|
170
|
+
dns,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Ensure clients directory exists
|
|
174
|
+
await mkdir(clientsDir, {recursive: true})
|
|
175
|
+
|
|
176
|
+
// Write client .conf to the gitignored clients/ directory
|
|
177
|
+
await writeFile(confPath, confContent, {mode: 0o600})
|
|
178
|
+
|
|
179
|
+
return {tunnelIp, confPath}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all VPN client peers from peers.json.
|
|
184
|
+
*/
|
|
185
|
+
export async function vpnClientList(peersJsonPath: string): Promise<Peer[]> {
|
|
186
|
+
const peersFile = await readPeers(peersJsonPath)
|
|
187
|
+
return peersFile.peers
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Remove a VPN client peer from peers.json.
|
|
192
|
+
*
|
|
193
|
+
* Note: The client .conf file in clients/ is NOT deleted — the operator
|
|
194
|
+
* should delete it manually. The peer is removed from peers.json so the
|
|
195
|
+
* next deploy will revoke access.
|
|
196
|
+
*/
|
|
197
|
+
export async function vpnClientRemove(name: string, peersJsonPath: string): Promise<void> {
|
|
198
|
+
const peersFile = await readPeers(peersJsonPath)
|
|
199
|
+
const {peers: updatedPeers} = removePeer(peersFile.peers, name)
|
|
200
|
+
await writePeers(peersJsonPath, {peers: updatedPeers})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Default paths ────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function resolveDefaultPeersJsonPath(): string {
|
|
206
|
+
// packages/cli/src/commands/vpn/ → repo root → apps/vpn/config/peers.json
|
|
207
|
+
return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'config', 'peers.json')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveDefaultClientsDir(): string {
|
|
211
|
+
// packages/cli/src/commands/vpn/ → repo root → apps/vpn/clients/
|
|
212
|
+
return resolve(import.meta.dir, '..', '..', '..', '..', '..', 'apps', 'vpn', 'clients')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export function registerVpnClient(cli: ReturnType<typeof goke>): void {
|
|
218
|
+
cli
|
|
219
|
+
.command(
|
|
220
|
+
'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/.',
|
|
222
|
+
)
|
|
223
|
+
.option(
|
|
224
|
+
'--split-tunnel [cidrs]',
|
|
225
|
+
z.string().describe('Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).'),
|
|
226
|
+
)
|
|
227
|
+
.option('--dns [dns]', z.string().describe('DNS server for the client config.'))
|
|
228
|
+
.option('--server-pubkey [key]', z.string().describe('Server public key. Falls back to VPN_SERVER_PUBKEY env var.'))
|
|
229
|
+
.option(
|
|
230
|
+
'--endpoint [host]',
|
|
231
|
+
z.string().describe('VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.'),
|
|
232
|
+
)
|
|
233
|
+
.example('# Add a new client named "laptop"')
|
|
234
|
+
.example('infra vpn client add laptop')
|
|
235
|
+
.example('# Add a split-tunnel client')
|
|
236
|
+
.example('infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16')
|
|
237
|
+
.action(async (name, options) => {
|
|
238
|
+
const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY
|
|
239
|
+
const endpoint = options.endpoint ?? process.env.VPN_HOST
|
|
240
|
+
|
|
241
|
+
if (!serverPublicKey) {
|
|
242
|
+
console.error('Server public key is required. Pass --server-pubkey or set VPN_SERVER_PUBKEY.')
|
|
243
|
+
process.exitCode = 1
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!endpoint) {
|
|
248
|
+
console.error('VPN endpoint is required. Pass --endpoint or set VPN_HOST.')
|
|
249
|
+
process.exitCode = 1
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const result = await vpnClientAdd(name as string, {
|
|
255
|
+
peersJsonPath: resolveDefaultPeersJsonPath(),
|
|
256
|
+
clientsDir: resolveDefaultClientsDir(),
|
|
257
|
+
serverPublicKey,
|
|
258
|
+
endpoint,
|
|
259
|
+
allowedIps: options.splitTunnel ?? undefined,
|
|
260
|
+
dns: options.dns ?? undefined,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
console.log(`Client "${name as string}" added.`)
|
|
264
|
+
console.log(` Tunnel IP: ${result.tunnelIp}`)
|
|
265
|
+
console.log(` Config: ${result.confPath}`)
|
|
266
|
+
console.log('')
|
|
267
|
+
console.log('Next step: run `infra vpn deploy` to apply the new peer to the VPN box.')
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
270
|
+
process.exitCode = 1
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
cli.command('vpn client list', 'List all VPN client peers from peers.json.').action(async () => {
|
|
275
|
+
try {
|
|
276
|
+
const peers = await vpnClientList(resolveDefaultPeersJsonPath())
|
|
277
|
+
|
|
278
|
+
if (peers.length === 0) {
|
|
279
|
+
console.log('No peers configured.')
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log('Name Tunnel IP Public Key')
|
|
284
|
+
console.log('─────────────────────────────────────────────────────────')
|
|
285
|
+
for (const peer of peers) {
|
|
286
|
+
const name = peer.name.padEnd(16)
|
|
287
|
+
const ip = peer.tunnelIp.padEnd(15)
|
|
288
|
+
console.log(`${name} ${ip} ${peer.publicKey}`)
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
292
|
+
process.exitCode = 1
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
cli
|
|
297
|
+
.command(
|
|
298
|
+
'vpn client remove <name>',
|
|
299
|
+
'Remove a VPN client peer from peers.json. Run `infra vpn deploy` to revoke access.',
|
|
300
|
+
)
|
|
301
|
+
.example('# Remove a client named "laptop"')
|
|
302
|
+
.example('infra vpn client remove laptop')
|
|
303
|
+
.action(async name => {
|
|
304
|
+
try {
|
|
305
|
+
await vpnClientRemove(name as string, resolveDefaultPeersJsonPath())
|
|
306
|
+
console.log(`Peer "${name as string}" removed from peers.json.`)
|
|
307
|
+
console.log('Next step: run `infra vpn deploy` to revoke access on the VPN box.')
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
310
|
+
process.exitCode = 1
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
5
|
+
declare const process: {
|
|
6
|
+
env: Record<string, string | undefined>
|
|
7
|
+
exitCode?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const REPO = 'marcusrbrown/infra'
|
|
11
|
+
const WORKFLOW_NAME = 'Deploy VPN'
|
|
12
|
+
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml'
|
|
13
|
+
|
|
14
|
+
type CliInstance = ReturnType<typeof goke>
|
|
15
|
+
|
|
16
|
+
// ─── Env helpers ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export function getVpnDeployEnv(): Record<string, string> {
|
|
19
|
+
const path = process.env.PATH
|
|
20
|
+
const home = process.env.HOME
|
|
21
|
+
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
22
|
+
|
|
23
|
+
if (!path) {
|
|
24
|
+
throw new Error('PATH is required for local deploy')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!home) {
|
|
28
|
+
throw new Error('HOME is required for local deploy')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!sshAuthSock) {
|
|
32
|
+
throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
PATH: path,
|
|
37
|
+
HOME: home,
|
|
38
|
+
SSH_AUTH_SOCK: sshAuthSock,
|
|
39
|
+
VPN_HOST: process.env.VPN_HOST ?? '',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateVpnRemotePreconditions(): void {
|
|
44
|
+
if (!Bun.which('gh')) {
|
|
45
|
+
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function registerVpnDeploy(cli: CliInstance): void {
|
|
52
|
+
cli
|
|
53
|
+
.command(
|
|
54
|
+
'vpn deploy',
|
|
55
|
+
'Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.',
|
|
56
|
+
)
|
|
57
|
+
.option(
|
|
58
|
+
'--local',
|
|
59
|
+
z
|
|
60
|
+
.boolean()
|
|
61
|
+
.default(false)
|
|
62
|
+
.describe('Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.'),
|
|
63
|
+
)
|
|
64
|
+
.option(
|
|
65
|
+
'--dry-run',
|
|
66
|
+
z
|
|
67
|
+
.boolean()
|
|
68
|
+
.default(false)
|
|
69
|
+
.describe(
|
|
70
|
+
'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
.option(
|
|
74
|
+
'--force-server-key',
|
|
75
|
+
z
|
|
76
|
+
.boolean()
|
|
77
|
+
.default(false)
|
|
78
|
+
.describe(
|
|
79
|
+
'Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs.',
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
.example('# Trigger remote GitHub Actions deploy (default mode)')
|
|
83
|
+
.example('infra vpn deploy')
|
|
84
|
+
.example('# Validate local deploy preconditions and planned command')
|
|
85
|
+
.example('infra vpn deploy --local --dry-run')
|
|
86
|
+
.example('# Run local deploy with explicit SSH agent context')
|
|
87
|
+
.example('infra vpn deploy --local')
|
|
88
|
+
.action(async options => {
|
|
89
|
+
if (options.local) {
|
|
90
|
+
const command = [
|
|
91
|
+
'bun',
|
|
92
|
+
'run',
|
|
93
|
+
'--cwd',
|
|
94
|
+
'apps/vpn',
|
|
95
|
+
'deploy',
|
|
96
|
+
...(options.forceServerKey ? ['--force-server-key'] : []),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
if (options.dryRun) {
|
|
100
|
+
console.log('Dry run: local vpn deploy')
|
|
101
|
+
console.log(`- command: ${command.join(' ')}`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const env = getVpnDeployEnv()
|
|
106
|
+
|
|
107
|
+
const child = Bun.spawn(command, {
|
|
108
|
+
env,
|
|
109
|
+
stdout: 'inherit',
|
|
110
|
+
stderr: 'inherit',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const exitCode = await child.exited
|
|
114
|
+
if (exitCode !== 0) {
|
|
115
|
+
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
validateVpnRemotePreconditions()
|
|
122
|
+
|
|
123
|
+
if (options.dryRun) {
|
|
124
|
+
console.log('Dry run: remote vpn deploy')
|
|
125
|
+
console.log(`- command: gh workflow run "${WORKFLOW_NAME}" --repo ${REPO}`)
|
|
126
|
+
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
131
|
+
stdout: 'inherit',
|
|
132
|
+
stderr: 'inherit',
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const exitCode = await child.exited
|
|
136
|
+
if (exitCode !== 0) {
|
|
137
|
+
throw new Error(`Failed to trigger "${WORKFLOW_NAME}" workflow (exit code ${exitCode})`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
141
|
+
console.log('Approve the vpn environment deployment in GitHub Actions to continue.')
|
|
142
|
+
})
|
|
143
|
+
}
|