NAT動作をめぐる誤解まとめ

概要

この記事では、P2P通信の快適性などに大きく影響するところでありつつ、メーカー・プロバイダ側からそれほど積極的に情報が提供されているとは言えないNAT機器の動作について、よくある誤解をいくつか取り上げて解説します。

NAT動作に関する前提知識(cone NAT, symmetric NATあるいはEIM/APDFなどの用語)NATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blogなども参照してください(多少被っている内容もあります)。

Map-e(v6プラス・OCNバーチャルコネクトなど)ではポート開放ができない?

この記事を読む人でここを勘違いしている人はあまりいないと思いますが、まずは初歩的なところから行ってみましょう。Map-eによるIPv4 over IPv6(IPoE)通信ではIPv4のグローバルアドレスが割り当てられますが、他のユーザーと共有されており、自分に割り当てられた一部のポートしか使うことができません。OCNバーチャルコネクトなら1008個、v6プラスなら240個などと、サービスにより違いもあります。

グローバルIPへのNATはあくまでユーザー側で好き勝手に行えるので、この割り当てられた範囲内のポートであればポート開放などは自由に設定することができます。ただしTP-Link製など一部のルーターはMap-eで動かすとポート開放ができないものもあるようです。Map-eは多分国内の独自規格なので国産のルーターを買ったほうが安全な気がします。

また、一部のプロバイダではIPoE IPv6接続とPPPoE接続を同時に使わせてくれるところもあり、これだとPPPoEの方でポート開放ができます(ただし速度が遅くなるかもしれない)。これに関しては自分の過去記事のフレッツ光関連の設定について(ドコモ光、ひかり電話、IPoE/IPv4 over IPv6とPPPoEの併用など) - turgenev’s blogIPoE/PPPoE併用時(など)に一つの端末から同時に複数の接続経路を利用する - turgenev’s blogを含めて多くの情報があると思います。

あと、IPv6に関してはそもそもNATがないのでファイアウォールに穴をあければ自由にポート開放(と呼ぶのが正しいかわかりませんがそれと同等のこと)ができます。

DS-Lite(transix・クロスパスなど)ではポート開放ができない?

まあこれは基本的には正しいです。DS-Liteではプロバイダ側でNATが行われるため、グローバルIPに来た通信の扱いには手が出せません。

ただし、DS-LiteのNATはキャリア回線などと同じくフルコーンNATで、これはUDPだけでなくTCPもです。つまり、内側のTCPポートからインターネット側に何らかのパケットを送信してグローバルIP上のポートが割り当てられたとすると、その割り当てが有効な間はこのグローバルIP上のポートは任意の場所からの通信を受け入れて内側に通すようになります。つまり一時的にポートが開放された状態になるということです。

ということは、適当なホストにむけて一定間隔でkeepaliveパケットみたいなのを送っておけばそのTCPポートはいつまでも開放されるということになります。Natternatmapはこれをやってくれるようです。

TCPではなくUDPに関して言えば、まさにこれと同じことをやるのがUDPホールパンチングです。ポート開放の目的がVPNであれば、UDPホールパンチングをやってくれるVPNソフトウェア(tailscaleとかzerotierとかSoftEtherとかhamachiとか)を使うことで、ポート開放と同等の(リレーサーバーを介しない)VPN通信環境が実現できます。Tailscaleであれば他ユーザーにデバイスをシェアする機能もあるので友人同士などであればこれを使って(ポート開放が必要な)ゲームのマルチプレイをすることもできます。

まあとはいえ普通にポート開放ができたほうが何かと嬉しいので、(この記事でこのあと説明する色々な要素を加味した上で)自分だったらMap-eを選ぶと思います。

ポートセービングIPマスカレードとSymmetric NATは同じ意味?

違います。これはNATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blogのAddress Dependent Mappingあたりの説明をちゃんと読めばわかると思うのですが、ポートセービングIPマスカレード(ポートの共有)が行われることとSymmetric NATであることは基本的には完全に独立です。「ポートの共有を行わないSymmetric NAT」も「ポートの共有を許容し、結果として同じ内側(Address, Port)から複数の異なる宛先への通信に同じポートが使われる可能性があるNAT」も何の矛盾もなく可能です。

唯一、確実に言えることは、「ポートの共有が行われるならEndpoint Independent Mapping(cone NAT)ではない」ということです。EIMではないからといってSymmetric NATであるとは限らないというだけの話です。

ただ、ポートの共有を行わないSymmetric NATではEIMよりもかなり多くのポートを消費してしまうので、「Symmetric NATならばポートの共有を行う実装が多い」というのは傾向としては正しいのかもしれません。これに関しては色々なSymmetric NAT実装を見てみないとわかりません。

v6プラスとかはポートが少ないからポートセービングIPマスカレードを搭載しているYAMAHAとかNECルーターを買うべき?

これも間違いです。Linuxのnetfilterのconnection trackingとNAT動作の仕組み - turgenev’s blogでも解説していますが、多くの安物のルーターに搭載されていると思われるLinux(netfilter)のNAT機能はデフォルトでポートセービングIPマスカレードを行います。TCPUDP両方で有効です。家にあるBuffaloとNEC(Aterm)の最安価格帯の機種でも、NTT製のHGWでもそうでした。CentOSでOCNバーチャルコネクト | QuintRokkこのへんでも問題なくポートが使いまわされていることが確認できます。

もちろん他の性能面で差はあれど、少なくともポートの枯渇を避けるためという理由で高級品を買う必要はありません。

念のためですがポートセービングIPマスカレードが搭載されていればv6プラスの240ポートであっても困ることはまずありません。

EIM/ADF(アドレス制限コーンNAT)はNATタイプBになる?

間違いです。Nintendo Switchでは「NATタイプ」をA-Dの4段階で判定でき、EIM/EIF(フルコーン)ならA、EIM/APDF(ポート制限コーン)ならBになるのですが、両者の間にあたるEIM/ADF(アドレス制限コーン)についてはフルコーンと同じくNATタイプAになります。よく読まれているイカスミカフェの「NAT」「NAT越え」「NATタイプ」ってなーに?や、スプラトゥーン2とNATの奥深い関係(2ページ目) | 日経クロステック(xTECH)などでは間違って記述されているため注意が必要です。おそらく、EIM/ADFの実機がほとんどなくてテストしていないのでしょう。Nintendo Switch の「NATタイプ」判定条件 #Network - Qiitaは正しいです。

実際、UDPホールパンチングの成立条件の表を見ていると、EIM/ADFではEIM/EIFと同じく任意の相手とP2Pが成立するので、タイプAと判定されるのは妥当です(厳密には毎回違うアドレスが割り当てられるような変なNATが相手だったら通信は成立しにくくなりますが、そんなものは普通はありません)。

SymmetricPort RestrictedConeRestrictedConeFull ConeFull ConeRestricted Cone×Port RestrictedCone××Symmetric

またEIFと違ってADFは既知のアドレスとの通信しか通さないのでセキュリティ的にもベターです。逆にEIM/EIF以外のNATタイプは、既知のアドレスからの通信しか内側に通さず、かつ既知のアドレスからの通信であれば(相手が適切なポートを使用すれば)内側に通ってしまうという点ですべて同じです(TCPのConnection Dependent Filteringなどは除く)。そう考えるとEIM/ADFは妥協点として一番良さそうな気がします。

ただ、実装が不十分(EIM/ADFを想定していない)だとEIM/ADFとSymmetricの間でのUDPホールパンチングが成立しない可能性も否定はできず、そこは実装ごとに調べるしかないです。

実機を触る機会がなくてPS4/PS5やXBoxでの判定がわからないので、だれか試せた人がいればコメントで教えてください。

DMZするとフルコーンNATになってNATタイプがAとか1になる?

現象としては大体そうですが厳密には違います。

DMZホスト機能とは、(ほとんど)全てのルーターのポートを番号を変えずに特定IPに転送する機能で、言い換えれば特定のIPの全ポートを開放することになります。基本的には直接インターネットに接続されたような格好になりフィルタリングの類もなくなるのでNATタイプはA(あるいはタイプ1、オープン)になることが多いでしょう。任天堂のページでも公式に案内されています。また英語圏でもiptablesでfull cone NATを作る方法などとして紹介されているのを見かけます。

ただ、少なくともnetfilterの挙動に関して言えば、絶対に固定で内側ポートと同じ番号が割り当てられるというわけではなく、他のIPと競合すれば意図したポートが割り当てられない(結果としてEIMにならない)可能性はあります。まあこれは確率的には低いのでUDPホールパンチングが成立しにくくなる心配をする必要はありません。

あとは一応、内側からの通信では全然別のポートが割り当てられる(DNATが固定なだけでSNATはランダム、とか)という動作の可能性もあり、これだとSTUNとかを使おうとするとうまくいかなくなる気がします(外部からの接続を受け入れるだけなら問題ない)。そんな実装はあまりないのかもしれませんが。

それと、DMZホストでは全てのポートが常時開放されています。EIM/EIFはあくまでマッピングが存在する間だけ「一時的にポートが開放される」動作です。ゲーム機に使うなら気にする必要はなさそうですが一応セキュリティ的にはEIM/EIFのほうが少しだけマシです。

余談ですが、DMZホスト的なことをするにしても1-1023の特権ポートを開放するのは危険なので1024-65535(あるいは可能なら32768-65535などエフェメラルポートの範囲のみ)にしておいた方が無難です(40000-65535で十分だったという情報があります)。一時期、任天堂でも間違って案内されていたようです。

これも余談ですがこの「DMZホスト機能」がある時点でそのルーターには何らかの形でポートセービングIPマスカレードが備わっていることが明らかです。さもなければ、全ポートが特定のIPに転送されている状態で別の端末がインターネット通信できるはずがありません。

あとこれも余談ですが、この「DMZホスト機能」というのは、原義の「DMZ」とは全く別物です(参考:ブロードバンドルータのDMZホスト機能 2台のルータでDMZを用いたネットワークを構築し安全に自宅サーバを公開する | 積水成淵日記natテーブルを利用したLinuxルータの作成:習うより慣れろ! iptablesテンプレート集(2)(6/6 ページ) - @ITなど)。

DS-LiteはEIMなのにポートが1024しかないから枯渇すると詰む?

EIMということは家の中の機器が何かポートを使用するごとに1024個のうち1つが消費されるので、まあまあ人数が多かったりすると実際わりと枯渇するようです。それでNATもキャリア側でやってるから手が出せなくて詰み、という主張です。

まあ普通に使っているだけならそうなりますが、むこうのNATが機能不足ならこちらで勝手にポートセービングIPマスカレードをして出口を絞ってやれば解決します。つまり適当なLinuxマシンとかYAMAHA/NECみたいなルーターを用意して、家の全部の機器をその配下にして、出口ポート数を1024に絞ってNATして、それからDS-Liteルーターに食わせれば、そもそも出口が1024個なのでキャリア側では絶対に枯渇しないというわけです。

ちなみに出口を絞ってNATするのは単に使うポートを少なくするだけなので潜在的には安物ルーターでも全然できるはずなんですが、残念ながらこれを自由に設定させてくれるような安物ルーターは無さそうです。もしあったらそれを使うといいと思います。

あとそもそもDS-LiteってプライベートIPがついたLAN内のパケットをそのままIPv6にラップして家の外に出してるので、NATしておいた方がセキュリティ的にも安心感はあるかもしれません。

ただしこれだとDS-LiteのEIM/EIFの旨味がなくなってしまうので、必要に応じて一部の通信(VPNのポートとかNintendo Switchとか)は直接DS-Liteルーターの配下につないでおくのがよさそうです。その場合ポートセービング担当のルーターのほうは500ポートだとかもうちょい絞っておいてもよさそうです。

Map-eはDMZができないからNATタイプAとか1にするのは無理?

v6プラスとかOCNバーチャルコネクトは使えるポートが制限されているからDMZが使えなくてNATタイプAにするのは絶対無理、だからフルコーンNATなDS-Liteを使うべき(あるいはPPPoEパススルーしろ)、といった感じの主張です。

これは間違いです。あくまで市販ルーターの実装がフルコーン/制限コーンNATでないというだけで、出口ポートの数が制限されていてもフルコーン/制限コーンNATはできますし、そうすればちゃんとNATタイプAになります。DMZはフルコーンNATにするための一つの方法であり、必要条件ではありません。ただしこれもやっぱりLinuxYAMAHAルーターNECの高いルーターか、そのあたりが必要です。

Linuxでフルコーン/制限コーンNATを動かす方法についてはLinuxでポートセービングIPマスカレード付きの制限コーン風NAT(EIM/ADF)を動かす - turgenev’s blogを参照してください。YAMAHAとかNECでは、ポートセービングIPマスカレードを設定で無効にするとフルコーンNATになるはずです。

