underpost 3.2.90 → 3.3.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.
Files changed (71) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +110 -1
  6. package/CLI-HELP.md +139 -9
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +1 -1
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/package.json +5 -5
  19. package/scripts/audit-selinux.sh +64 -0
  20. package/scripts/coverall-test.sh +24 -0
  21. package/scripts/gpu-diag.sh +0 -0
  22. package/scripts/ip-info.sh +0 -0
  23. package/scripts/k3s-node-setup.sh +18 -15
  24. package/scripts/kubeadm-node-setup.sh +12 -23
  25. package/scripts/link-local-underpost-cli.sh +0 -0
  26. package/scripts/lxd-vm-setup.sh +0 -0
  27. package/scripts/maas-nat-firewalld.sh +0 -0
  28. package/scripts/nat-iptables.sh +2 -0
  29. package/scripts/rhel-grpc-setup.sh +0 -0
  30. package/scripts/rocky-kickstart.sh +25 -9
  31. package/scripts/test-monitor.sh +1 -1
  32. package/src/cli/baremetal.js +1 -2
  33. package/src/cli/cloud-init.js +1 -1
  34. package/src/cli/cluster.js +73 -68
  35. package/src/cli/db.js +9 -2
  36. package/src/cli/deploy.js +21 -5
  37. package/src/cli/docker-compose.js +1 -1
  38. package/src/cli/env.js +1 -1
  39. package/src/cli/image.js +0 -1
  40. package/src/cli/index.js +121 -9
  41. package/src/cli/lxd.js +1 -1
  42. package/src/cli/monitor.js +1 -1
  43. package/src/cli/release.js +57 -22
  44. package/src/cli/repository.js +11 -9
  45. package/src/cli/run.js +36 -9
  46. package/src/cli/ssh.js +198 -77
  47. package/src/cli/system.js +26 -13
  48. package/src/cli/test.js +1 -1
  49. package/src/cli/vultr.js +583 -0
  50. package/src/cli/wireguard.js +2125 -0
  51. package/src/client-builder/client-build.js +20 -14
  52. package/src/db/mongo/MongooseDB.js +4 -0
  53. package/src/index.js +25 -1
  54. package/src/projects/underpost/catalog-underpost.js +4 -1
  55. package/src/server/backup.js +1 -1
  56. package/src/server/conf.js +18 -108
  57. package/src/server/cron.js +249 -51
  58. package/src/server/dns.js +100 -6
  59. package/src/server/environment.js +98 -0
  60. package/src/server/forward-proxy.js +549 -0
  61. package/src/server/middlewares.js +56 -1
  62. package/src/server/process.js +0 -1
  63. package/src/server/selinux.js +185 -0
  64. package/src/server/systemd.js +205 -0
  65. package/src/server/underpost-compression.js +186 -0
  66. package/src/server/underpost-gateway.js +20 -10
  67. package/src/server/underpost-ingress.js +18 -2
  68. package/test/selinux.test.js +71 -0
  69. package/test/underpost-gateway.test.js +41 -0
  70. package/test/underpost-ingress.test.js +52 -0
  71. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,2125 @@
