mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-21 06:33:28 +00:00
7dfb7474f5
This also drops support for Node 14 Change-type: major
219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2020 Balena Ltd.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
/**
|
|
* This module creates two HTTP servers listening on the local machine:
|
|
* * The "proxy server" which is a standard HTTP proxy server that handles the
|
|
* CONNECT HTTP verb, using the `http-proxy` dependency.
|
|
* * The "interceptor server" which actually handles the proxied requests.
|
|
*
|
|
* The proxy server proxies the client request to the interceptor server. (This
|
|
* two-server approach (proxy + interceptor) is mainly a result of accommodating
|
|
* the typical setup documented by the `http-proxy` dependency.)
|
|
*
|
|
* The use case for these servers is to test the standalone executable (CLI's
|
|
* standalone zip package) in a child process. Most of the CLI's automated tests
|
|
* currently test HTTP requests using `nock`, but `nock` can only mock/test the
|
|
* same process (Node's built-in `http` library). However, the CLI has support
|
|
* for proxy servers as a product feature, so the idea was to proxy the child
|
|
* process requests to the parent process, where the proxy / interceptor servers
|
|
* run. The interceptor server then forwards the request (mostly unchanged) with
|
|
* the expectation that `nock` will intercept the requests for testing (in the
|
|
* parent process) as usual.
|
|
*
|
|
* 1. A `mocha` test case calls `runCommand('push test-rpi')`, with `nock` setup
|
|
* to intercept HTTP requests (in the same process that runs `mocha`).
|
|
* 2. The proxy and interceptor servers are started in the parent process (only
|
|
* once: singleton) at free TCP port numbers randomly allocated by the OS.
|
|
* 3. A CLI child process gets spawned to run the command (`balena push test-rpi`)
|
|
* with environment variables including BALENARC_PROXY (set to
|
|
* 'http://127.0.0.1:${proxyPort}'). (Additional env vars instruct the
|
|
* child process to use HTTP instead of HTTPS for the balena API and builder.)
|
|
* 4. The child process sends the HTTP requests to the proxy server.
|
|
* 5. The proxy server forwards the request to the interceptor server.
|
|
* 6. The interceptor server simply re-issues the HTTP request (unchange), with
|
|
* the expectation that `nock` will intercept it.
|
|
* 7. `nock` (running on the parent process, same process that runs `mocha`)
|
|
* intercepts the HTTP request, test it and replies with a mocked response.
|
|
* 8. `nocks` response is returned to the interceptor server, which returns it
|
|
* to the proxy server, which returns it to the child process, which continues
|
|
* CLI command execution.
|
|
*/
|
|
|
|
import * as http from 'http';
|
|
|
|
const proxyServers: http.Server[] = [];
|
|
|
|
after(function () {
|
|
if (proxyServers.length) {
|
|
if (process.env.DEBUG) {
|
|
console.error(
|
|
`[debug] Closing proxy servers (count=${proxyServers.length})`,
|
|
);
|
|
}
|
|
proxyServers.forEach((s) => s.close());
|
|
proxyServers.splice(0);
|
|
}
|
|
});
|
|
|
|
export let proxyServerPort = 0;
|
|
export let interceptorServerPort = 0;
|
|
|
|
export async function createProxyServerOnce(): Promise<[number, number]> {
|
|
if (proxyServerPort === 0) {
|
|
[proxyServerPort, interceptorServerPort] = await createProxyServer();
|
|
}
|
|
return [proxyServerPort, interceptorServerPort];
|
|
}
|
|
|
|
async function createProxyServer(): Promise<[number, number]> {
|
|
const httpProxy = require('http-proxy') as typeof import('http-proxy');
|
|
|
|
const interceptorPort = await createInterceptorServer();
|
|
|
|
const proxy = httpProxy.createProxyServer();
|
|
proxy.on('error', function (err, _req, res, _target) {
|
|
(res as http.ServerResponse).writeHead(500, {
|
|
'Content-Type': 'text/plain',
|
|
});
|
|
const msg = `Proxy server error: ${err}`;
|
|
console.error(msg);
|
|
res.end(msg);
|
|
});
|
|
|
|
const server = http.createServer(function (
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
) {
|
|
if (process.env.DEBUG) {
|
|
console.error(`[debug] Proxy forwarding for ${req.url}`);
|
|
}
|
|
proxy.web(req, res, { target: `http://127.0.0.1:${interceptorPort}` });
|
|
});
|
|
proxyServers.push(server);
|
|
|
|
server.on('error', (err: Error) => {
|
|
console.error(`Proxy server error (http.createServer):\n${err}`);
|
|
});
|
|
|
|
let proxyPort = 0; // TCP port number, 0 means automatic allocation
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
// TODO: remove 'as any' below. According to @types/node v16.18.25, the
|
|
// callback type is `() => void`, but our code assumes `(err: Error) => void`
|
|
const listener = (server.listen as any)(0, '127.0.0.1', (err: Error) => {
|
|
if (err) {
|
|
console.error(`Error starting proxy server:\n${err}`);
|
|
reject(err);
|
|
} else {
|
|
const info: any = listener.address();
|
|
proxyPort = info.port;
|
|
console.error(
|
|
`[Info] Proxy server listening on ${info.address}:${proxyPort}`,
|
|
);
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
return [proxyPort, interceptorPort];
|
|
}
|
|
|
|
async function createInterceptorServer(): Promise<number> {
|
|
const url = await import('url');
|
|
|
|
const server = http.createServer();
|
|
proxyServers.push(server);
|
|
|
|
server
|
|
.on('error', (err: Error) => {
|
|
console.error(`Interceptor server error: ${err}`);
|
|
})
|
|
.on(
|
|
'request',
|
|
(cliReq: http.IncomingMessage, cliRes: http.ServerResponse) => {
|
|
const proxiedFor = `http://${cliReq.headers.host}${cliReq.url}`;
|
|
if (process.env.DEBUG) {
|
|
console.error(`[debug] Interceptor forwarding for ${proxiedFor}`);
|
|
}
|
|
const parsed = url.parse(proxiedFor);
|
|
const { hash, hostname, path: urlPath } = parsed;
|
|
let { port, protocol } = parsed;
|
|
protocol = (protocol || 'http:').toLowerCase();
|
|
port = port || (protocol === 'https:' ? '443' : '80');
|
|
const reqOpts = {
|
|
protocol,
|
|
port,
|
|
host: hostname,
|
|
path: `${urlPath || ''}${hash || ''}`,
|
|
method: cliReq.method,
|
|
headers: cliReq.headers,
|
|
};
|
|
const srvReq = http.request(reqOpts);
|
|
srvReq
|
|
.on('error', (err) => {
|
|
console.error(
|
|
`Interceptor server error in onward request:\n${err}`,
|
|
);
|
|
})
|
|
.on('response', (srvRes: http.IncomingMessage) => {
|
|
// Copy headers, status code and status message from interceptor to client
|
|
for (const [key, val] of Object.entries(srvRes.headers)) {
|
|
if (key && val) {
|
|
cliRes.setHeader(key, val);
|
|
}
|
|
}
|
|
cliRes.statusCode = srvRes.statusCode || cliRes.statusCode;
|
|
cliRes.statusMessage = srvRes.statusMessage || cliRes.statusMessage;
|
|
srvRes.pipe(cliRes).on('error', (err: Error) => {
|
|
console.error(
|
|
`Interceptor server error piping response to proxy server:\n${err}`,
|
|
);
|
|
cliRes.end();
|
|
});
|
|
});
|
|
cliReq.pipe(srvReq).on('error', (err: Error) => {
|
|
console.error(
|
|
`Proxy server error piping client request onward:\n${err}`,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
let interceptorPort = 0;
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
// TODO: remove 'as any' below. According to @types/node v16.18.25, the
|
|
// callback type is `() => void`, but our code assumes `(err: Error) => void`
|
|
const listener = (server.listen as any)(0, '127.0.0.1', (err: Error) => {
|
|
if (err) {
|
|
console.error(`Error starting interceptor server:\n${err}`);
|
|
reject(err);
|
|
} else {
|
|
const info: any = listener.address();
|
|
interceptorPort = info.port;
|
|
console.error(
|
|
`[Info] Interceptor server listening on ${info.address}:${interceptorPort}`,
|
|
);
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
return interceptorPort;
|
|
}
|