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証明書の動的生成がポイントになる。
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!