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もできるようです。

従って既存のソフトウェアと干渉しないように独自ルールを一括で管理するのは一応できそうです。

また、firewalldについてもufw同様にこの記事の手法が有効です。

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

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

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

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

Linuxで制限コーンNAT(EIM/ADF)を動かす

概要

前回記事のNATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、netfilter(iptables/nftables)の動作 - turgenev’s blogでは、UDPホールパンチングのしやすさとポートの節約を両立するには「Address Dependentなマッピングを保持しつつEIM風に動作するADFなNAT」が一番いいのではないか?という主張をしつつ、実際にこれを動かすところまでは達成できませんでした。

その後、ユーザースペースで動作するruby製NATであるratGitHub - kazuho/rat: NAT written in pure ruby)を手元で動かし、またコードを少しだけ変更することで前述のNATを実際に動作させることができたため、これについて紹介します。変更後のコードはGitHub - ge9/rat: NAT written in pure rubyに公開しています。

NATタイプやUDPホールパンチングなどについては適宜前回記事も参照してください。

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にはポリシーベースルーティングがあるので、まあ割とどんなルールでも指定できてしまうのですが、そもそもルーティングが行われないという状況も存在し、それだとratは上手く動作しません。それが、パケットの行き先アドレスがPC自体に割り当てられたアドレスである場合です。この場合、そのIPに直接(PC内で)パケットを送ればいいのでルーティングを行う必要がありません。

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

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

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

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

ratはrubyで書かれているのでrubyを入れます。そんなに新しい機能とかは使っていない気がしますがパフォーマンス的にはver3.0以降くらいのRubyがあると良さそうです。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するルールを書きます。

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しか使っていませんが、本来はもっと色々ちゃんとやる必要があるかと思います。

ファイアウォールの設定

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

ここでは細かい設定は省略しますが、よくあるファイアウォールの設定では、①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からリモート側ポートの情報を使わないように変更するだけです。載せても別に面白くないので割愛します。

試してみる

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

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

静的なポート変換など

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

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

Path MTU Discoveryが失敗する

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

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

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

  • TCPに関して、sudo iptables -t mangle -A POSTROUTING -p tcp --tcp-flags SYN,RST SYN -o rat -j TCPMSS --set-mss 1414のようにMTUから40を引いた値をMSSとして設定する。TCPについてはこれが最も標準的な方法。詳しくは【図解】MTUとMSS, パケット分割の考え方 ~IPフラグメンテーションとTCPセグメンテーション~ | SEの道標を参照。しかしppp0にはもともとこのようなルールが設定されていたのにそれでうまくいっていなかった理由はわからない。
  • rat自身のMTUもppp0に合わせて1454に設定する。これも比較的まともで、unreachableパケットがratを経由せず直接元の端末に送信されるため今回の問題は起こらない。
  • 前述のrp_filterをallとratに関して0にする。これでパケットがきちんと送られるようになる。ただ、セキュリティ的にちょっと納得いかない。
  • rat内で送信元アドレスを別のものに書き換える。ただ、ハードコードするものが増え、あまり綺麗ではない。

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

パフォーマンス

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

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

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

余談: eBPFによるカスタムNAT実装

今回のようなtunデバイスを使う方法はユーザースペースでのNAT実装ですが、eBPFというのを使うとカーネルスペースにも独自機能を追加でき、パフォーマンスの向上が期待できます。実際、GitHub - EHfive/bpf-full-cone-nat: A Full Cone(EIM + EIF) NAT implemented in eBPFなどがそういうことをしていて、手元でも一応動かすことができました。EIM/ADFにすることも理論上はできるようですが、結構大変そうです。

余談: Map-eで制限コーンorフルコーンNATにする

近年増えてきたIPoE(IPv4 over IPv6)接続の回線のうち、DS-LiteはフルコーンNATなので(ポートが枯渇さえしなければ)UDPホールパンチングには最適なのですが、Map-e方式だと市販ルーターは大抵ポート制限コーンNATの実装で、しかも使えるポート数に制限があってDMZホスト機能(多くの場合フルコーンNATに近くなる、詳細は前回記事)も使えないのでフルコーンNATにできないという主張を多く見かけます。

しかし引用したkazuhoさんのツイートにもあるようにこれは間違いです。ポート数制限があっても適切なシステムを使っていればちゃんとフルコーンNATになります(なお、タイプAというのはNintendo Switchにおけるフルコーンnatの呼称です)。

ratを稼働させているLinuxをそのままmap-eのルーターにしてもいいですが、それだとIPトンネルの設定とか結構難しそうです。もし、使用しているmap-eルーターポートの割り当てに規則性(内側のポートと同じものが使えれば外側でもそれを使うとか、内側ポートを利用可能ポート数で割った余りで決まっているとか)(これも前回記事参照)があれば、それに従って外側からも内側に向けてポートを開放し、そのポートだけを使うようにratを動作させれば全体としてフルコーンNATや制限コーンNATとして機能します。実際、自分のOCNバーチャルコネクトでは、ratの使用ポートを9952-9999(48個)にした上で、NTTのHGWで58208-58223をLinuxの9952-9967、59232-59247をLinuxの9968-9983、60256-60271をLinuxの9984-9999にむけて開放することでHGW配下のLinuxのさらに配下にある端末が制限コーンNATやフルコーンNATの環境になることを確認しました。(割り当てるべきポートはstunclientで各自確認しましょう)

なお、ポート48個というのはまあまあ厳しい条件なので、接続するのはゲーム機とか、是非ともUDPホールパンチングしたい端末だけにしておくと安全かもしれません。あるいは、53番ポート以外のUDPに対してのみratを使うといったやり方もあるでしょう。

ルーターがSymmetric NATだった場合は規則性がないことになるのでこのやり方は無理そうです。

ちなみに、Nintendo SwitchPS4では制限コーンNATはNATタイプBあるいはタイプ2といった、ポート制限コーンNATと同じグループに分類されているようですが、理論的にはフルコーンNATと同じくらいUDPホールパンチングはやりやすいはずなので、意外とちゃんと動くのではないかと予想しています。持っていないので誰か試してみてください。

NATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、netfilter(iptables/nftables)の動作

概要

この記事では、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と呼ぶようです。

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」という関係が成り立っている可能性もあるのではないかと思います。

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の動作はいくらでも考えられます。

前述の「ポートの共有を行うようなNATでEIMでもADMでもAPDMでもないもの」や「Connection Dependent Mapping (or Filtering)」、あるいは「基本的にEIMだが、気が向いたら内側の(Address, Port)が同じでも別のポートを使ってみる」といったような振る舞いをするNATも理論上は可能です。

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

NATはRFC 4787では非決定的NAT(Non-deterministic NAT)などと呼ばれています。

また、名称についてですが、Nintendo SwitchだとNATタイプA, B, C、PS4だとNATタイプ1, 2, 3などの呼称があり、これらはそれぞれフルコーンNAT、(ポート)制限コーンNAT、シンメトリック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機器は様々なアルゴリズムを使って内側の機器に未使用のポートを割り当てていくわけですが、使用可能なポートの数は有限なので、いつかはポートが全て埋まってしまいます。ポートを割り当てることができなければ、ルーターの外側との新規の通信はできなくなってしまいます(ポートの枯渇)。

このため、どの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"の定義とは合致しない)ことになります。

ポートセービングIPマスカレードとSymmetric NATの関係

ネット上では、ポートセービングIPマスカレード(ポートの共有)とSymmetric NAT(ADM, APDM)が同義であるかのような誤った説明をしばしば見かけるのでこれに関して改めて説明しておきます。

今までの説明からわかる通り、ポートの共有が行われることとSymmetric NATであることは基本的には完全に独立です。「ポートの共有を行わないSymmetric NAT」も「ポートの共有を許容し、結果として同じ内側(Address, Port)から複数の異なる宛先への通信に同じポートが使われる可能性があるNAT」も何の矛盾もなく存在します。

唯一、確実に言えることは、「ポートの共有が行われるならEIM(cone NAT)ではない」ということです。

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

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ホールパンチングは困難です。

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

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

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

TCPホールパンチング

STUNプロトコル自体はTCPでもUDPでも使用可能で、その他の部分に関してもTCPUDPで特に差は無いため、TCPにおいてホールパンチングが成立する条件は(Mapping/Filteringが同じであれば)実はUDPと変わりません。しかし実際には多くのNAT機器は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ではエラーになってしまいます(かといって指定しないと結果がおかしくなる)。

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

上記の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リクエストを投げるのは結構面倒なのであまり使う場面は無さそうです。

netfilter及び手元のルーターのNAT挙動

stunclientを使用して身の回りのNAT環境(Linuxのnetfilter及び手元の3台のルーター)の動作を調べてみました。それぞれの詳細は以下の通りです。一応説明しておくとnetfilterとはパケットのフィルタ・変換を担当するLinuxカーネルの機能で、iptablesとかnftablesとかufwとかfirewalldのような様々なフロントエンドがありますがすべて裏ではnetfilterが動いています(記事では説明のためiptablesを用います)。

netfilter…Linux Mint 21.2。SNATルールを使用し、TCPUDPともに100個のポートを使用可能としてルーター化した。具体的にはsudo iptables -t nat -A POSTROUTING --proto tcp -o enp1s0 -j SNAT --to-source 192.168.1.15:55500-55599のようなコマンドを使用した(UDPも同様)。つまり、--random(-fully)は使っていない。

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

ルーター2…BuffaloのWSR-1166DHPL2で、PPPoE接続。