TCPはホールパンチングには基本使わないしフルコーンにすると枯渇のおそれがあるので、P2P通信(ゲーム機など)のUDP通信だけフルコーンにするのがいいと思います(NETルーターでの設定例: https://twitter.com/xpzr4/status/1527318238400757761)。

とはいえ初心者にはかなり設定が難しいと思うので、ネットワークに自信がなくて家族の人数が少なくてサーバー公開も必要なくて対戦ゲームが快適にできることは重要という場合はDS-Liteを選ぶのがいいかもしれません。

RFC 4787のNAT分類ではMappingがEIM/ADM/APDM、FilteringがEIF/ADF/APDFで、3x3=9種類ある?

これどこにもちゃんと書いてないけど多分嘘だと思うんですよね…詳しくはNATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blogを見てくださいという感じなのですが、例えばマッピングがAPDMなのにフィルタリングはEIFって、少なくともポート共有が有効な場合はどう考えてもありえません(マッピングに存在しない宛先から通信が来たとして、一体どの内側ポートに転送すればいいのか?)。要するにFilteringよりもMappingのほうが細かいのはありえないということです。

結局、EIMならEIF/ADF/APDF、ADMならADF/APDF、APDMならAPDF、という計6種類になると思います。念のためですがnetfilter搭載の多くのルーターはこのどれにも入りません。

RFC 3489の分類はガバガバだ!とRFC 4787は言っていますが、結局大して分ける必要なかったんじゃん?という…

二重ルーターだとSymmetric NATになる?

これもNATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blogに書きましたが、間違いです。複数あったら最も厳しいポリシーに揃えられるというだけで、EIM/EIFを10個つなげたところで全体はEIM/EIFのままです。

二重ルーターが問題になるとよく言われているのはおそらくUPnPの関係だと思います。UPnPは自分を直接管轄している一つ外側のルーターに対してポートの開放を要求するものであり、それがグローバルIP上のポートじゃなかったらうまくいかないという話です。一応、UPnPブリッジとかいう機能があれば二重ルーターでもちゃんとUPnPできるという話もあるようです。

UPnP自体はセキュリティ的に怪しいらしいので基本的には無効にして、NAT動作を工夫するのとあとは静的ポート開放をちゃんとやるのが良いと思います。

TCPでホールパンチングするのは無理?

これもNATタイプ(ryに書いたものですが、NATタイプが同じであればUDPホールパンチングとTCPホールパンチングの難易度は変わりません。

しかし実際には、TCPに関しては、既知の相手であっても外側からの通信は受け入れないというRFC 4787でいうところのConnection Dependent Filteringの動作をするNATが多く(netfilterがそうなっている)、こうなるとホールパンチングは困難になります。

UDPホールパンチングのためにはNATはEIMでなければいけないのでポートセービングIPマスカレードは不可能?

ここまでちゃんと読めていればわかると思いますが、NATが「ほとんどの場合にEIMのように振る舞う」ためには、必ずしもNATが「厳密にEIMの定義に従っている」必要はありません。

さらに、NATタイプ(ryに書いた通り、宛先アドレスが異なる場合のみに限ってポートセービングIPマスカレードを認める場合は、フィルタリングをADFにすることができます。つまり、以下のようにすれば、EIM/ADF「的」な動作とポートセービングIPマスカレードを両立できます。

  • 通信相手のアドレスのみに関するマッピングを使用する。(ポート番号は含めない)
  •  
  • 内側のポートがインターネットの新規の相手と通信しようとした際、その内側のポートに(別の相手との通信のために)直近で割り当てられたものと同じポートを原則として割り当てる。(=EIM的な動作)
    • (その内側のポートの使用履歴が無ければ、内側のポートと同じ番号、あるいはそれが利用不可ならランダムな番号を割り当てる)
  • ただし、そのポートを通ってその相手(のアドレス)と通信している他の内側ポートがすでに存在して競合する場合は、別のランダムなポートを割り当てる
    • そのポートを通って他の相手と通信している内側ポートが存在する分には競合しないのでそのまま割り当てる(=ポートセービングIPマスカレード
  • マッピングに一致する外側からの通信はすべて内側に通す(既知のアドレスなら、未知のポートからの通信でも通る)

こうすれば、近接したタイミングで同じポートから2つの相手と通信した際には同じポートが割り当てられることが期待され(EIM)、既知のアドレスからの通信はすべて内側に通り(ADF)、かつアドレスが異なる場合はポートセービングIPマスカレードができます。

一般には同じアドレスの異なるポートに大量のコネクションを張ることはあまりない(だいたい53, 80, 443とかしか使わない)ので、ポートも条件に入れたポートセービングIPマスカレードとほぼ同等の効果が得られるはずです。

というわけでこれをLinuxで実際に動かしたという記事がLinuxでポートセービングIPマスカレード付きの制限コーン風NAT(EIM/ADF)を動かす - turgenev’s blogです。実際、手元でもホールパンチングとポートセービングIPマスカレードが両立することを確認しています。

まとめ

思った以上にいろいろな種類の誤解があることがわかります。NAT動作は原理としては単純な話ばかりなので、適当な情報を鵜呑みにせずにちゃんと整理して考えてみるのが大事だと感じます。

元をたどれば、RFC 3489やRFC 4787が網羅的でない雑な分類を提示したために、UDPホールパンチングやポートセービングIPマスカレードという実際の使い勝手に影響を与える部分に関して深く考察されることがないままCone NAT/Symmetric NATという二元論が広まってしまったことが要因にあるでしょう。実は、draft-naito-nat-port-overlapping-01という文書がこの記事で書いたような事実に触れているのですが、残念ながらこれが正式なRFCに反映されることはなかったようです。

時代はIPv6へと移行してきており、IPv4でのNATに脚光が当たることはもはやないのかもしれませんが、少しでもNATに関する正しい理解が広まることを願っています。

質問などあればお気軽にどうぞ。

Linuxのnetfilterのconnection trackingとNAT動作の仕組み

概要

LinuxではNATやファイアウォールなどのパケットの扱いを担当するカーネルの機能としてnetfilterというものが搭載されており、特にそのconnection trackingの機能を理解することがLinuxのNAT動作の理解には不可欠です。また、安価な市販ルーターの多くはLinuxを搭載しているため、netfilterを理解することは市販ルーターの動作を理解することにもつながります。

NAT動作タイプ(cone NAT, symmetric NATあるいはEIM/APDFなどの用語)については以前の記事(NATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blog)などを参照してください。

他のプロトコルに関しても参考になる部分はあるかもしれませんが、この記事では主にUDPTCPのみについて扱います。

conntrackコマンド

以下で説明するconnection trackingの様子はconntrack -Lというコマンドで閲覧できます。接続情報のタイムアウト秒数なども表示されていて大いに参考になります。

conntrackはデフォルトではインストールされていない場合もあるのでパッケージマネージャなどで入れましょう。

connection trackingのエントリ

connection trackingは一言でいえば「現在有効な接続」の一覧を通信の両端点のアドレスとポートに従って管理するものです。何かしら新規の通信が発生すると接続情報がエントリ(マッピング)として登録され、(接続形態に応じて)定められたタイムアウト時間が経過するまでそれが保持されます。

conntrack -Lコマンドの出力から1行取り出して見てみましょう。

tcp      6 118 TIME_WAIT src=192.168.1.12 dst=192.168.1.9 sport=3311 dport=5678 src=192.168.1.9 dst=192.168.1.12 sport=5678 dport=3579 [ASSURED] mark=0 use=1

これの意味は、「TCPプロトコル番号6番)通信で、タイムアウトまであと118秒で、状態はTIME_WAIT、通信を開始したのは192.168.1.12:3111で宛先は192.168.1.9:5678、相手側から見ると送信元は192.168.1.9:5678で送信先は192.168.1.12:3579、確立済みのコネクション(ASSURED)で、マークは0」という感じです。useだけはちょっと意味がよくわかりませんがiptables - "use" column in /proc/net/ip_conntrack or nf_conntrack? - Server Faultによると一種の参照カウンタ?のようです。あまり気にしなくていい気がします。

「相手側から見ると192.168.1.12:3579」なのはなぜ?というのは後で解説しますがNATが介在するとこうなります。

ここでの「マーク」は接続に対するマークであって、パケットに対するマークではありません。iptablesでいえばCONNMARKのほうであってMARKではありません。注意してください。

登録のタイミング、TCPUDPの違い

上記の通り登録のタイミングは「新規の通信が発生したとき」ですが、これはTCPUDPというプロトコルの性質によっても若干違います。

TCPでは明示的な接続の概念があり、基本的にはSYNパケットの送受信に伴ってエントリが作成されます。ただしSYNパケットが送られただけで応答がないときなどは通信が成立したとみなされないので、さっきのTIME_WAITのところはSYN_SENT、[ASSURED]のところは[UNREPLIED]などとなります。この時点ではタイムアウトも数十秒程度など短めに設定されています。ここからhandshakeが完了して通信が確立するとTIME_WAITのところがESTABLISHEDになり、[UNREPLIED]が[ASSURED]に変わって、タイムアウトも数時間程度など大幅に長くなります(もちろん設定で変えることはできます)。接続が終わると先ほどのようにTIME_WAITの状態になり、再びタイムアウトが短めに修正されます。

UDPでは接続状態がないのでもっと単純で、今までのリストに存在しない(src IP, src Port)と(dst IP, dst Port)の間で通信が発生するとエントリが作成されます。こちらも一方向にしか通信がない場合は[UNREPLIED]、双方向に通信が発生した場合は[ASSURED]と区別され、タイムアウト時間にも差があります。一方で、TIME_WAITやESTABLISHEDにあたる部分に関しての区別はないようです。

逆に言えば、新規の通信でないパケット、つまり既存のマッピングに一致する(src IP, src Port)と(dst IP, dst Port)の組み合わせを持つパケットはそのマッピングに従って転送されることになります。

だいたいここまでがconntrackの基礎です。

NATの適用

NATも、新規の通信が発生したとき、つまりconntrackのエントリ作成と同じタイミングで行われます。NATは大きく分けて送信元を書き換えるSNATと送信先を書き換えるDNATの2つがあり、SNATはPCを出て行く直前のパケット(iptablesのPOSTROUTING)、DNATはPCに入ってくる直前のパケット(iptablesのPREROUTING)及びPC内部で生成されて出て行くパケット(iptablesのOUTPUT)に適用されます。masqueradeというのはSNATの一種です。

例えばDNATの例としては192.168.1.1の3000に来たパケットを192.168.1.2(別のPC)の13000に転送するといったものが考えられます。

この場合であれば、192.168.1.1の3000に未知のホスト(例えば1.1.1.1:11111)からSYNパケットが送られてきた際には、

src=1.1.1.1 dst=192.168.1.1 sport=11111 dport=3000 src=192.168.1.2 dst=1.1.1.1 sport=13000 dport=11111 

のようなエントリが作成され、1.1.1.1:11111からすれば192.168.1.1:3000に送ったつもりのところが内側では192.168.1.2:13000から1.1.1.1:11111への通信が行われているという様子が表現されています。

エントリの競合の回避

エントリの新規作成にあたっては、「既存のエントリと競合(衝突)しないか?」ということが考慮されます。競合というのは、同じポートを同じ宛先に対して使用する二つのエントリが存在する状況です。例えば、192.168.1.2:6000が192.168.1.1:8000を使って1.1.1.1:11111と通信している状況で、192.168.1.3:7000も192.168.1.1:8000を使って1.1.1.1:11111と通信する、というようなエントリを作ってしまうと、1.1.1.1:11111からパケットが送信されてきたときにそれを192.168.1.2:6000と192.168.1.3:7000のどちらに送ればいいのかわからなくなってしまいます。

netfilterは、このように転送先が一意に決定できなくなるような状況が発生するのを避けます。例えばNAT先のポートとして8000-8010が指定されていたときにこの状況になったら、8000番以外のポートが選ばれます。もし8000番しか候補がなければ、ポートの割り当ては失敗し、通信が成立しなくなります。これはTCPでもUDPでも同じです。

逆に、通信先が違う場合であれば、同じ8000番を使う複数のマッピングが同時に存在していても問題はありません。この辺に関しては前回記事でも「ポートの共有」「ポートセービングIPマスカレード」のようなキーワードで説明しました。

この動作は、ポートが他のアプリケーションによって使用されている(EADDRINUSE)というエラーとは全く無関係なので注意してください。

ポート番号の選択とNAT動作タイプ

基本的にはDNATもSNATもどちらも、可能な限り元のポート番号を維持しようとします。つまり、変換元のポート番号と同じ番号が利用可能な場合は、上記の競合が起こらない限りは、それが割り当てられます。こちらのツイートソースコードの該当箇所っぽいの(find_appropriate_src関数)が載っています。 

同じ番号が選ばれる状況だとnetfilterのNAT動作はcone NAT、つまりEndpoint Independent Mapping (EIM)と一致します。一方で、競合が起これば一つの変換元ポートに対して複数の変換先ポートが存在することになるので、EIMの厳密な定義には当てはまりません。

そうでない(利用不可能or競合)場合は、ランダムなものが選ばれるようです。ただ、その変換元のポートから以前別のどこかに接続したときに選ばれた変換先ポートの履歴が残っていれば、それと同じものが選ばれることもあります(詳細はわからないが、そうならない場合もある)。この場合もやはりEIM的な挙動ということになります。

また、マッピングが宛先の(Address, Port)に従って管理されている関係上、未知の(Address, Port)から来た通信が内側に通ることはないので、フィルタリング動作としてはAddress and Port Dependent Filteringになります。ただしTCPに関しては、既知の(Address, Port)からだったとしても外部から来たものは別の接続と見なされるため、内側に通されません。従ってConnection Dependent Filtering(この用語はあまり使われませんが)になります。

結局、UDPに関しては、全体としてポート制限コーンNATPort Restricted Cone NAT)のように動作します。

ちなみに、SNATなどのオプションでrandomなどを指定すれば最初から完全にランダムなものが選ばれます。これはsymmetric NATやEndpoint Dependent MappingあるいはConnection Dependent Mappingといった動作に近くなりますが、あくまでランダムというだけで、必ず別のポートを割り当てるというわけでもないため、厳密にはそれらの定義には当てはまりません。

ファイアウォールによるフィルタリング動作

conntrackはNATを伴わないファイアウォールとしても機能します。典型的にはufw enableで有効になるようなもののことです。これも同じように、既存の接続と一致しないものは通されないので、UDPならAddress and Port Dependent Filtering、TCPならConnection Dependent Filteringの動作になります。

NAT自体が起こらないのでMappingも何もないですが、しいて言えばEIMと考えればいいです。

ポート範囲をずらす形でのNAT

上記の動作により、例えば192.168.1.1の10000-20000と192.168.1.2の10000-20000というように全く同じポート番号範囲での変換であれば同じ番号の間で1対1に変換が行われます。一方で、192.168.1.1の10000-20000と192.168.1.2の30000-40000など、個数は一致していたとしても範囲が異なっていると、変換先ポート番号は予測不能になります。

しかし、nat - Is it possible to map 1:1 port range iptable DNAT rules - Stack Overflowによると、比較的最近(2018年、Linux 4.19あたり)から、ポート範囲をずらす形での1対1での転送ができるようになったようです。

どのように指定するかというと、

iptables -t nat -A PREROUTING -d 192.168.1.1 -j DNAT --dport 10000:19999 -j DNAT --to-destination 192.168.1.1:20000-29999/5000

のようにスラッシュでオフセットを記述します。すると、変換先ポート範囲を0-indexedな配列として見たときに「変換前のポート番号からオフセットを引いたものを変換先ポート範囲の個数で割った余り」をindexとしたポート番号を(優先して)選んでくれるということのようです。この例であれば変換前が12000とするとそこから5000を引いて10000(変換先ポート範囲の個数)で割って0あまり7000なので(0-indexedで7000ということは7001番目であるところの)27000が選ばれるというわけです。

これはmanとか見ても書いていなくて、こことかここにあるコミットログみたいなやつとかソースコードを見るくらいしか手がかりがありませんが、手元ではちゃんと動きました。

ただ、少なくともiptablesではDNATにしか使えないようです。SNATでは同じ構文を使ってもエラーは出ませんが挙動はスラッシュ以降がないときと変わらないように見えます。

nftablesではSNATでもDNATでも使えません(ただしiptablesコマンドから設定するとなぜかちゃんと動く)。Linux Netfilter Devel — Re: [PATCH nftables 0/8] Support for shifted port-ranges in NATのように1年前くらいにパッチを送っている人がいることはわかりますが、大元のソースを見る限りまだ取り入れられていないようです。てかパッチの見方わからん…

netfilterの自体はSNATにも対応していそうに見えるので、カーネルモジュールとか書けば理論上はいける気がします。あるいはLinuxで制限コーンNAT(EIM/ADF)を動かす - turgenev’s blogみたいな自前のNATを使うのもありですね。

もちろん、別にずらした形での転送ではなくても構わないというケースも実際には多そうです。

「ポートの開放」の意味、市販ルーターの挙動

多くの市販ルーターでは「ポート変換」「ポート開放」「静的NAPT」「静的IPマスカレード」といった名前で、ルーターのポートにアクセスした際に内部の機器の別のポートにアクセスが通るように設定することができます。これがnetfilterにおけるどのような設定に対応するかを説明します。

ルーターとして稼働しているlinuxのnetfilterにおいて「ポートの開放」をするといったときに含意される可能性のある操作は概ね以下の3つだと思います。

Destination NAT(DNAT)により、宛先を変更する…たとえば、iptables -t nat -A PREROUTING -p tcp -d 1.1.1.1 --dport 10000:20000 -j DNAT --to-destination 192.168.1.1:10000-20000

②Source NAT(SNAT)により、送信元を変更する…たとえば、iptables -t nat -A POSTROUTING -p tcp -s 192.168.1.1 --sport 10000:20000 -j SNAT --to-source 1.1.1.1:10000-20000

③上記のパケットを許可…たとえば、iptables -A FORWARD -p tcp -d 192.168.1.1 --dport 10000:20000 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT(あるいは、ufw route allow 10000:20000/tcp

このうち①③は必須で、①がないとアクセスの転送が行われず、③がないとパケットがルーターの手前で止まってしまいます。

一方で、②は設定しなくても外側からのアクセスは内側に転送されるため通常のサーバー用途であれば問題なく動作します。手元のルーター(後述)では、NTTのHGWとBuffaloのものではSNATも適用されましたが、Atermのものは適用されていませんでした。

例1: NATよりも既存エントリが優先される

netfilterの挙動に慣れるため、例を見てみましょう。

例えば今、1.1.1.1:10000から192.168.1.1:5000にDNATが設定されているとします。このとき、1.1.1.1:10000を使用(バインド)して2.2.2.2:20000に向かってUDPパケットを送信したとします。するとこの2つの間でのエントリが作成されます。このエントリが存在する間に2.2.2.2:20000から1.1.1.1:10000に向かってUDPパケットが送信されると、(仮にアプリケーション上は最初のパケットと無関係だったとしても)それはDNATされずに1.1.1.1:10000にそのまま到達します。他の場所(例えば2.2.2.2:20001)から来たパケットはDNATされます。

SNATでも同様です。例えば1.1.1.1:10000から1.1.1.1:5000にSNATが設定されていたとしても、2.2.2.2:20000から1.1.1.1:10000に向かってUDPパケットが来たら、以後1.1.1.1:10000から2.2.2.2:20000に向かって出て行くパケットはSNATされずそのまま1.1.1.1:10000から出て行きます。

このように、通信相手との間に既にエントリがある場合はNATが使用されなくなることがあります。

TCPでは接続状態があるのでもう少し安定した挙動になります。同様にDNAT設定した上で1.1.1.1:10000から2.2.2.2:20000へ接続したとして、この接続が閉じた後のTIME_WAITの状態のときは、2.2.2.2:20000から1.1.1.1:10000に来た接続は問題なく192.168.1.1:5000にDNATされます。

ESTABLISHEDの状況でもどうなるのか調べたかったのですが、ここでいう2.2.2.2:20000側の動作を再現する(接続中のTCPコネクションと同じポートからその相手に対して全く別の接続要求を送る)のが普通のやり方ではできないのでやめました。明らかに従来の接続と別のパケットだと判別できるので多分DNATされると思いますが、そもそもINVALID扱いで拒否されるのかもしれません。このへんあまり理解しておらず…

例2: 外部からのパケットによるマッピングとの衝突

UDP通信では、未知のhost:portから自分のポートにパケットが送信されてきたときにもエントリが作成されます。さらに、この状態で内側からそのエンドポイントへの通信を開始しようとすると、競合を避けるためこのポートは使えません。

例えば、PC2の30000番から1.1.1.1:40000に対してUDPパケットを送信すると、40000番をリッスンしているアプリケーションが無かった(この場合ICMPのunreachableが返ることもある)としても、[UNREPLIED]という扱いで1.1.1.1:40000に関するエントリが登録されます。このときにPC1をルーターとして使用している別の端末のポート(例えば192.168.1.1:40000)がPC2の30000番にアクセスしようとすると、PC1の40000番は既に1.1.1.1:40000によって使用されているため競合し、40000番以外のポートが使用されます。もちろん、1.1.1.1:40000自体からパケットを送り返す分には(この既存のエントリに従って)そのまま40000番から出ていきます。

これは例えばLinuxルーターとして使用してUDPホールパンチングを行う際に問題になります。STUNサーバーとの通信時に割り当てられたポートを相手に通知しても、先にそのポートに相手からの通信が到達してしまうと、内側の端末がそのポートを通じて相手と通信することができなくなるからです。実際、判定アプリのリモートアクセスチェックツール|DiXiM.NETなどでは、filtering判定を先に行うため、意図した結果が出なくなります。

解決策としては、既存の接続と関連しない外部からのパケットをACCEPTしないDROPREJECTする)ことが有効です。端的に言えばファイアウォールufwなど)を有効にすると解決します。これにより、パケットがポート(上記の例なら1.1.1.1:40000)に到達する前にドロップされるため、エントリが作成されなくなります。

また、PC自体に対してマッピングが作成されてしまうことが問題なので、内側の端末に対して明示的にDNATを指定してやれば、そちらに向けてマッピングが作成されるようになり、内側からの通信がそのマッピングを利用できるようになります。もちろん、DNATを設定できるのは(ポートごとに)1つの宛先だけです。

この問題は以下のツイートで知りました。

分かってしまえば簡単ですが、知らないと結構ハマりそうです。

TCPでは、外部からのパケット(SYN)が来たとしてもこちらがSYN/ACKで応答しなければ接続自体が開始しないのでエントリが作られることもありません。

Linux(netfilter)搭載の市販ルーターでは原則としてここでのファイアウォールにあたる機能が有効なはずなので気にする必要はあまりありません。

暗黙のSNATルール

あまり知られていないと思いますが、明示的なSNATの対象になっていない新規接続でエントリの競合が発生した際にも、同じIPの間で暗黙的にSNATが行われます。感覚的には、iptables -t nat -A POSTROUTING -d 192.168.1.1 -j SNAT --to-source 192.168.1.1のようなルールが全てのIPについて設定されているような感じです。netfilterのドキュメントにもちゃんと記載されています(https://www.netfilter.org/documentation/HOWTO/NAT-HOWTO-6.html)。

例えば192.168.1.1:3000から192.168.1.1:4000にSNATするルールだけを追加し、192.168.1.1:3000から2.2.2.2:10000に通信したとします。このときに192.168.1.1:4000からも2.2.2.2:10000に通信しようとすると、これは特にSNATの対象になっていないにもかかわらず、(ポートの割り当てができずに通信が成立しないのではなく)別のランダムなポートにSNATされた上で外部に送信されます。

TCPのTIME_WAITの再利用と市販ルーターの挙動

この辺はどこまでnetfilterの話でどこまでカーネル本体の話なのかよくわからないところもあるのですが、一応わかるところまで書きます。

再び1.1.1.1でnetfilterが動いているとします。2.2.2.2:20000から1.1.1.1:10000にむけてTCP接続が成立し、それが切断されると、1.1.1.1:10000側には2.2.2.2:20000との間でTIME_WAITのエントリが残っています。

このときに1.1.1.1:10000から2.2.2.2:20000に向けて(つまりさっきと逆方向)TCP接続を開始すると、これは問題なく成立します。conntrack -Lで見てみると、先ほどのTIME_WAITのエントリがそのまま上書きされたような形になっていることがわかります。(一方で他のポートが1.1.1.1:10000を通って2.2.2.2:20000と通信することはできません。)

これはSNAT/DNATが介在していても変わりません。つまり1.1.1.1:10000と192.168.1.1:10000の間でDNAT/SNATが設定されていたとして同様に2.2.2.2:20000からの接続が切断された後に192.168.1.1:10000から2.2.2.2:20000へと接続した場合は問題なく成立します。

しかし、手元の(netfilterが稼働していると思われる)2つのルーター(下記の1と2)ではこのケースで通信が成立しませんでした。エントリが競合するためポートが割り当てられず通信が始まらないという感じの動作です。

原因はよくわかりません。

手元のルーターのNAT挙動

stunclientなどを使用してnetfilterが内部で動いている可能性がありそうな3台のルーターの動作を実際に細かく調べてみました。それぞれの詳細は以下の通りです。

ルーター1…NTTのホームゲートウェイであるRX-600KIで、OCNバーチャルコネクトによるMap-e接続。(多分Linux搭載?)

ルーター2…BuffaloのWSR-1166DHPL2で、PPPoE接続。Linux搭載(搭載しているOSは? | バッファロー)。

ルーター3…NECAterm WX1500HPで、ローカルルーター(WAN側IPをDHCPで取得)として使用。

これらは、以下のように動作しました。

  • デフォルトの割り当て先は、内側ポートと同じ番号が利用可能ならそれを使う
  • 同じ番号が(競合、あるいはMap-eで利用可能な1008個やiptablesのto-sourceの範囲に含まれていないために)使えなければ代替のポートを使用する
  • ルーター1ではポート番号を1008で割ったもの(具体的には、余りがnなら利用可能なポートのうちn番目(ただし最初が0番目とする)のもの)が最優先の代替として使用され、それも空いていなければ連番で増やしていって最初に空いていたものを使用する
  • ルーター2の代替ポートは規則性が不明だが、5000以下など、番号の小さいものが使われることが多い
  • ルーター3では1000番台などかなり小さいものが代替として使われることが多い
  • ルーター2では、競合が起こっていないはずの状況でも代替ポートに割り当てられることも結構ある気がする
  • 特にUDPでは、競合が起こって代替ポートに割り当てられた場合は、その後の同じ内側ポートから他の宛先への通信もそちらに割り当てられるようになる(ことが多い)
  • UDPでは既に送信履歴がある(Address, Port)からでないと通信を通さない(APDF)。TCPでは送信履歴にかかわらず外側からの新規通信(内側からの通信への応答ではないもの)は通さない(Connection Dependent Filtering)
  • TCPにおいては、同じ内側(Address, Port)から外側(Address, Port)へと通信してその終了直後に再び同じ宛先で通信を開始した場合などに、別のポートが使用される場合もある(Connection-Dependent Mapping的な挙動)。特にnetfilterで55500-55599以外のポートから発信した場合に顕著に見られたほか、ルーター2でも発生した。
  • 追記: ELECOMのWRC-1167FS-BをAtermと同様のローカルルーターモードで使用すると、挙動はほぼAtermのものと同等だった。
  • 追記2: BuffaloとAtermをそれぞれOCNバーチャルコネクトで使用した。Atermは、内側ポートに近いポート範囲を選んでから16で割った余りで決めているような感じ。Buffaloはランダムっぽい。

特にルーター1のポート割り当て動作(1008で割った余りとか連番で増やすとか)はiptablesで設定するのは無理だと思うので、したがって、netfilterを直接いじっているか、あるいはそもそもnetfilterではないという可能性もあるかもしれません。(しかし競合がない限り同じポートを使用するという基本的な挙動はnetfilterに類似しているように見えますが…)

まとめ

このconnection trackingの部分がLinuxルーターとしての性質のほとんどを決定しているにもかかわらず意外と情報の少ないところかと思うので、記事にまとめられてよかったかなと思います。質問などあればコメントにどうぞ。

nftables/iptables/ufwの使い分け・ベストプラクティス

概要

Linuxファイアウォール(パケットフィルタ)は、内部的にはLinuxカーネルのnetfilterという機能が担っています。iptablesやnftablesはnetfilterを操作するフロントエンドで、ufwiptablesやnftableを操作するさらに上位のフロントエンドです。また、筆者は使ったことがありませんがfirewalldというのもufwと同じくiptablesやnftablesを操作する上位のフロントエンドのようです。

Linuxファイアウォールを運用するにあたってこのどれを使うべきかが問題になると思うのですが、うまくいいとこどりをして良い感じに運用できそうな方法を見つけたので紹介します。なお、主に個人でのサーバー運営程度を想定しており、業務で使うような大規模システムだったらもっとちゃんとしたやり方が必要かと思います。

iptablesとnftablesの基本

この2つの違いとしては、簡単にいうとnftablesはiptablesの後継で、nftablesが本格的に導入されているUbuntu 22.04などではiptablesコマンドもnftablesを操作するフロントエンドとして動作するようになっています。

iptablesiptablesコマンド、nftablesはnftコマンドを使用します。(処理の共通化など細かい使用感を除けば)パケット処理に関してできることは大体同じですが、構文の見た目は結構違います。nftables自体はそこまで最近導入されたものではないと思うのですが、依然としてiptablesコマンドがほぼ変わらない動作で使用可能なこともあってか、使い方に関する情報が比較的少ない印象です。

例えば以下が参考になります。

nftablesのテーブル

nftablesとiptablesの(特にこの記事において)重要な違いは、ユーザーが独自のテーブルを作成できるということです。

iptablesではnatやmangleのように役割ごとに決まった名前のテーブルを使う必要がありました。一方でnftablesではテーブルは好きな名前で作成し、その中のチェインにおいてnatやmangleなどのタイプを指定するやり方に変わりました。

nftables環境でiptablesコマンドを使用した際には、natやmangleといった従来の固定的なテーブル名に由来するテーブルだけが読み書きされます。つまり、iptablesコマンドを使って独自のテーブルの内容を管理することはできず、nftコマンドを使う必要があります。ufwは(nftables環境でも)依然としてiptablesで動いていますが、firewalldはnftablesを使って独自テーブルを管理しているので、nftを使わないと見られないということに注意が必要です。

nftablesのルールの管理

nftablesでは、nftコマンドを用いてルールをいちいち追加・削除してもいいですが、設定ファイルを用いてルールを管理することもできます。

nftablesというサービスがあって、これを有効化(sudo systemctl enable nftables)すると、/etc/nftables.confというファイル(ディストリビューションによって異なる可能性あり)を起動時に読み込んでくれます(デーモンではなくOneShotタイプ)。

設定ファイルの読み込みの際にはnft -f xxx.confとfオプションを使用します。nftablesが動くのは起動時だけなので、起動後であればルールを反映させたいタイミングでこのコマンドを実行します。

設定ファイルでは、以下のように最初でflush rulesetとして既存のルールを全て削除してから新たにルールを追加していく方法がよく使われます。

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
   ......(省略)
}

そのまま追加するとnft -fでの読み込みのたびにルールが複製されてしまいますが、flushを使用するとこれを防げます。

flush rulesetの問題点

しかし、このflush rulesetには問題があり、文字通り全ての設定を消去してしまうので、ufwやfirewalldやその他VPNなど(例: tailscale)が追加したルールまで全て消えてしまうのです。実際、ufw稼働中にnft -f xxx.confと(ufw関連のルールがない)confファイルを読み込むと、ufwは勝手にdisabledの状態になってしまいます。tailscaleも、うまく通信が通らない状態になってしまいます。

全てをnftablesのファイルで管理するようにすればこの問題はなくなりますが、ufwの簡潔なコマンドの恩恵を受けられなくなり、tailscaleは二重管理のような状態にならざるを得ません。

table単位でのflush

そこで、この記事で解決策として提案したいのが、tableの単位でルールを管理してflushを行うことです。

ntfablesでは、flush rulesetのように全てを消去するのではなく、flush table ip my_ip_tableのようにテーブルを指定してその中のルールを全て消去するということが可能です。これならmy_ip_tableの外側にあるルールとは一切干渉しません。

具体的には、以下のような記述を基本単位としてルールを管理するのが良さそうです。

table ip my_ip_table {
}
flush table ip my_ip_table
table ip my_ip_table {
......(独自のチェイン・ルール)
}

このようにすれば、ファイルを読み込み時にmy_ip_tableの内容だけファイルに書かれた内容に変更できます。flushの前に最初で一旦空のmy_ip_tableを作成しているのは、my_ip_tableが存在しない状態だとflushコマンドが失敗してしまうからです。(既存のテーブルを改めて作成するのはエラーにはなりません)

なお、前述のようにnatやmangleなどの役割に応じてテーブルを分ける必要はありませんが、arpとipのテーブルなどは別々に書く必要があるので、その個数の分だけ上記の基本単位を書くことになります。

この内容をどこに書けばいいかというと、もちろん既存の/etc/nftables.confに書いてもいいですが、/etc/nftablesにはflush ruleset文を始めとしてちょっとした内容が書いてあるのでそれを変えたくないという場合は、

include "/etc/nftables.custom.conf"

のように独自のファイルをインクルードしておいて、そのcustomファイルのほうに上記の基本単位を記述していく(ルールの更新の際はcustomのほうだけをnft -fで読み込む)という方法も考えられます。

nftables.custom.confに誤りがあると、起動時にnftablesサービス自体が失敗し、ufwなども連鎖的にうまく起動できなくなってしまうので注意してください。(セキュリティ的に必須でない内容は事後的に読み込むのもアリかも?)

ufw/iptablesとの使い分け

これで、ufwVPNの動作を阻害することなく任意のタイミングで独自ルールを適用できるようになりました。

ufwは、ポートやインターフェイスなど条件を指定して許可/不許可の設定をする分には見通しがよく便利で、gufwというGUIツールまであるのですが、NATなどの(必ずしも「ファイアウォール」とあまり関係のない)機能を扱おうとすると設定ファイルの編集が必要になります。

そこで、ファイアウォール的な部分だけはufwで管理して、NATなどの細かい設定は前述の独自のユーザーテーブルを用いて管理するという使い分け方がいいのではないかと思います。ufwとnftablesのどちらか一方だけを選ぶ必要はありません。

ところで、結局nftablesとufwの使い分けの話ばっかりしてしまいましたが、(nftables環境の)iptablesにも使い道はあります。

nftコマンドの大きな欠点として、追加したルールの削除が非常に面倒というのがあります。追加のしかたは大体iptablesと同じなのですが、削除はルールの内容を指定して検索することはできず、ルールの番号(handle)を取得してそれを指定して削除しなければいけません。

iptablesであれば、追加の際の-A(または-I)を-Dに変えるだけで対応するルールを見つけ出して削除してくれます。構文の見やすさ、ネット上の情報の多さなどからいっても、動作テスト時にルール単位で追加・削除をする分にはiptablesコマンドのほうが圧倒的に便利です。

従って、試験的なルールの追加・削除はiptablesで行い、確定したらそのルールをnft list rulesetで表示してnftablesでの書き方を学び、それを先ほどのnftables.custom.confに転記し、iptablesで設定したものは削除する、といった使い方をすると結構やりやすいです。

nftables未導入環境やfirewalldでの方法

この記事ではnftables環境を前提にしていましたが、iptables環境でも似たようなことはできます。

iptablesでは残念ながら独自のテーブルまでは作れませんが、独自のチェインは作れて、またチェイン単位でのflushもできます。また、iptables-restoreを使えばアトミックにルールを置き換えてくれます。従って、まず自分用のチェインをそれぞれのテーブルに登録しておいてから、好きなタイミングでチェイン内のflushとルール追加を行うことができます。(空行がないとエラーになったりするので気をつけてください: firewall - Error applying iptables rules using iptables-restore - Server Fault

ただしiptables-restoreのファイル内でincludeを行うことができないのと、チェインの追加・削除はそれぞれチェインが存在しない状態・する状態で実行しないとiptables-restore全体の実行が失敗する(何も起こらない)のでさっきのnftでのmy_ip_tableの追加のような「既に存在するならそのまま、存在しないなら作成」ができません。

従って、起動時に一度のみ実行されるファイルと起動後の任意のタイミングで実行するファイルに分けて、独自チェインを追加する部分は前者、チェイン内を編集する部分は後者に書いておく必要があります。多分。

あと、具体的には触れませんが、firewalldについてもufw同様にiptables/nftablesとの使い分けができます。

アトミックなルールの置き換え

この記事ではルールを一旦削除して追加するということを行っていますが、このときに、削除されている一瞬の間にパケットが侵入してこないか?ということが不安になるかと思います。

実際には、nftablesやiptablesでは、ルールの変更をアトミック(原子的)に実行することができます。簡単にいえば、全ての変更が一気に適用されるという意味で、設定ファイルなどを読み込んだ際に「既存のルールがある状態」か「既存のルールがなくなって新規ルールが導入された状態」のどちらか一方でのみパケットを処理するため、隙が生じないということです。

nft -fでの読み込みや、iptablesならiptables-restoreというコマンドがこのアトミックな動作をします。

【v6プラス/OCNバーチャルコネクトでも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とか)も参照してください。

【追記】Rustへの移植

GitHub - ge9/rust-tun-nat: NAT with a TUN device written in Rust

パフォーマンスに定評があるRustに移植してみました。ついでにTCP向けにSPI(Establishedかそうでないかでタイムアウトを変更する)の機能も追加してみました。もしよければ使ってみてください。セットアップ方法はこの記事に書いた内容の通りです。

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の外向き通信をLinuxで直接接続している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をソースとして(まずLinuxからratへ送られ、その結果として)ratからLinuxへ送信されていたためでした。自分自身を送信元として外から来たパケットというのは普通に考えれば怪しいのでLinuxは破棄してしまいます。ちなみに、これはあくまでLinuxで直接PPPoE接続をしていたのが原因であり、Linuxに直接接続している以外の経路(インターネット上)のMTUが小さかった場合はちゃんとICMP unreachableが届くはずです。

さらに調べていくと、どうやら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上限です)。

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

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になっていると自負しています。動かないとかあればコメントにお願いします。

NATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN

概要

この記事では、NAT(NAPT)を行う機器の動作タイプの分類、およびそれと密接に関連する話題として、ポートの枯渇を防止するためのいわゆる「ポートセービングIPマスカレード」の手法とUDPホールパンチングについて解説します。

NAT(NAPT)(あるいはIPマスカレード)の概念については、ここでは基礎的な解説はしないので他サイト等を参考にしてください。基本的には、「ルーターだけがインターネットと直接接続し、ルーターの内側にある各機器を代表して通信を行う」「そのためには内側の各機器のアドレス/ポートとルーター自身のアドレス/ポートとの間で書き換えが必要」というイメージがあれば問題ありません。

この記事で扱うようなNATの動作は基本的にポートに関連するものであり、ポートを使用するプロトコルTCPUDP)以外にはあまり影響はありません。

RFC 4787による分類

NATタイプの分類にはいくつかの方式がありますが、この記事では現代の標準的な分類方法を示しているRFC 4787という文書に従います。

RFC 4787では、NAT機器の動作における「Mapping」「Filtering」という要素がそれぞれ「Endpoint Independent」「Address Dependent」「Address and Port Dependent」のどれに分類されるか、という基準でNAT機器を分類します。基本的にはMappingのほうが重要で、Mappingがどれであるかによって大きく3種類、その中でFilteringによる違いもある、という感じになります。

まずはMappingについて見ていきます。

Endpoint Independent Mapping

例えば、ルーターの内側に192.168.1.1と192.168.1.2という2台のPCがあって、ルーターグローバルIPは1.1.1.1としましょう。これらのPCの様々なポート、つまり192.168.1.1:40000とか192.168.1.2:50000とかからインターネットの様々なサイト(google.comとかyahoo.comとか)との間で通信が行われる状況をイメージしてください。

このとき、「NATの内側の機器の(Address, Port)の組」によってのみルーターの使用ポートを変化させるというのがEndpoint IndependentなMappingです。Endpointとはインターネット側の通信相手(行き先)のことです。つまり、LAN側が192.168.1.1:40000であれば、相手がgoogle.comだろうとyahoo.comだろうと関係なく常に1.1.1.1:44444を使用する、というような動作をするのがEndpoint Independent Mappingです。

逆に、LAN側が192.168.1.1:50000とか192.168.1.2:40000とかであれば、それぞれまた別のポート(たとえばそれぞれ44445番と44446番とか)を使用することになります。なぜなら、例えば192.168.1.1:40000と192.168.1.1:50000にどちらも44444番を割り当ててしまうと、例えばgoogle.comから1.1.1.1:44444に何らかの通信(内側からの通信への応答含む)が来たとなった場合に192.168.1.1:40000と192.168.1.1:50000のどちらに転送すればいいかわからないからです。外側から内側への通信の転送先が一意に定まるためには何が必要かを考えるのは以降でも重要なポイントです(外側のアドレスはNATによる書き換えが起こらないので気にする必要はありません)。というわけで結局、Endpoint Independent Mappingでは「NATの内側の機器の(Address, Port)の組」とルーターの使用ポートが(多対一ではなく)一対一に対応するということになります。

"Endpoint Independent Mapping"だと長いので、"EIM"のように略されることもあります。これは他のものについても同じです(APDF=Address and Port Dependent Filteringなど)。EIM-NATやEIM NATなどと呼ぶこともあります。このような呼び方は英語圏でも通じるようです。

RFC 4787より古いRFC 3489という文書では、EIM NATのことをcone NATと呼んでおり、この呼び方も(RFC 4787では非推奨とされているものの)現在でも広く使われています。coneというのは円錐という意味で(トウモロコシ=cornではない)、ルーターの一つのポートから全てのEndpointへと放射状にルートが伸びている様子を円錐に見立てたものです。

Address Dependent Mapping

ADMでは、ルーターのポートを変える基準に「EndpointのAddress」が加わります。つまり、「NATの内側の機器の(Address, Port)の組」および相手のアドレスによってルーターの使用ポートを変化させる、ということです。例えば、192.168.1.1:40000とgoogle.comの間での通信には(google.comのポートが何であっても)44444番を使用するが、192.168.1.1:40000とyahoo.comの間での通信には別のポート、例えば44445番を使用する、といった形になります。

これについても先ほどと同様に外側から内側への転送先が一意に定まるための条件を考えてみます。ADMではEIMと違って「ルーターの各ポートに関して、内側のそれぞれの(Address, Port)がどの相手と通信するためにそれを使用しているのかを特定できる」という特徴があります。つまりは「外側アドレスが異なっていれば、内側にある複数の(Address, Port)がルーターのポートを共有しても問題はない」ということになります。若干わかりづらいですが、ここは後述の「ポートセービングIPマスカレード」を理解するにあたって重要な部分です。

