HTTP代理原理及nodejs实现

2,423 阅读6分钟

HTTP代理原理及实现

HTTP 代理存在两种形式:

  1. 普通代理
  2. 隧道代理

普通代理

HTTP 客户端向代理发送请求报文,代理服务器需要正确地处理请求和连接(例如正确处理 Connection: keep-alive),同时向服务器发送请求,并将收到的响应转发给客户端。

web_proxy.png

隧道代理

参考链接:

imququ.com/post/web-pr…

node中使用http.request发起请求

发起get请求

const https = require('https');
const url = require('url')
// 也可以直接传入url字符串, 但是在内部也会被url.parse()解析
const options = url.parse('https://nodejs.org/');

// 访问https协议的网站用https模块
const req = https.request(options, (res) => {
  ...
});
req.end();

注意,上面代码中,req.end() 必须被调用,即使没有在请求体内写入任何数据,也必须调用。因为这表示已经完成HTTP请求

否则导致超时, 报Error: socket hang up错误

也可以使用https.get, 它的内部也是使用https.request, 源码如下:

function get(input, options, cb) {
  const req = request(input, options, cb);
  req.end();
  return req;
}

发起post请求

const postData = querystring.stringify({
  'msg': 'Hello World!'
});

const options = {
  hostname: 'www.google.com',
  port: 80,
  path: '/upload',
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': Buffer.byteLength(postData)
  }
};

const req = http.request(options, (res) => {
  console.log(`STATUS: ${res.statusCode}`);
  console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
  res.setEncoding('utf8');
  res.on('data', (chunk) => {
    console.log(`BODY: ${chunk}`);
  });
  res.on('end', () => {
    console.log('No more data in response.');
  });
});

req.on('error', (e) => {
  console.error(`problem with request: ${e.message}`);
});

// write data to request body
req.write(postData);
req.end();

发起请求时需要注意的几个header

  • 发送 'Connection: keep-alive' 会通知 Node.js 与服务器的连接应该持续到下一个请求。
  • 发送 'Content-Length' 请求头会禁用默认的分块编码
  • 发送 'Expect' 请求头会立即发送请求头。
  • 发送授权请求头会覆盖auth参数

请求的事件触发顺序

在成功的请求中,会按以下顺序触发以下事件:

  • socket 事件
  • response 事件
    • res 对象上任意次数的 data 事件(如果响应主体为空,则根本不会触发 - data 事件,例如在大多数重定向中)
    • res 对象上的 end 事件
  • close 事件

如果出现连接错误,则触发以下事件:

  • socket 事件
  • error 事件
  • close 事件

如果在连接成功之前调用 req.abort(),则按以下顺序触发以下事件:

  • socket 事件
  • (在这里调用 req.abort())
  • abort 事件
  • error 事件并带上错误信息 'Error: socket hang up' 和错误码 'ECONNRESET'
  • close 事件

如果在响应被接收之后调用 req.abort(),则按以下顺序触发以下事件:

  • socket 事件
  • response 事件
  • res 对象上任意次数的 data 事件
  • (在这里调用 req.abort())
  • abort 事件
  • res 对象上的 aborted 事件
  • close 事件
  • res 对象上的 end 事件
  • res 对象上的 close 事件

http.request(options, callback)中的callback就是当response 事件触发时运行. socket 事件: 将套接字分配给此请求后触发。

HTTP普通代理服务器nodejs实现

var http = require('http');
var net = require('net');
var url = require('url');

function request(cIncomeReq, cRes) {
    var u = url.parse(cIncomeReq.url);

    var options = {
        hostname : u.hostname,
        port     : u.port || 80,
        path     : u.path,
        method     : cIncomeReq.method,
        headers     : cIncomeReq.headers
    };

    var pReq = http.request(options, function(pIncomeRes) {
        cRes.writeHead(pIncomeRes.statusCode, pIncomeRes.headers);
        pIncomeRes.pipe(cRes);
    }).on('error', function(e) {
        cRes.end();
    });

    cIncomeReq.pipe(pReq);
}

http.createServer().on('request', request).listen(8888, '0.0.0.0');

这里需要注意的是管道的写法, http.IncomingMessage, http.ClientRequest, http.ServerResponse都是继承自Stream, 它们通常出现在如下代码中:

请求:

ClientRequest = http.request(options, (IncomingMessageRes) => {});
ClientRequest.send(data);

响应:

http.createServer().on('request', (IncomingMessageReq, ServerResponse) => {})

20190902093020.png

ClientRequest, ServerResponse可以看作是WriteStream, 主动发送数据, IncomingMessage则是ReadStream, 被动接收数据.

所以cIncomeReq.pipe(pReq)可以理解为客户端发送的代理服务器的请求数据(cIncomeReq)通过(pReq)被转发到目标服务器.

pIncomeRes.pipe(cRes)可以理解为目标服务器响应的数据(pIncomeRes)通过(cRes)被转发会客户端.

以上代码运行后,会在本地 8888 端口开启 HTTP 代理服务,这个服务从请求报文中解析出请求 URL 和其他必要参数,新建到服务端的请求,并把代理收到的请求转发给新建的请求,最后再把服务端响应返回给浏览器。修改浏览器的 HTTP 代理(我用的是ProxySwitchy来修改的代理)为 127.0.0.1:8888 后再访问 HTTP 网站,代理可以正常工作。

但是,使用我们这个代理服务后,HTTPS 网站完全无法访问,这是为什么呢?

HTTP隧道代理服务器nodejs实现

var http = require('http');
var net = require('net');
var url = require('url');

