Node.js で https をサポートする http proxy サーバを 80行で書いた

  • 139
    Like
  • 3
    Comment

Node.js

新しく普通に使える http proxyサーバ を書いた。

https をサポートする http proxy サーバ

前回、Qiitaに投稿した記事「Node.jsによる必要最小限のhttpサーバとhttpsサーバとhttp proxyサーバ」では、
http proxyサーバ と言っても、本当に http だけしかサービスしてくれないので、
proxy 経由では、Google さえアクセスできず、あまり役に立ちませんでした。

そこで、今度は HTTP/1.1 の CONNECT メソッドをサポートしました。
これで Google も Facebook もアクセスできます。

またまた、必要最小限の機能しか実装していません。
なので、 注意点 がひとつあります。
この http proxyサーバ を動作させる前に、以下のリンクの情報をお読みください。
脆弱なホストを狙った不正中継を見抜く - @IT

HTTP/1.1 CONNECT をサポートするという事は、つまり
この http proxyサーバ にアクセスできる人は、ここから到達できる全ての IP と port 全てに、
つまり踏み台にして、どこにでも、どの様なプロトコルでもアクセスできる様になるという事です。
http proxyサーバ は、こういう不正アクセスにも気をつけていなければ、
どんな事でも許可してしまう事になります。
内向きから外向きはいいけど、外部からのアクセスは許可しない、など。
ファイアウォールの無い環境では実行しない方がいいかも。

このプログラムは、セキュリティ的に なんにもしていませんので、ご注意ください。

http proxy サーバのコード

