@marcusrbrown/infra 0.10.0 → 0.10.1

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.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -18,6 +18,9 @@
18
18
  "license": "MIT",
19
19
  "author": "Marcus R. Brown <contact@marcusrbrown.com>",
20
20
  "type": "module",
21
+ "exports": {
22
+ "./vpn/peers": "./src/commands/vpn/peers.ts"
23
+ },
21
24
  "bin": {
22
25
  "infra": "src/cli.ts"
23
26
  },
@@ -31,7 +34,6 @@
31
34
  "dependencies": {
32
35
  "@clack/prompts": "^1.2.0",
33
36
  "@goke/mcp": "^0.0.10",
34
- "@marcusrbrown/infra-shared": "workspace:*",
35
37
  "goke": "^6.3.2",
36
38
  "string-dedent": "^3.0.2",
37
39
  "zod": "^4.3.6"
@@ -3,15 +3,8 @@ import type {goke} from 'goke'
3
3
  import {mkdir, writeFile} from 'node:fs/promises'
4
4
  import {resolve} from 'node:path'
5
5
 
6
- import {
7
- addPeer,
8
- readPeers,
9
- removePeer,
10
- renderClientConfig,
11
- writePeers,
12
- type Peer,
13
- } from '@marcusrbrown/infra-shared/vpn/peers'
14
6
  import {z} from 'zod'
7
+ import {addPeer, readPeers, removePeer, renderClientConfig, writePeers, type Peer} from './peers'
15
8
 
16
9
  declare const process: {
17
10
  env: Record<string, string | undefined>
@@ -0,0 +1,367 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {describe, expect, it} from 'bun:test'
4
+
5
+ import {
6
+ addPeer,
7
+ nextTunnelIp,
8
+ parsePeersJson,
9
+ removePeer,
10
+ renderClientConfig,
11
+ renderServerConfig,
12
+ type Peer,
13
+ type PeersFile,
14
+ } from './peers'
15
+
16
+ // ── renderServerConfig ────────────────────────────────────────────────────────
17
+
18
+ describe('renderServerConfig', () => {
19
+ const SERVER_PRIVATE_KEY = 'sErVeRpRiVaTeKeY1234567890abcdefghijklmnopqrstuvwxyz='
20
+
21
+ it('renders [Interface] block with Address 10.8.0.1/24, ListenPort 51820, and PrivateKey', () => {
22
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
23
+
24
+ expect(config).toContain('[Interface]')
25
+ expect(config).toContain('Address = 10.8.0.1/24')
26
+ expect(config).toContain('ListenPort = 51820')
27
+ expect(config).toContain(`PrivateKey = ${SERVER_PRIVATE_KEY}`)
28
+ })
29
+
30
+ it('renders PostUp and PostDown iptables MASQUERADE rules', () => {
31
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
32
+
33
+ expect(config).toContain('PostUp')
34
+ expect(config).toContain('PostDown')
35
+ expect(config).toContain('MASQUERADE')
36
+ expect(config).toContain('FORWARD')
37
+ })
38
+
39
+ it('renders 0 [Peer] blocks when peers list is empty', () => {
40
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
41
+
42
+ const peerMatches = config.match(/^\[Peer\]/gm)
43
+ expect(peerMatches).toBeNull()
44
+ })
45
+
46
+ it('renders exactly 1 [Peer] block for 1 peer with correct PublicKey and AllowedIPs', () => {
47
+ const peers: Peer[] = [{name: 'laptop', publicKey: 'pUbLiCkEy1==', tunnelIp: '10.8.0.2'}]
48
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, peers)
49
+
50
+ const peerMatches = config.match(/^\[Peer\]/gm)
51
+ expect(peerMatches).toHaveLength(1)
52
+ expect(config).toContain('PublicKey = pUbLiCkEy1==')
53
+ expect(config).toContain('AllowedIPs = 10.8.0.2/32')
54
+ })
55
+
56
+ it('renders exactly 3 [Peer] blocks for 3 peers with correct PublicKey and AllowedIPs per peer', () => {
57
+ const peers: Peer[] = [
58
+ {name: 'laptop', publicKey: 'pubkey-laptop==', tunnelIp: '10.8.0.2'},
59
+ {name: 'phone', publicKey: 'pubkey-phone==', tunnelIp: '10.8.0.3'},
60
+ {name: 'tablet', publicKey: 'pubkey-tablet==', tunnelIp: '10.8.0.4'},
61
+ ]
62
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, peers)
63
+
64
+ const peerMatches = config.match(/^\[Peer\]/gm)
65
+ expect(peerMatches).toHaveLength(3)
66
+ expect(config).toContain('PublicKey = pubkey-laptop==')
67
+ expect(config).toContain('AllowedIPs = 10.8.0.2/32')
68
+ expect(config).toContain('PublicKey = pubkey-phone==')
69
+ expect(config).toContain('AllowedIPs = 10.8.0.3/32')
70
+ expect(config).toContain('PublicKey = pubkey-tablet==')
71
+ expect(config).toContain('AllowedIPs = 10.8.0.4/32')
72
+ })
73
+
74
+ it('uses default WAN interface eth0 in PostUp/PostDown when no opts provided', () => {
75
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, [])
76
+
77
+ expect(config).toContain('eth0')
78
+ })
79
+
80
+ it('uses custom WAN interface when wanInterface opt is provided', () => {
81
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, [], {wanInterface: 'ens5'})
82
+
83
+ expect(config).toContain('ens5')
84
+ expect(config).not.toContain('eth0')
85
+ })
86
+
87
+ it('rendering is deterministic — same inputs produce identical output', () => {
88
+ const peers: Peer[] = [{name: 'laptop', publicKey: 'pubkey-laptop==', tunnelIp: '10.8.0.2'}]
89
+ const first = renderServerConfig(SERVER_PRIVATE_KEY, peers)
90
+ const second = renderServerConfig(SERVER_PRIVATE_KEY, peers)
91
+
92
+ expect(first).toBe(second)
93
+ })
94
+
95
+ it('a removed peer does not appear in rendered config', () => {
96
+ const peers: Peer[] = [{name: 'laptop', publicKey: 'pubkey-laptop==', tunnelIp: '10.8.0.2'}]
97
+ const {peers: updatedPeers} = removePeer(peers, 'laptop')
98
+ const config = renderServerConfig(SERVER_PRIVATE_KEY, updatedPeers)
99
+
100
+ expect(config).not.toContain('pubkey-laptop==')
101
+ expect(config).not.toContain('10.8.0.2')
102
+ const peerMatches = config.match(/^\[Peer\]/gm)
103
+ expect(peerMatches).toBeNull()
104
+ })
105
+ })
106
+
107
+ // ── renderClientConfig ────────────────────────────────────────────────────────
108
+
109
+ describe('renderClientConfig', () => {
110
+ const CLIENT_PRIVATE_KEY = 'cLiEnTpRiVaTeKeY1234567890abcdefghijklmnopqrstuvwxyz='
111
+ const SERVER_PUBLIC_KEY = 'sErVeRpUbLiCkEy1234567890abcdefghijklmnopqrstuvwxyz='
112
+ const ENDPOINT = '1.2.3.4'
113
+ const TUNNEL_IP = '10.8.0.2'
114
+
115
+ it('renders [Interface] block with PrivateKey and Address tunnelIp/32', () => {
116
+ const config = renderClientConfig({
117
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
118
+ serverPublicKey: SERVER_PUBLIC_KEY,
119
+ endpoint: ENDPOINT,
120
+ tunnelIp: TUNNEL_IP,
121
+ })
122
+
123
+ expect(config).toContain('[Interface]')
124
+ expect(config).toContain(`PrivateKey = ${CLIENT_PRIVATE_KEY}`)
125
+ expect(config).toContain(`Address = ${TUNNEL_IP}/32`)
126
+ })
127
+
128
+ it('renders [Peer] block with server PublicKey and Endpoint host:51820', () => {
129
+ const config = renderClientConfig({
130
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
131
+ serverPublicKey: SERVER_PUBLIC_KEY,
132
+ endpoint: ENDPOINT,
133
+ tunnelIp: TUNNEL_IP,
134
+ })
135
+
136
+ expect(config).toContain('[Peer]')
137
+ expect(config).toContain(`PublicKey = ${SERVER_PUBLIC_KEY}`)
138
+ expect(config).toContain(`Endpoint = ${ENDPOINT}:51820`)
139
+ })
140
+
141
+ it('defaults AllowedIPs to 0.0.0.0/0 for full-tunnel when not specified', () => {
142
+ const config = renderClientConfig({
143
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
144
+ serverPublicKey: SERVER_PUBLIC_KEY,
145
+ endpoint: ENDPOINT,
146
+ tunnelIp: TUNNEL_IP,
147
+ })
148
+
149
+ expect(config).toContain('AllowedIPs = 0.0.0.0/0')
150
+ })
151
+
152
+ it('uses provided allowedIps for split-tunnel configuration', () => {
153
+ const config = renderClientConfig({
154
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
155
+ serverPublicKey: SERVER_PUBLIC_KEY,
156
+ endpoint: ENDPOINT,
157
+ tunnelIp: TUNNEL_IP,
158
+ allowedIps: '10.0.0.0/8,192.168.0.0/16',
159
+ })
160
+
161
+ expect(config).toContain('AllowedIPs = 10.0.0.0/8,192.168.0.0/16')
162
+ expect(config).not.toContain('0.0.0.0/0')
163
+ })
164
+
165
+ it('rendering is deterministic — same inputs produce identical output', () => {
166
+ const opts = {
167
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
168
+ serverPublicKey: SERVER_PUBLIC_KEY,
169
+ endpoint: ENDPOINT,
170
+ tunnelIp: TUNNEL_IP,
171
+ }
172
+ const first = renderClientConfig(opts)
173
+ const second = renderClientConfig(opts)
174
+
175
+ expect(first).toBe(second)
176
+ })
177
+
178
+ it('includes DNS line when dns option is provided', () => {
179
+ const config = renderClientConfig({
180
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
181
+ serverPublicKey: SERVER_PUBLIC_KEY,
182
+ endpoint: ENDPOINT,
183
+ tunnelIp: TUNNEL_IP,
184
+ dns: '1.1.1.1',
185
+ })
186
+
187
+ expect(config).toContain('DNS = 1.1.1.1')
188
+ })
189
+
190
+ it('omits DNS line when dns option is not provided', () => {
191
+ const config = renderClientConfig({
192
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
193
+ serverPublicKey: SERVER_PUBLIC_KEY,
194
+ endpoint: ENDPOINT,
195
+ tunnelIp: TUNNEL_IP,
196
+ })
197
+
198
+ expect(config).not.toContain('DNS')
199
+ })
200
+
201
+ it('omits DNS line when dns option is explicitly undefined', () => {
202
+ const config = renderClientConfig({
203
+ clientPrivateKey: CLIENT_PRIVATE_KEY,
204
+ serverPublicKey: SERVER_PUBLIC_KEY,
205
+ endpoint: ENDPOINT,
206
+ tunnelIp: TUNNEL_IP,
207
+ dns: undefined,
208
+ })
209
+
210
+ expect(config).not.toContain('DNS')
211
+ })
212
+ })
213
+
214
+ // ── nextTunnelIp ──────────────────────────────────────────────────────────────
215
+
216
+ describe('nextTunnelIp', () => {
217
+ it('returns 10.8.0.2 when peers list is empty (skips .1 reserved for server)', () => {
218
+ expect(nextTunnelIp([])).toBe('10.8.0.2')
219
+ })
220
+
221
+ it('returns 10.8.0.3 when .2 is already allocated', () => {
222
+ const peers: Peer[] = [{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'}]
223
+ expect(nextTunnelIp(peers)).toBe('10.8.0.3')
224
+ })
225
+
226
+ it('allocates sequentially for multiple peers', () => {
227
+ const peers: Peer[] = [
228
+ {name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
229
+ {name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
230
+ ]
231
+ expect(nextTunnelIp(peers)).toBe('10.8.0.4')
232
+ })
233
+
234
+ it('reuses freed slot after remove — lowest free N >= 2', () => {
235
+ const peers: Peer[] = [
236
+ {name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
237
+ {name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
238
+ {name: 'tablet', publicKey: 'pk3==', tunnelIp: '10.8.0.4'},
239
+ ]
240
+ // Remove the middle peer (.3), next should reuse .3 (lowest free >= 2)
241
+ const {peers: afterRemove} = removePeer(peers, 'phone')
242
+ expect(nextTunnelIp(afterRemove)).toBe('10.8.0.3')
243
+ })
244
+
245
+ it('reuses lowest freed slot when first peer is removed', () => {
246
+ const peers: Peer[] = [
247
+ {name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
248
+ {name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
249
+ ]
250
+ const {peers: afterRemove} = removePeer(peers, 'laptop')
251
+ expect(nextTunnelIp(afterRemove)).toBe('10.8.0.2')
252
+ })
253
+
254
+ it('throws an exhaustion error when all 253 client slots are allocated (.2 through .254)', () => {
255
+ // Fill all valid client octets: .2 through .254 (253 peers)
256
+ const peers: Peer[] = []
257
+ for (let i = 2; i <= 254; i++) {
258
+ peers.push({name: `peer${i}`, publicKey: `pk${i}==`, tunnelIp: `10.8.0.${i}`})
259
+ }
260
+ expect(() => nextTunnelIp(peers)).toThrow(/exhausted|max 253 peers/)
261
+ })
262
+ })
263
+
264
+ // ── addPeer ───────────────────────────────────────────────────────────────────
265
+
266
+ describe('addPeer', () => {
267
+ it('adds a peer with the next available tunnel IP', () => {
268
+ const {peers, tunnelIp} = addPeer([], {name: 'laptop', publicKey: 'pk-laptop=='})
269
+
270
+ expect(peers).toHaveLength(1)
271
+ expect(peers[0]).toEqual({name: 'laptop', publicKey: 'pk-laptop==', tunnelIp: '10.8.0.2'})
272
+ expect(tunnelIp).toBe('10.8.0.2')
273
+ })
274
+
275
+ it('assigns sequential IPs for multiple adds', () => {
276
+ const {peers: peers1} = addPeer([], {name: 'laptop', publicKey: 'pk1=='})
277
+ const {peers: peers2, tunnelIp} = addPeer(peers1, {name: 'phone', publicKey: 'pk2=='})
278
+
279
+ expect(peers2).toHaveLength(2)
280
+ expect(tunnelIp).toBe('10.8.0.3')
281
+ })
282
+
283
+ it('throws when adding a peer with a duplicate name', () => {
284
+ const {peers} = addPeer([], {name: 'laptop', publicKey: 'pk1=='})
285
+
286
+ expect(() => addPeer(peers, {name: 'laptop', publicKey: 'pk2=='})).toThrow(/duplicate|already exists/i)
287
+ })
288
+
289
+ it('does not mutate the original peers array', () => {
290
+ const original: Peer[] = [{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'}]
291
+ const originalLength = original.length
292
+
293
+ addPeer(original, {name: 'phone', publicKey: 'pk2=='})
294
+
295
+ expect(original).toHaveLength(originalLength)
296
+ })
297
+ })
298
+
299
+ // ── removePeer ────────────────────────────────────────────────────────────────
300
+
301
+ describe('removePeer', () => {
302
+ it('removes a peer by name and returns updated peers', () => {
303
+ const peers: Peer[] = [
304
+ {name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
305
+ {name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
306
+ ]
307
+ const {peers: updated} = removePeer(peers, 'laptop')
308
+
309
+ expect(updated).toHaveLength(1)
310
+ expect(updated[0]?.name).toBe('phone')
311
+ })
312
+
313
+ it('throws a clear error when removing a non-existent peer', () => {
314
+ const peers: Peer[] = [{name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'}]
315
+
316
+ expect(() => removePeer(peers, 'nonexistent')).toThrow(/not found|does not exist/i)
317
+ })
318
+
319
+ it('does not mutate the original peers array', () => {
320
+ const original: Peer[] = [
321
+ {name: 'laptop', publicKey: 'pk1==', tunnelIp: '10.8.0.2'},
322
+ {name: 'phone', publicKey: 'pk2==', tunnelIp: '10.8.0.3'},
323
+ ]
324
+ const originalLength = original.length
325
+
326
+ removePeer(original, 'laptop')
327
+
328
+ expect(original).toHaveLength(originalLength)
329
+ })
330
+ })
331
+
332
+ // ── Zod schema validation (readPeers-equivalent) ─────────────────────────────
333
+
334
+ describe('parsePeersJson', () => {
335
+ it('throws on malformed JSON shape — wrong top-level type', () => {
336
+ expect(() => parsePeersJson([])).toThrow()
337
+ })
338
+
339
+ it('throws when peers array contains an entry missing publicKey', () => {
340
+ expect(() =>
341
+ parsePeersJson({
342
+ peers: [{name: 'laptop', tunnelIp: '10.8.0.2'}],
343
+ }),
344
+ ).toThrow()
345
+ })
346
+
347
+ it('throws when peers array contains an entry missing tunnelIp', () => {
348
+ expect(() =>
349
+ parsePeersJson({
350
+ peers: [{name: 'laptop', publicKey: 'pk=='}],
351
+ }),
352
+ ).toThrow()
353
+ })
354
+
355
+ it('accepts a valid peers file with empty peers array', () => {
356
+ const result: PeersFile = parsePeersJson({peers: []})
357
+ expect(result.peers).toHaveLength(0)
358
+ })
359
+
360
+ it('accepts a valid peers file with one peer', () => {
361
+ const result: PeersFile = parsePeersJson({
362
+ peers: [{name: 'laptop', publicKey: 'pk==', tunnelIp: '10.8.0.2'}],
363
+ })
364
+ expect(result.peers).toHaveLength(1)
365
+ expect(result.peers[0]?.name).toBe('laptop')
366
+ })
367
+ })
@@ -0,0 +1,193 @@
1
+ import {readFile, writeFile} from 'node:fs/promises'
2
+
3
+ import {z} from 'zod'
4
+
5
+ // ── Schema ────────────────────────────────────────────────────────────────────
6
+
7
+ const peerSchema = z.object({
8
+ name: z.string(),
9
+ publicKey: z.string(),
10
+ tunnelIp: z.string(),
11
+ })
12
+
13
+ const peersFileSchema = z.object({
14
+ peers: z.array(peerSchema),
15
+ })
16
+
17
+ export type Peer = z.infer<typeof peerSchema>
18
+ export type PeersFile = z.infer<typeof peersFileSchema>
19
+
20
+ // ── Validation ────────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Parse and validate a raw value as a PeersFile.
24
+ * Throws a ZodError on invalid shape — fail-closed, no silent empty.
25
+ */
26
+ export function parsePeersJson(raw: unknown): PeersFile {
27
+ return peersFileSchema.parse(raw)
28
+ }
29
+
30
+ // ── File I/O (thin layer — only these functions touch the filesystem) ─────────
31
+
32
+ export async function readPeers(filePath: string): Promise<PeersFile> {
33
+ const content = await readFile(filePath, 'utf-8')
34
+ const raw: unknown = JSON.parse(content)
35
+ return parsePeersJson(raw)
36
+ }
37
+
38
+ export async function writePeers(filePath: string, peersFile: PeersFile): Promise<void> {
39
+ const content = `${JSON.stringify(peersFile, null, 2)}\n`
40
+ await writeFile(filePath, content, 'utf-8')
41
+ }
42
+
43
+ // ── IP allocation ─────────────────────────────────────────────────────────────
44
+
45
+ const TUNNEL_SUBNET_PREFIX = '10.8.0.'
46
+ const SERVER_OCTET = 1
47
+ const FIRST_CLIENT_OCTET = 2
48
+
49
+ /**
50
+ * Extract the last octet from a tunnel IP like '10.8.0.N'.
51
+ * Returns null if the IP doesn't match the expected subnet.
52
+ */
53
+ function extractOctet(tunnelIp: string): number | null {
54
+ if (!tunnelIp.startsWith(TUNNEL_SUBNET_PREFIX)) {
55
+ return null
56
+ }
57
+ const octetStr = tunnelIp.slice(TUNNEL_SUBNET_PREFIX.length)
58
+ const octet = Number.parseInt(octetStr, 10)
59
+ return Number.isFinite(octet) && octet >= FIRST_CLIENT_OCTET ? octet : null
60
+ }
61
+
62
+ // Last valid client octet: .254 (.1 is server, .255 is broadcast)
63
+ const LAST_CLIENT_OCTET = 254
64
+
65
+ /**
66
+ * Compute the next available tunnel IP.
67
+ * Sequential 10.8.0.N/32 starting at .2 (.1 is reserved for the server).
68
+ * Reuses freed slots — returns the lowest free N >= 2.
69
+ * Throws when all 253 client slots (.2–.254) are exhausted.
70
+ */
71
+ export function nextTunnelIp(peers: Peer[]): string {
72
+ const allocated = new Set<number>()
73
+ for (const peer of peers) {
74
+ const octet = extractOctet(peer.tunnelIp)
75
+ if (octet !== null) {
76
+ allocated.add(octet)
77
+ }
78
+ }
79
+
80
+ let n = FIRST_CLIENT_OCTET
81
+ while (allocated.has(n) || n === SERVER_OCTET) {
82
+ n++
83
+ }
84
+
85
+ if (n > LAST_CLIENT_OCTET) {
86
+ throw new Error(`Tunnel subnet 10.8.0.0/24 exhausted (max 253 peers).`)
87
+ }
88
+
89
+ return `${TUNNEL_SUBNET_PREFIX}${n}`
90
+ }
91
+
92
+ // ── Peer mutations (pure — return new arrays, never mutate) ───────────────────
93
+
94
+ /**
95
+ * Add a peer with the next available tunnel IP.
96
+ * Throws if a peer with the same name already exists.
97
+ */
98
+ export function addPeer(
99
+ peers: Peer[],
100
+ {name, publicKey}: {name: string; publicKey: string},
101
+ ): {peers: Peer[]; tunnelIp: string} {
102
+ const duplicate = peers.find(p => p.name === name)
103
+ if (duplicate !== undefined) {
104
+ throw new Error(`Peer "${name}" already exists (duplicate name).`)
105
+ }
106
+
107
+ const tunnelIp = nextTunnelIp(peers)
108
+ const newPeer: Peer = {name, publicKey, tunnelIp}
109
+ return {peers: [...peers, newPeer], tunnelIp}
110
+ }
111
+
112
+ /**
113
+ * Remove a peer by name.
114
+ * Throws a clear error if the peer does not exist — no silent no-op.
115
+ */
116
+ export function removePeer(peers: Peer[], name: string): {peers: Peer[]} {
117
+ const index = peers.findIndex(p => p.name === name)
118
+ if (index === -1) {
119
+ throw new Error(`Peer "${name}" not found. Cannot remove a peer that does not exist.`)
120
+ }
121
+
122
+ const updated = [...peers.slice(0, index), ...peers.slice(index + 1)]
123
+ return {peers: updated}
124
+ }
125
+
126
+ // ── Config rendering (pure — string in → string out, no IO) ──────────────────
127
+
128
+ export interface ServerConfigOpts {
129
+ wanInterface?: string
130
+ }
131
+
132
+ /**
133
+ * Render a full wg0.conf server configuration.
134
+ *
135
+ * Pure function: takes keys and peers as inputs, returns a config string.
136
+ * The server private key is passed in (it lives on the box; this function
137
+ * is used server-side where the key is available locally).
138
+ */
139
+ export function renderServerConfig(serverPrivateKey: string, peers: Peer[], opts?: ServerConfigOpts): string {
140
+ const wanInterface = opts?.wanInterface ?? 'eth0'
141
+
142
+ const lines: string[] = [
143
+ '[Interface]',
144
+ `Address = 10.8.0.1/24`,
145
+ `ListenPort = 51820`,
146
+ `PrivateKey = ${serverPrivateKey}`,
147
+ `PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o ${wanInterface} -j MASQUERADE`,
148
+ `PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o ${wanInterface} -j MASQUERADE`,
149
+ ]
150
+
151
+ for (const peer of peers) {
152
+ lines.push('')
153
+ lines.push('[Peer]')
154
+ lines.push(`PublicKey = ${peer.publicKey}`)
155
+ lines.push(`AllowedIPs = ${peer.tunnelIp}/32`)
156
+ }
157
+
158
+ return `${lines.join('\n')}\n`
159
+ }
160
+
161
+ export interface ClientConfigOpts {
162
+ clientPrivateKey: string
163
+ serverPublicKey: string
164
+ endpoint: string
165
+ tunnelIp: string
166
+ allowedIps?: string
167
+ dns?: string
168
+ }
169
+
170
+ /**
171
+ * Render a WireGuard client .conf file.
172
+ *
173
+ * Pure function: takes all config as inputs, returns a config string.
174
+ * allowedIps defaults to '0.0.0.0/0' (full tunnel).
175
+ * Pass specific CIDRs for split-tunnel.
176
+ */
177
+ export function renderClientConfig(opts: ClientConfigOpts): string {
178
+ const {clientPrivateKey, serverPublicKey, endpoint, tunnelIp, allowedIps = '0.0.0.0/0', dns} = opts
179
+
180
+ const lines: string[] = ['[Interface]', `PrivateKey = ${clientPrivateKey}`, `Address = ${tunnelIp}/32`]
181
+
182
+ if (dns !== undefined) {
183
+ lines.push(`DNS = ${dns}`)
184
+ }
185
+
186
+ lines.push('')
187
+ lines.push('[Peer]')
188
+ lines.push(`PublicKey = ${serverPublicKey}`)
189
+ lines.push(`Endpoint = ${endpoint}:51820`)
190
+ lines.push(`AllowedIPs = ${allowedIps}`)
191
+
192
+ return `${lines.join('\n')}\n`
193
+ }