underpost 3.2.90 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +110 -1
  6. package/CLI-HELP.md +139 -9
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +1 -1
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/package.json +5 -5
  19. package/scripts/audit-selinux.sh +64 -0
  20. package/scripts/coverall-test.sh +24 -0
  21. package/scripts/gpu-diag.sh +0 -0
  22. package/scripts/ip-info.sh +0 -0
  23. package/scripts/k3s-node-setup.sh +18 -15
  24. package/scripts/kubeadm-node-setup.sh +12 -23
  25. package/scripts/link-local-underpost-cli.sh +0 -0
  26. package/scripts/lxd-vm-setup.sh +0 -0
  27. package/scripts/maas-nat-firewalld.sh +0 -0
  28. package/scripts/nat-iptables.sh +2 -0
  29. package/scripts/rhel-grpc-setup.sh +0 -0
  30. package/scripts/rocky-kickstart.sh +25 -9
  31. package/scripts/test-monitor.sh +1 -1
  32. package/src/cli/baremetal.js +1 -2
  33. package/src/cli/cloud-init.js +1 -1
  34. package/src/cli/cluster.js +73 -68
  35. package/src/cli/db.js +9 -2
  36. package/src/cli/deploy.js +21 -5
  37. package/src/cli/docker-compose.js +1 -1
  38. package/src/cli/env.js +1 -1
  39. package/src/cli/image.js +0 -1
  40. package/src/cli/index.js +121 -9
  41. package/src/cli/lxd.js +1 -1
  42. package/src/cli/monitor.js +1 -1
  43. package/src/cli/release.js +57 -22
  44. package/src/cli/repository.js +11 -9
  45. package/src/cli/run.js +36 -9
  46. package/src/cli/ssh.js +198 -77
  47. package/src/cli/system.js +26 -13
  48. package/src/cli/test.js +1 -1
  49. package/src/cli/vultr.js +583 -0
  50. package/src/cli/wireguard.js +2125 -0
  51. package/src/client-builder/client-build.js +20 -14
  52. package/src/db/mongo/MongooseDB.js +4 -0
  53. package/src/index.js +25 -1
  54. package/src/projects/underpost/catalog-underpost.js +4 -1
  55. package/src/server/backup.js +1 -1
  56. package/src/server/conf.js +18 -108
  57. package/src/server/cron.js +249 -51
  58. package/src/server/dns.js +100 -6
  59. package/src/server/environment.js +98 -0
  60. package/src/server/forward-proxy.js +549 -0
  61. package/src/server/middlewares.js +56 -1
  62. package/src/server/process.js +0 -1
  63. package/src/server/selinux.js +185 -0
  64. package/src/server/systemd.js +205 -0
  65. package/src/server/underpost-compression.js +186 -0
  66. package/src/server/underpost-gateway.js +20 -10
  67. package/src/server/underpost-ingress.js +18 -2
  68. package/test/selinux.test.js +71 -0
  69. package/test/underpost-gateway.test.js +41 -0
  70. package/test/underpost-ingress.test.js +52 -0
  71. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Environment and global installation path resolution.
3
+ * @module src/server/environment.js
4
+ * @namespace ServerEnvironment
5
+ */
6
+ 'use strict';
7
+
8
+ import dotenv from 'dotenv';
9
+ import fs from 'fs-extra';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { loggerFactory } from './logger.js';
12
+
13
+ const logger = loggerFactory(import.meta);
14
+
15
+ /**
16
+ * Node directory backing every hostPath PersistentVolume the deploy flow
17
+ * materializes (`<root>/<pv id>`), including the shared gateway's static tree.
18
+ * Cluster bring-up gives it the shared container label, because the pods that
19
+ * read these documents are unprivileged.
20
+ * @constant {string}
21
+ * @memberof ServerEnvironment
22
+ */
23
+ const HOST_VOLUME_ROOT = '/home/dd/engine/volume';
24
+ const envFileCache = new Map();
25
+ let rootEnvPath;
26
+
27
+ /**
28
+ * Resolves the global npm module directory.
29
+ * @returns {string}
30
+ * @memberof ServerEnvironment
31
+ */
32
+ const getNpmRootPath = () => {
33
+ try {
34
+ return execFileSync('npm', ['root', '-g'], {
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ }).trim();
38
+ } catch {
39
+ return '';
40
+ }
41
+ };
42
+
43
+ /**
44
+ * Resolves the global Underpost installation directory.
45
+ * @returns {string}
46
+ * @memberof ServerEnvironment
47
+ */
48
+ const getUnderpostRootPath = () => {
49
+ const npmRoot = getNpmRootPath();
50
+ return npmRoot ? `${npmRoot}/underpost` : '';
51
+ };
52
+
53
+ const readEnvFile = (path) => {
54
+ if (envFileCache.has(path)) return envFileCache.get(path);
55
+ let values = {};
56
+ try {
57
+ if (path && fs.existsSync(path)) values = dotenv.parse(fs.readFileSync(path, 'utf8'));
58
+ } catch (error) {
59
+ logger.warn('Ignoring unreadable env file', { target: path, message: error.message });
60
+ }
61
+ envFileCache.set(path, values);
62
+ return values;
63
+ };
64
+
65
+ const environmentValueFactory = (key) => {
66
+ const processValue = `${process.env[key] ?? ''}`.trim();
67
+ if (processValue) return processValue;
68
+
69
+ if (rootEnvPath === undefined) {
70
+ const underpostRoot = getUnderpostRootPath();
71
+ rootEnvPath = underpostRoot ? `${underpostRoot}/.env` : '';
72
+ }
73
+
74
+ for (const path of ['./.env', rootEnvPath]) {
75
+ const value = `${readEnvFile(path)[key] ?? ''}`.trim();
76
+ if (value) return value;
77
+ }
78
+ return '';
79
+ };
80
+
81
+ /**
82
+ * Writes environment values as a dotenv file.
83
+ * @method writeEnv
84
+ * @param {string} envPath - Destination file path.
85
+ * @param {Object<string, *>} envObj - Environment values keyed by variable name.
86
+ * @returns {void}
87
+ * @memberof ServerEnvironment
88
+ */
89
+ const writeEnv = (envPath, envObj) =>
90
+ fs.writeFileSync(
91
+ envPath,
92
+ Object.keys(envObj)
93
+ .map((key) => `${key}=${envObj[key]}`)
94
+ .join('\n'),
95
+ 'utf8',
96
+ );
97
+
98
+ export { environmentValueFactory, getNpmRootPath, getUnderpostRootPath, HOST_VOLUME_ROOT, writeEnv };
@@ -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
+ };