/** * @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((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 { 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((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; }