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.
- 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 +291 -1
- package/CLI-HELP.md +174 -23
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +19 -17
- 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/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +19 -19
- 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 +12 -4
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +786 -96
- package/src/cli/db.js +11 -4
- package/src/cli/deploy.js +1698 -177
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +15 -7
- package/src/cli/index.js +245 -44
- package/src/cli/ipfs.js +82 -11
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +2 -2
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +12 -10
- package/src/cli/run.js +2195 -427
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +206 -105
- 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 +102 -13
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +51 -32
- 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 +1216 -168
- package/src/server/cri.js +70 -0
- 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 +1083 -0
- package/src/server/underpost-ingress.js +380 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/selinux.test.js +71 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +510 -0
- package/test/underpost-ingress.test.js +305 -0
- package/test/wireguard-edge.test.js +1177 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated HTTP/HTTPS forward-proxy primitives.
|
|
3
|
+
* @module src/server/forward-proxy.js
|
|
4
|
+
* @namespace ForwardProxy
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import https from 'node:https';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { environmentValueFactory } from './environment.js';
|
|
13
|
+
import { loggerFactory, loggerMiddleware } from './logger.js';
|
|
14
|
+
import {
|
|
15
|
+
homeDirectoryPathFactory,
|
|
16
|
+
systemdRunCommandFactory,
|
|
17
|
+
systemdServiceCommandsFactory,
|
|
18
|
+
systemdUnitFactory,
|
|
19
|
+
} from './systemd.js';
|
|
20
|
+
|
|
21
|
+
const logger = loggerFactory(import.meta);
|
|
22
|
+
const proxyLogger = loggerFactory(import.meta, 'debug');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Forward-proxy defaults, service metadata, and environment variable names.
|
|
26
|
+
* @constant {object}
|
|
27
|
+
* @memberof ForwardProxy
|
|
28
|
+
*/
|
|
29
|
+
const FORWARD_PROXY = Object.freeze({
|
|
30
|
+
port: 1080,
|
|
31
|
+
timeoutMs: 30000,
|
|
32
|
+
env: Object.freeze({
|
|
33
|
+
apiKey: 'FORWARD_PROXY_API_KEY',
|
|
34
|
+
host: 'FORWARD_PROXY_HOST',
|
|
35
|
+
port: 'FORWARD_PROXY_PORT',
|
|
36
|
+
}),
|
|
37
|
+
serviceName: 'underpost-forward-proxy',
|
|
38
|
+
unitPath: '/etc/systemd/system/underpost-forward-proxy.service',
|
|
39
|
+
supervisedEnv: 'UNDERPOST_FORWARD_PROXY_SUPERVISED',
|
|
40
|
+
restartSeconds: 5,
|
|
41
|
+
nodePaths: Object.freeze(['/usr/bin/node', '/usr/local/bin/node', '/bin/node']),
|
|
42
|
+
defaultHost: '10.0.0.1',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hop-by-hop headers that must not be sent to an upstream server.
|
|
47
|
+
* @constant {Set<string>}
|
|
48
|
+
* @private
|
|
49
|
+
* @memberof ForwardProxy
|
|
50
|
+
*/
|
|
51
|
+
const FORWARD_PROXY_HOP_HEADERS = new Set([
|
|
52
|
+
'connection',
|
|
53
|
+
'keep-alive',
|
|
54
|
+
'proxy-authenticate',
|
|
55
|
+
'proxy-authorization',
|
|
56
|
+
'proxy-connection',
|
|
57
|
+
'te',
|
|
58
|
+
'trailer',
|
|
59
|
+
'transfer-encoding',
|
|
60
|
+
'upgrade',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compares two non-empty values without returning early on a mismatched byte.
|
|
65
|
+
* @method secretEqual
|
|
66
|
+
* @param {*} a - First value.
|
|
67
|
+
* @param {*} b - Second value.
|
|
68
|
+
* @returns {boolean} Whether both values are equal and non-empty.
|
|
69
|
+
* @private
|
|
70
|
+
* @memberof ForwardProxy
|
|
71
|
+
*/
|
|
72
|
+
const secretEqual = (a, b) => {
|
|
73
|
+
const left = `${a ?? ''}`;
|
|
74
|
+
const right = `${b ?? ''}`;
|
|
75
|
+
if (left.length !== right.length || left.length === 0) return false;
|
|
76
|
+
let diff = 0;
|
|
77
|
+
for (let index = 0; index < left.length; index++) diff |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
78
|
+
return diff === 0;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validates a bearer credential against a configured proxy API key.
|
|
83
|
+
* @method forwardProxyAuthorizedFactory
|
|
84
|
+
* @param {{header?: string, apiKey?: string}} [options={}] - Credential inputs.
|
|
85
|
+
* @returns {boolean} Whether the request is authorized.
|
|
86
|
+
* @memberof ForwardProxy
|
|
87
|
+
*/
|
|
88
|
+
const forwardProxyAuthorizedFactory = ({ header = '', apiKey = '' } = {}) => {
|
|
89
|
+
const expected = `${apiKey || ''}`.trim();
|
|
90
|
+
if (!expected) return false;
|
|
91
|
+
const match = /^Bearer\s+(.+)$/i.exec(`${header || ''}`.trim());
|
|
92
|
+
return match ? secretEqual(match[1].trim(), expected) : false;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parses an absolute HTTP request URI into upstream request options.
|
|
97
|
+
* @method forwardProxyTargetFactory
|
|
98
|
+
* @param {string} requestUrl - Absolute HTTP request URI.
|
|
99
|
+
* @returns {{hostname: string, port: number, host: string, path: string}|null} Parsed target or `null` when invalid.
|
|
100
|
+
* @memberof ForwardProxy
|
|
101
|
+
*/
|
|
102
|
+
const forwardProxyTargetFactory = (requestUrl) => {
|
|
103
|
+
try {
|
|
104
|
+
const target = new URL(requestUrl);
|
|
105
|
+
if (target.protocol !== 'http:' || !target.hostname) return null;
|
|
106
|
+
return {
|
|
107
|
+
hostname: target.hostname,
|
|
108
|
+
port: Number(target.port) || 80,
|
|
109
|
+
host: target.host,
|
|
110
|
+
path: `${target.pathname}${target.search}`,
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parses a CONNECT authority into a hostname and TCP port.
|
|
119
|
+
* @method forwardProxyTunnelTargetFactory
|
|
120
|
+
* @param {string} authority - CONNECT authority, optionally including a port.
|
|
121
|
+
* @returns {{hostname: string, port: number}|null} Parsed target or `null` when invalid.
|
|
122
|
+
* @memberof ForwardProxy
|
|
123
|
+
*/
|
|
124
|
+
const forwardProxyTunnelTargetFactory = (authority) => {
|
|
125
|
+
const match = /^(\[[^\]]+\]|[^:@/\s]+)(?::(\d+))?$/.exec(`${authority || ''}`.trim());
|
|
126
|
+
if (!match) return null;
|
|
127
|
+
const port = Number(match[2] || 443);
|
|
128
|
+
if (port < 1 || port > 65535) return null;
|
|
129
|
+
return { hostname: match[1].replace(/^\[|\]$/g, ''), port };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Removes hop-by-hop headers before forwarding a request or response.
|
|
134
|
+
* @method forwardProxyHeadersFactory
|
|
135
|
+
* @param {object} [headers={}] - Header map to filter.
|
|
136
|
+
* @returns {object} Header map safe to forward.
|
|
137
|
+
* @memberof ForwardProxy
|
|
138
|
+
*/
|
|
139
|
+
const forwardProxyHeadersFactory = (headers = {}) =>
|
|
140
|
+
Object.fromEntries(
|
|
141
|
+
Object.entries(headers || {}).filter(([name]) => !FORWARD_PROXY_HOP_HEADERS.has(`${name}`.toLowerCase())),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolves proxy connection settings from explicit values and environment variables.
|
|
146
|
+
* @method forwardProxyConfigFactory
|
|
147
|
+
* @param {{host?: string, port?: string|number, apiKey?: string}} [options={}] - Proxy overrides.
|
|
148
|
+
* @returns {{host: string, port: number, apiKey: string}} Resolved proxy configuration.
|
|
149
|
+
* @memberof ForwardProxy
|
|
150
|
+
*/
|
|
151
|
+
const forwardProxyConfigFactory = ({ host, port, apiKey } = {}) => ({
|
|
152
|
+
host: `${host || environmentValueFactory(FORWARD_PROXY.env.host)}`.trim() || FORWARD_PROXY.defaultHost,
|
|
153
|
+
port: Number(port || environmentValueFactory(FORWARD_PROXY.env.port)) || FORWARD_PROXY.port,
|
|
154
|
+
apiKey: `${apiKey || environmentValueFactory(FORWARD_PROXY.env.apiKey)}`.trim(),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Builds the CLI command that starts the forward-proxy server.
|
|
159
|
+
* @method forwardProxyCommandFactory
|
|
160
|
+
* @param {{host?: string, port?: string|number, execPath?: string, scriptPath?: string}} options - Command inputs.
|
|
161
|
+
* @returns {string} Shell command.
|
|
162
|
+
* @memberof ForwardProxy
|
|
163
|
+
*/
|
|
164
|
+
const forwardProxyCommandFactory = ({ host, port, execPath = process.execPath, scriptPath = process.argv[1] }) =>
|
|
165
|
+
[
|
|
166
|
+
execPath,
|
|
167
|
+
scriptPath,
|
|
168
|
+
'wireguard',
|
|
169
|
+
'--forward-proxy-server',
|
|
170
|
+
`--forward-proxy-server-host ${host}`,
|
|
171
|
+
`--forward-proxy-server-port ${port}`,
|
|
172
|
+
].join(' ');
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Orders Node executable candidates for a systemd service probe.
|
|
176
|
+
* @method forwardProxyNodeCandidatesFactory
|
|
177
|
+
* @param {{execPath?: string, systemPaths?: string[]}} [options={}] - Candidate sources.
|
|
178
|
+
* @returns {string[]} Ordered, unique executable paths.
|
|
179
|
+
* @memberof ForwardProxy
|
|
180
|
+
*/
|
|
181
|
+
const forwardProxyNodeCandidatesFactory = ({
|
|
182
|
+
execPath = process.execPath,
|
|
183
|
+
systemPaths = FORWARD_PROXY.nodePaths,
|
|
184
|
+
} = {}) => {
|
|
185
|
+
const own = `${execPath || ''}`.trim();
|
|
186
|
+
const inHome = homeDirectoryPathFactory(own);
|
|
187
|
+
return [...new Set([...(own && !inHome ? [own] : []), ...systemPaths, ...(own && inHome ? [own] : [])])];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Builds a transient systemd command that probes a Node executable.
|
|
192
|
+
* @method forwardProxyNodeProbeCommandFactory
|
|
193
|
+
* @param {string} nodePath - Candidate Node executable path.
|
|
194
|
+
* @param {string} [user] - User that will own the service.
|
|
195
|
+
* @returns {string} systemd-run command.
|
|
196
|
+
* @memberof ForwardProxy
|
|
197
|
+
*/
|
|
198
|
+
const forwardProxyNodeProbeCommandFactory = (nodePath, user = os.userInfo().username) =>
|
|
199
|
+
systemdRunCommandFactory({ command: `${nodePath} --version`, user, properties: { Type: 'oneshot' } });
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Builds a transient systemd command that probes the proxy entry script.
|
|
203
|
+
* @method forwardProxyStartProbeCommandFactory
|
|
204
|
+
* @param {{nodePath: string, scriptPath?: string, user?: string, workingDirectory?: string}} options - Probe inputs.
|
|
205
|
+
* @returns {string} systemd-run command.
|
|
206
|
+
* @memberof ForwardProxy
|
|
207
|
+
*/
|
|
208
|
+
const forwardProxyStartProbeCommandFactory = ({
|
|
209
|
+
nodePath,
|
|
210
|
+
scriptPath = process.argv[1],
|
|
211
|
+
user = os.userInfo().username,
|
|
212
|
+
workingDirectory = process.cwd(),
|
|
213
|
+
}) =>
|
|
214
|
+
systemdRunCommandFactory({
|
|
215
|
+
command: `${nodePath} ${scriptPath} --version`,
|
|
216
|
+
user,
|
|
217
|
+
properties: { Type: 'oneshot', WorkingDirectory: workingDirectory },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Renders the systemd unit used to supervise the forward proxy.
|
|
222
|
+
* @method forwardProxyUnitFactory
|
|
223
|
+
* @param {{host?: string, port?: string|number, apiKey?: string, interfaceName?: string, workingDirectory?: string, user?: string, command?: string}} [options={}] - Unit inputs.
|
|
224
|
+
* @returns {string} Rendered systemd unit file.
|
|
225
|
+
* @memberof ForwardProxy
|
|
226
|
+
*/
|
|
227
|
+
const forwardProxyUnitFactory = ({
|
|
228
|
+
host,
|
|
229
|
+
port,
|
|
230
|
+
apiKey,
|
|
231
|
+
interfaceName = 'wg0',
|
|
232
|
+
workingDirectory = process.cwd(),
|
|
233
|
+
user = os.userInfo().username,
|
|
234
|
+
command,
|
|
235
|
+
} = {}) => {
|
|
236
|
+
const tunnelUnit = `wg-quick@${interfaceName}.service`;
|
|
237
|
+
return systemdUnitFactory({
|
|
238
|
+
header:
|
|
239
|
+
'# Generated by `underpost wireguard --forward-proxy-server`. Do not edit by\n' +
|
|
240
|
+
'# hand: the next run rewrites the file and restarts the service.',
|
|
241
|
+
sections: {
|
|
242
|
+
Unit: {
|
|
243
|
+
Description: `Underpost edge forward proxy on ${host}:${port}`,
|
|
244
|
+
Documentation: 'https://www.nexodev.org/docs',
|
|
245
|
+
After: `network-online.target ${tunnelUnit}`,
|
|
246
|
+
Wants: 'network-online.target',
|
|
247
|
+
Requires: tunnelUnit,
|
|
248
|
+
PartOf: tunnelUnit,
|
|
249
|
+
StartLimitIntervalSec: 0,
|
|
250
|
+
},
|
|
251
|
+
Service: {
|
|
252
|
+
Type: 'simple',
|
|
253
|
+
User: user,
|
|
254
|
+
WorkingDirectory: workingDirectory,
|
|
255
|
+
Environment: [`${FORWARD_PROXY.supervisedEnv}=1`, `${FORWARD_PROXY.env.apiKey}=${apiKey}`],
|
|
256
|
+
ExecStart: command || forwardProxyCommandFactory({ host, port }),
|
|
257
|
+
Restart: 'always',
|
|
258
|
+
RestartSec: FORWARD_PROXY.restartSeconds,
|
|
259
|
+
},
|
|
260
|
+
Install: { WantedBy: `multi-user.target ${tunnelUnit}` },
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Builds lifecycle commands for the forward-proxy systemd service.
|
|
267
|
+
* @method forwardProxyServiceCommandsFactory
|
|
268
|
+
* @param {{changed?: boolean, name?: string, unitPath?: string}} [options={}] - Service state inputs.
|
|
269
|
+
* @returns {{ensure: string[], remove: string[]}} Commands grouped by lifecycle operation.
|
|
270
|
+
* @memberof ForwardProxy
|
|
271
|
+
*/
|
|
272
|
+
const forwardProxyServiceCommandsFactory = ({
|
|
273
|
+
changed = false,
|
|
274
|
+
name = FORWARD_PROXY.serviceName,
|
|
275
|
+
unitPath = FORWARD_PROXY.unitPath,
|
|
276
|
+
} = {}) => systemdServiceCommandsFactory({ changed, name, unitPath });
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Sends a plain-text proxy refusal response.
|
|
280
|
+
* @method forwardProxyRefuse
|
|
281
|
+
* @param {import('node:http').ServerResponse} res - Response to close.
|
|
282
|
+
* @param {number} status - HTTP status code.
|
|
283
|
+
* @param {string} message - Response body message.
|
|
284
|
+
* @returns {void}
|
|
285
|
+
* @private
|
|
286
|
+
* @memberof ForwardProxy
|
|
287
|
+
*/
|
|
288
|
+
const forwardProxyRefuse = (res, status, message) => {
|
|
289
|
+
res.writeHead(status, {
|
|
290
|
+
'content-type': 'text/plain',
|
|
291
|
+
...(status === 407 ? { 'proxy-authenticate': 'Bearer realm="underpost-forward-proxy"' } : {}),
|
|
292
|
+
});
|
|
293
|
+
res.end(`${message}\n`);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Creates an HTTP request handler that relays authenticated proxy traffic.
|
|
298
|
+
* @method forwardProxyRequestHandlerFactory
|
|
299
|
+
* @param {{apiKey: string, timeoutMs?: number}} options - Authentication and timeout settings.
|
|
300
|
+
* @returns {Function} Node HTTP request handler.
|
|
301
|
+
* @memberof ForwardProxy
|
|
302
|
+
*/
|
|
303
|
+
const forwardProxyRequestHandlerFactory = ({ apiKey, timeoutMs = FORWARD_PROXY.timeoutMs }) =>
|
|
304
|
+
function forwardProxyRequestHandler(req, res) {
|
|
305
|
+
if (!forwardProxyAuthorizedFactory({ header: req.headers['proxy-authorization'], apiKey }))
|
|
306
|
+
return void forwardProxyRefuse(res, 407, 'proxy authentication required');
|
|
307
|
+
const target = forwardProxyTargetFactory(req.url);
|
|
308
|
+
if (!target)
|
|
309
|
+
return void forwardProxyRefuse(res, 400, 'an absolute http:// request-URI is required; use CONNECT for https');
|
|
310
|
+
|
|
311
|
+
const upstream = http.request(
|
|
312
|
+
{
|
|
313
|
+
host: target.hostname,
|
|
314
|
+
port: target.port,
|
|
315
|
+
method: req.method,
|
|
316
|
+
path: target.path,
|
|
317
|
+
headers: { ...forwardProxyHeadersFactory(req.headers), host: target.host },
|
|
318
|
+
timeout: timeoutMs,
|
|
319
|
+
},
|
|
320
|
+
(upstreamRes) => {
|
|
321
|
+
res.writeHead(upstreamRes.statusCode, forwardProxyHeadersFactory(upstreamRes.headers));
|
|
322
|
+
upstreamRes.pipe(res);
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
upstream.on('timeout', () => upstream.destroy(new Error('upstream timed out')));
|
|
326
|
+
upstream.on('error', (error) => {
|
|
327
|
+
logger.warn('Forward proxy upstream failed', { target: `${target.host}${target.path}`, message: error.message });
|
|
328
|
+
if (res.headersSent) res.destroy();
|
|
329
|
+
else forwardProxyRefuse(res, 502, 'upstream request failed');
|
|
330
|
+
});
|
|
331
|
+
res.on('close', () => upstream.destroy());
|
|
332
|
+
req.pipe(upstream);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Creates a CONNECT handler that relays authenticated TLS tunnels.
|
|
337
|
+
* @method forwardProxyConnectHandlerFactory
|
|
338
|
+
* @param {{apiKey: string, timeoutMs?: number}} options - Authentication and timeout settings.
|
|
339
|
+
* @returns {Function} Node HTTP CONNECT handler.
|
|
340
|
+
* @memberof ForwardProxy
|
|
341
|
+
*/
|
|
342
|
+
const forwardProxyConnectHandlerFactory = ({ apiKey, timeoutMs = FORWARD_PROXY.timeoutMs }) =>
|
|
343
|
+
function forwardProxyConnectHandler(req, clientSocket, head) {
|
|
344
|
+
const startedAt = Date.now();
|
|
345
|
+
const log = (status, bytes = '-') =>
|
|
346
|
+
proxyLogger.http(
|
|
347
|
+
`${clientSocket.remoteAddress || '-'} CONNECT ${req.url} ${status} ${bytes} - ${Date.now() - startedAt} ms`,
|
|
348
|
+
);
|
|
349
|
+
const reject = (status, reason) => {
|
|
350
|
+
log(status);
|
|
351
|
+
clientSocket.end(`HTTP/1.1 ${status} ${reason}\r\n\r\n`);
|
|
352
|
+
};
|
|
353
|
+
if (!forwardProxyAuthorizedFactory({ header: req.headers['proxy-authorization'], apiKey }))
|
|
354
|
+
return void reject(407, 'Proxy Authentication Required');
|
|
355
|
+
const target = forwardProxyTunnelTargetFactory(req.url);
|
|
356
|
+
if (!target) return void reject(400, 'Bad Request');
|
|
357
|
+
|
|
358
|
+
let established = false;
|
|
359
|
+
const upstream = net.connect(target.port, target.hostname, () => {
|
|
360
|
+
established = true;
|
|
361
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
362
|
+
if (head && head.length > 0) upstream.write(head);
|
|
363
|
+
upstream.pipe(clientSocket);
|
|
364
|
+
clientSocket.pipe(upstream);
|
|
365
|
+
});
|
|
366
|
+
upstream.setTimeout(timeoutMs, () => upstream.destroy());
|
|
367
|
+
upstream.on('error', (error) => {
|
|
368
|
+
logger.warn('Forward proxy tunnel failed', {
|
|
369
|
+
target: `${target.hostname}:${target.port}`,
|
|
370
|
+
message: error.message,
|
|
371
|
+
});
|
|
372
|
+
if (established || clientSocket.writableEnded || clientSocket.destroyed) clientSocket.destroy();
|
|
373
|
+
else reject(502, 'Bad Gateway');
|
|
374
|
+
});
|
|
375
|
+
upstream.on('close', () => {
|
|
376
|
+
if (established) log(200, upstream.bytesRead + upstream.bytesWritten);
|
|
377
|
+
});
|
|
378
|
+
clientSocket.on('error', () => upstream.destroy());
|
|
379
|
+
clientSocket.on('close', () => upstream.destroy());
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Resolves a completed client request to its buffered proxy response.
|
|
384
|
+
* @method forwardProxyResponseFactory
|
|
385
|
+
* @param {{request: import('node:http').ClientRequest, body?: string|null, timeoutMs: number}} options - Request inputs.
|
|
386
|
+
* @returns {Promise<{status: number|undefined, headers: object, body: string}>} Buffered response.
|
|
387
|
+
* @private
|
|
388
|
+
* @memberof ForwardProxy
|
|
389
|
+
*/
|
|
390
|
+
const forwardProxyResponseFactory = ({ request, body = null, timeoutMs }) =>
|
|
391
|
+
new Promise((resolve, reject) => {
|
|
392
|
+
request.once('response', (res) => {
|
|
393
|
+
const chunks = [];
|
|
394
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
395
|
+
res.once('error', reject);
|
|
396
|
+
res.once('end', () =>
|
|
397
|
+
resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }),
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
request.once('error', reject);
|
|
401
|
+
request.setTimeout(timeoutMs, () => request.destroy(new Error(`request timed out after ${timeoutMs}ms`)));
|
|
402
|
+
if (body !== null) request.write(body);
|
|
403
|
+
request.end();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Opens an authenticated CONNECT tunnel through the configured proxy.
|
|
408
|
+
* @method forwardProxyTunnelFactory
|
|
409
|
+
* @param {{proxy: {host: string, port: number, apiKey: string}, authority: string, timeoutMs: number}} options - Tunnel inputs.
|
|
410
|
+
* @returns {Promise<import('node:net').Socket>} Connected tunnel socket.
|
|
411
|
+
* @private
|
|
412
|
+
* @memberof ForwardProxy
|
|
413
|
+
*/
|
|
414
|
+
const forwardProxyTunnelFactory = ({ proxy, authority, timeoutMs }) =>
|
|
415
|
+
new Promise((resolve, reject) => {
|
|
416
|
+
const request = http.request({
|
|
417
|
+
host: proxy.host,
|
|
418
|
+
port: proxy.port,
|
|
419
|
+
method: 'CONNECT',
|
|
420
|
+
path: authority,
|
|
421
|
+
headers: { host: authority, 'proxy-authorization': `Bearer ${proxy.apiKey}` },
|
|
422
|
+
agent: false,
|
|
423
|
+
});
|
|
424
|
+
const refused = (status) => reject(new Error(`[forward-proxy] proxy refused CONNECT ${authority} (${status})`));
|
|
425
|
+
request.once('connect', (res, socket) => {
|
|
426
|
+
if (res.statusCode !== 200) {
|
|
427
|
+
socket.destroy();
|
|
428
|
+
refused(res.statusCode);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
socket.setTimeout(0);
|
|
432
|
+
resolve(socket);
|
|
433
|
+
});
|
|
434
|
+
request.once('response', (res) => {
|
|
435
|
+
res.resume();
|
|
436
|
+
refused(res.statusCode);
|
|
437
|
+
});
|
|
438
|
+
request.once('error', reject);
|
|
439
|
+
request.setTimeout(timeoutMs, () => request.destroy(new Error('[forward-proxy] CONNECT timed out')));
|
|
440
|
+
request.end();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Starts an authenticated HTTP forward-proxy server.
|
|
445
|
+
* @method forwardProxyServerFactory
|
|
446
|
+
* @param {{config: {host: string, port: number, apiKey: string, timeoutMs?: number}, requestMiddleware?: Function, onError?: Function, onListen?: Function}} [options={}] - Server configuration and hooks.
|
|
447
|
+
* @returns {import('node:http').Server} Listening proxy server.
|
|
448
|
+
* @memberof ForwardProxy
|
|
449
|
+
*/
|
|
450
|
+
const forwardProxyServerFactory = ({ config, requestMiddleware, onError, onListen } = {}) => {
|
|
451
|
+
const server = http.createServer();
|
|
452
|
+
const relay = forwardProxyRequestHandlerFactory(config);
|
|
453
|
+
const middleware = requestMiddleware || loggerMiddleware(import.meta, 'debug', () => false);
|
|
454
|
+
server.on('request', (req, res) => middleware(req, res, () => relay(req, res)));
|
|
455
|
+
server.on('connect', forwardProxyConnectHandlerFactory(config));
|
|
456
|
+
server.on('clientError', (_error, socket) => {
|
|
457
|
+
if (!socket.destroyed) socket.destroy();
|
|
458
|
+
});
|
|
459
|
+
if (onError) server.on('error', onError);
|
|
460
|
+
server.listen(config.port, config.host, onListen);
|
|
461
|
+
return server;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Fetches an HTTP or HTTPS resource through the authenticated forward proxy.
|
|
466
|
+
* @async
|
|
467
|
+
* @method fetchViaForwardProxy
|
|
468
|
+
* @param {string|URL} url - Target resource URL.
|
|
469
|
+
* @param {{proxy?: {host?: string, port?: string|number, apiKey?: string}, method?: string, timeout?: number, body?: *, headers?: object}} [options={}] - Request options.
|
|
470
|
+
* @returns {Promise<{status: number|undefined, headers: object, body: string}>} Buffered upstream response.
|
|
471
|
+
* @memberof ForwardProxy
|
|
472
|
+
*/
|
|
473
|
+
const fetchViaForwardProxy = async (url, options = {}) => {
|
|
474
|
+
const target = new URL(url);
|
|
475
|
+
const proxy = forwardProxyConfigFactory(options.proxy);
|
|
476
|
+
if (!proxy.apiKey)
|
|
477
|
+
throw new Error(`[forward-proxy] ${FORWARD_PROXY.env.apiKey} is not set; the proxy cannot be used without it`);
|
|
478
|
+
const method = `${options.method || 'GET'}`.toUpperCase();
|
|
479
|
+
const timeoutMs = Number(options.timeout) > 0 ? Number(options.timeout) : FORWARD_PROXY.timeoutMs;
|
|
480
|
+
const body =
|
|
481
|
+
options.body === undefined || options.body === null
|
|
482
|
+
? null
|
|
483
|
+
: typeof options.body === 'string'
|
|
484
|
+
? options.body
|
|
485
|
+
: JSON.stringify(options.body);
|
|
486
|
+
const headers = {
|
|
487
|
+
host: target.host,
|
|
488
|
+
...(body === null ? {} : { 'content-length': `${Buffer.byteLength(body)}` }),
|
|
489
|
+
...(options.headers || {}),
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
if (target.protocol === 'http:')
|
|
493
|
+
return await forwardProxyResponseFactory({
|
|
494
|
+
request: http.request({
|
|
495
|
+
host: proxy.host,
|
|
496
|
+
port: proxy.port,
|
|
497
|
+
method,
|
|
498
|
+
path: target.href,
|
|
499
|
+
headers: { ...headers, 'proxy-authorization': `Bearer ${proxy.apiKey}` },
|
|
500
|
+
agent: false,
|
|
501
|
+
}),
|
|
502
|
+
body,
|
|
503
|
+
timeoutMs,
|
|
504
|
+
});
|
|
505
|
+
if (target.protocol !== 'https:')
|
|
506
|
+
throw new Error(`[forward-proxy] fetch supports http: and https: targets only, not ${target.protocol}`);
|
|
507
|
+
|
|
508
|
+
const port = Number(target.port) || 443;
|
|
509
|
+
const socket = await forwardProxyTunnelFactory({ proxy, authority: `${target.hostname}:${port}`, timeoutMs });
|
|
510
|
+
const agent = new https.Agent({ keepAlive: false, maxSockets: 1 });
|
|
511
|
+
const createConnection = agent.createConnection.bind(agent);
|
|
512
|
+
agent.createConnection = (connectOptions, callback) =>
|
|
513
|
+
createConnection({ ...connectOptions, socket, servername: target.hostname }, callback);
|
|
514
|
+
try {
|
|
515
|
+
return await forwardProxyResponseFactory({
|
|
516
|
+
request: https.request({
|
|
517
|
+
host: target.hostname,
|
|
518
|
+
port,
|
|
519
|
+
method,
|
|
520
|
+
path: `${target.pathname}${target.search}`,
|
|
521
|
+
headers,
|
|
522
|
+
agent,
|
|
523
|
+
}),
|
|
524
|
+
body,
|
|
525
|
+
timeoutMs,
|
|
526
|
+
});
|
|
527
|
+
} finally {
|
|
528
|
+
socket.destroy();
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
export {
|
|
533
|
+
FORWARD_PROXY,
|
|
534
|
+
fetchViaForwardProxy,
|
|
535
|
+
forwardProxyAuthorizedFactory,
|
|
536
|
+
forwardProxyCommandFactory,
|
|
537
|
+
forwardProxyConfigFactory,
|
|
538
|
+
forwardProxyConnectHandlerFactory,
|
|
539
|
+
forwardProxyHeadersFactory,
|
|
540
|
+
forwardProxyNodeCandidatesFactory,
|
|
541
|
+
forwardProxyNodeProbeCommandFactory,
|
|
542
|
+
forwardProxyRequestHandlerFactory,
|
|
543
|
+
forwardProxyServerFactory,
|
|
544
|
+
forwardProxyServiceCommandsFactory,
|
|
545
|
+
forwardProxyStartProbeCommandFactory,
|
|
546
|
+
forwardProxyTargetFactory,
|
|
547
|
+
forwardProxyTunnelTargetFactory,
|
|
548
|
+
forwardProxyUnitFactory,
|
|
549
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Express middleware and controller/router helpers for engine APIs.
|
|
3
3
|
*
|
|
4
4
|
* @module src/server/middlewares.js
|
|
5
|
+
* @namespace Middlewares
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { loggerFactory } from './logger.js';
|
|
@@ -12,8 +13,11 @@ const logger = loggerFactory(import.meta);
|
|
|
12
13
|
/**
|
|
13
14
|
* The public-read CORS policy: reflect the request origin (or allow any)
|
|
14
15
|
* and mark the resource embeddable cross-origin.
|
|
16
|
+
* @method setCrossOriginHeaders
|
|
15
17
|
* @param {import('express').Request} req
|
|
16
18
|
* @param {import('express').Response} res
|
|
19
|
+
* @returns {void}
|
|
20
|
+
* @memberof Middlewares
|
|
17
21
|
*/
|
|
18
22
|
const setCrossOriginHeaders = (req, res) => {
|
|
19
23
|
if (req && req.headers && req.headers.origin) res.set('Access-Control-Allow-Origin', req.headers.origin);
|
|
@@ -21,7 +25,15 @@ const setCrossOriginHeaders = (req, res) => {
|
|
|
21
25
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
22
26
|
};
|
|
23
27
|
|
|
24
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Express middleware form of {@link setCrossOriginHeaders}.
|
|
30
|
+
* @method crossOriginMiddleware
|
|
31
|
+
* @param {import('express').Request} req
|
|
32
|
+
* @param {import('express').Response} res
|
|
33
|
+
* @param {import('express').NextFunction} next
|
|
34
|
+
* @returns {void}
|
|
35
|
+
* @memberof Middlewares
|
|
36
|
+
*/
|
|
25
37
|
const crossOriginMiddleware = (req, res, next) => {
|
|
26
38
|
setCrossOriginHeaders(req, res);
|
|
27
39
|
next();
|
|
@@ -31,7 +43,10 @@ const crossOriginMiddleware = (req, res, next) => {
|
|
|
31
43
|
* Shallow request copy with `page`/`limit` parsed to integers.
|
|
32
44
|
* `path` and `params` are copied explicitly because spreading an Express
|
|
33
45
|
* request drops prototype getters.
|
|
46
|
+
* @method withParsedPagination
|
|
34
47
|
* @param {import('express').Request} req
|
|
48
|
+
* @returns {import('express').Request} Request-like object with parsed pagination.
|
|
49
|
+
* @memberof Middlewares
|
|
35
50
|
*/
|
|
36
51
|
const withParsedPagination = (req) => {
|
|
37
52
|
const { page, limit } = req.query;
|
|
@@ -43,15 +58,35 @@ const withParsedPagination = (req) => {
|
|
|
43
58
|
};
|
|
44
59
|
};
|
|
45
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Sends the standard success response envelope.
|
|
63
|
+
* @method sendSuccess
|
|
64
|
+
* @param {import('express').Response} res
|
|
65
|
+
* @param {*} data - Response payload.
|
|
66
|
+
* @returns {import('express').Response} JSON response.
|
|
67
|
+
* @memberof Middlewares
|
|
68
|
+
*/
|
|
46
69
|
const sendSuccess = (res, data) => res.status(200).json({ status: 'success', data });
|
|
47
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Sends the standard error response envelope.
|
|
73
|
+
* @method sendError
|
|
74
|
+
* @param {import('express').Response} res
|
|
75
|
+
* @param {Error} error - Error to expose.
|
|
76
|
+
* @param {number} [status=400] - HTTP status code.
|
|
77
|
+
* @returns {import('express').Response} JSON response.
|
|
78
|
+
* @memberof Middlewares
|
|
79
|
+
*/
|
|
48
80
|
const sendError = (res, error, status = 400) => res.status(status).json({ status: 'error', message: error.message });
|
|
49
81
|
|
|
50
82
|
/**
|
|
51
83
|
* Binary response with cross-origin and content headers.
|
|
84
|
+
* @method sendBlob
|
|
52
85
|
* @param {import('express').Request} req
|
|
53
86
|
* @param {import('express').Response} res
|
|
54
87
|
* @param {{ buffer: Buffer, mimetype: string, filename: string, disposition?: 'inline'|'attachment' }} blob
|
|
88
|
+
* @returns {import('express').Response} Completed binary response.
|
|
89
|
+
* @memberof Middlewares
|
|
55
90
|
*/
|
|
56
91
|
const sendBlob = (req, res, { buffer, mimetype, filename, disposition = 'inline' }) => {
|
|
57
92
|
setCrossOriginHeaders(req, res);
|
|
@@ -63,8 +98,11 @@ const sendBlob = (req, res, { buffer, mimetype, filename, disposition = 'inline'
|
|
|
63
98
|
|
|
64
99
|
/**
|
|
65
100
|
* Wraps a controller body with error logging and the error response envelope.
|
|
101
|
+
* @method controllerHandler
|
|
66
102
|
* @param {(req, res, options) => Promise<any>} fn
|
|
67
103
|
* @param {{ errorStatus?: number }} [config]
|
|
104
|
+
* @returns {Function} Async Express-compatible controller handler.
|
|
105
|
+
* @memberof Middlewares
|
|
68
106
|
*/
|
|
69
107
|
const controllerHandler =
|
|
70
108
|
(fn, { errorStatus = 400 } = {}) =>
|
|
@@ -80,8 +118,11 @@ const controllerHandler =
|
|
|
80
118
|
/**
|
|
81
119
|
* Builds a controller method that delegates to a service method and wraps the
|
|
82
120
|
* result in the success envelope.
|
|
121
|
+
* @method serviceHandler
|
|
83
122
|
* @param {(req, res, options) => Promise<any>} serviceFn
|
|
84
123
|
* @param {{ errorStatus?: number, crossOrigin?: boolean, pagination?: boolean }} [config]
|
|
124
|
+
* @returns {Function} Async Express-compatible controller handler.
|
|
125
|
+
* @memberof Middlewares
|
|
85
126
|
*/
|
|
86
127
|
const serviceHandler = (serviceFn, { errorStatus = 400, crossOrigin = false, pagination = false } = {}) =>
|
|
87
128
|
controllerHandler(
|
|
@@ -96,14 +137,26 @@ const serviceHandler = (serviceFn, { errorStatus = 400, crossOrigin = false, pag
|
|
|
96
137
|
/**
|
|
97
138
|
* Builds a standard CRUD controller class (static post/get/put/delete) from a
|
|
98
139
|
* service exposing the same methods. `get` parses pagination.
|
|
140
|
+
* @method buildCrudController
|
|
99
141
|
* @param {{ post, get, put, delete }} service
|
|
100
142
|
* @param {Object<string, Function>} [extend] - Extra or overriding static handlers.
|
|
143
|
+
* @returns {Function} CRUD controller class.
|
|
144
|
+
* @memberof Middlewares
|
|
101
145
|
*/
|
|
102
146
|
const buildCrudController = (service, extend = {}) => {
|
|
147
|
+
/**
|
|
148
|
+
* Generated controller namespace containing static CRUD handlers.
|
|
149
|
+
* @class CrudController
|
|
150
|
+
* @memberof Middlewares
|
|
151
|
+
*/
|
|
103
152
|
class CrudController {
|
|
153
|
+
/** @static @memberof Middlewares */
|
|
104
154
|
static post = serviceHandler(service.post);
|
|
155
|
+
/** @static @memberof Middlewares */
|
|
105
156
|
static get = serviceHandler(service.get, { pagination: true });
|
|
157
|
+
/** @static @memberof Middlewares */
|
|
106
158
|
static put = serviceHandler(service.put);
|
|
159
|
+
/** @static @memberof Middlewares */
|
|
107
160
|
static delete = serviceHandler(service.delete);
|
|
108
161
|
}
|
|
109
162
|
Object.assign(CrudController, extend);
|
|
@@ -115,6 +168,7 @@ const buildCrudController = (service, extend = {}) => {
|
|
|
115
168
|
* public reads, moderator-guarded writes, admin-guarded collection delete.
|
|
116
169
|
* Custom routes must be registered before calling this (generic `/:id` routes
|
|
117
170
|
* capture everything).
|
|
171
|
+
* @method registerCrudRoutes
|
|
118
172
|
* @param {import('express').Router} router
|
|
119
173
|
* @param {{ post, get, put, delete }} Controller
|
|
120
174
|
* @param {import('../../api/types.js').RouterOptions} options
|
|
@@ -122,6 +176,7 @@ const buildCrudController = (service, extend = {}) => {
|
|
|
122
176
|
* Pass empty arrays for unguarded endpoints (e.g. player-written progress)
|
|
123
177
|
* or explicit guard chains (e.g. admin-only reads).
|
|
124
178
|
* @returns {import('express').Router}
|
|
179
|
+
* @memberof Middlewares
|
|
125
180
|
*/
|
|
126
181
|
const registerCrudRoutes = (router, Controller, options, { readGuards = [], writeGuards, deleteAllGuards } = {}) => {
|
|
127
182
|
const write = writeGuards ?? [options.authMiddleware, moderatorGuard];
|