11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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 データを転送する
タイムアウト DialerSetDeadlineで設定
context キャンセル処理

次回は、透過プロキシの実装に進む。HTTPヘッダの解析や、Host別のルーティングを実装するよ。

この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?