ssh-agent転送のセキュリティ問題と適切な鍵管理の方法について考える

ssh-agentは鍵ファイルのパスフレーズを記憶してsshログイン時に毎回入力しなくても済むようにしてくれたりリモートマシンからローカルの鍵を使えるようにしてくれたり(ssh-agent転送)といった機能をもつ便利なツールですが、このssh-agent転送にはセキュリティ的なリスクがあり、しかも現状のssh周りのエコシステムはこれに対処するためのベストな機能を提供していません。この記事ではこの問題の詳細と、現状で最もマシと思われる鍵管理の方法について説明します。

 

状況として、まずあなたの手元には自分自身が所有する(信頼できる)マシンAがあり、そこから管理者がそれほど信頼できない踏み台マシンBにアクセスし、さらにその上でsshを実行して別のマシンCにアクセスしたいといったあたりを想定してください。

 

基本知識として、sshの公開鍵認証においては、サーバー側が送ってきたランダムなデータにクライアント側が自身の秘密鍵で署名して送り返すことでサーバー側がクライアント側の正当性(実際に秘密鍵を所有していること)を確認します。そこで、ssh-agent転送を使う場合は、踏み台マシンBから手元のマシンAに署名リクエストを送信し、受け取った署名済みデータを最終的な目的地であるマシンCに転送することで認証を行います。こうすることで、マシンBに秘密鍵を置いておくのを避けることができ、安全性が向上するとされています。

しかし実際には、マシンBの管理者(root、Administrator)はマシンBの任意のユーザーになりすますことが可能であり、あなたと全く同様にあなたのssh-agentを使用して任意の署名リクエストを送信することができますssh-agent hijacking)。つまり、あなたがマシンB上からssh-agentを通じてアクセスできるサーバーにはマシンBの管理者も自由にアクセスできます。ssh-agentはあくまで署名を代行するだけなので秘密鍵それ自体を入手することはできませんが、実質的には秘密鍵を持っているのと同レベルの知識が利用可能になります。詳細はAn Illustrated Guide to SSH Agent Forwardingなども参照してください。

逆にマシンBに秘密鍵をそのまま置いたとしても適切なパーミッションが設定されていればrootユーザー以外からは見えないため、「リモートのssh鯖マシンのディスクが物理的に盗難された」「ssh鯖マシンの管理者に悪意があったがssh-agent hijackingについては知らなかった」といった特殊な状況を除けば、ssh-agent転送のリスクは秘密鍵をそのまま置いておくのとあまり変わりません

 

この問題は(現状よりもっと知られたほうがいいとは思いますが)ある程度は知られていて、いくつかの対策が考案されています。

 

まず有効なのはProxyJumpを使用することです。これは2016年リリースのOpenSSH7.3(PuTTYだとおそらく2022年リリースの0.77?)からサポートされた比較的新しい機能で、今まで使われていたProxyCommand='ssh -W %h:%p user@server'のような設定ではsshコマンドを踏み台サーバーで実行していたのに対し、目的地のサーバに対して手元から直接ログインする(イメージとしてはssh A -NL 10022:serverB:22をしてから ssh -p 10022 user@localhostをする感じ)ようになるため、踏み台サーバーにおいて鍵が利用可能である必要がなくなります。従って、単にログイン時の一時的な踏み台として使うだけであれば常にProxyJumpを使うべきです。少なくとも.ssh/configにあるProxyCommand='ssh -W %h:%p user@server'のような記述は全てProxyJumpに書き換えたほうがいいと思います。踏み台マシンBに一旦ログインして作業しつつその途中でマシンCにもsshログインしたいという場合には使えませんが、BからCにアクセスして行う作業が決まりきったものである(たとえばsftpでファイルを転送するとか)場合はB上からCにログインするのではなくポート転送を併用してローカルマシンを介してBとCの間で通信を行うほうが望ましいといえます。gitでも、ファイルはrcloneなどでマウントしてpushとfetchだけは手元で実行するといったやり方もあります。

 

しかし、それでもやはりssh-agent転送をしたい、という場合もあると思います。その場合でも、必要最小限のサーバーに必要最小限の鍵を転送するように設定できるのが理想的です。そこで、アクセス先サーバーごとにssh-agentの鍵を変える方法について考えます。

 

その前にまずssh-agentとクライアント(ssh, ssh-add)がどのように通信するかについて見ておく必要があります。

まずssh-agentというのはOpenSSHに含まれるもので、クライアントとの通信に使われるプロトコルは、draft-miller-ssh-agent-14に記載されています(参照: openssh-portable/PROTOCOL.agent at master · openssh/openssh-portable · GitHub)。筆者の理解では、これはSSHプロトコル自体とは独立に定義されているものであり、全く別のプロトコルを使ってssh-agentのようなものを勝手に実装したsshクライアントを作ることも可能だと思います。しかし実際にはOpenSSHと並んで結構使われているPuTTYの(ssh-agentにあたる機能である)pageantssh-agentと互換性のあるプロトコルで動作するようで、これがデファクトスタンダードになっていると考えてよいでしょう。

また、このプロトコルは適当な双方向のバイトストリームがあれば動作し、このバイトストリームの部分にはシステムによって異なるものが使用される場合があります。基本的に*nix系ではUnix domain socketが使われると考えてよいのですが、WindowsにはUnix domain socketがないため色々と面倒なことになっています。これに関しては混沌を極めるWindowsのssh-agent事情 #Windows - Qiitaに詳しく書いてあります。少しこの記事に付け加えておくと、基本的に標準入出力というのはWindows(ネイティブ)/WSL/WSL2/Cygwin/MSYS/Git Bashの全ての間で互換性があるので、例えばnpiperelayをsocatと併用すれば、Windowsネイティブのssh-agentをCygwinやGit Bashsshから使うことも普通に可能です(noforkではなくpipesを使用してください copy from stdin to pipe failed · Issue #13 · jstarks/npiperelay · GitHub)。あまり知られていないようですがstream-connector - Programming Fieldというのも使えそうです。

 

本題に戻ります。今はアクセス先サーバーごとに別のssh-agentを見るように設定するのが目的でした。*nixでは基本的にSSH_AUTH_SOCKという環境変数ssh-agentと通信するためのUnix domain socketが入っているので、ここを適切に変えた上でsshを起動してやればできそうです。一方Windowsの標準のOpenSSH実装ではUnix domain socketのかわりにNamedPipeを使っていて、しかも通信先パスが//./pipe/openssh-ssh-agentにハードコードされているので、標準のsshに見せるssh-agentを切り替えるのは簡単ではなさそうです。ここではCygwinやWSLなどWindowsネイティブ以外のsshを使うことを想定します。

別々のssh-agentを立てるといっても、全く独立のssh-agentにそれぞれ鍵を追加するというのは、自動起動させたい場合などの管理が面倒そうです。そこで有力なのが、「親となるssh-agentとの間で通信をプロキシし、許可された鍵に関する情報だけを転送する」というアプローチです。

