在现代微服务架构中, Service Mesh 作为基础设施层为服务间通信提供了强大支持,其中透明代理是一项关键技术,这篇文章做了一个比较细致的分析,彻底弄懂 TPROXY 透明代理/REDIRECT 的技术细节,涉及到下面这些内容:
- service-mesh 中 TPROXY 和 REDIRECT 模式
- 内核提供的透明代理 TPROXY 功能
- 自定义 iptables 规则链
- conntrack 状态跟踪
- IP_TRANSPARENT、SO_MARK 选项
- 自定义路由表、策略路由
- istio 等透明代理的实现原理
- SO_ORIGINAL_DST、NAT 与 conntrack
- systemtap 内核观测
实验环境介绍
为了更好地理解透明代理的工作机制, 我们搭建了以下实验环境:
- 两台主机, 每台运行一个容器
- 容器间通过 Flannel vxlan 进行通信
- 容器 A (IP: 172.100.1.2) 运行一个监听 8080 端口的 HTTP 服务
- 容器 B (IP: 172.100.36.2) 作为 HTTP 请求的发起端
此时的基于 vxlan 的 flannel 容器通信如下形式。
在没有配置任何 iptables 规则时, 容器 B 可以正常访问容器 A 的 8080 端口服务。
shell
>> sudo ip netns exec aaa curl http://172.100.1.2:8080/hello
method: GET
url: /hello
peer addr: 172.100.36.0:60434
这个 8080 端口的 http-server 服务会返回客户端的 IP 地址和端口。
透明代理的需求
在 Service Mesh 方案中,我们需要引入一个 proxy 来做流量的代理,它的角色有点类似于一个 nginx,用于做正向和反向代理。
普通的代理方式存在一些限制,我们有两个朴素且原始的需求:
- proxy 可以接管所有端口的入流量
- 让后端对前面有一层代理无感,后端服务可以获取到客户端的真实源 IP
很明显,如果通过普通的代理技术,首先不能很好的监听所有的流量,其次经过代理以后,后端服务的请求源 ip 会变为本机 ip。
为了解决这些问题, 我们需要利用 Linux 内核提供的 TPROXY 功能来实现真正的透明代理。
TPROXY(Transparent Proxy)
TPROXY 需要 Linux 内核 2.2 及以上版本的支持。它允许在用户空间程序中透明地代理流量,使得应用程序无需知道是否存在代理服务器, 流量可以被透明地重定向到代理服务。
此时我们来在 A 容器中新增一条 iptables 规则,将非本地目的地址的 TCP 数据包通过 TPROXY 转发到本机监听的 15006 端口,同时对数据包打上 0x539 的标记(这个标记值你可以自己随意指定,这里保持跟 istio 一致)
css
iptables -t mangle -A PREROUTING ! -d 127.0.0.1/32 -p tcp -j TPROXY --on-port 15006 --on-ip 0.0.0.0 --tproxy-mark 0x539
此时 B 再次 curl A 容器的服务,现象变为了 B 发送给 A 的 SYN 包没有回复 SYN+ACK,B 一直重传 SYN。
是不是因为 A 容器内并没有一个服务监听 15006 端口,导致没有回复 SYN+ACK 呢,启动一个服务监听 15006 端口试试。
rust
use std::net::SocketAddr;
use nix::sys::socket;
use nix::sys::socket::sockopt;
use tokio::net::{TcpSocket, TcpStream};
const PORT: u16 = 15006;
const LISTENER_BACKLOG: u32 = 65535;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let listen_addr = format!("0.0.0.0:{}", PORT).parse().unwrap();
println!("Listening on: {}", listen_addr);
let socket = TcpSocket::new_v4()?;
// #[cfg(any(target_os = "linux"))]
// socket::setsockopt(&socket, sockopt::IpTransparent, &true)?;
socket.bind(listen_addr)?;
let listener = socket.listen(LISTENER_BACKLOG)?;
while let Ok((mut downstream_conn, _)) = listener.accept().await {
println!("accept new connection, peer[{:?}]->local[{:?}]", downstream_conn.peer_addr()?, downstream_conn.local_addr()?);
tokio::spawn(async move {
// 处理连接,这里调用 sleep
let result = handle_connection(downstream_conn).await;
match result {
Ok(_) => {
println!("connection closed");
}
Err(err) => {
println!("connection closed with error: {:?}", err);
}
}
});
}
Ok(())
}
async fn handle_connection(mut downstream_conn: TcpStream) -> anyhow::Result<()> {
tokio::time::sleep(tokio::time::Duration::from_secs(u64::MAX)).await;
Ok::<(), anyhow::Error>(())
}
B 再次 curl A 容器的服务,现象依旧是一直重传 SYN。
为了解决这个问题,需要介绍另外一个重要的知识点,IP_TRANSPARENT
。
IP_TRANSPARENT
介绍
IP_TRANSPARENT
是一个 Linux 中的 socket 选项, 主要用于实现透明代理功能,它具有以下两个关键作用:
- 接收 TPROXY 重定向的连接:允许应用程序接收通过 iptables TPROXY 规则重定向的连接流量。这使得代理服务器可以无缝拦截和处理原本不是发往它的网络流量
- 绑定到非本地 IP:通常情况下,socket 只能绑定到主机自身的 IP 地址。但启用 IP_TRANSPARENT 选项后,应用程序可以绑定到任意 IP 地址,即使该地址不属于本机网络接口
以下是修改后的 Rust 代码,监听 15006 端口并设置 IP_TRANSPARENT 选项:
在 B 容器再次 curl 以后,此时可以看到三次握手可以成功了。通过日志我们可以看到,目前 tproxy 拿到的客户端 ip 也是正确的,是 B 容器所在节点的 flannel.1
的 ip 172.100.36.0
.
bash
$ ./target/debug/tproxy-rs
Listening on: 0.0.0.0:15006
accept new connection, peer[172.100.36.0:45966]->local[172.100.1.2:8080]
大家可能会注意到,尽管我们的 tproxy-rs 程序实际上监听的是 15006 端口,但连接的本地地址却显示为 8080 端口。这种 "欺骗" 效果正是 IP_TRANSPARENT 选项和 iptables 规则共同作用的结果。
不过因为我们没有真正把流量代理到目标服务,所以 curl 请求的 http 响应是不会返回的。
IP_TRANSPARENT 的内核代码介绍
为什么仅仅给套接字添加 IP_TRANSPARENT 选项就能使握手成功呢?这需要从 linux 内核源码角度去理解,文件位于 net/netfilter/xt_TPROXY.c
。tproxy
是一个 netfilter 框架下一个内核模块,target 处理函数是 tproxy_tg4_v1
:
c
static struct xt_target tproxy_tg_reg[] __read_mostly = {
{
.name = "TPROXY",
.family = NFPROTO_IPV4,
.table = "mangle",
.target = tproxy_tg4_v1,
.revision = 1,
.targetsize = sizeof(struct xt_tproxy_target_info_v1),
.checkentry = tproxy_tg4_check,
.hooks = 1 << NF_INET_PRE_ROUTING,
.me = THIS_MODULE,
},
}
module_init(tproxy_tg_init);
module_exit(tproxy_tg_exit);
MODULE_DESCRIPTION("Netfilter transparent proxy (TPROXY) target module.");
tproxy_tg4_v1
真正调用 tproxy_tg4
函数:
c
static unsigned int
tproxy_tg4(struct net *net, struct sk_buff *skb, __be32 laddr, __be16 lport,
u_int32_t mark_mask, u_int32_t mark_value)
{
const struct iphdr *iph = ip_hdr(skb);
struct udphdr _hdr, *hp;
struct sock *sk;
hp = skb_header_pointer(skb, ip_hdrlen(skb), sizeof(_hdr), &_hdr);
if (hp == NULL)
return NF_DROP;
// 查找是否存在已经建连的 socket
sk = nf_tproxy_get_sock_v4(net, skb, iph->protocol,
iph->saddr, iph->daddr,
hp->source, hp->dest,
skb->dev, NF_TPROXY_LOOKUP_ESTABLISHED);
laddr = nf_tproxy_laddr4(skb, laddr, iph->daddr);
if (!lport)
lport = hp->dest;
if (sk && sk->sk_state == TCP_TIME_WAIT)
sk = nf_tproxy_handle_time_wait4(net, skb, laddr, lport, sk);
else if (!sk)
// 没有找到已经建连的 socket,查找 tproxy 重定向地址/端口的 listener
/* no, there's no established connection, check if
* there's a listener on the redirected addr/port */
sk = nf_tproxy_get_sock_v4(net, skb, iph->protocol,
iph->saddr, laddr,
hp->source, lport,
skb->dev, NF_TPROXY_LOOKUP_LISTENER);
// 如果 tproxy 目标 socket 存在,且设置了 IP_TRANSPARENT,则返回 NF_ACCEPT
if (sk && nf_tproxy_sk_is_transparent(sk)) {
// 设置 skb 的 mark
skb->mark = (skb->mark & ~mark_mask) ^ mark_value;
pr_debug("redirecting: proto %hhu %pI4:%hu -> %pI4:%hu, mark: %x\n",
iph->protocol, &iph->daddr, ntohs(hp->dest),
&laddr, ntohs(lport), skb->mark);
nf_tproxy_assign_sock(skb, sk);
return NF_ACCEPT;
}
// 如果 tproxy 目标 socket 不存在,或者 socket 存在但没有设置 IP_TRANSPARENT,返回 NF_DROP
pr_debug("no socket, dropping: proto %hhu %pI4:%hu -> %pI4:%hu, mark: %x\n",
iph->protocol, &iph->saddr, ntohs(hp->source),
&iph->daddr, ntohs(hp->dest), skb->mark);
return NF_DROP;
}
可以看到这段代码的逻辑是:
- 先找是否有已经建连好的连接
- 没有找到已经建连的 socket,查找 tproxy 重定向地址/端口的 listener
- 如果 tproxy 目标 socket 存在,且设置了 IP_TRANSPARENT,则返回 NF_ACCEPT
- 如果 tproxy 目标 socket 不存在,或者 socket 存在但没有设置 IP_TRANSPARENT,返回 NF_DROP
为了验证我们之前的结论,我们可以使用 SystemTap 来深入分析 tproxy_tg4 函数的行为。通过对比设置和未设置 IP_TRANSPARENT 选项时 tproxy_tg4 函数的返回值,脚本如下:
c
probe begin {
printf("probe begin!\n")
}
probe module("xt_TPROXY").function("tproxy_tg4") {
printf("Entering tproxy_tg4, args: %s\n", $$parms)
iphdr = __get_skb_iphdr($skb);
saddr = format_ipaddr(__ip_skb_saddr(iphdr), %{ AF_INET %})
daddr = format_ipaddr(__ip_skb_daddr(iphdr), %{ AF_INET %})
tcphdr = __get_skb_tcphdr($skb);
dport = __tcp_skb_dport(tcphdr);
sport = __tcp_skb_sport(tcphdr);
printf("[skb]: [src]%s:%d -> [dst]%s:%d\n", saddr, sport, daddr, dport);
}
probe module("xt_TPROXY").function("tproxy_tg4").return {
printf("Exiting tproxy_tg4, return : %d\n", $return);
}
未设置 IP_TRANSPARENT 时:
ini
probe begin!
Entering tproxy_tg4, args: net=0xffff9f7a9b9a3600 skb=0xffff9f7da21c9e00 laddr=0x0 lport=0x9e3a mark_mask=0xffffffff mark_value=0x539
[skb]: [src]172.100.36.0:40756 -> [dst]172.100.1.2:8080
Exiting tproxy_tg4, return : 0
我们可以观察到:
- mark 值确实被设置为 0x539,与我们的预期一致。
- 返回值为 0,对应内核中的 NF_DROP,表示这个数据包被丢弃。
arduino
#define NF_DROP 0
#define NF_ACCEPT 1
设置 IP_TRANSPARENT 后:
ini
$ sudo stap -g tproxy_tg4_test.stp
probe begin!
Entering tproxy_tg4, args: net=0xffff9f7a9b9a3600 skb=0xffff9f7b8c83e200 laddr=0x0 lport=0x9e3a mark_mask=0xffffffff mark_value=0x539
[skb]: [src]172.100.36.0:38938 -> [dst]172.100.1.2:8080
Exiting tproxy_tg4, return : 1
设置 IP_TRANSPARENT 后,tproxy_tg4 返回值为 1,对应内核中的 NF_ACCEPT,表示这个数据包被接受。
完整的代码见:github.com/arthur-zhan...
下一步是让我们的 tproxy-rs 程序与真正的后端服务建立连接,实现完整的透明代理功能。理想情况下,tproxy-rs 将作为中间人,将流量从客户端无缝转发到 http-server。这个过程可以描述如下:
为了实现完整的透明代理功能,我们需要让 tproxy-rs 程序执行以下步骤:
- 接收来自客户端的连接
- 与后端 http-server 建立新的连接
- 在两个连接之间转发数据
connect 如何指定源 ip(伪装 IP 地址)
在网络编程中,通常 connect() 操作不需要显式指定源 IP 地址,操作系统会根据路由规则自动选择合适的源 IP。
bash
# 在容器 A 内请求本机服务
curl http://172.100.1.2:8080/hello
对应的 tcpdump 抓包如下
可以看到此时选择的网络接口是 lo,源 ip 地址是本机 ip(172.100.1.2),这很合理。
为了让后端服务器感知到真实的源 IP (172.100.36.0),我们需要在建立连接时强制指定源 IP。虽然通常 connect 操作不需要指定源 IP,但在这种特殊情况下,我们可以使用 bind 来实现。
运行代码后出现绑定失败的错误:
因为 172.100.36.0 这个 IP 地址并不属于 A 容器的网络命名空间。内核对应的源码如下:在 net/ipv4/af_inet.c
的 inet_bind
函数
我们重点看红框中的部分代码,如果当前 bind 的地址无法被分配,则开始判断:
- 如果系统不允许非本地绑定(sysctl_ip_nonlocal_bind 为 0)
- 且套接字没有设置 freebind 或 transparent 标志
- 且提供的 IP 地址不是 INADDR_ANY
- 且地址类型不是本地、多播或广播
- 则返回 EADDRNOTAVAIL 错误
如果想让 bind 成功,我们可以对 socket 设置 IP_TRANSPARENT 选项。
设置此选项后,socket 将被允许绑定到非本地 IP 地址。
但 curl 并没有正常返回。我们在 A 容器中抓包,发现与 172.100.1.2:8080 的三次握手有问题,从 lo 收到了 SYN 包,回复的 SYN + ACK 是从 eth0 网卡,随后收到了 RST 包。
这个问题的过程如下:
- 我们伪造了源 IP 地址发送请求没有问题。
- 当需要回复 SYN+ACK 包时,内核会查询系统的主路由表来决定使用哪个网卡发送数据包。在这种情况下,由于 172.100.36.0 不是本机的 IP 地址,内核选择了默认路由,即通过 eth0 网卡发送。
为了解决这个问题,我们需要实现一种特殊的处理方式,使内核将 172.100.36.0 视为本机 IP 地址。这就需要用到策略路由(Policy Routing)
策略路由(Policy-based Routing)
根据路由决策的方式不同,路由可以分为
- 策略路由:根据 IP 源地址、端口、报文长度等灵活来进行路由选择
- 普通路由:仅根据报文的目的地址来选择出接口和下一跳的地址
策略路由更加灵活,功能更加强大,比如你可以通过策略路由实现将 SSH 流量通过一个网关发送,而 HTTP 流量通过另一个网关发送,从而实现负载均衡。
策略路由的使用分为两部分:自定义路由表和匹配策略。
Linux 系统默认有三个路由表:
- 本地路由表(Local table):路由表编号 255,由内核自动维护,负责本地接口地址、广播地址的路由
- 主路由表(Main table):路由表编号 254,负责单播目的地的路由,我们
route -n
默认会查这个表 - 默认路由表(Default table):路由表编号 253,一般都是空的
除了上述默认表, 管理员还可以添加自定义路由表, 表 ID 取值范围是 1~252。自定义路由表的创建和使用与内置表没有什么区别,可以使用 ip route 命令将路由添加和查看自定义路由表。
ruby
# 新增规则到编号为 128 的自定义路由表表
$ ip route add 192.168.10.0/24 via 172.100.1.1 dev eth0 table 128
# 查看编号为 128 的自定义路由表
$ ip route list table 128
192.168.10.0/24 via 172.100.1.1 dev eth0
除了自定义路由表,策略路由另外一个重要的组成部分是匹配策略。策略路由提供了很多种类型的匹配规则,比如 from
、to
、tos
、fwmark
、iif
和 oif
。
比如 from
根据数据包的源地址来匹配规则,fwmark
根据数据包的防火墙标记(firewall mark)来匹配规则。
有了上面的基础,我们来看一下策略路由如何在透明代理应用。
以 istio 为例,它创建一个编号为 133 的自定义路由表,
shell
# 创建一条路由规则到编号为 133 的路由表
$ sudo ip route add local 0.0.0.0/0 dev lo table 133
这条路由表项表示所有目的地为 0.0.0.0/0(即所有地址)的数据包都通过 lo(本地回环接口)处理。
同时增加一条路由策略规则:
csharp
# 增加策略路由
$ sudo ip rule add fwmark 0x539 lookup 133
fwmark 0x539 表示匹配防火墙标记为 0x539 的数据包,如果匹配成功, 就查找路由表 133 来确定如何路由这个数据包。
这个时候策略路由是有了,这还不够,我们还需要对包打上标记 0x539,这样才可以命中策略路由规则。
SO_MARK
选项
SO_MARK 是一个强大的套接字选项,它允许我们给通过特定套接字发送的所有数据包打上标记。以下是 SO_MARK 的典型使用方式:
ini
uint32_t mark = 0x539; // 设置标记值为 0x539
setsockopt(sockfd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark));
通过这样的设置,从该套接字发出的所有数据包都会带有 0x539 这个标记。这个标记可以被后续的网络处理过程(如 iptables 规则和路由决策)识别和利用。
我们来测试一下,修改 tproxy-rs 的代码新增这个值。
我们测试一下,实际上没有什么变化。
这是因为我们只是通过 SO_MARK 我们只是对发送的 SYN 包打了标记,回复的 SYN+ACK 并没有这个标记,这样这个回复的 SYN+ACK 就不会命中策略路由,出口路由依旧选择了 eth0。
为了验证一下这个结论,我们先开启 iptables 的 trace 日志。这些规则将记录所有 TCP 数据包在 iptables 规则链中的流转过程。
css
# iptables -t raw -A PREROUTING -p tcp -j TRACE
# iptables -t raw -A OUTPUT -p tcp -j TRACE
通过分析 trace 日志,我们可以看到:
- 发起的 SYN 包:通过 SO_MARK 选项成功地带上了 0x539 标记。
- 回复的 SYN+ACK 包:没有携带 0x539 标记。
作为响应包,SYN+ACK 是由内核自动生成的,没有经过我们的应用程序处理,它没有被设置 SO_MARK。没有标记的 SYN+ACK 包无法匹配我们的策略路由规则,内核使用默认路由表进行路由决策,选择了 eth0 作为出口。
要解决这个问题,我们需要确保 SYN+ACK 包也能带上正确的标记。这可以通过使用 conntrack 模块来实现:
- conntrack 可以跟踪整个连接的状态。
- 我们可以配置 iptables 规则,使用 conntrack 模块保存和恢复连接的标记
首先我们需要弄清楚「连接标记(Connection Mark)」与「数据包标记(Packet Mark)」的区别:
- 数据包标记 (Packet Mark)只应用于单个数据包
- 连接标记 (Connection Mark)存储在连接跟踪表中,跨越整个连接的生命周期,连接跟踪标记通常在数据包进入时被设置。
比如:
-m connmark --mark 0x539
的作用是匹配那些属于入站时被打上 0x539 标记的连接的所有数据包。-m mark --mark 0x539
的作用是匹配被打上 0x539 标记的单个数据包
conntrack 模块提供了几个关键的操作来管理这些标记:
--set-mark / --set-xmark
:设置单个数据包的标记 示例:iptables -t mangle -A PREROUTING -j MARK --set-mark 0x539
--save-mark
:将数据包的标记保存到连接跟踪表中 示例:iptables -t mangle -A PREROUTING -j CONNMARK --save-mark
--restore-mark
:从连接跟踪表中恢复标记到数据包 示例:iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark
接下来就是要用 conntrack 模块匹配数据包的连接状态。使得本来不带 MARK 的 SYN+ACK 包也能打上 MARK,使得包可以走到 133 策略路由规则。
因为 istio 的 iptables 规则为了支持更多的特性比较复杂,为了更清楚的知道透明代理相关的功能,我简化了最需要的几条规则,完整的 iptables 规则如下:
shell
创建自定义链
iptables -t mangle -N MY_INBOUND
# 将所有入站 TCP 流量导向自定义链
iptables -t mangle -A PREROUTING -p tcp -j MY_INBOUND
# 对已标记的包直接返回, 避免重复处理
iptables -t mangle -A MY_INBOUND -p tcp -m mark --mark 0x539 -j RETURN
# 对已建立的连接设置标记
iptables -t mangle -A MY_INBOUND -p tcp -m conntrack --ctstate RELATED,ESTABLISHED -j MARK --set-xmark 0x539/0xffffffff
# 使用 TPROXY 重定向非本地流量到代理端口
iptables -t mangle -A MY_INBOUND ! -d 127.0.0.1/32 -p tcp -j TPROXY --on-port 15006 --on-ip 0.0.0.0 --tproxy-mark 0x539/0xffffffff
# 保存数据包标记到连接
iptables -t mangle -A PREROUTING -p tcp -m mark --mark 0x539 -j CONNMARK --save-mark --nfmask 0xffffffff --ctmask 0xffffffff
# 恢复连接标记到数据包
iptables -t mangle -A OUTPUT -p tcp -m connmark --mark 0x539 -j CONNMARK --restore-mark --nfmask 0xffffffff --ctmask 0xffffffff
ro
通过上面的规则,我们可以做到:
- 入流量被正确标记和重定向
- 连接状态被跟踪
- 出流量能够恢复正确的标记
通过这种配置, 我们可以确保所有相关的数据包, 包括 SYN+ACK, 都能被正确标记并通过策略路由规则进行处理。
包在 iptables 规则链中的流转全过程
我们来梳理一下整个包的过程,先来看前半部分,也就是红框中的流量部分。
内核收到 B 容器发过来的 SYN 包(SRC = 172.100.36.0:12345 DST = 172.100.1.2:8080):
初始时,SYN 包不携带任何 MARK 标记。当该包经过 MY_INBOUND 链时,它匹配到该链的第三条规则。这条规则执行以下操作:
- 将 TCP 包劫持并重定向至 15006 端口
- 为包添加 0x539 MARK ��记
- 终止在 PREROUTING 链中的后续匹配过程
完成 PREROUTING 链的处理后,系统会进行路由判断,以确定该包的目标地址是否为本机。 在本例中,包的目标 IP 地址为 172.100.1.2。经过路由表匹配,系统判定这是一个发往本机的数据包。
内核回复 SYN+ACK 给对端
内核向对端发送 SYN+ACK 响应包。此时,SYN+ACK 包不携带任何 MARK 标记,conntrack 连接也没有 MARK 标记。因此,该包不会匹配 OUTPUT 链中的任何规则。 经过出路由规则判断后,SYN+ACK 包将直接通过 eth0 接口发送出去。
内核收到对端的 ACK 包
- 当收到 ACK 包时,包会首先经过 MY_INBOUND 链的第二条规则,并被设置为 MARK 0x539。
- 随后,包会匹配 MY_INBOUND 链的第三条规则,通过 TPROXY 劫持到 15006 端口。
接着,系统进行路由决策,判断该包的目标地址是否为本机,发往本机继续处理。
内核收到 HTTP 包内容
内核收到 PSH 包的处理流程与收到 ACK 包的一致。
接下来,我们来看 tproxy-rs 与后端服务器通信的部分,即下图红框所示的部分。
connect 发送 SYN
当我们使用 connect 发送 SYN 包时,会设置 SO_MARK,将包标记为 0x539 MARK。然而,此时 conntrack 的 MARK 仍为空,因此该包不会匹配 OUTPUT 链中的任何规则。
经过路由决策后,SYN 包将通过 lo 接口发出。
内核收到 SYN
上一步发出去的 SYN 包由于是在本机,依旧是本机内核处理,将会经历以下步骤:
- 命中 MY_INBOUND 的第一条规则,因为包含 0x539 MARK,跳出 MY_INBOUND 链
- 经过 PREROUTING 链中继续处理,并匹配到 --mark 0x539 规则,执行 save-mark 操作,将包的 MARK 保存到连接的 MARK 中。
完成 PREROUTING 链的处理后,包进入路由规则匹配阶段。系统发现这是一个发往本机的包,随后将其交给本机处理。
内核回复 SYN+ACK
回复的 SYN+ACK 自然是不带 0x539 这个 MARK 的,但由于 conntrack 关联的连接具有该 MARK,SYN+ACK 包会匹配 OUTPUT 链中的条件,触发 --restore-mark 操作,将连接的 MARK 应用到 SYN+ACK 包上。
这样 SYN+ACK 数据包就有了 0x539 这个 MARK,它将在后续的路由匹配中命中我们自定义的 133 路由表。
尽管目的地址(172.100.36.0)本来不是本机地址,SYN+ACK 包本应通过 eth0 接口发出,但由于策略路由的使用,使得原本非本机的目的地址(172.100.36.0)被当作本地地址来处理。
内核收到 SYN+ACK
当内核收到 SYN+ACK 包后,将会经过以下 iptables 链的处理:
- 命中 MY_INBOUND 的第一条规则,跳出 MY_INBOUND 链,继续 PREROUTING 链
- 在 PREROUTING 链中,包的 MARK 被保存到 conntrack 的 MARK 中
经过策略路由匹配后,包被判定为发往本机,并交由本机处理。
内核回复 ACK
这个比较简单,过程如下图所示。
剩下的流程与之前基本上差不多,就不再赘述。至此,我们就把 TPROXY 模式所涉及的方方面面介绍清楚了。
完整代码见: github.com/arthur-zhan...
除 TPROXY 的另外的选择:REDIRECT 模式
相比于 TPROXY 复杂的 iptables 规则,REDIRECT 模式要简单得多。只需要一条规则即可实现:
css
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 15006
然而,这里存在一个大问题:经过 NAT 后,流量被劫持到 15006 端口的服务时,在代理应用中获取到的 TCP 连接的目标端口变为了我们监听的 15006 端口。
css
accept new connection, peer[172.100.36.0:39882]->local[172.100.1.2:15006]
这样一来,我们如何知道将这个请求转发到后端服务的哪个端口呢?
使用 conntrack 获取原始目标端口
NAT 的功能实际上是通过 conntrack 实现的,我们可以通过 conntrack 来获取原始的目标端口。
通过查看 conntrack,我们可以看到如下映射:172.100.36.0:39882<->172.100.1.2:8080
被 NAT 到 172.100.1.2:15006 <-> 172.100.36.0:39882
css
conntrack -L
tcp 6 431997 ESTABLISHED src=172.100.36.0 dst=172.100.1.2 sport=39882 dport=8080 src=172.100.1.2 dst=172.100.36.0 sport=15006 dport=39882 [ASSURED] mark=0 use=1
通过这种方式,我们可以确定将请求转发到后端服务的正确端口。
不过我们不需要直接操作 conntrack,socket 提供了一个 api 可以用来获取,典型的用法如下:
c
struct sockaddr_in orig_dst;
socklen_t orig_dst_len = sizeof(orig_dst);
getsockopt(sock, SOL_IP, SO_ORIGINAL_DST, &orig_dst, &orig_dst_len);
printf("Original destination: %s:%d\n", inet_ntoa(orig_dst.sin_addr), ntohs(orig_dst.sin_port));
这个功能是由内核 netfilter conntrack 提供,对应的与源码如下。
于是我们可以修改对应的 rust 代码:
这样我们就可以实现了代理的功能,不过这个时候后端获取的来源 ip 是本地地址 127.0.0.1。
vbnet
$ curl http://172.100.1.2:8080/hello
method: GET
url: /hello
peer addr: 127.0.0.1:41757
完整的代码见:github.com/arthur-zhan...
至此,REDIRECT 模式就介绍结束了。
后记
TPROXY 相比于 REDIRECT 的优势是少了一个 DNAT 的过程,且后端可以获取到真实的客户端 IP,但是实现相对复杂一点点。istio 两个模式都提供了,实测 istio 这两种模式性能差别不大。