Ruby で SOCKS Proxy を利用する

ローカル環境から直接アクセスできない Web ページにアクセスするために、そのページにアクセスできるサーバに SSH dynamic port forwarding をして、ブラウザに SOSCKS proxy の設定することでアクセスするのはよくある話だと思います。
例えば Amazon EMR で各種 Web UI にアクセスするための方法として、SSH dynamic port forwarding を使った方法が次のドキュメントで紹介されています。

Option 2, part 1: Set up an SSH tunnel to the primary node using dynamic port forwarding - Amazon EMR

ブラウザで SOCKS proxy を利用する際にはブラウザでプロキシの設定をする必要があったわけですが、Ruby で SOCKS proxy を利用するにはどうしたら良いのでしょう?
というわけで、RFC 1928 や既存ソフトウェアを見つつ socks_handler という gem を作ったので、SOCKS についての簡単な解説と gem の紹介をしていきます。

そもそも SOCKS Proxy とは何か?

SOCKS とは RFC 1928 で定められているプロトコルで、SOCKS proxy はそのプロトコルを扱えるサーバです。
プロキシといえば HTTP プロキシが有名ですが、HTTP プロキシは HTTP しか扱えないのに対して、SOCKS プロキシは HTTP を含めた任意の TCP 通信に加え、UDP 通信も扱えます。

SOCKS proxy としては SSH dynamic port forwarding が最も有名じゃないかと思いますが、次のように -D オプションに SOCKS proxy サーバが listen するポート番号を指定して、SSH サーバにログインすると、SOCKS proxy として localhost:1080 を利用した通信は SSH サーバ経由で通信することができます。

$ ssh -D 1080 <ssh-server>

localhost:1080 に SOCKS proxy が立っている場合、curl では次のように --proxy オプションに socks5h://localhost:1080 を指定することで SOCKS proxy 経由で通信できます。

$ curl --proxy socks5h://localhost:1080 -I http://nginx
HTTP/1.1 200 OK
Server: nginx/1.25.1
Date: Wed, 30 Aug 2023 13:30:17 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 13 Jun 2023 15:08:10 GMT
Connection: keep-alive
ETag: "6488865a-267"
Accept-Ranges: bytes

上記の例では http://nginx に対して SOCKS proxy 経由で HTTP リクエストを送っています。nginx というホスト名は SOCKS proxy が解決できる名前であれば、curl を実行する環境で名前解決できなくても問題ありません。例えば AWS VPC 内に SSH サーバがあれば、AWS VPC 外の環境から AWS VPC 内の private DNS name を使って HTTP リクエストを送ることも可能ということです。

なお、RFC は SOCKS versoin 5 についての仕様で、その前身である version 4 も存在しますが、version 4 の説明は割愛します。

SOCKS Proxy を利用するには何が必要か?

SOCKS proxy が必要なのは言うまでもないですが、proxy を利用するアプリケーション側でも SOCKS プロトコルを使って proxy サーバと通信する必要があります。
TCP 通信に関しては接続を確立するまでにちょっとした手順を踏めば、その後は通常の TCP 通信と同様に処理できます。UDP 通信に関してはリクエストを送る度にリモートホストの情報を追加したり、レスポンスから余計な情報を取り除いたりする必要があります。

詳細は RFC 1928 に譲りますが、RFC 自体シンプルな内容なので、動作確認できる環境と、自分の慣れた言語で書かれた既存コードがあればすんなり理解できると思います。
動作確認環境についてはこの後言及します。

socks_handler gem の紹介

前置きが長くなりましたがようやく gem の紹介です。使い方については README を読んでもらえば十分だとは思いますが、ちょっと補足します。動作確認には dokcer-compose.yml が使えて、docker compose up すると次のような構成でコンテナが起動します。

  • sockd-auth-none: 認証なしの SOCKS server(1080 番ポート)
    • Docker host の 1080 番ポートでアクセス可能
  • sockd-auth-username-password: username/password 認証ありの SOCKS server(1080 番ポート)
    • Docker host の 1081 番ポートでアクセス可能
    • username: user, passsword: pass というユーザーが作成されている
  • nginx: HTTP サーバ(80 番ポート)
    • Docker host からは直接アクセス不可だが、SOCKS server 経由でアクセス可能
  • echo: UDP echo サーバ(7 番ポート)
    • Docker host からは直接アクセス不可だが、SOCKS server 経由でアクセス可能

以降、上記の構成を前提として説明します。

