@marcusrbrown/infra 0.13.1 → 0.13.3
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 +11965 -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,478 +0,0 @@
|
|
|
1
|
-
/// <reference types="bun" />
|
|
2
|
-
|
|
3
|
-
import {mkdtempSync, rmSync, statSync, writeFileSync} from 'node:fs'
|
|
4
|
-
import {tmpdir} from 'node:os'
|
|
5
|
-
import {join} from 'node:path'
|
|
6
|
-
import {describe, expect, it} from 'bun:test'
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
addPeer,
|
|
10
|
-
nextTunnelIp,
|
|
11
|
-
parsePeersJson,
|
|
12
|
-
readPeersOrEmpty,
|
|
13
|
-
removePeer,
|
|
14
|
-
renderClientConfig,
|
|
15
|
-
renderServerConfig,
|
|
16
|
-
writePeers,
|
|
17
|
-
type Peer,
|
|
18
|
-
type PeersFile,
|
|
19
|
-
} from './peers'
|
|
20
|
-
|
|
21
|
-
// ── renderServerConfig ────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
describe('renderServerConfig', () => {
|
|
24
|
-
const SERVER_PRIVATE_KEY = 'sErVeRpRiVaTeKeY1234567890abcdefghijklmnopqrstuvwxyz='
|
|
25
|
-
|
|
26
|
-
it('renders [Interface] block with Address 10.8.0.1/24, ListenPort 51820, and PrivateKey', () => {
|
|
27
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
|
|
28
|
-
|
|
29
|
-
expect(config).toContain('[Interface]')
|
|
30
|
-
expect(config).toContain('Address = 10.8.0.1/24')
|
|
31
|
-
expect(config).toContain('ListenPort = 51820')
|
|
32
|
-
expect(config).toContain(`PrivateKey = ${SERVER_PRIVATE_KEY}`)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('renders PostUp and PostDown iptables MASQUERADE rules', () => {
|
|
36
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
|
|
37
|
-
|
|
38
|
-
expect(config).toContain('PostUp')
|
|
39
|
-
expect(config).toContain('PostDown')
|
|
40
|
-
expect(config).toContain('MASQUERADE')
|
|
41
|
-
expect(config).toContain('FORWARD')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('renders 0 [Peer] blocks when peers list is empty', () => {
|
|
45
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
|
|
46
|
-
|
|
47
|
-
const peerMatches = config.match(/^\[Peer\]/gm)
|
|
48
|
-
expect(peerMatches).toBeNull()
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('renders exactly 1 [Peer] block for 1 peer with correct PublicKey and AllowedIPs', () => {
|
|
52
|
-
const peers: Peer[] = [{name: 'laptop', publicKey: 'pUbLiCkEy1==', tunnelIp: '10.8.0.2'}]
|
|
53
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, peers)
|
|
54
|
-
|
|
55
|
-
const peerMatches = config.match(/^\[Peer\]/gm)
|
|
56
|
-
expect(peerMatches).toHaveLength(1)
|
|
57
|
-
expect(config).toContain('PublicKey = pUbLiCkEy1==')
|
|
58
|
-
expect(config).toContain('AllowedIPs = 10.8.0.2/32')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('renders exactly 3 [Peer] blocks for 3 peers with correct PublicKey and AllowedIPs per peer', () => {
|
|
62
|
-
const peers: Peer[] = [
|
|
63
|
-
{name: 'laptop', publicKey: 'pubkey-laptop==', tunnelIp: '10.8.0.2'},
|
|
64
|
-
{name: 'phone', publicKey: 'pubkey-phone==', tunnelIp: '10.8.0.3'},
|
|
65
|
-
{name: 'tablet', publicKey: 'pubkey-tablet==', tunnelIp: '10.8.0.4'},
|
|
66
|
-
]
|
|
67
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, peers)
|
|
68
|
-
|
|
69
|
-
const peerMatches = config.match(/^\[Peer\]/gm)
|
|
70
|
-
expect(peerMatches).toHaveLength(3)
|
|
71
|
-
expect(config).toContain('PublicKey = pubkey-laptop==')
|
|
72
|
-
expect(config).toContain('AllowedIPs = 10.8.0.2/32')
|
|
73
|
-
expect(config).toContain('PublicKey = pubkey-phone==')
|
|
74
|
-
expect(config).toContain('AllowedIPs = 10.8.0.3/32')
|
|
75
|
-
expect(config).toContain('PublicKey = pubkey-tablet==')
|
|
76
|
-
expect(config).toContain('AllowedIPs = 10.8.0.4/32')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('uses default WAN interface eth0 in PostUp/PostDown when no opts provided', () => {
|
|
80
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
|
|
81
|
-
|
|
82
|
-
expect(config).toContain('eth0')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('uses custom WAN interface when wanInterface opt is provided', () => {
|
|
86
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, [], {wanInterface: 'ens5'})
|
|
87
|
-
|
|
88
|
-
expect(config).toContain('ens5')
|
|
89
|
-
expect(config).not.toContain('eth0')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('rendering is deterministic — same inputs produce identical output', () => {
|
|
93
|
-
const peers: Peer[] = [{name: 'laptop', publicKey: 'pubkey-laptop==', tunnelIp: '10.8.0.2'}]
|
|
94
|
-
const first = renderServerConfig(SERVER_PRIVATE_KEY, peers)
|
|
95
|
-
const second = renderServerConfig(SERVER_PRIVATE_KEY, peers)
|
|
96
|
-
|
|
97
|
-
expect(first).toBe(second)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('a removed peer does not appear in rendered config', () => {
|
|
101
|
-
const peers: Peer[] = [{name: 'laptop', publicKey: 'pubkey-laptop==', tunnelIp: '10.8.0.2'}]
|
|
102
|
-
const {peers: updatedPeers} = removePeer(peers, 'laptop')
|
|
103
|
-
const config = renderServerConfig(SERVER_PRIVATE_KEY, updatedPeers)
|
|
104
|
-
|
|
105
|
-
expect(config).not.toContain('pubkey-laptop==')
|
|
106
|
-
expect(config).not.toContain('10.8.0.2')
|
|
107
|
-
const peerMatches = config.match(/^\[Peer\]/gm)
|
|
108
|
-
expect(peerMatches).toBeNull()
|
|
109
|
-
})
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
// ── renderClientConfig ────────────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
describe('renderClientConfig', () => {
|
|
115
|
-
const CLIENT_PRIVATE_KEY = 'cLiEnTpRiVaTeKeY1234567890abcdefghijklmnopqrstuvwxyz='
|
|
116
|
-
const SERVER_PUBLIC_KEY = 'sErVeRpUbLiCkEy1234567890abcdefghijklmnopqrstuvwxyz='
|
|
117
|
-
const ENDPOINT = '1.2.3.4'
|
|
118
|
-
const TUNNEL_IP = '10.8.0.2'
|
|
119
|
-
|
|
120
|
-
it('renders [Interface] block with PrivateKey and Address tunnelIp/32', () => {
|
|
121
|
-
const config = renderClientConfig({
|
|
122
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
123
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
124
|
-
endpoint: ENDPOINT,
|
|
125
|
-
tunnelIp: TUNNEL_IP,
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
expect(config).toContain('[Interface]')
|
|
129
|
-
expect(config).toContain(`PrivateKey = ${CLIENT_PRIVATE_KEY}`)
|
|
130
|
-
expect(config).toContain(`Address = ${TUNNEL_IP}/32`)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('renders [Peer] block with server PublicKey and Endpoint host:51820', () => {
|
|
134
|
-
const config = renderClientConfig({
|
|
135
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
136
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
137
|
-
endpoint: ENDPOINT,
|
|
138
|
-
tunnelIp: TUNNEL_IP,
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
expect(config).toContain('[Peer]')
|
|
142
|
-
expect(config).toContain(`PublicKey = ${SERVER_PUBLIC_KEY}`)
|
|
143
|
-
expect(config).toContain(`Endpoint = ${ENDPOINT}:51820`)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it('defaults AllowedIPs to 0.0.0.0/0 for full-tunnel when not specified', () => {
|
|
147
|
-
const config = renderClientConfig({
|
|
148
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
149
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
150
|
-
endpoint: ENDPOINT,
|
|
151
|
-
tunnelIp: TUNNEL_IP,
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
expect(config).toContain('AllowedIPs = 0.0.0.0/0')
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('uses provided allowedIps for split-tunnel configuration', () => {
|
|
158
|
-
const config = renderClientConfig({
|
|
159
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
160
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
161
|
-
endpoint: ENDPOINT,
|
|
162
|
-
tunnelIp: TUNNEL_IP,
|
|
163
|
-
allowedIps: '10.0.0.0/8,192.168.0.0/16',
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
expect(config).toContain('AllowedIPs = 10.0.0.0/8,192.168.0.0/16')
|
|
167
|
-
expect(config).not.toContain('0.0.0.0/0')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('rendering is deterministic — same inputs produce identical output', () => {
|
|
171
|
-
const opts = {
|
|
172
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
173
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
174
|
-
endpoint: ENDPOINT,
|
|
175
|
-
tunnelIp: TUNNEL_IP,
|
|
176
|
-
}
|
|
177
|
-
const first = renderClientConfig(opts)
|
|
178
|
-
const second = renderClientConfig(opts)
|
|
179
|
-
|
|
180
|
-
expect(first).toBe(second)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('includes DNS line when dns option is provided', () => {
|
|
184
|
-
const config = renderClientConfig({
|
|
185
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
186
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
187
|
-
endpoint: ENDPOINT,
|
|
188
|
-
tunnelIp: TUNNEL_IP,
|
|
189
|
-
dns: '1.1.1.1',
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
expect(config).toContain('DNS = 1.1.1.1')
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
it('omits DNS line when dns option is not provided', () => {
|
|
196
|
-
const config = renderClientConfig({
|
|
197
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
198
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
199
|
-
endpoint: ENDPOINT,
|
|
200
|
-
tunnelIp: TUNNEL_IP,
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
expect(config).not.toContain('DNS')
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
it('omits DNS line when dns option is explicitly undefined', () => {
|
|
207
|
-
const config = renderClientConfig({
|
|
208
|
-
clientPrivateKey: CLIENT_PRIVATE_KEY,
|
|
209
|
-
serverPublicKey: SERVER_PUBLIC_KEY,
|
|
210
|
-
endpoint: ENDPOINT,
|
|
211
|
-
tunnelIp: TUNNEL_IP,
|
|
212
|
-
dns: undefined,
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
expect(config).not.toContain('DNS')
|
|
216
|
-
})
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
// ── nextTunnelIp ──────────────────────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
describe('nextTunnelIp', () => {
|
|
222
|
-
it('returns 10.8.0.2 when peers list is empty (skips .1 reserved for server)', () => {
|
|
223
|
-
expect(nextTunnelIp([])).toBe('10.8.0.2')
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('returns 10.8.0.3 when .2 is already allocated', () => {
|
|
227
|
-
const peers: Peer[] = [{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'}]
|
|
228
|
-
expect(nextTunnelIp(peers)).toBe('10.8.0.3')
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('allocates sequentially for multiple peers', () => {
|
|
232
|
-
const peers: Peer[] = [
|
|
233
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
234
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
235
|
-
]
|
|
236
|
-
expect(nextTunnelIp(peers)).toBe('10.8.0.4')
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
it('reuses freed slot after remove — lowest free N >= 2', () => {
|
|
240
|
-
const peers: Peer[] = [
|
|
241
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
242
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
243
|
-
{name: 'tablet', publicKey: 'pk3==', tunnelIp: '10.8.0.4'},
|
|
244
|
-
]
|
|
245
|
-
// Remove the middle peer (.3), next should reuse .3 (lowest free >= 2)
|
|
246
|
-
const {peers: afterRemove} = removePeer(peers, 'phone')
|
|
247
|
-
expect(nextTunnelIp(afterRemove)).toBe('10.8.0.3')
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
it('reuses lowest freed slot when first peer is removed', () => {
|
|
251
|
-
const peers: Peer[] = [
|
|
252
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
253
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
254
|
-
]
|
|
255
|
-
const {peers: afterRemove} = removePeer(peers, 'laptop')
|
|
256
|
-
expect(nextTunnelIp(afterRemove)).toBe('10.8.0.2')
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
it('throws an exhaustion error when all 253 client slots are allocated (.2 through .254)', () => {
|
|
260
|
-
// Fill all valid client octets: .2 through .254 (253 peers)
|
|
261
|
-
const peers: Peer[] = []
|
|
262
|
-
for (let i = 2; i <= 254; i++) {
|
|
263
|
-
peers.push({name: `peer${i}`, publicKey: `pk${i}==`, tunnelIp: `10.8.0.${i}`})
|
|
264
|
-
}
|
|
265
|
-
expect(() => nextTunnelIp(peers)).toThrow(/exhausted|max 253 peers/)
|
|
266
|
-
})
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
// ── addPeer ───────────────────────────────────────────────────────────────────
|
|
270
|
-
|
|
271
|
-
describe('addPeer', () => {
|
|
272
|
-
it('adds a peer with the next available tunnel IP', () => {
|
|
273
|
-
const {peers, tunnelIp} = addPeer([], {name: 'laptop', publicKey: 'pk-laptop=='})
|
|
274
|
-
|
|
275
|
-
expect(peers).toHaveLength(1)
|
|
276
|
-
expect(peers[0]).toEqual({name: 'laptop', publicKey: 'pk-laptop==', tunnelIp: '10.8.0.2'})
|
|
277
|
-
expect(tunnelIp).toBe('10.8.0.2')
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('assigns sequential IPs for multiple adds', () => {
|
|
281
|
-
const {peers: peers1} = addPeer([], {name: 'laptop', publicKey: 'pk1=='})
|
|
282
|
-
const {peers: peers2, tunnelIp} = addPeer(peers1, {name: 'phone', publicKey: 'pk2=='})
|
|
283
|
-
|
|
284
|
-
expect(peers2).toHaveLength(2)
|
|
285
|
-
expect(tunnelIp).toBe('10.8.0.3')
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
it('throws when adding a peer with a duplicate name', () => {
|
|
289
|
-
const {peers} = addPeer([], {name: 'laptop', publicKey: 'pk1=='})
|
|
290
|
-
|
|
291
|
-
expect(() => addPeer(peers, {name: 'laptop', publicKey: 'pk2=='})).toThrow(/duplicate|already exists/i)
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
it('does not mutate the original peers array', () => {
|
|
295
|
-
const original: Peer[] = [{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'}]
|
|
296
|
-
const originalLength = original.length
|
|
297
|
-
|
|
298
|
-
addPeer(original, {name: 'phone', publicKey: 'pk2=='})
|
|
299
|
-
|
|
300
|
-
expect(original).toHaveLength(originalLength)
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
// ── removePeer ────────────────────────────────────────────────────────────────
|
|
305
|
-
|
|
306
|
-
describe('removePeer', () => {
|
|
307
|
-
it('removes a peer by name and returns updated peers', () => {
|
|
308
|
-
const peers: Peer[] = [
|
|
309
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
310
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
311
|
-
]
|
|
312
|
-
const {peers: updated} = removePeer(peers, 'laptop')
|
|
313
|
-
|
|
314
|
-
expect(updated).toHaveLength(1)
|
|
315
|
-
expect(updated[0]?.name).toBe('phone')
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
it('throws a clear error when removing a non-existent peer', () => {
|
|
319
|
-
const peers: Peer[] = [{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'}]
|
|
320
|
-
|
|
321
|
-
expect(() => removePeer(peers, 'nonexistent')).toThrow(/not found|does not exist/i)
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
it('does not mutate the original peers array', () => {
|
|
325
|
-
const original: Peer[] = [
|
|
326
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
327
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
328
|
-
]
|
|
329
|
-
const originalLength = original.length
|
|
330
|
-
|
|
331
|
-
removePeer(original, 'laptop')
|
|
332
|
-
|
|
333
|
-
expect(original).toHaveLength(originalLength)
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
// ── Zod schema validation (readPeers-equivalent) ─────────────────────────────
|
|
338
|
-
|
|
339
|
-
describe('parsePeersJson', () => {
|
|
340
|
-
it('throws on malformed JSON shape — wrong top-level type', () => {
|
|
341
|
-
expect(() => parsePeersJson([])).toThrow()
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
it('throws when peers array contains an entry missing publicKey', () => {
|
|
345
|
-
expect(() =>
|
|
346
|
-
parsePeersJson({
|
|
347
|
-
peers: [{name: 'laptop', tunnelIp: '10.8.0.2'}],
|
|
348
|
-
}),
|
|
349
|
-
).toThrow()
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
it('throws when peers array contains an entry missing tunnelIp', () => {
|
|
353
|
-
expect(() =>
|
|
354
|
-
parsePeersJson({
|
|
355
|
-
peers: [{name: 'laptop', publicKey: 'pk=='}],
|
|
356
|
-
}),
|
|
357
|
-
).toThrow()
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
it('accepts a valid peers file with empty peers array', () => {
|
|
361
|
-
const result: PeersFile = parsePeersJson({peers: []})
|
|
362
|
-
expect(result.peers).toHaveLength(0)
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
it('accepts a valid peers file with one peer', () => {
|
|
366
|
-
const result: PeersFile = parsePeersJson({
|
|
367
|
-
peers: [{name: 'laptop', publicKey: 'pk==', tunnelIp: '10.8.0.2'}],
|
|
368
|
-
})
|
|
369
|
-
expect(result.peers).toHaveLength(1)
|
|
370
|
-
expect(result.peers[0]?.name).toBe('laptop')
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
// Fix #7: duplicate tunnelIp / duplicate name validation
|
|
374
|
-
it('rejects a roster with duplicate tunnelIp values', () => {
|
|
375
|
-
expect(() =>
|
|
376
|
-
parsePeersJson({
|
|
377
|
-
peers: [
|
|
378
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
379
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.2'},
|
|
380
|
-
],
|
|
381
|
-
}),
|
|
382
|
-
).toThrow(/duplicate.*tunnel|tunnel.*duplicate/i)
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
it('rejects a roster with duplicate name values', () => {
|
|
386
|
-
expect(() =>
|
|
387
|
-
parsePeersJson({
|
|
388
|
-
peers: [
|
|
389
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
390
|
-
{name: 'laptop', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
391
|
-
],
|
|
392
|
-
}),
|
|
393
|
-
).toThrow(/duplicate.*name|name.*duplicate/i)
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
it('accepts a valid roster with unique names and tunnel IPs', () => {
|
|
397
|
-
const result: PeersFile = parsePeersJson({
|
|
398
|
-
peers: [
|
|
399
|
-
{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
|
|
400
|
-
{name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
|
|
401
|
-
],
|
|
402
|
-
})
|
|
403
|
-
expect(result.peers).toHaveLength(2)
|
|
404
|
-
})
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
// ── readPeersOrEmpty ──────────────────────────────────────────────────────────
|
|
408
|
-
|
|
409
|
-
describe('readPeersOrEmpty', () => {
|
|
410
|
-
it('returns {peers: []} when the file does not exist (ENOENT)', async () => {
|
|
411
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
412
|
-
try {
|
|
413
|
-
const nonExistentPath = join(tmpDir, 'peers.json')
|
|
414
|
-
const result = await readPeersOrEmpty(nonExistentPath)
|
|
415
|
-
expect(result).toEqual({peers: []})
|
|
416
|
-
} finally {
|
|
417
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
418
|
-
}
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
it('returns parsed file when the file exists and is valid', async () => {
|
|
422
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
423
|
-
try {
|
|
424
|
-
const filePath = join(tmpDir, 'peers.json')
|
|
425
|
-
writeFileSync(
|
|
426
|
-
filePath,
|
|
427
|
-
JSON.stringify({peers: [{name: 'laptop', publicKey: 'pk==', tunnelIp: '10.8.0.2'}]}, null, 2),
|
|
428
|
-
)
|
|
429
|
-
const result = await readPeersOrEmpty(filePath)
|
|
430
|
-
expect(result.peers).toHaveLength(1)
|
|
431
|
-
expect(result.peers[0]?.name).toBe('laptop')
|
|
432
|
-
} finally {
|
|
433
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
434
|
-
}
|
|
435
|
-
})
|
|
436
|
-
|
|
437
|
-
it('throws on corrupt JSON (not ENOENT — corrupt file must NOT silently become empty roster)', async () => {
|
|
438
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
439
|
-
try {
|
|
440
|
-
const filePath = join(tmpDir, 'peers.json')
|
|
441
|
-
writeFileSync(filePath, 'not valid json {{{')
|
|
442
|
-
await expect(readPeersOrEmpty(filePath)).rejects.toThrow()
|
|
443
|
-
} finally {
|
|
444
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
445
|
-
}
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
it('throws on schema-invalid JSON (not ENOENT — schema errors must not silently become empty roster)', async () => {
|
|
449
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-test-'))
|
|
450
|
-
try {
|
|
451
|
-
const filePath = join(tmpDir, 'peers.json')
|
|
452
|
-
writeFileSync(filePath, JSON.stringify({peers: [{name: 'laptop'}]}))
|
|
453
|
-
await expect(readPeersOrEmpty(filePath)).rejects.toThrow()
|
|
454
|
-
} finally {
|
|
455
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
456
|
-
}
|
|
457
|
-
})
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
// ── writePeers: restrictive permissions ──────────────────────────────────────
|
|
461
|
-
|
|
462
|
-
describe('writePeers: file permissions', () => {
|
|
463
|
-
it('writes peers.json with mode 0o600 (owner read/write only)', async () => {
|
|
464
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'vpn-peers-perm-test-'))
|
|
465
|
-
try {
|
|
466
|
-
const filePath = join(tmpDir, 'peers.json')
|
|
467
|
-
await writePeers(filePath, {peers: []})
|
|
468
|
-
|
|
469
|
-
const stat = statSync(filePath)
|
|
470
|
-
// On macOS/Linux: check that mode is 0o600 (owner rw, no group/other)
|
|
471
|
-
// stat.mode includes file type bits; mask with 0o777 for permission bits
|
|
472
|
-
const permBits = stat.mode & 0o777
|
|
473
|
-
expect(permBits).toBe(0o600)
|
|
474
|
-
} finally {
|
|
475
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
476
|
-
}
|
|
477
|
-
})
|
|
478
|
-
})
|