彻底弄懂 Service Mesh 透明代理 TPROXY 的技术原理

在现代微服务架构中, 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.ctproxy 是一个 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.cinet_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

除了自定义路由表,策略路由另外一个重要的组成部分是匹配策略。策略路由提供了很多种类型的匹配规则,比如 fromtotosfwmarkiifoif

比如 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 这两种模式性能差别不大。

相关推荐
向前看-2 小时前
验证码机制
前端·后端
超爱吃士力架4 小时前
邀请逻辑
java·linux·后端
AskHarries5 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion6 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp7 小时前
Spring-AOP
java·后端·spring·spring-aop
TodoCoder7 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
凌虚8 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心9 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
.生产的驴9 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven
顽疲9 小时前
springboot vue 会员收银系统 含源码 开发流程
vue.js·spring boot·后端