ルーター3…NECAterm WX1500HPで、ローカルルーターとして使用。

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

  • 内側のポートに応じて(アドレスは無関係)デフォルトの割り当てポート番号が設定されていて、基本的には(通信先の(Address, Port)によらず)それが割り当てられる(=EIMのように振る舞う
  • そのポートが既にほかの「内側の(Address, Port)」によって目的の(自分が今通信しようと思っている)「外側の(Address, Port)」との通信に使われているときは、ルーティングが競合するのを避けるため、そのポートは使わない。
  • 逆に、宛先の(Address, Port)が異なっていれば、異なる内側(Address, Port)が同時に同じポートを使用することは可能(=ポートの共有あり)。
  • デフォルトの割り当て先は、内側ポートと同じ番号が利用可能ならそれを使い、(競合、あるいはMap-eで利用可能な1008個やiptablesのto-sourceの範囲に含まれていないために)使えなければ代替のポートを使用する
  • ルーター1ではポート番号を1008で割ったもの(具体的には、余りがnなら利用可能なポートのうちn番目(ただし最初が0番目とする)のもの)が最優先の代替として使用され、それも空いていなければ連番で増やしていって最初に空いていたものを使用する
  • ルーター2の代替ポートは規則性が不明だが、5000以下など、番号の小さいものが使われることが多い
  • ルーター3では1000番台などかなり小さいものが代替として使われることが多い
  • netfilterでは規則性が不明(ポート範囲を変えて実験してみてもよいかもしれない)
  • ルーター2では、競合が起こっていないはずの状況でも代替ポートに割り当てられることも結構ある気がする
  • UDPでは、競合が起こるなどして代替ポートに割り当てられたら、その後の同じ内側ポートから他の宛先への通信もそちらに割り当てられるようになる(ことが多い)(やはりEIM的な挙動?)
  • UDPでは既に送信履歴がある(Address, Port)からでないと通信を通さない(APDF)。TCPでは送信履歴にかかわらず外側からの新規通信(内側からの通信への応答ではないもの)は通さない(Connection Dependent Filtering)
  • TCPにおいては、同じ内側(Address, Port)から外側(Address, Port)へと通信してその終了直後に再び同じ宛先で通信を開始した場合などに、別のポートが使用される場合もある(Connection-Dependent Mapping的な挙動)。特にnetfilterで55500-55599以外のポートから発信した場合に顕著に見られたほか、ルーター2でも発生した。

ちなみに、内側の端末のアドレスではなくポート番号のみに応じてルーターのポートが割り当てられるという特性上、異なるIPの同じポート番号から(ほぼ)同時に通信を実行すると簡単にポートの競合・共有に関する挙動を確かめることができます。テストでは主にstunclientを用いましたがnetcat(nc)も便利です。また、conntrackを使うと直接的にマッピングの状態を閲覧できます。

実験結果の検討

上記のNATはどれも、基本的にはEIM風に振る舞う(Symmetricではない)が、ポートセービングIPマスカレード的な挙動をするためにポートが共用されることもある(従ってEIMでもない)という、まさに今まで説明してきた「Symmetric NATではなくポート共有があるNAT」の典型例になっています。ちなみに、Buffaloなどの通常の家庭用ルーターのOSは大半がLinuxのはずで、今回使ったルーター1-3も内部ではLinuxのnetfilterが動作していてその結果としてこうして互いに似通った動作をしているのではないかと思われます。

RFC 4787では、場合によってマッピングやフィルタリングの動作が異なるNATを非決定的NAT(Non-deterministic NAT)と呼んでいて、「Non-deterministic NATs generally change behavior when a conflict of some sort happens, i.e., when the port that would normally be used is already in use by another mapping.」という記述はまさにこのnetfilterの挙動に合致します。RFC 4787ではこのようなシステムは動作が予測しづらいとして望ましくないとしています。

しかし、競合が起こらなければ基本的にはこのnetfilterの挙動はEIMと同等であり、例えば仮にPC1を担当するNAT機器(netfilterとする)とPC2を担当するNAT機器(こちらもnetfilterとする)との間で長期間にわたって通信が発生していない状態からUDPホールパンチングを開始する場合を考えれば、(互いに向けて通信を開始する際に既存の接続との競合が発生することがないため)問題なく通信が成立します(TCPはConnection Dependent Mappingなので困難)。仮に競合が発生したとしても、何度か試行すれば成立する可能性は高いでしょう。

むしろ、UDPホールパンチングの成立しやすさとポートの共有を両立している点で優れた動作であるとも言えるかもしれません。実は、全く同じようなことがdraft-naito-nat-port-overlapping-01という文書(最後の方のDiscussions)にも書いてあります。(これも関連? https://www.ietf.org/proceedings/87/slides/slides-87-behave-7.pdf

ただ、(宛先ポートのみが異なる場合にもポートの共有を許した結果として)フィルタリングがAPDFなので、相手がSymmetric NATである場合にはUDPホールパンチングが困難になってしまうのは惜しいところです。

現実には同じアドレスの異なるポートにそれぞれ大量のアクセスをしたいという場合は限られる(80番だけ、あるいは443番だけ使うようなケースが多い)のと、EIM/ADFであればSymmetric NATが相手でもUDPホールパンチングが成立することを考えれば「EIM風、アドレスが異なる場合のみポート共有あり、ADF」なNATが最強なのでは?という気がします。しかし、残念ながらLinuxでこれを実現する簡単な方法はない(netfilter自体の書き換えが必要)と思います(ちなみにfull cone NATはGitHub - Chion82/netfilter-full-cone-nat: A kernel module to turn MASQUERADE into full cone SNATがやってそう)。誰かやってくれないかなあ…

外部からのパケットによるマッピング割り当ての問題

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

例えば、PC1でnetfilterが動いているとして、PC2の30000番からPC1(たとえば1.1.1.1)の40000番にUDPパケットを送信すると、(40000番をリッスンしているアプリケーションが無く一切応答が返らなかったとしても)[UNREPLIED]という扱いで1.1.1.1:40000自体に対してこのマッピングが保存されます。次にPC1をルーターとして使用している別の端末(例えば192.168.1.1)がPC2の30000番にアクセスしようとすると、PC1の40000番は既に1.1.1.1:40000によって使用されているため競合し、40000番以外のポートが使用されます。

つまり、自分が相手と通信するために通りたいポートがあったとしても、そこに向かって通信相手のほうから先にパケットが来てしまうと、自分がそこを通れなくなってしまうということです。これは実際にUDPホールパンチングにおいて問題になります。

解決策としては、既存の接続と関連しない外部からのパケットをACCEPTしないDROPREJECTする)ことが有効です。端的に言えばファイアウォールufwなど)を有効にすると解決します。

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

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

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

また、この問題が発生するのはおそらくUDPだけのようで、TCPでは外部からのパケットに一切応答(SYN/ACL)が返らなければ接続自体が開始しないのでマッピングが作られることもないようです。

netfilterによる静的NAPTの挙動

上記と密接に関連する話題としてnetfilterの静的NAPT(ポートの開放)について説明しておきます。

まず、ルーターとして稼働しているlinuxのnetfilterにおける静的NAPTは概ね以下の2つを行うことを意味すると思います。

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

②上記のパケットを許可…たとえば、iptables -A FORWARD -p tcp -d 192.168.1.1 --dport 10000:20000 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT

