socks5 トンネルの原理#
実際、私たちは fq を行う際に socks プロトコルをよく使用しますが、その動作原理についてはあまり明確ではありません。週末を利用して整理してみました。
まず、ネットワークトンネルとは何ですか?
さまざまな百科事典で提供されている定義を整理すると、以下のようになります:
ネットワークトンネルは、既存のネットワークプロトコルの上に構築された新しい仮想ネットワーク接続です。あるネットワークプロトコル内に別のネットワークプロトコルのデータパケットをカプセル化することによって、異なるネットワーク間でのデータ転送を実現します。この方法により、公共ネットワーク(例えばインターネット)上のデータ転送をプライベートネットワークや他のネットワークから隔離し、データ転送の安全性を向上させることができます。
しかし、この定義は理解しにくいため、なぜ「トンネル」と呼ばれるのかを考え始めました。
私たちの日常で見かけるトンネルに例えると、例えば鉄道の谷間トンネルがあります。点 A から点 B に行くために大きな山があるため、A から B へのトンネルを掘ります。このトンネルは列車などの車両が通過できるようにします。
ネットワークプロトコルに例えると、点 A から点 B に何らかの理由で直接通信できない(理由 dddd)ため、A と B の間にトンネル(socks5 プロトコル)を作り、私たちの列車(HTTP データ、結局私たちがインターネットを利用するのは HTTP 通信です)をこのトンネルを通して転送します。
こうすることで、socks ネットワークトンネルが非常に理解しやすくなります!私は本当に小賢しいです。
同様に、安全な侵入においては HTTP トンネルというものもあります。これは HTTP プロトコルの特性(例えば chunked)を利用して HTTP トンネルを構築し、HTTP 通信データを転送するものです(禁止された入れ子 /doge)。ただし、これは後の話であり、この記事では socks ネットワークトンネルのみを研究します。
上記の類推から、socks ネットワークトンネルを構築する条件は以下の通りです:
- 目的地、つまり socks プロキシが接続するターゲット
- 施工隊、つまり socks プロキシサーバー
つまり、クライアント A は施工隊を持っていて、施工隊にどこに行くかを伝える必要があります。そうすれば、施工隊はあなたのためにトンネルを掘ってくれるのです。
最後に socks5 トンネルの定義を挙げておきます。類推すると、理解しやすくなります。
SOCKS5 トンネルは、クライアントとターゲットサーバー間でデータを転送するためのネットワークプロトコルトンネルです。SOCKS5 は SOCKS プロトコルの第 5 版であり、さまざまな認証方法や IPv4 および IPv6 アドレスをサポートしています。SOCKS5 トンネルは、HTTP、FTP、SMTP などのさまざまなプロトコルをその上で実行でき、クライアントとターゲットサーバー間で中間プロキシサービスを提供します。
SOCKS5 トンネルの動作原理は、クライアントとターゲットサーバー間にプロキシサーバーを構築することです。クライアントはターゲットサーバーと直接通信せず、データを SOCKS5 プロキシに送信します。SOCKS5 プロキシはデータを受信し、それをターゲットサーバーに転送します。ターゲットサーバーは応答を SOCKS5 プロキシに送り、プロキシはその応答をクライアントに転送します。
SOCKS5 トンネルの主な利点は、さまざまなプロトコルとアドレスタイプをサポートする汎用のネットワークプロキシソリューションを提供することです。これにより、SOCKS5 トンネルはファイアウォールやコンテンツフィルタを回避し、制限されたネットワークリソースへのアクセスを実現できます。
socks プロキシサービスの実装
ここでは、go と rust を選択して socks5 プロキシサーバーを比較実装します。つまり、トンネルの施工隊であり、性能を簡単に比較して、rust と go の socks5 プロキシの性能がどちらが優れているかを見てみましょう。
TCP プロキシサーバーの実装#
まず、一般的な TCP サーバーをどう作るかを見てみましょう。ここでは通信データの伝達の概念図です:
+-----------+ +--------------+ +--------------+
| Browser | <---> | TCP Proxy | <---> | Target Server|
+-----------+ +--------------+ +--------------+
注意してください。TCP プロキシは本質的に TCP データを受信して転送処理を行うだけです。したがって、実際には socks5 のリクエストを発起するのはブラウザであり、これが通常、プロキシ方式を選択するために chrome プラグイン(例えば proxy switchy omega)をインストールする理由です。
golang
では、プロキシサーバーを実装するのは非常に簡単で、net.Listen
を使用してポートを開くだけです。ポートを開いた後、サーバーは常にAccept
を繰り返し、リクエストが来るたびに新しいgoroutine
を開きます。
func main() {
server, err := net.Listen("tcp", ":1081")
if err != nil {
fmt.Printf("Listen failed: %v\n", err)
return
}
for {
client, err := server.Accept()
if err != nil {
fmt.Printf("Accept failed: %v", err)
continue
}
go process(client)
}
}
この client には、ブラウザから送信されたリクエストと、応答データを書き込むためのインターフェースが含まれています。
rust にも goroutine に似た実装があり、tokio を使用して非同期 IO タスクを実現します。基本的なコードは以下の通りです:
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:1080").await.unwrap();
loop {
let (client, _) = listener.accept().await.unwrap();
spawn(handle_client(client));
}
}
socks5 プロキシで最も一般的に使用されるポートは 1080 です。wireshark でパケットをキャプチャして確認したい場合、wireshark は 1080 ポートの socks5 通信のみを解析できます。
socks5 プロキシの実装#
socks5 プロトコルは本質的にアプリケーション層プロトコルであり、データは TCP データパケットのペイロードにパッケージ化されます。sock5 プロトコルはトンネルを掘ることに例えると、いくつかの部分に分けられます。
socks5auth
施工隊を見つけるsocks5connect
トンネルを掘り始めるsocks5forward
トンネルが通行可能に!
socks5forward
はトンネル通行段階に入ります。この段階では socks5 は関与していません。なぜなら、トンネルはすでに掘り終わっているからです。HTTP データパケットを自由に走らせましょう!
socks5auth 施工隊を見つける#
socks5 プロトコルはクライアントから最初に発起されます:
# クライアント送信
+----+----------+----------+
|VER | NMETHODS | METHODS |
+----+----------+----------+
| 1 | 1 | 1 to 255 |
+----+----------+----------+
# サーバー応答
+----+--------+
|VER | METHOD |
+----+--------+
| 1 | 1 |
+----+--------+
具体的なフィールドは以下の通りです:
クライアントリクエスト
- VER バージョン番号 1 バイト
- NMETHODS 利用可能な認証方法、1 バイト
- METHODS (長さは NMETHODS に等しい) 1 バイトごとに 1 つの方法
サーバー応答
- VER バージョン番号
- METHOD 認証方法、私たちは認証なしで 0x00 を入力します。
したがって、最初のステップではリクエストを読み取り、0x05,0x00
をクライアントに返して接続を同意することだけが必要です。
func Socks5Auth(client net.Conn) (err error) {
buf := make([]byte, 256)
// VERとNMETHODSを読み取る
n, err := io.ReadFull(client, buf[:2])
ver, nMethods := int(buf[0]), int(buf[1])
// METHODSリストを読み取る
n, err = io.ReadFull(client, buf[:nMethods])
// 認証なし
n, err = client.Write([]byte{0x05, 0x00})
return nil
}
同様に rust の実装:
async fn socks5_auth(client: &mut TcpStream) -> Result<(), Box<dyn std::error::Error>> {
let mut buf = [0u8; 2]; // [0,0]で初期化
client.read_exact(&mut buf).await?;
let ver = buf[0];
let n_methods = buf[1];
let mut methods = vec![0u8; n_methods as usize];
client.read_exact(&mut methods).await?;
client.write_all(&[0x05, 0x00]).await?;
Ok(())
}
これで、socks5 プロトコルの第一歩、施工隊が見つかり、クライアントにトンネルを掘る手伝いをすることを伝えました!
socks5connect トンネルを掘り始める#
プロトコルの詳細は以下の通りです(数字はバイト長を示します):
# クライアント送信
+----+-----+-------+------+----------+----------+
|VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
# サーバー応答
+----+-----+-------+------+----------+----------+
|VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
クライアントリクエスト:
- VER バージョン番号 1 バイト、デフォルトは 5
- CMD 0x01 は接続を示す
- RSV 保留固定ビット 0x00
- ATYP リクエストタイプ、0x01 は ipv4、0x03 はドメイン名、0x04 は ipv6
- DST.ADDR アドレス、リクエストがドメイン名の場合、最初のバイトはドメイン名の長さ、そうでなければ 4 バイトの ipv4 アドレス(ipv6 は無視)
- DST.PORT ポート 2 バイト
サーバー応答:
- VER バージョン番号 1 バイト、デフォルトは 5
- REP 確認応答 0x00 成功
- RSV 保留、デフォルト 0
後のフィールドはクライアント BIND コマンド(私たちが使用する connect コマンドではない)にのみ適用され、すべて 0 を送信すれば良いです。
- ATYP 応答タイプ、0x01 は ipv4、0x03 はドメイン名、0x04 は ipv6
- BND.ADDR アドレス
- BND.PORT ポート
このステップはトンネルを掘ることなので、クライアントが私たちにどこに向かうトンネルを掘るかを知る必要があります。したがって、ここでは実際に 2 つのステップに分かれます。
- クライアントが私たちに送信した目的地を解析する(上記のプロトコルに従って解析)
- 目的地への TCP 接続を確立する
クライアント → socks プロキシ
socks プロキシ → クライアントのコードは 1 行だけで、コメントに書いてあります。
func Socks5Connect(client net.Conn) (net.Conn, error) {
buf := make([]byte, 256)
n, err := io.ReadFull(client, buf[:4])
// 最初の4バイト
ver, cmd, _, atyp := buf[0], buf[1], buf[2], buf[3]
addr := ""
switch atyp {
case 1: // 最初のipv4のケースのみを仮定
n, err = io.ReadFull(client, buf[:4])
if n != 4 {
return nil, errors.New("invalid IPv4: " + err.Error())
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
// ...
default:
return nil, errors.New("invalid atyp")
}
// ポートを解析、バイト順序に注意
n, err = io.ReadFull(client, buf[:2])
port := binary.BigEndian.Uint16(buf[:2])
// 目的地のアドレスを取得しました!
destAddrPort := fmt.Sprintf("%s:%d", addr, port)
// トンネルを掘り始める
dest, err := net.Dial("tcp", destAddrPort)
// クライアントへの応答、トンネルが完成しました!
_, err = client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return dest, nil
}
同様に rust で実装します。
async fn socks5_connect(client: &mut TcpStream) -> Result<TcpStream, Box<dyn std::error::Error>> {
let mut buf = [0u8; 4];
client.read_exact(&mut buf).await?;
let ver = buf[0];
let cmd = buf[1];
let atyp = buf[3];
let target_addr = match atyp {
1 => {
let mut addr = [0u8; 4];
client.read_exact(&mut addr).await?;
format!("{}.{}.{}.{}", addr[0], addr[1], addr[2], addr[3])
}
_ => return Err("Invalid atyp".into()),
};
let mut port_buf = [0u8; 2];
client.read_exact(&mut port_buf).await?;
let port = u16::from_be_bytes(port_buf);
// トンネルを掘り始める!
let target = TcpStream::connect(format!("{}:{}", target_addr, port)).await?;
// クライアントにトンネルが完成したことを伝える!
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
Ok(target)
}
socks5forward トンネルが通行可能に!#
この時点で、クライアントの client と遠隔の target が接続を確立する必要があります。これは、トンネルを接続することに等しく、少し詹天佑が京張鉄道トンネルを掘る際に使用した両端から進む戦略に似ています。
go では、直接io.Copy
を使用して実現します。
func Socks5Forward(client, target net.Conn) {
forward := func(src, dest net.Conn) {
defer src.Close()
defer dest.Close()
io.Copy(src, dest)
}
go forward(client, target)
go forward(target, client)
}
rust にも同様の API があり、tokio::io::copy
を使用します。
let (mut cr, mut cw) = client.split();
let (mut tr, mut tw) = target.split();
let c_to_t = async {
match tokio::io::copy(&mut cr, &mut tw).await {
Ok(_) => {}
Err(e) => {
eprintln!("Error forwarding from client to target: {}", e);
}
}
};
let t_to_c = async {
match tokio::io::copy(&mut tr, &mut cw).await {
Ok(_) => {}
Err(e) => {
eprintln!("Error forwarding from target to client: {}", e);
}
}
};
これで、socks5 のネットワークトンネルが完成しました。その後は HTTP データパケット(列車)が走り始めます。
wireshark でのパケットキャプチャテスト#
前述の通り、socks が 1080 ポートで動作している場合にのみ、wireshark は socks プロトコルを正しく解析できます。
以下の図に示すように、socks の 3 つのプロセスが示されています。具体的なデータパケットの詳細は自分で確認できます:
負荷テストの比較#
ここでの負荷テストの考え方は、http サーバーを立て、go と rust で実装された socks5 プロキシを使用してトンネルを構築し、リクエストを発起して実際の QPS のパフォーマンスを確認することです。
便利なように、gin を使って http サーバーを構築します。
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
r.Run(":8082")
}
このベンチマークツールを使用すると、socks5 プロトコルをサポートできます。
go install github.com/cnlh/benchmark@latest
まず、web サーバーの QPS を測定します。m1 mac マシンの構成は比較的低いため、100 の同時接続で 10 万のリクエストを実行します。
❯ benchmark -c 100 -n 100000 http://127.0.0.1:8082/ping -ignore-err
Running 100000 test @ 127.0.0.1:8082 by 100 connections
Request as following format:
GET /ping HTTP/1.1
Host: 127.0.0.1:8082
100000 requests in 4.45s, 11.46MB read, 4.20MB write
Requests/sec: 22464.38
Transfer/sec: 3.52MB
Error(s) : 0
Percentage of the requests served within a certain time (ms)
50% 2
65% 2
75% 3
80% 4
90% 8
95% 17
98% 29
99% 51
100% 82
ここでのデータの解釈は以下の通りです:
- 4.45 秒内に合計 10 万回のリクエストを完了しました。
- 平均毎秒 22464.38、すなわち QPS は 22k です。
- 最後のデータは、特定の時間内に処理されたリクエストの割合と、それに対応する応答時間を示しています。
- 50% のリクエストが 2ms 以内に応答
- 99% のリクエストが 51ms 以内に応答
次に、2 つの施工隊が登場します。まずは go を代表する goroutine です。QPS は若干低下しましたが、低下幅は少なく、リクエストの応答時間分布がより均一になったことがわかります。
❯ benchmark -c 100 -n 100000 -proxy socks5://127.0.0.1:1080 http://127.0.0.1:8082/ping -ignore-err
Running 100000 test @ 127.0.0.1:8082 by 100 connections
Request as following format:
GET /ping HTTP/1.1
Host: 127.0.0.1:8082
100000 requests in 4.49s, 11.46MB read, 4.20MB write
Requests/sec: 22295.77
Transfer/sec: 3.49MB
Error(s) : 0
Percentage of the requests served within a certain time (ms)
50% 2
65% 3
75% 4
80% 4
90% 7
95% 14
98% 25
99% 35
100% 63
次に rust を代表する tokio が登場します。QPS は約 2k 低下し、リクエストの応答時間分布に差が大きくなりました。
❯ benchmark -c 100 -n 100000 -proxy socks5://127.0.0.1:1080 http://127.0.0.1:8082/ping -ignore-err
Running 100000 test @ 127.0.0.1:8082 by 100 connections
Request as following format:
GET /ping HTTP/1.1
Host: 127.0.0.1:8082
100000 requests in 4.95s, 11.46MB read, 4.20MB write
Requests/sec: 20218.10
Transfer/sec: 3.17MB
Error(s) : 0
Percentage of the requests served within a certain time (ms)
50% 3
65% 4
75% 4
80% 5
90% 7
95% 13
98% 25
99% 34
100% 92
どうやら goroutine 選手が優れているようです。
参考:
https://segmentfault.com/a/1190000038247560