underpost 3.2.80 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,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
- /** Express middleware form of {@link setCrossOriginHeaders}. */
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];