【Map-EでもNATタイプA】LinuxでポートセービングIPマスカレード付きの制限コーン風NAT(EIM/ADF)を動かす

概要

NAT動作をめぐる誤解まとめ - turgenev’s blogでは、UDPホールパンチングのしやすさとポートの節約を両立するには「Address Dependentなマッピングを保持しつつEIM風に動作するADFなNAT」が一番いいという話を書きました。これだとv6プラスやOCNバーチャルコネクトでもNintendo SwitchのNAT判定が「タイプA」になります。

この記事では、Symmetric NAT/Full Cone NATをサポートするruby製NATであるratGitHub - kazuho/rat: NAT written in pure ruby)を手元で動かし、またコードを少しだけ変更することで上記のようなNATを実際に動作させるところまでを紹介します。変更後のコードはGitHub - ge9/rat: NAT written in pure rubyに公開しています。

オリジナルのratの開発の背景などはkazuhoさんがプログラミング言語 Ruby30 周年記念イベント レポートに書いてくださっていて、輻輳制御などNAT以外の話題にも触れられているのであわせてお読みください。

NATタイプやUDPホールパンチングなどについては適宜以前の記事(NATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blogとか)も参照してください。

ratを動かす - tunの動作の概要

ratは、ベースとなる技術としてtun/tapを利用しています。tun/tapとは、主にUnix系のシステムで利用できる仮想的なネットワークインターフェースのことで、tunがL3(IPレベル)、tapがL2(Ethernetレベル)で動作します。今回使うratはtunのほうです。

中身は単純で、IPパケットが双方向に行き来できる(読み書き両方可能な)仮想的な通信路がPCから伸びていて、その先にプログラム(今回でいえばrat)があるというだけです。プログラムはPCから来たパケットを読むことができ、またPCに向けてパケットを送信することができます。PCはそのパケットを、普通の物理デバイスから送られてきたパケットと同じように処理します。

たとえば、tunがよく応用されるVPNであれば、PCからtunに送信されてきたパケットは暗号化された上でVPNプログラムによって改めて物理デバイスから通信先PCまで送信され、逆に通信先PCから来た暗号化パケットは復号された上でtunを通じてPCに対して送信されます。

ratを動かす - ratにおけるtunの役割

VPNの場合だとまあまあ直感的に分かりやすいと思うのですが、ratはNATを行うので、ちょっと特殊なtunの使い方をしています。具体的には、LANからWANに向かってNATされる際も、WANからLANに向かってNATされる際も、どちらの場合でもtunに向かってパケットが書き込まれ、NATされたパケットがtunから出てきます。ratはVPNと違って独自にインターネットとの通信を担当することはなく、あくまでアドレスやポートを書き換えたパケットをPCに向けて送り返してくるだけということです。

じゃあ、ratに送り付けられてきたパケットがWAN側から来たのかLAN側から来たのかどう判断しているんだ?と疑問に思うかもしれませんが、コードを読むとわかる通り、単純に送信先が自身のWAN側IPであればWAN側から来たものと判断しています。

ratを動かす - IPの割り当て

では、ratにパケットを送り付けるにはどうするのかというと、ルーティングでratが選ばれるようにすればよいです。

Linuxにはポリシーベースルーティングがあるので、まあ割とどんなルールでも指定できてしまうのですが、ちゃんとルーティングの仕組み(ip routeとip ruleの出力)を理解していないとratは上手く動作しません。

特に注意が必要なのが、最優先に設定されているlocalというルーティングテーブルで、これによってPC自身が持つアドレスへのパケットはPCの内部で(ratなどの外部のデバイスを使わずに)処理されます。

先ほども述べたように、ratは、自身が使用するWAN側のIP(ちなみに192.168.0.139にハードコードされています)(もちろん書き換えは可能です)を行き先にしているかどうかでパケットを区別します。従って、ratが動作するPCにおいては上記の192.168.0.139をどのインターフェイスにも割り当ててはいけません。(ここで3日くらいハマっていました)(まあ、localテーブルをいじるという方法もありますが…)

もっと言えば、実はratには1つもIPアドレスを割り当てる必要はありません。物理インターフェイスに割り当てられたアドレスに対してもポリシーベースルーティングを使用すればratを使用させることができるからです。

