node.jsで作るアドホックなミラーサーバ

Node.jsアドベントカレンダー2013の4日目。

node.jsを使うとWebプロキシサーバのようなものは簡単に作れますが、それと似た仕組みでミラーサーバを作れます。

機能要件*1は以下を想定します。

  • 一個のNode.jsプロセスで複数のサイトをミラー出来る
  • ブラウザ側の設定を変えない(なのでプロキシやchromeプラグインはNG)
  • ミラー対象サイトのURLを与えるとミラーサイトのURLを発行する設定API

例えば、http://aaa.bbb.com/ccc をミラーしたいとすると、http://aaa.bbb.com/ccc を登録するとミラーサイトのURL(後述)を発行してくれて、そのURLへのアクセスはすべてサーバサイドで元のサイトに中継してくれる、というものを作りたいわけです。それが出来れば、ミラーサイトの内容を編集するのは簡単なので、任意のサイトの改変後の姿を見ることができます。そしてフィッシングサイトを量産するような悪の用途にも使おうと思えば使えるでしょう。

設計

自動発行するURLの形式

自動的に発行するミラーサイトのURLは、対象サイトが http://aaa.bbb.com/ccc だとすると http://proxy_XXX.adfive.net/ccc のようにします(adfive.netは弊社ドメインです)。XXXはaaa.bbb.comを意味する通し番号で、対象サイトを登録した順番にカウントアップしていきます。

ミラーサイトのURLを http://proxy.adfive.net/aaa.bbb.com/ccc という風にしてもいいように思いますが、そうやってしまうとHTML内の絶対パス(http://aaa.bbb.com/img/001.jpgを/img/001.jpgと書いてるとか)が http://proxy.adfive.net/ の直下にマップされてしまいまずいのです。

またhttp://aaa.bbb.com.proxy.adfive.net/ccc のようにサブドメインに連結する方法もありますが、こうすると大抵のレンタルDNSではミラーURLを発行するたびに設定追加が必要になります。

そこで、対象サイトのドメインaaa.bbb.comをサービス内部で通し番号XXXに変換することはどうしても必要です。そしてhttp://proxy_XXX.adfive.net/ccc のように変化するサブドメインの階層が一つ(proxy_XXX)であれば、DNS設定上はワイルドカード(*.adfive.net)が使えて、クライアント側のDNSキャッシュもワイルドカードで名前解決するはずなので通し番号が発行されるたびに名前解決用のDNSエントリが増えることもありません。

対象URLと通し番号のマッピング

というわけで対象ドメインを通し番号にマップするわけですが、これにはredisを使うことにします。

  • ミラー対象の登録APIでは、redis上で新しい通し番号を発行し「通し番号→対象URL」のエントリを追加
  • 発行したURLにアクセスされた場合は、サブドメイン(proxy_XXX.adfive.net)から通し番号(XXX)を抽出し、redisを引いて対象URLを取得し、その対象URLにHTTPリクエストをプロキシ
    • その対象URLからのレスポンスを編集してから返すことでサイト改変後の姿を表示できる
対象URLの登録API

登録用のAPIは、通し番号の無いproxy.adfive.netでホストすることにします。登録APIはPOSTにするのが自然ですが、後でJSONPで使えるようにGETにしておきます。GETするURLはhttp://proxy.adfive.net/?url=(対象URLをBase64エンコードしたもの) とします。

実装

全体のソースはGistにアップしてあります。

デモがこちらで動いてるので試せます。

メインのサーバ部分
var httpProxy = require('http-proxy');
(中略)
httpProxy.createServer(function (req, res, proxy) {
    var key = req.headers.host.split('.')[0];
    if (key == 'proxy') {
        record_url(req,res);
    } else {
        do_proxy(key,proxy,req,res);
    }
}).listen(port, function(){
    console.log("proxy start.");
});

httpProxyモジュールを使ってサーバを作成します。リクエストドメインの末尾のサブドメインをkeyとして、ミラー対象URLの登録APIと、ミラーサイトへのアクセスを振り分けています。