TCP 通信で SOCKS Proxy を利用する

例えば nginx にアクセスするには次のような手順を踏みます

  1. SOCKS server との接続を確立
  2. SOCKS server と SOCKS5 プロトコルでやり取りして nginx との接続を確立
  3. 通常の TCP socket と同じように操作

上記の手順を行っているのが README に載っている最初のサンプルコードです

require "socks_handler"

# 1. SOCKS server との接続を確立
socket = TCPSocket.new("127.0.0.1", 1080)
# 2. SOCKS server と SOCKS5 プロトコルでやり取りして nginx との接続を確立
SocksHandler::TCP.establish_connection(socket, "nginx", 80)

# 3. 通常の TCP socket と同じように操作
socket.write(<<~REQUEST.gsub("\n", "\r\n"))
  HEAD / HTTP/1.1
  Host: nginx

REQUEST
puts socket.gets #=> HTTP/1.1 200 OK

これでめでたく SOCKS proxy を利用できますね!

…とはいきませんよね。
普段の開発で直接 TCP socket を作成するような人は稀だと思うので、正直上記の手順を踏む必要があるのは使えないも同然です。

というわけで、次のように書けば、TCPSocketSocket にモンキーパッチが適用されるので、おそらく Ruby レイヤーで作成する TCP socket は全て SOCKS proxy を経由させることができます。

require "net/http"
require "socks_handler"

