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.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +110 -1
- package/CLI-HELP.md +139 -9
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +1 -1
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +5 -5
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +2 -0
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +1 -1
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +73 -68
- package/src/cli/db.js +9 -2
- package/src/cli/deploy.js +21 -5
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +0 -1
- package/src/cli/index.js +121 -9
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +1 -1
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +11 -9
- package/src/cli/run.js +36 -9
- package/src/cli/ssh.js +198 -77
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +20 -14
- package/src/db/mongo/MongooseDB.js +4 -0
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +18 -108
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +20 -10
- package/src/server/underpost-ingress.js +18 -2
- package/test/selinux.test.js +71 -0
- package/test/underpost-gateway.test.js +41 -0
- package/test/underpost-ingress.test.js +52 -0
- 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
|
+
});
|