対象URLの登録
function record_url(req, res) {
    var q = qs.parse(url.parse(req.url).query);
    if (q.url && q.ua) {
        var u = decodeB64(q.url);
        var sel = q.sel?decodeB64(q.sel):'';
        var html = q.html?decodeB64(q.html):'';
        console.log([u,sel,html]);
        rc.incr('proxy',function(err,id){
            rc.hmset('proxy_'+id,{url:u,ua:q.ua,sel:sel,html:html});
            res.setHeader('location','http://proxy_'+id+'.adfive.net:'+port+url.parse(u).pathname);
            res.statusCode = 302;
            res.end();
        });
    } else {
        res.end();
    }
}

クライアント側からのURLからクエリパラメータを抜き出してredisレコード"proxy_XXX"に登録します。通し番号のXXXは、カウンタをredisエントリ"proxy"で覚えておいて呼ばれるたびにインクリメントしていきます。また、ここではプロキシを通すときにUserAgentを指定したり、プロキシから帰ってきたHTMLをjqueryで一部書き換えられるようにパラメータ(ua,sel,html)も加えています。

リクエストをプロキシに投げる
function do_proxy(key,proxy,req,res,id) {
    rc.hgetall(key, function(err, redis_val){
        if (err) {
            console.log(err);
            res.end();
        } else {
            var u = url.parse(redis_val.url);
            modify(req, res, redis_val);
            proxy.proxyRequest(req, res, {
                host: u.host, port: u.port || 80
            });
        }
    });
}

リクエストされたサブドメインproxy_XXXから通し番号XXXを抜き出してredisを引いて対象URLへのアクセスになるようリクエストヘッダを書き換えてhttpProxyに渡すだけです。ヘッダをどう書き換えるかは次で。

リクエストヘッダの書き換え
function modify(req, res, rv) {
    for (var k in req.headers) {
        if (k.match(/^x-forwarded/)) {
            delete req.headers[k];
        }
    }
    delete req.headers.cookie;
    delete req.headers.referer;
    delete req.headers['user-agent'];
    delete req.headers['accept-encoding'];

    if (rv.ua) {
        req.headers['user-agent'] = ua[rv.ua] || default_ua;
    }

    req.url = url.parse(req.url).path;
    url_base = url.parse(rv.url);

    if (req.url == url_base.path) {
        req.headers.host = url_base.host;
        if (rv.sel && rv.html) {
            modify_res(res,sdk_snippet(rv.sel,rv.html));
        }
    }
}

x-forwardedやクッキー、リファラ、accept-encodingを消して置くのがポイント。

レスポンスコンテンツの書き換え
function modify_res(res,snippet) {
    var _write = res.write;
    var _end = res.end;

    var bufs = [];

    res.write = function(d) {
        bufs.push(d);
    }
    res.end = function() {
        var buf = Buffer.concat(bufs).toString();
        var $ = cheerio.load(buf);
        $('head').append(snippet);
        _write.call(res,$.html());
        _end.call(res);
    }
}

nodeのhttpモジュールでresponseオブジェクトはendメソッドを上書きすることで、レスポンスをフックできます。httpProxyはイベントにフックさせるインタフェースが無いので、このようにせざるを得ないようです。jquery使ってもいいですが、軽いという噂のcheerioでHTMLのheadタグ内の末尾にサイトを書換え用のjsスニペットを挿入します。

クライアント側

フォームで入力された値をクエリにしてURLを作って移動するだけです。GistのHTMLをご参照ください。

デモ

デモサイトはこちらにあります。

こちらにアクセスすると、Yahooのトップページのロゴを弊社のものに書き換えたサイトが表示されます。

UserAgentを指定してミラーすることも出来ます。例えば、iPhoneで見たYahoo

(あくまでもこれはデモです。サイトの権利的に問題があるので、一時的なデモ以外で使う場合は元サイトの運営者にきちんと許諾を取る必要があります。)

まとめ

サーバ間でHTTPでやり取りするようなケースではnode.jsはベストチョイスだと思います。本記事のようなことを他の言語でやろうとすると結構大変なのではないでしょうか。

*1:任意のWebサイトの「広告を差し込んだプレビュー」を表示する機能で必要になり作りました