(ちなみにこれは実際に動かしたコマンドではないので間違っているかもしれません)(参考: iptablesを使ってWebサーバおよびTCP通信をポートフォワーディング #Linux - Qiitalinux - How can I port forward with iptables? - Server Fault

文脈によっては(netfilterが動いているマシンがサーバーとしても稼働している場合)は、特定ポートをACCEPTするという②の部分だけで「ポートの開放」ということになりますが、この節では主にDNATも行う場合を想定します。

今までの内容(特に前節)からなんとなくわかると思いますがnetfilterのポートの開放は完全に静的なアドレスの変換ではなくあくまで接続ごとのマッピングの作成を通じて行われます。つまり、1.1.1.1:40000にアクセスがあったら必ず192.168.1.1:50000に転送する、といった形の設定がなされているわけではなく、1.1.1.1:40000に「未知の外部(Address, Port)から」アクセスがあったら「ポート40000番に192.168.1.1:50000とその(Address, Port)とのマッピングを追加する」という動作をします。逆に言えば、既知のマッピングがあればそれが常に優先されます

これにより、想定していないポートに通信が転送されることがありえます。例えば以下のようなケースです。

①1.1.1.1:40000から192.168.1.1:50000へのポート転送(ポート開放)設定をする。

②192.168.1.1:40000からインターネットにある2.2.2.2:60000に向けてUDPパケットを送信し、NATがそのためのポートとして1.1.1.1:40000を割り当てる。

③直後に、上記のパケットとは無関係に、2.2.2.2側が、2.2.2.2:60000を使用して1.1.1.1:40000にアクセスする。

この状況になると、2.2.2.2側としては1.1.1.1:40000で通常アクセスできるはずのアプリケーション(192.168.1.1:50000で動作している)にアクセスしたかったはずなのに、2.2.2.2:60000に関して既存のマッピングがあるために、192.168.1.1:40000に転送されてしまいます。

ただし、以下のような理由で、現実にはそこまで大きな問題にはならないと思われます。

  • 1023以下のウェルノウンポート(特権ポート)を使っていれば、(技術的には可能であるもののほとんどのルーターでは)NAPT時のポートとしては使用されないので衝突を避けることができる
  • 応答のないUDP通信のタイムアウトは通常より短く設定されている(よくある設定だと応答ありが180秒で応答なしが30秒)ことが多く、このタイミングに一致する確率は高くない
  • クライアント側ではランダムに割り当てられたエフェメラルポートを使用することが多いため、その際にアプリケーションが稼働しているポートが偶然割り当てられる確率はそれほど高くない
  • そもそも一般公開されている2つのサーバーが互いに無関係に相手にアクセスする可能性があるという状況自体が少ない

また、TCPUDPと違って接続の概念があるので、同様の衝突が起こった際は別のポートに転送されるのではなくそもそも接続が通らなくなります

この「既知のマッピングがあればそれが常に優先される」というのはDNATが無い場合も同じです。例えば192.168.1.1:30000からのパケットがSNATされて192.168.1.1:40000からのパケットとして別のPCの192.168.1.2:50000に向かっていき、直後に192.168.1.2:50000から無関係なパケットが192.168.1.1:40000に来たら、仮に192.168.1.1:40000をリッスンしているアプリケーションがあったとしてもそれは192.168.1.1:30000に転送(あるいはTCPなら無視・拒絶)されます。

ちなみに、netfilterで動いていると思われる多くの家庭用ルーターには「DMZホスト機能」という全ポートを特定IPに向けて開放する機能があって、これと通常のインターネット接続がどうやって両立するのか疑問に思っていた(ポート開放したポートは内側からは一切使えないと思っていた)のですが、今回で疑問が晴れました。同じ質問がnat - How to forward ports to DMZ using iptables and separate modem - Server Faultにあります。

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

iptablesでのポート範囲を指定した1対1での転送

iptablesのSNATやDNATでは、先ほどの--dport 10000:20000 -j DNAT --to-destination 192.168.1.1:10000-20000のようにポート範囲が一致している場合は1対1で転送されるものの、そうでない場合はランダムだったり最初から順に使われてしまったりといった挙動になってしまいます。

しかし、nat - Is it possible to map 1:1 port range iptable DNAT rules - Stack Overflowによると、比較的最近(2018年、Linux 4.19あたり)から、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とか見ても書いていなくて、こことかここにあるコミットログみたいなやつとかソースコードを見るくらいしか手がかりがありませんが、手元ではちゃんと動きました。

SNATでは同じ構文を使ってもエラーは出ませんが挙動はスラッシュ以降がないときと変わらないように見えるので対応していないようです。OCNバーチャルコネクトとかv6プラスの市販ルーターはどうやっているんでしょうね…。

DMZホスト機能とEIM/EIFの関係

前述のDMZホスト機能((ほとんど)全てのポートを番号を変えずに特定IPに転送する行為)は、「NATをEIM/EIFにする方法」として認識されている場合があります。これは半分くらい正しく、半分くらい間違っています。

これが実際にEIM/EIFと似たような挙動をする場面が多いのは事実です。なぜなら、netfilterで動いているルーターであれば、インターネットへの接続のために可能なら内側ポート番号と同じ番号を割り当てるため、結果として内側からのマッピングと同じものがインターネット側の任意のエンドポイントからもそのまま使えることになるからです。実際、UDPホールパンチングも成功しやすくなり、任天堂のページでも公式に案内されています。

一方で、まず今まで述べたようにnetfilterはEIMのような挙動をするだけで実際にはEIMではなく、このことはDMZホストをしても変わりません。つまり、ポートの衝突が発生すれば、内側からのマッピングと外側からの新規アクセスに対するマッピングが一致しなくなり、ADM/APDMのような挙動をする可能性があります。さらに、ルーターがnetfilterで動いていないなどでそもそも内側のポートと異なる(不規則な)ポートを割り当てる場合もEIMにはなりません(ただし、ポートを開放することによる効果はある)。また、内側からのマッピングとは無関係にポートが常時開放されているという点もEIFとは異なります。EIM/EIFはあくまで「一時的にポートが開放される」動作です。セキュリティ的にはEIM/EIFのほうがベターです。

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

業務用ルーターのNAT

CiscoYAMAHAなど業務用に使われるようなハイスペックなルーターは独自のOSを搭載しているため、netfilterとは挙動が異なりそうです。

YAMAHAのポートセービングIPマスカレードは、UDPホールパンチングの成立性を考慮したためか、TCPのみ有効になっているようです。また、「ポートセービングIPマスカレード=Symmetric NAT」的な主張は(「ポートセービングIPマスカレード」がYAMAHA用語であることから当然かもしれませんが)YAMAHA関連の文脈でよく出てくるので、YAMAHAルーターTCPは本当にSymmetric NATなのかもしれません。

誰かYAMAHAルーターをお持ちの方がいれば実験してみてコメントなどで教えていただけると嬉しいかもしれません。

その他の通信環境

netfilterでも--randomを付加すると、Symmetric NATどころか同じ(Address, Port)が相手でも毎回ランダムなポートが割り当てられるという非常にUDPホールパンチングに不適な環境ができあがります(stunclientの判定としてはAPDM/APDFだが、実際はConnection Dependent Mappingに近い)。しかし、あくまでランダムなだけであって、「宛先が違えば別のポートから出て行く」という制約があるわけではないので、Symmetric NAT(APDM)の定義には従っていません。

フリーWi-FiなどはたまにSymmetric NATなものがあったりします。

またdocomoスマホ回線やDS-Lite(transix)は試した限りTCP/UDPともにEIM/EIFでした(TCPホールパンチングも成功しました)。

ポートの枯渇に関する実験

既にnetfilterでポート共有が有効であることは確かめましたが、実用的なテストとして、ニチバンのスピール膏のページスピール膏™ワンタッチEX|うおのめ・たこ|ニチバン株式会社:製品情報サイトを用いた実験(いわゆるニチバンベンチ)も試してみました。

今回のnetfilterの設定ではポート数を100に絞っているので、上記サイトにアクセス後にShift+リロードを1度実行しただけでも読み込みに長い時間がかかり、ポートが枯渇していることが確認できます。一方、この状態で他のサイトにアクセスしてみると問題なく接続できました。

スマートフォンで色々なアプリを開くなどするとconntrack -Lで数えたTCPコネクションを300以上(ポート数上限の100を大幅に上回る)にすることもできましたが、その状態でもやはり通信は正常でした。当然、ポートは全て(何らかのホストとの通信には)使用されています。

同様の報告はCentOSでOCNバーチャルコネクト | QuintRokkなどにもあります。

現実には、ニチバンのサイトをShift+リロードすることはないと思いますが、ニチバンのサイトの複数のページを閲覧することはあると思うので、ポート数はやはり100では不十分で、v6プラスの240くらいは最低限あった方がいいのではないかと思います。

DS-Liteでポートの枯渇を回避する

DS-Liteは1024ポートしかない割にEIM/EIFらしい(これもやはりUDPホールパンチングが目的?)ので大人数で使っていると結構ポートの枯渇が発生しそうです。実際、せっかくのIPoE化もDS-liteの制限で台無しだったので、代替策を考えた|しょっさんという報告があります。そこで思いついたのですが、DS-Liteルーターと各端末の間に出口ポートを1024以下に絞ったnetfilterのルーターを設置して、あえて2重ルーターにすれば、この問題は解決するのではないでしょうか?家庭内の大量の端末からの通信はnetfilterによってうまくポートを共有した上でDS-Liteルーターに向かっていき、DS-Liteルーターから見ればたった1つの端末の1024個以下のポートからしか通信が来ないのでEIMだとしてもポートが枯渇することはないはずです。netfilterは「EIM風」なのでPort-Restricted Cone NATと同様の動作になり、UDPホールパンチングも成立する場合のほうが多いでしょう。

筆者は手元にDS-Lite環境がないので、誰か試してみていただけるとありがたいです。

二重ルーターDMZホストを増やす

同様の発想で、外側ルーターがSymmetric NATであるもののポート開放に対応している場合は、内側の別のCone NATなルーターにむけてポート開放をしてそのルーターの配下に複数の端末を入れることで実質的に複数の端末をDMZホストのように機能させることができます。もちろんこの2つの端末で同じポートを使おうとしたりすればうまくいきませんが、良い感じにマッピングタイムアウトしてくれる分には確率的には結構うまくいくだろうという手法です。

出典: SEILでニンテンドースイッチの「NAT越え」問題を解決する方法 #Network - Qiita

要点まとめ

特に言いたかったことのまとめです。

  • RFC 4787のNATには3x3タイプあるように見えるが、実際にはADMならADF/APDF、APDMならAPDFなのでは?
  • Symmetric NATであることとポートの共有(ポートセービングIPマスカレード)を行うことは完全に独立だが、Symmetric NATならEIMではないとは言える
  • Mapping/Filteringが同じであればTCPホールパンチングの成立条件はUDPホールパンチングと同じだが、実際には世の中にはTCPに関してConnection Dependent FilteringであるNATが多いため困難である
  • ポートセービングIPマスカレードYAMAHAの専売特許ではなく、むしろnetfilterにはずっと前から実装されていてLinux搭載の安価なルーターにも存在する機能
  • しかもnetfilterはポート共有は有効でありつつ基本はEIM NATな振る舞いをするのでUDPホールパンチングにとってそこまで不利ではない
  • とはいえnetfilterはAPDFであり、Symmetric NAT相手のUDPホールパンチングを考えたら「アドレス違いの場合のみポートを共有するEIM風ADFのNAT」が一番便利なはずで、これがLinuxに実装されていないのは残念すぎる
  • DS-Liteのポート不足は間にnetfilterを嚙ませれば解決するのでは?

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にはアプリケーションごとのルール設定はなく、使用するポートごとに通過の可否を設定します。GitHub - matrix-ac/LAF: Linux Application Firewallなどはアプリケーションごとの設定ができそうですが、(この種のソフトウェアとしては)そこまで広く使われていないので扱いません。

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

WindowsLinuxの違い

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

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

しかし、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)もあるようですがこちらは正確な書き方がわからず手元では使えませんでした。

ただし、sequence番号を判定するとなるとその分パフォーマンスが落ちる可能性もあるので、場合によってはこの条件を書かずrstを全てdropさせるという選択肢もありだと思います。