httpsをサポートするhttp proxyサーバ を無理やり80桁以内×80行に収めました。(笑
http service port 番号と同じ行数で実装できた。わ~い。
サービスする時のport番号のデフォルトは、やはり 8080番でいいかな。

http-proxy-server-in-80-lines.js
'use strict';
const http = require('http'), url = require('url'), net  = require('net');
const HTTP_PORT = process.argv[2] || 8080;  // internal proxy server port
const PROXY_URL = process.argv[3] || null;  // external proxy server URL
const PROXY_HOST = PROXY_URL ?  url.parse(PROXY_URL).hostname    : null;
const PROXY_PORT = PROXY_URL ? (url.parse(PROXY_URL).port || 80) : null;

const server = http.createServer(function onCliReq(cliReq, cliRes) {
  let svrSoc;
  const cliSoc = cliReq.socket, x = url.parse(cliReq.url);
  const svrReq = http.request({host: PROXY_HOST || x.hostname,
      port: PROXY_PORT || x.port || 80,
      path: PROXY_URL ? cliReq.url : x.path,
      method: cliReq.method, headers: cliReq.headers,
      agent: cliSoc.$agent}, function onSvrRes(svrRes) {
    svrSoc = svrRes.socket;
    cliRes.writeHead(svrRes.statusCode, svrRes.headers);
    svrRes.pipe(cliRes);
  });
  cliReq.pipe(svrReq);
  svrReq.on('error', function onSvrReqErr(err) {
    cliRes.writeHead(400, err.message, {'content-type': 'text/html'});
    cliRes.end('<h1>' + err.message + '<br/>' + cliReq.url + '</h1>');
    onErr(err, 'svrReq', x.hostname + ':' + (x.port || 80), svrSoc);
  });
})
.on('clientError', (err, soc) => onErr(err, 'cliErr', '', soc))
.on('connect', function onCliConn(cliReq, cliSoc, cliHead) {
  const x = url.parse('https://' + cliReq.url);
  let svrSoc;
  if (PROXY_URL) {
    const svrReq = http.request({host: PROXY_HOST, port: PROXY_PORT,
        path: cliReq.url, method: cliReq.method, headers: cliReq.headers,
        agent: cliSoc.$agent});
    svrReq.end();
    svrReq.on('connect', function onSvrConn(svrRes, svrSoc2, svrHead) {
      svrSoc = svrSoc2;
      cliSoc.write('HTTP/1.0 200 Connection established\r\n\r\n');
      if (cliHead && cliHead.length) svrSoc.write(cliHead);
      if (svrHead && svrHead.length) cliSoc.write(svrHead);
      svrSoc.pipe(cliSoc);
      cliSoc.pipe(svrSoc);
      svrSoc.on('error', err => onErr(err, 'svrSoc', cliReq.url, cliSoc));
    });
    svrReq.on('error', err => onErr(err, 'svrRq2', cliReq.url, cliSoc));
  }
  else {
    svrSoc = net.connect(x.port || 443, x.hostname, function onSvrConn() {
      cliSoc.write('HTTP/1.0 200 Connection established\r\n\r\n');
      if (cliHead && cliHead.length) svrSoc.write(cliHead);
      cliSoc.pipe(svrSoc);
    });
    svrSoc.pipe(cliSoc);
    svrSoc.on('error', err => onErr(err, 'svrSoc', cliReq.url, cliSoc));
  }
  cliSoc.on('error', err => onErr(err, 'cliSoc', cliReq.url, svrSoc));
})
.on('connection', function onConn(cliSoc) {
  cliSoc.$agent = new http.Agent({keepAlive: true});
  cliSoc.$agent.on('error', err => console.log('agent:', err));
})
.listen(HTTP_PORT, () =>
  console.log('http proxy server started on port ' + HTTP_PORT +
    (PROXY_URL ? ' -> ' + PROXY_HOST + ':' + PROXY_PORT : '')));

function onErr(err, msg, url, soc) {
  if (soc) soc.end();
  console.log('%s %s: %s', new Date().toLocaleTimeString(), msg, url, err + '');
}

起動方法

http-proxy-server-in-80-lines.js として保存してください。

以下の様なコマンドで起動します。

そのまま外部に接続できる環境の時:
node http-proxy-server-in-80-lines 8080

会社内など外部に接続する時に、proxy サーバが必要な場合:
node http-proxy-server-in-80-lines 8888 http://127.0.0.1:8080

上記の両方を起動すると、多段串という事になりますね。

特徴と特記事項

https サポート

https をサポートしました。
HTTP/1.1 CONNECTメソッドを実装する必要がありました。

上位の proxy サーバが指定可能

自身も proxy サーバですが、リクエストを更に上位の proxy サーバに投げられる様になっています。

Node.js と http プロトコル勉強用

できるだけ、正しく実装したつもりです。
どこで何を実行すべきなのか、考えてコードを配置しました。
特に関数定義などは配置する場所で内側で使用できる変数が変わります。
変数が参照できるからといって、タイミングによって中身が正しいとも限りませんしね。

必要十分なエラー処理

まぁ、落ちない程度に、必要と思われる場所だけにエラー処理を記述しました。
無名関数(匿名関数)に名前を付けました。じゃ、無名(匿名)じゃないな。
エラーで落ちた時、どこで落ちたかわかりにくいからです。

Node.js らしく実装

Node.js らしいプログラムとして実装したつもりです。
通常 Node では、readable, end のイベントを使用するプログラムが多いですが、
それらを使用せず stream を pipe で繋ぐ事にすると、結構、効率が良い様に感じます。
※dataとendイベントよりreadableとendイベントを使用しましょう。可能ならpipeを。

短い

必要最小限の機能だけを実装する事に専念しました。
更に行数を減らすためにエラー処理関数は closure 使ったりしています。
妙なコードに見えると思いますが、80行に収めるためにちょっと苦労しました。
そもそも closure を理解しないと、このプログラム全体の動きは正しく理解できないと思います。

依存しているファイルが無い

npm などを使って外部のモジュールを利用する事は大事だと思いますが、
お勉強用なので、単独で動作できる事を目標にし、1つのファイルで実装しました。

それなりに動作検証した

この記事はここにある http proxyサーバ を2つ起動し多段串として動作させたまま、書いています。
まぁ、この1週間くらいは、ずっとこの状態です。

性能

1つのプロセスでJavaScriptは1スレッドで動きます。
それ以外に4スレッド~6スレッド程度、I/Oのためのスレッドが作られたりしている様です。

メモリも30MB~70MBくらいだし、今時すっげ~少ないんじゃね。

CPUも突発的には使用している様だけど、2段のproxy越しに、キーインしながら
この記事を書けているんだから、まぁいい方でしょう。

リソースもそんなに食わないし、性能は悪くは無いと思います。
みなさん、是非、試してみてください。

次はこのプログラムをどう料理すべきか

セキュリティ対策

アクセスしてくる IP の範囲などを決めないと怖いですね。
例えば以下の様なコードを追加すれば、自分以外は誰も使用できません。

http-proxy-server-plus-connection-security.js
const whiteAddressList = {};
whiteAddressList['::1'] = true;
whiteAddressList['::ffff:127.0.0.1'] = true;
whiteAddressList['::ffff:192.168.251.1'] = true;
server.on('connection', function onConn(cliSoc) {
  if (cliSoc.remoteAddress in whiteAddressList) return;
  console.log(new Date().toLocaleTimeString() +
    ' reject: from: ' + cliSoc.remoteAddress);
  cliSoc.destroy();
});

広告/AD BLOCK, コマーシャル/CMカット

ホスト名やURLの一部を利用して、無理やり通信を終わらせたりできます。
ちょっと頑張れば違うコンテンツに差し替える事も可能です。
ただし、http の場合は URL の hostname や path やヘッダなども使って細かく制限できますが、
https の場合ヘッダや内容は暗号化されているので、
IP (hostname) と port 番号だけで制御しなければいけません。

Ad Block くらいなら ChromeやFirefoxなどの拡張プラグインの方が便利かな。

接続数や接続時間を表示させてみる

http Serverの connection イベントで接続数をカウントアップ、
その引数の socket の close イベントで接続数をカウントダウンすれば
接続数の遷移がわかります。
最近は常時接続状態のサイトも多い様ですね。
サイトでは Google, Facebook, ブラウザでは Chrome など。

以下のコードを server 変数が存在する所に追加してください。
一番最後で結構です。

http-proxy-server-plus-connection-count-and-time.js
var connCount = 0;
server.on('connection', function onConn(cliSoc) {
  cliSoc.connTime = new Date();
  console.log('++conn: ' + (++connCount) + ' from: ' + cliSoc.remoteAddress);
  cliSoc.on('close', function onDisconn() {
    console.log('--conn: ' + (--connCount) + ' time: ' + 
      (new Date() - cliSoc.connTime) / 1000.0 + ' sec');
  });
});

多段串にしてみると、proxyに常時いくつ接続しているか、httpsで常時いくつ接続しているか、わかります。

簡易ロードバランサ

複数のAPサーバに向き先を変えて接続させるとか、できそうですね。
keep-alive connection は振り分けられないかな。

あまり良くないがステルスモード

ヘッダ上の proxy を示す様な痕跡を消すなどすれば、ステルスモードになります。
診断くん にも proxy を疑われる事はないと思います。
(生でアクセスした時と同じヘッダであれば区別つかなくて当然ですが)
注意: 本来 proxy サーバは connection ヘッダを転送してはいけない事になっているみたい。
HTTP Connections

http-proxy-plus-stealth-mode.js
  if ('proxy-connection' in cliReq.headers) {
    cliReq.headers['connection'] = cliReq.headers['proxy-connection'];
    delete cliReq.headers['proxy-connection'];
    delete cliReq.headers['cache-control'];
  }

その他

後はログ機能などでしょうか。

参考文献

node.jsでHTTPプロキシ経由でhttpsアクセスするには
http://qiita.com/kjunichi/items/02f6d340c1b903a976ee

node.jsでhttpsも扱えるProxyを作った
http://qiita.com/kjunichi/items/dfdd928945c9e6e202a5

node.jsでhttpsも使えるHttp proxyを書いている
http://kjunichi.cocolog-nifty.com/misc/2012/04/nodejshttpshttp.html

同じ人の記事でした。上記のコードを試してみました。
一部は動作するものの net で直接 TCP/IP を扱っているのにHTTP プロトコルの解析を
サボっているので、うまく動作しないことがありました。
Content-Length だけでなく Transfer-Encoding: chunked やら
いろいろ考えると HTTP を生で扱うなら、かなり頑張らないといけないと思います。

コメントがあればどうぞ

可能な限り対応・改良いたします。

2014/03/21 追記: 不具合があり http.Agent に対応した。88行になった。
2014/03/28 追記: v0.11.12 で http.Agent から request メソッドが無くなったので本来の形式に変更した。
2016/03/31 追記: node v4/v5 対応。80行になった。
2017/02/07 追記: node v6/v7 動作確認。メソッドチェインに変更。70行になった。