iptablesとiproute2を使ってマルチホーム環境でNAT内のサーバーをうまく公開する方法

「普段はISP1を使いつつ、サーバ用には固定IPを貰える(i-revoなどの)ISP0を使いたい」といった、よくあるPPPoEマルチセッション環境におけるNATとポートフォワーディングの問題を解決する。

ネットワーク構成

まずは前提条件を。

構成図
構成図
ルータLinuxマシン
  • 物理IF
    • WAN側: eth0
    • LAN側: eth1
  • ppp
    • ppp0: ISP0(サーバ用固定IP)
    • ppp1: ISP1(外出用動的IP)
  • IP
    • WAN側: X.X.X.X (ppp0), Y.Y.Y.Y (ppp1)
    • LAN側: 192.168.0.1 (eth1)
LAN内ウェブサーバー
IP: 192.168.0.2
ウェブサーバーにアクセスしてくる外部のクライアント
IP: 192.0.2.4

設定手順

iptablesのパケットフロー図を見ながら読んで欲しい。

まず、内部ホストからの外向きの通信(通常の外出)はマスカレードして ppp1 から発信する。これはiptablesで簡単に設定できる。

iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -o ppp1 -j MASQUERADE

このルールは、パケットのソースIPアドレスがLAN内のネットワークアドレスであり、そのパケットがppp1から出て行く場合に、いわゆる「NAT」として働く。

この時点でルータ内のデフォルトルートは当然 ppp1 である。外向きのパケットは全て ppp1 から外に出て行き、ppp0 から外に出て行くことはない。ここまでは単に ppp0 が余計に存在するだけの、普通のNAT環境といえる。

次に、サーバ向けの ppp0 宛にやってきた通信を ppp0 から返信するように設定する。ppp0から入ってきたパケットに対する返信は何もしなくてもppp0から出て行くかと思いきや、そんなことはない。返信だろうが何だろうが、IPレベルでのルーティングはOSが持っているルーティングテーブルに従って行われるので、「ppp0に振られているIP X.X.X.X をソースIPにもつパケット」が ppp1 から送信されてしまう。ルーティングにおいてIPアドレスとIFは概念上分離している。

このような動作はしばしばトラブルとなる。ISPによってはソースIPが自分のネットワークに含まれていないパケットをスプーフィングと見なして転送してくれない事がある。さらに、ISP1の回線が接続されていないときにISP0経由の通信まで繋がらなくなってしまうし、ISP0の帯域を使いたい通信が全てISP1に流れてしまうのも問題であろう。

ppp0 (に振られているIP)宛のパケットに対する返信をイメージ通りにppp0から流すためにはルーティングテーブルを動的に切り替える必要がある。そこで、ルータ内のデフォルトルートは ppp1 のままにしつつ、iproute2 をつかって特定ルールにマッチする場合のみ ppp0 へのルーティングを行う。

まずは ppp0 用のルーティングテーブルを新たに定義する。ip routeというコマンドで設定できる。

ip route add 0/0 dev ppp0 table 10

これは 10(別に1でも3でも100でも何でもいい)という名前のルーティングテーブルにppp0をデフォルトゲートウェイとして追加している。このルーティングテーブルを参照した場合、あらゆるパケットがppp0経由で送信される。

ちなみに、普段使うrouteコマンドで見えるルーティングテーブルはiproute2上ではmainという名前のテーブルとして扱われている。つまりrouteコマンドはiproute2に定義されているデフォルトのテーブル「main」を扱っているに過ぎないのだ。

次に、このルーティングテーブルを参照する条件を定義する。ip ruleで定義できる。

ip rule add from X.X.X.X table 10 pref 100

これはつまり、ソースIPが X.X.X.X(ppp0のIPアドレス) のパケットについては、(mainではなく)10のテーブルを見ろと言うことである。prefは優先度なのだが、複雑なルールを作らない限りはあんまり気にしなくていい。適当に100ぐらいで定義しておこう。

なお、既存のルールは

ip rule

とだけ打てば参照できる。デフォルト状態だと以下のような感じになっているはずだ。

0:      from all lookup local
32766:  from all lookup main
32767:  from all lookup default

最も低い優先度に普段用のmainテーブルを参照するためのルールがあるので、これより高い優先度で先ほどのルールを定義すれば良い。

ここまでの設定で、ppp0 宛の通信に対する返信パケットは正しくppp0から送信されていくようになっているはずだ。返信パケットは当然 ppp0 に振られている X.X.X.X をソースIPとして送信されるので、新たに定義したルールに従って10番ルーティングテーブルが参照される。そこには ppp0 がデフォルトルートとして定義されているので、返信パケットは ppp0 から送信されていく。ためしにX.X.X.にpingしてみればきちんとppp0から返信のパケットが飛ぶ。なにも問題はない。ここまでは。

次にいわゆるポートフォワーディングの設定を行う。内部にあるウェブサーバーのためにppp0に来たポート80への通信だけ192.168.0.2に転送する。これはiptablesのDNATで簡単に設定できる。

iptables -A PREROUTING -t nat -p tcp -i ppp0 --dport 80 -j DNAT --to 192.168.0.2:80