ratを動かす - インストール

具体的な手順に移ります。自分の環境はLinux Mint 21.2です。

ratはrubyで書かれているのでrubyを入れます。tun.rbにbytespliceという関数があってこれのためにRuby 3.2が必要です(ユーティリティ関数だと思うので簡単に書けると思いますが)。rbenvで3.3.0を入れてみました。

その他、依存ライブラリとしてlibyaml-dev、またruby内での依存ライブラリとしてrackupというやつが要るっぽいのでgem install rackupをしました。

ruby rat.rbで起動するのですが、ネットワークインターフェースを追加するのでroot権限が要ります。rubyのバージョンが新しすぎたためか、文法エラーが出た箇所(parse関数のicmp_payload)があったので、自分のGitHubのやつでは修正してあります。

reflector.rbというのはベンチマーク用(NATを行わない自明なtunデバイス)のようで、使いません。

ratを動かす - ネットワーク設定

起動するとratというインターフェースが追加されているのがip addr showで確認できます。まずはとりあえずip link set rat upをします。

次に/etc/iproute2/rt_tablesを開いてルーティングテーブル"rat"(名前は何でもいいです)を追加します("230 rat"みたいな行を追加すればよい)。

次にこのratというテーブルに、(全ての通信において)ratというデバイスを使用するというルートを追加しましょう。ip route add default dev rat table ratを実行します。これはratの起動のたびに必要です。
次に、ratのLAN側とWAN側にパケットを流し込む用のアドレスをそれぞれ作成します。ここではPCの所属サブネットが192.168.1.0/24として、既に192.168.1.1/24が割り当てられているとしましょう。そこで、192.168.1.2/24(LAN側)と192.168.1.3/24(WAN側)を追加します。

sudo ip addr add 192.168.1.2/24 dev eno1 label eno1:rat

sudo ip addr add 192.168.1.3/24 dev eno1 label eno1:rat2

まずLAN側に関しては、この192.168.1.2から出て行くものがratを見るようにすればいいです。

sudo ip rule add from 192.168.1.2 lookup rat

WAN側に関してですが、前述の通りratが使用するWAN側IPは192.168.0.139です。したがってまず192.168.1.3と192.168.0.139との間でSNAT/DNATするルールを書きます。--randomはつけてはいけません。

sudo iptables -t nat -A POSTROUTING -o eno1 -s 192.168.0.139 -j SNAT --to-source 192.168.1.3

sudo iptables -t nat -A PREROUTING -i eno1 -d 192.168.1.3 -j DNAT --to-destination 192.168.0.139

その上で、この192.168.0.139に送られてきたパケットに対してratを見るようにルールを設定します。

sudo ip rule add to 192.168.0.139 lookup rat

また、WAN側として使用するインターフェース(今回ならeno1)がデフォルトのデバイスでない場合(例えばPPPoE接続とかVPNとか)は、192.168.0.139から出て行くパケットがそちらを使用するようにします。

sudo ip rule add from 192.168.0.139 lookup some_vpn_table

あと、net.ipv4.ip_forwardを1にしておきます。試行錯誤の過程でnet.ipv4.conf.*.accept_localやnet.ipv4.conf.*.rp_filterもいじりましたが、最終的には自分の環境のデフォルト値(前者は0、後者は2)のままで問題ありませんでした。

これで設定は完了のはずです。

192.168.1.2から送信されたパケットはratに書き込まれ、ratはそれを192.168.0.139から出て行くパケットに書き換えてLinuxに送信します。これは物理インターフェイスのeno1から出ていきますが、出て行く前にSNATルールによって送信元が192.168.1.3に書き換えられます。その応答が192.168.1.3に返ってくると、DNATルールによって宛先が192.168.0.139に書き換えられ、再びratに書き込まれます。ratはこの宛先を適切なLAN側IP・ポート(今回であれば192.168.1.2)へと書き換え、Linuxに送信します。これで正しく192.168.1.2でパケットが受信されます。

PC自体からではなく他のデバイスからも使用する(PCをルーターとして稼働させる)場合は、192.168.1.2に対して設定したのと同様にそのデバイスからの通信もratを見るように設定します。