function connect(cReq, cSock) {
    var u = url.parse('http://' + cReq.url);

    var pSock = net.connect(u.port, u.hostname, function() {
        cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
        pSock.pipe(cSock);
    }).on('error', function(e) {
        cSock.end();
    });

    cSock.pipe(pSock);
}

http.createServer().on('connect', connect).listen(8888, '0.0.0.0');

https-proxy-agent源码解读

或者我们可以直接使用https-proxy-agent

https-proxy-agent的使用

安装:

yarn add https-proxy-agent

使用:

const https = require('https');
const HttpsProxyAgent = require('https-proxy-agent');

// 代理服务器地址, 代理协议可以是http也可以https
const proxyEndpoint = 'https://115.204.25.88:4217';
const agent = new HttpsProxyAgent(proxyEndpoint);

const options = {
  agent,
};

// 使用http.get或者https.get都可以, 取决访问网址协议是http还是https
const req = https.get(url, options, (res) => {
  res.pipe(process.stdout);
});
req.end();

源码解读

大体结构

var Agent = require('agent-base');
var inherits = require('util').inherits;

function HttpsProxyAgent(opts) { ... };

inherits(HttpsProxyAgent, Agent);

HttpsProxyAgent.prototype.callback = function connect(req, opts, fn) {  ... };

HttpsProxyAgent继承自agent-base, agent-base作用是创建http.Agent实例, 这里我们介绍它其中一种用法:

自定义它的callback属性, 下面是callback函数签名:

callback(http.ClientRequest req, Object options, Function cb) → undefined

req可以获取headerspath等和请求相关的信息. options包含了在调用http.request()/https.request()时所传入的参数, options是已经格式化了的, 可以直接作为参数传入net.connect()/tls.connect(). 在这个函数中你需要创建一个socket, 然后传递给cb, HTTP请求将继续进行.

connect

https-proxy-agent使用的隧道代理的方式, 所以它的主要流程是:

  1. 判断代理服务器的地址是否为https协议, 使用net或者tls来创建一个socket.
  2. 使用创建的socket向代理服务器发起请求:
CONNECT 实际请求地址 HTTP/1.1
  1. socket监听响应, 请求返回状态码为200, 则代表链接建立, 将socket传递给cb, 交给http|https进行后续的请求

发起请求

一个请求示例:

CONNECT www.arrow.com:443 HTTP/1.1
Host: www.arrow.com
Connection: close

var hostname = opts.host + ':' + opts.port;
var msg = 'CONNECT ' + hostname + ' HTTP/1.1\r\n';

var headers = Object.assign({}, proxy.headers);

// 如果代理服务器需要认证, 需要加上认证header
if (proxy.auth) {
 headers['Proxy-Authorization'] =
   'Basic ' + Buffer.from(proxy.auth).toString('base64');
}

// the Host header should only include the port
// number when it is a non-standard port
var host = opts.host;
if (!isDefaultPort(opts.port, opts.secureEndpoint)) {
 host += ':' + opts.port;
}
headers['Host'] = host;

headers['Connection'] = 'close';

Object.keys(headers).forEach(function(name) {
 msg += name + ': ' + headers[name] + '\r\n';
});

socket.write(msg + '\r\n');

读取数据

if (socket.read) {
  read();
} else {
  socket.once('data', ondata);
}

function ondata(b) {
  buffers.push(b);
  buffersLength += b.length;
  var buffered = Buffer.concat(buffers, buffersLength);
  var str = buffered.toString('ascii');

  if (!~str.indexOf('\r\n\r\n')) {
    // keep buffering
    debug('have not received end of HTTP headers yet...');
    if (socket.read) {
      read();
    } else {
      socket.once('data', ondata);
    }
    return;
  }
  ...
}

关于socket.read我查询了node10.5.3的API是没有这个接口的, socket.read可能是为了兼容以前的版本, 为了方便理解上面的代码可以简化成:

socket.once('data', ondata);


var buffers = [];
var buffersLength = 0;

function ondata(b) {
  buffers.push(b);
  buffersLength += b.length;
  var buffered = Buffer.concat(buffers, buffersLength);
  var str = buffered.toString('ascii');

  // ~表示取反码, -1的反码为0, !~-1 === true, 其他情况!~i === false.
  // !~str.indexOf('\r\n\r\n') 等价于 str.indexOf('\r\n\r\n') === -1
  // 也就是如果遇到`\r\n\r\n`代表数据接收完成
  if (!~str.indexOf('\r\n\r\n')) {
    // keep buffering
    debug('have not received end of HTTP headers yet...');
    socket.once('data', ondata);
    return;
  }
  ...
}

返回socket给cb

这个步骤是在与代理服务器建立隧道成功后, 需要返回一个socketcb, 让http|https继续处理请求.

if (200 == statusCode) {
   // 200 Connected status code!
   var sock = socket;

   // nullify the buffered data since we won't be needing it
   buffers = buffered = null;

   if (opts.secureEndpoint) {
     // since the proxy is connecting to an SSL server, we have
     // to upgrade this socket connection to an SSL connection
     debug(
       'upgrading proxy-connected socket to TLS connection: %o',
       opts.host
     );
     // 这里当是https协议访问目标网站时, 需要升级此套接字
     // 目前还没有搞明白为什么要升级, 已经以下升级代码的含义
     opts.socket = socket;
     opts.servername = opts.servername || opts.host;
     opts.host = null;
     opts.hostname = null;
     opts.port = null;
     sock = tls.connect(opts);
   }

   cleanup();
   fn(null, sock);
}