DNATはデスティネーション(宛先)NATの事で、ルーティングが行われる前(PREROUTING)にディスティネーションIPを内部にあるサーバのIPに書き換える。今回の例では ppp0 に届くパケットについて、宛先ポートが80番であれば、192.168.0.2のポート80宛に書き換えを行う。書き換え後にルーティングが行われるので、LAN内にあるウェブサーバー宛に正常にパケットが配送されていく。

ではウェブサーバーから戻ってきたパケットはどうなるだろうか。ウェブサーバーに届くパケットのソースIPアドレスは特に書き換えられていないので、外部にいるクライアントのIPアドレス192.0.2.4のままとなっている。当然ウェブサーバーはそのIPアドレス宛に返信パケットを送信するが、返信パケットのソースIPアドレスはウェブサーバーの持つローカルIPアドレス192.168.0.2となる。

このパケットをそのまま外部に出すことは出来ないので、パケットを受け取ったルータのiptablesは逆DNATを行う。これは特に定義しなくても先ほど定義したDNATのルールに従って自動で行ってくれる。逆DNATによりソースIPアドレスが ppp0 に振られている X.X.X.X に書き換えられるので、外部から見ればまるでルーターが直接受け答えしているかのように見える。すばらしい。

ソースデスティネーション
ルータが受け取るパケット192.0.2.4X.X.X.X
ルータがDNATしたパケット(ウェブサーバーが受け取るパケット)192.0.2.4192.168.0.2
ウェブサーバーが返信するパケット192.168.0.2192.0.2.4
ルータが逆DNATしたパケットX.X.X.X192.0.2.4

だが現実はそううまくはいかない。実は(なぜそうなっているのかは知らないが)逆DNATはルーティングが行われた後に行われる。つまり、最初のDNATはPREROUTINGに行われるのに対し、帰りの逆DNATはあたかもPOSTROUTINGのタイミングで行われる。

だからどうしたと思うかもしれないが、さっき定義したiproute2のルールを思い出して欲しい。そこでは「ソースIPアドレスが X.X.X.X の時は」という定義だった。しかし逆DNATがルーティングの後に行われるとすると、ルーティング時におけるソースIPアドレスはまだ、ウェブサーバーの192.168.0.2のままになっている。よってルーティングはデフォルトのmainテーブルを参照して行われ、パケットは ppp0ではなく ppp1から送信される。ルーティング決定後に逆DNATでソースIPアドレスの書き換えが行われ、ソースIPアドレスがX.X.X.XとなったパケットがISP0のppp0ではなく、ISP1のppp1から送信されることになる。……さっきと同じ問題だ。

ここまでで分かることは、ソースIPアドレスによるip ruleの定義はルータがサーバーを兼務する場合にはうまく動作するが、内部の異なるホストへポートフォワードした場合は役に立たないと言うことだ。

これを解決するにはmarkを使わなければならない。markについての詳しい話は省略するが、要はパケットに印を付ける仕組みだ。iptablesでもiproute2でも使うことが出来るので、両者の連携に用いることが出来る。ただ、カーネル内での仕組みなのでホスト間での連携は出来ないので注意して欲しい。

markをどのように使うかというと、まずはiptablesを使ってppp0宛のコネクションには印を付ける。そしてルーティング時にその印を見てルーティングテーブルを切り替える。

まずは印を付けるためにiptablesのmangleテーブルを使う。mangleテーブルはパケットフロー図を見れば分かるとおり、常に他のテーブルより先に処理されるので、パケットの前処理に使用することが出来る。今回はppp0宛に来たコネクションに関してのみ、10(例によって3でも100でもなんでもいい)をマークとして付ける。

iptables -t mangle -A PREROUTING -j CONNMARK --restore-mark
iptables -t mangle -A PREROUTING -i ppp0 -j MARK --set-mark 10
iptables -t mangle -A PREROUTING -j CONNMARK --save-mark

ここで若干話がややこしくなるのだが、本来markはパケット単位で付けるものである。上の3行で言えば真ん中の行がそれに当たる。ここでは、ppp0にきたパケットについて10という値を付けている。ただ、これだけだと外からやってくるウェブサーバーへのパケットはマーク付きで処理できるのだが、ウェブサーバーからやってくる返信パケットにはマークが付かない。そこで、コネクション単位でのマーキングを行うためにCONNMARKを使う。CONNMARKは--save-markでパケットマークをコネクションマークとして記録し、--restore-markで逆のことを行う。少なくともtcpに関しては、この方法でコネクション単位でのマーキングが可能となる。

さて、コネクションにマークが付いたので、あとはこのマークを元にルーティングテーブルの切り替えを行うだけだ。

ip rule add iif eth1 fwmark 10 table 10 pref 99

LAN側(eth1)から流れてきたパケットに関して、10のマークが付いている場合は先に定義した10番のルーティングテーブルを参照する。これでウェブサーバーから流れてきた返信のパケットが正しくppp0から発信される。

以上で全ての問題が解決した。普段の外出はppp1から発信され、ppp0にやってくるパケットに関しては、返信がppp0から流れていく。