第11章 透明代理:iptables REDIRECT/DNAT、TPROXY 与 eBPF 劫持
学习目标
- 理解透明代理的核心难题:被劫持后,代理如何找回"客户端原本想去哪"
- 掌握三种劫持机制:iptables REDIRECT/DNAT、TPROXY、eBPF,及其取舍
- 用
SO_ORIGINAL_DST亲手取回原始目标,理解 Istio Sidecar 零侵入劫持的底层 - 学会规避透明代理的头号坑:流量环路
前置知识
- 第01章 透明代理、第02章 L3 转发与 NAT
- 网络手册 · Netfilter 与防火墙(iptables 表/链)
- 后续 第26章 Sidecar 流量劫持 是本章在 K8s 的落地
原理
透明代理的核心难题
第01章 说过:透明代理对客户端零配置、零感知,靠内核把流量"拐"进代理端口。但这立刻带来一个棘手问题:
客户端的包本来要去
93.184.216.34:443,被内核劫持改道到了本地代理:15001。代理accept()到这条连接后,用getpeername()只能看到客户端地址,用getsockname()看到的是自己的:15001——那客户端原本想去哪,丢了!
代理必须有办法找回"原始目标地址",否则它不知道该把流量转给谁。三种劫持机制的差异,核心就在怎么保存和取回这个原始目标。
机制一:iptables REDIRECT(NAT 表)
REDIRECT 是 DNAT 的特例——把目标地址改写成"本机的某个端口":
# 把本机发往外部 80 端口的 TCP,重定向到本地代理 3128
iptables -t nat -A OUTPUT -p tcp --dport 80 -j REDIRECT --to-port 3128
# 网关场景用 PREROUTING(劫持流经本机的别人的流量)
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3128
怎么取回原始目标? REDIRECT 是 NAT,内核 conntrack 记住了"改写前的原始目标"。代理用 getsockopt(SO_ORIGINAL_DST) 向 conntrack 查回来:
内核 conntrack 表:[原始: ...→93.184.216.34:80] [改写后: ...→127.0.0.1:3128]
代理 getsockopt(fd, SOL_IP, SO_ORIGINAL_DST) ──查──▶ 拿回 93.184.216.34:80 ✅
- 优点:简单、广泛支持
- 局限:只支持 TCP(UDP 没有连接,conntrack 取原始目标的方式不适用 REDIRECT);改写了包;只能重定向到本机
机制二:TPROXY(mangle 表,不改包)
TPROXY 是为透明代理量身设计的:不做 NAT、不改写目标地址,而是把包"标记"后交给一个绑定了 IP_TRANSPARENT 的本地 socket。
# mangle 表 PREROUTING:打标记 + 投递到本地 50080,但不改包目标
iptables -t mangle -A PREROUTING -p tcp --dport 80 \
-j TPROXY --on-port 50080 --tproxy-mark 0x1/0x1
# 策略路由:把带标记的包导向本地(lo)处理
ip rule add fwmark 0x1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
代理 socket 要开 IP_TRANSPARENT,这样它既能收到目标不是本机的包,又能用 getsockname() 直接拿到原始目标(因为包没被改写)。
| 对比 | REDIRECT/DNAT | TPROXY |
|---|---|---|
| 改写包目标 | ✅ 改 | ❌ 不改 |
| 取原始目标 | SO_ORIGINAL_DST(查 conntrack) | getsockname() 直接得到 |
| 支持 UDP | ❌(TCP 为主) | ✅ TCP + UDP |
| 需要策略路由 | 否 | 是(ip rule/ip route) |
| 典型使用 | Istio 旧版、redsocks、squid | Istio TPROXY 模式、需要 UDP 时 |
一句话:REDIRECT 简单但改包、只 TCP;TPROXY 复杂但不改包、支持 UDP、保留原始目标更干净。
机制三:eBPF(Cilium,绕过 iptables)
iptables 在规则多时性能差(线性匹配)、且 NAT/conntrack 有开销。Cilium 用 eBPF 在更靠近 socket 的位置做重定向:
- socket 层:用
bpf_sk_assign/sockmap,在连接建立时直接把流量定向到代理 socket,连 NAT 都不需要 sockmap/sk_msg:同主机两个 socket 间数据直接在内核 redirect,绕过 TCP/IP 栈(也是 第12章 的零拷贝话题)- 优势:无 iptables 规则膨胀、低开销、可编程;是 Istio Ambient(无 sidecar)/ Cilium Service Mesh 的基础(第26章)
详见 网络手册 · Cilium 与 eBPF、eBPF 深度实践。
Istio Sidecar 劫持:透明代理的教科书级应用
这是透明代理最重要的现实落地。Istio 给 Pod 注入一个 istio-init init-container,它在 Pod 的网络命名空间里用 iptables 把所有进出流量劫持到 Envoy:
Pod netns 内
┌─────────────────────────────────────────────┐
│ 业务容器 Envoy sidecar │
│ │ 出站流量 ▲ :15001(出) ▲ │
│ ▼ │ :15006(入) │ │
│ [iptables REDIRECT] ───────┘ │ │
│ 出站→15001,入站→15006,由 Envoy 接管 │ │
└─────────────────────────────────────────────┘
业务代码完全无感(零侵入),但所有流量都过了 Envoy——于是 mTLS、路由、重试、遥测(第29章)全平台接管。关键细节:Envoy 自己发出的流量必须排除,否则无限环路——见下。
头号坑:流量环路与如何规避
透明代理最危险的陷阱:代理自己转发出去的流量,又被同一条劫持规则抓回来,形成无限环路。规避靠"按发起者身份排除代理自己":
# Istio 的做法:排除 Envoy 进程的 UID(1337),它的流量不再被劫持
iptables -t nat -A OUTPUT -m owner --uid-owner 1337 -j RETURN
# 普通透明代理同理:用 --uid-owner / fwmark / cgroup 把"代理自己的出流量"放行
这就是为什么 Istio 让 Envoy 以专用 UID 1337 运行——为了在 iptables 里能用
--uid-owner精确地"放过自己",斩断环路。
️ 实现 / 命令
实验一:REDIRECT 劫持 + 用 SO_ORIGINAL_DST 取回原始目标
一个透明代理(Go)取原始目标的核心代码:
import (
"net"
"syscall"
"unsafe"
)
const SO_ORIGINAL_DST = 80
// 从被 REDIRECT 劫持的 TCP 连接里,取回客户端原本想访问的目标
func originalDst(conn *net.TCPConn) (string, error) {
raw, err := conn.SyscallConn()
if err != nil {
return "", err
}
var addr syscall.RawSockaddrInet4
size := uint32(unsafe.Sizeof(addr))
var gerr error
raw.Control(func(fd uintptr) {
// 向 conntrack 查询改写前的原始目标
_, _, e := syscall.Syscall6(syscall.SYS_GETSOCKOPT, fd,
syscall.SOL_IP, SO_ORIGINAL_DST,
uintptr(unsafe.Pointer(&addr)), uintptr(unsafe.Pointer(&size)), 0)
if e != 0 {
gerr = e
}
})
ip := net.IPv4(addr.Addr[0], addr.Addr[1], addr.Addr[2], addr.Addr[3])
port := int(addr.Port&0xff)<<8 | int(addr.Port>>8) // 网络字节序转主机序
return net.JoinHostPort(ip.String(), strconv.Itoa(port)), gerr
}
配上劫持规则就能跑(注意用 --uid-owner 放过代理自己防环路):
# 让本机出站 80 进入透明代理 3128,但放过代理进程自己(假设其 uid=1000)
iptables -t nat -A OUTPUT -p tcp --dport 80 -m owner ! --uid-owner 1000 -j REDIRECT --to-port 3128
curl http://example.com/ # 注意:没有 -x!客户端零配置
实验二:查看 Istio 注入的劫持规则
# 在被注入 sidecar 的 Pod 里看 iptables(Istio 生成的 ISTIO_* 链)
kubectl exec <pod> -c istio-proxy -- iptables -t nat -L -n -v
# 关键链:
# ISTIO_OUTPUT → 出站流量 REDIRECT 到 15001(放过 uid 1337)
# ISTIO_INBOUND → 入站流量 REDIRECT 到 15006
# -m owner --uid-owner 1337 -j RETURN ← 放过 Envoy 自己,防环路
实验三:TPROXY 透明代理 UDP(概念验证)
# TPROXY 能劫持 UDP(REDIRECT 不行)
iptables -t mangle -A PREROUTING -p udp --dport 53 \
-j TPROXY --on-port 5353 --tproxy-mark 0x1/0x1
ip rule add fwmark 0x1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
# 代理 socket 需 setsockopt(IP_TRANSPARENT) 才能收非本机目标的包并拿到原始目标
排错
| 现象 | 根因 | 解决 |
|---|---|---|
| 代理拿不到原始目标,全转到自己 | 用 getsockname() 而非 SO_ORIGINAL_DST | REDIRECT 必须查 conntrack 取原始目标 |
| 连接无限循环、CPU 打满 | 代理自身流量又被劫持 | --uid-owner/fwmark 放过代理自己 |
| UDP 劫持不生效 | 用了 REDIRECT(不支持 UDP) | 改用 TPROXY |
| TPROXY 配了不通 | 缺策略路由 | 补 ip rule add fwmark + ip route ... table |
SO_ORIGINAL_DST 返回代理自己地址 | conntrack 没记录(包没走 NAT) | 确认 REDIRECT 规则命中、conntrack 开启 |
| Pod 里 sidecar 没拦到流量 | init-container 未注入/失败 | 查 istio-init 日志、命名空间注入标签 |
| iptables 规则多导致延迟高 | NAT/conntrack + 线性匹配开销 | 迁移到 eBPF(Cilium) |
本章小结
- 透明代理的核心难题是找回原始目标:REDIRECT 靠
SO_ORIGINAL_DST查 conntrack,TPROXY 靠不改包 +getsockname()。 - 三种劫持:REDIRECT/DNAT(简单、改包、TCP 为主)、TPROXY(不改包、支持 UDP、要策略路由)、eBPF(绕 iptables、低开销、Ambient/Cilium 基础)。
- Istio Sidecar 用 init-container 在 Pod netns 里 iptables 劫持全流量到 Envoy,业务零侵入。
- 流量环路是头号坑,靠
--uid-owner等"放过代理自己"斩断(Istio 的 UID 1337 即为此)。
下一章 第12章 数据搬运的艺术,我们钻到代理性能的最底层:字节到底怎么在两个 socket 间移动,splice/sendfile/io_uring/eBPF 如何做到零拷贝,以及为什么 L7 代理享受不到。