SocksHandler::TCP.socksify([
  # 最初に host_patterns にマッチしたルールが適用される
  SocksHandler::DirectAccessRule.new(
    host_patterns: ["localhost", "127.0.0.1", "::1"],
  ),
  # // は全ての文字列にマッチする正規表現
  SocksHandler::ProxyAccessRule.new(
    host_patterns: [//],
    socks_server: "127.0.0.1:1080",
  ),
])

Net::HTTP.start("nginx", 80) do |http|
  pp http.head("/") #=> #<Net::HTTPOK 200 OK readbody=true>
end

nginx だけ SOCKS proxy を経由したい場合は次のように ProxyAccessRule を 1 つ指定すれば良いです。

require "net/http"
require "socks_handler"

SocksHandler::TCP.socksify([
  # host_patterns にマッチするルールがなければ直接アクセスになる
  SocksHandler::ProxyAccessRule.new(
    host_patterns: ["nginx"],
    socks_server: "127.0.0.1:1080",
  ),
])

Net::HTTP.start("nginx", 80) do |http|
  pp http.head("/") #=> #<Net::HTTPOK 200 OK readbody=true>
end

UDP 通信で SOCKS Proxy を利用する

UDP に関してはもう少し複雑で、次のような手順を踏みます

  1. SOCKS server との接続を確立(TCP と同じ)
  2. SOCKS server と SOCKS5 プロトコルでやり取りして UDP で通信することを通知
  3. SOCKS server が用意したポートに対して UDP の接続を確立(という表現で良いんだろうか?)
  4. 通常の UDP socket と同じように操作して、レスポンスから SOCKS 用のヘッダを除去

上記の手順を行っているのが README に載っているサンプルコードです

require "socks_handler"

# 1. SOCKS server との接続を確立(TCP と同じ)
tcp_socket = TCPSocket.new("127.0.0.1", 1080) # or Socket.tcp("127.0.0.1", 1080)

# 2. SOCKS server と SOCKS5 プロトコルでやり取りして UDP で通信することを通知
# 3. SOCKS server が用意したポートに対して UDP の接続を確立(という表現で良いんだろうか?)
udp_socket = SocksHandler::UDP.associate_udp(tcp_socket, "0.0.0.0", 0)

# 4. 通常の UDP socket と同じように操作して、レスポンスから SOCKS 用のヘッダを除去
udp_socket.send("hello", 0, "echo", 7)
puts udp_socket.gets #=> hello

サンプルコードだと通常の UDP 通信とほとんど変わらないように見えますが、SocksHandelr::UDPSocket というクラスが涙ぐましい処理をしています。

SocksHandler::UDP.socksify というメソッドも用意しようかと思ったんですが力尽きました。気が向いたら実装するかもしれません。

利用上の注意

Limitation に書いてあるとおり、native extension で行っている TCP 通信には対応できないので、mysql2 等には使えません。

何故一から gem を作ったのか?

README の冒頭や Related Work にある程度書いてあるとおりですが、少し補足します。
同じようなことをしている gem として ruby-proxifiersocksify-ruby があるんですが、SOCKS proxy を通すかどうか柔軟に指定することができません。
じゃあプルリクエストを出して機能拡張すればと思うわけですが、ruby-proxifier は次のような一瞬でマージできるようなプルリクエストすらマージされていない状況です。

socksify-ruby に関しては一応メンテされているんですが、とにかく読み辛いです。Net::HTTP のモンキーパッチ部分は実際にコードを動かしてみないと理解できないです。更には、Ruby 3.1 に対応させるために、Ruby 3.0 の Net::HTTP#connect の実装をコピペしてきていて、これを恒久対応としています。
cf. patch Net::HTTP for Ruby 3.1 by MatzFan · Pull Request #51 · astro/socksify-ruby

以上から、そんなに実装も大変じゃないので自分が自由にメンテできる gem を一から開発した方が良いのでは?と思って自作するに至りました。

ProxyChains-NG で柔軟にルールが指定できて、macOS でも SIP の影響を受けずに利用できればベストなんですけどね…。

おまけ

curl で SOCKS proxy を利用する

冒頭でも軽く言及しましたが、curl だと --proxysocks5h scheme を指定すれば名前解決も SOCKS server に任せることができます。

$ curl --proxy socks5h://localhost:1080/ -I http://nginx
HTTP/1.1 200 OK
Server: nginx/1.25.1
Date: Wed, 30 Aug 2023 10:40:50 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 13 Jun 2023 15:08:10 GMT
Connection: keep-alive
ETag: "6488865a-267"
Accept-Ranges: bytes

h を付けないと名前解決できないので注意が必要です。

proxychains4 を使う場合、macOS では組み込みのものではなく Homebrew 等でインストールしたものを使わないと preload が期待どおりに動かないことに注意です

$ proxychains4 /usr/local/opt/curl/bin/curl -I http://nginx
[proxychains] config file found: /usr/local/etc/proxychains.conf
[proxychains] preloading /usr/local/Cellar/proxychains-ng/4.16/lib/libproxychains4.dylib
[proxychains] DLL init: proxychains-ng 4.16
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  nginx:80  ...  OK
HTTP/1.1 200 OK
Server: nginx/1.25.1
Date: Wed, 30 Aug 2023 10:40:38 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 13 Jun 2023 15:08:10 GMT
Connection: keep-alive
ETag: "6488865a-267"
Accept-Ranges: bytes

do you use system curl or homebrew curl ? the former is known to not work with hooking (any system binaries, that is).

cf. https://github.com/rofl0r/proxychains-ng/issues/409#issuecomment-1002771526

OpenSSH で SOCKS Proxy を立てて動作確認する

次のような docker-compose.yml を用意して

version: "2.4"
services:
  put-custom-file:
    image: lscr.io/linuxserver/openssh-server:latest
    entrypoint: ""
    command:
      # Resolve the error like "channel 4: open failed: administratively prohibited: open failed"
      # cf. https://unix.stackexchange.com/questions/14160/ssh-tunneling-error-channel-1-open-failed-administratively-prohibited-open
      /bin/sh -c "echo \"sed -i 's/AllowTcpForwarding no/AllowTcpForwarding yes/' /etc/ssh/sshd_config\" >/work/allow-tcp-forwarding"
    volumes:
      - type: volume
        source: custom-files
        target: /work

  openssh-server:
    image: lscr.io/linuxserver/openssh-server:latest
    environment:
      PUID: 1000
      PGID: 1000
      TZ: Etc/UTC
      PUBLIC_KEY_URL: https://github.com/abicky.keys
    ports:
      - 2222:2222
    volumes:
      - type: volume
        source: custom-files
        target: /custom-cont-init.d
        read_only: true
    depends_on:
      put-custom-file:
        condition: service_completed_successfully

  nginx:
    image: nginx:latest

volumes:
  custom-files:

次のような感じでポートフォワーディングすれば、dante と同じように SOCKS proxy として動作させることができます。

$ ssh -fND 1080 -p 2222 localhost -l linuxserver.io \
  -o UserKnownHostsFile=/dev/null \
  -o StrictHostKeyChecking=no \
  -o ExitOnForwardFailure=yes

docker-compose.yml の PUBLIC_KEY_URL はご自身のものに変更してください。
また、コンテナを立ち上げ直すと何度もホスト情報が変わったと警告が出るので ssh コマンドには上記のオプションも合わせて指定すると便利です。