NAT をやめて、透過 SOCKS プロキシを導入した

以下の記事内容について、奥一穂氏(@kazuho)より、「connectのエラーコードが信頼できなくなるといった欠点もあるのに透過 SOCKS プロキシが汎用的に良いように読めてしまう」というご指摘をいただきました。確かに、下記内容は当社が抱えていた複数の課題を短期間で解消できる「ワークアラウンド」として透過 SOCKS プロキシという技法もあることを紹介したものであり、NAT と比較して常に良いという主張をしたかったわけではありません。また、記事内では解説を省きましたが、従来より HTTP(S) 通信は NAT ではなく HTTP プロキシを利用しています。謹んで補足・訂正とさせていただきます。

猫が好きだけど猫アレルギーで近寄ることができない山本泰宇です。 先日アーキテクチャ刷新プロジェクト「Neco」を紹介しましたが、今回はその活動の一環として実施したネットワークアドレス変換(NAT, Network Address Translation) を廃止して透過 SOCKS プロキシに置き換えた件を紹介します。

多少長い記事ですので、内容を先にまとめておきます:

  • NAT は透過性が利点だが、欠点でもある
  • SOCKS の弱点である透過性の問題を自作透過プロキシ transocks で解決
  • 透過プロキシには LD_PRELOAD より断然 iptables 方式が良い
  • SOCKS サーバーも必要な要件を実装したものを自作
  • Go はいいぞ

NAT の欠点

ご存知の方も多いと思いますが、NAT はインターネットに直接接続していないプライベートネットワーク内のコンピュータのアドレスを、NAT 装置が持つグローバル IP アドレスに書き換えることで、プライベートネットワーク内のコンピュータがインターネット上のサーバーと通信できるようにする技術です。

IP パケットのプライベートアドレスを NAT 装置が書き換えるだけなので、LAN 内のコンピュータは自分がグローバル IP アドレスを持っていないことを意識することなく(= 透過的に)インターネット上のサーバーと通信ができます。

一方で、この透過性には欠点もあります。実際サイボウズで直面した問題を紹介します。

  • ルーティングがネットワーク装置に依存しているため、出口を柔軟に選べない

    ある程度以上の規模のクラウドサービスでは、各国・地方に専用線を通じた高速なアクセスを提供したいといった理由で、インターネットに接続している出口を複数持つことが多いと思います。NAT の場合、どの出口を選ぶかはスイッチのルーティングによるため、ポリシーベースルーティング (PBR) などの設定で対応します。

    しかしながら、ネットワーク層での設定は通常アプリケーション層とは異なる人がメンテナンスしているため、アプリケーション層からすると柔軟性に欠けることになります。

  • 通信先の設定によっては、通信できないことがある

    有名な例に、Linux の net.ipv4.tcp_tw_recycle を有効にすると NAT の内側から接続できなくなるというものがあります。インターネット上のサーバーでは tcp_tw_recycle を有効にするべきではないのですが、有効にしているサーバーは実際に存在します。

    対策としては TCP タイムスタンプを付けないようにすることで、NAT 装置によってはタイムスタンプを取り去る機能を持つものがあります。

  • NAT 装置は通信先サーバーの IP アドレスとポート番号しか分からないため、ドメイン名での規制ができない

    例えば cybozu.com のサブドメインは全て同じ IP アドレスを持つため、demo.cybozu.com だけ通信を許可するといったことができません。各種の CDN も同様の事情を抱えているため、NAT で通信規制すると CDN をまるごと規制するしかなく、各種 Web サービスの利用が困難になる場合があります。

    IP アドレスではなく、ドメイン名(FQDN)で規制したい場合に困るというわけです。

まとめると、以下が NAT の欠点です。

  1. ルーティングの柔軟性に欠ける
  2. 通信先によっては通信できないことがある
  3. ドメイン名で規制ができない

SOCKS とは

プライベートネットワーク内のコンピュータが外部と通信する技術は NAT 以外に、プロキシという方法もあります。「代理」という意味で、文字通りプライベートネットワーク内のコンピュータの代わりにインターネット接続を代行してくれるサービスです。

主にウェブで利用する HTTP プロキシが有名ですが、SOCKS は電子メール通信など TCP/IP 通信全般を対象にできるプロトコルです。SOCKS5 では IPv4/v6 アドレスやドメイン名で接続先を依頼することができます。

NAT と違い、SOCKS は利用するプログラムが明示的に SOCKS プロトコルで SOCKS サーバーに接続する必要があるため、透過的ではありません。そのため、SOCKS 対応していないプログラムはそのままではインターネット上のサーバーに接続できないことになります。

透過性がない代わりに、NAT の欠点は以下のように回避できます。

  1. 接続先 SOCKS サーバーをプログラムが選択できるため、柔軟にルーティングできる
  2. SOCKS サーバーが先方と直接通信するため、tcp_tw_recycle 問題は発生しない
  3. ドメイン名で規制できる

透過 SOCKS プロキシの実装方式

SOCKS 未対応のプログラムをどうにかして SOCKS 対応させることができると、NAT の欠点を回避することができます。そのためにはプログラムに手をいれずに通常の TCP 通信を SOCKS サーバーへの通信に変換する必要があります。

プログラムに手を入れずに TCP 通信を変換する方式としては、動的リンカで connect 等のシステムコールを自作のものに置き換える LD_PRELOAD 方式やカーネルモジュールでシステムコールテーブルを乗っ取る方式も考えられますが、あまり良いアイデアとは言えません。