例えば、192.168.1.1:40000がgoogle.comと通信するために1.1.1.1:44444を割り当てたとしたら、44444番を経由して192.168.1.1:40000と通信している相手はgoogle.comだけです。だったら、この44444番ポートを「192.168.1.1:50000yahoo.comの通信」にも使う、となったとしても、google.comから44444番に来た通信は192.168.1.1:40000、yahoo.comから44444番に来た通信は192.168.1.1:50000、という風に一意に転送先を決めることができます。

というか、「転送先が一意に定まるか」を基準に考えると、「内側の(Address Port)が同じでも行き先のアドレスが違えば割り当てポートも変える」という条件は別に必要ではなく、「そのポートを使って同じ行き先と通信している他の(Address, Port)がまだ存在しないようなポートを使用する」というルールだけあればいいはずです。つまり、ポートごとに、「そのポートを使用する【内側機器の(Address, Port)、行き先のAddress】の組のリスト」を持っておいて、そのリスト内で「行き先のAddress」が一意になるようにしておけば、転送先は一意に定まるはずです。例えば、44444番ポートに関して、「192.168.1.1:40000google.comとの通信」「192.168.1.1:50000yahoo.comとの通信」「192.168.1.1:40000microsoft.comとの通信」に使用する、というようなリストがあったとすれば、「192.168.1.1:40000」がgoogle.comとの通信にもyahoo.comとの通信にも44444番が使用されることになりますが、行き先アドレスには重複がないので外側からの通信を一意に転送できます。このやり方はEIMでもADMでも(もちろんAPDMでも)ありません。(ただ、ADMの定義に当てはまらないというだけで、「内側ポートと行き先アドレスに従ったマッピングでNATが管理されている」ということは言えます)

念のためですが、RFC 4787には、「(内側の(Address, Port)と)行き先アドレスが同じ場合に、またそのときに限り、同じポートを使用する」ということがはっきり書いてあります。しかし実際には、この「そのときに限り」は「転送先を一意に定める」ためには必要でもなければ(上記の通り)十分でもない(そもそも内側の(Address, Port)が異なる場合への言及がないから)、ということになります。

ちなみに、ADMの動作をする機器は現実にはあまりないようです。

Address and Port Dependent Mapping

最後に、Address and Port DependentなMappingでは、ポートを変える基準としてEndpointのAddressだけではなくPortも使用します。つまり、192.168.1.1:40000とgoogle.com:80の間での通信には44444番を使用するが、192.168.1.1:40000とgoogle.com:8080の間での通信には44445番を使用する、といった形になります。これについても先ほどと同様に、「行き先アドレスとポート(の組)が異なっていれば、内側にある複数の(Address, Port)がルーターのポートを共有しても問題はない」ということになります。例えば、「192.168.1.1:40000からgoogle.com:80への通信」と「192.168.1.1:50000からgoogle.com:8080への通信」にどちらも44444番を使用したとしても、80番と8080番のどちらから来たかによって40000と50000を決めることができるということです。通信経路を一意に決められるかだけを考えるなら別に行き先が違うからといって別のポートを使う必要は無いというのも先ほどと同じです。

APDM NATは、RFC 3489ではSymmetric NATと呼ばれていました。実はADM NATもSymmetric NATに含まれるようですが、現実に例が少ないようなので気にしなくていいでしょう。

この"symmetric"(対称的)というのは、おそらくルーターのポートを中心として内側と外側が対称に見えるかということだと思います。つまり、cone NATではルーターの1つのポートが内側に関しては1つのポートだけ、外側に関しては全ての通信先とつながっている状態でしたが、symmetric NATでは、内側のポート1つにつき1つ(つまり同数)の通信先とつながっている状態になります。

Connection Dependent Mapping

ポートを使用する主なプロトコルUDPTCPですが、接続の概念が無く生のIP通信に近いUDPに比べると、TCPでは接続の開始・終了の手続きが明確に定まっているので、これに基づいたMappingを考えることもできます。TCPに関するNATの挙動について記述されているRFC 5382 - NAT Behavioral Requirements for TCP 日本語訳によれば、同じ内側(Address, Port)からの発信であっても、新規の接続であれば別の外側ポートを割り当てるNATがあり、これをConnection Dependent Mappingと呼ぶようです。

これはSymmetric NATよりもさらに細かいMappingということになりますが、現実的にはSymmetric NATとしてまとめて扱っておけば問題ないでしょう。

Filteringの違い - EIM NATの場合

次にFilteringについて説明します。

例として、EIM NATの場合について考えてみます。EIMでは内側の機器の(Address, Port)とルーターの使用ポートが1対1に対応します。いま、例えば、192.168.1.1:40000がgoogle.comにアクセスしようとしたため、ルーターが「192.168.1.1:40000には44444番を使う」というようにマッピングしたとしましょう。この状態では、もちろんgoogle.comから1.1.1.1:44444への正当な通信(応答)は通ります。しかし、例えばyahoo.comのような別のところから通信が来たらどうでしょうか?

これを決めるのがFilteringです。Endpoint Independent Filtering (EIF)は、有効な(既にマッピングで使われている)ポートに来た通信はどんなものであっても全て通します。つまり上記のyahoo.comの通信も192.168.1.1:40000に転送されます(ただしもちろん192.168.1.1:40000を使っているアプリケーションがそれを無視・拒絶する可能性はある)。これに対して、Address Dependent Filtering (ADF)は、google.comからの通信のみを内側に通します。さらに、Address and Port Dependent Filtering (APDF)は、通信先のポートも条件に加えます。つまり、内側からgoogle.com:80へと通信を行っていた場合でも、google.com:8080から来た通信は内側には通しません。

EIM NATはcone NATと呼ばれると前述しましたが、EIM/EIFなNATはfull cone NAT(フルコーンNAT)、EIM/ADFなNATは(address-)restricted cone NAT(制限コーンNAT)、EIM/APDFなNATはport-restricted cone NAT(ポート制限コーンNAT) とそれぞれ呼ばれます。

Filteringの違い - 他のMappingの場合

他のMappingについても考え方は同様です。

ポートの共有を行わない場合は、それぞれの(使用中の)ルーターのポートに対応する内側の(Address, Port)が一意に定まるので、EIMと同様にEIF/ADF/APDFの区別が考えられます。

ポートの共有がある場合は少し注意が必要です。異なる宛先アドレスに関してポートを共有する(=ポートのマッピングに宛先アドレスが使用されている)場合は、未知の宛先アドレスから通信が来たとしてもそもそも対応する内側ポートが存在しない(どの(Address,Port)に転送すべきか決定できない)ので、Filtering以前の問題です(実際の動作としては、無視・拒否されることになるはず)。ただ、「既知のAddressの未知のPort」から通信が来たという場合は、それを(そのAddressに対してMappingされている)ポートに通すかどうかという選択肢があるので、それによってADF(通す)とAPDF(通さない)とに分類することはできます。

さらに、宛先アドレスまたはポートが異なればポートが共有されるかもしれない、という仕様であれば、既知の外側(Address, Port)からの通信でないと転送先を決められないので、事実上はAPDFのみということになります。

ポート共有が無かったとしても、例えばADM/APDMでEIFを採用する場合、特定のホストにしか使用しないつもりだったポートに未知の別のホストから通信が来たらその未知のホストとの通信にも使用することになります。内側から別のホストに接続する場合は別のポートを割り当てるのに外側から来た場合は同じポートを使ってもよい(=EIM的)というのも変な話ではあります。実際にこのような動作をする機器は知る限りではありません。

従って、事実上は、「ADMならADF/APDF、APDMならAPDF」という関係が成り立っている可能性が高いではないかと思います。

これは、先ほどのような「ADMやAPDMの定義には当てはまらないが、アドレスあるいはアドレスとポートに従ったマッピングを保持する」ようなNATについても同じです。つまり、「宛先リモートアドレスが違う場合にはポートを共有したい」ならADF(またはAPDF)になるし、「リモートポートだけが違う場合でもポートを共有したい」ならAPDFになります。

Connection Dependent Filtering

接続の概念があるTCPにおいては接続にもとづいたマッピング(Connection-Dependent Mapping)も考えられるということを前述しましたが、Filteringに関しても同様に(既知のアドレス・ポートから来ていたとしても)外部からの接続要求(SYN)は内側に転送しないという動作が考えられ、RFC 5382において「Connection-Dependent Filtering」と呼ばれています。実際、このような動作をするNATシステムはそれなりにありそうです。

NATの種類まとめ

今までの主なNATの分類・呼称をまとめると以下の表のようになります(適宜用語を略している部分があります)。

Address&PortAddressIndependentMappingFiltering(ポート共有無し)(ポート共有無し)(Full Cone)Independent(ポート共有無し)-(Address Restricted Cone)Address--(Port Restricted Cone)Address&Port

この表に含まれないNATの動作はいくらでも考えられます。

「Connection Dependent Mapping (or Filtering)」もその一例です。後述の「ポートの共有を行うようなNATでEIMでもADMでもAPDMでもないもの」や、あるいは「基本的にEIMだが、気が向いたら内側の(Address, Port)が同じでも別のポートを使ってみる」といったような振る舞いをするNATも理論上は可能です。そのようなNATはRFC 4787の用語でいえば非決定的NAT(Non-deterministic NAT)ということになると思います。

また、Mapping/Filteringの動作はプロトコルごとに独立に決めることができます。例えばUDPがEIM/EIF、TCPがAPDM/APDFといったような動作が可能です。UDPTCPの動作は互いに全く干渉しないのが普通だと思います。理論上は、例えばTCPでもUDPでも必ず同じポートを利用する(いわば"Protocol Independent Mapping")ようなものも考えられますが、聞いたことはありません。

その他、奇妙な動作をするNAT

Cloud NAT の Endpoint-independent Mapping とは? | by Seiji Ariga | google-cloud-jp | Mediumこれによると、Google Cloud NATはEIM/EIFのくせに通信先を覚えておいて無理やりポート共有をするという実装になっています。そのせいで、同じ外側ポートを共有している片方のポートが既に通信していた宛先にもう片方も通信しようとするとENDPOINT_INDEPENDENCE_CONFLICTで失敗します(通信が通らない)。こんな実装をしてはいけません。

あと、この資料https://www.janog.gr.jp/meeting/janog30/doc/janog30-v64-pre-stun-ryosato-02.pdfの「ポート多重」のところにあるようなNAT(「最後に送信した方にレスポンスを届ける」)も、本当に存在するのか知りませんが、まともに通信できないのでダメです。

二重NAT(二重ルーター)について

環境によっては、NAT機器の内側に別のNAT機器があってその内側でインターネットを利用することになる場合もあります。このときは、全体としての(内側の端末がインターネットを利用するにあたっての)NATの動作というのは、基本的には2つのうちで厳しい方(Endpoint Independent<Address Dependent<Address and Port Dependent)を継承することになるのではないかと思います(ちゃんと証明してはいませんが)。例えばEIM/APDFとADM/ADFが二重ルーターになっていたらADM/APDFになるはずです。二重NATだとSymmetric NATになるというような説明を見かけることもありますがそれは誤りで、EIM/EIFであるNATが二重になっていたならそれは全体として引き続きEIM/EIFとして機能するはずです。一応、この資料とかはちゃんとそんな感じで書いてありますね。

ファイアウォールの影響

NATではありませんが、内側から出て行った通信への応答だけを通す(いわゆるステートフル)ようなファイアウォールは、(厳密には実装によりますがおそらく基本的には)UDPならAddress and Port Dependent Filtering、TCPならConnection Dependent Filteringとして機能します。つまり、PCの外側がEIM/EIFだったとしてもPC内部からはEIM/APDFのように見えるということになります。

ポートの消費とマッピングタイムアウト

NAT機器は様々なアルゴリズムを使って内側の機器に未使用のポートを割り当てていくわけですが、使用可能なポートの数は有限なので、いつかはポートが全て埋まってしまいます。ポートを割り当てることができなければ、ルーターの外側との新規の通信はできなくなってしまいます(ポートの枯渇)。

このため、どのMappingの手法を採用するにしても、タイムアウト時間が設定されており、一定期間使用されなかったMappingは削除されます。タイムアウト時間は、ルーターの設定やプロトコルによりますが数十秒-数時間程度です。これにより、有限個のポートを使い回しながらインターネットを使い続けられます。タイムアウト時間が短いほうがポートの消費は少なくて済みますが、短いと頻繁にテーブルの更新が必要でオーバーヘッドが大きくなります(多分)。また、時間経過ではなく実際に不足した際にもっとも長く使用されていないものを無効にする(LRU)方式のものもあるようです。

「フィルタリングのタイムアウト」(=過去何分以内に通信した相手なら履歴が残っているか)も存在しそうですが、これがシビアに影響してくる場面はあまりなさそうで、詳しい説明は見たことがありません。単純にマッピングタイムアウトするまで無制限に保持されているという可能性もあるでしょう。

利用可能なポート数とポートの共有

EIMではポートの共有ができず、一度内側の(Address, Port)に対して割り当てられたポートはタイムアウトまでは他の(Address, Port)からは一切利用できません。それに対してADMやAPDMでは宛先が異なればポートの共有が可能(お互いのキャパシティを圧迫しない)なので、特定の宛先への通信が大量に行われなければポートの枯渇は発生しません。

EIMの場合、通常の家庭での使用で消費するポートの数はおそらく100-200程度と思われます(IPoE接続でIPv4の固定IPが利用が使えるプロバイダ | インターリンク【公式】などでは200-300と書いてありますが、実際には200も消費することはあまりないでしょう)。

従来のPPPoE方式のインターネット契約ではグローバルIP1つを専有できるため使用可能なポート数は65535であるため、20人の家族が全員同時に300ポートを使用してやっと枯渇するかどうかというレベルですが、最近増えてきているIPv4 over IPv6 (IPoE)方式のISPでは、MAP-E方式と呼ばれる「v6プラス」(240ポート)、「OCNバーチャルコネクト」(1008ポート)、DS-Lite方式の「transix」「クロスパス」(1024ポート)など、65535ポートより少ないポートしか使えない仕様のものも多くあります。このようなISPを利用する場合は、ルーターのNATがポートの共有を行う仕様であることが望ましいと言えます。

ADMやAPDMであるにもかかわらずポートの共有を行わない場合、新たな宛先と通信するたびに未使用のポートの割り当てが必要なので、むしろEIM以上にポートを消費することになります。ただ、現実にポート消費の多くの割合を占めると思われるHTTPなどでは(クライアントPCの)1つのポートから異なる宛先に通信することは多分ないので、意外と問題ないのかもしれません。

「ポートセービングIPマスカレード」について

お気づきの方もいると思いますが、上級ユーザーの間で人気が高いYAMAHA製のルーターの機能である「ポートセービングIPマスカレード」は、基本的には今まで説明してきたような「ポートの共有」を指します。ポートの共有を指す一般的な用語として「ポートセービングIPマスカレード」(あるいは「ポートセービングNAT(NAPT)」など)が使われることも多くあります。「port saving」で検索してもほとんど何も出てこないので、英語圏ではおそらく通用しないと思います。

YAMAHA製品に関しては、公式のNAT動作タイプの違いについてなどに「宛先のポートが異なっていれば共有されうる」ことが明記されているので、内部的には宛先のアドレスとポートに基づくマッピングで管理している(念のため、これは必ずしも"APDM"の定義とは合致しないことに注意)ことになります。

UDPホールパンチング

ここからUDPホールパンチングの話題になります。

基本的に、インターネット上の2箇所の間で通信を行うには、少なくとも片方(受信側)はグローバルIP上に常時有効なポートを所有していることが求められますが、NATのマッピングによって一時的に割り当てられるポートをそのかわりに使用することも可能です。つまり、条件が合えば、受信側のPCの(Address, Port)に一時的に割り当てられたグローバルIP上のポートを通じて発信側のPCからの通信が受信側に通るようになります。一旦通信が通ってしまえば、定期的にパケットのやりとりがある限りはマッピングが維持されるため、いつまでも通信を継続することができます。これにより、静的なNAT設定(いわゆる「ポートの開放」)を行っていないNAT環境にいる端末同士の間でも直接的な(中継サーバーを使用しない)通信(=P2P通信)が可能になります。

成立のしやすさはNAT機器の動作やプロトコルに応じて様々(後述)ですが、基本的にはUDPのほうが成立しやすく、よく使われます。(基本的には外側からの通信を内側に通さない)NATの動作に「穴」を開けるイメージから、この手法は「UDPホールパンチング」と呼ばれています。

EIM/EIFでのUDPホールパンチングの流れ

今までNAT機器のMappingとFilteringについて説明してきましたが、いずれについても、Endpoint Independent→Address Dependent→Address and Port Dependentと後にいくほど「限られた相手との通信しか許容しない」傾向になるため、UDPホールパンチングを成立させるのがより難しくなります。

まずは最も簡単に成立するEIM/EIFを例に、UDPホールパンチングの流れを説明します。

まず、片方(例えばPC1とします)は、自分の(Address, Port)から(PC2とは異なる)インターネット上のサーバーに対してアクセスを行い、自分の(Address, Port)にどのようなグローバルIPとポートが割り当てられたかをそのサーバーから通知してもらいます(NAT機器が自分の管理下にあるのでなければ、内側から直接それを知るのは困難)。次に、その「グローバルIPとポート」をもう一方(PC2とします)に通知します(この時点では中継サーバーなどを使用)。PC2はその「グローバルIPとポート」にアクセスすると、NATがEIM/EIFであることから、PC2からのパケットはNATで破棄されることなくそのままPC1に到達します。これにより、P2P通信が成立します。いわば、EIM/EIFというのは「一時的にポートを開放できる」ような仕様であるため、きわめて容易にUDPホールパンチングが成立します。片方がEIM/EIFであればよく、もう一方は何でも構いません。

上記のうち「自分に割り当てられたIPとポートを通知してもらう」部分に関してはSTUN(Session Traversal Utilities for NAT)というクライアント-サーバー形式のシンプルなプロトコルがあります。稼働しているSTUNサーバーに対してクライアントがアクセスすると、サーバーはクライアント側のグローバルIPとポートを通知します。STUNは前述の(cone NATなどを定義している)RFC 3489で標準化されましたが、その後RFC 5389で更新され、現在はRFC 8489が最新の仕様となっているようです。STUNとは - 意味をわかりやすく - IT用語辞典 e-Wordsの説明もわかりやすいです。

その他の各NATタイプにおけるUDPホールパンチング