1
+ /** WireGuard overlay and HAProxy edge routing lifecycle. */
2
+
3
+ import fs from 'fs-extra';
4
+ import os from 'node:os';
5
+ import nodePath from 'node:path';
6
+ import { getConfFilePath, loadConfInstances, loadConfServerJson, resolveDeployList } from '../server/conf.js';
7
+ import {
8
+ FORWARD_PROXY,
9
+ forwardProxyCommandFactory,
10
+ forwardProxyConfigFactory,
11
+ forwardProxyNodeCandidatesFactory,
12
+ forwardProxyNodeProbeCommandFactory,
13
+ forwardProxyServerFactory,
14
+ forwardProxyServiceCommandsFactory,
15
+ forwardProxyStartProbeCommandFactory,
16
+ forwardProxyUnitFactory,
17
+ } from '../server/forward-proxy.js';
18
+ import { loggerFactory } from '../server/logger.js';
19
+ import { shellExec } from '../server/process.js';
20
+ import {
21
+ homeDirectoryPathFactory,
22
+ journalctlCommandFactory,
23
+ runSystemdCommands,
24
+ systemctlCommandFactory,
25
+ systemdAvailableCommandFactory,
26
+ systemdReloadIfActiveCommandFactory,
27
+ systemdStatusCommandsFactory,
28
+ } from '../server/systemd.js';
29
+
30
+ const logger = loggerFactory(import.meta);
31
+
32
+ /**
33
+ * @constant UNDERPOST_EDGE
34
+ * @description Fixed identity of the edge subsystem: paths, ports and resource
35
+ * names that both ends of a tunnel have to agree on.
36
+ * @memberof UnderpostWireguard
37
+ */
38
+ const UNDERPOST_EDGE = {
39
+ interfaceName: 'wg0',
40
+ listenPort: 51820,
41
+ // The hub's own address, and the subnet a spoke routes back through it. Both
42
+ // sides of every tunnel have to agree on the second one, so it is declared
43
+ // once here rather than defaulted separately at each end.
44
+ cidr: '10.0.0.1/24',
45
+ tunnelCidr: '10.0.0.0/24',
46
+ // Held below the 30s NAT mapping most consumer routers use, so a spoke's
47
+ // outbound session is refreshed before the ISP forgets it exists.
48
+ keepalive: 25,
49
+ wireguardDir: '/etc/wireguard',
50
+ haproxyDir: '/etc/haproxy',
51
+ haproxyConfName: 'haproxy.cfg',
52
+ sniMapName: 'domain2backend.map',
53
+ httpMapName: 'domain2backend-http.map',
54
+ statsSocket: '/var/lib/haproxy/stats',
55
+ natChainPrefix: 'UNDERPOST_WG',
56
+ sysctlPath: '/etc/sysctl.d/99-underpost-wireguard.conf',
57
+ packages: ['wireguard-tools', 'haproxy', 'iptables'],
58
+ httpPort: 80,
59
+ httpsPort: 443,
60
+ // Spoke-side SSH. The public port is per-edge and lives in the registry,
61
+ // because exposing SSH is a decision, not a default.
62
+ sshPort: 22,
63
+ defaultSshForwardPort: 2222,
64
+ // A peer is considered live within this window: WireGuard re-handshakes at
65
+ // least every 120s while traffic flows, so a longer gap means the link is
66
+ // down rather than merely idle.
67
+ handshakeStaleSeconds: 180,
68
+ };
69
+
70
+ /**
71
+ * @method deployIdFactory
72
+ * @description Normalizes a deploy id to the `dd-<conf-id>` convention.
73
+ * @param {string} deployId - Deploy id, with or without the prefix.
74
+ * @returns {string} Prefixed deploy id.
75
+ * @memberof UnderpostWireguard
76
+ */
77
+ const deployIdFactory = (deployId) => {
78
+ const value = `${deployId || ''}`.trim();
79
+ // `dd` is the meta id every runner reads as "all of dd.router"; prefixing it
80
+ // would turn it into a deploy that does not exist.
81
+ if (!value || value === 'dd') return value;
82
+ return value.startsWith('dd-') ? value : `dd-${value}`;
83
+ };
84
+
85
+ /**
86
+ * @method deployListFactory
87
+ * @description The deploys whose hostnames a run publishes.
88
+ *
89
+ * `dd` expands through the same `dd.router` read every other runner uses, so
90
+ * the edge routes exactly the set the cluster deploys — a hostname cannot be
91
+ * live in the cluster and absent from the edge because two lists drifted.
92
+ * @param {string} deployId - Deploy id, comma-separated list, or `dd`.
93
+ * @returns {Array<string>} Normalized deploy ids.
94
+ * @memberof UnderpostWireguard
95
+ */
96
+ const deployListFactory = (deployId) => {
97
+ const value = `${deployId || ''}`.trim();
98
+ if (!value) return [];
99
+ if (value === 'dd') return resolveDeployList('dd').map(deployIdFactory).filter(Boolean);
100
+ return value.split(',').map(deployIdFactory).filter(Boolean);
101
+ };
102
+
103
+ /**
104
+ * @constant EDGE_STATE_PATH
105
+ * @description Location of the peer registry.
106
+ *
107
+ * Cluster-wide rather than per deploy, and stored beside `dd.router` for that
108
+ * reason: the hub has one interface, one address and one peer table, and those
109
+ * are properties of the machine. A copy per deploy would be several records of
110
+ * one fact, free to disagree. `--deploy-id` selects which hostnames are routed
111
+ * across it, not which tunnel exists.
112
+ *
113
+ * Holds public keys only — the private half never leaves `/etc/wireguard` on
114
+ * the host that generated it.
115
+ * @memberof UnderpostWireguard
116
+ */
117
+ const EDGE_STATE_PATH = './engine-private/deploy/conf.wireguard.json';
118
+
119
+ /**
120
+ * @method peerFactory
121
+ * @description Normalizes one spoke entry, filling the fields a partially
122
+ * written registry may omit.
123
+ *
124
+ * `allowedIPs` defaults to the peer's own tunnel address alone: a spoke routes
125
+ * its LAN only when the registry says so, so a mistyped entry cannot silently
126
+ * claim a subnet another spoke already answers for.
127
+ * @param {object} peer - Raw registry entry.
128
+ * @returns {object} Normalized entry.
129
+ * @memberof UnderpostWireguard
130
+ */
131
+ const peerFactory = (peer = {}) => {
132
+ const address = `${peer.address || ''}`.trim();
133
+ const allowedIPs = (Array.isArray(peer.allowedIPs) ? peer.allowedIPs : [])
134
+ .map((entry) => `${entry || ''}`.trim())
135
+ .filter(Boolean);
136
+ return {
137
+ id: `${peer.id || ''}`.trim(),
138
+ address,
139
+ publicKey: `${peer.publicKey || ''}`.trim(),
140
+ allowedIPs: allowedIPs.length > 0 ? allowedIPs : address ? [`${address}/32`] : [],
141
+ hosts: (Array.isArray(peer.hosts) ? peer.hosts : []).map((host) => `${host}`.trim().toLowerCase()).filter(Boolean),
142
+ instances: (Array.isArray(peer.instances) ? peer.instances : []).map((id) => `${id}`.trim()).filter(Boolean),
143
+ default: peer.default === true,
144
+ };
145
+ };
146
+
147
+ /**
148
+ * @method defaultPeerFactory
149
+ * @description The spoke an unmatched hostname falls back to, and the one QUIC
150
+ * is forwarded to.
151
+ *
152
+ * A lone peer is its own fallback: a single-spoke hub has nowhere else to send
153
+ * a hostname, and requiring `"default": true` there would only be ceremony.
154
+ * With several peers and none nominated there is no fallback at all, so nothing
155
+ * is dispatched somewhere arbitrary.
156
+ * @param {Array<object>} [peers] - Normalized registry entries.
157
+ * @returns {?object} The fallback peer, or null.
158
+ * @memberof UnderpostWireguard
159
+ */
160
+ const defaultPeerFactory = (peers = []) =>
161
+ peers.find((peer) => peer.default === true) || (peers.length === 1 ? peers[0] : null);
162
+
163
+ /**
164
+ * @method edgeStateFactory
165
+ * @description Normalizes the registry as a whole, so every consumer reads one
166
+ * shape whether the file exists, is partial, or is absent.
167
+ *
168
+ * `endpoint` and `hubPublicKey` are the pair a spoke needs to rebuild its own
169
+ * interface: the hub it dials, and the identity it expects there. Both are
170
+ * recorded so re-running the setup does not require re-supplying them, which is
171
+ * what makes a spoke's bring-up repeatable and `--wireguard-reinstall` possible
172
+ * on a spoke at all. Neither is secret — a public key is public.
173
+ * @param {object} [state] - Raw registry contents.
174
+ * @returns {object} Normalized registry.
175
+ * @memberof UnderpostWireguard
176
+ */
177
+ const edgeStateFactory = (state = {}) => ({
178
+ interfaceName: `${state.interfaceName || UNDERPOST_EDGE.interfaceName}`.trim(),
179
+ role: `${state.role || ''}`.trim(),
180
+ listenPort: Number(state.listenPort) > 0 ? Number(state.listenPort) : UNDERPOST_EDGE.listenPort,
181
+ address: `${state.address || ''}`.trim(),
182
+ endpoint: `${state.endpoint || ''}`.trim(),
183
+ hubPublicKey: `${state.hubPublicKey || ''}`.trim(),
184
+ publicKey: `${state.publicKey || ''}`.trim(),
185
+ // 0 means the edge publishes no SSH port at all, which is the default: an
186
+ // edge that forwards SSH is one someone deliberately opened.
187
+ sshForwardPort: Number(state.sshForwardPort) > 0 ? Number(state.sshForwardPort) : 0,
188
+ peers: (Array.isArray(state.peers) ? state.peers : []).map(peerFactory).filter((peer) => peer.id),
189
+ });
190
+
191
+ /**
192
+ * @method readEdgeState
193
+ * @description The peer registry. A missing or malformed file is treated as an
194
+ * empty registry rather than an error: the first `--wireguard-setup` on a fresh
195
+ * host runs before any registry exists.
196
+ * @returns {object} Normalized registry.
197
+ * @memberof UnderpostWireguard
198
+ */
199
+ const readEdgeState = () => {
200
+ if (!fs.existsSync(EDGE_STATE_PATH)) return edgeStateFactory();
201
+ try {
202
+ return edgeStateFactory(JSON.parse(fs.readFileSync(EDGE_STATE_PATH, 'utf8')));
203
+ } catch (error) {
204
+ logger.warn('Ignoring unreadable wireguard registry', { target: EDGE_STATE_PATH, message: error.message });
205
+ return edgeStateFactory();
206
+ }
207
+ };
208
+
209
+ /**
210
+ * @method writeEdgeState
211
+ * @description Persists the peer registry.
212
+ * @param {object} state - Registry to write.
213
+ * @returns {boolean} True when the file changed.
214
+ * @memberof UnderpostWireguard
215
+ */
216
+ const writeEdgeState = (state) => {
217
+ const target = EDGE_STATE_PATH;
218
+ const next = `${JSON.stringify(edgeStateFactory(state), null, 2)}\n`;
219
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
220
+ if (current === next) return false;
221
+ fs.mkdirpSync(nodePath.dirname(target));
222
+ fs.writeFileSync(target, next, 'utf8');
223
+ return true;
224
+ };
225
+
226
+ /**
227
+ * @method redirectHostFactory
228
+ * @description The hostname a `redirect` points at.
229
+ * @param {string} [redirect] - Absolute URL or bare hostname.
230
+ * @returns {string} Hostname, or an empty string when there is none.
231
+ * @memberof UnderpostWireguard
232
+ */
233
+ const redirectHostFactory = (redirect) => {
234
+ const value = `${redirect || ''}`.trim();
235
+ if (!value) return '';
236
+ try {
237
+ return new URL(value.includes('://') ? value : `https://${value}`).hostname.toLowerCase();
238
+ } catch {
239
+ return '';
240
+ }
241
+ };
242
+
243
+ /**
244
+ * @method hostProxyEntriesFactory
245
+ * @description The hostnames a `conf.server.json` publishes through the edge,
246
+ * with the ports each one claims.
247
+ *
248
+ * A sub-path's `proxy` array is the declaration that the hostname is reachable
249
+ * from outside; a host whose sub-paths declare none is internal and gets no
250
+ * route. Ports are unioned across sub-paths because the edge routes a hostname,
251
+ * not a sub-path — a single stream carries every path the host serves.
252
+ * @param {object} confServer - Parsed `conf.server.json`.
253
+ * @returns {Array<{host: string, ports: Array<number>, redirects: Array<string>}>} One entry per published hostname.
254
+ * @memberof UnderpostWireguard
255
+ */
256
+ const hostProxyEntriesFactory = ({ confServer = {} } = {}) => {
257
+ const entries = [];
258
+ for (const host of Object.keys(confServer)) {
259
+ const paths = confServer[host] || {};
260
+ const ports = new Set();
261
+ const redirects = new Set();
262
+ for (const path of Object.keys(paths)) {
263
+ const node = paths[path] || {};
264
+ for (const port of Array.isArray(node.proxy) ? node.proxy : [])
265
+ if (Number.isInteger(Number(port))) ports.add(Number(port));
266
+ const redirect = redirectHostFactory(node.redirect);
267
+ if (redirect) redirects.add(redirect);
268
+ }
269
+ if (ports.size === 0) continue;
270
+ entries.push({
271
+ host: `${host}`.toLowerCase(),
272
+ ports: [...ports].sort((a, b) => a - b),
273
+ redirects: [...redirects],
274
+ });
275
+ }
276
+ return entries.sort((a, b) => a.host.localeCompare(b.host));
277
+ };
278
+
279
+ /**
280
+ * @method instanceProxyEntriesFactory
281
+ * @description The hostnames a deploy's instances publish.
282
+ *
283
+ * Instances declare no `proxy` array — their hostname reaches the same cluster
284
+ * ingress the deploy's own hosts do, so it terminates the same two ports. Every
285
+ * variant of a family resolves through both its own id and its template id, so
286
+ * a registry can bind a whole family with one entry.
287
+ * @param {Array<object>} [instances] - Expanded entries from `loadConfInstances`.
288
+ * @returns {Array<{host: string, ports: Array<number>, instances: Array<string>}>} One entry per instance hostname.
289
+ * @memberof UnderpostWireguard
290
+ */
291
+ const instanceProxyEntriesFactory = ({ instances = [] } = {}) => {
292
+ const byHost = new Map();
293
+ for (const instance of instances) {
294
+ const host = `${instance?.host || ''}`.trim().toLowerCase();
295
+ if (!host) continue;
296
+ if (!byHost.has(host))
297
+ byHost.set(host, { host, ports: [UNDERPOST_EDGE.httpPort, UNDERPOST_EDGE.httpsPort], instances: [] });
298
+ const ids = byHost.get(host).instances;
299
+ for (const id of [instance.id, instance.templateId].filter(Boolean)) if (!ids.includes(`${id}`)) ids.push(`${id}`);
300
+ }
301
+ return [...byHost.values()].sort((a, b) => a.host.localeCompare(b.host));
302
+ };
303
+
304
+ /**
305
+ * @method edgeRouteTableFactory
306
+ * @description Resolves every published hostname to the spoke that serves it.
307
+ *
308
+ * Resolution is ordered from most specific to least: an explicit hostname
309
+ * binding, then an instance id, then the hostname a redirect points at, then the
310
+ * default spoke. The redirect hop exists because a redirect host publishes
311
+ * nothing of its own — `dogmadual.com` only says "go to `www.dogmadual.com`",
312
+ * and the spoke that answers the target is the one that has to answer the
313
+ * redirect too.
314
+ *
315
+ * A hostname that resolves to nothing is returned in `unresolved` rather than
316
+ * dropped: a silently missing route is a hostname that answers nothing at all,
317
+ * which is invisible until someone reports the outage.
318
+ * @param {object} [confServer] - Parsed `conf.server.json`.
319
+ * @param {Array<object>} [instances] - Expanded instance entries.
320
+ * @param {Array<object>} [peers] - Registry entries.
321
+ * @returns {{routes: Array<object>, unresolved: Array<string>, peers: Array<object>}} Route table, unbound hostnames, and the peers actually referenced.
322
+ * @memberof UnderpostWireguard
323
+ */
324
+ const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } = {}) => {
325
+ const entries = new Map();
326
+ for (const entry of hostProxyEntriesFactory({ confServer })) entries.set(entry.host, { ...entry, instances: [] });
327
+ for (const entry of instanceProxyEntriesFactory({ instances })) {
328
+ const current = entries.get(entry.host);
329
+ if (!current) entries.set(entry.host, { ...entry, redirects: [] });
330
+ else {
331
+ current.ports = [...new Set([...current.ports, ...entry.ports])].sort((a, b) => a - b);
332
+ current.instances = [...new Set([...current.instances, ...entry.instances])];
333
+ }
334
+ }
335
+
336
+ const list = peers.map(peerFactory).filter((peer) => peer.id && peer.address);
337
+ const byHost = new Map();
338
+ const byInstance = new Map();
339
+ // First declaration wins, so a duplicated binding is deterministic rather
340
+ // than dependent on registry order changing under an edit.
341
+ for (const peer of list) {
342
+ for (const host of peer.hosts) if (!byHost.has(host)) byHost.set(host, peer);
343
+ for (const id of peer.instances) if (!byInstance.has(id)) byInstance.set(id, peer);
344
+ }
345
+ const fallback = defaultPeerFactory(list);
346
+
347
+ const resolve = (entry, seen) => {
348
+ if (byHost.has(entry.host)) return { peer: byHost.get(entry.host), via: 'host' };
349
+ for (const id of entry.instances) if (byInstance.has(id)) return { peer: byInstance.get(id), via: 'instance' };
350
+ for (const target of entry.redirects) {
351
+ if (seen.has(target)) continue;
352
+ const targetEntry = entries.get(target);
353
+ if (!targetEntry) continue;
354
+ const resolved = resolve(targetEntry, new Set([...seen, entry.host]));
355
+ if (resolved.peer && resolved.via !== 'default') return { peer: resolved.peer, via: 'redirect' };
356
+ }
357
+ return fallback ? { peer: fallback, via: 'default' } : { peer: null, via: '' };
358
+ };
359
+
360
+ const routes = [];
361
+ const unresolved = [];
362
+ for (const entry of [...entries.values()].sort((a, b) => a.host.localeCompare(b.host))) {
363
+ const { peer, via } = resolve(entry, new Set([entry.host]));
364
+ if (!peer) {
365
+ unresolved.push(entry.host);
366
+ continue;
367
+ }
368
+ routes.push({ host: entry.host, ports: entry.ports, peerId: peer.id, address: peer.address, via });
369
+ }
370
+ const referenced = new Set(routes.map((route) => route.peerId));
371
+ return { routes, unresolved, peers: list.filter((peer) => referenced.has(peer.id) || peer === fallback) };
372
+ };
373
+
374
+ /**
375
+ * @method allowedIpsConflictsFactory
376
+ * @description CIDRs that more than one peer claims.
377
+ *
378
+ * WireGuard resolves an outbound packet to a peer by longest-prefix match over
379
+ * every peer's `AllowedIPs`, so a CIDR claimed twice is not shared — one peer
380
+ * wins and the other silently never receives that traffic. Homelabs routinely
381
+ * sit on the same `192.168.1.0/24`, which makes this the likeliest way a
382
+ * multi-spoke registry breaks.
383
+ *
384
+ * Duplicate tunnel addresses are caught by the same pass, because an address
385
+ * with no explicit `allowedIPs` contributes its own `/32`.
386
+ * @param {Array<object>} [peers] - Registry entries.
387
+ * @returns {Array<{cidr: string, peers: Array<string>}>} Contested CIDRs.
388
+ * @memberof UnderpostWireguard
389
+ */
390
+ const allowedIpsConflictsFactory = ({ peers = [] } = {}) => {
391
+ const byCidr = new Map();
392
+ for (const peer of peers.map(peerFactory))
393
+ for (const cidr of peer.allowedIPs) byCidr.set(cidr, [...new Set([...(byCidr.get(cidr) || []), peer.id])]);
394
+ return [...byCidr.entries()]
395
+ .filter(([, ids]) => ids.length > 1)
396
+ .map(([cidr, ids]) => ({ cidr, peers: ids.sort() }))
397
+ .sort((a, b) => a.cidr.localeCompare(b.cidr));
398
+ };
399
+
400
+ /**
401
+ * @method mergeRouteTablesFactory
402
+ * @description Folds one route table per deploy into the single table the edge
403
+ * publishes.
404
+ *
405
+ * The edge is one machine holding one pair of map files, so every deploy in
406
+ * `dd.router` has to be compiled together — publishing one deploy's table alone
407
+ * would overwrite the maps and take every other deploy's hostname off the
408
+ * internet. Each route records the deploy that contributed it, which is the
409
+ * only way to attribute a hostname once the tables are merged.
410
+ *
411
+ * A hostname two deploys both claim is a configuration error, not a merge to
412
+ * resolve: one of them is not being served. First wins so the output is
413
+ * deterministic, and the collision is reported.
414
+ * @param {Array<{deployId: string, routes: Array<object>, unresolved: Array<string>, peers: Array<object>}>} [tables] - Per-deploy tables.
415
+ * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, peers: Array<object>}} Merged table.
416
+ * @memberof UnderpostWireguard
417
+ */
418
+ const mergeRouteTablesFactory = ({ tables = [] } = {}) => {
419
+ const routes = new Map();
420
+ const conflicts = [];
421
+ const unresolved = [];
422
+ const peers = new Map();
423
+ for (const table of tables) {
424
+ for (const route of table.routes || []) {
425
+ const current = routes.get(route.host);
426
+ if (!current) {
427
+ routes.set(route.host, { ...route, deployId: table.deployId });
428
+ continue;
429
+ }
430
+ if (current.peerId !== route.peerId)
431
+ conflicts.push({ host: route.host, claimed: [current.deployId, table.deployId], serving: current.peerId });
432
+ }
433
+ for (const host of table.unresolved || []) unresolved.push({ host, deployId: table.deployId });
434
+ for (const peer of table.peers || []) if (!peers.has(peer.id)) peers.set(peer.id, peer);
435
+ }
436
+ return {
437
+ routes: [...routes.values()].sort((a, b) => a.host.localeCompare(b.host)),
438
+ unresolved: unresolved.sort((a, b) => a.host.localeCompare(b.host)),
439
+ conflicts,
440
+ peers: [...peers.values()].sort((a, b) => a.id.localeCompare(b.id)),
441
+ };
442
+ };
443
+
444
+ /**
445
+ * @method backendNameFactory
446
+ * @description A peer id as an HAProxy proxy name. Only word characters are
447
+ * safe in one, and the prefix keeps the two transports of a single spoke
448
+ * distinguishable in `haproxy -c` output and the stats socket.
449
+ * @param {string} kind - `http` or `tls`.
450
+ * @param {string} peerId - Registry peer id.
451
+ * @returns {string} Backend name.
452
+ * @memberof UnderpostWireguard
453
+ */
454
+ const backendNameFactory = (kind, peerId) => `be_${kind}_${`${peerId || 'default'}`.replace(/[^a-zA-Z0-9]/g, '_')}`;
455
+
456
+ /**
457
+ * @method haproxyMapsFactory
458
+ * @description The two lookup tables the frontends select a backend with.
459
+ *
460
+ * Split by transport because the two are matched on different things — a
461
+ * plaintext `Host` header and a TLS SNI extension — and a hostname may publish
462
+ * one port without the other.
463
+ * @param {Array<object>} [routes] - Route table from {@link UnderpostWireguard.edgeRouteTableFactory}.
464
+ * @returns {{sni: string, http: string}} Rendered map files.
465
+ * @memberof UnderpostWireguard
466
+ */
467
+ const haproxyMapsFactory = ({ routes = [] } = {}) => {
468
+ const render = (port, kind) =>
469
+ routes
470
+ .filter((route) => route.peerId && route.ports.includes(port))
471
+ .map((route) => `${route.host} ${backendNameFactory(kind, route.peerId)}`)
472
+ .join('\n');
473
+ const sni = render(UNDERPOST_EDGE.httpsPort, 'tls');
474
+ const http = render(UNDERPOST_EDGE.httpPort, 'http');
475
+ return { sni: sni ? `${sni}\n` : '', http: http ? `${http}\n` : '' };
476
+ };
477
+
478
+ /**
479
+ * @method haproxyConfFactory
480
+ * @description Renders the public edge gateway.
481
+ *
482
+ * There is no `bind ... ssl` line anywhere in the output, and that absence is
483
+ * the design: the `:443` frontend runs in TCP mode and selects a backend from
484
+ * `req.ssl_sni`, which reads the ClientHello without decrypting it. The edge
485
+ * therefore holds no certificate and no private key for any hostname it routes.
486
+ *
487
+ * An unmatched hostname reaches the default spoke, or — with no default
488
+ * declared — is refused rather than dispatched somewhere arbitrary. `421` is
489
+ * the accurate answer for cleartext: the request reached a server that does not
490
+ * serve that authority.
491
+ * `sshForwardPort` adds the one frontend that is not hostname-routed. SSH
492
+ * carries no SNI and no `Host`, so there is nothing to route on: the port goes
493
+ * whole to the fallback spoke, exactly as UDP/443 does. It exists because a
494
+ * spoke behind CGNAT is otherwise unreachable for CI — the deploy runs on the
495
+ * cluster, and the only public address in the topology is the hub's.
496
+ * @param {Array<object>} [peers] - Peers to emit backends for.
497
+ * @param {string} [defaultPeerId] - Peer an unmatched hostname falls back to.
498
+ * @param {string} [mapDir] - Directory holding the map files.
499
+ * @param {number} [sshForwardPort] - Public port forwarded to the spoke's SSH; 0 emits no frontend.
500
+ * @returns {string} haproxy.cfg contents.
501
+ * @memberof UnderpostWireguard
502
+ */
503
+ const haproxyConfFactory = ({
504
+ peers = [],
505
+ defaultPeerId = '',
506
+ mapDir = UNDERPOST_EDGE.haproxyDir,
507
+ sshForwardPort = 0,
508
+ } = {}) => {
509
+ const sniMap = `${mapDir}/${UNDERPOST_EDGE.sniMapName}`;
510
+ const httpMap = `${mapDir}/${UNDERPOST_EDGE.httpMapName}`;
511
+ const fallback = peers.find((peer) => peer.id === defaultPeerId) || defaultPeerFactory(peers);
512
+ const backends = peers
513
+ .map(
514
+ (peer) => `backend ${backendNameFactory('http', peer.id)}
515
+ mode http
516
+ server ${peer.id} ${peer.address}:${UNDERPOST_EDGE.httpPort} check
517
+
518
+ backend ${backendNameFactory('tls', peer.id)}
519
+ mode tcp
520
+ server ${peer.id} ${peer.address}:${UNDERPOST_EDGE.httpsPort} check`,
521
+ )
522
+ .join('\n\n');
523
+ const defaults = fallback
524
+ ? `backend ${backendNameFactory('http', 'default')}
525
+ mode http
526
+ server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.httpPort} check
527
+
528
+ backend ${backendNameFactory('tls', 'default')}
529
+ mode tcp
530
+ server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.httpsPort} check`
531
+ : `backend ${backendNameFactory('http', 'default')}
532
+ mode http
533
+ http-request deny deny_status 421
534
+
535
+ backend ${backendNameFactory('tls', 'default')}
536
+ mode tcp
537
+ tcp-request content reject`;
538
+
539
+ // Both conditions matter: no port means SSH was never opened, and no fallback
540
+ // means there is no spoke to send it to. Emitting a frontend without a
541
+ // backend would bind the port and refuse every connection on it.
542
+ const sshEnabled = Number(sshForwardPort) > 0 && Boolean(fallback);
543
+ const sshFrontend = sshEnabled
544
+ ? `
545
+ frontend fe_ssh
546
+ bind :${sshForwardPort}
547
+ mode tcp
548
+ option tcplog
549
+ # No inspect-delay: the server speaks first in the SSH protocol, so waiting
550
+ # for client bytes here would stall every connection until the timeout.
551
+ default_backend ${backendNameFactory('ssh', 'default')}
552
+ `
553
+ : '';
554
+ const sshBackend = sshEnabled
555
+ ? `
556
+
557
+ backend ${backendNameFactory('ssh', 'default')}
558
+ mode tcp
559
+ server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.sshPort} check`
560
+ : '';
561
+
562
+ return `# Generated by \`underpost wireguard --haproxy-sync\`. Do not edit by hand:
563
+ # every rule here is derived from conf.server.json, conf.instances.json and
564
+ # conf.wireguard.json, and the next sync overwrites the file.
565
+ global
566
+ log /dev/log local0
567
+ maxconn 20000
568
+ user haproxy
569
+ group haproxy
570
+ daemon
571
+ # \`expose-fd listeners\` is what makes a reload seamless: the incoming
572
+ # process inherits the listening sockets from the outgoing one, so no
573
+ # connection is refused while routes change.
574
+ stats socket ${UNDERPOST_EDGE.statsSocket} mode 660 level admin expose-fd listeners
575
+ stats timeout 30s
576
+
577
+ defaults
578
+ log global
579
+ option dontlognull
580
+ timeout connect 5s
581
+ timeout client 60s
582
+ timeout server 60s
583
+ # Websockets and other long-lived streams are held by the tunnel timeout,
584
+ # not the idle ones, or the edge would cut a healthy connection at 60s.
585
+ timeout tunnel 1h
586
+
587
+ frontend fe_http
588
+ bind :${UNDERPOST_EDGE.httpPort}
589
+ mode http
590
+ option httplog
591
+ option forwardfor
592
+ # Plaintext, so the Host header is readable without terminating anything.
593
+ # The port is stripped before lookup because a client may send \`host:80\`.
594
+ use_backend %[req.hdr(host),lower,word(1,:),map(${httpMap},${backendNameFactory('http', 'default')})]
595
+ default_backend ${backendNameFactory('http', 'default')}
596
+
597
+ frontend fe_https
598
+ bind :${UNDERPOST_EDGE.httpsPort}
599
+ mode tcp
600
+ option tcplog
601
+ # Wait for the ClientHello, read the SNI, forward the bytes untouched. A
602
+ # connection that never sends one falls to the default backend.
603
+ tcp-request inspect-delay 5s
604
+ tcp-request content accept if { req_ssl_hello_type 1 }
605
+ use_backend %[req.ssl_sni,lower,map(${sniMap},${backendNameFactory('tls', 'default')})]
606
+ default_backend ${backendNameFactory('tls', 'default')}
607
+ ${sshFrontend}
608
+ ${backends}${backends ? '\n\n' : ''}${defaults}${sshBackend}
609
+ `;
610
+ };
611
+
612
+ /**
613
+ * @method wireguardPrivateKeyDirective
614
+ * @description Loads the interface key from disk after the interface is up.
615
+ *
616
+ * The key is deliberately not inlined into the rendered config: keeping it in
617
+ * its own 0600 file means no rendered configuration, dry-run print, diff or log
618
+ * line can ever carry it, and the config itself becomes a pure function of the
619
+ * registry. `wg-quick` runs `PostUp` after `wg setconf`, so the interface is
620
+ * keyed before it forwards anything.
621
+ * @param {string} keyPath - Path of the private key file.
622
+ * @returns {string} `PostUp` directive.
623
+ * @memberof UnderpostWireguard
624
+ */
625
+ const wireguardPrivateKeyDirective = (keyPath) => `PostUp = wg set %i private-key ${keyPath}`;
626
+
627
+ /**
628
+ * @method wireguardClientForwardingDirectivesFactory
629
+ * @description Lets workloads behind a spoke reach tunnel services using the
630
+ * spoke's WireGuard address. The destination-scoped masquerade avoids exposing
631
+ * Kubernetes pod CIDRs to the hub or changing ordinary pod egress.
632
+ * @param {string} cidr - Tunnel CIDR reachable through the hub.
633
+ * @returns {string} Paired `wg-quick` lifecycle directives.
634
+ * @memberof UnderpostWireguard
635
+ */
636
+ const wireguardClientForwardingDirectivesFactory = (cidr) => {
637
+ const outbound = `FORWARD -o %i -d ${cidr} -j ACCEPT`;
638
+ const inbound = `FORWARD -i %i -s ${cidr} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT`;
639
+ const masquerade = `POSTROUTING -o %i -d ${cidr} -j MASQUERADE`;
640
+ return [
641
+ 'PostUp = sysctl -q -w net.ipv4.ip_forward=1',
642
+ `PostUp = iptables -C ${outbound} 2>/dev/null || iptables -I ${outbound}`,
643
+ `PostUp = iptables -C ${inbound} 2>/dev/null || iptables -I ${inbound}`,
644
+ `PostUp = iptables -t nat -C ${masquerade} 2>/dev/null || iptables -t nat -I ${masquerade}`,
645
+ `PostDown = iptables -t nat -D ${masquerade} 2>/dev/null || true`,
646
+ `PostDown = iptables -D ${inbound} 2>/dev/null || true`,
647
+ `PostDown = iptables -D ${outbound} 2>/dev/null || true`,
648
+ ].join('\n');
649
+ };
650
+
651
+ /**
652
+ * @method wireguardClientSettingsFactory
653
+ * @description Reads the non-secret settings needed to reconcile an installed
654
+ * spoke when its shared registry has no machine-local client fields.
655
+ * @param {string} [conf] - Selected WireGuard configuration directives.
656
+ * @returns {{address: string, hubPublicKey: string, endpoint: string, cidr: string}}
657
+ * @memberof UnderpostWireguard
658
+ */
659
+ const wireguardClientSettingsFactory = (conf = '') => {
660
+ const values = new Map();
661
+ for (const line of `${conf}`.split('\n')) {
662
+ const match = line.match(/^\s*(Address|PublicKey|Endpoint|AllowedIPs)\s*=\s*(.*?)\s*$/i);
663
+ if (match && !values.has(match[1].toLowerCase())) values.set(match[1].toLowerCase(), match[2]);
664
+ }
665
+ return {
666
+ address: `${values.get('address') || ''}`.split(',')[0].trim(),
667
+ hubPublicKey: `${values.get('publickey') || ''}`.trim(),
668
+ endpoint: `${values.get('endpoint') || ''}`.trim(),
669
+ cidr: `${values.get('allowedips') || ''}`.split(',')[0].trim(),
670
+ };
671
+ };
672
+
673
+ /**
674
+ * @method wireguardServerConfFactory
675
+ * @description Renders the hub interface and its spoke table.
676
+ *
677
+ * Forwarding rules are attached to the interface rather than applied globally,
678
+ * so tearing the tunnel down removes them again and a stopped hub leaves no
679
+ * rule behind that outlives it.
680
+ * @param {string} [interfaceName] - Interface name.
681
+ * @param {string} address - Hub address with CIDR (`10.0.0.1/24`).
682
+ * @param {number} [listenPort] - UDP listen port.
683
+ * @param {string} keyPath - Path of the private key file.
684
+ * @param {Array<object>} [peers] - Registry entries.
685
+ * @returns {string} `wg-quick` configuration.
686
+ * @memberof UnderpostWireguard
687
+ */
688
+ const wireguardServerConfFactory = ({
689
+ interfaceName = UNDERPOST_EDGE.interfaceName,
690
+ address,
691
+ listenPort = UNDERPOST_EDGE.listenPort,
692
+ keyPath,
693
+ peers = [],
694
+ } = {}) => {
695
+ const peerBlocks = peers
696
+ .map(peerFactory)
697
+ .filter((peer) => peer.id && peer.publicKey && peer.allowedIPs.length > 0)
698
+ .map(
699
+ (peer) => `
700
+ [Peer]
701
+ # ${peer.id}
702
+ PublicKey = ${peer.publicKey}
703
+ AllowedIPs = ${peer.allowedIPs.join(', ')}`,
704
+ )
705
+ .join('\n');
706
+ return `# Generated by \`underpost wireguard --wireguard-setup --server\`. Do not edit by hand.
707
+ [Interface]
708
+ Address = ${address}
709
+ ListenPort = ${listenPort}
710
+ ${wireguardPrivateKeyDirective(keyPath)}
711
+ PostUp = sysctl -q -w net.ipv4.ip_forward=1
712
+ PostUp = iptables -I FORWARD -i ${interfaceName} -j ACCEPT
713
+ PostUp = iptables -I FORWARD -o ${interfaceName} -j ACCEPT
714
+ PostDown = iptables -D FORWARD -i ${interfaceName} -j ACCEPT
715
+ PostDown = iptables -D FORWARD -o ${interfaceName} -j ACCEPT
716
+ ${peerBlocks}
717
+ `;
718
+ };
719
+
720
+ /**
721
+ * @method wireguardClientConfFactory
722
+ * @description Renders a spoke interface.
723
+ *
724
+ * `AllowedIPs` is the tunnel CIDR alone, never `0.0.0.0/0`: the spoke is
725
+ * publishing services through the hub, not routing its own egress through it,
726
+ * and a default route here would send a whole cluster's outbound traffic across
727
+ * the VPS.
728
+ * @param {string} address - Spoke address (`10.0.0.2`, with or without CIDR).
729
+ * @param {string} keyPath - Path of the private key file.
730
+ * @param {string} publicKey - Hub public key.
731
+ * @param {string} endpoint - Hub `host:port`.
732
+ * @param {string} [cidr] - Tunnel CIDR reachable through the hub.
733
+ * @param {number} [keepalive] - Keepalive interval in seconds.
734
+ * @returns {string} `wg-quick` configuration.
735
+ * @memberof UnderpostWireguard
736
+ */
737
+ const wireguardClientConfFactory = ({
738
+ address,
739
+ keyPath,
740
+ publicKey,
741
+ endpoint,
742
+ cidr = UNDERPOST_EDGE.tunnelCidr,
743
+ keepalive = UNDERPOST_EDGE.keepalive,
744
+ } = {}) => `# Generated by \`underpost wireguard --wireguard-setup --client\`. Do not edit by hand.
745
+ [Interface]
746
+ Address = ${`${address}`.includes('/') ? address : `${address}/32`}
747
+ ${wireguardPrivateKeyDirective(keyPath)}
748
+ ${wireguardClientForwardingDirectivesFactory(cidr)}
749
+
750
+ [Peer]
751
+ PublicKey = ${publicKey}
752
+ Endpoint = ${endpoint}
753
+ AllowedIPs = ${cidr}
754
+ # Holds the outbound NAT mapping open, which is the only reason a hub behind no
755
+ # NAT can reach a spoke behind CGNAT at all.
756
+ PersistentKeepalive = ${keepalive}
757
+ `;
758
+
759
+ /**
760
+ * @method quicForwardCommandsFactory
761
+ * @description The packet rules that carry QUIC to the default spoke, and the
762
+ * ones that remove them again.
763
+ *
764
+ * QUIC cannot be routed by hostname — a QUIC Initial carries its SNI inside an
765
+ * encrypted frame — so the whole UDP port goes to one spoke, exactly as
766
+ * `underpost-ingress` sends every datagram to one data plane. A client that
767
+ * tries QUIC against another spoke's hostname gets no answer and falls back to
768
+ * TCP, which is routed correctly.
769
+ *
770
+ * The rules live in dedicated chains that are flushed and refilled on every
771
+ * apply, which is what makes a re-run idempotent: there is no accumulating list
772
+ * of near-duplicate rules and no need to guess what a previous run installed.
773
+ * @param {string} [chainPrefix] - Prefix for the two managed chains.
774
+ * @param {string} [interfaceName] - Tunnel interface.
775
+ * @param {string} [target] - Spoke address; empty renders the chains without rules.
776
+ * @param {number} [port] - UDP port to forward.
777
+ * @returns {{ensure: Array<string>, remove: Array<string>}} Shell commands.
778
+ * @memberof UnderpostWireguard
779
+ */
780
+ const quicForwardCommandsFactory = ({
781
+ chainPrefix = UNDERPOST_EDGE.natChainPrefix,
782
+ interfaceName = UNDERPOST_EDGE.interfaceName,
783
+ target = '',
784
+ port = UNDERPOST_EDGE.httpsPort,
785
+ } = {}) => {
786
+ const pre = `${chainPrefix}_PRE`;
787
+ const post = `${chainPrefix}_POST`;
788
+ return {
789
+ ensure: [
790
+ `sudo iptables -t nat -N ${pre} 2>/dev/null || true`,
791
+ `sudo iptables -t nat -N ${post} 2>/dev/null || true`,
792
+ `sudo iptables -t nat -C PREROUTING -j ${pre} 2>/dev/null || sudo iptables -t nat -A PREROUTING -j ${pre}`,
793
+ `sudo iptables -t nat -C POSTROUTING -j ${post} 2>/dev/null || sudo iptables -t nat -A POSTROUTING -j ${post}`,
794
+ `sudo iptables -t nat -F ${pre}`,
795
+ `sudo iptables -t nat -F ${post}`,
796
+ ...(target
797
+ ? [
798
+ // `! -i <iface>` keeps datagrams that already arrived through the
799
+ // tunnel from being re-DNAT'd back into it.
800
+ `sudo iptables -t nat -A ${pre} ! -i ${interfaceName} -p udp --dport ${port} -j DNAT --to-destination ${target}:${port}`,
801
+ `sudo iptables -t nat -A ${post} -o ${interfaceName} -d ${target} -p udp --dport ${port} -j MASQUERADE`,
802
+ ]
803
+ : []),
804
+ ],
805
+ remove: [
806
+ `sudo iptables -t nat -D PREROUTING -j ${pre} 2>/dev/null || true`,
807
+ `sudo iptables -t nat -D POSTROUTING -j ${post} 2>/dev/null || true`,
808
+ `sudo iptables -t nat -F ${pre} 2>/dev/null || true`,
809
+ `sudo iptables -t nat -F ${post} 2>/dev/null || true`,
810
+ `sudo iptables -t nat -X ${pre} 2>/dev/null || true`,
811
+ `sudo iptables -t nat -X ${post} 2>/dev/null || true`,
812
+ ],
813
+ };
814
+ };
815
+
816
+ /**
817
+ * @method firewallCommandsFactory
818
+ * @description Opens the ports each role needs, when firewalld is running.
819
+ *
820
+ * A spoke opens nothing publicly — it dials out — but its tunnel interface has
821
+ * to land in a zone that permits forwarded traffic, or the cluster ingress is
822
+ * unreachable over a tunnel that is otherwise perfectly healthy.
823
+ * The same list drives the teardown, flipped to `--remove-*`, so a reset cannot
824
+ * leave behind a permanent rule the setup added — the two directions are one
825
+ * declaration rather than two that drift.
826
+ * @param {string} role - `server` or `client`.
827
+ * @param {string} [interfaceName] - Tunnel interface.
828
+ * @param {number} [listenPort] - UDP listen port.
829
+ * @param {string} [tunnelCidr] - Tunnel source admitted to the forward proxy.
830
+ * @param {number} [sshForwardPort] - Public SSH port to open; 0 opens none.
831
+ * @param {boolean} [remove] - Withdraw the rules instead of adding them.
832
+ * @returns {Array<string>} Shell commands, each a no-op when firewalld is absent.
833
+ * @memberof UnderpostWireguard
834
+ */
835
+ const firewallCommandsFactory = ({
836
+ role,
837
+ interfaceName = UNDERPOST_EDGE.interfaceName,
838
+ listenPort = UNDERPOST_EDGE.listenPort,
839
+ tunnelCidr = UNDERPOST_EDGE.tunnelCidr,
840
+ sshForwardPort = 0,
841
+ remove = false,
842
+ } = {}) => {
843
+ const verb = remove ? 'remove' : 'add';
844
+ const rules =
845
+ role === 'server'
846
+ ? [
847
+ `--${verb}-port=${UNDERPOST_EDGE.httpPort}/tcp`,
848
+ `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/tcp`,
849
+ `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/udp`,
850
+ `--${verb}-port=${listenPort}/udp`,
851
+ // Public, unlike the forward proxy: CI dials it from GitHub's runners,
852
+ // which have no fixed address to narrow the rule to.
853
+ ...(Number(sshForwardPort) > 0 ? [`--${verb}-port=${sshForwardPort}/tcp`] : []),
854
+ // The forward proxy is admitted from the tunnel only. The listener
855
+ // already binds the tunnel address alone, so this rule narrows a port
856
+ // that is unreachable from anywhere else rather than opening one.
857
+ `--${verb}-rich-rule="rule family=ipv4 source address=${tunnelCidr} port port=${FORWARD_PROXY.port} protocol=tcp accept"`,
858
+ `--${verb}-masquerade`,
859
+ ]
860
+ : [`--zone=trusted --${verb}-interface=${interfaceName}`];
861
+ const guard =
862
+ 'command -v firewall-cmd >/dev/null 2>&1 && ' +
863
+ systemctlCommandFactory({ action: 'is-active --quiet', name: 'firewalld', sudo: false });
864
+ return [
865
+ ...rules.map((rule) => `sudo sh -c '${guard} && firewall-cmd --permanent ${rule} >/dev/null || true'`),
866
+ `sudo sh -c '${guard} && firewall-cmd --reload >/dev/null || true'`,
867
+ ];
868
+ };
869
+
870
+ /**
871
+ * @method peerSummaryFactory
872
+ * @description One spoke as the registry declares it: its transport address and
873
+ * the three bindings that route hostnames to it.
874
+ * @param {object} peer - Normalized registry entry.
875
+ * @returns {object} Registry view of the peer.
876
+ * @memberof UnderpostWireguard
877
+ */
878
+ const peerSummaryFactory = (peer) => ({
879
+ id: peer.id,
880
+ address: peer.address,
881
+ allowedIPs: peer.allowedIPs,
882
+ hosts: peer.hosts,
883
+ instances: peer.instances,
884
+ default: peer.default,
885
+ });
886
+
887
+ /**
888
+ * @method wireguardStatusFactory
889
+ * @description Folds `wg show` output onto the registry, one row per spoke.
890
+ *
891
+ * Built from the per-peer sub-commands rather than `wg show <iface> dump`,
892
+ * because the dump's first line contains the interface private key — this
893
+ * command prints its output, so it must never read that line at all.
894
+ * @param {Array<object>} [peers] - Registry entries.
895
+ * @param {string} [latestHandshakes] - `wg show <iface> latest-handshakes` output.
896
+ * @param {string} [transfer] - `wg show <iface> transfer` output.
897
+ * @param {string} [endpoints] - `wg show <iface> endpoints` output.
898
+ * @param {number} [now] - Current time in epoch seconds.
899
+ * @returns {Array<object>} One row per peer.
900
+ * @memberof UnderpostWireguard
901
+ */
902
+ const wireguardStatusFactory = ({
903
+ peers = [],
904
+ latestHandshakes = '',
905
+ transfer = '',
906
+ endpoints = '',
907
+ now = Math.floor(Date.now() / 1000),
908
+ } = {}) => {
909
+ const parse = (raw) =>
910
+ new Map(
911
+ `${raw || ''}`
912
+ .split('\n')
913
+ .map((line) => line.trim())
914
+ .filter(Boolean)
915
+ .map((line) => {
916
+ const [key, ...rest] = line.split(/\s+/);
917
+ return [key, rest];
918
+ }),
919
+ );
920
+ const handshakes = parse(latestHandshakes);
921
+ const transfers = parse(transfer);
922
+ const peerEndpoints = parse(endpoints);
923
+ return peers.map(peerFactory).map((peer) => {
924
+ const handshake = Number(handshakes.get(peer.publicKey)?.[0] || 0);
925
+ const [rx = '0', tx = '0'] = transfers.get(peer.publicKey) || [];
926
+ const endpoint = peerEndpoints.get(peer.publicKey)?.[0] || '';
927
+ return {
928
+ ...peerSummaryFactory(peer),
929
+ endpoint: endpoint && endpoint !== '(none)' ? endpoint : '',
930
+ handshakeAgeSeconds: handshake > 0 ? now - handshake : null,
931
+ rxBytes: Number(rx) || 0,
932
+ txBytes: Number(tx) || 0,
933
+ online: handshake > 0 && now - handshake <= UNDERPOST_EDGE.handshakeStaleSeconds,
934
+ };
935
+ });
936
+ };
937
+
938
+ /**
939
+ * @method writeRootFile
940
+ * @description Places a file the host's services read, at an explicit mode.
941
+ *
942
+ * Staged and installed rather than written directly: the deploy may run
943
+ * unprivileged while `/etc/wireguard` and `/etc/haproxy` are root-owned, and
944
+ * `install -m` sets the mode as it copies, so a 0600 file is never briefly
945
+ * readable at the default umask.
946
+ * @param {string} target - Destination path.
947
+ * @param {string} content - File contents.
948
+ * @param {string} [mode] - Octal mode.
949
+ * @param {boolean} [dryRun] - Print the destination instead of writing.
950
+ * @returns {boolean} True when the file changed.
951
+ * @memberof UnderpostWireguard
952
+ */
953
+ const writeRootFile = ({ target, content, mode = '0644', dryRun = false }) => {
954
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
955
+ if (current === content) return false;
956
+ if (dryRun) {
957
+ logger.info(`[dry-run] write ${target} (mode ${mode})`, { bytes: content.length });
958
+ return true;
959
+ }
960
+ const staged = nodePath.join('/tmp', `underpost-edge-${nodePath.basename(target)}-${process.pid}`);
961
+ fs.writeFileSync(staged, content, { mode: 0o600 });
962
+ shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
963
+ shellExec(`sudo install -m ${mode} -o root -g root ${staged} ${target}`, { silent: true });
964
+ fs.removeSync(staged);
965
+ return true;
966
+ };
967
+
968
+ /**
969
+ * @method writeServerInterfaceConf
970
+ * @description Rewrites the hub interface config from the current peer table.
971
+ *
972
+ * A spoke's config holds no peer table of its own, so this is a no-op there —
973
+ * which is what lets `--peer-add` and `--peer-remove` run unconditionally.
974
+ * @param {object} state - Registry the interface belongs to.
975
+ * @param {Array<object>} peers - Peer table to render.
976
+ * @param {boolean} [dryRun] - Print instead of writing.
977
+ * @returns {boolean} True when the file changed.
978
+ * @memberof UnderpostWireguard
979
+ */
980
+ const writeServerInterfaceConf = ({ state, peers, dryRun = false }) => {
981
+ if (state.role !== 'server') return false;
982
+ return writeRootFile({
983
+ target: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.conf`,
984
+ content: wireguardServerConfFactory({
985
+ interfaceName: state.interfaceName,
986
+ address: state.address,
987
+ listenPort: state.listenPort,
988
+ keyPath: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.key`,
989
+ peers,
990
+ }),
991
+ mode: '0600',
992
+ dryRun,
993
+ });
994
+ };
995
+
996
+ /**
997
+ * @method runHostCommands
998
+ * @description Executes a host-mutating command list, or prints it.
999
+ * @param {Array<string>} commands - Shell commands.
1000
+ * @param {boolean} [dryRun] - Print instead of executing.
1001
+ * @returns {void}
1002
+ * @memberof UnderpostWireguard
1003
+ */
1004
+ const runHostCommands = (commands, dryRun = false) => {
1005
+ for (const command of commands) {
1006
+ if (dryRun) logger.info(`[dry-run] ${command}`);
1007
+ else shellExec(command, { silent: true });
1008
+ }
1009
+ };
1010
+
1011
+ const runServiceCommands = (commands, dryRun = false) =>
1012
+ runSystemdCommands(commands, {
1013
+ dryRun,
1014
+ execute: (command) => shellExec(command, { silent: true }),
1015
+ onDryRun: (command) => logger.info(`[dry-run] ${command}`),
1016
+ });
1017
+
1018
+ /**
1019
+ * @method csvFactory
1020
+ * @description Splits a comma-separated CLI value, or reports that the flag was
1021
+ * not passed at all — which is what lets a partial update leave the stored
1022
+ * fields it does not name untouched.
1023
+ * @param {string} [value] - Raw option value.
1024
+ * @returns {?Array<string>} Trimmed entries, or `undefined` when absent.
1025
+ * @memberof UnderpostWireguard
1026
+ */
1027
+ const csvFactory = (value) =>
1028
+ value === undefined || value === null || `${value}`.trim() === ''
1029
+ ? undefined
1030
+ : `${value}`
1031
+ .split(',')
1032
+ .map((entry) => entry.trim())
1033
+ .filter(Boolean);
1034
+
1035
+ /**
1036
+ * @method liveWireguardCommand
1037
+ * @description Applies a `wg set` change to a running interface, and does
1038
+ * nothing when there is none.
1039
+ *
1040
+ * `wg set` is what admits or drops a peer without restarting the interface, so
1041
+ * established tunnels keep their sessions. It is also the only step that
1042
+ * requires the interface to be up — persistence is the regenerated config file,
1043
+ * so a host whose tunnel is currently down still records the change.
1044
+ * @param {string} interfaceName - Interface name.
1045
+ * @param {string} args - Arguments after `wg set <interface>`.
1046
+ * @returns {string} Shell command.
1047
+ * @memberof UnderpostWireguard
1048
+ */
1049
+ const liveWireguardCommand = (interfaceName, args) =>
1050
+ `sudo sh -c 'wg show ${interfaceName} >/dev/null 2>&1 && wg set ${interfaceName} ${args} || true'`;
1051
+
1052
+ /**
1053
+ * @method warnRegistryHazards
1054
+ * @description Reports the two registry states that break routing silently.
1055
+ *
1056
+ * A peer with no `hosts` and no `instances` claims nothing. It still serves
1057
+ * every hostname while it is the *only* peer, because a lone peer is its own
1058
+ * fallback — so a single-spoke edge works with no bindings at all. Adding a
1059
+ * second peer removes that implicit fallback, and every hostname the bindings do
1060
+ * not name goes unresolved at once, along with the QUIC forward.
1061
+ *
1062
+ * That cliff arrives when the peer is registered, not when routes are published,
1063
+ * so it is reported at both points rather than left for the next sync to
1064
+ * discover.
1065
+ * @param {Array<object>} [peers] - The registry's peers.
1066
+ * @returns {void}
1067
+ * @memberof UnderpostWireguard
1068
+ */
1069
+ const warnRegistryHazards = (peers = []) => {
1070
+ if (peers.length === 0) {
1071
+ logger.warn('Registry has no peers: no hostname can resolve until at least one is registered', {
1072
+ next: '--peer-add <id> --peer-ip <10.0.0.x> --public-key <key>',
1073
+ });
1074
+ return;
1075
+ }
1076
+ if (peers.length > 1 && !peers.some((peer) => peer.default))
1077
+ logger.warn('No peer is marked --default: unmatched hostnames are unresolved and UDP :443 is not forwarded', {
1078
+ peers: peers.map((peer) => peer.id),
1079
+ fix: '--peer-add <id> --peer-ip <ip> --public-key <key> --default',
1080
+ });
1081
+ const conflicts = allowedIpsConflictsFactory({ peers });
1082
+ if (conflicts.length > 0)
1083
+ logger.warn('Overlapping AllowedIPs: only one peer can receive traffic for each contested CIDR', {
1084
+ conflicts,
1085
+ fix: 'give each spoke a distinct tunnel address, and re-number or omit colliding LAN subnets',
1086
+ });
1087
+ };
1088
+
1089
+ /** Return an interface address without its CIDR prefix. */
1090
+ const tunnelAddressFactory = (address) => `${address || ''}`.trim().split('/')[0];
1091
+
1092
+ const tunnelNetworkCidrFactory = (address, fallback = UNDERPOST_EDGE.tunnelCidr) => {
1093
+ const [host, prefixValue] = `${address || ''}`.trim().split('/');
1094
+ const octets = host.split('.').map(Number);
1095
+ const prefix = Number(prefixValue);
1096
+ if (
1097
+ octets.length !== 4 ||
1098
+ octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) ||
1099
+ !Number.isInteger(prefix) ||
1100
+ prefix < 0 ||
1101
+ prefix > 32
1102
+ )
1103
+ return fallback;
1104
+ const value = octets.reduce((result, octet) => ((result << 8) | octet) >>> 0, 0);
1105
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
1106
+ const network = (value & mask) >>> 0;
1107
+ return `${[network >>> 24, (network >>> 16) & 255, (network >>> 8) & 255, network & 255].join('.')}/${prefix}`;
1108
+ };
1109
+
1110
+ /**
1111
+ * @class UnderpostWireguard
1112
+ * @description Lifecycle for the edge: the WireGuard transport that reaches the
1113
+ * spokes, and the HAProxy gateway that selects one per connection.
1114
+ * @memberof UnderpostWireguard
1115
+ */
1116
+ class UnderpostWireguard {
1117
+ static API = {
1118
+ /**
1119
+ * @method install
1120
+ * @description Installs the host packages the subsystem needs.
1121
+ *
1122
+ * Each package is queried before anything is installed, so a host that is
1123
+ * already provisioned performs no work and a partially provisioned one
1124
+ * installs only what it is missing.
1125
+ * @param {object} [options] - CLI options.
1126
+ * @returns {Array<string>} Packages that were missing.
1127
+ * @memberof UnderpostWireguard
1128
+ */
1129
+ install(options = {}) {
1130
+ if (shellExec('command -v dnf', { stdout: true, silent: true, silentOnError: true }).trim() === '')
1131
+ throw new Error('[wireguard] No dnf on this host; the edge subsystem targets RHEL 9 / Rocky Linux 9');
1132
+ const missing = UNDERPOST_EDGE.packages.filter(
1133
+ (name) => shellExec(`rpm -q ${name}`, { silent: true, silentOnError: true }).code !== 0,
1134
+ );
1135
+ if (missing.length === 0) {
1136
+ logger.info('Edge packages already installed', { packages: UNDERPOST_EDGE.packages });
1137
+ return [];
1138
+ }
1139
+ runHostCommands(
1140
+ [
1141
+ `sudo dnf -y install epel-release || true`,
1142
+ `sudo dnf -y install ${missing.join(' ')}`,
1143
+ // HAProxy dials backends on the tunnel; without this boolean SELinux
1144
+ // refuses the connection and every route answers 503.
1145
+ `sudo sh -c 'command -v setsebool >/dev/null 2>&1 && setsebool -P haproxy_connect_any 1 || true'`,
1146
+ ],
1147
+ options.dryRun,
1148
+ );
1149
+ logger.info('Edge packages installed', { missing });
1150
+ return missing;
1151
+ },
1152
+
1153
+ /**
1154
+ * @method ensureKeyPair
1155
+ * @description Resolves the interface key pair, generating it once.
1156
+ *
1157
+ * The private half is created under `umask 077` inside the same shell that
1158
+ * writes it, so it is never readable by anyone but root — not even for the
1159
+ * moment between creation and a later `chmod`. It is never read back into
1160
+ * this process; only the public half is returned.
1161
+ * @param {string} interfaceName - Interface name.
1162
+ * @param {boolean} [dryRun] - Skip generation.
1163
+ * @returns {{privateKeyPath: string, publicKeyPath: string, publicKey: string, generated: boolean}} Key pair locations and the public key.
1164
+ * @memberof UnderpostWireguard
1165
+ */
1166
+ ensureKeyPair(interfaceName, dryRun = false) {
1167
+ const privateKeyPath = `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`;
1168
+ const publicKeyPath = `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.pub`;
1169
+ const exists =
1170
+ shellExec(`sudo test -s ${privateKeyPath} && sudo test -s ${publicKeyPath}`, {
1171
+ silent: true,
1172
+ silentOnError: true,
1173
+ }).code === 0;
1174
+ if (!exists) {
1175
+ if (dryRun) {
1176
+ logger.info(`[dry-run] generate wireguard key pair for ${interfaceName}`);
1177
+ return { privateKeyPath, publicKeyPath, publicKey: '', generated: true };
1178
+ }
1179
+ shellExec(
1180
+ `sudo sh -c 'umask 077 && mkdir -p ${UNDERPOST_EDGE.wireguardDir} && wg genkey > ${privateKeyPath} && wg pubkey < ${privateKeyPath} > ${publicKeyPath}'`,
1181
+ { silent: true },
1182
+ );
1183
+ shellExec(`sudo chmod 0600 ${privateKeyPath} && sudo chmod 0644 ${publicKeyPath}`, { silent: true });
1184
+ }
1185
+ const publicKey = shellExec(`sudo cat ${publicKeyPath}`, {
1186
+ stdout: true,
1187
+ silent: true,
1188
+ silentOnError: true,
1189
+ }).trim();
1190
+ return { privateKeyPath, publicKeyPath, publicKey, generated: !exists };
1191
+ },
1192
+
1193
+ /**
1194
+ * @method setup
1195
+ * @description Builds the tunnel interface for either role and persists what
1196
+ * the other side needs to know.
1197
+ *
1198
+ * The hub writes its own address and public key into the registry so a spoke
1199
+ * can be configured from the same file the routes are derived from; a spoke
1200
+ * records the hub it dials. Re-running with the same inputs rewrites the same
1201
+ * bytes and reloads nothing.
1202
+ * @param {object} options - CLI options.
1203
+ * @returns {object} The registry as persisted.
1204
+ * @memberof UnderpostWireguard
1205
+ */
1206
+ setup(options = {}) {
1207
+ const state = readEdgeState();
1208
+ const buildConf = options.buildConf === true;
1209
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1210
+ if (options.server === true && options.client === true)
1211
+ throw new Error('[wireguard] --server and --client are mutually exclusive');
1212
+ const installedClient =
1213
+ buildConf || options.server === true
1214
+ ? wireguardClientSettingsFactory()
1215
+ : wireguardClientSettingsFactory(
1216
+ shellExec(
1217
+ `sudo sed -n -E '/^[[:space:]]*(Address|PublicKey|Endpoint|AllowedIPs)[[:space:]]*=/p' ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
1218
+ { stdout: true, silent: true, silentOnError: true },
1219
+ ).trim(),
1220
+ );
1221
+ // A re-run on a configured host inherits its recorded role, so repeating
1222
+ // the setup does not require repeating every flag that established it.
1223
+ const role =
1224
+ options.server === true
1225
+ ? 'server'
1226
+ : options.client === true
1227
+ ? 'client'
1228
+ : installedClient.endpoint
1229
+ ? 'client'
1230
+ : state.role;
1231
+ if (!role) throw new Error('[wireguard] --wireguard-setup requires --server or --client');
1232
+ const listenPort = Number(options.port) > 0 ? Number(options.port) : state.listenPort;
1233
+ // `--ssh-forward-port 0` is the off switch, so an explicit 0 has to win
1234
+ // over the stored value rather than read as "not supplied".
1235
+ const sshForwardPort =
1236
+ options.sshForwardPort === undefined || options.sshForwardPort === null || `${options.sshForwardPort}` === ''
1237
+ ? state.sshForwardPort
1238
+ : Math.max(0, Number(options.sshForwardPort) || 0);
1239
+ // Authoring the registry must work on a machine that is not the edge — a
1240
+ // workstation has no `/etc/wireguard` to key, and generating one there
1241
+ // would mint an identity no host will ever present.
1242
+ const { privateKeyPath, publicKey } = buildConf
1243
+ ? { privateKeyPath: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`, publicKey: state.publicKey }
1244
+ : UnderpostWireguard.API.ensureKeyPair(interfaceName, options.dryRun);
1245
+
1246
+ let conf;
1247
+ let next;
1248
+ if (role === 'server') {
1249
+ const address = `${options.cidr || state.address || UNDERPOST_EDGE.cidr}`.trim();
1250
+ if (!address.includes('/')) throw new Error('[wireguard] --cidr must carry a prefix length (e.g. 10.0.0.1/24)');
1251
+ conf = wireguardServerConfFactory({
1252
+ interfaceName,
1253
+ address,
1254
+ listenPort,
1255
+ keyPath: privateKeyPath,
1256
+ peers: state.peers,
1257
+ });
1258
+ next = { ...state, interfaceName, role, listenPort, address, publicKey, sshForwardPort };
1259
+ } else {
1260
+ const address = `${options.peerIp || installedClient.address || state.address || ''}`.trim();
1261
+ const endpoint = `${options.endpoint || installedClient.endpoint || state.endpoint || ''}`.trim();
1262
+ const hubPublicKey = `${options.publicKey || installedClient.hubPublicKey || state.hubPublicKey || ''}`.trim();
1263
+ const cidr = `${options.cidr || installedClient.cidr || UNDERPOST_EDGE.tunnelCidr}`.trim();
1264
+ if (!address) throw new Error('[wireguard] --client requires --peer-ip');
1265
+ if (!endpoint) throw new Error('[wireguard] --client requires --endpoint (e.g. vps.example.com:51820)');
1266
+ if (!hubPublicKey) throw new Error('[wireguard] --client requires --public-key (the hub public key)');
1267
+ conf = wireguardClientConfFactory({
1268
+ address,
1269
+ keyPath: privateKeyPath,
1270
+ publicKey: hubPublicKey,
1271
+ endpoint,
1272
+ cidr,
1273
+ keepalive: UNDERPOST_EDGE.keepalive,
1274
+ });
1275
+ next = { ...state, interfaceName, role, listenPort, address, endpoint, hubPublicKey, publicKey };
1276
+ }
1277
+
1278
+ let confChanged = false;
1279
+ if (!buildConf) {
1280
+ confChanged = writeRootFile({
1281
+ target: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
1282
+ content: conf,
1283
+ mode: '0600',
1284
+ dryRun: options.dryRun,
1285
+ });
1286
+ runHostCommands(
1287
+ [
1288
+ `sudo sh -c 'echo net.ipv4.ip_forward=1 > ${UNDERPOST_EDGE.sysctlPath}'`,
1289
+ `sudo sysctl -q --system`,
1290
+ ...firewallCommandsFactory({
1291
+ role,
1292
+ interfaceName,
1293
+ listenPort,
1294
+ tunnelCidr: tunnelNetworkCidrFactory(next.address),
1295
+ sshForwardPort: next.sshForwardPort,
1296
+ }),
1297
+ ],
1298
+ options.dryRun,
1299
+ );
1300
+ }
1301
+ if (!options.dryRun) writeEdgeState(next);
1302
+ // Writing the config is idempotent; the *running* interface is not. wg-quick
1303
+ // reads the file only at start, so a changed config under a live unit is a
1304
+ // divergence that stays silent until the next reboot re-reads it.
1305
+ const restartRequired =
1306
+ confChanged &&
1307
+ !options.dryRun &&
1308
+ shellExec(
1309
+ systemctlCommandFactory({ action: 'is-active --quiet', name: `wg-quick@${interfaceName}`, sudo: false }),
1310
+ { silent: true, silentOnError: true },
1311
+ ).code === 0;
1312
+ logger.info(buildConf ? 'Registry updated; host untouched' : 'WireGuard interface configured', {
1313
+ interfaceName,
1314
+ role,
1315
+ address: next.address,
1316
+ publicKey: next.publicKey,
1317
+ peers: next.peers.length,
1318
+ restartRequired,
1319
+ });
1320
+ if (restartRequired)
1321
+ logger.warn('Interface config changed while the tunnel is up; the live interface still runs the old one', {
1322
+ apply: `--wireguard-stop --wireguard-start --interface ${interfaceName}`,
1323
+ });
1324
+ return next;
1325
+ },
1326
+
1327
+ /**
1328
+ * @method peerAdd
1329
+ * @description Registers a spoke and applies it to the running hub.
1330
+ *
1331
+ * `wg set` installs the peer on the live interface, so an existing tunnel is
1332
+ * never interrupted to admit a new one — the registry and the config file
1333
+ * are updated in the same pass so the peer also survives a restart.
1334
+ *
1335
+ * A spoke that re-keys is registered under the same id with a new public key.
1336
+ * WireGuard identifies a peer *by its key*, not by any name, so admitting the
1337
+ * new one does not replace the old: the superseded key would stay on the live
1338
+ * interface still claiming the same `AllowedIPs`, and longest-prefix match
1339
+ * could keep handing that traffic to an identity the spoke no longer holds.
1340
+ * It is dropped first, which is what makes a reconnect leave no trace.
1341
+ * @param {object} options - CLI options.
1342
+ * @returns {object} The updated registry.
1343
+ * @memberof UnderpostWireguard
1344
+ */
1345
+ peerAdd(options = {}) {
1346
+ const state = readEdgeState();
1347
+ const id = `${options.peerAdd || ''}`.trim();
1348
+ const address = `${options.peerIp || ''}`.trim();
1349
+ const publicKey = `${options.publicKey || ''}`.trim();
1350
+ if (!id) throw new Error('[wireguard] --peer-add requires a peer id');
1351
+ if (!address) throw new Error('[wireguard] --peer-add requires --peer-ip');
1352
+ if (!publicKey) throw new Error('[wireguard] --peer-add requires --public-key');
1353
+ // Only flags that were actually passed override the stored entry, so
1354
+ // re-registering a spoke to correct its key does not silently drop the
1355
+ // subnets and bindings a previous run gave it.
1356
+ const overrides = Object.fromEntries(
1357
+ [
1358
+ ['allowedIPs', csvFactory(options.allowedIps)],
1359
+ ['hosts', csvFactory(options.hosts)],
1360
+ ['instances', csvFactory(options.instances)],
1361
+ ['default', options.default === true ? true : undefined],
1362
+ ].filter(([, value]) => value !== undefined),
1363
+ );
1364
+ const current = state.peers.find((entry) => entry.id === id) || {};
1365
+ const peer = peerFactory({ ...current, id, address, publicKey, ...overrides });
1366
+ const supersededKey = current.publicKey && current.publicKey !== publicKey ? current.publicKey : '';
1367
+ const peers = [...state.peers.filter((entry) => entry.id !== id), peer].sort((a, b) => a.id.localeCompare(b.id));
1368
+ const next = { ...state, peers };
1369
+ if (options.buildConf !== true) {
1370
+ runHostCommands(
1371
+ [
1372
+ ...(supersededKey ? [liveWireguardCommand(next.interfaceName, `peer ${supersededKey} remove`)] : []),
1373
+ liveWireguardCommand(next.interfaceName, `peer ${publicKey} allowed-ips ${peer.allowedIPs.join(',')}`),
1374
+ ],
1375
+ options.dryRun,
1376
+ );
1377
+ writeServerInterfaceConf({ state: next, peers, dryRun: options.dryRun });
1378
+ }
1379
+ if (!options.dryRun) writeEdgeState(next);
1380
+ logger.info(options.buildConf === true ? 'Peer recorded; host untouched' : 'Peer registered', {
1381
+ id,
1382
+ address,
1383
+ allowedIPs: peer.allowedIPs,
1384
+ rekeyed: supersededKey !== '',
1385
+ });
1386
+ warnRegistryHazards(peers);
1387
+ return next;
1388
+ },
1389
+
1390
+ /**
1391
+ * @method peerRemove
1392
+ * @description Removes a spoke from the registry and from the running hub.
1393
+ * @param {object} options - CLI options.
1394
+ * @returns {object} The updated registry.
1395
+ * @memberof UnderpostWireguard
1396
+ */
1397
+ peerRemove(options = {}) {
1398
+ const state = readEdgeState();
1399
+ const id = `${options.peerRemove || ''}`.trim();
1400
+ const peer = state.peers.find((entry) => entry.id === id);
1401
+ if (!peer) {
1402
+ logger.warn('No such peer in registry', { id });
1403
+ return state;
1404
+ }
1405
+ const peers = state.peers.filter((entry) => entry.id !== id);
1406
+ const next = { ...state, peers };
1407
+ if (options.buildConf !== true) {
1408
+ runHostCommands([liveWireguardCommand(next.interfaceName, `peer ${peer.publicKey} remove`)], options.dryRun);
1409
+ writeServerInterfaceConf({ state: next, peers, dryRun: options.dryRun });
1410
+ }
1411
+ if (!options.dryRun) writeEdgeState(next);
1412
+ logger.info(options.buildConf === true ? 'Peer removed from registry; host untouched' : 'Peer removed', { id });
1413
+ warnRegistryHazards(peers);
1414
+ return next;
1415
+ },
1416
+
1417
+ /**
1418
+ * @method buildConf
1419
+ * @description Rewrites the registry in place, normalized, touching no host.
1420
+ *
1421
+ * The registry is authored, not derived — its peer bindings and public keys
1422
+ * exist nowhere else, so nothing can regenerate it from other configuration.
1423
+ * This is the repair path: it fills in defaults a hand-edited file omitted,
1424
+ * drops entries with no id, and reports what it read, so a malformed registry
1425
+ * is corrected before a sync acts on it.
1426
+ * @param {object} [options] - CLI options.
1427
+ * @returns {object} The normalized registry.
1428
+ * @memberof UnderpostWireguard
1429
+ */
1430
+ buildConf(options = {}) {
1431
+ const state = readEdgeState();
1432
+ const changed = options.dryRun ? false : writeEdgeState(state);
1433
+ logger.info('Registry normalized', {
1434
+ target: EDGE_STATE_PATH,
1435
+ role: state.role,
1436
+ address: state.address,
1437
+ peers: state.peers.map((peer) => peer.id),
1438
+ changed,
1439
+ });
1440
+ if (!state.role)
1441
+ logger.warn('Registry records no role for this machine', {
1442
+ next: '--build-conf --wireguard-setup --server --cidr 10.0.0.1/24 (or --client …)',
1443
+ });
1444
+ warnRegistryHazards(state.peers);
1445
+ return state;
1446
+ },
1447
+
1448
+ /**
1449
+ * @method routeTable
1450
+ * @description The resolved hostname-to-spoke table for one deploy, a list,
1451
+ * or every deploy in `dd.router`.
1452
+ *
1453
+ * Reads each deploy's configuration through the same helpers the cluster
1454
+ * runners use, so a hostname the edge routes is exactly a hostname the
1455
+ * cluster publishes.
1456
+ *
1457
+ * A deploy listed in `dd.router` whose configuration is not checked out
1458
+ * locally is skipped with a warning rather than failing the run: the private
1459
+ * conf of an unrelated deploy is not a precondition for publishing the ones
1460
+ * that are present.
1461
+ *
1462
+ * Omitting the id means `dd` — the whole of `dd.router`. The edge holds one
1463
+ * pair of map files for the cluster, so a complete table is the only default
1464
+ * that publishes a working edge; narrowing it is the deliberate act.
1465
+ * @param {string} [deployId] - Deploy id, comma-separated list, or `dd` (the default).
1466
+ * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, peers: Array<object>, deployList: Array<string>, missing: Array<string>}} Merged table.
1467
+ * @throws {Error} When no requested deploy has a readable configuration.
1468
+ * @memberof UnderpostWireguard
1469
+ */
1470
+ routeTable(deployId) {
1471
+ const deployList = deployListFactory(deployId || 'dd');
1472
+ if (deployList.length === 0) throw new Error('[wireguard] --deploy-id resolved to no deploys');
1473
+ const state = readEdgeState();
1474
+ const tables = [];
1475
+ const missing = [];
1476
+ for (const id of deployList) {
1477
+ const confServerPath = getConfFilePath(id, 'server');
1478
+ if (!fs.existsSync(confServerPath)) {
1479
+ missing.push(id);
1480
+ continue;
1481
+ }
1482
+ const instancesPath = `./engine-private/conf/${id}/conf.instances.json`;
1483
+ tables.push({
1484
+ deployId: id,
1485
+ ...edgeRouteTableFactory({
1486
+ confServer: loadConfServerJson(confServerPath),
1487
+ instances: fs.existsSync(instancesPath) ? loadConfInstances(id) : [],
1488
+ peers: state.peers,
1489
+ }),
1490
+ });
1491
+ }
1492
+ if (missing.length > 0) logger.warn('Deploys with no local configuration were skipped', { missing });
1493
+ if (tables.length === 0)
1494
+ throw new Error(`[wireguard] No readable deploy configuration among: ${deployList.join(', ')}`);
1495
+ return { ...mergeRouteTablesFactory({ tables }), deployList, missing };
1496
+ },
1497
+
1498
+ /**
1499
+ * @method status
1500
+ * @description The whole edge context in one report: this machine's role and
1501
+ * transport, both daemons, every spoke with its bindings and link health, and
1502
+ * the routing the gateway would publish.
1503
+ *
1504
+ * This is the only read-only entry point. Host probes are skipped under
1505
+ * `--build-conf`, which has to run on a workstation with no interface to
1506
+ * query, so an off-box run reports registry and routing alone.
1507
+ * @param {object} options - CLI options.
1508
+ * @returns {object} Runtime, registry and routing summary.
1509
+ * @memberof UnderpostWireguard
1510
+ */
1511
+ status(options = {}) {
1512
+ const state = readEdgeState();
1513
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1514
+ const probeHost = options.buildConf !== true;
1515
+ const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
1516
+ const show = (subCommand) => (probeHost ? read(`sudo wg show ${interfaceName} ${subCommand}`) : '');
1517
+
1518
+ let table = null;
1519
+ try {
1520
+ table = UnderpostWireguard.API.routeTable(options.deployId);
1521
+ } catch (error) {
1522
+ logger.warn('Route table unavailable', { message: error.message });
1523
+ }
1524
+
1525
+ const via = {};
1526
+ for (const route of table?.routes || []) via[route.via] = (via[route.via] || 0) + 1;
1527
+ const summary = {
1528
+ role: state.role || '(unset)',
1529
+ interface: interfaceName,
1530
+ address: state.address,
1531
+ endpoint: state.endpoint,
1532
+ publicKey: state.publicKey,
1533
+ ...(probeHost
1534
+ ? {
1535
+ wireguard: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
1536
+ haproxy: read(systemdStatusCommandsFactory('haproxy').active),
1537
+ forwardProxy: read(systemdStatusCommandsFactory(FORWARD_PROXY.serviceName).active),
1538
+ quicTarget: defaultPeerFactory(state.peers)?.address || '',
1539
+ sshForward: state.sshForwardPort
1540
+ ? `:${state.sshForwardPort} -> ${defaultPeerFactory(state.peers)?.address || '(no spoke)'}:${UNDERPOST_EDGE.sshPort}`
1541
+ : '(closed)',
1542
+ }
1543
+ : {}),
1544
+ peers: probeHost
1545
+ ? wireguardStatusFactory({
1546
+ peers: state.peers,
1547
+ latestHandshakes: show('latest-handshakes'),
1548
+ transfer: show('transfer'),
1549
+ endpoints: show('endpoints'),
1550
+ })
1551
+ : state.peers.map(peerSummaryFactory),
1552
+ routing: table
1553
+ ? {
1554
+ deployList: table.deployList,
1555
+ missing: table.missing,
1556
+ via,
1557
+ routes: table.routes.map((route) => `${route.host} -> ${route.peerId} (${route.via}, ${route.deployId})`),
1558
+ }
1559
+ : null,
1560
+ };
1561
+ logger.info('Edge status', summary);
1562
+
1563
+ if (table && table.routes.length === 0)
1564
+ logger.warn('No hostname resolved to a spoke: the edge would refuse every request', {
1565
+ unresolved: table.unresolved.length,
1566
+ next: 'register a peer with --peer-add, then bind it with --hosts / --instances, or mark it --default',
1567
+ });
1568
+ else if (table?.unresolved.length > 0)
1569
+ logger.warn('Hostnames with no spoke binding', { unresolved: table.unresolved });
1570
+ if (table?.conflicts.length > 0)
1571
+ logger.warn('Hostnames claimed by more than one deploy; only the first is served', {
1572
+ conflicts: table.conflicts,
1573
+ });
1574
+ warnRegistryHazards(state.peers);
1575
+ return summary;
1576
+ },
1577
+
1578
+ /**
1579
+ * @method haproxySync
1580
+ * @description Recompiles the routing tables from the deploy configuration
1581
+ * and hot-reloads HAProxy.
1582
+ *
1583
+ * The candidate config is validated before the running process is signalled,
1584
+ * and the previous files are put back if it fails — a config that does not
1585
+ * parse would otherwise take the whole edge down on reload. The reload itself
1586
+ * hands the listening sockets to the incoming process, so established
1587
+ * connections and the tunnels underneath them are untouched.
1588
+ * @param {object} options - CLI options.
1589
+ * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, changed: boolean}} What was published.
1590
+ * @throws {Error} When HAProxy rejects the candidate config.
1591
+ * @memberof UnderpostWireguard
1592
+ */
1593
+ haproxySync(options = {}) {
1594
+ const { routes, unresolved, conflicts, peers, deployList } = UnderpostWireguard.API.routeTable(options.deployId);
1595
+ // Fail before the per-hostname report: with nothing resolved, listing
1596
+ // every unbound hostname buries the one line that explains the run.
1597
+ if (routes.length === 0)
1598
+ throw new Error(
1599
+ '[wireguard] No hostname resolved to a spoke; publishing would refuse every request. ' +
1600
+ 'Register a peer with --peer-add and bind it with --hosts / --instances, or mark it --default.',
1601
+ );
1602
+ if (unresolved.length > 0)
1603
+ logger.warn('Hostnames with no spoke binding; they will fall through to the default backend or be refused', {
1604
+ unresolved,
1605
+ });
1606
+ if (conflicts.length > 0)
1607
+ logger.warn('Hostnames claimed by more than one deploy; only the first is served', { conflicts });
1608
+ const state = readEdgeState();
1609
+ const defaultPeer = defaultPeerFactory(state.peers);
1610
+ const maps = haproxyMapsFactory({ routes });
1611
+ const conf = haproxyConfFactory({
1612
+ peers,
1613
+ defaultPeerId: defaultPeer?.id || '',
1614
+ sshForwardPort: state.sshForwardPort,
1615
+ });
1616
+
1617
+ const targets = [
1618
+ { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName}`, content: maps.sni },
1619
+ { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`, content: maps.http },
1620
+ { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`, content: conf },
1621
+ ];
1622
+ const previous = targets.map(({ target }) => ({
1623
+ target,
1624
+ content: fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null,
1625
+ }));
1626
+ const restore = () => {
1627
+ for (const entry of previous) {
1628
+ if (entry.content === null) shellExec(`sudo rm -f ${entry.target}`, { silent: true });
1629
+ else writeRootFile({ target: entry.target, content: entry.content, mode: '0644' });
1630
+ }
1631
+ };
1632
+
1633
+ let changed = false;
1634
+ for (const entry of targets) changed = writeRootFile({ ...entry, dryRun: options.dryRun }) || changed;
1635
+ if (options.dryRun) {
1636
+ logger.info('[dry-run] edge routes', { deployList, routes, unresolved, conflicts });
1637
+ return { routes, unresolved, conflicts, changed };
1638
+ }
1639
+
1640
+ const check = shellExec(
1641
+ `sudo haproxy -c -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName} 2>&1`,
1642
+ { stdout: true, silent: true, silentOnError: true },
1643
+ );
1644
+ if (!`${check}`.includes('Configuration file is valid')) {
1645
+ restore();
1646
+ const detail = `${check}`.trim().split('\n').slice(-3).join(' ');
1647
+ logger.error('HAProxy rejected the candidate config; the previous config was restored', { detail });
1648
+ throw new Error(`[wireguard] HAProxy rejected the generated config: ${detail}`);
1649
+ }
1650
+ // Reload only a daemon that is already up: on a first bring-up the config
1651
+ // is written before `--haproxy-setup` enables the service, and reloading a
1652
+ // stopped unit is a failure rather than a no-op.
1653
+ if (changed) shellExec(systemdReloadIfActiveCommandFactory('haproxy'), { silent: true });
1654
+
1655
+ const quicTarget = defaultPeer?.address || '';
1656
+ runHostCommands(
1657
+ quicForwardCommandsFactory({ interfaceName: state.interfaceName, target: quicTarget }).ensure,
1658
+ options.dryRun,
1659
+ );
1660
+ logger.info('Edge routes published', {
1661
+ deployList,
1662
+ routes: routes.length,
1663
+ unresolved: unresolved.length,
1664
+ conflicts: conflicts.length,
1665
+ reloaded: changed,
1666
+ quicTarget,
1667
+ });
1668
+ return { routes, unresolved, conflicts, changed };
1669
+ },
1670
+
1671
+ /**
1672
+ * @method haproxySetup
1673
+ * @description Installs HAProxy, publishes the current routes, and enables
1674
+ * the daemon so the edge survives a reboot.
1675
+ * @param {object} options - CLI options.
1676
+ * @returns {void}
1677
+ * @memberof UnderpostWireguard
1678
+ */
1679
+ haproxySetup(options = {}) {
1680
+ UnderpostWireguard.API.install(options);
1681
+ UnderpostWireguard.API.haproxySync(options);
1682
+ runServiceCommands([systemctlCommandFactory({ action: 'enable --now', name: 'haproxy' })], options.dryRun);
1683
+ logger.info('HAProxy edge gateway enabled');
1684
+ },
1685
+
1686
+ /**
1687
+ * @method forwardProxyConfig
1688
+ * @description Resolves and validates the hub proxy endpoint.
1689
+ * @param {object} [options] - CLI options.
1690
+ * @returns {{host: string, port: number, apiKey: string, address: string, interfaceName: string, listenPort: number, tunnelCidr: string}} Resolved endpoint; `apiKey` must never be logged.
1691
+ * @throws {Error} When run on a spoke, or with no key configured.
1692
+ * @memberof UnderpostWireguard
1693
+ */
1694
+ forwardProxyConfig(options = {}) {
1695
+ const state = readEdgeState();
1696
+ if (state.role !== 'server')
1697
+ throw new Error(
1698
+ state.role === 'client'
1699
+ ? '[wireguard] --forward-proxy-server runs on the hub: a spoke would relay through its own ISP address'
1700
+ : '[wireguard] --forward-proxy-server requires a configured hub; run --wireguard-setup --server first',
1701
+ );
1702
+ const config = forwardProxyConfigFactory({
1703
+ host: `${options.forwardProxyServerHost || ''}`.trim() || tunnelAddressFactory(state.address),
1704
+ port: options.forwardProxyServerPort,
1705
+ });
1706
+ if (!config.apiKey)
1707
+ throw new Error(
1708
+ `[wireguard] ${FORWARD_PROXY.env.apiKey} is not set; every proxied request is authenticated with it. ` +
1709
+ `Export it, put it in the deploy env selected by \`underpost env <deploy-id> <environment>\` (./.env), ` +
1710
+ `or set it with \`underpost env set ${FORWARD_PROXY.env.apiKey} <key>\``,
1711
+ );
1712
+ if (['0.0.0.0', '::', '*'].includes(config.host))
1713
+ logger.warn('Forward proxy is bound to a wildcard address, not the tunnel', {
1714
+ host: config.host,
1715
+ consequence: 'the proxy is reachable from every interface; only the API key refuses a request',
1716
+ instead: `--forward-proxy-server-host ${tunnelAddressFactory(state.address) || tunnelAddressFactory(UNDERPOST_EDGE.cidr)}`,
1717
+ });
1718
+ return {
1719
+ ...config,
1720
+ address: `${config.host}:${config.port}`,
1721
+ interfaceName: state.interfaceName,
1722
+ listenPort: state.listenPort,
1723
+ tunnelCidr: tunnelNetworkCidrFactory(state.address),
1724
+ };
1725
+ },
1726
+
1727
+ /**
1728
+ * @method forwardProxyNodePath
1729
+ * @description Selects a compatible Node binary that systemd can execute.
1730
+ * @returns {{path: string, probed: boolean, rejected: Array<object>}} The chosen binary, and why each earlier candidate was passed over.
1731
+ * @memberof UnderpostWireguard
1732
+ */
1733
+ forwardProxyNodePath() {
1734
+ const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
1735
+ const ok = (command) => shellExec(command, { silent: true, silentOnError: true }).code === 0;
1736
+ const requiredMajor = Number(`${process.versions.node}`.split('.')[0]) || 0;
1737
+ const probed = read(systemdAvailableCommandFactory()) !== '';
1738
+ const user = os.userInfo().username;
1739
+ const rejected = [];
1740
+ for (const candidate of forwardProxyNodeCandidatesFactory()) {
1741
+ if (!ok(`sudo test -x ${candidate}`)) {
1742
+ rejected.push({ candidate, reason: 'not present' });
1743
+ continue;
1744
+ }
1745
+ const major = Number(read(`sudo ${candidate} --version`).replace(/^v/, '').split('.')[0]) || 0;
1746
+ if (major < requiredMajor) {
1747
+ rejected.push({ candidate, reason: `runs Node v${major || '?'}, the engine needs v${requiredMajor}` });
1748
+ continue;
1749
+ }
1750
+ if (probed && !ok(forwardProxyNodeProbeCommandFactory(candidate, user))) {
1751
+ rejected.push({
1752
+ candidate,
1753
+ reason: homeDirectoryPathFactory(candidate)
1754
+ ? 'systemd cannot execute it: it is under a home directory, whose SELinux label a unit cannot enter'
1755
+ : 'systemd cannot execute it',
1756
+ });
1757
+ continue;
1758
+ }
1759
+ return { path: candidate, probed, rejected };
1760
+ }
1761
+ return { path: '', probed, rejected };
1762
+ },
1763
+
1764
+ /**
1765
+ * @method forwardProxyServer
1766
+ * @description Reconciles the supervised proxy service.
1767
+ * @param {object} [options] - CLI options.
1768
+ * @returns {?object} What was reconciled, or null under `--dry-run`.
1769
+ * @throws {Error} When run on a spoke, or with no key configured.
1770
+ * @memberof UnderpostWireguard
1771
+ */
1772
+ forwardProxyServer(options = {}) {
1773
+ if (`${process.env[FORWARD_PROXY.supervisedEnv] || ''}`.trim())
1774
+ return UnderpostWireguard.API.forwardProxyListen(options);
1775
+
1776
+ const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
1777
+ const ok = (command) => shellExec(command, { silent: true, silentOnError: true }).code === 0;
1778
+ const withdrawBrokenUnit = () => {
1779
+ if (fs.existsSync(FORWARD_PROXY.unitPath))
1780
+ runServiceCommands(forwardProxyServiceCommandsFactory().remove, false);
1781
+ };
1782
+ const config = UnderpostWireguard.API.forwardProxyConfig(options);
1783
+ const node = options.dryRun
1784
+ ? { path: process.execPath, probed: false, rejected: [] }
1785
+ : UnderpostWireguard.API.forwardProxyNodePath();
1786
+ if (!node.path) {
1787
+ logger.error('No Node binary the forward proxy service can execute', {
1788
+ requires: `Node v${`${process.versions.node}`.split('.')[0]}`,
1789
+ rejected: node.rejected,
1790
+ fix: 'install Node system-wide, then re-run: curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - && sudo dnf install -y nodejs',
1791
+ });
1792
+ withdrawBrokenUnit();
1793
+ throw new Error(
1794
+ '[wireguard] No Node binary the forward proxy service can execute; install Node system-wide (a binary under /root or /home cannot be run by a unit) and re-run',
1795
+ );
1796
+ }
1797
+ if (node.probed === false && homeDirectoryPathFactory(node.path))
1798
+ logger.warn('Forward proxy unit points at a Node binary under a home directory', {
1799
+ node: node.path,
1800
+ risk: 'systemd refuses those on an SELinux host and the service restarts on 203/EXEC',
1801
+ fix: 'install Node system-wide, or check `journalctl -u underpost-forward-proxy` after this run',
1802
+ });
1803
+ if (node.probed && !ok(forwardProxyStartProbeCommandFactory({ nodePath: node.path }))) {
1804
+ logger.error('The forward proxy service cannot start this checkout', {
1805
+ node: node.path,
1806
+ cli: process.argv[1],
1807
+ workingDirectory: process.cwd(),
1808
+ likely: homeDirectoryPathFactory(process.cwd())
1809
+ ? 'the checkout is under a home directory, which a unit cannot read on an SELinux host'
1810
+ : 'the service cannot read the checkout; see the journal for the failing command',
1811
+ check: `sudo ${forwardProxyStartProbeCommandFactory({ nodePath: node.path }).replace(/^sudo /, '')}`,
1812
+ });
1813
+ withdrawBrokenUnit();
1814
+ throw new Error(
1815
+ `[wireguard] A systemd unit cannot start ${process.argv[1]} from ${process.cwd()}; move the checkout out of a home directory (e.g. /opt/underpost/engine) and re-run`,
1816
+ );
1817
+ }
1818
+ runHostCommands(
1819
+ firewallCommandsFactory({
1820
+ role: 'server',
1821
+ interfaceName: config.interfaceName,
1822
+ listenPort: config.listenPort,
1823
+ tunnelCidr: config.tunnelCidr,
1824
+ }),
1825
+ options.dryRun,
1826
+ );
1827
+ const unit = forwardProxyUnitFactory({
1828
+ host: config.host,
1829
+ port: config.port,
1830
+ apiKey: config.apiKey,
1831
+ interfaceName: config.interfaceName,
1832
+ command: forwardProxyCommandFactory({ host: config.host, port: config.port, execPath: node.path }),
1833
+ });
1834
+ const changed = writeRootFile({
1835
+ target: FORWARD_PROXY.unitPath,
1836
+ content: unit,
1837
+ mode: '0600',
1838
+ dryRun: options.dryRun,
1839
+ });
1840
+ runServiceCommands(forwardProxyServiceCommandsFactory({ changed }).ensure, options.dryRun);
1841
+ if (options.dryRun) {
1842
+ logger.info('[dry-run] would reconcile the forward proxy service', {
1843
+ service: FORWARD_PROXY.serviceName,
1844
+ address: config.address,
1845
+ });
1846
+ return null;
1847
+ }
1848
+ const statusCommands = systemdStatusCommandsFactory(FORWARD_PROXY.serviceName);
1849
+ const state = read(statusCommands.active);
1850
+ const enabled = read(statusCommands.enabled);
1851
+ logger.info('Forward proxy service reconciled', {
1852
+ service: FORWARD_PROXY.serviceName,
1853
+ address: config.address,
1854
+ tunnel: config.tunnelCidr,
1855
+ node: node.path,
1856
+ unitChanged: changed,
1857
+ state,
1858
+ enabled,
1859
+ logs: journalctlCommandFactory({ name: FORWARD_PROXY.serviceName, follow: true }),
1860
+ });
1861
+ const running = state === 'active' || state === 'activating';
1862
+ if (!running) {
1863
+ logger.error('Forward proxy service did not come up', {
1864
+ state: state || '(unknown)',
1865
+ likely: `${config.host} does not exist yet, so the listener cannot bind — bring the tunnel up with --wireguard-start`,
1866
+ check: journalctlCommandFactory({ name: FORWARD_PROXY.serviceName, lines: 20 }),
1867
+ });
1868
+ process.exitCode = 1;
1869
+ }
1870
+ return { service: FORWARD_PROXY.serviceName, address: config.address, changed, state, enabled };
1871
+ },
1872
+
1873
+ /**
1874
+ * @method forwardProxyListen
1875
+ * @description Starts the authenticated listener on the resolved hub address.
1876
+ * @param {object} [options] - CLI options.
1877
+ * @returns {object} The listening server.
1878
+ * @throws {Error} When run on a spoke, or with no key configured.
1879
+ * @memberof UnderpostWireguard
1880
+ */
1881
+ forwardProxyListen(options = {}) {
1882
+ const config = UnderpostWireguard.API.forwardProxyConfig(options);
1883
+ let server;
1884
+ server = forwardProxyServerFactory({
1885
+ config,
1886
+ onError(error) {
1887
+ logger.error('Forward proxy listener failed', { address: config.address, message: error.message });
1888
+ process.exitCode = 1;
1889
+ server.close(() => {});
1890
+ },
1891
+ onListen() {
1892
+ logger.info('Forward proxy listening', { address: config.address, tunnel: UNDERPOST_EDGE.tunnelCidr });
1893
+ },
1894
+ });
1895
+ return server;
1896
+ },
1897
+
1898
+ /**
1899
+ * @method start
1900
+ * @description Enables and starts the tunnel, and the QUIC forward with it.
1901
+ * @param {object} options - CLI options.
1902
+ * @returns {void}
1903
+ * @memberof UnderpostWireguard
1904
+ */
1905
+ start(options = {}) {
1906
+ const state = readEdgeState();
1907
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1908
+ runServiceCommands(
1909
+ [systemctlCommandFactory({ action: 'enable --now', name: `wg-quick@${interfaceName}` })],
1910
+ options.dryRun,
1911
+ );
1912
+ if (state.role === 'server')
1913
+ runHostCommands(
1914
+ quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
1915
+ options.dryRun,
1916
+ );
1917
+ logger.info('WireGuard interface started', { interfaceName });
1918
+ },
1919
+
1920
+ /**
1921
+ * @method stop
1922
+ * @description Tears the tunnel down and removes the packet rules that only
1923
+ * make sense while it is up.
1924
+ * @param {object} options - CLI options.
1925
+ * @returns {void}
1926
+ * @memberof UnderpostWireguard
1927
+ */
1928
+ stop(options = {}) {
1929
+ const state = readEdgeState();
1930
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1931
+ runHostCommands(
1932
+ [
1933
+ systemctlCommandFactory({
1934
+ action: 'disable --now',
1935
+ name: `wg-quick@${interfaceName}`,
1936
+ stderr: true,
1937
+ allowFailure: true,
1938
+ }),
1939
+ `sudo sh -c 'wg-quick down ${interfaceName} 2>/dev/null || true'`,
1940
+ ...quicForwardCommandsFactory({ interfaceName }).remove,
1941
+ ],
1942
+ options.dryRun,
1943
+ );
1944
+ logger.info('WireGuard interface stopped', { interfaceName });
1945
+ },
1946
+
1947
+ /**
1948
+ * @method reset
1949
+ * @description Returns the host to zero: stops the daemons and withdraws
1950
+ * every artifact the setup installed.
1951
+ *
1952
+ * Everything this subsystem writes outside the repo is removed — interface
1953
+ * config, sysctl drop-in, both HAProxy map files *and* the generated
1954
+ * `haproxy.cfg`, the forward proxy unit, the NAT chains, and the firewalld
1955
+ * rules opened for the recorded role. Leaving `haproxy.cfg` behind while
1956
+ * deleting the maps it reads is worse than leaving both: the daemon then
1957
+ * fails to start on a config that references files that no longer exist.
1958
+ *
1959
+ * The key pair and the registry are deliberately kept: destroying the key
1960
+ * invalidates every spoke's peer entry, and the registry is authored source
1961
+ * that nothing can regenerate. A reset is for reconfiguring an edge rather
1962
+ * than for re-establishing trust with all of them; re-keying is what
1963
+ * {@link UnderpostWireguard.reinstall} is for.
1964
+ * @param {object} options - CLI options.
1965
+ * @returns {void}
1966
+ * @memberof UnderpostWireguard
1967
+ */
1968
+ reset(options = {}) {
1969
+ const state = readEdgeState();
1970
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1971
+ UnderpostWireguard.API.stop({ ...options, interface: interfaceName });
1972
+ runHostCommands(
1973
+ [
1974
+ `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
1975
+ `sudo rm -f ${UNDERPOST_EDGE.sysctlPath}`,
1976
+ systemctlCommandFactory({
1977
+ action: 'disable --now',
1978
+ name: 'haproxy',
1979
+ stderr: true,
1980
+ allowFailure: true,
1981
+ }),
1982
+ `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName}`,
1983
+ `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`,
1984
+ `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`,
1985
+ ...forwardProxyServiceCommandsFactory().remove,
1986
+ ...(state.role
1987
+ ? firewallCommandsFactory({
1988
+ role: state.role,
1989
+ interfaceName,
1990
+ listenPort: state.listenPort,
1991
+ tunnelCidr: tunnelNetworkCidrFactory(state.address),
1992
+ sshForwardPort: state.sshForwardPort,
1993
+ remove: true,
1994
+ })
1995
+ : []),
1996
+ ],
1997
+ options.dryRun,
1998
+ );
1999
+ logger.info('Edge host state removed; key pair and peer registry retained', {
2000
+ interfaceName,
2001
+ role: state.role || '(unset)',
2002
+ firewallWithdrawn: state.role !== '',
2003
+ });
2004
+ if (!state.role)
2005
+ logger.warn('Registry records no role, so no firewalld rules were withdrawn', {
2006
+ fix: `--wireguard-reset --interface ${interfaceName} after the role is recorded, or withdraw them by hand`,
2007
+ });
2008
+ },
2009
+
2010
+ /**
2011
+ * @method reinstall
2012
+ * @description Full purge and rebuild: reset, drop the key pair, reinstall
2013
+ * the packages, then re-key and republish.
2014
+ *
2015
+ * Every spoke has to be re-registered afterwards, because the hub's identity
2016
+ * changed — that is the point of the command, and the reason it is separate
2017
+ * from `--wireguard-reset`.
2018
+ * @param {object} options - CLI options.
2019
+ * @returns {void}
2020
+ * @memberof UnderpostWireguard
2021
+ */
2022
+ reinstall(options = {}) {
2023
+ const state = readEdgeState();
2024
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2025
+ UnderpostWireguard.API.reset({ ...options, interface: interfaceName });
2026
+ runHostCommands(
2027
+ [
2028
+ `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`,
2029
+ `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.pub`,
2030
+ `sudo dnf -y reinstall ${UNDERPOST_EDGE.packages.join(' ')} || sudo dnf -y install ${UNDERPOST_EDGE.packages.join(' ')}`,
2031
+ ],
2032
+ options.dryRun,
2033
+ );
2034
+ const next = UnderpostWireguard.API.setup({ ...options, interface: interfaceName });
2035
+ logger.warn('Re-keyed: this machine now presents a new identity, and the far end still expects the old one', {
2036
+ interfaceName,
2037
+ publicKey: next.publicKey,
2038
+ onEverySpoke:
2039
+ next.role === 'server'
2040
+ ? `--wireguard-setup --client --public-key '${next.publicKey}' (peer-ip and endpoint are remembered)`
2041
+ : 'none',
2042
+ onTheHub:
2043
+ next.role === 'client'
2044
+ ? `--peer-add <this spoke id> --peer-ip ${next.address} --public-key '${next.publicKey}'`
2045
+ : 'none',
2046
+ });
2047
+ },
2048
+
2049
+ /**
2050
+ * @method callback
2051
+ * @description CLI entry point for both `underpost wireguard` and
2052
+ * `underpost haproxy`.
2053
+ *
2054
+ * Flags are evaluated in lifecycle order — install, setup, peer changes,
2055
+ * route publication, then daemon control — so a single invocation can carry
2056
+ * a whole bring-up (`--wireguard-install --wireguard-setup --server
2057
+ * --haproxy-setup --wireguard-start`) and still execute the steps in the only
2058
+ * order that works. `--status` runs last, so it reports what the run left
2059
+ * behind.
2060
+ * @param {object} [options] - CLI options.
2061
+ * @returns {Promise<void>}
2062
+ * @memberof UnderpostWireguard
2063
+ */
2064
+ async callback(options = {}) {
2065
+ // `--build-conf` is a hard promise, not a modifier: it short-circuits
2066
+ // every host action so the run cannot touch /etc, iptables, systemd or a
2067
+ // live interface even when other lifecycle flags are also present.
2068
+ if (options.buildConf === true) {
2069
+ if (options.wireguardSetup === true) UnderpostWireguard.API.setup(options);
2070
+ if (options.peerAdd) UnderpostWireguard.API.peerAdd(options);
2071
+ if (options.peerRemove) UnderpostWireguard.API.peerRemove(options);
2072
+ if (!options.wireguardSetup && !options.peerAdd && !options.peerRemove)
2073
+ UnderpostWireguard.API.buildConf(options);
2074
+ if (options.status === true) UnderpostWireguard.API.status(options);
2075
+ return;
2076
+ }
2077
+
2078
+ if (options.wireguardReinstall === true) return void UnderpostWireguard.API.reinstall(options);
2079
+ if (options.wireguardReset === true) return void UnderpostWireguard.API.reset(options);
2080
+ if (options.wireguardInstall === true) UnderpostWireguard.API.install(options);
2081
+ if (options.wireguardSetup === true) UnderpostWireguard.API.setup(options);
2082
+ if (options.peerAdd) UnderpostWireguard.API.peerAdd(options);
2083
+ if (options.peerRemove) UnderpostWireguard.API.peerRemove(options);
2084
+ if (options.haproxySetup === true) UnderpostWireguard.API.haproxySetup(options);
2085
+ else if (options.haproxySync === true) UnderpostWireguard.API.haproxySync(options);
2086
+ if (options.wireguardStop === true) UnderpostWireguard.API.stop(options);
2087
+ if (options.wireguardStart === true) UnderpostWireguard.API.start(options);
2088
+ // After the tunnel, because the service requires it and its address only
2089
+ // exists once the interface is up; before `--status`, so a run that
2090
+ // reconciles the service also reports it.
2091
+ if (options.forwardProxyServer === true) UnderpostWireguard.API.forwardProxyServer(options);
2092
+ if (options.status === true) UnderpostWireguard.API.status(options);
2093
+ },
2094
+ };
2095
+ }
2096
+
2097
+ export {
2098
+ EDGE_STATE_PATH,
2099
+ UNDERPOST_EDGE,
2100
+ allowedIpsConflictsFactory,
2101
+ backendNameFactory,
2102
+ defaultPeerFactory,
2103
+ deployListFactory,
2104
+ edgeRouteTableFactory,
2105
+ edgeStateFactory,
2106
+ firewallCommandsFactory,
2107
+ haproxyConfFactory,
2108
+ haproxyMapsFactory,
2109
+ hostProxyEntriesFactory,
2110
+ instanceProxyEntriesFactory,
2111
+ mergeRouteTablesFactory,
2112
+ peerFactory,
2113
+ quicForwardCommandsFactory,
2114
+ readEdgeState,
2115
+ redirectHostFactory,
2116
+ tunnelAddressFactory,
2117
+ tunnelNetworkCidrFactory,
2118
+ wireguardClientConfFactory,
2119
+ wireguardClientSettingsFactory,
2120
+ wireguardServerConfFactory,
2121
+ wireguardStatusFactory,
2122
+ writeEdgeState,
2123
+ };
2124
+
2125
+ export default UnderpostWireguard;