LD_PRELOAD 方式はまず libc の動的なシンボル解決に依存していますので、静的リンクされているバイナリや libc に依存していない Go のプログラムには効果がありません(c.f. https://github.com/golang/go/issues/3744)。

またシステムコールを乗っ取るといっても、透過性を実現するためにどこまでのシステムコールを乗っ取るべきかという難しい問題があります。最低限は connect, getpeername などですが、非同期 connect に対応するなら select, poll, epoll なども必要でしょうし、TCP Fast Open なら sendmsg なども... と考えていくとどこまでやれば確実といえるのか、不安が残ります。

通信を乗っ取るのであれば、それ専用に設計されている仕組みを利用する方式のほうが筋が良いでしょう。というわけで Linux であれば iptables (ip6tables) を使うのが断然良いです。iptables でインターネット向けのパケットを一旦別のプログラムに渡るようにし、そのプログラムが SOCKS サーバーと通信してデータを転送するようにします。

今回はルーティングをプログラムで制御したいので、ローカルで生成されたパケットの宛先を制御できる DNAT / REDIRECT を利用することにします。具体的には nat テーブルの OUTPUT チェーンに以下のようにルールを設定すると、ローカルホストの 1081 版ポートで動作しているプログラムに通信をリダイレクトできます。(ルータにしかけるのであれば、TPROXY を利用する方式も考えられますが、割愛します)

*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:TRANSOCKS - [0:0]
-A OUTPUT -p tcp -j TRANSOCKS
-A TRANSOCKS -d 0.0.0.0/8 -j RETURN
-A TRANSOCKS -d 10.0.0.0/8 -j RETURN
-A TRANSOCKS -d 127.0.0.0/8 -j RETURN
-A TRANSOCKS -d 169.254.0.0/16 -j RETURN
-A TRANSOCKS -d 172.16.0.0/12 -j RETURN
-A TRANSOCKS -d 192.168.0.0/16 -j RETURN
-A TRANSOCKS -d 224.0.0.0/4 -j RETURN
-A TRANSOCKS -d 240.0.0.0/4 -j RETURN
-A TRANSOCKS -p tcp -j REDIRECT --to-ports 1081
COMMIT

DNAT / REDIRECT は NAT 技術に利用されるものですので、宛先アドレスが書き換えられた状態でプログラムに転送されてきます。透過プロキシをするには元々接続したかったインターネット上のサーバーのアドレスを知る必要がありますが、man には書かれていない getsockopt のオプションで、書き換えられる前の宛先アドレスを知る方法があります。

自作透過 SOCKS プロキシ transocks

iptables で宛先を書き換えられる前のアドレスを知るには、以下のようにします:

struct sockaddr_storage addr;

// for IPv4
socklen_t len = sizeof(struct sockaddr_in);
int level = IPPROTO_IP;
int opt = SO_ORIGINAL_DST;

// for IPv6 (Linux 3.8 or better)
// socklen_t len = sizeof(struct sockaddr_in6);
// int level = IPPROTO_IPV6;
// int opt = IP6T_SO_ORIGINAL_DST;

if( getsockopt(sock, level, opt, &addr, &len) == -1 ) {
    // handle error
}

このテクニックを利用して透過プロキシを実現する既存のソフトウェアは幾つかあるのですが、SOCKS や IPv6 に対応していなかったり、高負荷時にクラッシュするといった現象があったため、改修するより自作したほうが早いと判断しました。

自作にあたっては Go を利用しました。高速なネットワークサーバーを自作するなら、Go は非常に良い選択肢です。実際 SOCKS5 サーバーへの接続は golang.org/x/net/proxy として既成のものがあるため、必要なのは上記 getsockopt の部分くらいです。

成果物は github.com/cybozu-go/transocks で公開しています。SOCKS5 サーバー以外に、Squid のような HTTP プロキシの CONNECT メソッドを利用することもできるようになっています。全て含めて 2 日足らずで実装できました。

http_tunnel.go は x/net/proxy のプラグインとして利用できますので、部品としてもお使いいただけます。

自作 SOCKS サーバー usocksd

SOCKS サーバーも Go で自作しました。複数のグローバル IP アドレスを動的に使いわけるという要件が必要であったためです。こちらも既成の SOCKS5 サーバーライブラリを利用することで、1 日程度で実装できました。

利用した armon/go-socks5 は良い設計ですが、プライベートネットワーク内の同一ホストに対して、同じグローバル IP アドレスを利用するといったことが実現できなかったため、context に対応して実現できるよう改造しています。これをしないと、FTP サーバーへの接続などで障害が起きる場合があります。

成果物は github.com/cybozu-go/usocksd で公開しています。

稼動状況

usocksd を DMZ に、transocks を従来 NAT を必要としていたホストに設置する構成で既に cybozu.com にて稼動しています。

問題なく動いていますが、usocksd は少し GC の CPU 負荷が高いようだったので、GOGC=300 としてチューニングしました。Go1.6 の GC 負荷については、https://github.com/golang/go/issues/14161#issuecomment-178357292 を一読しておくと良いです。

まとめ

アーキテクチャ刷新プロジェクト「Neco」の成果の一つである、NAT の欠点を回避するための透過 SOCKS プロキシという技法と、その実装および稼動状況を紹介しました。

既存のソフトウェアはクラッシュしたり望ましい仕様ではなかったりしたため、透過プロキシおよび SOCKS サーバーは自作しました。Go を使えば望ましい仕様の SOCKS プロキシが楽々自作できることも紹介しました。Go はいいぞ。

サイボウズでは Go (や C/C++, Python, Java etc.)を駆使して問題を解決する技術者を募集しています。We are hiring!