今回は最低限の例示のためサブネットは192.168.1.0/24しか使っていませんが、本来はもっと色々ちゃんとやる必要があるかと思います。

あとratはirbを使っている関係なのかnohupとかでバックグラウンド動作させるとなんかまともに動かないので常駐させるときはtmuxとかscreenを使うのがいい気がします。

ファイアウォールの設定

ルーターの内側で実験する分にはファイアウォールは要りませんが、実用する場合はファイアウォールが必須になると思います。

ここでは細かい設定は省略しますが、よくあるファイアウォールの設定では、①192.168.0.139に向かってくる全てのパケットの転送②(PC自体のために)ratから向かってくるパケットの受け入れ③(配下の端末のために)ratから向かってくるパケットの転送、の3つを許可するルールを手動で追加しないと余計なフィルタリングがかかってしまうはずです。ufwでいうとそれぞれ

sudo ufw route allow to 192.168.0.139
sudo ufw allow in on rat
sudo ufw route allow in on rat

になります。

NAT動作についての前提知識

前回記事で詳しく書いたのでそちらをお読みください。以下が理解できていれば問題ありません。

  • ポートセービングIPマスカレード(ポートの共有)とSymmetric NATは全く別の機能であり、前者が有効でないSymmetric NATも、前者が有効であってSymmetricでないNATも存在しうる。
  • 実際に、Linuxのnetfilterは宛先の(アドレス, ポート)が異なる場合にポートの共有が有効であるが、異なる宛先のためにあえて異なるポートを割り当てようとはしないため、多くの場合はEIM/APDF(ポート制限コーンNAT)のように動作する。

NAT動作の変更

natではプロトコルごとにNATタイプを別々に設定することができます。nattable.rbを見るとNATTableというクラスが定義されていて、これのサブクラスとしてSymmetricNATTableとConeNATTableが実装されていました。

ConeNATTableのほうは純粋なEIM/EIF(フルコーンNAT)です。SymmetricNATTableは宛先のポートとアドレスに基づいてマッピングを管理していて、ポート共有(ポートセービングIPマスカレード)が有効、かつ宛先ポートをランダムで選んでいるので、iptablesのSNATやMASQUERADEで--randomを指定したときの挙動とほぼ同じです。ただしiptablesTCPに関してはAddress and Port Dependent FilteringではなくConnection Dependent Filteringなのでそこは微妙に違います。また、ランダムなだけで確率的には別の宛先に行くためのポートが一致する可能性もあるので厳密にはSymmetric NAT(Address and Port Dependent Mapping)ではありません。この辺も前回記事を参照してください。

今回実装したかったのは「Address Dependentなマッピングを保持しつつEIM風に動作するADFなNAT」ですが、まずその前段階としてiptablesで--randomを指定しないときの挙動、つまり「Address and Port Dependentなマッピングを保持しつつEIM風に動作するAPDFなNAT」も実装してみました。

これは、ランダムではなく以前使用したものと同じポートを可能な限り使用するように変更することで達成できます。PortRestrictedConeNATTableクラスは以下のような感じになりました。

class PortRestrictedConeNATTable < SymmetricNATTable
  def empty_port(remote_addr, remote_port, local_port, last_assigned)
    gc
    if !last_assigned.nil?
      return last_assigned unless @remotes[remote_key_from_tuple(last_assigned, remote_addr, remote_port)]
    end
    if (9000 <= local_port && local_port <= 9999)
      return local_port unless @remotes[remote_key_from_tuple(local_port, remote_addr, remote_port)]
    end
    20.times do
      test_port = @global_ports[rand(@global_ports.length)]
      return test_port unless @remotes[remote_key_from_tuple(test_port, remote_addr, remote_port)]
    end
    nil
  end
end
このように、まずはそのLAN側(Address, Port)に対して直近で割り当てられたポートを試し、それが使えなければLAN側ポートと同じ番号を試し、それもダメなら従来の実装と同様にランダムで20回試行します。これでほぼiptablesと同じになるはずです。
さらに、最終目的の制限コーンNATも実装してみましょう。とはいえ、上記のポート制限コーンNATからリモート側ポートの情報を使わないように変更するだけです。載せても別に面白くないので割愛します。

