第11章 透明代理:iptables REDIRECT/DNAT、TPROXY 与 eBPF 劫持

学习目标

  • 理解透明代理的核心难题:被劫持后,代理如何找回"客户端原本想去哪"
  • 掌握三种劫持机制:iptables REDIRECT/DNAT、TPROXY、eBPF,及其取舍
  • 用 SO_ORIGINAL_DST 亲手取回原始目标,理解 Istio Sidecar 零侵入劫持的底层
  • 学会规避透明代理的头号坑:流量环路

前置知识


原理

透明代理的核心难题

第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/DNATTPROXY
改写包目标✅ 改❌ 不改
取原始目标SO_ORIGINAL_DST(查 conntrack)getsockname() 直接得到
支持 UDP❌(TCP 为主)✅ TCP + UDP
需要策略路由否是(ip rule/ip route)
典型使用Istio 旧版、redsocks、squidIstio 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_DSTREDIRECT 必须查 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 代理享受不到。