underpost 3.2.80 → 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 (84) 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 +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  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/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,1177 @@
1
+ 'use strict';
2
+
3
+ import { expect } from 'chai';
4
+ import http from 'node:http';
5
+ import net from 'node:net';
6
+ import {
7
+ FORWARD_PROXY,
8
+ fetchViaForwardProxy,
9
+ forwardProxyAuthorizedFactory,
10
+ forwardProxyCommandFactory,
11
+ forwardProxyConfigFactory,
12
+ forwardProxyConnectHandlerFactory,
13
+ forwardProxyHeadersFactory,
14
+ forwardProxyNodeCandidatesFactory,
15
+ forwardProxyNodeProbeCommandFactory,
16
+ forwardProxyRequestHandlerFactory,
17
+ forwardProxyServiceCommandsFactory,
18
+ forwardProxyStartProbeCommandFactory,
19
+ forwardProxyTargetFactory,
20
+ forwardProxyTunnelTargetFactory,
21
+ forwardProxyUnitFactory,
22
+ } from '../src/server/forward-proxy.js';
23
+ import { homeDirectoryPathFactory } from '../src/server/systemd.js';
24
+ import {
25
+ UNDERPOST_EDGE,
26
+ allowedIpsConflictsFactory,
27
+ backendNameFactory,
28
+ defaultPeerFactory,
29
+ deployListFactory,
30
+ edgeRouteTableFactory,
31
+ edgeStateFactory,
32
+ firewallCommandsFactory,
33
+ haproxyConfFactory,
34
+ haproxyMapsFactory,
35
+ hostProxyEntriesFactory,
36
+ instanceProxyEntriesFactory,
37
+ mergeRouteTablesFactory,
38
+ peerFactory,
39
+ quicForwardCommandsFactory,
40
+ redirectHostFactory,
41
+ tunnelAddressFactory,
42
+ tunnelNetworkCidrFactory,
43
+ wireguardClientConfFactory,
44
+ wireguardClientSettingsFactory,
45
+ wireguardServerConfFactory,
46
+ wireguardStatusFactory,
47
+ } from '../src/cli/wireguard.js';
48
+
49
+ // The conf shape the PRD names: two published sites, each fronted by a bare
50
+ // domain that only redirects to its `www` host.
51
+ const CONF_SERVER = {
52
+ 'dogmadual.com': {
53
+ '/': {
54
+ client: null,
55
+ runtime: 'nodejs',
56
+ apis: [],
57
+ origins: [],
58
+ proxy: [80, 443],
59
+ redirect: 'https://www.dogmadual.com',
60
+ },
61
+ },
62
+ 'www.dogmadual.com': {
63
+ '/': { client: 'dogmadual', runtime: 'nodejs', apis: ['user', 'file'], ws: 'core', peer: true, proxy: [80, 443] },
64
+ },
65
+ 'nexodev.org': {
66
+ '/': {
67
+ client: null,
68
+ runtime: 'nodejs',
69
+ apis: [],
70
+ origins: [],
71
+ proxy: [80, 443],
72
+ redirect: 'https://www.nexodev.org',
73
+ },
74
+ },
75
+ 'www.nexodev.org': {
76
+ '/': { client: 'nexodev', runtime: 'nodejs', apis: ['default', 'user'], ws: 'core', peer: true, proxy: [80, 443] },
77
+ },
78
+ };
79
+
80
+ const INSTANCES = [
81
+ { id: 'mmo-server', templateId: 'mmo-server', host: 'server.cyberiaonline.com', path: '/' },
82
+ { id: 'mmo-server-forest', templateId: 'mmo-server', host: 'server.cyberiaonline.com', path: '/FOREST' },
83
+ { id: 'mmo-client', templateId: 'mmo-client', host: 'client.cyberiaonline.com', path: '/' },
84
+ ];
85
+
86
+ const PEERS = [
87
+ {
88
+ id: 'homelab-a',
89
+ address: '10.0.0.2',
90
+ publicKey: 'AAA=',
91
+ hosts: ['www.dogmadual.com'],
92
+ allowedIPs: ['10.0.0.2/32', '192.168.10.0/24'],
93
+ },
94
+ { id: 'homelab-b', address: '10.0.0.3', publicKey: 'BBB=', hosts: ['www.nexodev.org'], instances: ['mmo-server'] },
95
+ ];
96
+
97
+ describe('edge hub routing', () => {
98
+ describe('hostProxyEntriesFactory', () => {
99
+ it('extracts every domain that declares proxy ports, with its redirect target', () => {
100
+ expect(hostProxyEntriesFactory({ confServer: CONF_SERVER })).to.deep.equal([
101
+ { host: 'dogmadual.com', ports: [80, 443], redirects: ['www.dogmadual.com'] },
102
+ { host: 'nexodev.org', ports: [80, 443], redirects: ['www.nexodev.org'] },
103
+ { host: 'www.dogmadual.com', ports: [80, 443], redirects: [] },
104
+ { host: 'www.nexodev.org', ports: [80, 443], redirects: [] },
105
+ ]);
106
+ });
107
+
108
+ // The `proxy` array is the declaration that a hostname is reachable from
109
+ // outside; without one the host is internal and must not reach the edge.
110
+ it('skips a host whose sub-paths declare no proxy ports', () => {
111
+ const entries = hostProxyEntriesFactory({
112
+ confServer: { 'internal.test': { '/': { client: 'x' } }, 'public.test': { '/': { proxy: [443] } } },
113
+ });
114
+ expect(entries.map((entry) => entry.host)).to.deep.equal(['public.test']);
115
+ });
116
+
117
+ it('unions ports across sub-paths, because the edge routes a hostname rather than a path', () => {
118
+ const [entry] = hostProxyEntriesFactory({
119
+ confServer: { 'app.test': { '/': { proxy: [443] }, '/api': { proxy: [80, 443] } } },
120
+ });
121
+ expect(entry.ports).to.deep.equal([80, 443]);
122
+ });
123
+
124
+ it('reads a redirect target whether it is a URL or a bare hostname', () => {
125
+ expect(redirectHostFactory('https://www.nexodev.org/path')).to.equal('www.nexodev.org');
126
+ expect(redirectHostFactory('www.nexodev.org')).to.equal('www.nexodev.org');
127
+ expect(redirectHostFactory('')).to.equal('');
128
+ expect(redirectHostFactory('::not a url::')).to.equal('');
129
+ });
130
+ });
131
+
132
+ describe('instanceProxyEntriesFactory', () => {
133
+ it('collapses a variant family onto one hostname reachable by id and by template id', () => {
134
+ expect(instanceProxyEntriesFactory({ instances: INSTANCES })).to.deep.equal([
135
+ { host: 'client.cyberiaonline.com', ports: [80, 443], instances: ['mmo-client'] },
136
+ {
137
+ host: 'server.cyberiaonline.com',
138
+ ports: [80, 443],
139
+ instances: ['mmo-server', 'mmo-server-forest'],
140
+ },
141
+ ]);
142
+ });
143
+ });
144
+
145
+ describe('edgeRouteTableFactory', () => {
146
+ it('resolves each published hostname to the spoke its binding names, redirects included', () => {
147
+ const { routes, unresolved } = edgeRouteTableFactory({ confServer: CONF_SERVER, peers: PEERS });
148
+ expect(unresolved).to.deep.equal([]);
149
+ expect(routes).to.deep.equal([
150
+ { host: 'dogmadual.com', ports: [80, 443], peerId: 'homelab-a', address: '10.0.0.2', via: 'redirect' },
151
+ { host: 'nexodev.org', ports: [80, 443], peerId: 'homelab-b', address: '10.0.0.3', via: 'redirect' },
152
+ { host: 'www.dogmadual.com', ports: [80, 443], peerId: 'homelab-a', address: '10.0.0.2', via: 'host' },
153
+ { host: 'www.nexodev.org', ports: [80, 443], peerId: 'homelab-b', address: '10.0.0.3', via: 'host' },
154
+ ]);
155
+ });
156
+
157
+ it('resolves instance hostnames through the template id, covering every variant', () => {
158
+ const { routes } = edgeRouteTableFactory({ instances: INSTANCES, peers: PEERS });
159
+ const server = routes.find((route) => route.host === 'server.cyberiaonline.com');
160
+ expect(server).to.include({ peerId: 'homelab-b', via: 'instance' });
161
+ });
162
+
163
+ it('prefers an explicit hostname binding over the instance binding', () => {
164
+ const { routes } = edgeRouteTableFactory({
165
+ instances: INSTANCES,
166
+ peers: [
167
+ ...PEERS,
168
+ { id: 'homelab-c', address: '10.0.0.4', publicKey: 'CCC=', hosts: ['server.cyberiaonline.com'] },
169
+ ],
170
+ });
171
+ expect(routes.find((route) => route.host === 'server.cyberiaonline.com')).to.include({
172
+ peerId: 'homelab-c',
173
+ via: 'host',
174
+ });
175
+ });
176
+
177
+ it('sends an unmatched hostname to the default spoke', () => {
178
+ const { routes, unresolved } = edgeRouteTableFactory({
179
+ confServer: { 'orphan.test': { '/': { proxy: [443] } } },
180
+ peers: [...PEERS, { id: 'fallback', address: '10.0.0.9', publicKey: 'DDD=', default: true }],
181
+ });
182
+ expect(unresolved).to.deep.equal([]);
183
+ expect(routes).to.deep.equal([
184
+ { host: 'orphan.test', ports: [443], peerId: 'fallback', address: '10.0.0.9', via: 'default' },
185
+ ]);
186
+ });
187
+
188
+ it('treats a lone spoke as its own fallback', () => {
189
+ const { routes } = edgeRouteTableFactory({
190
+ confServer: { 'orphan.test': { '/': { proxy: [443] } } },
191
+ peers: [{ id: 'only', address: '10.0.0.2', publicKey: 'AAA=' }],
192
+ });
193
+ expect(routes[0]).to.include({ peerId: 'only', via: 'default' });
194
+ });
195
+
196
+ // A dropped hostname answers nothing at all, and nothing about the deploy
197
+ // reveals it — so an unbindable host is reported instead.
198
+ it('reports a hostname that binds to nothing rather than dropping it', () => {
199
+ const { routes, unresolved } = edgeRouteTableFactory({
200
+ confServer: { 'orphan.test': { '/': { proxy: [443] } } },
201
+ peers: PEERS,
202
+ });
203
+ expect(routes).to.deep.equal([]);
204
+ expect(unresolved).to.deep.equal(['orphan.test']);
205
+ });
206
+
207
+ it('does not loop on redirects that point at each other', () => {
208
+ const { routes, unresolved } = edgeRouteTableFactory({
209
+ confServer: {
210
+ 'a.test': { '/': { proxy: [443], redirect: 'https://b.test' } },
211
+ 'b.test': { '/': { proxy: [443], redirect: 'https://a.test' } },
212
+ },
213
+ peers: PEERS,
214
+ });
215
+ expect(routes).to.deep.equal([]);
216
+ expect(unresolved).to.deep.equal(['a.test', 'b.test']);
217
+ });
218
+
219
+ it('merges a hostname declared by both conf.server.json and an instance', () => {
220
+ const { routes } = edgeRouteTableFactory({
221
+ confServer: { 'server.cyberiaonline.com': { '/': { proxy: [443] } } },
222
+ instances: INSTANCES,
223
+ peers: PEERS,
224
+ });
225
+ const server = routes.find((route) => route.host === 'server.cyberiaonline.com');
226
+ expect(server.ports).to.deep.equal([80, 443]);
227
+ expect(server.via).to.equal('instance');
228
+ });
229
+
230
+ it('returns only the peers the routes reference, so no unused backend is emitted', () => {
231
+ const { peers } = edgeRouteTableFactory({
232
+ confServer: { 'www.dogmadual.com': CONF_SERVER['www.dogmadual.com'] },
233
+ peers: PEERS,
234
+ });
235
+ expect(peers.map((peer) => peer.id)).to.deep.equal(['homelab-a']);
236
+ });
237
+ });
238
+
239
+ describe('allowedIpsConflictsFactory', () => {
240
+ it('is quiet when every peer claims a distinct CIDR', () => {
241
+ expect(allowedIpsConflictsFactory({ peers: PEERS })).to.deep.equal([]);
242
+ });
243
+
244
+ // WireGuard picks one peer by longest-prefix match; the other silently
245
+ // never receives that traffic. Two homelabs on 192.168.1.0/24 is the
246
+ // ordinary way this happens.
247
+ it('reports a LAN subnet two spokes both claim', () => {
248
+ const conflicts = allowedIpsConflictsFactory({
249
+ peers: [
250
+ { id: 'homelab-a', address: '10.0.0.2', allowedIPs: ['10.0.0.2/32', '192.168.1.0/24'] },
251
+ { id: 'homelab-b', address: '10.0.0.3', allowedIPs: ['10.0.0.3/32', '192.168.1.0/24'] },
252
+ ],
253
+ });
254
+ expect(conflicts).to.deep.equal([{ cidr: '192.168.1.0/24', peers: ['homelab-a', 'homelab-b'] }]);
255
+ });
256
+
257
+ // An address with no explicit allowedIPs contributes its own /32, so the
258
+ // same pass catches two spokes handed the same tunnel address.
259
+ it('catches a duplicated tunnel address', () => {
260
+ const conflicts = allowedIpsConflictsFactory({
261
+ peers: [
262
+ { id: 'homelab-a', address: '10.0.0.2' },
263
+ { id: 'homelab-b', address: '10.0.0.2' },
264
+ ],
265
+ });
266
+ expect(conflicts).to.deep.equal([{ cidr: '10.0.0.2/32', peers: ['homelab-a', 'homelab-b'] }]);
267
+ });
268
+ });
269
+
270
+ // One rule for the catch-all, shared by hostname resolution, the HAProxy
271
+ // default backends and the QUIC forward — so all three name the same spoke.
272
+ describe('defaultPeerFactory', () => {
273
+ it('nominates the peer marked default', () => {
274
+ expect(defaultPeerFactory([...PEERS, { id: 'fallback', address: '10.0.0.9', default: true }]).id).to.equal(
275
+ 'fallback',
276
+ );
277
+ });
278
+
279
+ it('treats a lone peer as its own fallback', () => {
280
+ expect(defaultPeerFactory([PEERS[0]]).id).to.equal('homelab-a');
281
+ });
282
+
283
+ // Picking one arbitrarily would send every unmatched hostname and all of
284
+ // UDP :443 to a spoke nobody nominated.
285
+ it('nominates nothing when several peers exist and none is marked', () => {
286
+ expect(defaultPeerFactory(PEERS)).to.equal(null);
287
+ expect(defaultPeerFactory([])).to.equal(null);
288
+ });
289
+ });
290
+
291
+ describe('deployListFactory', () => {
292
+ it('expands the dd meta id through dd.router', () => {
293
+ const list = deployListFactory('dd');
294
+ expect(list.length).to.be.above(0);
295
+ for (const id of list) expect(id.startsWith('dd-')).to.equal(true);
296
+ });
297
+
298
+ // `dd` names every deploy; prefixing it would address a deploy that does
299
+ // not exist and silently route nothing.
300
+ it('never prefixes the dd meta id itself', () => {
301
+ expect(deployListFactory('dd')).to.not.include('dd-dd');
302
+ });
303
+
304
+ it('accepts a comma-separated list and normalizes bare ids', () => {
305
+ expect(deployListFactory('dd-core,cyberia')).to.deep.equal(['dd-core', 'dd-cyberia']);
306
+ });
307
+
308
+ it('resolves an empty id to no deploys rather than to everything', () => {
309
+ expect(deployListFactory('')).to.deep.equal([]);
310
+ });
311
+ });
312
+
313
+ describe('mergeRouteTablesFactory', () => {
314
+ // The edge holds one pair of map files. Publishing one deploy's table alone
315
+ // would overwrite them and take every other deploy off the internet.
316
+ it('unions every deploy into one table, attributing each hostname', () => {
317
+ const { routes } = mergeRouteTablesFactory({
318
+ tables: [
319
+ {
320
+ deployId: 'dd-cyberia',
321
+ routes: [{ host: 'www.cyberiaonline.com', ports: [80, 443], peerId: 'homelab-a', address: '10.0.0.2' }],
322
+ },
323
+ {
324
+ deployId: 'dd-core',
325
+ routes: [{ host: 'www.nexodev.org', ports: [80, 443], peerId: 'homelab-b', address: '10.0.0.3' }],
326
+ },
327
+ ],
328
+ });
329
+ expect(routes.map((route) => [route.host, route.deployId])).to.deep.equal([
330
+ ['www.cyberiaonline.com', 'dd-cyberia'],
331
+ ['www.nexodev.org', 'dd-core'],
332
+ ]);
333
+ });
334
+
335
+ it('reports a hostname two deploys claim, and serves the first deterministically', () => {
336
+ const { routes, conflicts } = mergeRouteTablesFactory({
337
+ tables: [
338
+ {
339
+ deployId: 'dd-core',
340
+ routes: [{ host: 'shared.test', ports: [443], peerId: 'homelab-a', address: '10.0.0.2' }],
341
+ },
342
+ {
343
+ deployId: 'dd-test',
344
+ routes: [{ host: 'shared.test', ports: [443], peerId: 'homelab-b', address: '10.0.0.3' }],
345
+ },
346
+ ],
347
+ });
348
+ expect(routes).to.have.lengthOf(1);
349
+ expect(routes[0].peerId).to.equal('homelab-a');
350
+ expect(conflicts).to.deep.equal([{ host: 'shared.test', claimed: ['dd-core', 'dd-test'], serving: 'homelab-a' }]);
351
+ });
352
+
353
+ it('does not report a duplicate that both deploys route identically', () => {
354
+ const { conflicts } = mergeRouteTablesFactory({
355
+ tables: [
356
+ {
357
+ deployId: 'dd-core',
358
+ routes: [{ host: 'shared.test', ports: [443], peerId: 'homelab-a', address: '10.0.0.2' }],
359
+ },
360
+ {
361
+ deployId: 'dd-test',
362
+ routes: [{ host: 'shared.test', ports: [443], peerId: 'homelab-a', address: '10.0.0.2' }],
363
+ },
364
+ ],
365
+ });
366
+ expect(conflicts).to.deep.equal([]);
367
+ });
368
+
369
+ it('keeps unresolved hostnames attributed to the deploy that declared them', () => {
370
+ const { unresolved } = mergeRouteTablesFactory({
371
+ tables: [
372
+ { deployId: 'dd-core', routes: [], unresolved: ['orphan.test'] },
373
+ { deployId: 'dd-lampp', routes: [], unresolved: ['other.test'] },
374
+ ],
375
+ });
376
+ expect(unresolved).to.deep.equal([
377
+ { host: 'orphan.test', deployId: 'dd-core' },
378
+ { host: 'other.test', deployId: 'dd-lampp' },
379
+ ]);
380
+ });
381
+
382
+ it('deduplicates the peers every deploy shares, so one backend is emitted per spoke', () => {
383
+ const { peers } = mergeRouteTablesFactory({
384
+ tables: [
385
+ { deployId: 'dd-core', routes: [], peers: [{ id: 'homelab-a', address: '10.0.0.2' }] },
386
+ { deployId: 'dd-cyberia', routes: [], peers: [{ id: 'homelab-a', address: '10.0.0.2' }] },
387
+ ],
388
+ });
389
+ expect(peers).to.have.lengthOf(1);
390
+ });
391
+ });
392
+
393
+ describe('haproxyMapsFactory', () => {
394
+ it('writes one map line per hostname and transport, keyed on the declared ports', () => {
395
+ const { sni, http } = haproxyMapsFactory({
396
+ routes: [
397
+ { host: 'a.test', ports: [80, 443], peerId: 'homelab-a', address: '10.0.0.2' },
398
+ { host: 'b.test', ports: [443], peerId: 'homelab-b', address: '10.0.0.3' },
399
+ { host: 'c.test', ports: [80], peerId: 'homelab-b', address: '10.0.0.3' },
400
+ ],
401
+ });
402
+ expect(sni).to.equal('a.test be_tls_homelab_a\nb.test be_tls_homelab_b\n');
403
+ expect(http).to.equal('a.test be_http_homelab_a\nc.test be_http_homelab_b\n');
404
+ });
405
+
406
+ it('renders an empty map rather than a stray newline when nothing is routed', () => {
407
+ expect(haproxyMapsFactory({ routes: [] })).to.deep.equal({ sni: '', http: '' });
408
+ });
409
+
410
+ it('keeps peer ids that are not valid proxy names out of the backend names', () => {
411
+ expect(backendNameFactory('tls', 'home lab.a-1')).to.equal('be_tls_home_lab_a_1');
412
+ });
413
+ });
414
+
415
+ describe('haproxyConfFactory', () => {
416
+ const conf = haproxyConfFactory({ peers: PEERS.map(peerFactory), defaultPeerId: 'homelab-a' });
417
+
418
+ // The whole point of the L4 edge: no certificate, no key, nothing to decrypt.
419
+ it('never terminates TLS', () => {
420
+ expect(conf).to.not.match(/bind\s+.*\bssl\b/);
421
+ expect(conf).to.not.include('crt ');
422
+ expect(conf).to.include('mode tcp');
423
+ });
424
+
425
+ it('selects the TLS backend from the ClientHello SNI', () => {
426
+ expect(conf).to.include('tcp-request content accept if { req_ssl_hello_type 1 }');
427
+ expect(conf).to.include(
428
+ `use_backend %[req.ssl_sni,lower,map(${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName},be_tls_default)]`,
429
+ );
430
+ });
431
+
432
+ it('selects the cleartext backend from the Host header, ignoring an appended port', () => {
433
+ expect(conf).to.include(
434
+ `use_backend %[req.hdr(host),lower,word(1,:),map(${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName},be_http_default)]`,
435
+ );
436
+ });
437
+
438
+ it('emits both transports for every peer', () => {
439
+ for (const peer of PEERS) {
440
+ expect(conf).to.include(`backend be_http_${peer.id.replace(/-/g, '_')}`);
441
+ expect(conf).to.include(`server ${peer.id} ${peer.address}:443 check`);
442
+ }
443
+ });
444
+
445
+ it('points the default backends at the nominated spoke', () => {
446
+ expect(conf).to.include('backend be_tls_default\n mode tcp\n server homelab-a 10.0.0.2:443 check');
447
+ });
448
+
449
+ it('refuses an unmatched hostname when no spoke is nominated as default', () => {
450
+ const strict = haproxyConfFactory({ peers: [] });
451
+ expect(strict).to.include('http-request deny deny_status 421');
452
+ expect(strict).to.include('tcp-request content reject');
453
+ });
454
+
455
+ it('keeps long-lived streams alive past the idle timeouts', () => {
456
+ expect(conf).to.include('timeout tunnel 1h');
457
+ });
458
+
459
+ it('hands listening sockets to the incoming process so a reload drops nothing', () => {
460
+ expect(conf).to.include('expose-fd listeners');
461
+ });
462
+ });
463
+
464
+ describe('wireguard interface configs', () => {
465
+ const server = wireguardServerConfFactory({
466
+ address: '10.0.0.1/24',
467
+ keyPath: '/etc/wireguard/wg0.key',
468
+ peers: PEERS,
469
+ });
470
+
471
+ // No rendered config, dry-run print or log line may ever carry the key.
472
+ it('loads the private key from its own file instead of inlining it', () => {
473
+ expect(server).to.not.include('PrivateKey =');
474
+ expect(server).to.include('PostUp = wg set %i private-key /etc/wireguard/wg0.key');
475
+ });
476
+
477
+ it('writes one peer block per registered spoke, carrying its routed subnets', () => {
478
+ expect(server).to.include('PublicKey = AAA=');
479
+ expect(server).to.include('AllowedIPs = 10.0.0.2/32, 192.168.10.0/24');
480
+ // A spoke that declares no subnets still gets its own tunnel address.
481
+ expect(server).to.include('AllowedIPs = 10.0.0.3/32');
482
+ });
483
+
484
+ it('skips a peer that has no public key yet', () => {
485
+ const partial = wireguardServerConfFactory({
486
+ address: '10.0.0.1/24',
487
+ keyPath: '/etc/wireguard/wg0.key',
488
+ peers: [{ id: 'pending', address: '10.0.0.5' }],
489
+ });
490
+ expect(partial).to.not.include('[Peer]');
491
+ });
492
+
493
+ it('removes its own forwarding rules on teardown', () => {
494
+ expect(server).to.include('PostUp = iptables -I FORWARD -i wg0 -j ACCEPT');
495
+ expect(server).to.include('PostDown = iptables -D FORWARD -i wg0 -j ACCEPT');
496
+ });
497
+
498
+ const client = wireguardClientConfFactory({
499
+ address: '10.0.0.2',
500
+ keyPath: '/etc/wireguard/wg0.key',
501
+ publicKey: 'HUB=',
502
+ endpoint: 'vps.example.com:51820',
503
+ });
504
+
505
+ // A default route here would send a whole cluster's egress via the VPS.
506
+ it('routes only the tunnel subnet through the hub, never a default route', () => {
507
+ expect(client).to.include('AllowedIPs = 10.0.0.0/24');
508
+ expect(client).to.not.include('0.0.0.0/0');
509
+ });
510
+
511
+ it('holds the outbound NAT mapping open across a CGNAT boundary', () => {
512
+ expect(client).to.include(`PersistentKeepalive = ${UNDERPOST_EDGE.keepalive}`);
513
+ expect(UNDERPOST_EDGE.keepalive).to.be.below(30);
514
+ });
515
+
516
+ it('gives a bare spoke address a host prefix', () => {
517
+ expect(client).to.include('Address = 10.0.0.2/32');
518
+ });
519
+
520
+ it('masquerades pod traffic only when it enters the tunnel', () => {
521
+ expect(client).to.include('PostUp = iptables -t nat -C POSTROUTING -o %i -d 10.0.0.0/24 -j MASQUERADE');
522
+ expect(client).to.not.include('POSTROUTING -o %i -j MASQUERADE');
523
+ });
524
+
525
+ it('forwards tunnel requests and established replies for spoke workloads', () => {
526
+ expect(client).to.include('PostUp = iptables -C FORWARD -o %i -d 10.0.0.0/24 -j ACCEPT');
527
+ expect(client).to.include(
528
+ 'PostUp = iptables -C FORWARD -i %i -s 10.0.0.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
529
+ );
530
+ });
531
+
532
+ it('withdraws every spoke forwarding rule with the interface', () => {
533
+ expect(client).to.include(
534
+ 'PostDown = iptables -t nat -D POSTROUTING -o %i -d 10.0.0.0/24 -j MASQUERADE 2>/dev/null || true',
535
+ );
536
+ expect(client).to.include('PostDown = iptables -D FORWARD -o %i -d 10.0.0.0/24 -j ACCEPT');
537
+ });
538
+
539
+ it('recovers repeatable client settings from an installed config without reading a private key', () => {
540
+ expect(wireguardClientSettingsFactory(client)).to.deep.equal({
541
+ address: '10.0.0.2/32',
542
+ hubPublicKey: 'HUB=',
543
+ endpoint: 'vps.example.com:51820',
544
+ cidr: '10.0.0.0/24',
545
+ });
546
+ });
547
+ });
548
+
549
+ describe('quicForwardCommandsFactory', () => {
550
+ const { ensure, remove } = quicForwardCommandsFactory({ target: '10.0.0.2' });
551
+
552
+ // Flush-then-refill is what makes a re-run idempotent: no accumulating
553
+ // near-duplicate rules, and no guessing what a previous run installed.
554
+ it('flushes its own chains before adding rules', () => {
555
+ expect(ensure.filter((command) => command.includes('-F UNDERPOST_WG_PRE'))).to.have.lengthOf(1);
556
+ expect(ensure.indexOf('sudo iptables -t nat -F UNDERPOST_WG_PRE')).to.be.below(
557
+ ensure.findIndex((command) => command.includes('DNAT')),
558
+ );
559
+ });
560
+
561
+ it('adds the jump rules only when they are missing', () => {
562
+ expect(ensure).to.include(
563
+ 'sudo iptables -t nat -C PREROUTING -j UNDERPOST_WG_PRE 2>/dev/null || sudo iptables -t nat -A PREROUTING -j UNDERPOST_WG_PRE',
564
+ );
565
+ });
566
+
567
+ it('does not re-DNAT datagrams that already came through the tunnel', () => {
568
+ expect(ensure.find((command) => command.includes('DNAT'))).to.include('! -i wg0');
569
+ });
570
+
571
+ it('renders the chains with no rules when there is no spoke to forward to', () => {
572
+ const empty = quicForwardCommandsFactory({});
573
+ expect(empty.ensure.some((command) => command.includes('DNAT'))).to.equal(false);
574
+ });
575
+
576
+ it('removes the jump rules before deleting the chains', () => {
577
+ expect(remove.findIndex((command) => command.includes('-D PREROUTING'))).to.be.below(
578
+ remove.findIndex((command) => command.includes('-X UNDERPOST_WG_PRE')),
579
+ );
580
+ });
581
+ });
582
+
583
+ describe('firewallCommandsFactory', () => {
584
+ it('opens both transports and the tunnel port on the hub', () => {
585
+ const commands = firewallCommandsFactory({ role: 'server' }).join('\n');
586
+ for (const port of ['80/tcp', '443/tcp', '443/udp', '51820/udp']) expect(commands).to.include(port);
587
+ });
588
+
589
+ // A spoke dials out; it publishes nothing. What it does need is for the
590
+ // tunnel interface to sit in a zone that permits the forwarded traffic.
591
+ it('opens nothing on a spoke beyond trusting the tunnel interface', () => {
592
+ const commands = firewallCommandsFactory({ role: 'client' }).join('\n');
593
+ expect(commands).to.include('--zone=trusted --add-interface=wg0');
594
+ expect(commands).to.not.include('--add-port');
595
+ });
596
+
597
+ it('is a no-op where firewalld is not running', () => {
598
+ for (const command of firewallCommandsFactory({ role: 'server' }))
599
+ expect(command).to.include('systemctl is-active --quiet firewalld');
600
+ });
601
+
602
+ // A reset that leaves the ports open has not returned the host to zero, and
603
+ // the only way the two directions cannot drift is sharing one rule list.
604
+ it('withdraws exactly the rules it opens, for either role', () => {
605
+ const opened = firewallCommandsFactory({ role: 'server', listenPort: 51821 });
606
+ const withdrawn = firewallCommandsFactory({ role: 'server', listenPort: 51821, remove: true });
607
+ expect(withdrawn).to.have.lengthOf(opened.length);
608
+ expect(withdrawn.join('\n')).to.equal(opened.join('\n').replace(/--add-/g, '--remove-'));
609
+ expect(firewallCommandsFactory({ role: 'client', remove: true }).join('\n')).to.include(
610
+ '--zone=trusted --remove-interface=wg0',
611
+ );
612
+ });
613
+ });
614
+
615
+ describe('wireguardStatusFactory', () => {
616
+ // One row per spoke carries both halves of its state: what the registry
617
+ // binds to it, and what the live interface reports — so there is no second
618
+ // command to list peers.
619
+ it('folds link state onto the registry bindings', () => {
620
+ const rows = wireguardStatusFactory({
621
+ peers: PEERS,
622
+ latestHandshakes: 'AAA=\t1000\nBBB=\t0',
623
+ transfer: 'AAA=\t2048\t4096',
624
+ endpoints: 'AAA=\t203.0.113.7:51820\nBBB=\t(none)',
625
+ now: 1030,
626
+ });
627
+ expect(rows).to.deep.equal([
628
+ {
629
+ id: 'homelab-a',
630
+ address: '10.0.0.2',
631
+ allowedIPs: ['10.0.0.2/32', '192.168.10.0/24'],
632
+ hosts: ['www.dogmadual.com'],
633
+ instances: [],
634
+ default: false,
635
+ endpoint: '203.0.113.7:51820',
636
+ handshakeAgeSeconds: 30,
637
+ rxBytes: 2048,
638
+ txBytes: 4096,
639
+ online: true,
640
+ },
641
+ {
642
+ id: 'homelab-b',
643
+ address: '10.0.0.3',
644
+ allowedIPs: ['10.0.0.3/32'],
645
+ hosts: ['www.nexodev.org'],
646
+ instances: ['mmo-server'],
647
+ default: false,
648
+ endpoint: '',
649
+ handshakeAgeSeconds: null,
650
+ rxBytes: 0,
651
+ txBytes: 0,
652
+ online: false,
653
+ },
654
+ ]);
655
+ });
656
+
657
+ it('treats a peer past the rekey window as down rather than idle', () => {
658
+ const [row] = wireguardStatusFactory({
659
+ peers: [PEERS[0]],
660
+ latestHandshakes: 'AAA=\t1000',
661
+ now: 1000 + UNDERPOST_EDGE.handshakeStaleSeconds + 1,
662
+ });
663
+ expect(row.online).to.equal(false);
664
+ });
665
+ });
666
+
667
+ describe('registry normalization', () => {
668
+ it('fills the defaults a hand-written registry may omit', () => {
669
+ const peer = peerFactory({ id: 'homelab-a', address: '10.0.0.2' });
670
+ expect(peer.allowedIPs).to.deep.equal(['10.0.0.2/32']);
671
+ expect(peer.hosts).to.deep.equal([]);
672
+ expect(peer.instances).to.deep.equal([]);
673
+ expect(peer.default).to.equal(false);
674
+ });
675
+
676
+ // The three routing bindings are the whole registry surface; anything else
677
+ // a hand-edited file carries is not a binding the edge honours.
678
+ it('keeps only the three routing bindings on a peer', () => {
679
+ const peer = peerFactory({ id: 'a', address: '10.0.0.2', clients: ['legacy'], keepalive: 15 });
680
+ expect(Object.keys(peer)).to.deep.equal([
681
+ 'id',
682
+ 'address',
683
+ 'publicKey',
684
+ 'allowedIPs',
685
+ 'hosts',
686
+ 'instances',
687
+ 'default',
688
+ ]);
689
+ });
690
+
691
+ it('lower-cases bound hostnames so they match the map lookups', () => {
692
+ expect(peerFactory({ id: 'a', address: '10.0.0.2', hosts: ['WWW.Nexodev.ORG'] }).hosts).to.deep.equal([
693
+ 'www.nexodev.org',
694
+ ]);
695
+ });
696
+
697
+ it('drops registry entries with no id', () => {
698
+ const state = edgeStateFactory({ peers: [{ address: '10.0.0.2' }, { id: 'ok', address: '10.0.0.3' }] });
699
+ expect(state.peers.map((peer) => peer.id)).to.deep.equal(['ok']);
700
+ });
701
+
702
+ // A spoke rebuilds its interface from `endpoint` + `hubPublicKey`. Without
703
+ // the second one recorded, re-running the setup — and every --wireguard-
704
+ // reinstall, which ends in one — fails on a flag the operator already gave.
705
+ it('records the hub identity a spoke dials, so its setup is repeatable', () => {
706
+ const state = edgeStateFactory({ role: 'client', endpoint: 'vps.example.com:51820', hubPublicKey: 'HUB=' });
707
+ expect(state.endpoint).to.equal('vps.example.com:51820');
708
+ expect(state.hubPublicKey).to.equal('HUB=');
709
+ expect(edgeStateFactory().hubPublicKey).to.equal('');
710
+ });
711
+
712
+ it('falls back to the subsystem defaults for an empty registry', () => {
713
+ const state = edgeStateFactory();
714
+ expect(state.interfaceName).to.equal(UNDERPOST_EDGE.interfaceName);
715
+ expect(state.listenPort).to.equal(UNDERPOST_EDGE.listenPort);
716
+ expect(state.peers).to.deep.equal([]);
717
+ });
718
+ });
719
+ });
720
+
721
+ // The forward proxy is the one outbound path across the tunnel: a spoke's
722
+ // request leaves through the hub, so the origin sees the VPS address. Everything
723
+ // below runs on loopback with no network egress.
724
+ describe('edge hub forward proxy', () => {
725
+ const API_KEY = 'forward-proxy-test-key';
726
+
727
+ describe('forwardProxyAuthorizedFactory', () => {
728
+ it('accepts the configured key presented as a bearer token', () => {
729
+ expect(forwardProxyAuthorizedFactory({ header: `Bearer ${API_KEY}`, apiKey: API_KEY })).to.equal(true);
730
+ expect(forwardProxyAuthorizedFactory({ header: `bearer ${API_KEY}`, apiKey: API_KEY })).to.equal(true);
731
+ });
732
+
733
+ it('refuses a wrong key, a missing header and a bare key with no scheme', () => {
734
+ expect(forwardProxyAuthorizedFactory({ header: 'Bearer wrong-key', apiKey: API_KEY })).to.equal(false);
735
+ expect(forwardProxyAuthorizedFactory({ apiKey: API_KEY })).to.equal(false);
736
+ expect(forwardProxyAuthorizedFactory({ header: API_KEY, apiKey: API_KEY })).to.equal(false);
737
+ expect(forwardProxyAuthorizedFactory({ header: `Basic ${API_KEY}`, apiKey: API_KEY })).to.equal(false);
738
+ });
739
+
740
+ // A server that read an unset key as "no authentication required" would be
741
+ // an open relay on the tunnel, which is the one failure that must not be
742
+ // possible by omission.
743
+ it('authorizes nothing when no key is configured', () => {
744
+ expect(forwardProxyAuthorizedFactory({ header: 'Bearer anything' })).to.equal(false);
745
+ expect(forwardProxyAuthorizedFactory({ header: 'Bearer ', apiKey: '' })).to.equal(false);
746
+ expect(forwardProxyAuthorizedFactory()).to.equal(false);
747
+ });
748
+ });
749
+
750
+ describe('forwardProxyTargetFactory', () => {
751
+ it('reads the origin out of an absolute request URI, keeping the query', () => {
752
+ expect(forwardProxyTargetFactory('http://api.vultr.com/v2/plans?per_page=500')).to.deep.equal({
753
+ hostname: 'api.vultr.com',
754
+ port: 80,
755
+ path: '/v2/plans?per_page=500',
756
+ host: 'api.vultr.com',
757
+ });
758
+ expect(forwardProxyTargetFactory('http://origin.test:8080/x')).to.include({ port: 8080 });
759
+ });
760
+
761
+ // An https origin arrives as CONNECT. Serving one over the forward path
762
+ // would mean terminating TLS on the hub, which the whole subsystem avoids.
763
+ it('refuses a relative URI and any scheme other than http', () => {
764
+ expect(forwardProxyTargetFactory('/v2/plans')).to.equal(null);
765
+ expect(forwardProxyTargetFactory('https://api.vultr.com/v2/plans')).to.equal(null);
766
+ expect(forwardProxyTargetFactory('')).to.equal(null);
767
+ expect(forwardProxyTargetFactory('http://')).to.equal(null);
768
+ });
769
+ });
770
+
771
+ describe('forwardProxyTunnelTargetFactory', () => {
772
+ it('parses a CONNECT authority, defaulting to the https port', () => {
773
+ expect(forwardProxyTunnelTargetFactory('api.vultr.com:443')).to.deep.equal({
774
+ hostname: 'api.vultr.com',
775
+ port: 443,
776
+ });
777
+ expect(forwardProxyTunnelTargetFactory('api.vultr.com')).to.deep.equal({
778
+ hostname: 'api.vultr.com',
779
+ port: UNDERPOST_EDGE.httpsPort,
780
+ });
781
+ });
782
+
783
+ it('refuses an authority that is not a bare host and port', () => {
784
+ expect(forwardProxyTunnelTargetFactory('http://api.vultr.com/')).to.equal(null);
785
+ expect(forwardProxyTunnelTargetFactory('user@api.vultr.com:443')).to.equal(null);
786
+ expect(forwardProxyTunnelTargetFactory('api.vultr.com:0')).to.equal(null);
787
+ expect(forwardProxyTunnelTargetFactory('api.vultr.com:https')).to.equal(null);
788
+ expect(forwardProxyTunnelTargetFactory('')).to.equal(null);
789
+ });
790
+ });
791
+
792
+ describe('forwardProxyHeadersFactory', () => {
793
+ // Relaying the proxy key onward would hand the hub's credential to every
794
+ // origin a spoke talks to.
795
+ it('drops the hop-by-hop headers, the proxy credential included', () => {
796
+ const headers = forwardProxyHeadersFactory({
797
+ host: 'api.vultr.com',
798
+ authorization: 'Bearer vultr-key',
799
+ 'proxy-authorization': `Bearer ${API_KEY}`,
800
+ 'Proxy-Connection': 'keep-alive',
801
+ connection: 'close',
802
+ 'transfer-encoding': 'chunked',
803
+ upgrade: 'h2c',
804
+ });
805
+ expect(headers).to.deep.equal({ host: 'api.vultr.com', authorization: 'Bearer vultr-key' });
806
+ });
807
+ });
808
+
809
+ describe('forwardProxyConfigFactory', () => {
810
+ const saved = { ...process.env };
811
+ afterEach(() => {
812
+ for (const key of Object.values(FORWARD_PROXY.env))
813
+ if (saved[key] === undefined) delete process.env[key];
814
+ else process.env[key] = saved[key];
815
+ });
816
+
817
+ // Asserted against the constants rather than against an unset environment:
818
+ // the factory also reads `./.env` and the underpost root env, which exist on
819
+ // a configured host and would make an "unset" expectation depend on the box
820
+ // the suite runs on.
821
+ it('falls back to the hub tunnel address and the subsystem port', () => {
822
+ expect(tunnelAddressFactory(UNDERPOST_EDGE.cidr)).to.equal('10.0.0.1');
823
+ expect(tunnelAddressFactory('10.0.0.1')).to.equal('10.0.0.1');
824
+ expect(FORWARD_PROXY.port).to.equal(1080);
825
+ });
826
+
827
+ it('reads the environment, and lets an explicit endpoint win over it', () => {
828
+ process.env[FORWARD_PROXY.env.host] = '10.0.0.9';
829
+ process.env[FORWARD_PROXY.env.port] = '3128';
830
+ process.env[FORWARD_PROXY.env.apiKey] = API_KEY;
831
+ expect(forwardProxyConfigFactory()).to.deep.equal({ host: '10.0.0.9', port: 3128, apiKey: API_KEY });
832
+ expect(forwardProxyConfigFactory({ host: '10.0.0.1', port: 1080, apiKey: 'other' })).to.deep.equal({
833
+ host: '10.0.0.1',
834
+ port: 1080,
835
+ apiKey: 'other',
836
+ });
837
+ });
838
+ });
839
+
840
+ describe('firewallCommandsFactory', () => {
841
+ // The listener binds the tunnel address alone, and the rule narrows the port
842
+ // to the tunnel CIDR on top of that — the port is reachable from nowhere else.
843
+ it('admits the proxy port from the tunnel only, on the hub only', () => {
844
+ const hub = firewallCommandsFactory({ role: 'server' }).join('\n');
845
+ expect(hub).to.include(
846
+ `--add-rich-rule="rule family=ipv4 source address=${UNDERPOST_EDGE.tunnelCidr} port port=${FORWARD_PROXY.port} protocol=tcp accept"`,
847
+ );
848
+ expect(hub).to.not.include(`--add-port=${FORWARD_PROXY.port}`);
849
+ expect(firewallCommandsFactory({ role: 'client' }).join('\n')).to.not.include('rich-rule');
850
+ });
851
+
852
+ it('admits a custom recorded tunnel subnet instead of the default', () => {
853
+ expect(firewallCommandsFactory({ role: 'server', tunnelCidr: '10.23.0.0/24' }).join('\n')).to.include(
854
+ `source address=10.23.0.0/24 port port=${FORWARD_PROXY.port} protocol=tcp accept`,
855
+ );
856
+ expect(tunnelNetworkCidrFactory('10.23.7.9/20')).to.equal('10.23.0.0/20');
857
+ });
858
+ });
859
+
860
+ describe('forwardProxyUnitFactory', () => {
861
+ const unit = () =>
862
+ forwardProxyUnitFactory({
863
+ host: '10.0.0.1',
864
+ port: 1080,
865
+ apiKey: API_KEY,
866
+ interfaceName: 'wg0',
867
+ workingDirectory: '/home/dd/engine',
868
+ user: 'dd',
869
+ command: forwardProxyCommandFactory({
870
+ host: '10.0.0.1',
871
+ port: 1080,
872
+ execPath: '/usr/bin/node',
873
+ scriptPath: '/home/dd/engine/bin/index.js',
874
+ }),
875
+ });
876
+
877
+ // The unit runs the same command the operator did, so there is one code path
878
+ // to the listener rather than a second one only systemd takes.
879
+ it('runs this CLI with the resolved host and port, marked as supervised', () => {
880
+ expect(unit()).to.include(
881
+ 'ExecStart=/usr/bin/node /home/dd/engine/bin/index.js wireguard --forward-proxy-server ' +
882
+ '--forward-proxy-server-host 10.0.0.1 --forward-proxy-server-port 1080',
883
+ );
884
+ expect(unit()).to.include(`Environment=${FORWARD_PROXY.supervisedEnv}=1`);
885
+ expect(unit()).to.include(`Environment=${FORWARD_PROXY.env.apiKey}=${API_KEY}`);
886
+ expect(unit()).to.include('WorkingDirectory=/home/dd/engine');
887
+ expect(unit()).to.include('User=dd');
888
+ });
889
+
890
+ it('carries the host and port the flags resolved into the description and the command', () => {
891
+ const custom = forwardProxyUnitFactory({
892
+ host: '10.0.0.5',
893
+ port: 3128,
894
+ apiKey: API_KEY,
895
+ execPath: '/usr/bin/node',
896
+ });
897
+ expect(custom).to.include('Description=Underpost edge forward proxy on 10.0.0.5:3128');
898
+ expect(custom).to.include('--forward-proxy-server-host 10.0.0.5 --forward-proxy-server-port 3128');
899
+ });
900
+
901
+ // The address the proxy binds exists only while the interface is up, so the
902
+ // two units are one lifecycle: the tunnel's stop and restart propagate, and
903
+ // starting the tunnel brings the proxy back.
904
+ it('ties the service to the tunnel unit and always restarts', () => {
905
+ const rendered = forwardProxyUnitFactory({ host: '10.0.0.1', port: 1080, apiKey: API_KEY, interfaceName: 'wg1' });
906
+ expect(rendered).to.include('Requires=wg-quick@wg1.service');
907
+ expect(rendered).to.include('PartOf=wg-quick@wg1.service');
908
+ expect(rendered).to.include('WantedBy=multi-user.target wg-quick@wg1.service');
909
+ expect(rendered).to.include('Restart=always');
910
+ expect(rendered).to.include(`RestartSec=${FORWARD_PROXY.restartSeconds}`);
911
+ // No start-limit window, so a bind that fails while the tunnel comes up
912
+ // retries instead of latching failed.
913
+ expect(rendered).to.include('StartLimitIntervalSec=0');
914
+ });
915
+ });
916
+
917
+ describe('forwardProxyNodeCandidatesFactory', () => {
918
+ // The failure this ordering exists for: systemd cannot enter /root, so a unit
919
+ // pointed at an nvm install there restarts on 203/EXEC forever while every
920
+ // ordinary permission check on the binary passes.
921
+ it('prefers a system Node and keeps one under a home directory for last', () => {
922
+ expect(
923
+ forwardProxyNodeCandidatesFactory({ execPath: '/root/.nvm/versions/node/v24.15.0/bin/node' }),
924
+ ).to.deep.equal([...FORWARD_PROXY.nodePaths, '/root/.nvm/versions/node/v24.15.0/bin/node']);
925
+ expect(forwardProxyNodeCandidatesFactory({ execPath: '/home/dd/.nvm/x/bin/node' })).to.deep.equal([
926
+ ...FORWARD_PROXY.nodePaths,
927
+ '/home/dd/.nvm/x/bin/node',
928
+ ]);
929
+ });
930
+
931
+ it('puts the running interpreter first when it is not in a home directory, without duplicating it', () => {
932
+ expect(forwardProxyNodeCandidatesFactory({ execPath: '/opt/node/bin/node' })).to.deep.equal([
933
+ '/opt/node/bin/node',
934
+ ...FORWARD_PROXY.nodePaths,
935
+ ]);
936
+ expect(forwardProxyNodeCandidatesFactory({ execPath: '/usr/bin/node' })).to.deep.equal(FORWARD_PROXY.nodePaths);
937
+ });
938
+
939
+ it('recognises a home directory path without mistaking a lookalike for one', () => {
940
+ expect(homeDirectoryPathFactory('/root/.nvm/x/node')).to.equal(true);
941
+ expect(homeDirectoryPathFactory('/root')).to.equal(true);
942
+ expect(homeDirectoryPathFactory('/home/dd/engine')).to.equal(true);
943
+ expect(homeDirectoryPathFactory('/usr/bin/node')).to.equal(false);
944
+ expect(homeDirectoryPathFactory('/rootfs/bin/node')).to.equal(false);
945
+ expect(homeDirectoryPathFactory('')).to.equal(false);
946
+ });
947
+ });
948
+
949
+ describe('forward proxy start probes', () => {
950
+ // `test -x` passes on a binary systemd still refuses, so the only reliable
951
+ // test is running it through systemd itself, as the unit's own user.
952
+ it('asks systemd whether it can execute the interpreter', () => {
953
+ expect(forwardProxyNodeProbeCommandFactory('/usr/bin/node', 'root')).to.equal(
954
+ 'sudo systemd-run --quiet --collect --wait --uid=root --property=Type=oneshot /usr/bin/node --version',
955
+ );
956
+ });
957
+
958
+ it('asks the same of the whole entry point, from the working directory the service will use', () => {
959
+ expect(
960
+ forwardProxyStartProbeCommandFactory({
961
+ nodePath: '/usr/bin/node',
962
+ scriptPath: '/opt/underpost/engine/bin',
963
+ user: 'dd',
964
+ workingDirectory: '/opt/underpost/engine',
965
+ }),
966
+ ).to.equal(
967
+ 'sudo systemd-run --quiet --collect --wait --uid=dd --property=Type=oneshot ' +
968
+ '--property=WorkingDirectory=/opt/underpost/engine /usr/bin/node /opt/underpost/engine/bin --version',
969
+ );
970
+ });
971
+ });
972
+
973
+ describe('forwardProxyServiceCommandsFactory', () => {
974
+ // Re-running the command must not restart a service that is carrying
975
+ // connections, and must not fail because one is already up.
976
+ it('starts an unchanged service without reloading or restarting it', () => {
977
+ const { ensure } = forwardProxyServiceCommandsFactory({ changed: false });
978
+ expect(ensure).to.deep.equal([
979
+ `sudo systemctl enable ${FORWARD_PROXY.serviceName} || true`,
980
+ `sudo systemctl start ${FORWARD_PROXY.serviceName} || true`,
981
+ ]);
982
+ expect(ensure.join('\n')).to.not.include('restart');
983
+ expect(ensure.join('\n')).to.not.include('daemon-reload');
984
+ });
985
+
986
+ it('reloads and restarts only when the unit file changed', () => {
987
+ expect(forwardProxyServiceCommandsFactory({ changed: true }).ensure).to.deep.equal([
988
+ 'sudo systemctl daemon-reload',
989
+ `sudo systemctl enable ${FORWARD_PROXY.serviceName} || true`,
990
+ `sudo systemctl restart ${FORWARD_PROXY.serviceName} || true`,
991
+ ]);
992
+ });
993
+
994
+ it('withdraws the unit it installed, reloading after the removal', () => {
995
+ expect(forwardProxyServiceCommandsFactory().remove).to.deep.equal([
996
+ `sudo systemctl disable --now ${FORWARD_PROXY.serviceName} 2>/dev/null || true`,
997
+ `sudo rm -f ${FORWARD_PROXY.unitPath}`,
998
+ 'sudo systemctl daemon-reload',
999
+ ]);
1000
+ });
1001
+ });
1002
+
1003
+ // Loopback only: an origin, the proxy handlers, and the client that drives
1004
+ // them. No name resolution and no egress, so the path is deterministic.
1005
+ describe('request and CONNECT relay', () => {
1006
+ const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
1007
+ const port = (server) => server.address().port;
1008
+ const servers = [];
1009
+ let origin;
1010
+ let proxy;
1011
+ let proxyConfig;
1012
+
1013
+ before(async () => {
1014
+ origin = await listen(
1015
+ http.createServer((req, res) => {
1016
+ res.writeHead(200, { 'content-type': 'application/json' });
1017
+ res.end(
1018
+ JSON.stringify({ url: req.url, host: req.headers.host, relayed: req.headers['proxy-authorization'] }),
1019
+ );
1020
+ }),
1021
+ );
1022
+ proxy = await listen(http.createServer());
1023
+ proxy.on('request', forwardProxyRequestHandlerFactory({ apiKey: API_KEY }));
1024
+ proxy.on('connect', forwardProxyConnectHandlerFactory({ apiKey: API_KEY }));
1025
+ servers.push(origin, proxy);
1026
+ proxyConfig = { host: '127.0.0.1', port: port(proxy), apiKey: API_KEY };
1027
+ });
1028
+
1029
+ after(() => {
1030
+ for (const server of servers) server.close();
1031
+ });
1032
+
1033
+ it('relays an http request and returns the origin answer', async () => {
1034
+ const response = await fetchViaForwardProxy(`http://127.0.0.1:${port(origin)}/v2/instances?per_page=500`, {
1035
+ proxy: proxyConfig,
1036
+ headers: { authorization: 'Bearer vultr-key' },
1037
+ });
1038
+ expect(response.status).to.equal(200);
1039
+ const body = JSON.parse(response.body);
1040
+ expect(body.url).to.equal('/v2/instances?per_page=500');
1041
+ expect(body.host).to.equal(`127.0.0.1:${port(origin)}`);
1042
+ expect(body.relayed).to.equal(undefined);
1043
+ });
1044
+
1045
+ it('answers 407 to a request with the wrong key, without reaching the origin', async () => {
1046
+ const response = await fetchViaForwardProxy(`http://127.0.0.1:${port(origin)}/v2/instances`, {
1047
+ proxy: { ...proxyConfig, apiKey: 'wrong-key' },
1048
+ });
1049
+ expect(response.status).to.equal(407);
1050
+ expect(response.headers['proxy-authenticate']).to.include('Bearer');
1051
+ });
1052
+
1053
+ it('answers 502 when the origin cannot be reached', async () => {
1054
+ const response = await fetchViaForwardProxy('http://127.0.0.1:1/dead', { proxy: proxyConfig });
1055
+ expect(response.status).to.equal(502);
1056
+ });
1057
+
1058
+ it('rejects an unsupported target scheme before opening any socket', async () => {
1059
+ try {
1060
+ await fetchViaForwardProxy('ftp://origin.test/file', { proxy: proxyConfig });
1061
+ expect.fail('only http: and https: targets are proxyable');
1062
+ } catch (error) {
1063
+ expect(error.message).to.include('http: and https:');
1064
+ }
1065
+ });
1066
+
1067
+ // CONNECT carries opaque bytes — TLS in production. A byte-echo origin proves
1068
+ // the splice without a certificate, and covers the `head` bytes a client
1069
+ // sends before the tunnel is established.
1070
+ it('splices a CONNECT tunnel, delivering the bytes sent with the request', async () => {
1071
+ const echo = await listen(net.createServer((socket) => socket.pipe(socket)));
1072
+ servers.push(echo);
1073
+ const tunnelled = await new Promise((resolve, reject) => {
1074
+ const request = http.request({
1075
+ host: '127.0.0.1',
1076
+ port: port(proxy),
1077
+ method: 'CONNECT',
1078
+ path: `127.0.0.1:${port(echo)}`,
1079
+ headers: { 'proxy-authorization': `Bearer ${API_KEY}` },
1080
+ });
1081
+ request.on('connect', (res, socket) => {
1082
+ if (res.statusCode !== 200) return void reject(new Error(`CONNECT refused (${res.statusCode})`));
1083
+ let received = '';
1084
+ socket.on('data', (chunk) => {
1085
+ received += chunk;
1086
+ if (received.length >= 6) {
1087
+ socket.destroy();
1088
+ resolve(received);
1089
+ }
1090
+ });
1091
+ socket.write('tunnel');
1092
+ });
1093
+ request.on('error', reject);
1094
+ request.end();
1095
+ });
1096
+ expect(tunnelled).to.equal('tunnel');
1097
+ });
1098
+
1099
+ it('refuses a CONNECT with the wrong key', async () => {
1100
+ const status = await new Promise((resolve, reject) => {
1101
+ const request = http.request({
1102
+ host: '127.0.0.1',
1103
+ port: port(proxy),
1104
+ method: 'CONNECT',
1105
+ path: '127.0.0.1:443',
1106
+ headers: { 'proxy-authorization': 'Bearer wrong-key' },
1107
+ });
1108
+ request.on('connect', (res, socket) => {
1109
+ socket.destroy();
1110
+ resolve(res.statusCode);
1111
+ });
1112
+ // A refused CONNECT never becomes a tunnel, so it arrives as a response.
1113
+ request.on('response', (res) => {
1114
+ res.resume();
1115
+ resolve(res.statusCode);
1116
+ });
1117
+ request.on('error', reject);
1118
+ request.end();
1119
+ });
1120
+ expect(status).to.equal(407);
1121
+ });
1122
+ });
1123
+
1124
+ // SSH carries no SNI and no Host, so there is nothing to route it on. The port
1125
+ // goes whole to the fallback spoke, exactly as UDP/443 does — it exists so CI,
1126
+ // which has no fixed address, can reach a cluster node behind CGNAT.
1127
+ describe('ssh forward', () => {
1128
+ const SPOKE = [peerFactory({ id: 'homelab-a', address: '10.0.0.2', publicKey: 'AAA=', default: true })];
1129
+
1130
+ it('is closed by default, so no edge exposes SSH it was not told to', () => {
1131
+ expect(edgeStateFactory({}).sshForwardPort).to.equal(0);
1132
+ expect(edgeStateFactory({ sshForwardPort: 'nonsense' }).sshForwardPort).to.equal(0);
1133
+ expect(edgeStateFactory({ sshForwardPort: 2222 }).sshForwardPort).to.equal(2222);
1134
+ expect(haproxyConfFactory({ peers: SPOKE, defaultPeerId: 'homelab-a' })).to.not.include('fe_ssh');
1135
+ });
1136
+
1137
+ it('binds the public port and sends it whole to the fallback spoke', () => {
1138
+ const conf = haproxyConfFactory({ peers: SPOKE, defaultPeerId: 'homelab-a', sshForwardPort: 2222 });
1139
+ expect(conf).to.include('frontend fe_ssh');
1140
+ expect(conf).to.include('bind :2222');
1141
+ expect(conf).to.include('default_backend be_ssh_default');
1142
+ expect(conf).to.include(`server homelab-a 10.0.0.2:${UNDERPOST_EDGE.sshPort} check`);
1143
+ });
1144
+
1145
+ // The server speaks first in SSH, so waiting on client bytes would stall
1146
+ // every connection until the inspect timeout expired.
1147
+ it('does not wait for client bytes the way the SNI frontend does', () => {
1148
+ const conf = haproxyConfFactory({ peers: SPOKE, defaultPeerId: 'homelab-a', sshForwardPort: 2222 });
1149
+ const directives = conf
1150
+ .split('frontend fe_ssh')[1]
1151
+ .split('\n\n')[0]
1152
+ .split('\n')
1153
+ .map((line) => line.trim())
1154
+ .filter((line) => line && !line.startsWith('#'));
1155
+ expect(directives).to.include('mode tcp');
1156
+ expect(directives.some((line) => line.startsWith('tcp-request'))).to.equal(false);
1157
+ });
1158
+
1159
+ // Binding a port with nothing behind it would accept connections only to
1160
+ // drop them, which reads as a broken host rather than a closed door.
1161
+ it('emits nothing when there is no spoke to forward to', () => {
1162
+ expect(haproxyConfFactory({ peers: [], sshForwardPort: 2222 })).to.not.include('fe_ssh');
1163
+ });
1164
+
1165
+ it('opens the port publicly, and withdraws it on teardown', () => {
1166
+ const opened = firewallCommandsFactory({ role: 'server', sshForwardPort: 2222 }).join('\n');
1167
+ expect(opened).to.include('--add-port=2222/tcp');
1168
+ const closed = firewallCommandsFactory({ role: 'server', sshForwardPort: 2222, remove: true }).join('\n');
1169
+ expect(closed).to.include('--remove-port=2222/tcp');
1170
+ });
1171
+
1172
+ it('opens no SSH port when none is configured, and never on a spoke', () => {
1173
+ expect(firewallCommandsFactory({ role: 'server' }).join('\n')).to.not.include('2222');
1174
+ expect(firewallCommandsFactory({ role: 'client', sshForwardPort: 2222 }).join('\n')).to.not.include('2222');
1175
+ });
1176
+ });
1177
+ });