前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >彻底弄懂Service Mesh透明代理 TPROXY

彻底弄懂Service Mesh透明代理 TPROXY

作者头像
挖坑的张师傅
发布2024-07-05 14:58:29
1150
发布2024-07-05 14:58:29
举报
文章被收录于专栏:张师傅的博客张师傅的博客

在现代微服务架构中, 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 容器通信如下形式。

tproxy01

在没有配置任何 iptables 规则时, 容器 B 可以正常访问容器 A 的 8080 端口服务。

代码语言:javascript
复制
» 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,用于做正向和反向代理。

tproxy-00

普通的代理方式存在一些限制,我们有两个朴素且原始的需求:

  • proxy 可以接管所有端口的入流量
  • 让后端对前面有一层代理无感,后端服务可以获取到客户端的真实源 IP

很明显,如果通过普通的代理技术,首先不能很好的监听所有的流量,其次经过代理以后,后端服务的请求源 ip 会变为本机 ip。

为了解决这些问题, 我们需要利用 Linux 内核提供的 TPROXY 功能来实现真正的透明代理。

TPROXY(Transparent Proxy)

TPROXY 需要 Linux 内核 2.2 及以上版本的支持。它允许在用户空间程序中透明地代理流量,使得应用程序无需知道是否存在代理服务器, 流量可以被透明地重定向到代理服务。

此时我们来在 A 容器中新增一条 iptables 规则,将非本地目的地址的 TCP 数据包通过 TPROXY 转发到本机监听的 15006 端口,同时对数据包打上 0x539 的标记(这个标记值你可以自己随意指定,这里保持跟 istio 一致)

代码语言:javascript
复制
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 端口试试。

代码语言:javascript
复制
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.

代码语言:javascript
复制
$ ./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

代码语言:javascript
复制
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 函数:

代码语言:javascript
复制
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 函数的返回值,脚本如下:

代码语言:javascript
复制
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 时:

代码语言:javascript
复制
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,表示这个数据包被丢弃。
代码语言:javascript
复制
#define NF_DROP 0
#define NF_ACCEPT 1

设置 IP_TRANSPARENT 后:

代码语言:javascript
复制
$ 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,表示这个数据包被接受。

完整的代码见:https://github.com/arthur-zhang/mesh-proxy-demo/tree/main/tproxy_step_bind_ip_transparent

下一步是让我们的 tproxy-rs 程序与真正的后端服务建立连接,实现完整的透明代理功能。理想情况下,tproxy-rs 将作为中间人,将流量从客户端无缝转发到 http-server。这个过程可以描述如下:

tproxy-01

为了实现完整的透明代理功能,我们需要让 tproxy-rs 程序执行以下步骤:

  • 接收来自客户端的连接
  • 与后端 http-server 建立新的连接
  • 在两个连接之间转发数据

connect 如何指定源 ip(伪装 IP 地址)

在网络编程中,通常 connect() 操作不需要显式指定源 IP 地址,操作系统会根据路由规则自动选择合适的源 IP。

代码语言:javascript
复制
# 在容器 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 命令将路由添加和查看自定义路由表。

代码语言:javascript
复制
# 新增规则到编号为 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 的自定义路由表,

代码语言:javascript
复制
# 创建一条路由规则到编号为 133 的路由表
$ sudo ip route add local 0.0.0.0/0 dev lo table 133

这条路由表项表示所有目的地为 0.0.0.0/0(即所有地址)的数据包都通过 lo(本地回环接口)处理。

同时增加一条路由策略规则:

代码语言:javascript
复制
# 增加策略路由
$ sudo ip rule add fwmark 0x539 lookup 133

fwmark 0x539 表示匹配防火墙标记为 0x539 的数据包,如果匹配成功, 就查找路由表 133 来确定如何路由这个数据包。

这个时候策略路由是有了,这还不够,我们还需要对包打上标记 0x539,这样才可以命中策略路由规则。

SO_MARK 选项

SO_MARK 是一个强大的套接字选项,它允许我们给通过特定套接字发送的所有数据包打上标记。以下是 SO_MARK 的典型使用方式:

代码语言:javascript
复制
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 规则链中的流转过程。

代码语言:javascript
复制
# 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 规则如下:

代码语言:javascript
复制
创建自定义链
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。经过路由表匹配,系统判定这是一个发往本机的数据包。

tproxy-syn-0

内核回复 SYN+ACK 给对端

