current commit
260809.28 · test: allow half-open CONNECT tunnel sockets
'use strict';

const net = require('net');
const http = require('http');
const assert = require('assert');
const { once } = require('events');
const fixtures = require('./fixtures');

function logRequest(logs, req) {
  logs.push({
    method: req.method,
    url: req.url,
    headers: { ...req.headers },
  });
}

// This creates a minimal proxy server that logs the requests it gets
// to an array before performing proxying.
function createProxyServer(options = {}) {
  const logs = [];

  let proxy;
  if (options.https) {
    const common = require('../common');
    if (!common.hasCrypto) {
      common.skip('missing crypto');
    }
    proxy = require('https').createServer({
      cert: require('./fixtures').readKey('agent9-cert.pem'),
      key: require('./fixtures').readKey('agent9-key.pem'),
    });
  } else {
    proxy = http.createServer();
  }
  proxy.on('request', (req, res) => {
    logRequest(logs, req);
    // Route based on the absolute-form request-target.
    const url = new URL(req.url);
    const options = {
      hostname: url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
      port: url.port || 80,
      path: url.pathname + url.search,  // Convert back to relative URL.
      method: req.method,
      headers: {
        ...req.headers,
        'connection': req.headers['proxy-connection'] || 'close',
      },
    };

    const proxyReq = http.request(options, (proxyRes) => {
      res.writeHead(proxyRes.statusCode, proxyRes.headers);
      proxyRes.pipe(res, { end: true });
    });

    proxyReq.on('error', (err) => {
      logs.push({ error: err, source: 'proxy request' });
      if (!res.headersSent) {
        res.writeHead(500);
      }
      if (!res.writableEnded) {
        res.end(`Proxy error ${err.code}: ${err.message}`);
      }
    });

    res.on('error', (err) => {
      logs.push({ error: err, source: 'client response for request' });
    });

    req.pipe(proxyReq, { end: true });
  });

  proxy.on('connect', (req, res, head) => {
    logRequest(logs, req);

    const { hostname, port } = new URL(`https://${req.url}`);

    res.on('error', (err) => {
      logs.push({ error: err, source: 'client response for connect' });
    });

    const normalizedHostname = hostname.startsWith('[') && hostname.endsWith(']') ?
      hostname.slice(1, -1) : hostname;
    // A CONNECT tunnel is full-duplex. Keep the upstream socket writable after
    // receiving a FIN so that the client-to-upstream pipe can finish draining.
    // The reverse pipe will end `res`, and `res` will in turn end `proxyReq`.
    const proxyReq = net.connect({
      port,
      host: normalizedHostname,
      allowHalfOpen: true,
    }, () => {
      res.write(
        'HTTP/1.1 200 Connection Established\r\n' +
        'Proxy-agent: Node.js-Proxy\r\n' +
        '\r\n',
      );
      proxyReq.write(head);
      res.pipe(proxyReq);
      proxyReq.pipe(res);
    });

    proxyReq.on('error', (err) => {
      logs.push({ error: err, source: 'proxy connect' });
      // The proxy client might have already closed the connection
      // when the upstream connection fails.
      if (!res.writableEnded) {
        res.write('HTTP/1.1 500 Connection Error\r\n\r\n');
        res.end('Proxy error: ' + err.message);
      }
    });
  });

  proxy.on('error', (err) => {
    logs.push({ error: err, source: 'proxy server' });
  });

  return { proxy, logs };
}
exports.createProxyServer = createProxyServer;

function spawnPromisified(...args) {
  const { spawn } = require('child_process');
  let stderr = '';
  let stdout = '';

  const child = spawn(...args);
  child.stderr.setEncoding('utf8');
  child.stderr.on('data', (data) => {
    console.error('[STDERR]', data);
    stderr += data;
  });
  child.stdout.setEncoding('utf8');
  child.stdout.on('data', (data) => {
    console.log('[STDOUT]', data);
    stdout += data;
  });

  return new Promise((resolve, reject) => {
    child.on('close', (code, signal) => {
      console.log('[CLOSE]', code, signal);
      resolve({
        code,
        signal,
        stderr,
        stdout,
      });
    });
    child.on('error', (code, signal) => {
      console.log('[ERROR]', code, signal);
      reject({
        code,
        signal,
        stderr,
        stdout,
      });
    });
  });
}

async function checkProxied(type, envExtension, expectation, cliArgsExtension = []) {
  const script = type === 'fetch' ? fixtures.path('fetch-and-log.mjs') : fixtures.path('request-and-log.js');
  const { code, signal, stdout, stderr } = await spawnPromisified(
    process.execPath,
    [...cliArgsExtension, script], {
      env: {
        NO_LOG_REQUEST: '1',
        ...process.env,
        ...envExtension,
      },
    });

  assert.deepStrictEqual({
    stderr: stderr.trim(),
    stdout: stdout.trim(),
    code,
    signal,
  }, {
    stderr: '',
    code: 0,
    signal: null,
    ...expectation,
  });
};

exports.checkProxiedFetch = async function(...args) {
  return checkProxied('fetch', ...args);
};

exports.checkProxiedRequest = async function(...args) {
  return checkProxied('request', ...args);
};

exports.runProxiedRequest = async function(envExtension, cliArgsExtension = []) {
  const fixtures = require('./fixtures');
  return spawnPromisified(
    process.execPath,
    [...cliArgsExtension, fixtures.path('request-and-log.js')], {
      env: {
        ...process.env,
        ...envExtension,
      },
    });
};

exports.runProxiedPOST = async function(envExtension) {
  const fixtures = require('./fixtures');
  return spawnPromisified(
    process.execPath,
    [fixtures.path('post-resource-and-log.js')], {
      env: {
        ...process.env,
        ...envExtension,
      },
    });
};

exports.startTestServers = async function(options = {}) {
  const { proxy, logs } = createProxyServer();
  proxy.listen(0);
  await once(proxy, 'listening');

  let httpServer, httpsServer, httpEndpoint, httpsEndpoint;
  if (options.httpsEndpoint) {
    httpsServer = require('https').createServer({
      cert: fixtures.readKey('agent8-cert.pem'),
      key: fixtures.readKey('agent8-key.pem'),
    }, (req, res) => {
      res.end('Hello world');
    });
    httpsServer.listen(0);
    await once(httpsServer, 'listening');
    const { port } = httpsServer.address();
    httpsEndpoint = {
      serverHost: `localhost:${port}`,
      requestUrl: `https://localhost:${port}/test`,
    };
  }

  if (options.httpEndpoint) {
    httpServer = http.createServer((req, res) => {
      res.end('Hello world');
    });
    httpServer.listen(0);
    await once(httpServer, 'listening');
    const { port } = httpServer.address();
    httpEndpoint = {
      serverHost: `localhost:${port}`,
      requestUrl: `http://localhost:${port}/test`,
    };
  }

  return {
    proxyLogs: logs,
    shutdown() {
      if (httpServer) {
        httpServer.close();
      }
      if (httpsServer) {
        httpsServer.close();
      }
      proxy.close();
    },
    proxyUrl: `http://localhost:${proxy.address().port}`,
    httpEndpoint,
    httpsEndpoint,
  };
};