これを実装したソフトウェアとしてssh-agent-filter(https://git.tiwe.de/ssh-agent-filter.git/)があり、afsshというラッパーを使ってafssh --comment this-key-is-allowed -- my_serverのようなコマンドを実行することで、指定した文字列がコメントに入っているキーだけを有効にしたUnix domain socketをSSH_AUTH_SOCKに指定してsshを実行できるというものです。最近はあまり更新されておらず、試した感じWindowsCygwin)ではうまく動かないようですが、手元でRustで同じようなもの(ただし標準入出力だけを使っていてUnix domain socket部分はsocatに丸投げなのでクロスプラットフォーム対応)を作ったのでそのうち公開すると思います。

これにより、親となるssh-agentですべての鍵を一元管理しつつ、踏み台にアクセスするときはそれぞれの環境で必要な鍵だけを転送することができるようになります。例えばホストCではこちらのGitHub鍵のみ、ホストDではこちらのGitHub鍵のみを使うといった設定が可能です。

 

しかし、これでもまだ必要な機能が不足しています。それは、「sshの認証にはssh-agentの全ての鍵を使用するが、転送時には一部の鍵のみ使用する」という機能です。実際、パスフレーズ付きの鍵を使用していて入力をサボりたい場合にはアクセス先サーバー上で一切使用しないとしてもssh-agentに登録する必要があり、しかもsshの実行時にそれが見えている必要があります。で、sshのエージェント転送は自分の実行時に見えていたssh-agentの情報をそのままリモート側に転送してしまうので、必然的に「sshログイン時にssh-agentから使用した鍵はログイン先のサーバーからも利用可能になる」ということになります。これはセキュリティ的には不満です。

 

これを解決する方法を色々考えましたが、どれも微妙で、ssh本体を変更しない限りは無理という感触です。まず考えられるのはssh-agent側でどこから署名リクエストが来たのか判別するという方法ですが、sshログイン時でもssh-agent転送先からのリクエストでも送られてくるデータは全く同様なので判別は難しそうです(参考: ssh - Allow only specific keys in agent forwarding? - Server Fault)。呼び出し回数などで判断することもできそうですが、ProxyJumpなども考えるとログイン完了までに何度呼び出されるかは事前に決定できず、ssh接続を立てるたびに毎回新品のUnix domain socketを用意するのも美しくないのであまり実用的とは思えません。ssh-agent転送を使用せずTCPのポートフォワードを別で設定してsocatと併用して手元のssh-agentを使うというのも考えたのですが、TCPポートは他のユーザーから見えるし、domain socketはプロセスが終了しても自動で消えてはくれないし、かといってポートフォワードでランダムな名前を指定・取得することもできないしといった感じでこれも実用には耐えなさそうです。

 

将来的にはssh側の改善を期待するとして、当面の回避策を考えてみます。まずは、ローカルマシンからのログインの場合であればパスフレーズ無しの鍵を使用し、そもそもssh-agentに登録しないというのが考えられます。もちろんディスク上に平文で置いておくのは危険ですが、最近はBitLockerのように起動と同時に解除されるディスク・パーティション暗号化もあるため、再起動後もssh-agentに鍵がずっと登録されていてパスフレーズ入力も不要といった使い方をしている場合はそれほどリスクとしては変わらないと思います(逆にいえばこれ自体がそれなりに危険という話でもありますが)。

あるいは、最終的なアクセス先を保護する必要性がそれほど大きくない(例えば踏み台と最終的なアクセス先を同じ管理者が管理している)場合であれば、あえて最終的なアクセス先マシンのためのssh鍵を踏み台に平文で置いておき、ssh-agentを使用しないという選択肢もあるでしょう。先ほども述べたように、ポートフォワードでなんとかするというのも有力な方法です。

 

以上のように、ssh-agent転送は雑に使っているとむしろ平文で鍵を置いておくより危険になる場合もあります。十分注意して使用しましょう。

root権限のないssh上にUDPを通す方法について(ポートフォワード、SOCKS5プロキシなど)

sshはリモートマシンとの通信に汎用的に使われるセキュアなプロトコルで、ポートフォワードを使用してリモート上のプログラムがローカルで使える(あるいはその逆)ようにしたり-Dオプションを使用してSOCKSプロキシとして動作させることでローカルのアプリケーションにリモートの通信環境を使わせたりすることができます。

しかし、sshTCP接続ベースのアプリケーションであり、上記の機能もすべて同じTCP接続を通ることになるため、ポートフォワードはTCPポートにしか使えず、SOCKSのバージョン5(SOCKS5)で追加されたUDP associateの機能も使えません。root権限があるならVPNを立てたりUDPでポートを開放したりいくらでも方法はあるのですが、今回はroot権限がない場合を考えることにします。

とはいえ、TCP接続があるならそこに任意の形式のデータを流せるので、クライアント・サーバーの両側で適切に処理してやればUDP通信を通すこと自体は別に不可能というわけではありません。

ただし、TCPはストリーム指向のプロトコルであり、一般にはパケットの境界が維持されるとは限らないため、socatなどでそのままデータ部分を変換するだけだと、UDPパケットが転送中に分割・結合されてしまって正しく通信できなくなる可能性が高くなります(参考: UDPのパケットをSSHを通してトンネルするUDP traffic through SSH tunnel - Super User)。

解決策として考えられるのは上の2つ目のリンクにもあるようにTCPパケット中に元のパケット長さの情報を持たせておく方法です。これなら完全に元と同じUDPパケットを復元することができます。これをやってくれるソフトウェアの例がmullvad/udp-over-tcpです。

これを使えば、UDPのポートを一旦TCPに見えるように変換して、それをsshで通して、手元で再びそれをUDPとして見えるようにする、という形でUDPのポートフォワードを行うことができます。

他のソフトウェアとしては、すごく年季が入ってそうな感じのstoneというのも使えそうですが、試していません。

また、形としてはUDP通信ができていますがTCPを経由しているため、UDPプロトコルとしての恩恵(低遅延)を受けることは当然できません。比較的良好な通信環境で使うのがいいと思います。

単なるポートフォワードは比較的簡単なので、次はプロキシについても考えてみます。目標としては、sshサーバー側にすべてのTCPUDP通信を流すようなUDP associateをサポートする)SOCKS5プロキシをローカルで動作させるといった感じです。

SOCKS5のUDP associateは、「クライアントがUDP associateを要求→プロキシサーバーがUDPポートを1つ確保して通知→クライアントがそこにUDPパケットを送信→プロキシサーバーがそのUDPパケットを最終的な目的地との間で仲介」といった流れで動作します。つまりプロキシまでUDPが届く必要があります。

最終的な目的地にむかってUDPパケットを出すのはリモート(sshサーバー)側でやらなければいけないことなので、とりあえずUDP associateをサポートするSOCKS5プロキシ(Danteや3proxyなど。詳しくは特定アプリケーションのTCP・UDP通信を透過的なSocks5プロキシ経由にする方法(Windows・Linux・Androidなど) - turgenev’s blog)をリモート側で動かすことにします。そして、まずポートフォワードを使ってSOCKS5のポートがローカル側のlocalhostで見えるようにします。

この状態だとTCPなら普通にSOCKS5が使えますが、UDPだとサーバー側が割り当てたポートにクライアントがアクセスできません。これをさっきのudp-over-tcpのような方法で無理やりアクセスできるようにする必要があります。

そこで、こんなツールを作りました。

github.com

サーバー側(SOCKS5の本体と同じ側)/クライアント側(SOCKS5が動いていると見せかけたい側)に分かれていて、標準入出力を使って通信します。

クライアント側でSOCKS5のUDP associateリクエストへの応答(通信に使用すべきUDPポートが含まれている)を検出すると、サーバー側に対してそのUDPポートとの通信を行うよう要求し、ローカル側では別に新たなUDPポートを割り当ててそちらをクライアント側に通知します。これによりクライアントがローカル側のUDPポートに送った内容がリモート側に行くようになり、逆も通るようになります。

socatと併用すればローカルとリモートが逆の場合(sshサーバー側からクライアント側を通ってインターネットに出ていきたい場合)でも動かせます。

これでSOCKS5として動くようになったのであとは特定アプリケーションのTCP・UDP通信を透過的なSocks5プロキシ経由にする方法(Windows・Linux・Androidなど) - turgenev’s blogの通りにすれば特定アプリケーションのみにこのSOCKS5を使わせることができます。こちら側では管理者/root権限が必要であることに注意してください。

古い内容

(これは上記のツールを作る前に書いていた内容で、その直前からの続きです)

例えばDanteではUDP associateに使うポートの範囲をudp.portrangeで設定できます。ここで適当に2000-2010などと指定しておきます。次にリモート側でtcp2udpを11個起動して、Danteが稼働しているIPのUDPポートの2000-2010がそれぞれlocalhostTCPポートの2000-2010で見えるようにします。次にsshのポートフォワードを使ってローカル側のlocalhostTCPの2000-2010でこのTCPポートが見えるようにします。次にローカル側でudp2tcpを使ってlocalhostUDPの2000-2010で先ほどのTCPポートが見えるようにします。

これで、Danteが通知してきたUDPポートにむかってローカル側からパケットを送れるようになります。つまりローカル側でUDP associate付きのSOCKS5が動いているのと同じ状態になります。

