balena-cli/tests/nock/proxy-server.ts
2023-01-03 16:04:24 -03:00

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 v14.18.36, 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 v14.18.36, 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;
}