underpost 3.2.90 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +110 -1
- package/CLI-HELP.md +139 -9
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +1 -1
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +5 -5
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +2 -0
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +1 -1
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +73 -68
- package/src/cli/db.js +9 -2
- package/src/cli/deploy.js +21 -5
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +0 -1
- package/src/cli/index.js +121 -9
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +1 -1
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +11 -9
- package/src/cli/run.js +36 -9
- package/src/cli/ssh.js +198 -77
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +20 -14
- package/src/db/mongo/MongooseDB.js +4 -0
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +18 -108
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +20 -10
- package/src/server/underpost-ingress.js +18 -2
- package/test/selinux.test.js +71 -0
- package/test/underpost-gateway.test.js +41 -0
- package/test/underpost-ingress.test.js +52 -0
- package/test/wireguard-edge.test.js +1177 -0
|
@@ -0,0 +1,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
|
+
};
|