外側のルーターをEIM/EIFにする

ここまでで、Linuxから出ていくところまでは設定できました。今回は192.168.1.3というプライベートIPを例に説明しましたが、これを Linuxで直接接続しているPPPoEなどのアドレスに変えれば(もちろんeno1もppp0などに変更する)、ratを実用することができます。

しかし、(特にLinuxでの設定が面倒なMAP-Eでは)実際にはLinuxを直接インターネットに接続せず、別のルーターの配下につなげているケースも多いでしょう。この場合、ratを使用するときのNAT動作はこの外側のルーターのNAT動作の影響を受けます。そこで、この外側ルーターもEIM/EIFとして機能するように設定を変更する必要があります。具体的には、rat経由のパケットが出入りするポート(ratのポート範囲はデフォルトで9000-9999なので、今回なら192.168.1.3の9000-9999)に対して外側のルーターからポートを開放します。

とはいえ、MAP-Eの場合はポート範囲が16x15とか16x63とかに分かれているので9000-9999にそのまま開放することはできません。さらにルーターによってはMAP-E使用時にはポート1つずつしか開放できないものもあり(手元のRX-600KIとAterm WX1500HPはこれに該当し、Buffalo WSR-1166DHPL2は設定できるもの動作せず。参考)、開放のエントリの個数も32-64個程度しかないことが多いです。

これ自体は外側のルーターの制限なのでどうしようもないですが、ゲームくらいの用途だったら、20-30個くらいでもまともに機能する可能性が高そうです。その場合、ratの使用ポート範囲を20個くらいに絞って、そのポートに向けて一個ずつポート開放をすればよいです。

注意点として、外側からルーターに来た通信がrat側に転送されるだけでなく、ratのポートから出ていく通信がルーターの対応するポートを通って出ていくようになっていないとNAT動作がうまくEIM/EIFになりません。RX-600KIとBuffalo WSR-1166DHPL2では1:1でポート開放設定をした場合はちゃんと内側からの通信も固定で変換されましたが、Aterm WX1500HPはそうではありませんでした。基本的には内側のポート番号と同じ番号が利用可能ならそれを使うルーターが多いと思うので、外側と内側のポート番号を揃えてNATを設定するのが無難だと思います。1:1でポート開放設定しても内側からの通信は必ずランダムでポートが選ばれる、といったルーターだとどうしようもないので、別のルーターに変えてください。

試してみる

できたら、Stuntman - open source STUN serverなどを使って、NATタイプをテストしてみましょう(詳しくは前回記事)。Endpoint Independent MappingとAddress Dependent Filteringと表示されるはずです。相手がSymmetric NATであっても(IPが接続のたびに変わるなどでなければ)UDPホールパンチングが成立するはずです。実際tailscaleでもdirect connectionが成立しました。なお以前の記事に書きましたがstuntmanのテストはTCPに関しては動作がおかしいので全く参考になりません。

また、ポート共有が有効なので、同じアドレスに向けて大量の接続が発生しない限りはポートが不足することはありません。

もしうまく動かないときはtcpdumpとconntrack -Lで調べましょう。だいたいこの2つ(あと場合によってはnftables/iptablesのカウンタ?)があれば原因はわかるはずです。

静的なポート変換など

ratには静的なポート変換(ポート開放)の機能はありません。選択肢としてはいくつかあると思います。

  • ポート変換に使用する特定のポートをratの使用ポートから除外する…これが一番明快かと思います。現在、ratの使用ポートは9000-9999にこれまたハードコーディングされているのでこの辺を変える必要があります。おそらく、①使用ポートの個数②使用ポートのリスト(使用ポートのインデックスと実際の使用ポート番号との間の全単射)③与えられたポートが使用ポートに入っているかどうかを返すbool関数 あたりを付けて実装すると自分の機能拡張ともすんなり合いそうです。
  • 外部からの接続もマッピングで動的に管理することで、静的ポート変換に使用するポートを動的ポート変換においても兼用する…前回記事の通り、netfilterはこのような動作をするほか、ratにこのような機能を追加することも可能だと思いますが、若干わかりづらい動作になります。netfilterを使うなら、ratのほうはそのままで、優先順位を上げた別のDNATルールを追加するだけでいいので、一番手軽ではあります。使えるポートが少なく、また開放したいポートが多い場合もこちらの方がいいかもしれません。
  • ratに静的なポート変換の機能をつける…一番手間はかかりますが、これもこれでまともなやり方です。