なお、Danteが通知してきたUDPポートにはアドレスも書かれているため、これがlocalhostじゃなかったらうまく通信が成立しないのでは?と思われるかもしれません。が、先ほどの記事に書いた通り、WindowsでProxifyreを使う場合は勝手にIPアドレスを読み替えて、SOCKS5サーバーとして指定したIPのほう(今回ならlocalhost)を見に行ってくれるのでうまくいきます。手元で、例えばDiscordの通話などのUDP通信がちゃんとsshサーバーを通ることを確認しました。一方redsocksやhev-socks5-tproxyはそうしてくれないので、実際うまくいきません。通知されるパケットを書き換えるか、redsocksやhev-socks5-tproxyの側を変えるかだと思いますが未着手です。

また、それ以外にも以下のような問題が残っており、まだ実用できるレベルとは言えません。

  • udp2tcpは一度UDPパケットを受け取ったらずっとその相手にしか応答を返さなくなるのでポートを再利用することができない。しばらくすると使えるポートがなくなって通信が止まる。
  • udp2tcp・tcp2udp・ポート転送を大量に実行する必要があり、コストがかかる
  • リモート側でtcp2udpを実行するため、TCPポートを消費する

理想的には、SOCKS5のメインのTCPポートをプロキシする専用のアプリケーションを設けて、UDP associateのポート割り当てを検出し、それに応じて動的にリモート・ローカル両側でudp-over-tcpを設定してからそのポートをローカル側に通知する、といった実装をする必要があると思います。そのうちやってみたいものです。

あと多分ローカルとリモートを逆方向にしたプロキシについても同じように実現できると思います。

他の手法

hev-socks5-serverは、SOCKS5を独自拡張してTCPを通してUDPのデータを流せるようにしていて、同じ作者によるクライアント(これも特定アプリケーションのTCP・UDP通信を透過的なSocks5プロキシ経由にする方法(Windows・Linux・Androidなど) - turgenev’s blogにあります)を使えば勝手にUDP over TCPしてくれるようです。(ただ、Androidで試したときは自作ツールのほうが安定しているように見えました)

また、他のアプローチとして、tproxyとともに使用するとssh経由でUDP/TCPを転送してくれるsshuttleというのがあり、これもおそらく内部でudp-over-tcpと同等なことをしているので、クライアント側がLinuxならこれで目的が達成できそうな気がするのですが、SOCKS5じゃなくて透過プロキシ部分とつなげて実装されているのがあまり筋が良くなさそうな感じがするのとPythonで書かれているのが微妙な気がしたのでそこまで深入りしませんでした。

あとは関連するソフトウェアとして、sshと同様に動作しつつUDPも通してくれるSecure Socket Funnelingもありますが、数年更新されていないのとWindowsのバイナリがトロイの木馬扱いされるなどの難点があり、こちらも深入りは避けました。

DNSについて

これも特定アプリケーションのTCP・UDP通信を透過的なSocks5プロキシ経由にする方法(Windows・Linux・Androidなど) - turgenev’s blogに書いたのですが、UDPの主な用途の一つであるDNSに関しては、SOCKSプロキシはIPアドレスではなくドメインでリクエストを受け取ることもできて、その場合はプロキシ側でドメインを解決してくれるので、クライアントがこの方式に対応している場合(例: Firefox)はわざわざ今回のようにUDP over TCPをする必要はありません。

まとめ

udp-over-tcpの機構自体は単純なので、OpenSSHの-Dが標準でこれをサポートしてくれたら一番楽なのですが、さすがに無理でしょうかね。

需要について考えてみると、そもそもUDPが必要という状況自体がそこまで多くないのと、管理者権限のないsshサーバーというとレンタルサーバーなど非常に限られた資源しか使えない状況も多く、その場合だとリモート側でもUDPが使えないこともあってそうなると全く意味がありません。

しかし、できるとなれば需要が増えそうな気もします。

ファイルブラウザ(エクスプローラ)の右クリックメニューで、rclone・sshfs経由で閲覧しているssh先のファイル・フォルダをssh先のシェルやVS Codeで開く

sshを使ってリモートのサーバー上で開発を行っているとします。このとき、基本的にはほとんど全てのこと(コンパイル機械学習など計算を伴うものや、ファイル操作など)はsshで入った先のリモート上(ターミナル、あるいはVS CodeならRemote SSH拡張機能)で行えばいいのですが、ファイルの一覧・リネーム(移動)などはlsやmvを手打ちするよりも視覚的にわかりやすい手元のファイルマネージャーでやりたいという需要があります。

そこで使われるのがrclonesshfsなどのリモートのディレクトリをマウントしてくれるソフトウェアです。なおsshfsは開発終了になってしまったようなので現在はrcloneを使うのがいいと思います。rcloneのsshは独自の実装になっていてsshのconfigに書いてある内容を改めてrclone configの対話シェルで打ち込まなきゃいけないのが大変微妙(一方でsshfsはconfigを読んでくれる)だったのですが、2023年9月のv1.64.0から--sftp-sshというオプションが導入され、"dummy"みたいな名前のダミーのssh(sftp)設定を一つ作っておけばあとはrclone mount --sftp-ssh "ssh my_server" dummy:/ /media/myremoteみたいなコマンドで任意のsshサーバーがマウントできるようになり、いよいよsshfsを使う理由はなくなりました。

あと手元のLinux Mintで動かす限り、rclone mountするよりもrclone serve webdavしてからdav://localhost:8080にアクセスするやり方(gvfsというやつでマウントされるっぽい)のほうがレスポンスがいいような気がしますがこれは設定のしかたが悪いのかもしれません。

しかしマウント方法の細かい違いはここでは一旦置いておきます。とりあえず、マウントはできたとして、そこからがこの記事の対象です。ちなみにタイトルでわかると思いますがクライアントはWindows/Linuxどちらも対象です(持っていないので試していませんがMacでもできると思います)。サーバー側はとりあえず*nix想定です。

マウントまでできてもなお何が不満かというと、ファイルマネージャー(エクスプローラ)でファイルを閲覧したとして、そこから直接VS CodeのRemote SSHとかssh先のシェルを開く方法がないということです。ファイルの閲覧はせっかくできるのに、ssh先で同じものを操作しようと思ったらsshコマンドを手打ちしてlsで移動したりVS Codeの「最近使ったフォルダ」みたいなところを探さなきゃいけなかったりというのはバカバカしい感じがします。

幸い、エクスプローラはじめ多くのファイルマネージャーでは右クリックメニューに好き勝手なコマンドを入れることができます。これを使って、特定のパスに関しては、リモート先のパスに読み替えてコマンドを実行することにしてしまいましょう。

とりあえず、ここにコードの主要部分を上げておきました。

ge9/ssh-context-menu · GitHub

しかし、何せ右クリックメニューへの追加なので、レジストリをいじる必要があり、インストール/アンインストール用のコマンドとか書くのが面倒なのでそのへんは公開していません。各自で頑張って登録してください。この記事の伸び具合を見て整備しようと思います。

注意点をいくつか。Windows用のほうはPowerShellスクリプトなのでpowershell -ExecutionPolicy RemoteSigned -File "path\to\script.ps1"で呼び出せます。レジストリに関してはWindowsの右クリックメニューから複数ファイルをまとめて開く - Turgenev's Wikiも参考にしてください。なんのためかわからない置換処理は変な名前のフォルダへの対応策です。というかむしろここが一番価値のあるところかもしれません。これに関してはWindowsでエクスプローラーからフォルダを各種ターミナルで開く - Turgenev's Wikiにも書きました。

内容に関しても軽く解説します。

まず両者に共通して、設定ファイルにはローカルのフォルダ名とリモートのサーバー名・フォルダ名をペアで記載していて、上から順にマッチしていきます。なので適切に優先順位をつければ、C:\foo\bar\bazの中だったらserver1の/homeで開きたくて、そうじゃないけどC:\foo\barの中だったらserver2の/disk1で開きたくて、みたいなことも可能です。あとパスは単純に置換してるだけなので、ローカルのほうの末尾に(バック)スラッシュを書いたならリモートのほうにも書く、という感じで大丈夫です。テンプレートを付けているので、それをコピーして.txtに変えてから中身をいじるといいです。

