@marcusrbrown/infra 0.9.17 → 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcusrbrown/infra",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
|
package/src/commands/mcp.test.ts
CHANGED
|
@@ -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.
|
|
45
|
+
* per the Tier-1 test bar in packages/cli/AGENTS.md. Six read-only status tools.
|
|
45
46
|
*/
|
|
46
|
-
const EXPECTED_TOOLS = [
|
|
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
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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 () => {
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|