MTU問題

自宅ではOCNインターネットを使用していてPPPoEとも併用している(詳しくは過去記事のIPoE/PPPoE併用時(など)に一つの端末から同時に複数の接続経路を利用する - turgenev’s blogフレッツ光関連の設定について(ドコモ光、ひかり電話、IPoE/IPv4 over IPv6とPPPoEの併用など) - turgenev’s blogもどうぞ)のですが、ratの出口をPPPoE(ppp0)経由にしてみたらTwitterアプリなどで通信エラーが頻発するようになりました。

tcpdumpで見てみると「ICMP xxx.xxx.xxx.xxx unreachable - need to frag (mtu 1454)」というような怪しいメッセージが出ていました。これは、MTU値が1454に設定されている(フレッツ光の)PPPoE回線を通るために送信元にパケットのサイズを小さくするよう要求するメッセージで、Path MTU Discoveryという仕組みに基づいているのですが、このメッセージが端末側にちゃんと届いていませんでした。そうなると、端末側はエラーメッセージを受け取れず、返ってこない応答を待ち続けてタイムアウトしてしまいます(ブラックホール問題などというようです 【図解】Path MTU Discoveryの仕組み~ルータやWindows/Linuxでの設定確認/変更方法~ | SEの道標)。

届いていなかった理由は、このICMP unreachableパケットが、Linux自身のIPをソースとしてratからLinuxへ送信されていたためでした。自分自身を送信元として外から来たパケットというのは普通に考えれば怪しいのでLinuxは破棄してしまいます。

さらに調べていくと、どうやらrat自体は、そもそもIPフラグメントされたパケットのNATに対応していない(無言でdropされてしまう)ようです。これでは、仮にICMP unreachableを正しく返したとしても通信が成立しません。

ただし実際には、TCP通信に関しては、ICMP unreachableでMTUが通知された場合、(多くのOSでは?)IPフラグメンテーションではなくTCPセグメンテーションが使用されます(TCPレベルで分割されます)。これならratは問題なくNATを行えます。従って、TCP通信に限って言えば、先ほどのICMP unreachableをLAN側に返してやれば、それによってTCPのセグメントサイズが調整され、通信が正常化されます。

あるいはもっと別の(そして、より広く使われている)方法として、中継機器(今回ならratが稼働しているPC)でTCPのSYNパケットの最大セグメントサイズ(MSS)のフィールドを勝手に書き換えてしまうというのもあります。実際、ppp0でも、出ていくSYNパケットにはiptablesの「--clamp-mss-to-pmtu」が設定されていました。しかし、入ってくるSYNパケットには何も設定されておらず、結果として端末(Twitterアプリを使っている)側では適切なMSSサイズを知ることができていませんでした。

詳しくは【図解】MTUとMSS, パケット分割の考え方 ~IPフラグメンテーションとTCPセグメンテーション~ | SEの道標あたりも参考になります。

以上をまとめて、考えられる具体的な解決方法(TCP限定)を列挙しておきます。

  • (一番おすすめ)sudo iptables -t mangle -A PREROUTING -p tcp --tcp-flags SYN,RST SYN -i ppp0 -j TCPMSS --set-mss 1414のように手動でMSSを設定する。(「--clamp-mss-to-pmtu」は、行先のルートのMTUに従って決める設定であり、送信元のMTUに従う設定はなさそう)(元のMTU値が1414より小さい場合は、1414に増やすのではなく、そのまま維持してくれるっぽい)
  • rat自身のMTUもppp0に合わせて1454に設定する(unreachableパケットがratを経由せず直接元の端末に送信されるため、ルーティングの問題が起こらない)
  • 前述のrp_filterをallとratに関して0にする。これで怪しいパケットがきちんと送られるようになる。ただ、セキュリティ的にちょっと納得いかない。
  • rat内で送信元アドレスを別のものに書き換える。ただ、ハードコードするものが増え、あまり綺麗ではない。