シェルを開くほうはCygwinのターミナルを開くメニューと共用しています。どちらもWindows Terminalで開くように設定していて、この関係でセミコロンをバックスラッシュでエスケープする羽目になっています。

VS Codeのほうは、ファイル/フォルダを開くときにそれぞれ--file-uriと--folder-uriというオプションを指定する必要がある(自動判定に頼ると限界がある)ので、スクリプトの引数でvsc.ps1 folder C:\some\folderのようにタイプを指定できるようにしています。あとVS Codeのインストール時にパスを通すよう設定するとcode.cmdとかいう意味わからんファイルにはパスが通りますがCode.exeにパスが通りません。特殊文字を含んだパスの処理にcmd.exeを介在させるのは自爆行為なので、code.cmdが入っている親ディレクトリにあるCode.exeを直接呼び出しています。

あとremdelete.ps1というのがありますがこれはリモート先でrm -rfを実行するためのメニューを提供します。ローカルでやるとローカル側でファイルの再帰的な羅列と削除が走って非常に遅くなります。移動のときみたいにリモート側のコマンドに翻訳してくれればいいのに…。あと圧縮ファイルを解凍するメニューも入れようかなと思いましたがさすがにニッチすぎるのでやめました。このメニューに関しては手元ではレジストリキーのAppliedTo値にSystem.ItemPathDisplay:$<"\\"などと書くことで\\で始まるWebDAVサーバー内のフォルダのみこの右クリックメニューが表示されるようにしています。Windowsは意外と色々なことができて面白いですね。

Linuxは手元の環境としてはLinux Mintのcinnamonでgnome-terminalとNemoを使っています。gnome-terminalのかわりにここに置いてあるopen_gnome_terminal.shをデフォルトのターミナルとして登録する感じです。Nemoでは①フォルダの背景(白いところ)②フォルダの右クリック③(複数の、フォルダを含まない)ファイル、を対象にしたメニューをそれぞれ作れるのでこれを使ってメニューを追加しています。

ちなみに余談で、少し異なるものの関係あるツールとして、リモート先に接続したssh端末からコマンド一発でローカル側のVS CodeのRemote SSHを開けるようにしている方もいらっしゃるようです(VSCode Remote SSHで別Shellからファイルを開く)。

情報不足のところはあとから書き換えるつもりなので、わからないところがあれば気軽に聞いてください。

Rustのslotmapやslabを使って双方向連結リスト(っぽい何か)を作る

Rustで双方向連結リストが欲しいと思ったときは、意外と簡単に実装できる代替品が使えるかも?という記事です。

Rustでの連結リスト実装に関しては、何よりもまずIntroduction - Learning Rust With Entirely Too Many Linked Listsという有名な文献があります。この最初のページに、「Linked Listが本当に必要な場面は思ったよりかなり限られているので、まずはVecかVecDequeあたりの使用を検討しろ」ということが書いてあります。

ただそれでも連結リストが必要なときというのはあります。筆者はソフトウェアNATこちらの記事で詳しく解説しています)でIPアドレス・ポートのマッピングタイムアウトの順に並べて管理するために連結リストが欲しくなったのですが、ここではもう少し単純な問題を考えてみましょう。

以下のような状況をプログラムで表現するとします。

  • (uniqueな)名字を持った人間たちが、1列で順番待ちをしている。基本的には、最初に来た人が最初に通されるが、ときどき「高橋さんを先に通して」とか「田中さんを佐藤さんの一つ後ろに移動させて」のような命令が来る

先頭・末尾における要素の追加・削除はVecDequeでもO(1)でできますが、この例では途中での追加・削除があり、しかもその操作が名前で指定されます。名前で指定されるということは定数時間でのルックアップのためにHashMapを使いたくなりますが、HashMapの値として何を使うか?という問題が生じます。VecDequeのインデックスをそのまま使用すると、人の位置が時間により変動するのを捕捉できなくなります。計算量うんぬん以前に、要素へのポインタを保持しておくにはVecDequeは使えません。(多分。そこまで自信はないです)

そこで連結リストが欲しくなります。連結リストがあれば、ノードへのポインタをHashMapの値として持っておくことで、名前を指定されたときにその人がどの場所にいて前後に誰が並んでいるかまでわかるため、O(1)で削除・挿入を行うことができます。

しかし一方で、Rustには厳格な所有権システムがあるため、双方向連結リストのように循環するポインタを含むようなデータ構造を表現するのはかなり面倒です。