内核向对端发送 SYN+ACK 响应包。此时,SYN+ACK 包不携带任何 MARK 标记,conntrack 连接也没有 MARK 标记。因此,该包不会匹配 OUTPUT 链中的任何规则。经过出路由规则判断后,SYN+ACK 包将直接通过 eth0 接口发送出去。

tproxy-syn_ack-0

内核收到对端的 ACK 包

  • 当收到 ACK 包时,包会首先经过 MY_INBOUND 链的第二条规则,并被设置为 MARK 0x539。
  • 随后,包会匹配 MY_INBOUND 链的第三条规则,通过 TPROXY 劫持到 15006 端口。

接着,系统进行路由决策,判断该包的目标地址是否为本机,发往本机继续处理。

tproxy-ack-0

内核收到 HTTP 包内容

内核收到 PSH 包的处理流程与收到 ACK 包的一致。

tproxy-push-0

接下来,我们来看 tproxy-rs 与后端服务器通信的部分,即下图红框所示的部分。

connect 发送 SYN

当我们使用 connect 发送 SYN 包时,会设置 SO_MARK,将包标记为 0x539 MARK。然而,此时 conntrack 的 MARK 仍为空,因此该包不会匹配 OUTPUT 链中的任何规则。

经过路由决策后,SYN 包将通过 lo 接口发出。

tproxy-conncect-syn

内核收到 SYN

上一步发出去的 SYN 包由于是在本机,依旧是本机内核处理,将会经历以下步骤:

  • 命中 MY_INBOUND 的第一条规则,因为包含 0x539 MARK,跳出 MY_INBOUND 链
  • 经过 PREROUTING 链中继续处理,并匹配到 --mark 0x539 规则,执行 save-mark 操作,将包的 MARK 保存到连接的 MARK 中。

完成 PREROUTING 链的处理后,包进入路由规则匹配阶段。系统发现这是一个发往本机的包,随后将其交给本机处理。

tproxy-connect-syn-2

内核回复 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)被当作本地地址来处理。

tproxy-connect-syn-ack-0

内核收到 SYN+ACK

当内核收到 SYN+ACK 包后,将会经过以下 iptables 链的处理:

  • 命中 MY_INBOUND 的第一条规则,跳出 MY_INBOUND 链,继续 PREROUTING 链
  • 在 PREROUTING 链中,包的 MARK 被保存到 conntrack 的 MARK 中

经过策略路由匹配后,包被判定为发往本机,并交由本机处理。

tproxy-conncect-syn_ack2

内核回复 ACK

这个比较简单,过程如下图所示。

tproxy-conncect-ack0

剩下的流程与之前基本上差不多,就不再赘述。至此,我们就把 TPROXY 模式所涉及的方方面面介绍清楚了。

完整代码见:https://github.com/arthur-zhang/mesh-proxy-demo/tree/main/tproxy_step_connect

除 TPROXY 的另外的选择:REDIRECT 模式

相比于 TPROXY 复杂的 iptables 规则,REDIRECT 模式要简单得多。只需要一条规则即可实现:

代码语言:javascript
复制
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 15006

然而,这里存在一个大问题:经过 NAT 后,流量被劫持到 15006 端口的服务时,在代理应用中获取到的 TCP 连接的目标端口变为了我们监听的 15006 端口。

代码语言:javascript
复制
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

代码语言:javascript
复制
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 可以用来获取,典型的用法如下:

代码语言:javascript
复制
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。

代码语言:javascript
复制
$ curl http://172.100.1.2:8080/hello

method: GET
url: /hello
peer addr: 127.0.0.1:41757

完整的代码见:https://github.com/arthur-zhang/mesh-proxy-demo/tree/main/redirect

至此,REDIRECT 模式就介绍结束了。

后记

TPROXY 相比于 REDIRECT 的优势是少了一个 DNAT 的过程,且后端可以获取到真实的客户端 IP,但是实现相对复杂一点点。istio 两个模式都提供了,实测 istio 这两种模式性能差别不大。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-07-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 张师傅的博客 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 实验环境介绍
  • 透明代理的需求
  • TPROXY(Transparent Proxy)
  • IP_TRANSPARENT 介绍
  • IP_TRANSPARENT 的内核代码介绍
  • connect 如何指定源 ip(伪装 IP 地址)
  • 策略路由(Policy-based Routing)
  • SO_MARK 选项
  • 包在 iptables 规则链中的流转全过程
  • 除 TPROXY 的另外的选择:REDIRECT 模式
  • 使用 conntrack 获取原始目标端口
  • 后记
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档