次に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)を参考にしました。

今回は送信先を限定せず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には一切表示されません。サーバー停止時は、ブラウザ側ではずっと読み込み中の表示になります。

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で適切にルールを設定することでおそらく通常使用にほとんど影響を与えずに解決することができます。

Androidで各種クラウドストレージやNASの音楽をギャップレス再生する方法

はじめに

クラシック音楽やロックのライブアルバム等では、音楽や拍手が鳴り続いているところでトラックが分割されていることがよくあります。これらを快適に再生するには、無音を一切挿入することなく完全に連続的に複数トラックを連続再生する、いわゆる「ギャップレス再生」の機能が不可欠です。音が切れない箇所でトラックが分割されているアルバムは全く珍しいものではなく、それゆえに「ギャップレス再生」ができないというのは音楽プレイヤーとしては一種のバグのようなものだと言えると思いますが、残念ながら現実にはそのようなバグを含んだ音楽プレイヤーがたくさんあり、むしろそちらのほうが多いくらいです。そもそも「ギャップレス再生などという言葉が存在すること自体が音楽界への冒涜だと思うのですが…。

mpv

愚痴はさておき、実際に我々にはバグのないまともな音楽プレイヤーが必要です。その一つがmpvです。オープンソースであり、元はCUI版のみだったと思うのですが現在は公式のGUIが実装されているほか、他のGUI実装(celluloidなど)や快適なカスタムテーマ(uoscなど)もあります。またAndroid版のmpv-androidというのもあります。iPhoneも詳しくないですがなんか移植版がありそうです。

mpvでは、prefetch-playlist=yescache=yesも要るかも?)というオプションを設定しておけば、とりあえず普通にファイルとして見えているものは問題なくギャップレス再生できます。mpv-androidでもmpv.confが編集できるのでそこで設定できます。

WindowsにおいてはNAS(ネットワークドライブ)はもちろんGoogle Driveとかも公式クライアントとかを使えばエクスプローラーで普通にファイルとして見られます(このような操作を(ファイルシステムへの)マウントといいます)。またLinuxでもrcloneというソフトがあってこれを使うとGoogle Drive含めメジャーどころのクラウドストレージ・ファイルサーバープロトコルなら全てファイルとして閲覧できます(Google Driveならgoogle-drive-ocamlfuseもあります)(rcloneにはWindows版もあります)。ということで、筆者はPCにおいてはこれで問題なくギャップレス再生の手段を手にいれました。

モバイル端末の制限

しかしモバイル端末(筆者はAndroidしか使っていないので以下はAndroidの話題に限定しますが、iPhoneでも多分同様)ではそう簡単にはいきません。というのも、モバイル端末はPCよりもセキュリティ面で保守的なので、NASGoogle Driveのファイルをそのままファイルとして(ローカルのファイルシステムと同様の枠組みで)使うことができないのです(正確にはAndroidをroot化(iPhoneなら脱獄)すればできるのですが、デメリットが大きいので今回は無しとします)。となると、ファイルマネージャーやクラウドストレージのクライアント(Google Driveのアプリなど)側が頑張ってオーディオ再生に対応するか、あるいは逆にメディアプレイヤー側がクラウドストレージに自前で対応するか、といったことが必要になります。

クラウドストレージが閲覧できてオーディオファイルを外部アプリで開いてくれるファイルマネージャー、あるいはクラウド再生に対応した音楽アプリ、ならそこそこあるのですが、その中で複数ファイルをギャップレス再生できるものはありませんでした。一応、クラウドのフォルダを一度開いてスキャン(取り込み的なやつ)をすればその中身をギャップレス再生できるというアプリはあったのですが、聴きたい音楽が大量にある場合はフルスキャンに大量の時間とストレージ容量が必要ですし(それができるなら最初から全部ローカルに入れておけばいい)、かといって各フォルダをいちいちスキャンするような使い方がしやすいようにデザインされているわけでもありませんでした。

