@marcusrbrown/infra 0.10.0 → 0.10.2

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.
@@ -0,0 +1,243 @@
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
14
+ .object({
15
+ peers: z.array(peerSchema),
16
+ })
17
+ .superRefine((data, ctx) => {
18
+ // Reject duplicate tunnelIp values — duplicated tunnel IPs break WireGuard routing silently
19
+ const seenIps = new Set<string>()
20
+ for (const peer of data.peers) {
21
+ if (seenIps.has(peer.tunnelIp)) {
22
+ ctx.addIssue({
23
+ code: z.ZodIssueCode.custom,
24
+ message: `Duplicate tunnel IP "${peer.tunnelIp}" — each peer must have a unique tunnel IP.`,
25
+ path: ['peers'],
26
+ })
27
+ return
28
+ }
29
+ seenIps.add(peer.tunnelIp)
30
+ }
31
+
32
+ // Reject duplicate name values
33
+ const seenNames = new Set<string>()
34
+ for (const peer of data.peers) {
35
+ if (seenNames.has(peer.name)) {
36
+ ctx.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ message: `Duplicate name "${peer.name}" — each peer must have a unique name.`,
39
+ path: ['peers'],
40
+ })
41
+ return
42
+ }
43
+ seenNames.add(peer.name)
44
+ }
45
+ })
46
+
47
+ export type Peer = z.infer<typeof peerSchema>
48
+ export type PeersFile = z.infer<typeof peersFileSchema>
49
+
50
+ // ── Validation ────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Parse and validate a raw value as a PeersFile.
54
+ * Throws a ZodError on invalid shape — fail-closed, no silent empty.
55
+ */
56
+ export function parsePeersJson(raw: unknown): PeersFile {
57
+ return peersFileSchema.parse(raw)
58
+ }
59
+
60
+ // ── File I/O (thin layer — only these functions touch the filesystem) ─────────
61
+
62
+ export async function readPeers(filePath: string): Promise<PeersFile> {
63
+ const content = await readFile(filePath, 'utf-8')
64
+ const raw: unknown = JSON.parse(content)
65
+ return parsePeersJson(raw)
66
+ }
67
+
68
+ /**
69
+ * Read peers from filePath, returning {peers: []} when the file is absent (ENOENT).
70
+ *
71
+ * ENOENT is the only error that is silently treated as an empty roster — this
72
+ * supports fresh checkouts where peers.json is gitignored and does not exist yet.
73
+ *
74
+ * All other errors (corrupt JSON, schema violations, permission errors) still throw
75
+ * so that a corrupt file is never silently treated as an empty roster.
76
+ */
77
+ export async function readPeersOrEmpty(filePath: string): Promise<PeersFile> {
78
+ try {
79
+ return await readPeers(filePath)
80
+ } catch (error) {
81
+ if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
82
+ return {peers: []}
83
+ }
84
+ throw error
85
+ }
86
+ }
87
+
88
+ export async function writePeers(filePath: string, peersFile: PeersFile): Promise<void> {
89
+ const content = `${JSON.stringify(peersFile, null, 2)}\n`
90
+ await writeFile(filePath, content, {mode: 0o600})
91
+ }
92
+
93
+ // ── IP allocation ─────────────────────────────────────────────────────────────
94
+
95
+ const TUNNEL_SUBNET_PREFIX = '10.8.0.'
96
+ const SERVER_OCTET = 1
97
+ const FIRST_CLIENT_OCTET = 2
98
+
99
+ /**
100
+ * Extract the last octet from a tunnel IP like '10.8.0.N'.
101
+ * Returns null if the IP doesn't match the expected subnet.
102
+ */
103
+ function extractOctet(tunnelIp: string): number | null {
104
+ if (!tunnelIp.startsWith(TUNNEL_SUBNET_PREFIX)) {
105
+ return null
106
+ }
107
+ const octetStr = tunnelIp.slice(TUNNEL_SUBNET_PREFIX.length)
108
+ const octet = Number.parseInt(octetStr, 10)
109
+ return Number.isFinite(octet) && octet >= FIRST_CLIENT_OCTET ? octet : null
110
+ }
111
+
112
+ // Last valid client octet: .254 (.1 is server, .255 is broadcast)
113
+ const LAST_CLIENT_OCTET = 254
114
+
115
+ /**
116
+ * Compute the next available tunnel IP.
117
+ * Sequential 10.8.0.N/32 starting at .2 (.1 is reserved for the server).
118
+ * Reuses freed slots — returns the lowest free N >= 2.
119
+ * Throws when all 253 client slots (.2–.254) are exhausted.
120
+ */
121
+ export function nextTunnelIp(peers: Peer[]): string {
122
+ const allocated = new Set<number>()
123
+ for (const peer of peers) {
124
+ const octet = extractOctet(peer.tunnelIp)
125
+ if (octet !== null) {
126
+ allocated.add(octet)
127
+ }
128
+ }
129
+
130
+ let n = FIRST_CLIENT_OCTET
131
+ while (allocated.has(n) || n === SERVER_OCTET) {
132
+ n++
133
+ }
134
+
135
+ if (n > LAST_CLIENT_OCTET) {
136
+ throw new Error(`Tunnel subnet 10.8.0.0/24 exhausted (max 253 peers).`)
137
+ }
138
+
139
+ return `${TUNNEL_SUBNET_PREFIX}${n}`
140
+ }
141
+
142
+ // ── Peer mutations (pure — return new arrays, never mutate) ───────────────────
143
+
144
+ /**
145
+ * Add a peer with the next available tunnel IP.
146
+ * Throws if a peer with the same name already exists.
147
+ */
148
+ export function addPeer(
149
+ peers: Peer[],
150
+ {name, publicKey}: {name: string; publicKey: string},
151
+ ): {peers: Peer[]; tunnelIp: string} {
152
+ const duplicate = peers.find(p => p.name === name)
153
+ if (duplicate !== undefined) {
154
+ throw new Error(`Peer "${name}" already exists (duplicate name).`)
155
+ }
156
+
157
+ const tunnelIp = nextTunnelIp(peers)
158
+ const newPeer: Peer = {name, publicKey, tunnelIp}
159
+ return {peers: [...peers, newPeer], tunnelIp}
160
+ }
161
+
162
+ /**
163
+ * Remove a peer by name.
164
+ * Throws a clear error if the peer does not exist — no silent no-op.
165
+ */
166
+ export function removePeer(peers: Peer[], name: string): {peers: Peer[]} {
167
+ const index = peers.findIndex(p => p.name === name)
168
+ if (index === -1) {
169
+ throw new Error(`Peer "${name}" not found. Cannot remove a peer that does not exist.`)
170
+ }
171
+
172
+ const updated = [...peers.slice(0, index), ...peers.slice(index + 1)]
173
+ return {peers: updated}
174
+ }
175
+
176
+ // ── Config rendering (pure — string in → string out, no IO) ──────────────────
177
+
178
+ export interface ServerConfigOpts {
179
+ wanInterface?: string
180
+ }
181
+
182
+ /**
183
+ * Render a full wg0.conf server configuration.
184
+ *
185
+ * Pure function: takes keys and peers as inputs, returns a config string.
186
+ * The server private key is passed in (it lives on the box; this function
187
+ * is used server-side where the key is available locally).
188
+ */
189
+ export function renderServerConfig(serverPrivateKey: string, peers: Peer[], opts?: ServerConfigOpts): string {
190
+ const wanInterface = opts?.wanInterface ?? 'eth0'
191
+
192
+ const lines: string[] = [
193
+ '[Interface]',
194
+ `Address = 10.8.0.1/24`,
195
+ `ListenPort = 51820`,
196
+ `PrivateKey = ${serverPrivateKey}`,
197
+ `PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o ${wanInterface} -j MASQUERADE`,
198
+ `PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o ${wanInterface} -j MASQUERADE`,
199
+ ]
200
+
201
+ for (const peer of peers) {
202
+ lines.push('')
203
+ lines.push('[Peer]')
204
+ lines.push(`PublicKey = ${peer.publicKey}`)
205
+ lines.push(`AllowedIPs = ${peer.tunnelIp}/32`)
206
+ }
207
+
208
+ return `${lines.join('\n')}\n`
209
+ }
210
+
211
+ export interface ClientConfigOpts {
212
+ clientPrivateKey: string
213
+ serverPublicKey: string
214
+ endpoint: string
215
+ tunnelIp: string
216
+ allowedIps?: string
217
+ dns?: string
218
+ }
219
+
220
+ /**
221
+ * Render a WireGuard client .conf file.
222
+ *
223
+ * Pure function: takes all config as inputs, returns a config string.
224
+ * allowedIps defaults to '0.0.0.0/0' (full tunnel).
225
+ * Pass specific CIDRs for split-tunnel.
226
+ */
227
+ export function renderClientConfig(opts: ClientConfigOpts): string {
228
+ const {clientPrivateKey, serverPublicKey, endpoint, tunnelIp, allowedIps = '0.0.0.0/0', dns} = opts
229
+
230
+ const lines: string[] = ['[Interface]', `PrivateKey = ${clientPrivateKey}`, `Address = ${tunnelIp}/32`]
231
+
232
+ if (dns !== undefined) {
233
+ lines.push(`DNS = ${dns}`)
234
+ }
235
+
236
+ lines.push('')
237
+ lines.push('[Peer]')
238
+ lines.push(`PublicKey = ${serverPublicKey}`)
239
+ lines.push(`Endpoint = ${endpoint}:51820`)
240
+ lines.push(`AllowedIPs = ${allowedIps}`)
241
+
242
+ return `${lines.join('\n')}\n`
243
+ }