ちなみにこのパケットは元の送信先(たとえばTwitter)とは異なりLinux自身から発生していますが、元の接続への応答という扱いになるためSNATはかけられません。iptablesのnatテーブルは新規の接続にしかヒットしないからです。このへんはあまりちゃんと理解していませんでした。

あと、TCP以外だったらどうするのかという話ですが、とりあえずはどうしようもないです。ただ、【図解】UDPのMTU/MSSやフラグメント(パケット分割),サイズの考え方,EDNS0やQUICの例 | SEの道標などを読む限り、実際にはフラグメントされたパケットが送られてくることはあまりないのではないかと思います。

パフォーマンス(レイテンシ、スループット

NATは全てのインターネット通信が通るものなので、やはりパフォーマンスも気になります。これに関しては作者のkazuhoさんがかなり色々と試して報告してくださっていますが、個人的な感覚としては十分に実用的と言えると思います(ちなみにウチもkazuhoさんと同じくVDSLの100Mbps上限です)。

興味のある方はこのへんとかから辿ってみてください。

まあ、本当にパフォーマンスを追求したくなったらそのときにC++だかRustだかで書き直せばいいんではないかと思います。→現在Rustへの移植作業中です

TCPDNSについて

ratはTCPUDP・ICMP echo(request/reply)・ICMP error (Destination Unreachable / Time Exceeded / ICMPv6 packet too big)に対応していますが、TCPに使うのはあまりおすすめしません。というのもTCPのNATマッピング管理がステートレスで、establishedな通信にも終了した通信にも同じタイムアウト(デフォルトでは5分)が適用されるからです。普通はestablishedなら数時間、終了後なら1分とかに設定されています。つまりssh接続は(serveraliveintervalとか設定しないと)普段より短く切れるし、すぐに終了したHTTP接続はポートを消費しつづける状況になります。

それにTCPはホールパンチングにも普通使いません。

また、DNSUDPポート53番)についても、当然ホールパンチングとは無関係なのと、短時間で大量のリクエストが発生することがあるのでratを通すのはあまりおすすめしません。端末側で、MAP-Eを担当しているルーターDNSサーバーとして直接指定するのがいいと思います。

余談: 他のカスタムNAT実装

今回作ったようなNATの実装は知る限り他にはありませんが、Full Cone NATであればいくつかあります。

GitHub - llccd/netfilter-full-cone-nat: A kernel module to turn MASQUERADE into full cone SNATというカーネルモジュールはiptablesのextensionとして機能し、-j FULLCONENATのようにするとFull Cone NATが使えるようになります。手元のLinux Mint 21で動作確認しました。x64 Linux ルータのIPoE(map-e by iptables)環境でGame ConsoleをNAT越えさせる -- その1fullconenat module追加有りの場合にも解説があります。

またGitHub - EHfive/bpf-full-cone-nat: A Full Cone(EIM + EIF) NAT implemented in eBPFはeBPFという技術を使っていて、conntrackのAddress and Port Dependent Mappingに依存しないので先ほどのものよりパフォーマンスの向上が期待できるようです。これも手元で一応動かすことができました。EIM/ADFにすることも理論上はできるようですが、結構大変そうです。

また、eBPFとXDPの違いがよくわかっていないのですが、XDPを使って実装されたというGitHub - naoki9911/xdp-natというのもあります。ビルドしようとしましたが色々とエラーが出て面倒になったので動かすのは諦めました。あとTODOに色々書いてあって実際どこまでできてるのか(本当にFull Cone NATあたりの実装まで済んでいるのか)よくわからないです。

Windowsでできるのか?

WindowsではWinTunがあるので移植は一応できそうな感じでしたが(手元で書いてみたら、pingのパケットが読み込まれてログに記録されるくらいまではできた)、ポリシーベースルーティングがなくてNATの機能なども貧弱(「インターネット接続の共有(ICS)」が一つしか設定できず、また試した感じだとTUNのようなL3のみのアダプタは共有してもうまく動かない?)なので果たしてOSの機能で動かせるのか怪しいところです。

macOSはわかりません。

まとめ

かなり使い勝手のいいNATになっていると自負しています。動かないとかあればコメントにお願いします。