あと、実はrcloneにはAndroid版(RCX)もあり、これを使うと(本物のファイルシステムではないのですが)ContentProviderという仕組みを使って他の(ContentProviderに対応した)アプリからGoogle Driveなどのコンテンツが見られるようにできて、さらにmpv-androidはContentProviderに対応している……のですが、現在のmpv-androidだと、この方式では1ファイルごとにしか再生できないようで(Play Folder in document picker · Issue #627 · mpv-android/mpv-android · GitHub)、目的は達成できませんでした。

解決策: HTTPサーバーを立ててそこから再生

という感じで、mpvとrcloneの移植版があっても無理なら、やはり無理なのか……?と諦めムードが漂っていましたが、ふと思い出したのが、mpvネットワーク上のファイルでもプレイリストで指定すればギャップレス再生できる、という事実です(ちなみにこれを活かして、mpvを使ってYouTubeのプレイリストをギャップレス再生する方法をこちらで紹介しています)。このプレイリストは、単純にファイル名を並べたものか、m3u(m3u8)形式のプレイリストを使用することができ、ファイル指定では普通のファイルパスに加えて「http://」や「smb://」や「ytdl://」(内部でyoutube-dlを使う(ただしギャップレス再生不可))など色々な書式が使えます(詳しくはこちらを参照)。

ということは、Androidから見えるところにHTTPサーバーを立てて、そこにあるファイルを指定したプレイリストを作っておけば目的は達成できます。

このHTTPサーバーはAndroid上でも自宅LAN上でもインターネット上でもどこでもよく、またソフトウェアもApacheやnginxなど何を使っても構いません。音声ファイル(.flacとか)が直接HTTPでアクセスできる状態になっていれば問題ありません。m3u8プレイリストの書き方も調べれば出てきます。なので、ここでもう記事を終わりにしてもいいのですが、せっかくなのでAndroid上で完結させよう&最低限実用できる状態にしようということで、もう少し具体的に設定方法を説明してみます。

TermuxのrcloneでHTTPサーバーを立てる

先ほど、HTTPサーバーを立てるだけなのでApacheでもnginxでも何でもいいと書きましたが、Android内でサーバーを立てようとなると普通のHTTPサーバーはあまり役に立ちません。なぜなら、普通のHTTPサーバーはローカルにあるファイルをHTTPを通じて利用可能にするという機能しかなく、今はそもそもNASクラウドストレージのフォルダをローカルにファイルとしてマウントできないからこそ困っていたわけなので、Apacheやnginxを使ってクラウドのファイルをホストすることもできません。

ここで救世主となるのは先ほどでてきたrcloneで、rcloneでは各種クラウドストレージをマウントするのではなくHTTP/FTP/WebDAVなどの各種サーバーとしてホストすることもできます。カッコイイ!ちなみにローカルフォルダとの同期とかもできるみたいです(使ったことありませんが)。

ただし、先ほど言ったAndroid版のrclone(RCX)は、CUI上で動くコマンドではないので、ちょっと普通のrcloneと動作が違います。HTTPサーバーをホストする機能も一応ついているのですが、ポート番号が8080に固定されている(8080が使用中だったらどうなるのかは知らない)という問題があります(Add Possibility to change Server, Port, Username and Password and switch to Public Key auth · Issue #236 · x0b/rcx · GitHub)。それではちょっと不安があるので、もっと本来のrcloneに近いものをということで、今回はTermuxというAndroid上でCUI操作が使えるようにしてくれるアプリを入れて、そこでrcloneを動かしてみます。

Termuxとrcloneのインストール

Termuxのインストール方法はいろいろなところで解説されているのでここではやりません。注意してほしい点として、Termuxは現在ではGoogle Playストアを利用しておらず(一応存在はするがバージョンが古い)、F-Droidというサードパーティーのストアアプリからインストールすることになります。この関係で、インストール時に多少の警告?確認?メッセージみたいなのに出くわすことになります。とはいえroot化などに比べれば相当リスクの低い行動だと思います。

Playストアにあるものがいいという場合は、Debian norootとかUserLAndのように他にもCUI環境を提供してくれるアプリがあるのでそちらでも多分同様にできると思うんですが、調べた感じTermuxがパフォーマンス的に良さそうな気がしました。

で、Termuxのインストールが終わったら、pkg install rcloneでrcloneを入れます。自前でビルドする必要などは無く、簡単です。

rclone configによるセットアップ

rcloneで各種クラウドストレージを使うには、当然ながらログインしたりなど適切なセットアップが必要です。このときはrclone configというコマンドを使います。これに関しても公式サイト含め他のところで色々解説されていると思うのでここでは解説しません。というかセットアップが必要なのはCUI版だけでなくアプリ版のRCXでも同じですね。

ここでは、mydriveという名前のストレージのセットアップが完了したとしましょう(rcloneではこのように、NASGoogle Driveなど各種クラウドストレージに関する設定(認証情報などを含む)に名前をつけて管理できます)。すると、

rclone serve http --addr=localhost:15555 mydrive:/some/folder

とすることで、今設定したmydriveの/some/folderというフォルダがhttp://localhost:15555にて閲覧できるようになります。ブラウザにlocalhost:15555と入れてみて、ちゃんと動いているか確認してみましょう。

ポート番号は15555ではなくても何でもいいですが、root化していないので、1024以下は使えません。localhostのところは、0.0.0.0みたいに全アドレスをリッスンしてしまうとAndroidはデフォルトでファイアウォールがないのでフリーWi-Fiとかで他の人に音楽聴かれちゃいますので気を付けてください。

m3u8ファイルの生成

これで、あとはhttp://localhost:15555にあるファイルを好きに指定してプレイリストを書いてそれをmpvで開くだけです。しかし、聴きたいファイルを決めてからいちいちプレイリストを書くのはあまりにも不便すぎます。現実的なケースでは、連続して聴きたいファイルどうしは同じフォルダにあることがほとんどだと思うので、各フォルダ内に「フォルダにある音声ファイルを順番に全て再生する」だけのm3u8ファイルをあらかじめ作ってしまいましょう(m3uでなくm3u8にしたのは、なんとなく新しそうな気がしたからというだけで、そもそもm3uとm3u8がどう違うのか知らないです)。一度作ってしまえば、あとはそのフォルダ内の曲を聴くときにこのプレイリストを開くだけです。途中から再生したいときは、若干手間ですが全曲プレイリストをまず開いてからmpv-android上で曲を選び直しましょう。

HTTPでアクセスするので、URLはパーセントエンコーディングしておく必要があります。そのへんも考慮するとシェルスクリプトだと微妙そうだったので、pythonで書いてみました。というかChatGPTに書かせました。

#!/usr/bin/python3
import sys
import os
import shutil
import urllib.parse

temp_m3u8 = "/tmp/gen_m3u8_temp"
def urlencode(path):
    """
    Unicode 文字を含むパスを正しくパーセントエンコードする
    """
    return urllib.parse.quote(path)

def generate_m3u8(directory, base_url):
    """
    指定されたディレクトリに .m3u8 プレイリストを生成する
    """
    # 一時ファイルのパスを生成
   
    with open(temp_m3u8, 'w', encoding='utf-8') as f:
        f.write('#EXTM3U\n')
        for filename in os.listdir(directory):
            if filename.endswith('.flac') or filename.endswith('.mkv'):
                filepath = os.path.join(directory, filename)
                if os.path.isfile(filepath):
                    encoded_url = base_url + '/' + urlencode(filepath).replace('./', '',1)
                    f.write('#EXTINF:-1\n')
                    f.write(encoded_url + '\n')

def main():
    base_url = sys.argv[1]   # ベースURL

    # 対象のディレクトリを再帰的に探索し、プレイリストを生成
    for root, dirs, files in os.walk('.'):
        generate_m3u8(root, base_url)
        final_m3u8 = os.path.join(root, '0000LH15555.m3u8')
        shutil.move(temp_m3u8, final_m3u8)

if __name__ == '__main__':
    main()

こんな感じで、カレントディレクトリ(".")の中身を再帰的に検索し、見つかった各ディレクトリにおいてそこにある全てのファイルを羅列した.m3u8を生成しています。なお、クラウドストレージにファイルを作るだけなのでこのスクリプト自体はAndroid上で実行する必要はなく、自分は別のLinux上(rcloneでマウントしたGoogle Drive)で実行しました。rcloneの仕様なのか、( --vfs-cache-mode writesは付けてたんですが)直接書き込みするとうまくいかなかったので、一旦/tmp/gen_m3u8_tempという適当な名前で生成してからコピーしています。ファイル名が0000LH15555.m3u8となっているのはまあ適当ですが、音楽ファイルはすべて01-artist-title.flac的な名前になっているので、必ずプレイリストが先頭に表示されるようにこの名前にしました。LH15555はlocalhost:15555という意味で付けておきました。

コードを見ればわかりますが、localhost:15555という部分はコマンドライン引数として与えるようにしています。具体的には

m3u8.py http://localhost:15555

あるいは

m3u8.py http://localhost:15555/some/folder2

のように実行すればよいです(最後にスラッシュは無し)。さっきのrclone serve httpでの指定内容と整合するように各自で変えてください。

(RCXだと8080で固定なのが微妙と言っておいて)結局15555に固定してしまっていますが、ここは綺麗な代案が思いつかなかったのでとりあえず諦めました。

ファイルマネージャーから開く

これで、Termuxからrclone serve httpした状態でGoogle Driveからm3u8を選んでmpv-androidで開けばギャップレス再生できるのでほぼゴールなのですが、例えばGoogle Driveでm3u8を開くとサポートされていないファイル形式ですなどと言われて、mpv-androidを選ぶまでに2-3クリックかかってしまいます。なので筆者は現在RCXをそのままGoogle Drive用のファイルマネージャーとして使っています(デフォルトのアプリで直接開いてくれる)。Google Driveはメジャーなクラウドストレージなので他のアプリでも対応しているのがあるかなと思います。そちらでもいいでしょう。

まとめ

Google Drive以外では実は(NASさえ)試していないんですが、広範なクラウドストレージをAndroidでギャップレス再生する方法が確立されました。おそらく英語圏ですらほとんど知られていないやり方ではないかと思います。まあ需要がないんですかね…かくいう筆者も基本的にモバイルで音楽を聴くことは無い(外出中に音楽を聴かない)ので、これができたからといって特に日常生活で具体的なありがたみがあるわけではありません。あと、mpv-androidユーザーインターフェースはお世辞にも使いやすいとは言えないので、そのへんに改善があるといいのですが…(参考: [Discussion] UI improvements · Issue #554 · mpv-android/mpv-android · GitHub

とはいえ、理論上は(?)できることがわかったので、個人的には満足しました。あとは実際に誰かの役に立てば幸いです。

P2P通信の仕組み、NATタイプ、Tailscaleで別のIPを割り当ててサブネットを公開する方法、などについて

最近の自宅ネットワーク環境の更新に伴って前々回前回と記事を書いてきましたが(特に前回記事はオススメ!)、それに引き続いて自宅までVPNで接続できる設定などをしていたので、今回はそれに関する記事です。

本来はTailscaleで別のIPを割り当ててサブネットを公開する方法について書きたかっただけだったのですが背景となるP2Pの仕組みとかについて調べて書いていたら結局そっちの方が長くなってしまいました。しかしもともと筆者がそれほど詳しい分野ではなく、説明が間違っている可能性も十分ありますので注意してください。

インターネット接続とポートの動作

まず、ポートの動作について基本的なところを説明します。

普通、インターネットを通じて接続を確立しようと思ったら、接続に関して、そこにたどり着くためのグローバルIPアドレスとその上の固定的なポートが必要です。例えば、Googleのトップページであるgoogle.comがインターネットで閲覧できるのは、(google.comに関連づけられた)IPアドレスがあり、その80番ポートにサーバーが立っているからです。

一方、インターネットへの接続に関しては、固定的なポートを割り当てるのではなく、接続に際して一時的なポートエフェメラルポート)を開放して(割り当てて)、必要な応答が返ってきたらすぐに閉じる(割り当て解除する)、という方法で接続を行うのが普通です。いつなんどき世界のどこから接続が来るかわからない接続先サーバーでは常に固定のポートが必要ですが、自分から接続して応答を待つだけの接続元では毎回違うポートを使っても大丈夫ですし、そのほうが複数の接続を捌くには便利です。

例えば、PCが直接インターネットに接続していて1.1.1.1というグローバルIPを持っていたとします。そのPCでgoogle.comを見たい場合、PCはまず空いているエフェメラルポートを一つ探します。例えばそれが50000だったら、1.1.1.1:50000からgoogle.com:80にむけて通信を行い、その応答が1.1.1.1:50000に返ってきます。ページの読み込みが終わったら(ブラウザで言えば、読み込み中のぐるぐるマークが消えたら)、もう50000番ポートを使う必要はないので解放(開放ではない)して、未使用になります。もし、google.comの読み込み中に別のサイト(例えばamazon.com)に接続するとしたら、空いている別のエフェメラルポート(例えば50001とか)を使って同じようなことをすることになります。ちなみにPC側の使用ポートはアクセス情報【使用中のIPアドレス確認】などを使うとわかります。

ちなみに、このようにグローバルIPを持った端末なら、何かのポートを使用するアプリケーションを普通に起動すれば(&適宜ファイアウォールを開ければ)ただちにそれはインターネット上の誰でも使える接続先として機能するようになります。

しかし実際には、世界中の接続端末ごとに一つずつグローバルIPアドレスを割り当てるにはIPv4アドレスが足りないので、通常はルーターだけをインターネットに直接接続し(=グローバルIPを持たせる)、ルーターNAT(NAPT)という仕組みを用いて自身の配下の端末のインターネット接続を"代行"するという方法が使われます。この際は、端末(PCなど)と全く同様にルーター自身も一時的なポートを使うことになります。

例えば、先ほどの例にルーターを追加してみましょう。1.1.1.1というグローバルIPを持ったルーターがあって、その配下に10.1.1.1というプライベートIPを持ったPCがあるとします。この場合、PCからgoogle.comに接続したければ、まずPCは空いているエフェメラルポート(例えば50000)を使って、「10.1.1.1の50000番に、google.com:80の内容を持ってきてくれ」とルーターに依頼します。するとルーターは、自身のエフェメラルポート、例えば60000(このように50000から60000へとポート番号まで変換されうることを指してNAPTと呼んでいます)を使って、google.com:80と通信します。google.comからの応答が1.1.1.1:60000に返ってくるので、その内容をもともとの依頼元である10.1.1.1:50000に返せば、最終的にPCからgoogleのサイトが見られるというわけです。この接続の途中でamazon.comも見たくなったら、PCは50001番ポートを使い、ルーターは60001番ポートを使ってそれをamazonに転送し…という感じになります。先ほどと同じくアクセス情報【使用中のIPアドレス確認】ルーター側の使用ポートが確認できます。

ルーターが外から受け入れるのはあくまで上記のような「自身の配下の端末に依頼された内容への応答」だけなので、この状況だと、PCで特定のポートを使うアプリケーションを起動するだけでは外から通信できません。なぜなら、外から見えているのはルーターだけであり、ルーターの適当なポートにいきなり外からアクセスしたとしても、それは配下の端末からの依頼への応答ではないので拒否されてしまうからです。そこで、ルーターの設定で、「ルーターの20000番ポートは常に開けておいて、そこに来た通信は無条件で全て10.1.1.1の30000番ポートに転送」というように先ほどのNAPTを静的に(常にポート番号・LAN側の宛先を固定して)行うようにします(これがいわゆるポート開放設定)。その上で10.1.1.1において30000番ポートを使うアプリケーションを起動すれば、ようやくPCが接続先として機能するようになります。

ただ、ポートを開放するということはセキュリティ的なリスクを伴います。また、インターネットの契約によってはこのルーターの設定にあたる部分が外部(マンションの管理室やプロバイダ側設備)にあって手出しできない場合も多くあります。LAN配線方式の集合住宅やモバイル回線、あるいは(光配線方式・VDSL方式であっても)DS-Lite方式のIPv4通信などはこれに該当します。この場合だと、自宅へとつながるグローバルIP上のポートを確保することができません。

一旦内から外への接続が確立してしまえば外から内へと固定的に通信することは可能(例: SSHのリモートポートフォワード、各種VPN)(概念的には、モバイル端末であってもメールやLINEやTwitterのメッセージを外から受信できるのと同様)なので、他のサーバーを起点としてそこから家に通信が入るようにするという回避策はあります。VPSやngrok, Cloudflare Tunnelなどのサービスは実際このような目的でよく使われます。ただ、コストがかかることもありますし、中継するわけなのでそのサーバーの品質に全体の通信品質が左右されます。あるいは別の方法として、自宅に接続しようとしているまさにそのPCが接続として使える可能性もあります。例えば、外出先で使っているPCにグローバルIPが割り当てられていたり、あるいは経由している(グローバルIPが割り当てられた)ルーターの設定をいじれたり(親戚の家にいる等)する場合は、自宅からそのグローバルIPに接続するように(別ルートで)命令することで自宅との通信を確立できます。しかしそういった環境にいる(そういう場合にのみ使えればよい)ことは稀です。それに、いずれにしてもどこかしらではポート開放が必要なのでセキュリティリスクが若干増えます。

長くなりましたが、まとめると、インターネットから自宅に接続しようと思ったらグローバルに接続可能な公開ポートを自宅に設置する必要があるが、金銭コスト・品質・セキュリティリスクの面でそれが難しい場合もある、ということです。

P2P通信とUDPホールパンチング

しかし実は、この「インターネットから自宅に接続しようと思ったら自宅に接続可能な公開ポートを設置する必要がある」というのはあくまで原則論で、実は回避する方法が存在します。つまり、インターネットに公開されたポートがどこにもなくても、ルーター(NATを行う機器)の内側にいる離れた端末同士でインターネットを介して接続を確立できる技術が存在します。それがUDPホールパンチングです。これにより、中継サーバーを介しない端末同士の通信、いわゆるP2P(Peer to Peer)通信が実現します。グローバルIPを持っている自宅と通信したい場合に限ったとしても、静的なポート開放が不要というメリットがあります。

UDPホールパンチングについての詳細な解説はしませんが、要は、固定的(静的)なポート開放設定ができなくても、どれかのポートを一時的に開放させることはできるので、その一瞬の隙をついて(穴を開けて)互いにそのポートを使って通信してしまおう、ということです。

ただし、どんな場合でもUDPホールパンチングが成立するわけではありません。NATの動作にはいくつか分類があり、それによって、どれくらいその「隙」を突きやすいかが異なります。

この分類は、RFC 4787という規格において、「Mapping」「Filtering」という2つの特性がそれぞれ「Endpoint-Independent」「Address-Dependent」「Address and Port-Dependent」のどれに当てはまるかという3x3=9種類が定められています。3つの中では、後にいくほど「隙」が少ない(UDPホールパンチングが難しい)ものになります。これらに加えて、ポート番号の偶奇がNATで維持されるかといった細かい性質もあるのですがそれにはここでは触れません。

9種類といっても、まずMappingがどれであるかで3種類に大別されて、その上でFilteringがどれであるかで細分化されるというイメージがよいでしょう。メジャーなのは、まず「Endpoint-Independent Mapping」のもの(最もホールパンチングしやすいグループ)です。この中では3種類の「Filtering」方式である「Endpoint-Independent Filtering」「Address-Dependent Filtering」「Address and Port-Dependent Filtering」がいずれもよく利用されており、これらは以前の規格(RFC 3489)で「Full Cone NAT」「(Address-)Restricted Cone NAT」「Port-Restricted Cone NAT」と呼ばれているものにそれぞれ対応します。また、最もホールパンチングが困難な「Address and Port-Dependent Mapping」は、RFC 3489で「Symmetric NAT」と呼ばれているものに対応するようです。「Address-Dependent Mapping」はあまり使われていないようです。

実際には、今でもFull Cone NATやSymmetric NATなどの旧来の用語が使われることも多く、これらで十分な場合もあるのですが、一応新しい分類のほうが正確とされているようです。しかし以降では便宜的に旧来の用語を使用することもあります。

これらの中で、もっとも隙の大きいFull Cone NATだと、実質的には内側からポートを一時的に全世界にむけて開放することができるような状態なので、ほぼ確実にUDPホールパンチングが成功します。一方、制限が厳しいSymmetric NATだと、通信を行いたい特定の相手(ポートとアドレスの組み合わせ)に対してしかポートを開けることができないので、成功率が下がります。

http://toremoro21.world.coocan.jp/study/voip2008/NATTraversal.pdfのP.21によると、通信を行うどちらか一方がFull ConeあるいはRestricted Cone NATである場合、あるいは双方がPort Restricted Cone NATである場合は成功するが、Port Restricted Cone NATとSymmetric NAT、あるいは双方がSymmetric NATである場合は不可能、などとされています。英語で調べてもこのような情報が最も多いです。一方、開発者ドキュメント|SkyWay(スカイウェイ)では、Restricted ConeとSymmetricの組み合わせでもダメと書いてあります。また、この後で扱うVPNサービスであるTailscaleのサイトにも How NAT traversal works · Tailscale という大変詳しい記事があるのですが、こちらではEndpoint Independent Mappingの中での区別にはあまり言及されておらず、どちらか片方でもEndpoint Independent MappingであればUDPホールパンチングが成立する(つまり、Port-Restricted Cone NATとSymmetric NATの間でも成立する)という意味にも読めなくはありません(しかし後述のように自分の環境ではその通りにはなりませんでした)。このへんの細かいところはサイトによっても違いがあり、よくわかりません。Symmetric同士であっても次に解放されるポートを予測するなどの方法によって確率的にUDPホールパンチングが成立する場合もあるようです。

NATというのは一応ファイアウォール的な役割も果たしており、その「隙」を突くというのはイケナイことのようにも思われるかもしれませんが、あくまでこれはNATの内側の端末の協力がないと成立せず、外からいきなり内側に侵入できるようなものではありません。言い換えれば、既に内側に「侵入者」がいないと侵入できないという点で安全といえます。ただし、それでもセキュリティ的に厳しい組織などであればそれっぽい通信がブロックされたり偉い人に怒られたりという可能性も一応なくはなさそうなのでそこは注意してください。

これより詳しい解説は以下のサイトなどを参照してください(他にもいろいろあります)。

UDPホールパンチング - Wikipedia

Hamachi - Wikipedia

NAT Traversalって知ってますか | Cerevo TechBlog

https://www.janog.gr.jp/meeting/janog30/doc/janog30-v64-pre-stun-ryosato-01.pdf

WebRTCの裏側にあるNATの話 / A talk on NAT behind WebRTC - Speaker Deck

Cloud NAT の Endpoint-independent Mapping とは? | by Seiji Ariga | google-cloud-jp | Medium

インターネット環境のNATタイプの確認・変更

UDPホールパンチングが成立しそうかどうか確かめるために、自分のインターネット接続環境のNATタイプを確認したくなります。これについてはあまり情報がなく探すのに苦労したのですが、PCからだとStuntman - open source STUN serverを使うのが良さそうです。

Stuntmanの使い方はport - How do i check my nat type using stun? New on this - Stack Overflowにある通りで、MappingとFilteringに関してそれぞれ結果を表示できます。また、IPv4またはIPv6(デフォルトはIPv4)、プロトコルとしてUDPまたはTCP(デフォルトはUDP)、送信元IPアドレス・ポートなどをオプションで指定できます。

自分が試したところ、以下のような多くの環境では、UDPについてはEndpoint Independent MappingAddress and Port Dependent FilteringTCPについてはAddress and Port Dependent MappingEndpoint Independent Filteringとそれぞれ判定されました。従来の用語でいえば、UDPについてはPort-Restricted Cone NAT、TCPについてはSymmetric NATということになります。

・自宅のOCNバーチャルコネクトによるMap-e接続(ルーターはRX-600KI、UPnP無効、プロバイダはOCN。詳しくは前々回前回も参照。)

・自宅のPPPoE接続(ルーターはBuffaloのWSR-1166DHPL2、ほか同上)

テザリング経由のスマホdocomo回線

・コンビニのd-WiFi

・渋谷駅の「SHIBUYA Wi-Wi-Fi

一方で、筆者の所属大学のWi-Fiや、東京メトロが提供するMetro_Free_Wi-Fiでは(注: 駅によるかも)、TCPについては上記と同様でしたが、UDPについてはAddress and Port Dependent MappingAddress and Port Dependent Filteringと判定されました。つまりUDPに関してもSymmetric NATであるということになります。

他にはpythonで書かれたPyStun3というのもありました。OCNバーチャルコネクトではRestric NAT(これはAddress-Restricted NATの意味らしい)、モバイル回線にするとSymmetric NATになりました。この辺はよくわかりません。新しい用語を使っているStuntmanのほうが良さそうな気がします。

あとはNAT Test: Am I behind a Symmetric or Normal NAT?というサイトもありますが、Normal/Symmetricの2通りしかない上に、RX-600KIでもSymmetric NATと表示されたのでちょっと微妙そうです。HTTPで見ているわけなので、TCPに関してしか判定してくれていないかも?GitHub - nthack/NatTypeChecker: A useful NAT type checker with STUN protocol, base JS. Using Google stun server, available. 一个使用STUN协议且基于JS的可用的NAT类型检查工具. 使用Google的stun服务器,可用,记得看看自己是否能访问Google。も同様でした。

P2P通信はゲーム機でも使われるので、ゲーム機にも判定機能が付いている場合があります。NintendoSwitchではNATタイプA/B/C/D/Fといった形で判定してくれるようです。自分は持っていないので確かめていません。XBoxにもあったりするようです。

一般には、普通の家庭用ルーターUDPに関してSymmetric NATであることはあまりない(参考:【Switch】Splatoon2/スプラトゥーン2 S帯スレ49【質問/雑談/愚痴】など)ようです。

NATタイプを変更するための直接の設定項目は普通ありませんが、UPnPを有効にするとFull Cone NATになるという話があるようです(ただしセキュリティ的にはやはりあまり良くなさそう)。また、ゲーム機に関しては、ルーターUDPポートをゲーム機にむけて全開放すると解決することもあるようです。例えばスプラトゥーン2などのオンラインゲームが遊べない「NAT越え失敗」の原因と解決方法【Switch】では、全ポートは使えないOCNバーチャルコネクトですがうまくいったようで、さらにポート開放の宛先ではない別のSwitchからもいけるようになったとのこと。。不思議なものです。Nintendo Switchに関してはNintendo Switch の「NATタイプ」判定条件 #Network - QiitaSwitchに「NAT越え失敗(2618-05**)」のエラーが出てフレンドさんと遊べない原因と対策も詳しいです(全体的にNATタイプに関する情報はゲーム界隈で豊富に出回っているようです)。

あるいは、2重ルーターになっている場合は実質的にSymmetric NATになってしまっている可能性もあるようなので、内側のルーターをブリッジモードにしましょう。ちなみに自宅で2重ルーターにしてPyStun3を試しましたが変わらずRestrictedでした。Stuntmanは未調査。NAT Testのほうでは何ならSymmetricからNormalに変わりました。意味不明。ただ、2重ルーターにしたからといってただちにSymmetricになるということは無さそうです。

ところで、LAN配線方式のマンションで使われているルーターがどのNAT方式なのかというのは実用上も非常に気になるところですが、これに関してはあまり情報がなくわかりませんでした。

また、Linuxであれば、以下のコマンドによって自分自身の環境をSymmetric NATに変えられるようです。(eth0のところは各自の環境にあわせて変更)

sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE --random

正直原理はそこまでよくわかっていませんが、ホールパンチングが成立するかどうかのテストに使えそうです。

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

続・v6プラスの“怪しいウワサ”は本当か? ファクトチェックQUICとNATで確かめたいと思うこと (MAP-EでのNATとNAT64) #nat - QiitaYAMAHA RTX1200でMAP-Eした(けどやめた) | 愚行録 the Next GenerationUbuntu / Debian でIPv4 over IPv6 (OCNバーチャルコネクト, v6プラス), systemdによる設定, ルーター化, VPNおよび自宅サーバー可能な固定グローバルIPv4アドレス #RaspberryPi - Qiitaなどにあるように、「ポートセービングIPマスカレード機能」というのがある場合、ルーターは接続先ホストが異なる場合に同じポートを使いまわすことがあります。これによって、v6プラスのように240ポートしか使えないような状況でも円滑に通信を行うことが可能になります。

で、よくわからないんですが、これがSymmetric NATに対応する動作ということになる?というような話があります。おそらく、あるホストとの通信に使っているポートに別のホストから通信が来たとしても、それが全く別の通信という扱いになる(それに対応する通信がなければ破棄される)からでしょうか。。。

ちなみに「ポートセービングIPマスカレード機能」というのはYAMAHA用語で、最近のYAMAHAルーターではTCP通信のみに関してデフォルトでこの動作が選択されると明記されているものがあるようです。他社ルーターでもv6プラス(使えるポートが特に少なく、240しかない)やtransixなどのIPv4 over IPv6で通信する場合は暗黙的にこれと同等の機能が使用されている可能性が高いのではないかと思います。実際、自宅では、IPv4 over IPv6のほうだけでなくPPPoE通信(Buffalo)のほうでもTCPがSymmetric、UDPがPort-Restrictedと判定されたわけなので、この機能が原因である可能性もあるでしょう。UDPに関しても「ポートセービングIPマスカレード機能」的なものを設定できるルーターもあるようです。この場合ホールパンチングがうまくいかないかもしれないので設定を変更してください。

参考になりそうなサイト: YAMAHA ルータと Splatoon 2 - mura日記 (halfrack)

ヘアピンNAT問題

多くのNAT機器(ルーター)では、ルーターのWAN側のIP(グローバルIPとか)をLAN側から指定しても正しくルーティングしてくれません。例えば1.1.1.1というグローバルIPをもつルーター側で8080番ポートを開放して「1.1.1.1:8080」へのアクセスが「192.168.1.2:8080」に転送されるようにしていたとしても、このルーター内側にあるPCで「1.1.1.1:8080」にアクセスした場合は接続できません。

このようなルーティングを可能にする機能がヘアピンNATです。イメージ的にはNAT機器のところでヘアピン状にルーティングが折り返している感じです。NAT機器によってはこれをサポートしているものもあります。

で、これが今までの話と関係してくることがあり、それは同じNAT機器の内側でP2P通信したい場合です。詳しくは筆者自身よくわかっていないのですが、さっきのTailscaleの記事とかを読んだ感じだと、このときにNAT機器にヘアピンNAT機能がないとP2P通信がうまくいかない可能性がありそうです。単純なケース(同じルーターの配下で同じサブネットに属している2つの端末の間)では(手元で試した限りでも)うまくいきそうですが、CGNATのような大規模なNATの配下(同一会社のモバイル回線を使っている2つの端末間)や、IPv4 over IPv6で同じIPv4アドレスが割り当てられた家の間とかでP2P通信をする場合はうまくいかないことがあるのかもしれません。未調査。

STUN/TURN/ICEとかについて

UDPホールパンチングをするには最初に一時的にポートを開けるための仮の通信先が必要です。そのために一時的に使われるサーバー(あるいはそれを使う手法?)をSTUNと呼びます(さっきからSTUNという言葉は出てきていますが)。また、上記のように一般にはUDPホールパンチングが成立するとは限らないので、P2Pを謳う多くのサービスでは、UDPホールパンチングが失敗したらリレーサーバーを用います。このリレーサーバー(のプロトコル?)がTURNと呼ばれるようです。で、STUNがダメだったらTURNにフォールバックするというこの仕組み全体を指す言葉がICE?のようです。

見ての通り詳しくはよくわかってないので調べてください。

P2PVPN

やっとVPNの話に入ります。UDPホールパンチングの仕組みを活かしてVPNを構築するのがP2PVPNと呼ばれるサービスで、Hamachi(先ほどもリンクを載せましたが)、ZeroTier、そして今回使うTailscaleなどがあります。STUNサーバーにあたるものが要るのと、UDPホールパンチングが必要なので、(P2P型ではないVPNサーバーを立てることに比べると)なかなか個人でやるのは難しそうです。

TailscaleはWireguardというVPNソフトウェアを使用していて、これのパフォーマンスがいいのでTailscaleもパフォーマンスがいいらしいです。一方、Zerotierは独自プロトコルを使っていて、TailscaleがL3(IPレベル)のVPNであるのに対して、より低レイヤであるL2(Ethernetレベル)のVPNが構築できる(直接LANケーブルでつないでいるのと同じ扱いになるので、NetBIOSとかDLNAとかがインターネット越しに使えるようになる)という強みがあるようです。

あとこれはVPN一般に当てはまるのかTailscaleだけなのかわかりませんが、VPNが上手い具合に接続状況を仮想的に維持(=単にしばらくパケットロスが続いたのと同じ動作をする)してくれるので、Wi-Fiがちょっと切れてまたつながったりとかアクセスポイントを切り替えたりしてもVPN経由のssh接続はそのまま維持されるのが良い感じです。

Tailscaleの基本的な設定

設定は簡単で、Googleアカウントなどを使ってアカウントを作って、各クライアント端末にTailscaleソフトを入れてログインしておくだけで、クライアント同士がVPN接続されます。他にも色々サイトがあるので参考にしてください。

TailscaleでUDPホールパンチングができているか確認

TailscaleではUDPホールパンチングを勝手にやってくれますが、うまくいかなかった場合はリレーサーバーを使います(さっきのICEみたいな感じだが、DERPという独自プロトコルを使っている模様)。

tailscale pingコマンドによって実際にUDPホールパンチングが成立しているか確認できます(参考: Direct vs relayed connections · Tailscale Docs)。ホールパンチングが未成立の状態では

pong from my-device-name (100.x.y.z) via DERP(tok) in 190ms

というような応答が連続して返ってきます(上記の"tok"は東京にある中継サーバーという意味)。成立すると、

pong from my-device-name (100.x.y.z) via xxx.yyy.zzz.www:60760 in 99ms

というような応答が返ってきて、そこでコマンドが終了します。

自分が試した限りだと、(UDPに関して)双方がPort-Restricted Cone NATである場合にはホールパンチングが(数回のDERP応答の後に)成立しますが、Port-Restricted Cone NATとSymmetric NAT(例えばMetro_Free_Wi-Fiに接続した端末から自宅の端末)だとホールパンチングが成立しませんでした。

先ほどのリンクにありますが、ホールパンチングがなかなか成立しない場合、自分でポート開放できる環境であれば、41641番(設定ファイルで変更可)のポートを開放してUDPを通すとそこを使ってくれる(この場合、ホールパンチングではなくただの「中継サーバーを使わない直接接続」とでも呼べばいい)ようです。自宅やクラウドなどがSymmetric NATである場合、あるいはPort-Restricted Cone NATであるものの大学などのSymmetric NATの内側から接続したいことが多いという場合はこちらを選ぶのもよいでしょう。なお、セキュリティのためにはポートの開放をしなくて済むほうがよいと今まで述べてきましたが、UDPTCPと違って接続指向のプロトコル(ACKが返ってくる的な)ではないため、仮にUDPポートを開放していたとしても外部からそれを判断するのは(通信を傍受などしない限り)難しいというのをこの記事を書いているときに知りました。いわゆるポート開放確認ツールが原則的にTCPにしか対応していないのはそういうことだったようです。SSHのポートを外部公開していると怪しい国からログイン試行が来て気分が悪いみたいな話がありますが、そういうことはどうやら無さそうです。ということで、結局筆者もUDPポートを一つ解放する運用にしました。

ただし、Linuxで普通にポート開放すると、Tailscaleが起動していない時にそのポートが「閉じている」ことが分かってしまう(他のポートと挙動が異なるのが見える)という問題があります。これを回避する方法などに関してはDROP vs REJECT論争、そしてWindowsとLinuxのファイアウォールの動作の違いについて - turgenev’s blogで詳しく述べたのでお読みください。

設定ができたら、先ほどのようにSymmetric NATを模倣したLinuxからつながるかどうか試してみましょう。

ちなみに、大学・企業の設備などでは全てのUDP通信がブロックされているネットワーク環境だと何をどうやっても(ポート開放をしても)直接接続はできず、全てDERP経由になります。

サブネットの公開

Tailscaleでは、subnet router機能というのがあり、Tailscaleをインストールしている端末から見えている他のネットワーク機器をサブネット単位でまとめて公開(シェア)することができます。例えば、tailscaleの入っていないPCが192.168.1.12だとして、プリンター(もちろんtailscaleをインストールするのは無理)が192.168.1.6だとすると、PCのほうで

tailscale up --advertise-routes=192.168.1.0/24

とすることで、(そのPCが自宅LAN内で起動していれば)外出先の別のtailscale端末からでも192.168.1.6を指定すれば自宅のプリンターにアクセスできるようになります。

ちなみにFreeプランだと以前はサブネットを1つしか登録できませんでしたが、2023年4月くらいの変更で制限がなくなりました(ちなみに接続可能デバイスも100個に増えました、すごい)。

別のIPを割り当ててサブネットを公開

ただしこれだと若干問題があり、サブネットとして公開されたIP範囲は(tailscaleを使っている端末では)最優先で使用されるので、LAN内アクセスができなくなってしまうことがあります。

例えば先ほどの(192.168.1.0/24を公開している)PCとは別に、同じ192.168.1.0/24に所属するもう一つのPC2があってそこにTailscaleがインストールされている場合、PC2から192.168.1.0/24への通信は(実際にはその必要がないのに)Tailscale経由になります。普通のLANアクセスに比べると(僅かですが)オーバーヘッドが生じます。さらに、これは2つの「192.168.1.0/24」が実際には別のサブネットだった場合でも同じです。この場合、PC2がLAN内の端末に接続できなくなってしまいます。またサブネットを公開しているPCの電源が切れているとPC2から192.168.1.0/24につながらなくなります。かといっていちいちルーティングの優先度を変えるのは面倒です。

同様に、別々の場所にある192.168.1.0/24をどっちもTailscale上に公開しようとするとconflictしてしまうのでうまくいきません(ちなみに、subnet routerの冗長化のために同じsubnetを複数端末から公開する機能は存在します)。

この問題はNAT (DNAT) conflicting subnets in advertise-routes · Issue #826 · tailscale/tailscale · GitHubWhen local route is available to a subnet, bypass tailscale subnet relay · Issue #1227 · tailscale/tailscale · GitHubTailsacle accepted subnet routes should not not have a higher priority than main table · Issue #6231 · tailscale/tailscale · GitHubなどで扱われていますが、実は良い感じの(部分的な)解決策があります。それは、192.168.91.0/24のような「ダミー」のサブネットを公開し、そこへのアクセスをPC上で192.168.1.0/24に転送するという方法です。これなら、LAN経由の192.168.1.0/24と同時に使うことができます。また、複数の192.168.1.0/24を同時に公開することもできるようになります。もちろん、元のPCが常時稼働している前提です。

以下、PCとしてはLinuxUbuntu)を使っていると想定します(常時稼働のサーバーなのでLinuxを使うことが実際多いはずです)。まず、先ほどのようにadvertise-routesで192.168.91.0/24が公開された状態にしましょう。次に、IP転送を有効にします。具体的にはecho 1 > /proc/sys/net/ipv4/ip_forwardをします。rootが必要ですがリダイレクトがあるのでsudoを付けるだけではだめで、sudo suしてからこれを実行しましょう。

次に以下のようにtailscale0に対してDNATルールを設定します。

sudo iptables -t nat -A PREROUTING -i tailscale0 -d 192.168.91.4 -j DNAT --to-destination 192.168.1.4

これで、tailscaleをインストールした(他の)デバイスから192.168.91.4にアクセスすると、192.168.1.4につながるようになります。また、これだと機器ごとに転送設定が必要ですが(そのほうがいいときもあるかもしれませんが)、ネットワーク単位で一括で転送したければ以下のように「NETMAP」を使います。

sudo iptables -t nat -A PREROUTING -i tailscale0 -d 192.168.91.0/24 -j NETMAP --to 192.168.1.0/24

ちなみにローカル(このコマンドを実行しているまさにそのPC)から192.168.91.4でアクセスするのは、今までの設定だけではできず、以下のようにPREROUTINGではなくOUTPUTルールを使う必要があります。

sudo iptables -t nat -A OUTPUT -d 192.168.91.4 -j DNAT --to-destination 192.168.1.4

このままだと再起動後に設定がリセットされてしまいます。永続化するには、(tailscaleの起動後にこれを実行しなければいけないことので)以下のようなスクリプトを/etc/network/if-up.dに作成しましょう。

#!/bin/sh
if [ "$IFACE" = "tailscale0" ]; then
  iptables -t nat -A PREROUTING -i tailscale0 -d 192.168.91.0/24 -j NETMAP --to 192.168.1.0/24
  iptables -t nat -A OUTPUT -d 192.168.91.0/24 -j NETMAP --to 192.168.1.0/24
fi

ちなみに試していませんが、192.168.1.1/32のように個々の機器の単位でサブネットを公開することもできるはずで、これと今紹介したDNATを組み合わせるというやり方もありそうです。

追記: nftablesによる永続化

上記の方法だとtailscaleを再起動するたびにルールが増えていってしまいます。筆者が使っているubuntuではiptablesのバックエンドとしてnftablesという後継システムが使われており、これを使って転送設定を永続化させることもできます。以下のようにnftables.confに設定すればよいです。

table ip my_ip_table {
        chain prerouting_nat {
                type nat hook prerouting priority dstnat; policy accept;
                iifname "tailscale0" ip daddr 192.168.91.0/24 dnat ip prefix to ip daddr map { 192.168.91.0/24 : 192.168.1.0/24 }
        }
        chain output_nat {
                type nat hook output priority -100; policy accept;
                ip daddr 192.168.91.0/24 dnat ip prefix to ip daddr map { 192.168.91.0/24 : 192.168.1.0/24 }
        }
}

このip prefixを使った書き方は比較的新しく、あまり情報がないので注意が必要です。(参考:linux - IPTables how to nat 10.8.a.b to 10.0.a.b? - Super User

一時的にこの設定を行うには、上記の表の内容をnftコマンドで直接入力するだけです。これは難しくないので省略します。必要に応じて関連記事DROP vs REJECT論争、そしてWindowsとLinuxのファイアウォールの動作の違いについて - turgenev’s blogを参照してください。

他の方法・他のVPNサービスの状況

先ほど挙げたIssueにも載っていますが、192.168.1.0/23のような少し広いサブネットを--advertise-routesすることで、Tailscaleを介しない192.168.1.0/24へのルーティングより優先度を下げるという方法もあるようです。

またTailscaleの類似サービスであるZeroTierでも似たような手法でサブネットを公開できるようです。

Route between ZeroTier and Physical Networks - ZeroTier Knowledge Base - Confluence

Routing traffic to ZeroTier’s subnet from all devices on the LAN – Chris Tech Blog

その他Tailscaleでできること

自宅に常時稼働のTailscale有効PCがあると色々なことができます。例えば、前回紹介したような方法で、自宅のIPoE/PPPoE回線をそれぞれ経由するプロキシサーバーを立ててブラウザから使うことができます。

また、ProxyJumpを用いてsshを自宅のTailscale経由(ここでは紹介しませんが自分でsshサーバーを立てなくてもTailscale自体にssh機能があります)にすることで、ネットワーク環境が不安定(あるいはPCのスリープ中)でもコネクションがいちいち切断されないssh環境が手に入ります。moshやEternal Terminalもありますが、これらはリモート先のサーバーでroot権限やポート開放などのネットワーク設定ができる権限が必要です。

もちろん、自宅を経由するので、自宅から遠く離れた2箇所の間でsshしたいときなどは不便でしょう。

まとめ

Tailscaleを使えばポートを開放しなくても中継サーバーによるオーバーヘッドがない自宅までのVPN環境を整備でき、サブネットも自由自在に公開できるので大変便利です。おすすめです。