9
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プロキシを自作して低レイヤーを学ぶ【Part2:透過プロキシの実装】

Posted at

GoでTCPプロキシを作るシリーズ

Part1 net.Listener Part2 透過プロキシ Part3 HTTPS MITM Part4 ロードバランサー
✅ Done 👈 Now - -

はじめに

前回はTCPプロキシの基本を学んだ。

今回は「透過プロキシ」を実装するよ。HTTPリクエストを解析して、Hostヘッダを見て適切なサーバーに転送する仕組みなんだ。

普段使ってるFiddlerとかBurp Suiteとか、こういう単純なロジックで動いてるんだよね。

透過プロキシとは

┌──────────────────────────────────────────────────────────────────┐
│                       透過プロキシの動作                          │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│  Client ─── GET / HTTP/1.1 ───►  Proxy  ───► example.com         │
│            Host: example.com              └──► google.com          │
│                                           └──► github.com          │
│                                                                   │
│  プロキシがHostヘッダを見て、転送先を動的に決定                    │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

普通のプロキシ(フォワードプロキシ)との違い:

  • フォワードプロキシ: クライアントが明示的にプロキシを設定
  • 透過プロキシ: クライアントはプロキシの存在を意識しない

HTTPリクエストの構造

まずHTTPリクエストの構造を理解しよう。

GET /path HTTP/1.1\r\n          ← リクエストライン
Host: example.com\r\n           ← ヘッダ
User-Agent: Mozilla/5.0\r\n
Accept: text/html\r\n
\r\n                            ← 空行(ヘッダの終わり)
[body]                          ← ボディ(POSTの場合)

HTTPはテキストベースのプロトコルなので、解析しやすい。

HTTPリクエストパーサー

package main

import (
    "bufio"
    "fmt"
    "net"
    "strings"
)

type HTTPRequest struct {
    Method  string
    Path    string
    Version string
    Host    string
    Headers map[string]string
    RawData []byte // 元のリクエストデータ
}

func parseHTTPRequest(reader *bufio.Reader) (*HTTPRequest, error) {
    req := &HTTPRequest{
        Headers: make(map[string]string),
    }
    var rawData []byte

    // リクエストライン読み込み
    line, err := reader.ReadString('\n')
    if err != nil {
        return nil, err
    }
    rawData = append(rawData, []byte(line)...)

    // "GET /path HTTP/1.1" をパース
    parts := strings.Fields(strings.TrimSpace(line))
    if len(parts) != 3 {
        return nil, fmt.Errorf("invalid request line: %s", line)
    }
    req.Method = parts[0]
    req.Path = parts[1]
    req.Version = parts[2]

    // ヘッダ読み込み
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return nil, err
        }
        rawData = append(rawData, []byte(line)...)

        line = strings.TrimSpace(line)
        if line == "" {
            break // 空行でヘッダ終了
        }

        // "Host: example.com" をパース
        colonIdx := strings.Index(line, ":")
        if colonIdx > 0 {
            key := strings.TrimSpace(line[:colonIdx])
            value := strings.TrimSpace(line[colonIdx+1:])
            req.Headers[key] = value

            if strings.ToLower(key) == "host" {
                req.Host = value
            }
        }
    }

    req.RawData = rawData
    return req, nil
}

透過プロキシの実装

package main

import (
    "bufio"
    "fmt"
    "io"
    "net"
    "strings"
    "time"
)

const listenAddr = ":8080"

func main() {
    listener, err := net.Listen("tcp", listenAddr)
    if err != nil {
        panic(err)
    }
    defer listener.Close()

    fmt.Printf("Transparent proxy listening on %s\n", listenAddr)

    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        go handleConnection(conn)
    }
}

func handleConnection(clientConn net.Conn) {
    defer clientConn.Close()

    // タイムアウト設定
    clientConn.SetDeadline(time.Now().Add(30 * time.Second))

    reader := bufio.NewReader(clientConn)

    // HTTPリクエストをパース
    req, err := parseHTTPRequest(reader)
    if err != nil {
        fmt.Println("Parse error:", err)
        return
    }

    fmt.Printf("[%s] %s %s -> %s\n",
        clientConn.RemoteAddr(), req.Method, req.Path, req.Host)

    // Hostからターゲットアドレスを決定
    targetAddr := req.Host
    if !strings.Contains(targetAddr, ":") {
        targetAddr += ":80" // デフォルトポート
    }

    // ターゲットに接続
    serverConn, err := net.DialTimeout("tcp", targetAddr, 10*time.Second)
    if err != nil {
        fmt.Println("Failed to connect to", targetAddr, err)
        clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
        return
    }
    defer serverConn.Close()

    // 元のリクエストをサーバーに転送
    serverConn.Write(req.RawData)

    // 双方向転送
    done := make(chan struct{}, 2)

    go func() {
        io.Copy(serverConn, clientConn)
        done <- struct{}{}
    }()

    go func() {
        io.Copy(clientConn, serverConn)
        done <- struct{}{}
    }()

    <-done
}

