@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.9.18",
3
+ "version": "0.10.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -31,6 +31,7 @@
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.2.0",
33
33
  "@goke/mcp": "^0.0.10",
34
+ "@marcusrbrown/infra-shared": "workspace:*",
34
35
  "goke": "^6.3.2",
35
36
  "string-dedent": "^3.0.2",
36
37
  "zod": "^4.3.6"
@@ -143,9 +143,42 @@ Commands:
143
143
  --key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
144
144
 
145
145
 
146
+ vpn status Show operational health of the VPN box via SSH and wg show.
147
+
148
+ --key [key] Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.
149
+
150
+
151
+ vpn deploy Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.
152
+
153
+ --local Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions. (default: false)
154
+ --dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
155
+ --force-server-key Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs. (default: false)
156
+
157
+
158
+ vpn logs Stream WireGuard VPN logs via SSH and journalctl.
159
+
160
+ --tail [n] Number of log lines to tail. (default: 100)
161
+ --allow-ci Allow log streaming in CI environments. Logs may contain sensitive VPN data. (default: false)
162
+ --key [key] Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.
163
+
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/.
166
+
167
+ --split-tunnel [cidrs] Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).
168
+ --dns [dns] DNS server for the client config.
169
+ --server-pubkey [key] Server public key. Falls back to VPN_SERVER_PUBKEY env var.
170
+ --endpoint [host] VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.
171
+
172
+
173
+ vpn client list List all VPN client peers from peers.json.
174
+
175
+
176
+ vpn client remove <name> Remove a VPN client peer from peers.json. Run \`infra vpn deploy\` to revoke access.
177
+
178
+
146
179
  status Show status of all deployments
147
180
 
148
- --json Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.
181
+ --json Output machine-readable JSON with keeweb, cliproxy, gateway, umami, and vpn summary objects.
149
182
  --verbose Include verbose per-app health check details when building the summary rows.
150
183
 
151
184
 
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import {registerKeewebCommands} from './commands/keeweb'
8
8
  import {registerMcp} from './commands/mcp'
9
9
  import {registerStatus} from './commands/status'
10
10
  import {registerUmamiCommands} from './commands/umami'
11
+ import {registerVpnCommands} from './commands/vpn'
11
12
 
12
13
  declare const process: {
13
14
  argv: string[]
@@ -22,6 +23,7 @@ registerKeewebCommands(cli)
22
23
  registerCliproxyCommands(cli)
23
24
  registerGatewayCommands(cli)
24
25
  registerUmamiCommands(cli)
26
+ registerVpnCommands(cli)
25
27
  registerStatus(cli)
26
28
  registerMcp(cli)
27
29
 
@@ -35,15 +35,23 @@ import {registerKeewebCommands} from './keeweb'
35
35
  import {MCP_ALLOWLIST, registerMcp} from './mcp'
36
36
  import {registerStatus} from './status'
37
37
  import {registerUmamiCommands} from './umami'
38
+ import {registerVpnCommands} from './vpn'
38
39
 
39
40
  // ─── Tool name constants ──────────────────────────────────────────────────────
40
41
 
41
42
  /**
42
43
  * The MCP tool surface, pinned as an INDEPENDENT literal (deliberately NOT
43
44
  * derived from MCP_ALLOWLIST) so allowlist drift surfaces as a test failure —
44
- * per the Tier-1 test bar in packages/cli/AGENTS.md. Five read-only status tools.
45
+ * per the Tier-1 test bar in packages/cli/AGENTS.md. Six read-only status tools.
45
46
  */
46
- const EXPECTED_TOOLS = ['cliproxy_status', 'gateway_status', 'keeweb_status', 'status', 'umami_status'].sort()
47
+ const EXPECTED_TOOLS = [
48
+ 'cliproxy_status',
49
+ 'gateway_status',
50
+ 'keeweb_status',
51
+ 'status',
52
+ 'umami_status',
53
+ 'vpn_status',
54
+ ].sort()
47
55
 
48
56
  /**
49
57
  * CLI-only commands that must NOT appear in the MCP tool list: the
@@ -70,6 +78,12 @@ const CLI_ONLY_TOOLS = [
70
78
  'cliproxy_keys_remove',
71
79
  'cliproxy_config_get',
72
80
  'cliproxy_config_set',
81
+ // VPN: mutating / sensitive / log-streaming — CLI-only
82
+ 'vpn_deploy',
83
+ 'vpn_logs',
84
+ 'vpn_client_add',
85
+ 'vpn_client_list',
86
+ 'vpn_client_remove',
73
87
  ].sort()
74
88
 
75
89
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -81,6 +95,7 @@ function buildTestCli(): ReturnType<typeof goke> {
81
95
  registerCliproxyCommands(cli)
82
96
  registerGatewayCommands(cli)
83
97
  registerUmamiCommands(cli)
98
+ registerVpnCommands(cli)
84
99
  registerStatus(cli)
85
100
  registerMcp(cli)
86
101
  return cli
@@ -24,6 +24,11 @@ import {createMcpAction} from '@goke/mcp'
24
24
  * - `cliproxy keys remove` — mutating: revokes live bearer tokens on the proxy; CLI-only
25
25
  * - `cliproxy config get` — secret-disclosing: dumps management config incl. management key; CLI-only
26
26
  * - `cliproxy config set` — mutating: overwrites CLIProxyAPI runtime config; CLI-only
27
+ * - `vpn deploy` — mutating: deploys WireGuard config to live VPN box; CLI-only
28
+ * - `vpn logs` — sensitive: streams journalctl logs that may reveal peer IPs/traffic; CLI-only
29
+ * - `vpn client add` — mutating: generates keypair + appends peer + triggers redeploy; CLI-only
30
+ * - `vpn client list` — sensitive: lists peer public keys and tunnel IPs; CLI-only
31
+ * - `vpn client remove` — mutating: removes peer + triggers redeploy; CLI-only
27
32
  *
28
33
  * A defense-in-depth backstop also denies these in opencode.jsonc `permission`
29
34
  * so that even if MCP_ALLOWLIST were mistakenly re-expanded, opencode's native
@@ -35,6 +40,7 @@ export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
35
40
  'cliproxy status',
36
41
  'keeweb status',
37
42
  'umami status',
43
+ 'vpn status',
38
44
  'status',
39
45
  ])
40
46
 
@@ -40,12 +40,22 @@ const healthyUmami: StatusSummary = {
40
40
  usageStats: '—',
41
41
  }
42
42
 
43
+ const healthyVpn: StatusSummary = {
44
+ app: 'vpn',
45
+ http: 'OK: wg0 up, pubkey:TESTKEY== 1 peer(s)',
46
+ lastDeploy: '—',
47
+ version: '—',
48
+ contentHash: '—',
49
+ usageStats: '—',
50
+ }
51
+
43
52
  function makeDeps(overrides?: Partial<Parameters<typeof registerStatus>[1]>): Parameters<typeof registerStatus>[1] {
44
53
  return {
45
54
  getKeewebStatusSummary: async () => healthyKeeweb,
46
55
  getCliproxyStatusSummary: async () => healthyCliproxy,
47
56
  getGatewayStatusSummary: async () => healthyGateway,
48
57
  getUmamiStatusSummary: async () => healthyUmami,
58
+ getVpnStatusSummary: async () => healthyVpn,
49
59
  ...overrides,
50
60
  }
51
61
  }
@@ -63,6 +73,40 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
63
73
  expect(expectCapturedToInclude(captured, '| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')).toBe(true)
64
74
  expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
65
75
  expect(expectCapturedToInclude(captured, '| umami | OK: umami:running/healthy | — | — | — | — |')).toBe(true)
76
+ expect(expectCapturedToInclude(captured, '| vpn | OK: wg0 up, pubkey:TESTKEY== 1 peer(s) | — | — | — | — |')).toBe(
77
+ true,
78
+ )
79
+ })
80
+
81
+ it('includes a vpn row sourced from getVpnStatusSummary()', async () => {
82
+ const {ctx, captured} = createCapturedCtx()
83
+
84
+ await unifiedStatusAction({}, ctx, makeDeps())
85
+
86
+ // The vpn row must appear in the table output
87
+ expect(expectCapturedToInclude(captured, '| vpn |')).toBe(true)
88
+ })
89
+
90
+ it('shows an error row for vpn when getVpnStatusSummary rejects', async () => {
91
+ const {ctx, captured} = createCapturedCtx()
92
+
93
+ await unifiedStatusAction(
94
+ {},
95
+ ctx,
96
+ makeDeps({
97
+ getVpnStatusSummary: async () => {
98
+ throw new Error('VPN_HOST not set')
99
+ },
100
+ }),
101
+ )
102
+
103
+ expect(
104
+ expectCapturedToInclude(
105
+ captured,
106
+ '| vpn | ❌ VPN_HOST not set | ❌ VPN_HOST not set | ❌ VPN_HOST not set | ❌ VPN_HOST not set | ❌ VPN_HOST not set |',
107
+ ),
108
+ ).toBe(true)
109
+ expect(captured.stderr.join('')).toContain('vpn status check failed: VPN_HOST not set')
66
110
  })
67
111
 
68
112
  it('shows an error row when umami is unreachable and keeps the other results', async () => {
@@ -127,12 +171,14 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
127
171
  cliproxy: {version: string}
128
172
  gateway: {http: string}
129
173
  umami: {http: string}
174
+ vpn: {http: string}
130
175
  }
131
176
 
132
177
  expect(parsed.keeweb.http).toBe('OK')
133
178
  expect(parsed.cliproxy.version).toBe('v1.2.3')
134
179
  expect(parsed.gateway.http).toBe('OK: gateway:running/healthy')
135
180
  expect(parsed.umami.http).toBe('OK: umami:running/healthy')
181
+ expect(parsed.vpn.http).toBe('OK: wg0 up, pubkey:TESTKEY== 1 peer(s)')
136
182
  })
137
183
 
138
184
  it('does not write to global console (output is captured via ctx)', async () => {
@@ -8,13 +8,14 @@ import {getCliproxyStatusSummary} from './cliproxy/status'
8
8
  import {getGatewayStatusSummary} from './gateway'
9
9
  import {getKeewebStatusSummary} from './keeweb/status'
10
10
  import {getUmamiStatusSummary} from './umami'
11
+ import {getVpnStatusSummary} from './vpn'
11
12
 
12
13
  declare const process: {
13
14
  env: Record<string, string | undefined>
14
15
  }
15
16
 
16
17
  export interface StatusSummary {
17
- app: 'keeweb' | 'cliproxy' | 'gateway' | 'umami'
18
+ app: 'keeweb' | 'cliproxy' | 'gateway' | 'umami' | 'vpn'
18
19
  http: string
19
20
  lastDeploy: string
20
21
  version: string
@@ -29,6 +30,7 @@ interface StatusDependencies {
29
30
  getCliproxyStatusSummary: (baseUrl: string, key: string, verbose: boolean) => Promise<StatusSummary>
30
31
  getGatewayStatusSummary: (host: string) => Promise<StatusSummary>
31
32
  getUmamiStatusSummary: (host: string) => Promise<StatusSummary>
33
+ getVpnStatusSummary: (host: string) => Promise<StatusSummary>
32
34
  }
33
35
 
34
36
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
@@ -66,6 +68,7 @@ function toJsonPayload(rows: StatusSummary[]): Record<AppName, StatusSummary> {
66
68
  cliproxy: rows.find(row => row.app === 'cliproxy') ?? errorSummary('cliproxy', 'missing result'),
67
69
  gateway: rows.find(row => row.app === 'gateway') ?? errorSummary('gateway', 'missing result'),
68
70
  umami: rows.find(row => row.app === 'umami') ?? errorSummary('umami', 'missing result'),
71
+ vpn: rows.find(row => row.app === 'vpn') ?? errorSummary('vpn', 'missing result'),
69
72
  }
70
73
  }
71
74
 
@@ -82,6 +85,7 @@ export async function unifiedStatusAction(
82
85
  getCliproxyStatusSummary,
83
86
  getGatewayStatusSummary,
84
87
  getUmamiStatusSummary,
88
+ getVpnStatusSummary,
85
89
  },
86
90
  ): Promise<void> {
87
91
  const verbose = options.verbose === true
@@ -89,15 +93,17 @@ export async function unifiedStatusAction(
89
93
  const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
90
94
  const gatewayHost = process.env.GATEWAY_HOST ?? ''
91
95
  const umamiHost = process.env.UMAMI_DOMAIN ?? ''
96
+ const vpnHost = process.env.VPN_HOST ?? ''
92
97
 
93
98
  const results = await Promise.allSettled([
94
99
  dependencies.getKeewebStatusSummary(verbose),
95
100
  dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
96
101
  dependencies.getGatewayStatusSummary(gatewayHost),
97
102
  dependencies.getUmamiStatusSummary(umamiHost),
103
+ dependencies.getVpnStatusSummary(vpnHost),
98
104
  ])
99
105
 
100
- const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway', 'umami']
106
+ const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway', 'umami', 'vpn']
101
107
  const rows: StatusSummary[] = results.map((result, index) => {
102
108
  const app = appNames[index] ?? 'keeweb'
103
109
  if (result.status === 'fulfilled') {
@@ -127,13 +133,16 @@ export function registerStatus(
127
133
  getCliproxyStatusSummary,
128
134
  getGatewayStatusSummary,
129
135
  getUmamiStatusSummary,
136
+ getVpnStatusSummary,
130
137
  },
131
138
  ): void {
132
139
  cli
133
140
  .command('status', 'Show status of all deployments')
134
141
  .option(
135
142
  '--json',
136
- z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.'),
143
+ z
144
+ .boolean()
145
+ .describe('Output machine-readable JSON with keeweb, cliproxy, gateway, umami, and vpn summary objects.'),
137
146
  )
138
147
  .option(
139
148
  '--verbose',
@@ -0,0 +1,379 @@
1
+ import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
2
+ import {readFile} from 'node:fs/promises'
3
+ import {tmpdir} from 'node:os'
4
+ import {join, resolve} from 'node:path'
5
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
6
+
7
+ import {vpnClientAdd, vpnClientList, vpnClientRemove, type KeypairGenFn} from './client'
8
+
9
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
10
+
11
+ function makeKeypairGen(privateKey: string, publicKey: string): KeypairGenFn {
12
+ return async () => ({privateKey, publicKey})
13
+ }
14
+
15
+ function makePeersJson(peers: {name: string; publicKey: string; tunnelIp: string}[]): string {
16
+ return `${JSON.stringify({peers}, null, 2)}\n`
17
+ }
18
+
19
+ // ─── generateKeypair ─────────────────────────────────────────────────────────
20
+
21
+ describe('generateKeypair', () => {
22
+ it('returns an object with privateKey and publicKey strings via injected keypairGen', async () => {
23
+ // Use dependency injection — never depends on host `wg` binary.
24
+ const mockPrivateKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
25
+ const mockPublicKey = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
26
+ const keypairGen = makeKeypairGen(mockPrivateKey, mockPublicKey)
27
+
28
+ // vpnClientAdd uses keypairGen internally; test the injected path directly
29
+ // by calling the mock and asserting the returned shape
30
+ const keypair = await keypairGen()
31
+
32
+ expect(typeof keypair.privateKey).toBe('string')
33
+ expect(typeof keypair.publicKey).toBe('string')
34
+ expect(keypair.privateKey).toBe(mockPrivateKey)
35
+ expect(keypair.publicKey).toBe(mockPublicKey)
36
+ })
37
+
38
+ it('vpnClientAdd uses the injected keypairGen — no dependency on host wg binary', async () => {
39
+ // This test verifies that vpnClientAdd correctly uses the injected keypairGen
40
+ // and returns the keypair values from it (not from a real wg invocation).
41
+ const {mkdtempSync: mkdtemp, mkdirSync, writeFileSync: writeFile, rmSync: rm} = await import('node:fs')
42
+ const {tmpdir} = await import('node:os')
43
+ const {join: joinPath} = await import('node:path')
44
+
45
+ const tmpDir = mkdtemp(joinPath(tmpdir(), 'vpn-keypair-test-'))
46
+ const peersJsonPath = joinPath(tmpDir, 'peers.json')
47
+ const clientsDir = joinPath(tmpDir, 'clients')
48
+ mkdirSync(clientsDir, {recursive: true})
49
+ writeFile(peersJsonPath, `${JSON.stringify({peers: []}, null, 2)}\n`)
50
+
51
+ try {
52
+ const injectedPrivateKey = 'INJECTEDPRIVATEKEYVALUE1234567890123456789='
53
+ const injectedPublicKey = 'INJECTEDPUBLICKEYVALUE12345678901234567890='
54
+ const keypairGen = makeKeypairGen(injectedPrivateKey, injectedPublicKey)
55
+
56
+ const result = await vpnClientAdd('testdevice', {
57
+ peersJsonPath,
58
+ clientsDir,
59
+ serverPublicKey: 'SERVERPUBKEY==',
60
+ endpoint: '1.2.3.4',
61
+ keypairGen,
62
+ })
63
+
64
+ // The result must use the injected keypair values
65
+ expect(result.tunnelIp).toBe('10.8.0.2')
66
+
67
+ // Client .conf must contain the injected private key
68
+ const {readFile} = await import('node:fs/promises')
69
+ const confContent = await readFile(result.confPath, 'utf-8')
70
+ expect(confContent).toContain(injectedPrivateKey)
71
+
72
+ // peers.json must contain the injected public key
73
+ const peersContent = await readFile(peersJsonPath, 'utf-8')
74
+ expect(peersContent).toContain(injectedPublicKey)
75
+ expect(peersContent).not.toContain(injectedPrivateKey)
76
+ } finally {
77
+ rm(tmpDir, {recursive: true, force: true})
78
+ }
79
+ })
80
+ })
81
+
82
+ // ─── vpnClientAdd ─────────────────────────────────────────────────────────────
83
+
84
+ describe('vpnClientAdd', () => {
85
+ let tmpDir: string
86
+ let peersJsonPath: string
87
+ let clientsDir: string
88
+
89
+ beforeEach(() => {
90
+ tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
91
+ peersJsonPath = join(tmpDir, 'peers.json')
92
+ clientsDir = join(tmpDir, 'clients')
93
+ mkdirSync(clientsDir, {recursive: true})
94
+
95
+ // Start with empty peers.json
96
+ writeFileSync(peersJsonPath, makePeersJson([]))
97
+ })
98
+
99
+ afterEach(() => {
100
+ rmSync(tmpDir, {recursive: true, force: true})
101
+ })
102
+
103
+ it('generates a keypair, assigns next IP, appends public key to peers.json, writes client .conf', async () => {
104
+ const keypairGen = makeKeypairGen('PRIVATEKEYVALUE==', 'PUBLICKEYVALUE==')
105
+
106
+ const result = await vpnClientAdd('laptop', {
107
+ peersJsonPath,
108
+ clientsDir,
109
+ serverPublicKey: 'SERVERPUBKEY==',
110
+ endpoint: '1.2.3.4',
111
+ keypairGen,
112
+ })
113
+
114
+ // Check result
115
+ expect(result.tunnelIp).toBe('10.8.0.2')
116
+ expect(result.confPath).toBe(join(clientsDir, 'laptop.conf'))
117
+
118
+ // Check peers.json was updated with PUBLIC key only
119
+ const peersContent = await readFile(peersJsonPath, 'utf-8')
120
+ const peersData = JSON.parse(peersContent) as {peers: {name: string; publicKey: string; tunnelIp: string}[]}
121
+ expect(peersData.peers).toHaveLength(1)
122
+ expect(peersData.peers[0]?.name).toBe('laptop')
123
+ expect(peersData.peers[0]?.publicKey).toBe('PUBLICKEYVALUE==')
124
+ expect(peersData.peers[0]?.tunnelIp).toBe('10.8.0.2')
125
+
126
+ // Check client .conf was written with PRIVATE key
127
+ const confContent = await readFile(result.confPath, 'utf-8')
128
+ expect(confContent).toContain('PRIVATEKEYVALUE==')
129
+ expect(confContent).toContain('SERVERPUBKEY==')
130
+ expect(confContent).toContain('1.2.3.4:51820')
131
+ expect(confContent).toContain('10.8.0.2/32')
132
+ expect(confContent).toContain('AllowedIPs = 0.0.0.0/0')
133
+ })
134
+
135
+ it('assigns sequential IPs for multiple clients', async () => {
136
+ const keypairGen1 = makeKeypairGen('PRIV1==', 'PUB1==')
137
+ const keypairGen2 = makeKeypairGen('PRIV2==', 'PUB2==')
138
+
139
+ await vpnClientAdd('laptop', {
140
+ peersJsonPath,
141
+ clientsDir,
142
+ serverPublicKey: 'SERVERPUBKEY==',
143
+ endpoint: '1.2.3.4',
144
+ keypairGen: keypairGen1,
145
+ })
146
+
147
+ const result2 = await vpnClientAdd('phone', {
148
+ peersJsonPath,
149
+ clientsDir,
150
+ serverPublicKey: 'SERVERPUBKEY==',
151
+ endpoint: '1.2.3.4',
152
+ keypairGen: keypairGen2,
153
+ })
154
+
155
+ expect(result2.tunnelIp).toBe('10.8.0.3')
156
+ })
157
+
158
+ it('supports split-tunnel via allowedIps option', async () => {
159
+ const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
160
+
161
+ const result = await vpnClientAdd('laptop', {
162
+ peersJsonPath,
163
+ clientsDir,
164
+ serverPublicKey: 'SERVERPUBKEY==',
165
+ endpoint: '1.2.3.4',
166
+ keypairGen,
167
+ allowedIps: '10.0.0.0/8,192.168.0.0/16',
168
+ })
169
+
170
+ const confContent = await readFile(result.confPath, 'utf-8')
171
+ expect(confContent).toContain('AllowedIPs = 10.0.0.0/8,192.168.0.0/16')
172
+ })
173
+
174
+ it('throws when a peer with the same name already exists', async () => {
175
+ const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
176
+
177
+ await vpnClientAdd('laptop', {
178
+ peersJsonPath,
179
+ clientsDir,
180
+ serverPublicKey: 'SERVERPUBKEY==',
181
+ endpoint: '1.2.3.4',
182
+ keypairGen,
183
+ })
184
+
185
+ await expect(
186
+ vpnClientAdd('laptop', {
187
+ peersJsonPath,
188
+ clientsDir,
189
+ serverPublicKey: 'SERVERPUBKEY==',
190
+ endpoint: '1.2.3.4',
191
+ keypairGen,
192
+ }),
193
+ ).rejects.toThrow('already exists')
194
+ })
195
+
196
+ it('refuses to write client .conf outside the clients directory', async () => {
197
+ const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
198
+
199
+ // Attempt path traversal: name with ../ to escape clients dir
200
+ await expect(
201
+ vpnClientAdd('../evil', {
202
+ peersJsonPath,
203
+ clientsDir,
204
+ serverPublicKey: 'SERVERPUBKEY==',
205
+ endpoint: '1.2.3.4',
206
+ keypairGen,
207
+ }),
208
+ ).rejects.toThrow()
209
+ })
210
+
211
+ it('client .conf contains private key but peers.json contains only public key', async () => {
212
+ const keypairGen = makeKeypairGen('SECRETPRIVATEKEY==', 'PUBLICKEY==')
213
+
214
+ const result = await vpnClientAdd('laptop', {
215
+ peersJsonPath,
216
+ clientsDir,
217
+ serverPublicKey: 'SERVERPUBKEY==',
218
+ endpoint: '1.2.3.4',
219
+ keypairGen,
220
+ })
221
+
222
+ // peers.json must NOT contain the private key
223
+ const peersContent = await readFile(peersJsonPath, 'utf-8')
224
+ expect(peersContent).not.toContain('SECRETPRIVATEKEY==')
225
+ expect(peersContent).toContain('PUBLICKEY==')
226
+
227
+ // client .conf must contain the private key
228
+ const confContent = await readFile(result.confPath, 'utf-8')
229
+ expect(confContent).toContain('SECRETPRIVATEKEY==')
230
+ })
231
+ })
232
+
233
+ // ─── vpnClientList ────────────────────────────────────────────────────────────
234
+
235
+ describe('vpnClientList', () => {
236
+ let tmpDir: string
237
+ let peersJsonPath: string
238
+
239
+ beforeEach(() => {
240
+ tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
241
+ peersJsonPath = join(tmpDir, 'peers.json')
242
+ })
243
+
244
+ afterEach(() => {
245
+ rmSync(tmpDir, {recursive: true, force: true})
246
+ })
247
+
248
+ it('returns empty array when no peers', async () => {
249
+ writeFileSync(peersJsonPath, makePeersJson([]))
250
+
251
+ const peers = await vpnClientList(peersJsonPath)
252
+
253
+ expect(peers).toHaveLength(0)
254
+ })
255
+
256
+ it('returns all peers with name, publicKey, tunnelIp', async () => {
257
+ writeFileSync(
258
+ peersJsonPath,
259
+ makePeersJson([
260
+ {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
261
+ {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
262
+ ]),
263
+ )
264
+
265
+ const peers = await vpnClientList(peersJsonPath)
266
+
267
+ expect(peers).toHaveLength(2)
268
+ expect(peers[0]?.name).toBe('laptop')
269
+ expect(peers[0]?.publicKey).toBe('PUB1==')
270
+ expect(peers[0]?.tunnelIp).toBe('10.8.0.2')
271
+ expect(peers[1]?.name).toBe('phone')
272
+ })
273
+ })
274
+
275
+ // ─── vpnClientRemove ──────────────────────────────────────────────────────────
276
+
277
+ describe('vpnClientRemove', () => {
278
+ let tmpDir: string
279
+ let peersJsonPath: string
280
+
281
+ beforeEach(() => {
282
+ tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
283
+ peersJsonPath = join(tmpDir, 'peers.json')
284
+ })
285
+
286
+ afterEach(() => {
287
+ rmSync(tmpDir, {recursive: true, force: true})
288
+ })
289
+
290
+ it('removes a peer from peers.json', async () => {
291
+ writeFileSync(
292
+ peersJsonPath,
293
+ makePeersJson([
294
+ {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
295
+ {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
296
+ ]),
297
+ )
298
+
299
+ await vpnClientRemove('laptop', peersJsonPath)
300
+
301
+ const peersContent = await readFile(peersJsonPath, 'utf-8')
302
+ const peersData = JSON.parse(peersContent) as {peers: {name: string}[]}
303
+ expect(peersData.peers).toHaveLength(1)
304
+ expect(peersData.peers[0]?.name).toBe('phone')
305
+ })
306
+
307
+ it('throws when removing a non-existent peer', async () => {
308
+ writeFileSync(peersJsonPath, makePeersJson([]))
309
+
310
+ await expect(vpnClientRemove('nonexistent', peersJsonPath)).rejects.toThrow('not found')
311
+ })
312
+
313
+ it('after remove, list reflects the removal', async () => {
314
+ writeFileSync(
315
+ peersJsonPath,
316
+ makePeersJson([
317
+ {name: 'laptop', publicKey: 'PUB1==', tunnelIp: '10.8.0.2'},
318
+ {name: 'phone', publicKey: 'PUB2==', tunnelIp: '10.8.0.3'},
319
+ ]),
320
+ )
321
+
322
+ await vpnClientRemove('laptop', peersJsonPath)
323
+ const peers = await vpnClientList(peersJsonPath)
324
+
325
+ expect(peers).toHaveLength(1)
326
+ expect(peers[0]?.name).toBe('phone')
327
+ })
328
+ })
329
+
330
+ // ─── Write-guard: client .conf must stay in clients/ ─────────────────────────
331
+
332
+ describe('write-guard: client .conf path safety', () => {
333
+ let tmpDir: string
334
+ let peersJsonPath: string
335
+ let clientsDir: string
336
+
337
+ beforeEach(() => {
338
+ tmpDir = mkdtempSync(join(tmpdir(), 'vpn-client-test-'))
339
+ peersJsonPath = join(tmpDir, 'peers.json')
340
+ clientsDir = join(tmpDir, 'clients')
341
+ mkdirSync(clientsDir, {recursive: true})
342
+ writeFileSync(peersJsonPath, makePeersJson([]))
343
+ })
344
+
345
+ afterEach(() => {
346
+ rmSync(tmpDir, {recursive: true, force: true})
347
+ })
348
+
349
+ it('resolves the output path under clientsDir', async () => {
350
+ const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
351
+
352
+ const result = await vpnClientAdd('mydevice', {
353
+ peersJsonPath,
354
+ clientsDir,
355
+ serverPublicKey: 'SERVERPUBKEY==',
356
+ endpoint: '1.2.3.4',
357
+ keypairGen,
358
+ })
359
+
360
+ // The resolved path must be under clientsDir
361
+ const resolvedClientsDir = resolve(clientsDir)
362
+ expect(result.confPath.startsWith(resolvedClientsDir)).toBe(true)
363
+ })
364
+
365
+ it('rejects a name that would escape the clients directory via path traversal', async () => {
366
+ const keypairGen = makeKeypairGen('PRIV==', 'PUB==')
367
+
368
+ // Path traversal attempt
369
+ await expect(
370
+ vpnClientAdd('../../etc/passwd', {
371
+ peersJsonPath,
372
+ clientsDir,
373
+ serverPublicKey: 'SERVERPUBKEY==',
374
+ endpoint: '1.2.3.4',
375
+ keypairGen,
376
+ }),
377
+ ).rejects.toThrow()
378
+ })
379
+ })