もちろんunsafeな生ポインタを使えばCと同じように実装でき、バグを埋め込みにくい自明なデータ構造であることとパフォーマンスも考えればこれも悪くない選択肢ではあるのですが、ここではsafeなやり方を考えてみます。その場合は、書き換え・共有が可能なスマートポインタであるRc<Refcell<T>>を使って実装することになると思います(Rustスマートポインタ比較表 #Rust - Qiitaが参考になります)。また、Debug表示の際などに循環参照が発生するのを防ぐため、双方向連結リストの場合はどちらか一方(典型的には一つ前のエントリを指すポインタ)をRc(強参照)ではなくWeak(弱参照)にする場合が多いです。具体的な実装についてはSimple doubly-linked list in safe Rust · GitHub『みんなのデータ構造』を読んで Rust で実装した #Rust - Qiitaを参照してください。また、Rustの標準ライブラリにも連結リストは一応あります(LinkedList in std::collections - Rust)が、途中の要素へのポインタを保持するために使えそうなcursor系の関数がNightlyのみで利用可能となっており、このあたりを見る限り使い勝手もあまりよくないようです。

そして、いずれにしても、メモリが分散して使用効率が悪いというリンクリストのよく知られた欠点が解決できません。

ここで少し考えてみると、普通の配列(Vec)を使って双方向連結リストっぽいものを作ることもできそうな気がします。つまり、配列のインデックスをポインタのように使用することで前後の要素をたどることができるということです。しかし単純に新規挿入のたびにインデックスを1つずつ増やすという実装にすると、インデックスが際限なく増えていってしまい、かつ小さいインデックスのところはすでに削除された要素が大量にあり、メモリを無駄遣いしてしまいます。

これを防ぐために、未使用のできるだけ小さいインデックスを使用するというような実装にすることが考えられます。こちらでキーを事前に指定するのではなく、挿入時に勝手に適切な場所を選んでキーを決めてもらうということです。

これをやってくれるのがslotmapslabといったcrateです。slotmapに関しては公式リポジトリ実装例があるのですが先頭・末尾の削除の実装が間違っています(報告済み)。自分のソフトウェアNATでの実装ではいくつかの関数を追加しているのでこちらもご覧ください。slabを使った実装例はGitHub - GallagherCommaJack/linked-slab: A doubly-linked list backed by a slabにあります。ほぼ同じ内容なのは見てわかると思います。

slotmapとslabの機能的な違いとしては、slotmapはキーのバージョン管理に対応しているというのがあります。すなわち、(index, version)という組がキーになっています。これによって、キーが指している要素が一旦削除されたあとそこに別の要素が追加されたとしても、古いキーが(indexの一致する)新しい要素を誤って指してしまうことなく、無効と判定されるということです。

これはゲームのように複数の場所から1つのオブジェクトが参照されたりオブジェクトがいつ勝手に消えるかわからなかったりする状況で特に有用なようで、実際にgenerational-arenaといった名前でslotmapと同等の機能を提供しているcrate(ただしこれは開発停止している?)もあります。この文脈での解説記事としては[Rust] ゲーム向け世代型Idアリーナによる最適化 #Rust - Qiitaが詳しく、こちらもおすすめです。

連結リストでは追加・削除を厳密に追跡できるのでslabで十分です。

他にもstable-vecなど様々なcrateがあるようで、ベンチマークBenchmarking slotmap, slab, stable_vec etc.にあります。

注意点として、タイトルにもある通り、こうして実装できるのは厳密には双方向連結リストではありません。具体的に何ができないかというと、リストの分割あるいはリスト同士の結合をO(1)で普通に行うことができません。slotmapやslabはあくまで1つの配列がベースになっているからです。

しかし、先ほど述べた待ち行列の管理のように、連結リストそれ自体は1つあればよくて分割も結合もしないという場合や、同じ配列上に複数の連結リストが共存しても問題ないという場合は、slotmapやslabはシンプルな連結リストの代替品となり得ます。

正直Rustにもデータ構造にもそこまで詳しくないので間違っているかもしれませんが、以上になります。お役に立てば幸いです。

日本国内の組織に割り当てられている1048576(/12)以上の大きさのIPアドレス範囲の一覧

何となく知りたくなったので、JPNIC管理下で、APNICが逆引きの管理を行っているIPv4アドレス一覧 - JPNIC この中で1048576(0x100000)以上の大きさのものをどこの会社が持っているのか調べてみました。全部で14個あります。大きい順(大きさが同じなら若い順)に並べます。大きさは16進数表示です。所有者はhttps://whois.nic.ad.jp/cgi-bin/whois_gwで調べて"[ネットワーク名]/[組織名]/[Organization]"を記載しています。範囲内のどのIPを入れるかによってこのサイト上で表示される所有者が複数出てくる場合もあり、見つけた限りのものはせっかくなので載せていますが、網羅的であるとは限りません。

1位(0x7E0000個) 153.128.0.0-153.253.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

  • 日本に割り当てられたIPアドレスの4%以上を占めます。直後の153.254.255.255-153.253.255.255((ネットワーク名無し?)/NTTリミテッド・ジャパン株式会社/NTT Ltd Japan Corporation)とあわせると153.128.0.0/9で、日本(同じ組織が対象とは限らない)に割り当てられたサブネット範囲としても最大級のものです(参考: 🇯🇵 日本[jp]に割り振りされたIPアドレスの一覧 : ipv4.fetus.jp)(最大は133.0.0.0/8)。

2位(0x400000個)106.128.0.0    -    106.191.255.255

所有者: KDDI-NET/KDDI株式会社/KDDI CORPORATION

所有者: WIMAX-NET/UQコミュニケーションズ 株式会社/UQ Communications Inc.

2位(0x400000個)180.0.0.0    -    180.63.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

4位(0x200000個)114.160.0.0    -    114.191.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

所有者: PLALA/株式会社NTTドコモ/NTT DOCOMO,INC.

5位(0x100000個)27.80.0.0   -   27.95.255.255

所有者: KDDI-NET/KDDI株式会社/KDDI CORPORATION

5位(0x100000個)49.96.0.0   -   49.111.255.255

所有者: MAPS/株式会社エヌ・ティ・ティ・ドコモ/NTT DoCoMo, Inc.

5位(0x100000個)59.128.0.0   -   59.143.255.255

所有者: KDDI-NET/KDDI株式会社/KDDI CORPORATION

所有者: KDDI-NET/DION (KDDI株式会社)/DION (KDDI CORPORATION)

5位(0x100000個)60.32.0.0   -   60.47.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

所有者: PLALA/株式会社NTTドコモ/NTT DOCOMO,INC.

5位(0x100000個)111.96.0.0   -   111.111.255.255

所有者: KDDI-NET/KDDI株式会社/KDDI CORPORATION

5位(0x100000個)113.144.0.0   -   113.159.255.255

所有者: KDDI-NET/KDDI株式会社/KDDI CORPORATION

5位(0x100000個)114.144.0.0   -   114.159.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

5位(0x100000個)118.0.0.0   -   118.15.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

5位(0x100000個)122.16.0.0   -   122.31.255.255

所有者: OCN/オープンコンピュータネットワーク/Open Computer Network

5位(0x100000個)221.240.0.0   -   221.255.255.255

所有者: UFO0101B2600/broadgate/broadgate

所有者: G-TK0110N18/アルテリア・ネットワークス株式会社/ARTERIA Networks Corp.

所有者: SUBA-468-Z19/株式会社UCOM/UCOM Corporation

  • クラスCのIPアドレス範囲(192.0.0.0以降)で唯一、またNTT/KDDI系以外で唯一のランキング入りとなりました。

2つのLinux間でIPv4 over IPv6トンネリング

IPv4 over IPv6トンネリングを使うと、IPv6しか使えない環境から、IPv4/IPv6どちらも使える環境(VPSなど)を経由してIPv4でインターネットに出ていくことができます。

今回は別にIPv4が使えなかったわけではないのですがこれを試してみました。

リモート側のみLinux(ローカル側はNECYAMAHAルーター)、あるいはローカル側のみLinux(リモート側はDS-Liteなど)という設定方法は結構ネット上にあったのですが両側ともLinuxというのは意外と少なかったので役に立つかもしれません。とはいえ他にもフレッツ網内折返しのv6 で拠点間を単純にIPIP/GREでトンネル接続してVPNする。 - それマグで!などはあるので、短めにします。リモート側をLinuxでやる記事はhttps://emeth.jp/diary/2018/05/ipv4-over-ipv6/IPv6とlinuxでネットを快適にしてみた #Linux - QiitaさくらのVPS で IPv4 over IPv6ルータの構築 | PPT固定IPv4アドレスを失ったので、VPS上にDS-Lite方式に対応したルーターを作ってみた | DevelopersIOあたりです。

今回の環境は、ローカル側がフレッツ光のIPoE(OCNバーチャルコネクト)(HGWはRX-600KI)、リモート側がGoogle Cloud PlatformのCompute Engineです。ローカル側を2400::xxxx、リモート側を2600::xxxxとします。IPv4トンネルのアドレスはローカル側が192.168.6.3/24でリモート側が192.168.6.6/24としましょう。ルートテーブル名などは適当につけています。

まずローカル側です。

sudo ip -6 tunnel add ip46 mode ip4ip6 remote 2600::xxxx local 2400::xxxx
sudo ip link set ip46 up
sudo ip a a 192.168.6.3/24 dev ip46
sudo ip route add default dev ip46 table ip46
sudo ip rule add from 192.168.6.3 lookup ip46

まずトンネルデバイスip46を作り、upし、アドレスを割り当てます。ip46をデフォルトゲートウェイとしたテーブルを作成します(/etc/iproute2/rt_tablesの編集は省略しました)。そして、192.168.6.3からのパケットはこれを使うように設定します。

次にリモート側です。基本的にローカル側とやることはほとんど同じです。

sudo ip -6 tunnel add tun46 mode ip4ip6 remote 2400::xxxx local  2600::xxxx encaplimit none
sudo ip link set tun46 up
sudo ip a a 192.168.6.6/24 dev tun46
sudo ip route add default dev tun46 table ipip
sudo ip rule add from 192.168.6.6 lookup ipip

重要な違いはencaplimit noneが付いていることです。https://emeth.jp/diary/2018/05/ipv4-over-ipv6/に詳しく書いてありますが、これがないと一部ルーターで不正パケットとして弾かれてしまうようです。手元でも、tcpdumpで見るとGCP側では正しく応答が出て行っているにもかかわらずローカル側に何も届かない状態になっていました。

encaplimit noneをつけない場合のパケットをtcpdumpで見ると「DSTOPT IP 192.168.6.3 > 192.168.6.6:」のようにIPの前にDSTOPTが付いていることがわかります。これがあると(一部のルーターでは)ダメということのようです。

他のサイトではrp_filterなどの設定が書いてありますが、この設定ではちゃんとIPアドレスを割り当てたためか、そのへんはデフォルトのままでも問題なく動きました。

また、ip6tnl0というデバイスが勝手に作られます。fb_tunnelsというのを無効にするとこれを防ぐことができるようです(Ubuntu / Debian でIPv4 over IPv6 (OCNバーチャルコネクト, v6プラス), systemdによる設定, ルーター化, VPNおよび自宅サーバー可能な固定グローバルIPv4アドレス #RaspberryPi - Qiita)。

tunnelの作成時にdev eth0などと物理デバイスを指定する方法もあるようですが、それがなくても動きました。何も指定しない場合、ip46@NONEのような名前のデバイスが作られますが、それで問題なかったということです。しかし、dev ip6tnl0やdev eth0を指定した場合でも問題なく動きました。

ローカル側ではencaplimit noneを指定する必要はないようですが、指定した場合でも問題なく動きました。

2台のPCから同時につなぎに行くのは普通にやるとうまくいかないっぽいのでやめたほうが良さそうです。(参照:さくらのVPS で IPv4 over IPv6ルータの構築 | PPT

(トンネル経由で)curlがだめでもpingだけ通ったり、逆にpingがだめでもcurlが通ることがあったりして挙動は全体的に結構謎です。確率的に通らないようなときもあります。おかしいなと思ったら再起動してIPv6アドレスの割り当てを変えたりしばらく待ったりすると治ることもあります。

安定しないときは大人しくTCP/UDP上で動作するVPNとかを使った方がいいかもしれません。

LinuxルーターのMAP-Eで良い感じにNATポートを使い回す方法

OpenWRTなどLinuxを使用してMAP-E(v6プラス、OCNバーチャルコネクトなど)接続を行う方法は様々なサイトで紹介されていますが、iptablesやnftables(バックで動いているのはnetfilter)のNATでは使用ポート範囲を複数(1000-2000, 3000-4000みたいな感じで)指定できないため、MAP-Eの利用可能ポートをうまく使い回すのが難しいという問題があります。具体的には、多くの接続を同時に行ってポートが不足気味のときに、実際には利用可能なポートが余っているにもかかわらずそれが割り当てられる対象になっていないため新規接続が失敗するという現象が発生する可能性があります。俗に言うニチバンベンチが成功しない状態です。

既存の手法はnthモードを使って接続ごとに順番にポートセットを切り替えるものとhmarkを使用してソースポートに応じてポートセットを変える(この場合ソースポートが同じなら同じポートセットが使われるのでcone NATになる)ものに大別されますが、いずれにしても全ポートを必ず使いきれるわけではありません。これらの設定方法についてはNanoPi NEO2をv6プラスのルーターにする 後編 - がとらぼCentOSでOCNバーチャルコネクト | QuintRokkフレッツ光クロス:MAP-E ROUTER by Debian Box (iptables)あたりをご覧ください。

この問題の解決策としては、既にブログでも書いたものが2つあります。1つは【Map-EでもNATタイプA】LinuxでポートセービングIPマスカレード付きの制限コーン風NAT(EIM/ADF)を動かす - turgenev’s blogのようにソフトウェア的に解決する方法で、これ自体はNAT動作を細かくカスタマイズすることもできて便利なのですが、今回の目的を達成する手段としては少し大袈裟です。Rust版を使うにしても、ユーザースペースで動作するのでパフォーマンス的に劣る可能性があります。

もう一つはLinuxで一つのパケットに2回(複数回)NATをかけるための2つの方法 - turgenev’s blogのようにnetfilterのNATを複数回行う方法です。しかしこれだとポートセットの個数(v6プラスなら15個、OCNバーチャルコネクトなら63個)分だけルールを書かなければいけないという問題があり、また1:1でのNATにもかかわらずconntrackを使用することになるのでパフォーマンス的にもやはり無駄があるように思えます。

そこで、この記事で紹介するのが、tcコマンドを使う方法です。tcを使うとnetfilterよりもさらに外側(PREROUTINGより前、POSTROUTINGより後)でパケットを処理することができ、またあまり知られていないと思うのですがpedit機能によりパケットの中身を書き換える(定数へのset、定数のaddなど)ことができます。conntrackのようなステートフルなNATはできませんが、逆にステートレスなNATであれば高速に行うことができます。

とはいえポートセットの個数分だけルールを書かなければいけないのは変わらないのでは?という気がしますが、ビット演算をうまく使うとlog(ポートセットの個数)個くらいのルール(つまりv6プラスなら4個、OCNバーチャルコネクトなら6個)で処理できます。

16進数表示と特に相性がいいv6プラスを使って説明します。

v6プラスでは、0xXXXXというポート番号のうち真ん中の2つの桁が固定(=ポートセットID、PSID)で、上一桁が1-F、下一桁が0-Fとなります(15x16=240ポート)。例えばポートセットIDが0x33であれば、

0x1330, 0x1331, 0x1332, ...., 0x133F

0x2330, 0x2331, 0x2332, ...., 0x233F

︙ .......

0xF330, 0xF331, 0xF332, ...., 0xF33F

の240個が利用可能ポートとなります。両端の桁(変化している桁)のみを見ると10..FFと連続する数値になっています。これをうまく利用します。

例えばiptablesで0x0010-0x00FFにSNAT(MASQUERADE)します。その結果、ポート番号が0x00PQになったとしたら、これが最終的に0xP33Qから出ていく(&戻ってくるときは逆向きに変換する)ようにすればいいということです。

下一桁はそのままなので簡単そうですが、問題は上一桁です。イメージとしてはPのところの4ビット分をそのままshiftさせられればいいのですが、見た感じtcのpeditにはshift演算は用意されていません。そこで、Pの中の各ビットごとに、そのビットが立っていたら対応するところにもビットを立てるというふうにします。

つまり、0x00PQが与えられたとして、

  • 0x0010のビットが立っていたら0x1000を立てる
  • 0x0020のビットが立っていたら0x2000を立てる
  • 0x0040のビットが立っていたら0x4000を立てる
  • 0x0080のビットが立っていたら0x8000を立てる

とすれば0xP0PQが得られます。あとは真ん中の2つの桁を33に変えれば0xP33Qになります。ちなみにもちろんPの中のビットを入れ替えるようなことをしても1:1で変換できるかと思いますが、ここに書いた方法が一番普通でしょう(大小関係を維持できる)。

ここではわかりやすくするため0x0010-0x00FFの範囲にしましたが実際にはウェルノウンポート番号を使うのはちょっと気が引けるので0x8010-0x80FF(32784-33023)を使うことにします。

では具体的なコマンドを見ていきます。ipは192.168.1.13、デバイス名はenp1s0としています(実際にMAP-Eで使う場合はip4ip6のトンネルデバイスの名前にする)。

まず外向きについてです。まず以下のようにiptablesで最下位ビット以外を揃えてmark(fwmark)を付けます。適当に0x54と0x55にしておきます。

sudo iptables -t mangle -A POSTROUTING -s 192.168.1.13 -p tcp -j MARK --set-mark 0x54
sudo iptables -t mangle -A POSTROUTING -s 192.168.1.13 -p udp -j MARK --set-mark 0x55

どうせtcが一番最後なのでマークを付けるタイミングはどこでもいいのですがせっかくなのでPOSTROUTINGのところで付けてみます。さらにここでNATも設定してしまいましょう。

sudo iptables -t nat -A POSTROUTING -s 192.168.1.13 -p tcp -j SNAT --to-source :32784-33023
sudo iptables -t nat -A POSTROUTING -s 192.168.1.13 -p udp -j SNAT --to-source :32784-33023

続けてtcのルールを設定します。まず以下のようなコマンドを打ちます(意味はあんまりよくわかっていません)。

tc qdisc replace dev enp1s0 root handle 1: htb

この1:に対してルールを追加していく感じです。以下のようにメインのポート計算のルールを入れます。ip ruleなどと同じく直近に追加したものの優先度が最も高くなるので、実際はここに書いてあるのと逆順にルールが適用されていくことに注意してください。

sudo tc filter add dev enp1s0 parent 1: handle 0x55 fw action csum ip4h udp continue
sudo tc filter add dev enp1s0 parent 1: handle 0x54 fw action csum ip4h tcp continue
sudo tc filter add dev enp1s0 parent 1: handle 0x54/0xfffffffe fw action pedit pedit munge ip sport set 0x0330 retain 0x0ff0 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0010 0x0010 action pedit pedit munge ip sport set 0x1000 retain 0x1000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0020 0x0020 action pedit pedit munge ip sport set 0x2000 retain 0x2000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0040 0x0040 action pedit pedit munge ip sport set 0x4000 retain 0x4000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0000 0x0080 action pedit pedit munge ip sport set 0x0000 retain 0x8000 continue

ポート変換の処理はビットマスクを0xfffffffeとしてTCP/UDPまとめて行って、最後のチェックサムの計算だけ別々にやっています(netfilterのNATでは勝手にやってくれるが、tcだと自分でやる必要がある)。

fwmarkへのマッチは一つ目のようにhandle 0x55 fwなどと書くのが短いのですが、4番目以降のように他のu32 matchと組み合わせるときはmark 0x55 0xffffffffなどとするしかない気がします。

ポートの計算は、特定のビットが立っているか見て対応するところにビットを立てるという先ほどの説明の通りです。retainを使うことで指定したビットのみ変更できます。0x8010-0x80FFを使うことにした関係で、8に関するルールは「0x80が立っていない場合に0x8000を消去する」ものにする必要があり、少し見た目が違います(違いがあるところを太字にしました)。あと、今回は使用する上位ビットと下位ビットの範囲が被らないので気にする必要はないのですが、一応上位ビットから先に処理していったほうが安心です。

最後にcontinueを付けているのは、これがないとルールを一個評価した時点で評価が終了してしまうからです。

なお、peditの加算はbyte-wiseのようです。つまり例えば0x3333に0xffffを加算すると0x3332になるのではなく0x3232になります。(今回は加算は使わないのでこれで困ることはありません。)

では次に内向きの設定です。こちらもまずparentとなるものを追加します。

tc qdisc add dev enp1s0 ingress handle ffff:

次にメインのルールです。

sudo tc filter add dev enp1s0 parent ffff: handle 0x65 fw action csum ip4h udp continue
sudo tc filter add dev enp1s0 parent ffff: handle 0x64 fw action csum ip4h tcp continue
sudo tc filter add dev enp1s0 parent ffff: handle 0x64/0xfffffffe fw action pedit pedit munge ip dport set 0x8000 retain 0xf000 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x8000 0x8000 action pedit pedit munge ip dport set 0x0080 retain 0x0080 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x4000 0x4000 action pedit pedit munge ip dport set 0x0040 retain 0x0040 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x2000 0x2000 action pedit pedit munge ip dport set 0x0020 retain 0x0020 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x1000 0x1000 action pedit pedit munge ip dport set 0x0010 retain 0x0010 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe action pedit pedit munge ip dport set 0 retain 0x0ff0 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match ip protocol 17 0xff match u16 0 1fff at 6 match ip dport 0x0330 0x0ff0 action skbedit mark 0x65 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match ip protocol 6 0xff match u16 0 1fff at 6 match ip dport 0x0330 0x0ff0 action skbedit mark 0x64 continue

まずTCPUDPパケットにfwmarkを付けます。PSIDの判定はなんとなく付けていますが実際のMAP-Eでは必ず成立する条件なので無くてもいいと思います。「0 1fff」のあたりはIP Fragmentationのためで、tc-u32(8) - Linux manual pageの最後の方を参考にしました。fwmarkの変更はskbeditというのを使うとできます。番号は適当に0x64と0x65にしておきました。次にPSIDの部分を0にします。それから今度は先ほどと逆に上位ビットの判定結果を下位ビットに反映していきます。下位ビットから処理していきます。最後に上一桁を0x8000に設定して完成です。

デバッグについては、tcよりも外側(最終的にLinuxから出入りするポート番号)を見たかったらtcpdump、tcとiptablesの間を見たかったらiptablesの-j LOGやconntrackを使うといいと思います。tcpdumpに-Xを付けるとパケットの中身が見れて-vvを付けるとチェックサムの正誤などが見れます。ちなみにv6プラスの割り当てポートはIPv6(MAP-E方式)使用可能ポート確認 | 監視・防犯カメラの設置なら「アイゼック」、OCNバーチャルコネクトはhttps://ipv4.web.fc2.com/map-e.htmlで計算できます。

tc filtlerのルールを一括で消すときはsudo tc filter del dev enp1s0sudo tc filter del dev enp1s0 parent ffff:でできます(つまりparent 1:は省略できるっぽい)。ip ruleと同じようにpref(prio)指定でのルールごとの追加・削除も可能です。

OCNバーチャルコネクトであれば、たとえばPSIDが0x23=0010 0111として同様に考えると、0x0010 - 0x03FF(0000 0000 0001 0000 - 0000 0011 1111 1111)にまずNATしてから、0000 0110 0111 0000 - 1111 1110 0111 1111 に変えればよくて、太字部分のPSIDが固定という感じになります。コマンドにしてみます。実際は0x8010 - 0x83FF (32784-33791)にしているのも同じです。一部さっきと同じところは省略します。

まずSNATです。

sudo iptables -t nat -A POSTROUTING -s 192.168.1.13 -p tcp -j SNAT --to-source :32784-33791
sudo iptables -t nat -A POSTROUTING -s 192.168.1.13 -p udp -j SNAT --to-source :32784-33791

次に外向きです。

sudo tc filter add dev enp1s0 parent 1: handle 0x55 fw action csum ip4h udp continue
sudo tc filter add dev enp1s0 parent 1: handle 0x54 fw action csum ip4h tcp continue
sudo tc filter add dev enp1s0 parent 1: handle 0x54/0xfffffffe fw action pedit pedit munge ip sport set 0x0230 retain 0x03f0
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0010 0x0010 action pedit pedit munge ip sport set 0x0400 retain 0x0400 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0020 0x0020 action pedit pedit munge ip sport set 0x0800 retain 0x0800 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0040 0x0040 action pedit pedit munge ip sport set 0x1000 retain 0x1000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0080 0x0080 action pedit pedit munge ip sport set 0x2000 retain 0x2000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0100 0x0100 action pedit pedit munge ip sport set 0x4000 retain 0x4000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x54 0xfffffffe match ip sport 0x0000 0x0200 action pedit pedit munge ip sport set 0x0000 retain 0x8000 continue

最後に内向きです。

sudo tc filter add dev enp1s0 parent ffff: handle 0x65 fw action csum ip4h udp continue
sudo tc filter add dev enp1s0 parent ffff: handle 0x64 fw action csum ip4h tcp continue
sudo tc filter add dev enp1s0 parent ffff: handle 0x64/0xfffffffe fw action pedit pedit munge ip dport set 0x8000 retain 0xfc00 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x8000 0x8000 action pedit pedit munge ip dport set 0x0200 retain 0x0200 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x4000 0x4000 action pedit pedit munge ip dport set 0x0100 retain 0x0100 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x2000 0x2000 action pedit pedit munge ip dport set 0x0080 retain 0x0080 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x1000 0x1000 action pedit pedit munge ip dport set 0x0040 retain 0x0040 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x0800 0x0800 action pedit pedit munge ip dport set 0x0020 retain 0x0020 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe match ip dport 0x0400 0x0400 action pedit pedit munge ip dport set 0x0010 retain 0x0010 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x64 0xfffffffe action pedit pedit munge ip dport set 0 retain 0x03f0 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match ip protocol 17 0xff match u16 0 1fff at 6 match ip dport 0x0230 0x03f0 action skbedit mark 0x65 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match ip protocol 6 0xff match u16 0 1fff at 6 match ip dport 0x0230 0x03f0 action skbedit mark 0x64 continue

ちなみにもちろん今回紹介したNATは1:1の静的NATでフィルタリングなどもないためフルコーンNATと同様であり、別のフルコーンNAT(前述の自分の記事やx64 Linux ルータのIPoE(map-e by iptables)環境でGame ConsoleをNAT越えさせる -- その1fullconenat module追加有りの場合に載っているGitHub - llccd/netfilter-full-cone-nat: A kernel module to turn MASQUERADE into full cone SNATなど)と組み合わせたら全体もフルコーンNATとして動作するはずです。基本的にはTCPは(NAT動作が重要ではないので)普通にiptablesのSNATでポートセービングさせておけばよく、UDPP2P通信用にフルコーンにして、ポートの消費が多いDNSは8.8.8.8などは使わずIPv6で問い合わせる(参考:ここの後半のほうとか)というのがおすすめです。もっと詳しいことが気になったら過去記事のNAT動作をめぐる誤解まとめとかNATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUNも読んでもらえると嬉しいです。

元のiptablesのSNATで割り当てられるポートは(NAT先ポート範囲に入っていない限りは)予測不能なので、この方法で割り当てられるポートも予測不能です。Linuxのnetfilterのconnection trackingとNAT動作の仕組み - turgenev’s blogに書いたように規則的にポートが割り当てられるように見えるルーターもありますが、これらの実装方法は不明です(専用のハードウェアを使っている、あるいはカーネルに手を加えている?)。

ちなみに、手元では普通にHGWを使ってMAP-Eを使っているのでこの方法を実際に試せてはいないのですが、2つのLinux間でIPv4 over IPv6トンネリング - turgenev’s blogのように自前でトンネリングをしてみたところ、ip4ip6のtunデバイスに関しても問題なく動作しました。

以上です。細かい仕様はよくわかっていないのでもっと簡潔なやり方があるかもしれませんが、とりあえずこんな感じで動くはずです。わからなければコメントにどうぞ。

追記: ICMP関連

ICMPに関して何も対応していませんでした。MAP-Eでは、インターネットから IPoE MAP-E ルータにPingをする | KUSONEKOの見る世界RFC 7597(MAP-Eの仕様)にある通り、ICMP Echo Request/Replyのidentifierをポートと同様に変換する必要があり、またICMPの各種エラーメッセージ(おそらく主に使われるのはdestination unreachable、中でも特にICMP Echo Requestに対するhost unreachable・UDPに関するport unreachable・PMTUd用のneed fragmentあたり?)に含まれる元のIPヘッダのデータのポート番号・identifierも正しく書き換える必要があります。

TCP/UDPのポートと異なり、これらのフィールドに関してはtcにおいて専用の構文が用意されていないので、バイト数を指定する必要があります。またIPヘッダーのサイズは20-60バイトの間で可変であり、ヘッダ長さフィールドを見て長さを判断する必要があります。しかし以下の理由で、今回は20バイトに決め打ちして処理することにしました。

  • オプション付きの(20バイトより長い)IPヘッダはIPsecなど限られた用途でのみ使われ、通常のTCP/UDPなどで対応する必要は事実上ほぼないはず。
  • そもそも用途がトラブルシューティングであり、完全対応していないと困るというほどのものではない。
  • ICMPのエラーメッセージでは外側のIPヘッダとICMPのペイロード部分に含まれたIPヘッダの2箇所のサイズが可変であり、処理が複雑になる。
  • u32 matchにおいてオフセットを動的に指定するには、ハッシュテーブルにルールを追加した上でそれを呼び出すというような回りくどいコマンドが必要(参照: tc-u32(8) - Linux manual page)。とはいえ書いてあるだけマシで、この通りにすれば正しく動いた。
  • tc peditにおいてオフセットを動的に指定するための構文として、manにはat AT offmask MASK shift SHIFTと書いてあるが、実際にはat AT MASK SHIFTと書かないとパースが通らず、またそう書いたとしても正しく動いているように見えず(特にshiftの挙動が不可解なのと、showでルールを表示してもat以下に関する情報が表示されない)、全体としてあまりテストされていない雰囲気を感じた。

v6プラスのみ書きますが、OCNバーチャルコネクトでも同じようにできるはずです。ではまずICMP echoです。iptablesルールを追加します。

sudo iptables -t nat -A POSTROUTING -p icmp --icmp-type 8 -j SNAT -s 192.168.1.13 --to-source :32784-33023

次に外向きのtc filterを追加します。

sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x59 0xffffffff action pedit pedit munge offset 24 u16 set 0x0330 retain 0x0ff0 pipe action csum ip4h icmp continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x59 0xffffffff match u16 0x0010 0x0010 at 24 action pedit pedit munge offset 24 u16 set 0x1000 retain 0x1000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x59 0xffffffff match u16 0x0020 0x0020 at 24 action pedit pedit munge offset 24 u16 set 0x2000 retain 0x2000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x59 0xffffffff match u16 0x0040 0x0040 at 24 action pedit pedit munge offset 24 u16 set 0x4000 retain 0x4000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match mark 0x59 0xffffffff match u16 0x0000 0x0080 at 24 action pedit pedit munge offset 24 u16 set 0x0000 retain 0x8000 continue
sudo tc filter add dev enp1s0 parent 1: u32 match ip protocol 1 0xff match ip icmp_type 8 0xff match ip ihl 0x5 0xf match u16 0 1fff at 6 action skbedit mark 0x59 continue

今回はiptablesではなくtc filterでマークを付けています。ICMP(1)のecho request(8)のうちヘッダ長さが20(=4x5)で最初のfragmentであるものに0x59を付けます。そして24バイト(IPヘッダ20バイト+ICMPヘッダの先頭4バイト)以降に書かれているidentifierを書き換えていきます。u16を使ってマッチしていますが、別にip sportでもいいです。最後にchecksumを書き換えています。pipeを使うと1つのfilterに複数のactionを繋げられます。TCP/UDPのほうはchecksumを書き換えなくても通ることもありますがpingはそうはいかないので注意が必要です。

次に内向きのtc filterです。

sudo tc filter add dev enp1s0 parent ffff: handle 0x69 fw action pedit pedit munge offset 24 u16 set 0x8000 retain 0xf000 pipe action csum ip4h icmp continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x69 0xffffffff match u16 0x8000 0x8000 at 24 action pedit pedit munge offset 24 u16 set 0x0080 retain 0x0080 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x69 0xffffffff match u16 0x4000 0x4000 at 24 action pedit pedit munge offset 24 u16 set 0x0040 retain 0x0040 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x69 0xffffffff match u16 0x2000 0x2000 at 24 action pedit pedit munge offset 24 u16 set 0x0020 retain 0x0020 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x69 0xffffffff match u16 0x1000 0x1000 at 24 action pedit pedit munge offset 24 u16 set 0x0010 retain 0x0010 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match mark 0x69 0xffffffff action pedit pedit munge offset 24 u16 set 0 retain 0x0ff0 continue
sudo tc filter add dev enp1s0 parent ffff: u32 match ip protocol 1 0xff match ip icmp_type 0 0xff match ip ihl 0x5 0xf match u16 0 1fff at 6 match u16 0x0330 0x0ff0 at 24 action skbedit mark 0x69 continue

こちらもecho replyに関して似たような条件でマッチさせてidを書き換えています。

では、UDPのport unreachableもやってみます。こちらは内向きのtc filterだけ設定すればいいです。

sudo tc filter add dev ip46 parent ffff: handle 0x79 fw action pedit pedit munge offset 48 u16 set 0x8000 retain 0xf000 pipe action csum ip4h and icmp continue
sudo tc filter add dev ip46 parent ffff: u32 match mark 0x79 0xffffffff match ip dport 0x8000 0x8000 at 48 action pedit pedit munge offset 48 u16 set 0x0080 retain 0x0080 continue
sudo tc filter add dev ip46 parent ffff: u32 match mark 0x79 0xffffffff match ip dport 0x4000 0x4000 at 48 action pedit pedit munge offset 48 u16 set 0x0040 retain 0x0040 continue
sudo tc filter add dev ip46 parent ffff: u32 match mark 0x79 0xffffffff match ip dport 0x2000 0x2000 at 48 action pedit pedit munge offset 48 u16 set 0x0020 retain 0x0020 continue
sudo tc filter add dev ip46 parent ffff: u32 match mark 0x79 0xffffffff match ip dport 0x1000 0x1000 at 48 action pedit pedit munge offset 48 u16 set 0x0010 retain 0x0010 continue
sudo tc filter add dev ip46 parent ffff: handle 0x79 fw action pedit pedit munge offset 48 u16 set 0 retain 0x0ff0 continue
sudo tc filter add dev ip46 parent ffff: u32 match ip protocol 1 0xff match ip icmp_type 3 0xff match ip ihl 0x5 0xf match ip ihl 0x5 0xf at 28 match u16 0 1fff at 6 match ip sport 0x0330 0x0ff0 at 48 action skbedit mark 0x79 continue

このようにICMP(1)のdestination unreachable(3)のうち外側のIPヘッダと内側のIPヘッダ(オフセットは20+8=28バイト)がどちらも20バイトで、かつ最初のfragmentであるものにマッチさせています。そしてオフセット48(20+8+20)にある内部のポート番号を書き換えています。

nc -u xxx.xxx.xxx.xxx xxxx

などで閉じたポートにデータを送信してみて即座に終了するかどうか確認してみましょう。他のICMPエラーに関しても同様にできるはずです。