第21章 Go:100 行手写 HTTP/CONNECT + SOCKS5 代理
学习目标
- 用 Go 标准库从零写出一个能跑的 HTTP 正向代理(含
CONNECT隧道) - 按 RFC 1928 手撕 SOCKS5 协议:握手、地址解析、双向转发
- 理解 Go 为何是写代理的"顺手"语言:goroutine-per-connection +
io.Copy+Hijack - 亲手用
curl -x/curl --socks5验证自己写的代理
前置知识
- 第04章 HTTP 代理协议(absolute-form、CONNECT、hop-by-hop 头)
- 第06章 SOCKS 协议(报文级解析,本章是它的代码落地)
- 会一点 Go,装了 Go 1.21+
原理:任何代理的核心都是这四步
抛开协议细节,所有代理的骨架都一样:
┌─ 1. Accept ──────── 接受客户端连接
├─ 2. 解析目标 ────── 从协议里读出"要去哪"(HTTP 看 URL,SOCKS 看请求报文)
├─ 3. Dial 目标 ───── 自己发起到源站的连接
└─ 4. 双向拷贝 ────── 在两条连接间 bidirectional copy,直到一方关闭
不同协议的差异只在第 2 步"怎么知道目标"。Go 把第 1、4 步做到了极致省心:
- 第 1 步:
for { c, _ := ln.Accept(); go handle(c) }——一个连接一个 goroutine,几行搞定百万并发模型 - 第 4 步:
io.Copy(dst, src)——在 Linux 上,TCP→TCP 的io.Copy底层自动走splice(2)零拷贝(见 第12章),不经用户态缓冲
所以"灵活性"在 Go 里几乎是免费的——你只需专注第 2 步的协议解析。
代码一:HTTP 正向代理(含 CONNECT)
Go 的 net/http 自带 HTTP 解析,正向代理可以站在它肩上:普通请求用 Transport.RoundTrip 转发,CONNECT 用 Hijack 拿到裸连接做隧道。
// http_proxy.go —— go run http_proxy.go,监听 :8080
package main
import (
"io"
"log"
"net"
"net/http"
"time"
)
// hop-by-hop 头:代理必须删除,不得转发(见第04章)
var hopHeaders = []string{
"Connection", "Proxy-Connection", "Keep-Alive",
"Proxy-Authenticate", "Proxy-Authorization",
"Te", "Trailer", "Transfer-Encoding", "Upgrade",
}
func main() {
server := &http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(handle),
}
log.Println("HTTP 代理已启动: 127.0.0.1:8080")
log.Fatal(server.ListenAndServe())
}
func handle(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
handleConnect(w, r) // HTTPS 等加密流量走隧道
} else {
handleHTTP(w, r) // 明文 HTTP 直接转发
}
}
// 明文 HTTP:absolute-form 的 URL 已被标准库解析进 r.URL,直接重发
func handleHTTP(w http.ResponseWriter, r *http.Request) {
removeHopHeaders(r.Header)
r.RequestURI = "" // RoundTrip 要求客户端请求必须清空 RequestURI
resp, err := http.DefaultTransport.RoundTrip(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) // 502:到源站失败
return
}
defer resp.Body.Close()
removeHopHeaders(resp.Header)
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// CONNECT:劫持底层 TCP,两条连接间盲转字节
func handleConnect(w http.ResponseWriter, r *http.Request) {
dst, err := net.DialTimeout("tcp", r.Host, 10*time.Second) // r.Host = "example.com:443"
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
hij, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "不支持 Hijack", http.StatusInternalServerError)
dst.Close()
return
}
client, _, err := hij.Hijack() // 拿回裸 net.Conn,标准库不再插手
if err != nil {
dst.Close()
return
}
// 手动写 200 到被劫持的连接,告诉客户端"隧道已开"
client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
// 双向拷贝:任一方向结束就关闭两端
go transfer(dst, client)
transfer(client, dst)
}
func transfer(dst, src net.Conn) {
defer dst.Close()
defer src.Close()
io.Copy(dst, src) // Linux 上 TCP→TCP 自动 splice 零拷贝
}
func removeHopHeaders(h http.Header) {
for _, k := range hopHeaders {
h.Del(k)
}
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
跑起来验证:
go run http_proxy.go &
# 明文 HTTP:走 handleHTTP(absolute-form)
curl -x http://127.0.0.1:8080 http://httpbin.org/ip
# {"origin": "203.0.113.7"}
# HTTPS:走 handleConnect(CONNECT 隧道)
curl -x http://127.0.0.1:8080 https://example.com/ -s -o /dev/null -w "%{http_code}\n"
# 200
就这 80 行,你已经有了一个能代理明文 HTTP 和 HTTPS 的可用正向代理。
Hijack是关键——它让你从"HTTP 语义"掉回"裸 TCP 字节流",这正是隧道所需。
代码二:SOCKS5 代理(裸 TCP 手撕协议)
SOCKS5 没有现成标准库解析,正好让我们逐字节实现 RFC 1928。流程三段:协商认证方法 → 读取 CONNECT 请求 → 转发。
// socks5.go —— go run socks5.go,监听 :1080
package main
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
)
func main() {
ln, err := net.Listen("tcp", ":1080")
if err != nil {
log.Fatal(err)
}
log.Println("SOCKS5 代理已启动: 127.0.0.1:1080")
for {
c, err := ln.Accept()
if err != nil {
continue
}
go handle(c)
}
}
func handle(client net.Conn) {
defer client.Close()
// ── 阶段 1:方法协商 ──
// 客户端: VER(0x05) NMETHODS METHOD...
head := make([]byte, 2)
if _, err := io.ReadFull(client, head); err != nil || head[0] != 0x05 {
return
}
methods := make([]byte, int(head[1]))
if _, err := io.ReadFull(client, methods); err != nil {
return
}
// 服务端: VER(0x05) METHOD(0x00=无需认证)
client.Write([]byte{0x05, 0x00})
// ── 阶段 2:读取请求 ──
// 客户端: VER CMD RSV ATYP DST.ADDR DST.PORT
req := make([]byte, 4)
if _, err := io.ReadFull(client, req); err != nil {
return
}
ver, cmd, atyp := req[0], req[1], req[3]
if ver != 0x05 || cmd != 0x01 { // 只支持 CONNECT(0x01)
reply(client, 0x07) // 0x07 = 命令不支持
return
}
host, err := readAddr(client, atyp)
if err != nil {
reply(client, 0x08) // 0x08 = 地址类型不支持
return
}
portBuf := make([]byte, 2)
if _, err := io.ReadFull(client, portBuf); err != nil {
return
}
target := fmt.Sprintf("%s:%d", host, binary.BigEndian.Uint16(portBuf))
// ── 阶段 3:拨号 + 应答 + 转发 ──
dst, err := net.Dial("tcp", target)
if err != nil {
reply(client, 0x05) // 0x05 = 连接被拒
return
}
defer dst.Close()
reply(client, 0x00) // 0x00 = 成功
go io.Copy(dst, client)
io.Copy(client, dst)
}
// 按 ATYP 读出目标地址
func readAddr(c net.Conn, atyp byte) (string, error) {
switch atyp {
case 0x01: // IPv4:4 字节
b := make([]byte, 4)
_, err := io.ReadFull(c, b)
return net.IP(b).String(), err
case 0x04: // IPv6:16 字节
b := make([]byte, 16)
_, err := io.ReadFull(c, b)
return net.IP(b).String(), err
case 0x03: // 域名:1 字节长度 + N 字节
l := make([]byte, 1)
if _, err := io.ReadFull(c, l); err != nil {
return "", err
}
b := make([]byte, int(l[0]))
_, err := io.ReadFull(c, b)
return string(b), err
}
return "", fmt.Errorf("未知 ATYP: %d", atyp)
}
// 应答: VER REP RSV ATYP BND.ADDR BND.PORT(BND 全填 0 即可)
func reply(c net.Conn, rep byte) {
c.Write([]byte{0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
}
跑起来验证:
go run socks5.go &
# --socks5:本地解析 DNS,发 IPv4/IPv6 ATYP
curl --socks5 127.0.0.1:1080 http://httpbin.org/ip
# {"origin": "203.0.113.7"}
# --socks5-hostname:远端解析 DNS,发域名 ATYP(走我们的 0x03 分支)
curl --socks5-hostname 127.0.0.1:1080 https://example.com/ -s -o /dev/null -w "%{http_code}\n"
# 200
SOCKS5 比 HTTP 代理"更底层":它不关心你传的是 HTTP、TLS 还是任意 TCP,只负责"帮你连到 host:port"。所以同一个 80 行 SOCKS5,能代理 HTTP、HTTPS、SSH、数据库连接——这就是 SOCKS 的通用性。两者对比详见 第06章。
抓包看自己写的 SOCKS5 握手
sudo tcpdump -i lo -n -X 'tcp port 1080' 2>/dev/null | head -20
你能亲眼看到二进制握手:开头 05 01 00(VER=5, NMETHODS=1, METHOD=0),服务端回 05 00,然后 05 01 00 03 0b ...(CONNECT + 域名长度 11 + "httpbin.org")。把 第06章 的报文图和这段抓包对照,协议就刻进脑子里了。
为什么 Go 写代理这么顺:灵活性来自哪
| 能力 | Go 怎么做到 | 对代理意味着什么 |
|---|---|---|
| 高并发连接 | goroutine-per-connection,每连接几 KB 栈 | 不用写 epoll 事件循环就能扛海量长连接 |
| 零拷贝转发 | io.Copy(tcpA, tcpB) 自动 splice(2) | 数据不进用户态,吞吐接近内核转发 |
| 掉回裸字节 | http.Hijacker | 同一端口既做 L7 解析又做 L4 隧道 |
| 协议解析 | encoding/binary、bufio | 手撕二进制协议(SOCKS)几十行 |
| 超时/取消 | context、SetDeadline | 连接级超时控制简单可靠 |
这也是为什么云原生代理 Traefik、frp、gost、v2ray-core、mihomo(Clash) 全用 Go 写——开发效率和性能的甜点。对比其它语言的取舍(Rust 更快更省、C 更接近极限、Python 更适合调试)见 第24章 语言选型。
排错
| 现象 | 原因 | 解决 |
|---|---|---|
RoundTrip 报 RequestURI can't be set | 忘了 r.RequestURI = "" | 转发前清空 |
| CONNECT 后客户端卡住无响应 | 没写 200 Connection Established,或没换行 \r\n\r\n | 检查手写的状态行 |
| HTTPS 代理证书错误 | 你的代理没做 MITM 是正常的(隧道不解密) | 想看明文见 第05章 |
SOCKS5 curl: (97) Can't complete SOCKS5 | ATYP 分支没读全字节,或应答格式错 | 核对 readAddr 与 reply 的字节数 |
| 连接泄漏、goroutine 暴涨 | io.Copy 一端结束没关另一端 | 用 defer Close() 成对关闭(如代码所示) |
大量 CLOSE_WAIT | 同上,半关闭未处理 | 任一方向 EOF 即关闭双端 |
本章小结
- 代理骨架永远是 Accept → 解析目标 → Dial → 双向拷贝,差异只在"怎么解析目标"。
- HTTP 代理可站在
net/http上,普通请求用RoundTrip、CONNECT 用Hijack转隧道。 - SOCKS5 需手撕协议:方法协商 → CONNECT 请求(ATYP 解析) → 应答 → 转发,但它对载荷无感,更通用。
- Go 的 goroutine +
io.Copy(splice) +Hijack让"灵活性"近乎免费,这是它成为代理首选语言的原因。
下一章 第22章 我们换用 Rust + tokio,看异步运行时如何在不牺牲性能的前提下榨干每一核。
动手作业:给本章的 HTTP 代理加上
407代理认证(校验Proxy-Authorization),再给 SOCKS5 加上用户名/密码认证(method0x02,RFC 1929)。两个加起来不超过 30 行——你会真正体会到"代理的灵活性"。