@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
|
+
}
|