両者がEIM同士であれば、EIFでなくてもUDPホールパンチングは成立します。まず、両者(PC1, PC2とします)がそれぞれSTUNサーバーと通信して自分自身に割り当てられたIPとポートを知り、それをお互いに通知します。次にPC1がPC2のグローバルIPとポートに向かってパケットを送信します(EIMなのでこれは先ほどと同じポートを通じて送信されます)。PC2側がADFまたはAPDFであった場合、PC1のIPは未知なのでこのパケットは破棄されますが、PC1側のほうではPC2側へのパケットの送信履歴が残ります。次にこんどはPC2側からPC1側にパケットを送信(これもやはり同じポートが使用される)すると、PC1側のNATには既にPC2側に送信した履歴があるためにPC2は既知の通信先と判断され(APDFを通過し)、PC1に到達します。これでUDPホールパンチングが成立します。

あと残っているのは、片方がEIMであるもののEIFでなく、もう片方はEIMでないという場合です。

まず、片方(PC1)がEIM/ADF、もう一方(PC2)がADMまたはAPDMという場合ですが、このときはUDPホールパンチングが成立します。この場合、まずPC1とPC2がSTUNで自分のグローバルIPとポートを取得し、次にPC1がPC2側のポートにパケットを送信し、破棄されます。ここまでは先ほど完全に同じです。次にPC2がPC1側のポートに向かってパケットを送信しますが、PC2側はEIMでないため、これはSTUNで取得したものとは別のポートを通じてPC1に送信されます。しかし、PC1側はADFなので、既知の送信先とアドレスさえ一致していれば、ポートが未知でも通信を通します。これでUDPホールパンチングが成立します。

PC2側の環境によっては割り当てアドレスも変化するかもしれませんが、それほど大量のIPアドレスが選ばれうる環境は多くないと思うので、数回試行すればUDPホールパンチングは成立する可能性が高いでしょう。一般に、P2P通信ではメインの通信のスループットが重要であり、P2P通信が開始するまでに多少の遅延が生じることは許容できる場合が多いと思います。

次に、PC1がADFではなくAPDFだった場合は、途中まで同じですが、PC2からのパケットが来たときに、PC1のNATがAPDFなので未知のポートからの通信を破棄してしまいます。従ってUDPホールパンチングを成立させるのは困難です。

また、両方ともADMまたはAPDMの場合は、お互いに相手との通信の際に割り当てられるポートがわからないため、(フィルタリングによらず)UDPホールパンチングは困難です。

まとめるとだいたい以下の表のようになります。

SymmetricPort RestrictedConeRestrictedConeFull ConeFull ConeRestricted Cone×Port RestrictedCone××Symmetric

ポート番号の確率的な予測

上記のように、ADM/APDMではSTUNを使ったとしても実際の相手との通信に使用されるポート番号がわからないためUDPホールパンチングが困難ですが、異なるポート番号が使用されるといっても実際には何らかの規則性が存在する場合も多くあります。よくあるのは、宛先が変わると以前割り当てたポートより一つ大きい番号のポートを使用するというようなもので、この場合であれば、STUNでポート番号を取得した直後に一つ大きい番号のところに向かって他方のPCからパケットを送ればホールパンチングが成功します。P2Pソフトウェアではこのような動作が実装されているものも多くあります。

ものによっては宛先ごとに完全にランダムなポートが割り当てられるNATもあり、こうなるとホールパンチングは確率的にも非常に困難になります。

TCPホールパンチングはできるのか?

STUNプロトコル自体はTCPでもUDPでも使用可能で、その他の部分に関してもTCPUDPで特に差は無いため、TCPにおいてホールパンチングが成立する条件は(Mapping/Filteringが同じであれば)実はUDPと変わりません。しかし実際にはLinuxのnetfilterなどはConnection Dependent Filteringを採用しています。例えば両者がEIM/Connection Dependent Filteringである場合を考えると、外側からの新規の接続は基本的に全てブロックされてしまうため、タイミングを揃えて両側からSYNパケットを送信するような困難で非効率な手法が必要になります(参考: TCPでホールパンチングしてみるなど)。

このような事情があるために、ホールパンチングはほとんどUDPで行われます。

NATとセキュリティ

NAT(NAPT)はセキュリティのための機能ではないとよく言われますが、同時に(一部の)NATは実際にそれなりのセキュリティ効果があるというのも事実です。

NATタイプの中では、Mapping/Filteringの制約が強いものほどセキュリティ効果も高くなります。EIM/EIFでは割り当てられたポートは全世界からのアクセスを内側に通すようになる(「一時的にポートが開放された」状態になる)ので、セキュリティとしてはあまり高くありません。一方、EIMであってもADFあるいはAPDFなら特定の相手との通信しか通さなくなります。ADMとAPDMも同様です。TCPの場合、Connection Dependentなら一段とセキュリティは高くなります。

過去記事のDROP vs REJECT論争、そしてWindowsとLinuxのファイアウォールの動作の違いについて - turgenev’s blogでも書いた通り、TCPは開いているポートを隠蔽することができず、またsshなど重要なサービスが実行されていることが多いので、厳しめのNATにしておくのがいいかもしれません。

また、NATタイプによらず、外側に出て行ったのと同じポートを即座に別のアプリケーションで使用するようなことをするとアクセスが通る可能性があり危険なので、一般論としてポートを使用するアプリケーションではエフェメラルポートとして使用されない29999番以下程度のポートを使用するのが安全ではないかと思います。

STUNによるNATタイプの判定

STUNでは、PCのポートに対して割り当てられたグローバルIPとポートを知ることができるので、これを使ってNATタイプを判定することができます。つまり、host1:port1とhost1:port2とhost2:port3という3か所でSTUNサーバーが稼働していれば、クライアントはそれぞれにアクセスして自らに割り当てられたポートを知ることで、マッピングがポートとアドレスに応じてそれぞれ変化するかどうかを知ることができます。さらに、host1:port1へのアクセス時に割り当てられたポートに向かってhost1:port2とhost2:port3からそれぞれパケットを送信してもらい、それが届くかどうかによってフィルタリングがポートとアドレスそれぞれに基づいているかどうか判断できます。

事前にhost1:port1とhost1:port2とhost2:port3を全て把握しておくのでももちろんいいですが、サーバーが対応していれば、自らのポートやアドレスを変更したSTUNサーバーの情報を"Other Address"として通知してくれるのでこれを用いることもできます。

若干雑な説明でしたが伝わったでしょうか。詳細はゲームでよくある「NATタイプ」はどう判定しているの?接続 | 好奇心旺盛な人のためのWebRTCなどにあります。

利用可能なSTUNのクライアント・サーバー

STUNのクライアントとしてはStuntman - open source STUN serverのstunclient(Windows/Linux/macOS)が利用可能です。(サーバーがOther Addressに対応していれば)--mode behaviorと--mode filteringでそれぞれmappingとfilteringの情報が出力されます。ただしTCPに関しては動作が不自然で、NATタイプを調べるにはPC側のポートを固定する必要があるにもかかわらずサーバーごとに異なるポートを使用してしまっているようなので--localportを指定する必要があります。これで--mode behaviorではちゃんと動くのですが--mode filteringではエラーになってしまいます(かといって指定しないと結果がおかしくなる)。手動でパケット送ったりサーバー立てたりすれば一応調べられるのですが面倒なので、ちょっとしたテストツールを作ってみました: GitHub - ge9/tcp-nat-filtering-discovery。ちゃんと2つのIPからテストパケットを送って調べてくれます。

Androidアプリは現在利用不可のものしか見つけられませんでしたが、stunclientはtermuxで動作しました。ビルドにあたって、common/commonincludes.hppの"sys/termios.h"を"termios.h"に書き換え、pkg installでboost-headersとbinutilsを入れる必要がありました(書いてから気づいたけど、普通にpkg install stuntmanでインストールできた…(openssl-toolも必要))。

あとは、リモートアクセスチェックツール|DiXiM.NETというアプリも簡単な判定を行ってくれます。Android/iOS版があります。ただEIM/ADFとEIM/APDFが区別されておらず(この2つがまとめてタイプ4)、IPアドレスやポートなどの詳細も出ないのであまりおすすめはしません。

上記のStuntmanはサーバーソフトウェア(stunserver)も入っています。そのほかGitHub - coturn/coturn: coturn TURN server projectなどもstunサーバー/クライアントとして機能しそうです。ちなみにTURNというのはSTUNを用いたUDPホールパンチングが成立しないときによく使われるリレーサーバーのプロトコルです。

STUN自体は相手のポートとアドレスを通知するだけの軽量なプロトコルなので、無料で利用可能なサーバーもあります。STUN server list · GitHubに色々と載っています。

ただし、TCPに対応しているものは少なく、見つけられた中でTCPでも使えるのは

stunserver.stunprotocol.org(Stuntmanのサイトに載っている)
stun.f.haeder.net(個人運営っぽい)
freestun.net

の3つでした。UDPでは上記に加えて、

stun.l.google.com:19302
stun1.l.google.com:19302
stun2.l.google.com:19302
stun3.l.google.com:19302
stun4.l.google.com:19302
stun.ekiga.net
stun.schlund.de
stun.voipbuster.com
stun.voipstunt.com
stun.xten.com

が利用可能でした(ポート番号の記載がないものは全てSTUNプロトコルのデフォルトの3478番)。このうち太字になっているものでは"Other Address"に対応しているのでstunclientのbehavior/filteringによるテストができました。

前述の通り、stunclientの不具合(?)によりTCPのfilteringについてはSTUNでのテストの方法がよくわからないので、手元ではかわりにnetcatなどを用いて手動で疎通確認をしていました。

またSTUNではありませんがIP確認のためによく使われるアクセス情報【使用中のIPアドレス確認】ではクライアント側のポートも表示されます。ただしPC側のポートを指定してHTTPリクエストを投げるのは結構面倒なのであまり使う場面は無さそうです。

前述しましたがfilteringについて調べるときはファイアウォールを適宜無効にする(Linuxなら一部ポートを開ける、Windowsなら実行ファイルに対して許可設定をする)のを忘れないようにしましょう。

PS4Nintendo SwitchでのNATの呼称

PS4Nintendo Switchなどの対戦ゲーム機ではUDPホールパンチングが使われており、通信の成立条件の目安としてゲーム機で「NATタイプ」を表示する機能があります。

