GoでTCPプロキシを作るシリーズ
| Part1 net.Listener | Part2 透過プロキシ | Part3 HTTPS MITM | Part4 ロードバランサー |
|---|---|---|---|
| 👈 Now | - | - | - |
はじめに
「プロキシってなんか難しそう...」
そう思ってない?私も最初はそう思ってた。
でも実は、Goならめちゃくちゃ簡単に作れるんだよ。
今回から4回に分けて、TCPプロキシを自作しながらネットワークプログラミングを学んでいく。自分で作ると、普段使ってるプロキシの仕組みがよく分かるようになるよ。
なぜGoでネットワークプログラミング?
┌─────────────────────────────────────────────────────────────┐
│ Goがネットワークに強い理由 │
├─────────────────────────────────────────────────────────────┤
│ 1. 標準ライブラリが充実(net, net/http, crypto/tls) │
│ 2. goroutineで並行処理が簡単 │
│ 3. クロスコンパイルが楽(Linux/Windows/macOS) │
│ 4. シングルバイナリでデプロイ簡単 │
└─────────────────────────────────────────────────────────────┘
C/C++だとソケットプログラミングは結構面倒だけど、Goなら数十行で書ける。
環境構築
# Goのバージョン確認
go version
# go version go1.23.4 windows/amd64
# プロジェクト作成
mkdir tcp-proxy && cd tcp-proxy
go mod init tcp-proxy
net.Listenerとは
Goのnetパッケージには、ネットワーク通信の基本的なインターフェースが定義されている。
// Listener インターフェース
type Listener interface {
Accept() (Conn, error) // 接続を受け付ける
Close() error // リスナーを閉じる
Addr() Addr // リッスンしているアドレスを返す
}
// Conn インターフェース
type Conn interface {
Read(b []byte) (n int, err error) // データを読み込む
Write(b []byte) (n int, err error) // データを書き込む
Close() error // 接続を閉じる
LocalAddr() Addr // ローカルアドレス
RemoteAddr() Addr // リモートアドレス
SetDeadline(t time.Time) error // タイムアウト設定
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}
TCPサーバーの基本形
package main
import (
"fmt"
"net"
)
func main() {
// ポート8080でリッスン開始
listener, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("Server listening on :8080")
for {
// 接続を待ち受ける(ブロッキング)
conn, err := listener.Accept()
if err != nil {
fmt.Println("Accept error:", err)
continue
}
// goroutineで並行処理
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
// クライアントのアドレスを表示
fmt.Printf("Client connected: %s\n", conn.RemoteAddr())
// バッファを用意
buf := make([]byte, 1024)
for {
// データを読み込む
n, err := conn.Read(buf)
if err != nil {
fmt.Printf("Client disconnected: %s\n", conn.RemoteAddr())
return
}
// 受信したデータを表示
fmt.Printf("Received: %s", string(buf[:n]))
// エコーバック(受信したデータをそのまま返す)
conn.Write(buf[:n])
}
}
動作確認
# サーバー起動
go run main.go
# 別ターミナルでクライアント接続(telnetかncを使う)
# Windows PowerShell
Test-NetConnection localhost -Port 8080
# または nc(netcat)があれば
nc localhost 8080
TCPクライアントの基本形
サーバーだけじゃなくて、クライアント側も作ってみよう。
package main
import (
"bufio"
"fmt"
"net"
"os"
)
func main() {
// サーバーに接続
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
panic(err)
}
defer conn.Close()
fmt.Println("Connected to server")
// 標準入力からの読み取り
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("Enter message: ")
text, _ := reader.ReadString('\n')
// サーバーに送信
conn.Write([]byte(text))
// レスポンスを受信
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
fmt.Println("Server disconnected")
return
}
fmt.Printf("Server response: %s", string(buf[:n]))
}
}
プロキシの基礎:2つのConnを繋ぐ
プロキシの本質は「2つの接続を繋ぐ」こと。
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ ──────► │ Proxy │ ──────► │ Server │
│ │ ◄────── │ │ ◄────── │ │
└──────────┘ └──────────┘ └──────────┘
clientConn serverConn
クライアントからの接続(clientConn)と、サーバーへの接続(serverConn)を作って、データを双方向に流すだけ。
シンプルなTCPプロキシ
package main
import (
"fmt"
"io"
"net"
)
const (
listenAddr = ":8080" // プロキシがリッスンするアドレス
targetAddr = "example.com:80" // 転送先のアドレス
)
func main() {
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Printf("Proxy listening on %s, forwarding to %s\n", listenAddr, targetAddr)
for {
clientConn, err := listener.Accept()
if err != nil {
fmt.Println("Accept error:", err)
continue
}
go handleProxy(clientConn)
}
}
func handleProxy(clientConn net.Conn) {
defer clientConn.Close()
fmt.Printf("Client connected: %s\n", clientConn.RemoteAddr())
// ターゲットサーバーに接続
serverConn, err := net.Dial("tcp", targetAddr)
if err != nil {
fmt.Println("Failed to connect to target:", err)
return
}
defer serverConn.Close()
fmt.Printf("Connected to target: %s\n", targetAddr)
// 双方向にデータを転送
// Client → Server
go func() {
io.Copy(serverConn, clientConn)
}()
// Server → Client
io.Copy(clientConn, serverConn)
fmt.Printf("Connection closed: %s\n", clientConn.RemoteAddr())
}
io.Copyの魔法
io.Copyは、ReaderからWriterにデータをコピーし続ける関数。EOFまたはエラーが発生するまで自動でループしてくれる。
// io.Copy の内部実装(簡略化)
func Copy(dst Writer, src Reader) (written int64, err error) {
buf := make([]byte, 32*1024) // 32KBバッファ
for {
nr, er := src.Read(buf)
if nr > 0 {
nw, ew := dst.Write(buf[0:nr])
// ...
}
if er != nil {
if er != EOF {
err = er
}
break
}
}
return
}
接続のタイムアウト設定
実用的なプロキシを作るなら、タイムアウトの設定は必須。
func handleProxyWithTimeout(clientConn net.Conn) {
defer clientConn.Close()
// 接続タイムアウト(10秒)
dialer := net.Dialer{
Timeout: 10 * time.Second,
}
serverConn, err := dialer.Dial("tcp", targetAddr)
if err != nil {
fmt.Println("Connection timeout or error:", err)
return
}
defer serverConn.Close()
// 読み書きのタイムアウト(30秒)
clientConn.SetDeadline(time.Now().Add(30 * time.Second))
serverConn.SetDeadline(time.Now().Add(30 * time.Second))
// 双方向転送
errChan := make(chan error, 2)
go func() {
_, err := io.Copy(serverConn, clientConn)
errChan <- err
}()
go func() {
_, err := io.Copy(clientConn, serverConn)
errChan <- err
}()
// どちらかが終了したら終わり
<-errChan
}
context.Contextでキャンセル対応
Go 1.7以降では、context.Contextを使ったキャンセル処理が推奨されている。
func handleProxyWithContext(ctx context.Context, clientConn net.Conn) {
defer clientConn.Close()
// contextからDialer作成
dialer := net.Dialer{}
serverConn, err := dialer.DialContext(ctx, "tcp", targetAddr)
if err != nil {
fmt.Println("Dial error:", err)
return
}
defer serverConn.Close()
// contextがキャンセルされたら接続を閉じる
go func() {
<-ctx.Done()
clientConn.Close()
serverConn.Close()
}()
// 双方向転送
go io.Copy(serverConn, clientConn)
io.Copy(clientConn, serverConn)
}
ListenConfigで細かい設定
Go 1.11以降ではListenConfigでより細かい設定ができる。
func main() {
lc := net.ListenConfig{
KeepAlive: 30 * time.Second, // Keep-Alive間隔
}
listener, err := lc.Listen(context.Background(), "tcp", ":8080")
if err != nil {
panic(err)
}
defer listener.Close()
// ...
}
転送量をカウントする
プロキシでは転送量をログに残したいことがある。
type CountingWriter struct {
writer io.Writer
count int64
}
func (cw *CountingWriter) Write(p []byte) (int, error) {
n, err := cw.writer.Write(p)
cw.count += int64(n)
return n, err
}
func handleProxyWithStats(clientConn net.Conn) {
defer clientConn.Close()
serverConn, err := net.Dial("tcp", targetAddr)
if err != nil {
return
}
defer serverConn.Close()
// カウンター付きWriter
clientWriter := &CountingWriter{writer: clientConn}
serverWriter := &CountingWriter{writer: serverConn}
done := make(chan struct{})
go func() {
io.Copy(serverWriter, clientConn)
done <- struct{}{}
}()
go func() {
io.Copy(clientWriter, serverConn)
done <- struct{}{}
}()
<-done
<-done
fmt.Printf("Stats: Client→Server: %d bytes, Server→Client: %d bytes\n",
serverWriter.count, clientWriter.count)
}
まとめ
今回学んだこと:
| 概念 | 説明 |
|---|---|
net.Listen |
TCPサーバーを作る |
net.Dial |
TCPクライアントを作る |
Conn |
接続を表すインターフェース |
io.Copy |
データを転送する |
| タイムアウト |
DialerやSetDeadlineで設定 |
| context | キャンセル処理 |
次回は、透過プロキシの実装に進む。HTTPヘッダの解析や、Host別のルーティングを実装するよ。
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!