CONNECTメソッド対応

HTTPSの場合、クライアントはまずCONNECTメソッドを送ってトンネルを確立する。

CONNECT example.com:443 HTTP/1.1
Host: example.com:443

func handleConnection(clientConn net.Conn) {
    defer clientConn.Close()

    reader := bufio.NewReader(clientConn)
    req, err := parseHTTPRequest(reader)
    if err != nil {
        return
    }

    // CONNECTメソッドの場合
    if req.Method == "CONNECT" {
        handleConnect(clientConn, req)
        return
    }

    // 通常のHTTPリクエスト
    handleHTTP(clientConn, reader, req)
}

func handleConnect(clientConn net.Conn, req *HTTPRequest) {
    targetAddr := req.Host
    if !strings.Contains(targetAddr, ":") {
        targetAddr += ":443"
    }

    fmt.Printf("[CONNECT] %s -> %s\n", clientConn.RemoteAddr(), targetAddr)

    // ターゲットに接続
    serverConn, err := net.DialTimeout("tcp", targetAddr, 10*time.Second)
    if err != nil {
        clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
        return
    }
    defer serverConn.Close()

    // 接続成功を通知
    clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))

    // トンネルモード(生のTCP転送)
    done := make(chan struct{}, 2)

    go func() {
        io.Copy(serverConn, clientConn)
        done <- struct{}{}
    }()

    go func() {
        io.Copy(clientConn, serverConn)
        done <- struct{}{}
    }()

    <-done
}

アクセスログの実装

実用的なプロキシにはログ機能が必要。

type AccessLog struct {
    Timestamp   time.Time
    ClientAddr  string
    Method      string
    Host        string
    Path        string
    StatusCode  int
    BytesSent   int64
    Duration    time.Duration
}

func (log *AccessLog) String() string {
    return fmt.Sprintf("[%s] %s %s %s%s %d %d bytes %.2fms",
        log.Timestamp.Format("2006-01-02 15:04:05"),
        log.ClientAddr,
        log.Method,
        log.Host,
        log.Path,
        log.StatusCode,
        log.BytesSent,
        float64(log.Duration.Microseconds())/1000,
    )
}

// ResponseWriter をラップして転送量を計測
type loggingWriter struct {
    writer    io.Writer
    bytesSent int64
}

func (lw *loggingWriter) Write(p []byte) (int, error) {
    n, err := lw.writer.Write(p)
    lw.bytesSent += int64(n)
    return n, err
}

ブラックリスト機能

特定のドメインへのアクセスをブロックする機能。

var blacklist = map[string]bool{
    "ads.example.com":     true,
    "tracker.example.com": true,
    "malware.example.com": true,
}

func isBlocked(host string) bool {
    // ポート番号を除去
    h := strings.Split(host, ":")[0]
    return blacklist[h]
}

func handleConnection(clientConn net.Conn) {
    defer clientConn.Close()

    reader := bufio.NewReader(clientConn)
    req, err := parseHTTPRequest(reader)
    if err != nil {
        return
    }

    // ブラックリストチェック
    if isBlocked(req.Host) {
        fmt.Printf("[BLOCKED] %s -> %s\n", clientConn.RemoteAddr(), req.Host)
        clientConn.Write([]byte("HTTP/1.1 403 Forbidden\r\n"))
        clientConn.Write([]byte("Content-Type: text/html\r\n\r\n"))
        clientConn.Write([]byte("<h1>Access Denied</h1>"))
        return
    }

    // 通常処理...
}

コネクションプール

毎回新しい接続を作るのは非効率。コネクションプールを実装しよう。

type ConnectionPool struct {
    mu    sync.Mutex
    conns map[string][]net.Conn
    max   int
}

func NewConnectionPool(maxPerHost int) *ConnectionPool {
    return &ConnectionPool{
        conns: make(map[string][]net.Conn),
        max:   maxPerHost,
    }
}

func (p *ConnectionPool) Get(addr string) (net.Conn, error) {
    p.mu.Lock()
    defer p.mu.Unlock()

    // プールから取得を試みる
    if conns, ok := p.conns[addr]; ok && len(conns) > 0 {
        conn := conns[len(conns)-1]
        p.conns[addr] = conns[:len(conns)-1]
        return conn, nil
    }

    // なければ新規作成
    return net.DialTimeout("tcp", addr, 10*time.Second)
}

