sshはリモートマシンとの通信に汎用的に使われるセキュアなプロトコルで、ポートフォワードを使用してリモート上のプログラムがローカルで使える(あるいはその逆)ようにしたり-Dオプションを使用してSOCKSプロキシとして動作させることでローカルのアプリケーションにリモートの通信環境を使わせたりすることができます。
しかし、sshはTCP接続ベースのアプリケーションであり、上記の機能もすべて同じ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サーバー側にすべてのTCP・UDP通信を流すような(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のような方法で無理やりアクセスできるようにする必要があります。
そこで、こんなツールを作りました。
サーバー側(SOCKS5の本体と同じ側)/クライアント側(SOCKS5が動いていると見せかけたい側)に分かれていて、標準入出力を使って通信します。
クライアント側でSOCKS5のUDP associateリクエストへの応答(通信に使用すべきUDPポートが含まれている)を検出すると、サーバー側に対してそのUDPポートとの通信を行うよう要求し、ローカル側では別に新たなUDPポートを割り当ててそちらをクライアント側に通知します。これによりクライアントがローカル側のUDPポートに送った内容がリモート側に行くようになり、逆も通るようになります。
socatと併用すればローカルとリモートが逆の場合(sshサーバー側からクライアント側を通ってインターネットに出ていきたい場合)でも動かせます。
これでSOCKS5として動くようになったのであとは特定アプリケーションのTCP・UDP通信を透過的なSocks5プロキシ経由にする方法(Windows・Linux・Androidなど) - turgenev’s blogの通りにredsocksなどと併用すれば特定アプリケーションのみにこのSOCKS5を使わせることができます。こちら側では管理者/root権限が必要であることに注意してください。
追記: gostでできました
色々なプロキシプロトコルの変換などができるGo製ツールのgost(Go simple tunnel)で自作ツールと同じことができました。リサーチ不足…。
SOCKS | GOST v2に、「When forward proxies are set, GOST uses UDP-over-TCP to forward UDP data」と書いてあります。例えば以下のような感じです。
- ssh先で
gost -L :7777
- ローカルで
ssh -NL 7777:localhost:7777 myserver
gost -L socks5://:1080 -F localhost:7777
ただし注意点として、Goのこの仕様(proposal: net: UDPAddr/TCPAddr AddrPort should map 4in6 addresses to IPv4 netip.Addrs · Issue #53607 · golang/go · GitHub)により、::ffff:c000:0280あるいは::ffff:192.0.2.128などと表記される「IPv4-mapped」と呼ばれるIPv6アドレスがUDPポートとして通知される関係で、redsocksが正しく動きません。hev-socks5-tproxyは動きます。自分が送ったプルリクが承認されたため解決しました。
古い内容
(これは上記のツールを作る前に書いていた内容で、その直前からの続きです)
例えばDanteではUDP associateに使うポートの範囲をudp.portrangeで設定できます。ここで適当に2000-2010などと指定しておきます。次にリモート側でtcp2udpを11個起動して、Danteが稼働しているIPのUDPポートの2000-2010がそれぞれlocalhostのTCPポートの2000-2010で見えるようにします。次にsshのポートフォワードを使ってローカル側のlocalhostのTCPの2000-2010でこのTCPポートが見えるようにします。次にローカル側でudp2tcpを使ってlocalhostのUDPの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が使えないこともあってそうなると全く意味がありません。
しかし、できるとなれば需要が増えそうな気もします。