Nintendo Switchでは「NATタイプA/B/C/D/F」と分類されており、それぞれフルコーンNATまたは制限コーンNAT、ポート制限コーンNAT、シンメトリックNATのうちポートが連番で割り当てられるもの、シンメトリックNATのうちポート番号の予測が困難なもの、UDP通信ができない環境(NATの問題ではない)、に対応するようです。(参考: Nintendo Switch の「NATタイプ」判定条件 #Network - Qiita

他にはPS4/PS5やXboxなどにNATタイプの概念がありますが、情報が少なくあまりよくわかっていません。特にEIM/ADF(制限コーンNAT)は実機がほぼ流通していなことから情報がほとんどありません。

PS4/PS5では「NATタイプ1/2/3」と分けられており、フルコーンNATは1、ポート制限コーンNATは2、シンメトリックNATは3になるようです。

Xboxでは「オープン/モデレート/ストリクト」に分かれており、フルコーンNATはオープン、ポート制限コーンNATはモデレート、シンメトリックNATはストリクトになるようです。

実際のNAT機器の動作

Linuxのnetfilterのconnection trackingとNAT動作の仕組み - turgenev’s blogに書いた通り、Linuxは普通に設定するとUDPがEIM/APDF(ポート制限コーンNAT)、TCPがEIM/Connection Dependent Filteringになります。

Windowsの「モバイルホットスポット」はUDPがEIM/EIF、TCPがAPDM/Connection Dependent Filteringでした。試せなかったのですがおそらく「インターネット接続の共有」もそうでしょう。

AndroidLinuxなのでAndroidテザリングLinuxと同様になります。iPhoneは持っていないのでわかりませんが「iPhone テザリング NAT」で調べるとNATタイプAになったという情報が結構出てくるのでUDPはEIM/EIFなのではないかと思います。

家庭用ルーターに関しては、カタログスペックには載らない部分なので情報は少ないのですが、低価格帯のものはたいていLinux搭載なので同じくPort Restricted Coneのものが多いのと思います。少なくとも自宅のRX-600KI(NTTのHGW)、WSR-1166DHPL2(Buffalo Aterm  WX1500HP(NEC)、WRC-1167FS-B(ELECOM)はそうです。ネットで調べるとBuffaloのWSR-5400AX6とかWSR-1800AX4とかはNATタイプがCになる(Symmetric)という情報を見かけます(ファームウェアアップデートで治る場合もあるらしい)。

他に情報としては以下のようなものがあります。

家庭用ルーターのNAT特性

プロバイダによるCG-NATなど業務用のものではフルコーンNATも多くあります。例えばdocomoスマホ回線やDS-Lite(transix)はTCP/UDPともにフルコーンNATです(TCPホールパンチングも成功します)。d-WiFiは0001docomoがフルコーンで0000docomoがシンメトリックです。

まとめ

NATタイプとかUDPホールパンチングに関して大体のことはまとめられたかと思います。質問なども歓迎しております。

ssh接続の切断-各種原因と対策

概要

リモートマシンの操作に欠かせないssh接続は様々な原因により切断される可能性があります。しかし、原因によっては、適切な対策をすることで、接続を維持することも可能です。

この記事では原因と対策についてできるだけ網羅的に紹介します。

注: ssh接続の「維持」について

先ほどssh接続を「維持」できると言いましたが、物理的に接続が通っていない状態で通信することはできるはずがありません。

では、どういう状態が「維持」と呼べるのかというと、接続が通っていない間は一時的に大きな遅延が挟まり、その後にまた以前と同じ正常な接続状態に戻るということです。具体的な動作としては、物理的に切断されている間は文字を入力しても表示されず、向こうからの応答が返ってくることもありませんが、復帰後にそれらがまとめて送信あるいは受信されるという感じになります。

sshコマンドが終了されて認証からやり直さなければいけない完全な切断と比べると、コマンド実行や入力の作業をそのまま続けられるので便利です。

完全な切断ではないものの一時的に通信が停止した状態を指すときは、この記事では「中断」などの言葉を使います。

原因1: TCPパケットのタイムアウトによる切断

TCPにおいては、確立した通信においてデータ(パケット)の送受信が成功しない状態が一定時間にわたって続くと通信を切断することが定められています。ssh接続は単一のTCP接続のうえで動作するので、TCP接続が切断されればssh接続も切断されます。切断されるのはあくまでデータの送信が実際に失敗したときだけであるため、ssh上で何も操作を行っていない場合には(実際には通信が不可能な状態にあったとしても)より長時間にわたって接続が持続する可能性があります。

切断の具体的な動作としては、送信したパケットへの応答が一定時間にわたって返ってこなければ、正常な通信中に測定した遅延時間をもとに、間隔を毎回2倍にしながら何度か再送信し、上限回数を超えたら接続が切断される、という感じになります。ただし、2倍にしていくといっても、上限値が定められている場合もあります。これらのパラメータは各システムやユーザーがある程度自由に設定できます。

Linuxでは /proc/sys/net/ipv4/tcp_retries2でリトライ回数が定められていて、デフォルトで15に設定されているようです。また、再送信の間隔の上限はTCP_RTO_MAXという変数で120と定められており、結果的には20分程度で接続が切断されます。なお、TCP_USER_TIMEOUTという変数によりユーザーレベルでリトライ時間を設定できるという情報(Technical Memorandum: TCPの再送タイムアウトを制御したいTCPの再送タイムアウトを制御したい #Linux - Qiita)もありましたが、sshでこれを使う方法があるかは不明です。

Windowsでは、リトライ回数はレジストリのHKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\Tcpip\ParametersキーのTcpMaxDataRetransmissions値において設定されます(公式ドキュメント: Windows XP の TCP/IP および NBT 構成パラメーター - Windows Client | Microsoft Learn)。デフォルトでは値が存在しませんが内部的に5に設定されています。これはかなり小さく、20秒ほどタイムアウトに達します。VPN使用時にWi-Fiのアクセスポイントを変更したり、地下鉄でモバイル回線を使ったりすることを想定すると若干不安な数値です。手元ではTcpMaxDataRetransmissions10(0x0000000a)に設定することで1分程度の切断には耐えるようになりました(regeditではデフォルトで16進数入力なので注意)。また、情報が古いですが、TCP/IP の再送タイムアウトの最大値を変更する方法 - Microsoft サポートによると上限値として240秒が設定されているようです。これを変更する方法はわかりません。

Macはおそらくnet.inet.tcp.rexmt_threshがリトライ回数を定めているようです。rexmtのxはtransの略だと思われます。

再送信のアルゴリズムの都合上、パケットが実際に送られてssh上で通信再開が確認できるまでには、物理的な通信が再開した後さらに数十秒待たなければならない場合があります。もちろん、実際に切断されたままになっているときのタイムアウトまでの時間も非常に長くなる可能性があります。リトライが多くなると負荷がかかりそうなので、回数の上限は10-15くらいにしておくのが無難そうです。Linuxでも、デフォルトより小さくしておくべきだという意見があるくらいです。

名前が似ていますがWindowsTcpMaxConnectRetransmissionsなどは接続を開始するときのSYNパケットの動作を決めるものなので、確立されたssh接続の動作とは関係ありません。KeepAliveInterval、KeepAliveTimeあたりも関係ないと思います(効果は無さそうでした)。Windowsを例に出しましたが、他のOSでも同様です。

原因2: ネットワーク環境の変化

接続するネットワークを変更して使用するIPなどが変わった場合は、当初確立したTCP接続が利用できなくなるため、通信が成立しなくなります。これが長時間にわたって続くと、前節のようにssh接続が切断されます。

該当するのは例えば以下のような場合です。

光回線からモバイル回線に切り替えた
・プロバイダによってグローバルIPが変更された
・2重ルーター環境において外側のルーターから内側のルーターに切り替えた
・PCで無線から有線に切り替えた(アダプタのMacアドレスが異なるため、異なるIPが割り当てられるはず)
・PCがスマホ経由でインターネットにアクセスしているときに、スマホの接続先を家のWi-Fiからモバイル回線に切り替えた(PC自体のIPアドレスは変わっていないがスマホから先の経路が変わっている)

一方で、以下のような場合は該当しません。

・全く同じネットワーク(SSIDが同じWi-Fiや、同じLANケーブル)に再接続した
・同じWi-Fiルーターの2.4GHzから5GHzに切り替えて、同じIPが割り当てられた
Wi-Fiの親機から(その親機に接続された)ブリッジモードのルーターや中継器に切り替えて、同じIPが割り当てられた
スマホ経由でアクセスしている場合にスマホの接続先アクセスポイントを2.4GHzから5GHzなどに切り替えて、同じIPが割り当てられた

これらの場合は、物理的な帯域・経路が変わるだけで、IPアドレスは変わっていないため、以前と同じTCP接続を引き続き利用できます。

ネットワークが変更された場合も同じ接続を引き続き使うことは可能ですが、理論上、「接続先の協力」が不可欠です(多分)。本来はIPレベルで全く別の経路を通ってきているわけなので、それを先ほどと同じものと認識するためには接続先で通信を識別するための工夫をしなければいけないということです。ssh単体にこのような機能はありません

有効な対策は、VPNを使って通信経路を仮想化すること、あるいはmoshEternal Terminal (et)のようなsshの代替となる別のソフトウェアを使うことです(sshを使わないのは「sshの維持」の厳密な意味からは外れますが)。これらを接続先で設定するためには、root権限Windowsなら管理者権限)や、場合によってはネットワーク管理権限も必要になるでしょう。

VPNは、L3かL2レベルで動作するとされているものなら、理論上は(=クライアントの実装がちゃんとしていれば)なんでも大丈夫だと思います。筆者が試したのはTailscaleだけですが、(Tailscaleのバックエンドである)Wireguardそれ自体、あるいはOpenVPN(や、それを使うVPNサービス)などでもできるはずです。これらを介して接続している場合は、通信に使用するのはVPN専用の仮想的なアダプタおよびIPなので、物理的な接続が変更されてもネットワーク環境は仮想的にそのまま維持され、ssh接続も維持されます。この方法だと、rootは要りますが、ネットワーク管理権限は無くてもいける場合が多いでしょう。例えばTailscaleは双方がSymmetric NAT環境の中であってもリレーサーバーを使ってVPN接続を維持してくれます(過去記事を参照)。

moshやEternal Terminalに関しては使ったことがないのであまり詳しく紹介できません。moshのほうが古く、UDPを使用していて、必要な最大接続数に応じて複数のポートを開放する必要がありますが、TCPパケットのタイムアウトによる切断の問題を完全に回避できるかもしれません。Eternal Terminalのほうが新しく、TCPポートを1つ開ければいけるようです。これらを動かす自体にroot権限は要らないかもしれませんが、ポートを開放するためにはネットワークの管理権が必要で、これは一般にはroot権限よりむしろ厳しい条件でしょう。

プロキシサーバーを使う

先ほど「接続先の協力が不可欠」と述べましたが、これはあくまで手元のPCと接続先という2つのコンピュータのみを想定したときの話です。実際には「接続が不安定な部分」さえVPNで保護できればいいので、「接続先」それ自体ではなく「接続先への安定した経路を持っている何らかのPC」の協力があれば目的は達成できます。要するにプロキシサーバーを経由するということです。当然、そこでの管理者権限/ネットワーク権利権限は必要です。

例えばプロキシサーバーにVPNだかmoshだかEternal Terminalを入れておき、プロキシサーバーから最終的な接続先にssh接続してローカルポートフォワード(-L)してプロキシサーバーの10022番ポートとかで接続先のsshサーバーが見えるようにすれば、あとは手元の端末からVPNとかmoshとかを経由してその10022番ポートに接続する(moshとかでできるのかは知りませんが)ことで、安定的なssh環境が手に入ります。

あるいは、プロキシサーバーでsshトンネル(ローカルポートフォワード)を実行するのではなく、プロキシサーバーにHTTPS・SOCKSプロキシなどを立てたりsshサーバーを立てたりしてProxyJumpあるいはProxyCommandを使用して手元からつなぐという手もありますがこの場合ちょっと不利になることがあります(後述)。

プロキシサーバーを稼働させるには相応のコストがかかり、立地によっては通信品質が低下する可能性もあるので、その辺のデメリットも考慮した上で使うことになります。

原因3: NATテーブルのマッピングが消滅する

インターネット越しの通信などNAT機器の内側から外側へとsshをしている場合には、ルーターのNATテーブルの持続時間に注意が必要です。

ssh接続で定期的にやり取りを続けている間は、外側から来るパケットはNATテーブルが保持するマッピングによって内側まで通されますが、一定時間全くパケットが来ないとマッピングが消滅し、同じポートを使うことができなくなるので、通信が中断(いずれは切断)されます。一度消滅したマッピングを復活させることはできません。

一般にTCPに関するNATマッピングの持続時間は他のプロトコルよりは長めに設定されていますが、それでも数十分-数時間程度です。

NATマッピング消滅による切断を防ぐには、定期的に無意味な通信を行うことが有効です。.ssh/configのServerAliveInterval値により定期的にパケット(keep-aliveパケット)を送信することができるほか、toppingのような定期的に出力を行うプログラムを実行するという方法もあります。逆に言えば、NATが介在しない経路(VPNも該当する)でのssh接続であればServerAliveIntervalなどは通常不要です。

あるいは可能であればルーター側でNATテーブルの持続時間を長くするという方法もあります。長すぎるとポートが枯渇するおそれもあるので注意が必要です。

なお、ルーターの静的NAT設定を使って固定化したポートを使って通信するという案(sshのoutgoingポートを固定するのはHow can I set the source port for an SSH command-line client? - Server Faultみたいにnetcatを使えばいけそう)も思いつきましたが、色々と面倒そうなのでやめました(試してもいません)。とはいえ、多機能なルーターなら、ssh接続に使うマッピングだけ長期間保持するような機能を実現できる可能性は無くはないかもしれません。

原因4: スリープ/ハイバーネートでkeep-aliveパケットが送れない

先ほど紹介したServerAliveIntervalやtopなどのパケット送信手段ですが、手元のPCがスリープしている時にはこれらの送信も停止してしまいます。ルーターの設定を変更できれば多少は緩和できますが、限界があります。

すなわち、NATマッピングの保持時間を超えるような長時間のスリープを挟んでもssh接続を維持したければ、VPNを使って仮想的にNATを回避する必要があります。別の言い方をすれば、NATマッピングが消滅してしまうということは、別の経路でTCP接続をやり直さなければいけないので、「原因2: ネットワーク環境の変化」と同様の対策が必要です。

また、ここで注意が必要なのが先ほど少し触れたProxyJump/ProxyCommandの動作です。ProxyJumpやProxyCommandは、途中のサーバーを経由して手元の端末が最終的な接続先へのssh接続を行う機能です。従って、定期的なパケットを接続先に送信するのは手元のPCの役目であり、スリープしている状態だとそれができません。このため、手元からプロキシサーバーまでの経路がVPNで維持されていたとしても、プロキシサーバーから接続先までの経路は(プロキシサーバーがNAT機器の内側にある場合は)NATマッピングの消滅により無効化され、ssh接続は維持できません。

というわけで、VPNで接続したプロキシサーバーを経由する場合は、ProxyCommandやProxyJumpを使うのではなく、プロキシサーバー側でsshを実行して接続先までのトンネルを掘っておく(接続先との定期的な通信はプロキシサーバーに担当させる)のが良いと思います。ネットワークの切り替えは挟むかもしれないが長時間のスリープはしない、という場合はProxyJump/ProxyCommandでも構いません。

原因5: Windowsネットワークアダプタの仕様

完全に解析できてはいませんが、Windowsでは以下のようなときにssh接続が切断されます。

・PCをスリープ状態にしたとき(デスクトップPCのみ?ノートPCでも長時間だとなる?)
・IPを(DHCPではなく)固定割り当てで設定したWi-Fiネットワークから切断されたとき
・(DHCP割り当ての)Wi-Fiネットワークから切断後、異なるSSIDWi-Fiネットワークに接続したとき
Wi-Fi接続から有線接続に切り替えたり、IP割り当てを変更したりして、Windowsに割り当てられるIPが変わったとき

逆に、以下のような場合は(中断されることはあっても)切断されません。

・ノートPCを(短時間?)スリープ状態にしたとき
・PCをスリープ状態にしたときのVPN経由の通信
DHCP割り当てのWi-Fi接続や有線接続から切断後、再び全く同じ(Wi-FiであればSSIDが同じ)ネットワークに接続したとき
Windowsが経由しているスマホなど他のデバイスで接続が切断されたり割り当てられたIPが変更されたりしたとき

Linuxでは、以下のような場合でも(中断されることはあっても)切断されません。

・(デスクトップ含め)PCをスリープ状態にしたとき
・NetworkManagerをrestartした場合
・異なるWi-Fiアクセスポイントに接続したり、割り当てられるIPが変わったりしたとき

このように、Windowsに比べると切れることが少なくなります。特に、接続経路が変わらない場合(2.4GHz→5GHzや、親機→子機or中継器などの切り替え)は引き続き同じ通信を利用できます。

そもそもこのWindowsLinuxの動作の違い自体があまり知られていないようで、WindowsでこのようなLinux的な動作をさせる方法は調べても出てこなかったのですが、VPNのような仮想的な接続ルートを維持するという発想で、ダメ元でHyper-Vの仮想スイッチを作ってみたら、効果がありました。Linuxと同様、同じネットワーク環境内での切り替えやスリープを挟んだ場合でもそのままssh接続が維持されるようになります。

Hyper-V自体あまり使っていないので、スイッチの作成について詳しい説明はできませんが、タイプとしては「外部」を選んで、実体として使う物理NICとしてWi-Fiあるいは有線接続を選ぶだけで、勝手に物理接続が仮想アダプタでラップされた感じになってくれました。仮想マシンを動かすわけではないので負荷・オーバーヘッドはほとんどないのではないかと思います。

なお、Windows 10や11のHomeエディションだとダイアログからHyper-Vの有効化ができませんが、コマンドラインからだとできます(参考:Windows 10や11のHomeエディションでHyper-Vを利用する方法 | 4thsight.xyzWindows10Home でHyper-Vを使う方法を試してみる | sakura86.com)。

ただ、この設定をしても、ネットワークが切り替わってWindowsに異なるIPが割り当てられるとsshはただちに切断されます。一方Linuxでは、1分ごとにネットワークを切り替えながら2つのsshセッションを並行して操作するようなことも可能です。ただし、切り替えのタイミングで文字入力をすると切断されるなど安定はしません。このへんの挙動については詳しくは不明です。

Windowsで同様に2セッション並行をするには、スマホなど他のデバイスを介在させる以外だと、インターネット接続共有(ICS)の機能を使ってHyper-VNIC(固定IP)に対して設定をしてそのIPをsshのbind addressに指定するという方法でも一応できましたが、いずれにしても実用上は無意味でしょう。

原因6: VPNの有効/無効の切り替え

VPNを使えばネットワークの切り替えやスリープなどによる切断を抑制できますが、VPN自体の有効/無効を切り替えるとネットワーク経路やIPが変更されるためssh接続が中断あるいは切断されます。

特に、社内ネットワークへの接続・発信元IPの偽装などの目的で全てのインターネットトラフィックを仮想化するVPNの場合、作業内容によって頻繁に切り替えることもあるかと思いますが、そのたびに無関係な(そのVPNがなくてもアクセスできる)ssh接続まで切断されるのは避けたいところです。

この場合は、ssh接続のために、特定ホストへのアクセスのみを仮想化するVPNを別途構築するのが有効です。具体的な動作はVPNソフトウェアによって異なる可能性があるので確実なことは言えませんが、特定のホストへの経路はインターネットのデフォルト経路より優先されることが多いのではないかと思います。

筆者が試した範囲では、Cisco Anyconnect VPNやTunnelbearの有効/無効を切り替えてもTailscale経由のVPN接続を維持することができました。ただし、Cisco Anyconnect VPNでは「Allow local (LAN) access when using VPN (if configured)」を有効にする必要があります。「if configured」と書いてあるので、接続先サーバーによっては動作しない場合もあるのかもしれません。また、Tailscaleが使用する100.x.y.zのようなIPアドレス(CGNATアドレス)はもう一方のVPNによって処理されてしまい使えないので、--advertise-routesで指定したプライベートIPを使うと良いです。

併用するVPNの仕様によっては、Tailscaleの接続がもう一方のVPN経由になるので、遅延が増える可能性があります(Ciscoもその一例です)。これはSupport specifying which local interfaces to bind to, and support binding to multiple interfaces · Issue #1066 · tailscale/tailscale · GitHubが修正されれば一部解決される可能性があります。

原因7: サーバー側での切断

ここまでは全てクライアント側の設定について書いてきましたが、サーバー側が原因でssh接続が切断されることもあります。ただ、クライアント側に比べると原因は限られます。

まず、sshサーバーは安定したネットワーク環境に設置されて常時稼働しているのが普通なので原因2, 4, 5, 6については考える必要はありません。3に関してもサーバー側では22番ポートが固定的に使われるのでNATテーブルは無関係です。

ただし、1のTCPパケットのタイムアウトには注意が必要です。試していませんが、サーバー側がWindowsである場合は、同様にTcpMaxDataRetransmissionsを増やす必要がありそうです。さらに、pingのようにサーバーから継続的に応答を受け取るようなコマンドを実行している途中にクライアント側で接続が中断された場合、パケットがクライアント側に届かないためにサーバー側が接続を切断してしまう可能性があります。例えば、クライアントが長時間スリープする場合は、クライアント側では通信が停止するのでパケットがタイムアウトすることはありませんが、サーバー側はそのまま通信を続けようとしてタイムアウトし、通信を切断してしまいます。

手元で実験してみました。まず実験用にサーバーのtcp_retries2を5程度に変更して1分以内に接続が切れるようにします。クライアント側では2つの端末を起動してどちらも同じサーバーにssh接続し、片方では何も実行せず、片方はping -n localhostを実行しておきます。その状態でPCを1分ほどスリープさせます。復帰後、何もしていない方の端末ではssh接続が維持されていましたが、pingを実行していた方の端末では切断されました。

すなわち、サーバー側での切断を防ぐには、受け取るべきパケットが残っていない状態にしてからスリープする必要があります。しかし、これはそれなりに難しそうです。単純なシェルであればコマンドが実行されていない状態にすれば十分ですが、VS Code Remote SSHのような複雑なアプリケーションを起動している場合はいつパケットが来るか予測できません。また、単純なシェルでも、ものすごく運が悪ければ、ServerAliveIntervalによるkeep-aliveパケットが送られた直後にスリープされたためにサーバーからの応答のパケットが残ってしまうというようなことも考えられます(つまり、NATが介在しないssh接続であればServerAliveIntervalは無効にした方が安定します)。

サーバー側のTCPパケットのタイムアウトをもっと長くしてもいいですが、多くのユーザーが同時に使用するようなサーバーだと負荷が大きくなりそうです。

ちなみに、クライアント側のServerAliveIntervalに対応する設定としてサーバー側にもClientAliveIntervalという設定がありますが、これはあまり効果がありません。keep-aliveパケットが必要なのはNATエントリを維持するためだけであり、クライアント側が起動しているならServerAliveIntervalでクライアント側から送れば十分だからです。クライアントがスリープしているときは通信が成立しないので、サーバーからkeep-aliveパケットを送ってもNATテーブルの維持には寄与しないのではないかと思います(NATテーブルの細かい動作があまりよくわかっていません)。むしろ、不必要に疎通確認をすることでTCPパケットのタイムアウトssh自体のタイムアウト(ClientAliveCountMax)に達する可能性もあります。従ってClientAliveIntervalはむしろ無効にしたほうが接続を維持しやすいことが多いでしょう。

これ以外にも、サーバー側の独自の設定によって接続が切断される可能性もあります。例えばGCPのCompute EngineのVPCネットワークでは10分間通信がないTCP接続が無効化されるようです(Compute Engine を使用する場合の一般的なヒント  |  Compute Engine ドキュメント  |  Google Cloud)。実際試してみると、複数のサーバーに接続した状態で40分ほどスリープするとGCPだけが切断されていました。クライアントが長時間スリープしても接続が切れないようにするには、プロキシサーバーからトンネルを掘っておくしかなさそうです。

原因8: conhost.exeのバグ

これはssh自体とは無関係で、しかもWindowsに限定した話ですが、Windowsのconhost.exeは文字選択中にプログラムの出力がブロックされるという仕様があります(参考: ruby on rails - how can I stop my server from freezing when powershell is in 'select' mode? - Stack Overflow)。また、経験上、文字を選択していなくても長時間(数十分~数時間)の放置で似た状態になることがあります。これが原因でパケットが通らず通信が切断される可能性もあるかもしれません。Windows Terminalやminttyなど、このような問題が発生しない別の端末を使いましょう。ssh以外でも気を付けてください。

その他の対策: tmuxとかscreenとかautosshとか

ここまで、ssh接続を維持する方法について解説してきましたが、「ssh接続が完全に切断されたとしても影響を最小限にする」ような方法もあります。

例えばtmuxscreenのような仮想端末を用いると、ssh接続とは独立に端末セッションを管理できるため、切断されてから接続し直す場合でも元の作業状況を引き継ぐことができます。

また、対話セッションの維持はできませんが、autosshは切断が起こると勝手に再接続してくれるので、sshトンネルを恒久的に設置しておきたい場合には有用です。

その他

Keep ssh connection alive if internet briefly disconnects - Unix & Linux Stack Exchange Linuxのipコマンドでtuntapとかいう見慣れないキーワードを使っています。なんか関係ありそうですが、詳細不明。

余談: WindowsでHTTPプロキシ経由でSSHしてみた

一応試したのでメモしておきます。

まずこれはWindows関係なくHTTPプロキシ(squid)側の設定なのですが、HTTP Proxy経由でsshを使う | にーまるろく あーるしー どっと ねっとこのようにsquid側で22番ポートを有効にする必要があったようです。

また、Windowsでは、プロキシに使うconnect(あるいはconnect_proxy)コマンドがパスの通った場所に存在しないことが多く、Linux用のconfigのProxyCommandの内容をそのままコピーしてきても動きません。

そこで、Git for Windowsに付属してくるconnect.exeをフルパスで指定してやると動くようになります(参考: Proxy下のWindows10でOpenSSHする #Windows10 - QiitaWindows SOCKS Proxy SSH環境で Git Bash と VSCode Remote Development の ~/.ssh/config を共通にする #VSCode - QiitaSSHを駆使して数々の試練(プロキシ、踏み台)を乗り越える話 #SSH - Qiitaなど)。

まとめ

現象の発生に比較的長時間かかる場合も多いことから「なんか気づいたらssh切れててウザい」くらいに認識されていることが多いsshの切断ですが、意外と色々なところに原因があり、それぞれ違った対策が必要ということがわかります。

まとめると、TcpMaxDataRetransmissionsは(切断を防ぐためなら)上げて損なし、ServerAliveIntervalはNAT環境なら設定推奨、複数のネットワークを渡り歩くならVPN必須、安定した環境ならHyper-VWindowsLinuxに変えてごまかす、という感じになります。

DROP vs REJECT論争、そしてWindowsとLinuxのファイアウォールの動作の違いについて

概要

この記事では、IP通信における不要なパケットへの2通りの対処法であるDROPREJECTWindowsLinuxファイアウォールにおいてどのように使われているか説明し、Linuxの動作をWindowsに近づけるための(おそらく英語圏含めほとんどあまり知られていない)具体的な方法なども紹介します。

DROP vs REJECTとは

全世界がインターネットで接続された現代では、いつ、どこから、どんなパケットが自宅に届くかわかりません。素性のわからない"招かれざるパケット"が自宅に届いたとき、我々には2つの選択肢があります。1つは単にそのパケットを無視(破棄)すること、もう1つは相手に「これは受け入れられません」と通知する(した上で破棄する)ことです。

Linuxにおいてパケットの扱いを設定するiptables(や、その後継のnftables)では、前者(無視)にDROP、後者(明示的な拒絶応答)にREJECTという言葉を使うため、以降ではこの2つの用語を説明に用います。

日本製のルーターのパケットフィルタ設定では「無視」「拒否」のような用語が使われており、同じLinuxでもufw(後述)はDROPのかわりにdenyという単語を使っています。従って、DROP/REJECTという用語がIPの仕様で定められているとかいうわけではないと思います。多分。

で、このDROPREJECTのどちらがセキュリティ的に優れているか?というのが、どうやらネットワーク界隈でときおり議論になるトピックのようです。

大前提として、DROPREJECTも攻撃者のパケットの内容を一切無視するという点では変わらず、この2つのどちらを採用するかによる安全性の違いというのは、インターネットとの接続に使用する各種ソフトウェアの安全性の違いに比べれば微々たるものです。実際に何か攻撃を受けてしまったとして、「DROPだったから攻撃を受けた」「REJECTにしておけばよかった…」みたいな話になることはあまりないでしょう。とはいえ、ネットワークの情報をできるだけ秘匿したいという心理的な面も含めて多少は安全性に影響する部分もあり、またトピックとしてもそれなりに面白いと思います。

REJECTの方法の分類、TCPUDP

DROPは文字通り来たパケットを単に無視するというそれだけなのですが、REJECTに関しては「これは受け入れられません」という拒絶応答をどういう形式のメッセージで返すかという違いがあります。

拒絶応答に用いられるプロトコルICMPというもので、筆者もあまりよくわかっていませんがIPと同じL3層のプロトコルで、pingにも用いられているものです。

このICMPの拒否メッセージには以下のようなものがあります。

icmp-net-unreachable
icmp-host-unreachable
icmp-port-unreachable
icmp-proto-unreachable
icmp-net-prohibited
icmp-host-prohibited
icmp-admin-prohibited

例えば、icmp-host-unreachableは、同じサブネット内の存在しないIPアドレスpingしたときとかに(自分自身から)返ってきます。閉じている(アプリケーションがリッスンしていない)UDPポートにアクセスしようとしたときはicmp-port-unreachableが返ってきます。これはWindowsでもLinuxでも同じです。ほかのはよくわかりませんが、まあ使い道がどこかにあるのでしょう。

UDPではなくTCPの場合は、上記のどれかを返してもいいのですが、それ以外にTCPのRST(リセット)パケットを送るという選択肢があります。ICMPはTCPとは全く別のプロトコルで、ポート番号の概念も無いのですが、TCPのRSTならTCP内でやりとりが完結するので、むしろ分かりやすいかもしれません。WindowsLinuxで閉じているTCPポートにアクセスしたときにはTCPのRSTが返ります。

これら以外だとping応答など一部で使用できるecho-replyというのがあるらしい(https://www.asahi-net.or.jp/~aa4t-nngk/ipttut/output/rejecttarget.html)ですが、REJECTの方法の選択肢としては概ね上記の通りです。

Webで使われているHTTPはTCPが使われているので、REJECTの挙動はブラウザで手軽に試せます。たとえばlocalhost:33333とか適当なポート番号を打ってみると、直後(Windowsなら2秒後くらい)に「接続が拒否されました」というような応答が返ってくるはずです。

DROPも試してみる

即座に拒否が返ってくるREJECTの雰囲気は先ほど試したので、次はDROPがどんな感じなのか試してみましょう。例えばブラウザにgoogle.com:8000とか適当なポート番号を付けて打ってみると、ぐるぐると読み込み状態になるだけでいっこうに反応がなく、十秒以上ののちにブラウザがようやく諦めて「アクセスできません」「タイムアウトしました」「応答時間が長すぎます」的な表示になります。つまりDROPだと応答が一切返ってこないので、単に(回線の遅延などで)読み込みに時間がかかっているのかそれとも無視されているのかが判別できないというわけです。

アクセスしている側からすると、REJECTは「すぐに拒絶が返ってくる」、DROPは「時間がかかっているのか無視されているのか分からない」という感じになります。

ポートスキャン入門: TCP

REJECTDROPか、あるいはポートが開いているのかといったことを判断するのにいちいちブラウザを使っていたのでは手間ですし、そもそもブラウザはHTTPにしか対応していないので、専用のツールを導入しましょう。ここでは広く使われているnmapを使用します。

nmapを使えば、各ポートがそれぞれ外部からのパケットにどのように応答するかを確かめることができます。攻撃者はこのようなツールを使って65535個のポートをしらみつぶしに調べて何か攻撃のとっかかりがないか探すことがあり、このような行為をポートスキャンといいます。従って、他人のIPアドレスにむけてむやみにnmapを使うと攻撃の準備と見なされるおそれがありますが、今回は自分のPCの動作確認なので問題ありません。

nmapのインストール方法は省略します。Linuxではsudo apt install nmapです。Windows版もあるようです。

まずはlocalhost対象に、TCPからやってみましょう。確認のために、適当な番号でHTTPサーバーを立てておきましょう。

python -m http.server 8001 

それからnmapで8001を含む3つのポートをスキャンしてみます。結果が以下です。

$ nmap -p 8000-8002 -sT localhost -Pn
Starting Nmap 7.80 ( https://nmap.org ) at 2023-11-27 16:26 JST
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000097s latency).

PORT     STATE  SERVICE
8000/tcp closed http-alt
8001/tcp open   vcom-tunnel
8002/tcp closed teradataordbms

Nmap done: 1 IP address (1 host up) scanned in 0.02 seconds

HTTPポートを立てた8001だけがopen、それ以外はclosedと判断されています。ちなみに"SERVICE"の"http-alt"みたいなのは、「そのポートで一般によく使われるサービス」を参考に示しているだけで、実際に起動しているアプリケーションとは何も関係ありません。

nmapのオプションの-sTというのがTCPでスキャンすることを表しています。ちなみに、TCPスキャンの中には、root権限が必要なかわりに高速なSYNスキャンという別の方式もあり、こちらは-sTのかわりに-sSを指定します。-Pnは多分おまじないみたいなやつで、今回扱う範囲だとつけておいたほうがうまくいきます。

次に、DROPの例としてさっきのgoogle.comの例もやってみましょう。(以降、nmapの出力の余計な部分は削ります)

$ nmap -p 8000-8002 -sT google.com -Pn
PORT     STATE    SERVICE
8000/tcp filtered http-alt
8001/tcp filtered vcom-tunnel
8002/tcp filtered teradataordbms

このように、open/closedとは異なるfilteredというキーワードが表示されます。これは、応答が一定時間内に返ってこなかったことを示します(フィルタという用語は、パケットを選別する機構一般のことを指す?)。つまりDROPされたということです。

UDPでポートスキャン: 開放していてもDROPになる?

次はUDPでも試してみましょう。UDPで動くサービスということで、netcat(nc)を使ってみます。

nc -lu -p 9001

ちなみに、この-luのuがUDPを表しています。つまりTCPでも使えるということです。httpよりこちらのほうが手軽かも…。

で、nmapでスキャンすると次のようになります。UDPでのスキャンにはroot権限が必要です。

$ sudo nmap -p 9000-9002 -sU localhost
PORT     STATE         SERVICE
9000/udp closed        cslistener
9001/udp open|filtered etlservicemgr
9002/udp closed        dynamid

先ほどと似ていますが、openではなくopen|filteredという表示になっています。これはopenかfilteredのどちらであるか判断できなかったということです。次にgoogle.comの例もやってみます。

$ sudo nmap -p 8000-8002 -sU google.com -Pn
PORT     STATE         SERVICE
8000/udp open|filtered irdmi
8001/udp open|filtered vcom-tunnel
8002/udp open|filtered teradataordbms

やはり全てopen|filteredとなっています。

これはUDPTCPの動作の違いによります。TCPは双方向の接続に基づくプロトコルであり、3ウェイ・ハンドシェイクというプロセスで接続の準備を完了してからきっちり順番を決めてパケットを送っていきます。一方、UDPはもっと無秩序で、順番が乱れたりロストしたりするのも構わず事前準備なしにひたすらパケットを送り付けるというプロトコルです。この特性上、UDPで起動しているサービスは、意図した形式・内容のパケットが送られてこなければ何も応答しないこともできます。従ってnmapは、送ったパケットがルーターなど外側で弾かれたのか、それともアプリケーションに届いたのにもかかわらず応答がなかったのか判別ができないのです。いわゆるポート開放確認系のツールがTCPにしか対応していないのはこのためです。UDPで正しくポートが開放されているか確認するには実際に対応したアプリケーションで接続してみるしかありません。

TCPでは、通信内容(認証情報とか)で態度を変えようと思っても、内容のある通信を始める前に必ず形式的なハンドシェイクが必要なので、このような「openかfilteredかわからない」ような状態を作り出すことはどうやってもできません。

これはある意味ではUDPTCPより優れている点とも言えます。実際、UDPを使用するVPNであるWireguardなどはこのような点でセキュアであるという意見もあります(参考: WireGuard概要まとめ)。ただし、実際にWireguardで通信を行う際には宛先IPとポートは平文で見えてしまうので、使用するネットワーク経路を完全に信用できない限りは、誰にもポート番号が分からないようにするのは難しいかと思います。また当然ですがWireguardのセキュリティの99%以上はポート番号がバレないことではなく内容の暗号化が強力であることに由来します。

Windowsファイアウォール

DROP/REJECTの概念にも慣れてきたところで、次は実際のネットワーク機器のファイアウォールの動作について説明します。まずはWindowsです。

Windowsファイアウォールは基本的にDROPを採用しています(Windowsファイアウォールは「受信」と「送信」に分かれていますが、この記事に関連するのは主に「受信」のほうです)。また、アプリケーションごとに許可/ブロックを設定できるのが特徴で、条件に一致したアプリケーションに対するパケットのみがファイアウォールを通過できます。言い換えれば、アプリケーションが起動しているときのみポートが開放され、それ以外は全てDROP、というのがWindowsの動作です。

手元にWindowsを含む2台のPCがあればnmapで試すことができます。何も起動していないポートは、TCPならfiltered、UDPならopen|filteredと表示され、TCPのほうは起動した瞬間にopenに変わり、UDPは変わらないあるいは(アプリケーションが通信を返せば)openになるはずです。

この「許可されたアプリケーションが起動した瞬間に切り替わる」的な動作が具体的にどのように実装されているのかなどの詳細はわかりませんが、Windowsはとりあえずこんな感じです。あと、今回の議論には関係ありませんが「プライベート ネットワーク」「パブリック ネットワーク」の区別があるのも慣れると結構便利です。

Linuxファイアウォール

Linuxはそもそもデフォルトではファイアウォールが無効の場合が多いです。手元にデフォルト状態のLinuxAndroidでも可)を含む2台の端末があったらnmapでテストしてみましょう。おそらく、何も起動していないポートはtcpでもudpでもclosedと表示されるはずです。起動したらそれぞれopenとopen|filtered(またはopen)に変わります。

しかし、もちろんファイアウォールを有効にすることもできます。広く使われているのはufwで、これは内部でiptablesあるいはnftables(コマンド名はnft)を用いているのでそちらで設定しても構いません。ufwもデフォルトではDROPを使用します(前述の通りufw用語としては"deny")。これはユーザーが好きに変更することもでき、--reject-with icmp-port-unreachableや--reject-with tcp-reset(こちらはTCP限定)というオプションで各種応答を返すよう設定できます。

ufwにはアプリケーションごとのルール設定はなく、使用するポートごとに通過の可否を設定します。

  • 追記: OpenSnitchなどはアプリケーションごとの設定ができそうですが、試していません。

許可する場合は、ufw allow 8080/tcpのようなコマンドを使うことになります

WindowsLinuxの違い

ここまでの説明だと、アプリケーションで判断するかポートで判断するかというところが違うだけでWindowsLinuxファイアウォールの動作は(ufwのdenyを使うとすれば)あまり変わらないのでは?と思われるかもしれませんが、実はアプリケーションが起動していないときの動作に大きな違いがあります。

Windowsファイアウォールは、起動中のアプリケーションを見てパケットを通過させるかどうかを判断するので、ポートがどのアプリケーションにも使われていないときはそのポートへの通信はいつものようにDROPされるだけです。

しかし、(ufwなどの一般に使われている)Linuxファイアウォールは、「8080番ポートへのtcp通信を許可します」というようにポートに対して固定的に設定されます。つまり、8080番ポートがアプリケーションに使用されているかどうかとは関係なく常にポートが開放されます。するとどうなるかというと、アプリケーションが起動しているときはWindowsと変わりませんが、起動していないときはDROPされず、ポートが使用できないことを示すメッセージが返ることになります。すなわちUDPであればicmp port-unreachable、TCPであればRSTパケットが返ります。この挙動はファイアウォールで設定されたものではなく、Linuxカーネルによる標準的な応答方法であり、直接的に変える方法は無さそうです

参考までに、以下のページでもやはり無理と書いてあります。

iptables - DROP packets (instead of REJECT) from specific port when no application is listening? - Server Fault

Replying to SYN with RST,ACK when port is closed. Solutions? [SOLVED] / Networking, Server, and Protection / Arch Linux Forums

これは、ファイアウォールの基本的な挙動としてDROPを採用している場合、あまり望ましい状況ではありません。というのも、外部から使いたいサービスのために一部のポートを開放した状態で、かつサービスが起動されていないとき、開放されているポートは他のポートとは異なる挙動をすることが外部から観測できてしまうからです。TCPの場合は、起動している場合はどうせ外部からわかってしまうのでまだいいのですが、UDPだと「openだとしても外部からはfilteredとの区別がつかない」というせっかくのメリットが、アプリケーションの停止時には失われてしまいます

TCPに関してだけなら--reject-with tcp-resetを設定することで「開放されているもののlistenされていないポート」と「開放していないポート」の挙動は揃えられますが、UDPでは同様の発想で--reject-with icmp-port-unreachableにするとこんどはアプリケーションが起動しているときに特定ポートだけ反応が変わってしまいます。

別の方法として、アプリケーションの起動中にだけ動的にポートを開放するようにスクリプトを組むことはできるかと思いますが、Windowsファイアウォールが「listenしているアプリケーションがいなければ無視」というシンプルな挙動をそのまま実現しているのに比べると筋の悪そうな方法です。

ということで、この記事で言いたかったことの一つは、Linuxファイアウォールって実は重大な欠陥品じゃね?ということです。アプリケーションごとのルール設定ができないのはしょうがないとしても、ファイアウォールの一環として「開放されているもののlistenされていないポート」に来た(自分自身以外からの)パケットへの応答をDROP/REJECTで設定できる機能を最低限提供するべきではないでしょうか?

追記: Windowsのstealth mode

後から知ったのですが、Windowsのみに存在する「閉じているポートへのパケットには応答せずDROPする」という動作は、stealth modeという名前の付いたれっきとした機能だったようです。レジストリから無効にすることもできるようです。

Stealth Mode in Windows Firewall with Advanced Security | Microsoft Learn

隠しモードを無効にする方法 - Windows Server | Microsoft Learn

ただ、Windowsではそもそも開放するポートを限定しないので、無効にした場合の動作がよくわかりません。全ポートがreject応答をするようになって、ファイアウォールは「許可されていないアプリケーションへの通信だけはブロックする」という役割になるのでしょうか。その場合DROPREJECTのどっちになるんでしょう…?

各種ルーター

汎用のコンピュータであるWindowsLinuxと共に、ネットワークの構成要素として避けて通れないルーターの動作についても説明します。

といっても、廉価な市販品も含め、ルーターにはLinuxベースのOSが入っていることも多く、PCと厳密に区別する意味はあまりありません。逆に、汎用PCとして使っているLinuxマシンをルーターとして使うこともできます(ただ、ethernetポートが2つついてないとさすがに厳しいかな?)。その場合は、Linuxで設定できる基本的な内容は設定できるかと思います。

ただ、一般的な家庭にあるルーターの動作という点でいえば、外部からの不要パケットは全てDROPに設定され、この部分を変更できないものがほとんどだと思います。パケットフィルタ設定はありますが、それはあくまでDMZやポート転送などでルーターの内側に入ってくるパケットへの対処を決めるものであり、無関係なポートに来るパケットへの反応は変更できません。

また、当然ながら、ポート転送(開放)設定で内側の機器のポートを外側に晒す場合、内側のポートが実際に稼働しているかどうかで挙動を変えるような(Windowsみたいな)ことはしてくれません。(高機能なルーターなら絶対に無いとは言い切れませんが…)

従って、ルーター経由でポートをインターネットに公開することを考えるなら、内側の機器の動作もルーターに揃えるのが望ましいということになります。ルーターDROPで動いているなら、ポートを晒しているPCもそのポートはできるだけDROP的な動作にしたほうが目立たなくて済みますし、REJECTの場合も同じです。

DROP vs REJECT、結局どっち?

ここまで見てきたところで、結局DROPREJECTどっちがいいの?という話に戻りましょう。いくつかの既存の議論も載せておきます。

パケットフィルタリングで不正パケットをDROPするかREJECTするか問題 - 新・日々録 by TRASH BOX@Eel わかりやすく、詳しい。良記事。ホストの存在自体を隠すのはどうせ無理という話が丁寧に書いてある。

iptablesはDROPすべきか、REJECTか。tcp-resetという手も - のめうブログ

iptablesは DROP , REJECT or --reject-with tcp-rest - Note to Self 上のブログを参考にしてそう

(以下は英語)

Drop versus Reject

IPtables: TCP-Reject vs DROP : linux

iptables REJECT vs DROP | todisco.de

[ale] iptables: DROP vs. REJECT --reject-with tcp-reset

Closed Ports Vs Stealth Ports, Drop Rules Vs Reject Rules - Which is better | Ron's Tech Tips

linux - REJECT vs DROP when using iptables - Server Fault

ufw Linux firewall difference between reject and deny - Stack Overflow

ip - Is it better to set -j REJECT or -j DROP in iptables? - Unix & Linux Stack Exchange

SANS - Internet Storm Center - Cooperative Cyber Threat Monitor And Alert System

Revisit REJECT vs DROP in #507 · Issue #2217 · fail2ban/fail2ban · GitHub

Is it better a CLOSED port or a FILTERED port to for DoS attack protection? : AskNetsec

linux - Why does iptables accept packets on a given port, when it is closed? - Unix & Linux Stack Exchange

[ubuntu] Is "Stealth" more secure than "closed" ports?

これらの記事と今までの内容をまとめると、大体以下のようになるかと思います。筆者の意見もあります。

  • DROPを支持する意見
    • 攻撃者がポートスキャンを行う際にタイムアウトを待つ(1秒程度のオーダー)必要があるので、時間をかけさせることができる
    • 応答パケットを返さないので、PCへの負荷・回線への負荷・一部のDDoS攻撃への耐性の面で優れる
    • UDPの場合、DROPで統一していれば稼働中であってもアプリケーションの存在を完全に隠蔽できる
    • そもそも市販ルーターだとたいていDROP一択(なので、内側でもそれに従うしかない)
    • 単純にみんな使っているので無難(REJECTすると、積極的にネットワーク設定をいじる中上級ユーザーと思われて狙われるかも?的な)
    • google.comだってDROPしてたわけだし…
  • REJECTを支持する意見
    • 応答が一瞬で返ってくるので、悪意のないユーザーを不必要に待たせることがない
    • TCPの場合、tcp-resetでREJECTすることで、Linuxの「ポートが開放されているもののアプリケーションがいない」状態を模倣できる

まず最初に重要なのは、一番外側の(インターネットに晒されている)機器の動作に揃えなければいけないということです。攻撃者というのは大抵はLAN内ではなくLAN外から入ってくるので、外側とのゲートウェイである市販ルーターなどの動作に従って、ルーター経由でサービスを公開しているPCの動作も決める必要があります。となると、一般家庭ではDROP動作をするルーターがほとんどなので、全体をDROPで統一するしかないということになります。外からのアクセス経路がなくLAN内からでないとアクセスできないPCではこの限りではありませんが、わざわざ変える理由もないでしょう。

さらに、UDP/TCPの各プロトコルごとに見ていきます。

まずUDPについてはDROPが明確に有利なのではないかと思います。DROPで統一することで、稼働中のアプリケーションの存在を隠蔽できるというメリットがある一方で、REJECTを使うことによるメリットはあまり見当たりません。

一方、TCPについては、REJECTも悪くはないですが、やはりDROPのほうが無難なのは間違いないと思います。ポートが開いていれば確実にバレてしまうので、それ以外のポートがclosedだろうがfilteredだろうが、使っているポートを知られるかどうかという点では変わりません。でも、それだったら省エネで済むしスキャン時間も稼げるDROPのほうが幾分マシです。REJECTに明確なメリットは見当たりません。

REJECTを支持する意見のうち、「悪意のないユーザーを不必要に待たせることがない」については、そもそも悪意のないユーザーが開放していないポートにアクセスしてくることは通常ないので、あまり説得力がありません。

2番目の「tcp-resetによるREJECTに統一すれば、全てが単なる"閉じているポート"に見える」というのも、いくつかのサイトに書いてあり、一見それっぽいですが、今まで書いてきた通り、これはあくまで「Linuxの挙動を前提にしたメリット」でしかありません。Linuxは、開放に設定したポートにアプリケーションがいない場合にtcp-resetを返してしまうので、やむを得ず周りをそれに揃えたというだけの話です。Windowsであれば、アプリケーションがいない場合はポートも閉じているのでtcp-resetが返ることもなくDROPされ、それで問題なく全ポートの挙動が揃います。tcp-resetが優れているというより、tcp-resetを使わせるLinuxが悪いです。

UDPの場合もicmp-port-unreachableを使えば閉じているポートを模倣できますが、こちらはアプリケーションが起動しているポートだけが目立ってしまうという欠点があるのは既に述べた通りです。

以上の通り、基本的には全てDROPで統一すべきであるというのが筆者としての結論です。Linuxだとアプリケーションが閉じているときにそのアプリケーション用のポートの挙動が変わってしまいますが、それはLinuxの責任です。この後、これを何とかする方法を紹介します。

最後に、もう一度言っておきますが、DROP vs REJECTのどちらを選ぶかよりも、実際に脆弱なアプリケーションをインターネットに晒さないことが一番大事です。

Linuxの挙動をWindowsに近づける

ここまでで、我々はDROPを採用すべきであることがわかりました。Linuxを使う場合は、挙動をそれに適合させるための工夫が必要です。

現状の問題点は、「開放されているもののlistenされていない」ポートにアクセスがあったら、TCPならTCPのRSTパケット、UDPならICMPのport-unreachableが送り返されてしまうということでした。それなら、これらのパケットが送り返されることを防止できれば、それは単なる無視(=DROP)と同じ動作になります。

前述の通り、これらのパケットをLinuxカーネルが生成して送り出そうとするのを止めることはできませんが、生成されたパケットが実際にLinuxマシンから外に出ていこうとするタイミングで止めることは実は可能です。

このためにはufwのバックエンドでもあったiptables、あるいは最近のLinuxならnftablesを使います。筆者のUbuntuはnftablesを使っているようだったのでそちらを使いますがiptablesでも本質は変わらないと思います。

TCPなら、特定ポートから出ていくTCP RSTパケットを全滅させるルール、UDPなら特定のポートに関するicmp-port-unreachableパケットを全滅させるルールを追加すれば目的は達成できます。

ここで問題なのは、閉じているポートに来たアクセスへの応答以外の用途でこれらのパケットが使われることはないのか?ということです。他の用途で使われるにもかかわらず全て一律に止めてしまったら副作用が出るかもしれません。

ただ、まずicmp-port-unreachableは、その名の通りポートに到達できないときのエラーメッセージとしてしか使われず、プロトコルもICMPなので、これを止めたところで正当なUDP通信に影響することはありません。TCPのRSTに関しては、ソフトウェアによって送信されることもありますが、それは強制的に接続を切断しなければならないような異常事態が発生したときだけで、通常の接続終了(HTTPでページ読み込みが完了したとかゲームのサーバーからキックするとか)ではFINパケットなどが使われるのでRSTが使えなくても問題ありません。さらに、ポートが閉じている際のRSTパケットにはsequence番号が0であるという特徴がある(sequence番号が0ならポートが閉じているかというとそうとは限らないかもしれないが多分大体はそうっぽい)ので、これを活かしてポートが閉じている際のRSTだけをより限定的に禁止することが可能になります。

ただ、それでも従来のネットワーク環境への影響はできるだけ少なくしたいところです。そこで今回は、外部に公開するための専用のIPアドレスを設けて、そこから出ていくRSTやicmp-port-unreachableを禁止するというやり方でやってみます。

IPアドレスの追加と各種パケットの禁止

ここでは、PCのもともとのIPアドレスが192.168.1.2で、追加するIPアドレスが192.168.1.3であるとします。NIC名はeno1とします。PCの外側にはDROPに設定されたルーター(ポート開放機能あり)があるとします。

まず、以下のようなコマンドでIPアドレスを追加します。

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

再起動すると消えてしまうので、永続化する場合は/etc/network/if-up.dとかに書いてください。IP追加に関して詳しくは筆者の過去記事IPoE/PPPoE併用時(など)に一つの端末から同時に複数の接続経路を利用する - turgenev’s blogや他サイトもご参照ください。

次に、192.168.1.3から出ていくicmp-port-unreachableとTCPのresetを禁止します。TCPポートとしては8000を使うことにします。もちろん、全ポート対象や範囲指定も可能です。

まず、IP(v4)関連ルール追加用にテーブルを新規作成します(正直nftのベストプラクティスがよくわかっていないのですが、既存テーブルへの追加でも構いません)。

sudo nft add table ip my_ip_table 

次に、ここにpostroutingのfilterを設定するためのchainを追加します。

sudo nft add chain ip my_ip_table postrouting_filter "{type filter hook postrouting priority 700; }"

ここでpriorityを700と指定しているのがちょっと重要で、nftablesのルールというのはpriorityの値が小さいものから順に適用されていきます。47.3. nftables テーブル、チェーン、およびルールの作成および管理 Red Hat Enterprise Linux 8 | Red Hat Customer Portalにある通り、デフォルト設定で使われている値は-300から300くらいまでの範囲なので、300より大きい値(700は適当に選びました)を指定しておくことで、このfilterが最後に適用されるようにできます。

ちなみに、ここではpostroutingのhookを使っていますが、outputを使っても似たようなことができます。ただ、outputは少し適用タイミングが早く、パケットが生成されるときに適用されるので、後述のようにSNATをかけた後のパケットも対象にしたい場合は送信直前に適用されるpostroutingを使う必要があります。

そして、実際のルールを設定します。まずTCPです。

sudo nft add rule ip my_ip_table postrouting_filter ip saddr 192.168.1.3 tcp sport 8000 "tcp flags & rst == rst tcp sequence == 0 drop"

tcpポート8000から出ていくパケットのうちrstフラグを持っていて(flags & rstはビット演算)、かつsequenceが0であるものdropさせています。なお、特にsequence番号での振り分けルールはインターネット上にほとんど例が載っておらず、manページを読まないと書き方がわかりませんでした。構文がいくつかあり、"tcp flags & rst == rst @th,32,32 {0} drop"みたいにth(transport heading)からのビット数で指定する方法でも同じように動きます。iptablesの後に来るものは何か?: nftables - 赤帽エンジニアブログにあるような[payload load 4b @ transport header + 4 => reg 1] [cmp eq reg 1 0x00000000]みたいな構文(バイト数指定なので32ではなく4)もあるようですがこちらは正確な書き方がわからず手元では使えませんでした。

次にUDPです。

sudo nft add rule ip my_ip_table postrouting_filter ip saddr 192.168.1.3 icmp type destination-unreachable icmp code port-unreachable @th,240,16 {8000, 8080} drop

このようにtypeとcodeを用いてパケットの種別を指定した上で、先ほどのようにヘッダからの位置で元のUDP通信のdestination portが載っている部分を指定することで8000と8080ポートに関するport unreachableをdropさせています。この記法はほぼ何でもできてかなり強力ですね。なおICMPの仕様については6.5 ICMP Port Unreachable Error | TCP/IP Illustrated, Vol. 1: The Protocols (Addison-Wesley Professional Computing Series)を参考にしました。また、内側(元のUDP通信)のIPヘッダサイズが20であることを仮定しています。

今回は送信先を限定せずRSTやport-unreachableを一律に禁止したので、効果は自分自身からでも確かめることができます。

$ nmap -p 7999-8001 -sT 192.168.1.3 -Pn
PORT     STATE  SERVICE
7999/tcp closed irdmi2
8000/tcp filtered http-alt
8001/tcp closed vcom-tunnel

このように、192.168.1.3に対してnmapしたときはポート8000だけがfilteredとなっているはずです。192.168.1.2に対して実行したときは全てclosedになることを確かめてください。

同様に、UDPについても、192.168.1.3に対して実行したときは全てopen|filteredになることを確かめてください。(同じく7999-8001を指定していますが、何でも構いません)

$ sudo nmap -p 7999-8001 -sU 192.168.1.3 -Pn
7999/udp open|filtered irdmi2
8000/udp open|filtered irdmi
8001/udp open|filtered vcom-tunnel

これで、TCPの192.168.1.3:8000やUDPの192.168.1.3:*を外部から見えるようにしたとしてもアプリケーションが起動していないときはDROPの動作をするようになります。

自分自身やLAN内からアクセスしたときにDROPさせるかどうかなど、場合によって多少変えるべきところはあるかもしれませんが、基本的にはこれでLinuxの動作をほぼWindowsと同様にできます。

HTTPサーバーで試す

TCPのRSTパケットが正しくフィルタされているか確かめるため、ページ読み込みに5秒かかるHTTPサーバーを用意して実験してみました。サーバーはpythonで書きます。

from http.server import BaseHTTPRequestHandler, HTTPServer
import time

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # 5秒遅延
        time.sleep(5)
        # HTTPレスポンスヘッダを送信
        self.send_response(200)
        self.end_headers()
        # レスポンスボディを送信
        self.wfile.write(b'Hello, world!')
# サーバーを設定し、起動
port = 8000
httpd = HTTPServer(('', port), SimpleHTTPRequestHandler)
print(f"Server running on port {port}")
httpd.serve_forever()

そして、tcpdumpでパケットをキャプチャします。

sudo tcpdump -i any tcp port 8000

この状態で、例えばブラウザから192.168.1.3:8000にアクセスし、5秒の読み込み時間の途中でpythonを強制終了すると(sequenceが0でない)RSTパケットが送られているのがわかると思います。一方で、サーバーが起動していない(ポートが閉じている)ときのパケットは先ほどのルールで全てdropされ、tcpdumpには一切表示されません。サーバー停止時は、ブラウザ側ではずっと読み込み中の表示になります。

netcatで試す

こちらの方がより挙動がわかりやすいので追記しました。

netcat(ncコマンド)をTCPモードで使ったとき、接続をサーバー側から終了する(サーバーに対してCtrl+Cを送信する)と、クライアント側ではただちにコマンドが終了することはありませんが、その後にもう一度データを送信すると、サーバー側からRSTが送信され、これによりクライアント側の実行も終了します。このRSTは既存の接続に関連したものであるためsequence番号は0ではありません。(このへんの挙動について詳しくはtcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話 #C - Qiitaに載っています)

従って、sequence番号が0のRSTだけDROPさせた場合は、上記の通りクライアント側の実行は終了しますが、その後にもう一度サーバーに接続しようとすると(サーバーは実行されていないので)sequence番号が0のRSTが生成されて、これはDROPされるので何も返ってきません。比較的良い感じの動作といえます。

一方ですべてのRSTをDROPさせてしまうと、サーバー側が終了した後にクライアントでデータを送信しても何も返ってこないためクライアント側の実行がしばらく(CLOSE_WAITのタイムアウトになるまで)終了しません。これはちょっと微妙な動作という気がします。

Tailscaleをうまく動かす

実は、この記事を書いた当初の目的はVPNサービスのTailscaleのために開放しているポートが(Tailscaleの停止時に)closedに見えてしまうのを防止するためでした。ここまでの設定でそれはできたのですが、このままだとTailscaleのUDPによるダイレクト接続がうまく成立しません。なぜなら、Tailscaleはoutgoing IPとしてメインの192.168.1.2のほうのIPを使おうとするため、外からの通信とうまくマッチしなくなってしまうからです。

Tailscaleに限らず、単に受動的にサービスを公開するだけでなく能動的に外部に出ていこうとするようなソフトウェアだと同様の問題が起こる可能性があります。

対処法としては、192.168.1.2と192.168.1.3の間でDNAT/SNATをかけて、外部との通信を全て192.168.1.3経由にしてしまいます。さっき作ったmy_ip_tableの中に、postroutingとprerouting向けのnatタイプのチェインを追加し、それぞれに対してルールを追加します。ここではポート番号としてTailscaleのデフォルトの41641を使用しています。

sudo nft add chain ip my_ip_table postrouting_nat "{type nat hook postrouting priority srcnat;}"
sudo nft add chain ip my_ip_table prerouting_nat "{type nat hook prerouting priority dstnat;}"
sudo nft add rule ip my_ip_table postrouting_nat ip saddr 192.168.1.2 udp sport 41641 snat to 192.168.1.3:41641
sudo nft add rule ip my_ip_table prerouting_nat ip daddr 192.168.1.3 udp dport 41641 dnat to 192.168.1.2:41641

これで、あとはルーター側の41641番ポートを192.168.1.3:41641にマッピングすればTailscaleのポートが公開された状態になります。起動中も停止中も外部からポート番号を推測されることはありません。

なお、192.168.1.2:41641は直接的には使えなくなってしまいますが、LAN内端末との通信でも192.168.1.3:41641を使ってちゃんと接続してくれるようです。問題があれば、snatルールからLAN内(プライベートIP)との通信を除外するなどの方法で対処できるかと思います。

設定結果、永続化など

今まで述べたnftコマンドでの設定内容をまとめて確認するため、sudo nft list rulesetで確認するとmy_ip_tableの内容は以下のようになっていると思います。

table ip my_ip_table {
        chain postrouting_filter {
                type filter hook postrouting priority 700; policy accept;
                ip saddr 192.168.1.3 icmp code port-unreachable drop
                ip saddr 192.168.1.3 tcp sport 8000 tcp flags & rst == rst tcp sequence 0 drop
        }
        chain postrouting_nat {
                type nat hook postrouting priority srcnat; policy accept;
                ip saddr 192.168.1.2 udp sport 41641 snat to 192.168.1.3:41641
        }
        chain prerouting_nat {
                type nat hook prerouting priority dstnat; policy accept;
                ip daddr 192.168.1.3 udp dport 41641 dnat to 192.168.1.2:41641
        }
}

もし意図した通りに動いていない場合はsudo nft list rulesetの結果をこれと見比べてみましょう。

IPアドレスの追加と同様、nftによる設定も再起動後には消えてしまうので永続化する必要があります。

まず、nftablesサービスを有効化します(sudo systemctl enable nftables)。これはシステム起動時にnftablesの設定ファイル(ubuntuなら/etc/nftables.conf)の読み込みを行うOneShotタイプのサービスです(常時稼働のデーモンではない)。その上で、nftables.confに先ほどのmy_ip_tableの内容をそのまま追加します。あるいはincludeを使ってファイルを分けることもできるようです。検索すると、sudo nft list rulesetの結果をそのままnftables.confに書き込むものが多く出てきますが、tailscaleなどによって動的に挿入されたルールなどが混入してしまうため、個人的には元ファイルを直接いじった方がいい気がします。

結論

ファイアウォールの挙動としてDROP vs REJECTのどちらが良いかという論争がありますが、多数派がDROPであること、UDPのサービスを隠蔽できることなどからDROPを選択すべきです。Linuxファイアウォールには、閉じているポートの動作がDROPにならないというWindowsファイアウォールにはない欠陥がありますが、これはiptables/nftablesで適切にルールを設定することでおそらく通常使用にほとんど影響を与えずに解決することができます。