func (p *ConnectionPool) Put(addr string, conn net.Conn) {
    p.mu.Lock()
    defer p.mu.Unlock()

    conns := p.conns[addr]
    if len(conns) >= p.max {
        conn.Close() // プールがいっぱい
        return
    }

    p.conns[addr] = append(conns, conn)
}

ホストベースのルーティング

内部サービスへのルーティングも実装できる。

var routingTable = map[string]string{
    "api.local":     "192.168.1.10:8080",
    "web.local":     "192.168.1.20:80",
    "db.local":      "192.168.1.30:5432",
}

func resolveTarget(host string) string {
    // ポートを除去してルーティングテーブルを検索
    h := strings.Split(host, ":")[0]

    if target, ok := routingTable[h]; ok {
        return target
    }

    // ルーティングテーブルになければそのまま
    return host
}

HTTPレスポンスの解析

レスポンスも解析すると、より詳細なログが取れる。

type HTTPResponse struct {
    Version    string
    StatusCode int
    StatusText string
    Headers    map[string]string
}

func parseHTTPResponse(reader *bufio.Reader) (*HTTPResponse, error) {
    resp := &HTTPResponse{
        Headers: make(map[string]string),
    }

    // ステータスライン
    line, err := reader.ReadString('\n')
    if err != nil {
        return nil, err
    }

    // "HTTP/1.1 200 OK" をパース
    parts := strings.SplitN(strings.TrimSpace(line), " ", 3)
    if len(parts) < 2 {
        return nil, fmt.Errorf("invalid status line")
    }
    resp.Version = parts[0]
    resp.StatusCode, _ = strconv.Atoi(parts[1])
    if len(parts) == 3 {
        resp.StatusText = parts[2]
    }

    // ヘッダ
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return nil, err
        }
        line = strings.TrimSpace(line)
        if line == "" {
            break
        }
        colonIdx := strings.Index(line, ":")
        if colonIdx > 0 {
            key := strings.TrimSpace(line[:colonIdx])
            value := strings.TrimSpace(line[colonIdx+1:])
            resp.Headers[key] = value
        }
    }

    return resp, nil
}

完全な実装例

package main

import (
    "bufio"
    "fmt"
    "io"
    "net"
    "strings"
    "sync"
    "time"
)

type Proxy struct {
    listener net.Listener
    pool     *ConnectionPool
    mu       sync.RWMutex
    stats    ProxyStats
}

type ProxyStats struct {
    TotalRequests    int64
    TotalBytes       int64
    ActiveConnections int32
}

func NewProxy(addr string) (*Proxy, error) {
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        return nil, err
    }

    return &Proxy{
        listener: listener,
        pool:     NewConnectionPool(10),
    }, nil
}

func (p *Proxy) Start() {
    fmt.Println("Proxy started")
    for {
        conn, err := p.listener.Accept()
        if err != nil {
            continue
        }
        go p.handleConnection(conn)
    }
}

func (p *Proxy) handleConnection(clientConn net.Conn) {
    defer clientConn.Close()

    reader := bufio.NewReader(clientConn)
    req, err := parseHTTPRequest(reader)
    if err != nil {
        return
    }

    start := time.Now()

    // CONNECT の場合
    if req.Method == "CONNECT" {
        p.handleConnect(clientConn, req)
        return
    }

    // 通常HTTP
    targetAddr := resolveTarget(req.Host)
    if !strings.Contains(targetAddr, ":") {
        targetAddr += ":80"
    }

    serverConn, err := p.pool.Get(targetAddr)
    if err != nil {
        clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
        return
    }

    // リクエスト転送
    serverConn.Write(req.RawData)

    // レスポンス転送
    io.Copy(clientConn, serverConn)

    p.pool.Put(targetAddr, serverConn)

    fmt.Printf("%s %s %s (%v)\n",
        req.Method, req.Host, req.Path, time.Since(start))
}

func main() {
    proxy, err := NewProxy(":8080")
    if err != nil {
        panic(err)
    }
    proxy.Start()
}

まとめ

今回学んだこと:

機能 説明
HTTPパース リクエストライン・ヘッダの解析
透過プロキシ Hostヘッダで転送先を決定
CONNECT対応 HTTPSトンネリング
ブラックリスト ドメイン単位のフィルタリング
コネクションプール 接続の再利用

次回はHTTPSのMITM(Man-in-the-Middle)プロキシを実装するよ。TLS証明書の動的生成がポイントになる。

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

9
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
9
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?