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

概要

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

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

iptablesとnftablesの基本

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

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

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

nftablesのテーブル

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

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

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

nftablesのルールの管理

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

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

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

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

#!/usr/sbin/nft -f

flush ruleset

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

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

flush rulesetの問題点

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

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

table単位でのflush

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

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

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

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

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

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

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

include "/etc/nftables.custom.conf"

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

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

ufw/iptablesとの使い分け

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

【v6プラス/OCNバーチャルコネクトでもNATタイプA】LinuxでポートセービングIPマスカレード付きの制限コーン風NAT(EIM/ADF)を動かす

概要

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

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

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

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

【追記】Rustへの移植

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

sudo ip rule add from 192.168.1.2 lookup rat

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

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

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

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

sudo ip rule add to 192.168.0.139 lookup rat

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

sudo ip rule add from 192.168.0.139 lookup some_vpn_table

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

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

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

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

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

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

ファイアウォールの設定

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

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

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

になります。

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

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

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

NAT動作の変更

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

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

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

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

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

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

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

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

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

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

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

試してみる

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

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

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

静的なポート変換など

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

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

MTU問題

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TCPDNSについて

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

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

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

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

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

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

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

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

Windowsでできるのか?

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

macOSはわかりません。

まとめ

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

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

概要

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

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

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

RFC 4787による分類

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

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

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

Endpoint Independent Mapping

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

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

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

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

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

Address Dependent Mapping

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

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

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

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

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

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

Address and Port Dependent Mapping

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

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

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

Connection Dependent Mapping

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

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

Filteringの違い - EIM NATの場合

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

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

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

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

Filteringの違い - 他のMappingの場合

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

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

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

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

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

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

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

Connection Dependent Filtering

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

NATの種類まとめ

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

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

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

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

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

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

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

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

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

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

ファイアウォールの影響

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

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

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

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

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

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

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

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

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

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

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

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

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

UDPホールパンチング

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SymmetricPort RestrictedConeRestrictedConeFull ConeFull ConeRestricted Cone×Port RestrictedCone××Symmetric

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

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

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

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

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

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

NATとセキュリティ

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

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

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

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

STUNによるNATタイプの判定

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PS4Nintendo SwitchでのNATの呼称

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

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

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

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

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

実際のNAT機器の動作

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

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

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

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

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

家庭用ルーターのNAT特性

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

まとめ

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

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

概要

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

プロキシサーバーを使う

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

原因8: conhost.exeのバグ

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

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

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

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

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

その他

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

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

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

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

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

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

まとめ

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

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

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

概要

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

DROP vs REJECTとは

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

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

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

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

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

REJECTの方法の分類、TCPUDP

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

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

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

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

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

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

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

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

DROPも試してみる

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

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

ポートスキャン入門: TCP

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

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

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

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

python -m http.server 8001 

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

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

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

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

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

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

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

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

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

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

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

nc -lu -p 9001

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

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

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

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

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

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

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

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

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

Windowsファイアウォール

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

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

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

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

Linuxファイアウォール

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

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

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

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

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

WindowsLinuxの違い

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

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

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

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

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

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

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

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

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

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

追記: Windowsのstealth mode

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

Stealth Mode in Windows Firewall with Advanced Security | Microsoft Learn

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

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

各種ルーター

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

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

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

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

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

DROP vs REJECT、結局どっち?

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

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

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

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

(以下は英語)

Drop versus Reject

IPtables: TCP-Reject vs DROP : linux

iptables REJECT vs DROP | todisco.de

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

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

linux - REJECT vs DROP when using iptables - Server Fault

ufw Linux firewall difference between reject and deny - Stack Overflow

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Linuxの挙動をWindowsに近づける

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

sudo nft add table ip my_ip_table 

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

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

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

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

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

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

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

次にUDPです。

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

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

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

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

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

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

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

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

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

HTTPサーバーで試す

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

from http.server import BaseHTTPRequestHandler, HTTPServer
import time

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

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

sudo tcpdump -i any tcp port 8000

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

netcatで試す

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

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

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

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

Tailscaleをうまく動かす

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

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

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

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

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

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

設定結果、永続化など

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

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

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

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

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

結論

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

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環境を整備でき、サブネットも自由自在に公開できるので大変便利です。おすすめです。