关于 FullNat 模式的 Toa 实现原理【转】

背景

FullNat 模式的特点,比如跨机房、可运维性强等优势。不过会存在一个问题,在后端服务器上,应用程序能够获取到的请求源 IP 是 lvs 的 LocalIP,并不是真实客户端的 ClientIP。而现在大多数业务都需要对用户信息进行分析画像,也有一些敏感业务需要对用户进行溯源,所以获取用户的真实客户端 IP 地址是非常重要和必要的。

获取client ip的几种方法

有一定流量的业务基本上都要经过负载均衡设备,所以后端服务器要获取客户端真实IP地址,也是常见的问题和需求,这里先罗列几种常见的获取源 IP 的方式:

  • 通过 L3 转发时,源 IP 不变。
    比如 lvs 的 dr、nat、tunnel 模式,后端服务器可以直接获取到真实客户端 IP 地址。
  • 通过 proxy protocol 协议实现源 IP 传递。
    原理是在三次握手后,发送请求数据前,在四层头之后插入一个 proxy protocol 数据包,数据包中可以携带 src ip、src port 等信息,该协议是由 haproxy 提出的,目前常见的 web 服务器都已经支持。
  • 通过 toa 模块获取源 IP。
    在三次握手最后一个 ack 数据包的 tcp option 中插入源 IP 和源 Port 等信息;后端服务器在调用 getpeername 获取源 IP 时读取 tcp option 数据即可获取真实客户端的 IP 地址。
  • 通过七层的 XFF 字段。
    HTTP 协议实现的字段,没什么可说的。业务层自己实现。

几种方案各有优缺点,以及自己适用的应用场景。本文重点要说的是 TOA ,TOA 工作在 L4 层,适用性更通用一些。

toa/uoa 获取client ip

原理

TOA 名字全称是 tcp option address,是 FullNat 模式下能够让后端服务器获取 ClientIP 的一种实现方式,它的基本原理比较简单。

  • 客户端用户请求数据包到达 LVS 时,LVS 在数据包的 tcp option 中插入 src ip 和 src port 信息。
  • 数据包到达后端服务器(装有 toa 模块)后,应用程序正常调用 getpeername 系统函数来获取连接的源端 IP 地址。
  • 由于在 toa 代码中 hook(修改)了 inet_getname 函数(getpeername 系统调用对应的内核处理函数),该函数会从 tcp option 中获取 lvs 填充的 src 信息。
  • 这样后端服务器应用程序就获取到了真实客户端的 ClientIP,而且对应用程序来说是透明的。

tcp option 字段

在这里插入图片描述
client ip 就是放在 tcp option 字段中。option 字段最长 40 字节,每个选项由三部分组成:op-kind、op-length、op-data,我们最常见的 MSS 字段就是在 option 里。
目前 option 使用的 op-kind 并不多,我们只需要构建一个不冲突的 op-kind 就可以把 clientIP 填充进去。IPv4 地址占用 4 个字节,IPv6 占用 16 字节,填充到 option 中是没有问题的。

lvs中的toa格式与插入

格式

首先要确定 toa 的具体数据格式:
(一)IPv4 toa 格式

+----------+----------+--------------------+
|  opcode  |  opsize  |         port       |
+----------+----------+--------------------+
|                clientIP                  |
+------------------------------------------+

各字段含义:

opcode: opcode = 254
opsize: toa 大小 8 字节
port: 客户端端口
clientIP: 客户端 IP(4 字节)
注:opsize 大小包含了自身opsize(2B) + port(2B) + ip(4B)

(二)IPv6 toa 格式

+----------+----------+--------------------+
|  opcode  |  opsize  |         port       |
+----------+----------+--------------------+
|                                          |
|               clientIPv6                 |
|               clientIPv6                 |
|                                          |
+------------------------------------------+

各字段含义:

opcode: opcode = 254
opsize: toa 大小 20 字节
port: 客户端端口
clientIP: 客户端 IP(16 字节)

插入

lvs 需要对每个 tcp 数据包都要插入 toa 信息么?如果这样会影响到 lvs 整体性能的,而且后端服务器也没必要对每个 tcp 数据包进行解析,当然也很影响服务器性能。
其实只需要在第 3 次握手 ack 数据包中插入 toa 选项即可,后端服务器从 ack 数据包中解析并获取即可。

注:
其实syn包中插入 tcp option也是没有什么意义的。因为后端时收到三次握手的ack,才会从ack中获取tcp option的。

后端获取client ip

TCP 协议栈中处理三次握手的 ack 数据包的函数是 tcp_v4_syn_recv_sock,完成连接的建立,并创建 newsock。

  1. toa 模块会将此函数通过 tcp_v4_syn_recv_sock_toa 函数进行劫持,也就是说第三次握手的 ack 到达协议栈后调用的是 tcp_v4_syn_recv_sock_toa 函数,而不是tcp_v4_syn_recv_sock 。
  2. 在 tcp_xx_toa 函数中首先会调用内核原有的处理函数 tcp_v4_syn_recv_sock 函数,这样兼容了那些不是通过 toa 的连接。然后解析 ack 数据包中 tcp option 内容,获取到 lvs 插入到 toa 的 src ip 和 src port 信息,将此信息挂在 newsock 结构变量中。
static struct sock *
tcp_v4_syn_recv_sock_toa(struct sock *sk, struct sk_buff *skb,
            struct request_sock *req, struct dst_entry *dst)
{
    newsock = tcp_v4_syn_recv_sock(sk, skb, req, dst);
    newsock->sk_user_data = get_toa_data_compatible(AF_INET, skb, &nat64);

    return newsock;
}

当应用程序,如 nginx 或 MySQL 调用 getpeername 系统函数时,正常情况会调用 inet_getname 函数来获取连接远端的 ClientIP。

  1. toa 模块对 inet_getname 函数也用 inet_getname_toa 函数进行了劫持,也就是说应用程序调用 getpeername 时,内核对应的处理函数是 inet_getname_toa。
static int
inet_getname_toa(struct socket *sock, struct sockaddr *uaddr,
        int *uaddr_len, int peer)
{
    int retval = 0;
    struct sock *sk = sock->sk;
    struct sockaddr_in *sin = (struct sockaddr_in *) uaddr;
    struct toa_ip4_data tdata;

    // 调用内核原来的函数,兼容那些不是toa的情况
    retval = inet_getname(sock, uaddr, uaddr_len, peer);

    // 如果是toa,则直接从sk->sk_user_data获取数据
    if (retval == 0 && NULL != sk->sk_user_data && peer) {
            memcpy(&tdata, &sk->sk_user_data, sizeof(tdata));
            sin->sin_port = tdata.port;
            sin->sin_addr.s_addr = tdata.ip;
    }

    return retval;
}

总结:

  • hook 三次握手中收到ack后建立连接的函数:tcp_v4_syn_recv_sock:
    将三次握手的ack中携带的 client-ip, client-port 保存到 新建sock 的 sk_user_data 中。
  • hook 后端server的 getpeer 的函数:
    sock 的 sk_user_data 非空时,则getpeer 返回调度时 sock 的 sk_user_data 的数据。

ss/netstat 和 toa的关系

  • ss/netstat -apn 看到的连接的 ip 是否为真实的 client-ip?
    经过测试发现:
    netstat -apn 中看到的 ip 不是client-ip ,而是lvs的 local-ip
    因为netstat 并不是一个真实的server,监听某个端口,然后新建连接,进行getpeer 获取对端的ip,其是通过读取/proc/net下的内核文件来获取所有的连接信息。
(1)安装
> yum install -y nginx
> #which nginx
> #rpm -qf /sbin/nginx
(2) 安装 toa
(3) nginx 日志查看client ip
如下所示:此时看到的 ip 的确是 client-ip,但是netstat 看到的依然是 local-ip;

在这里插入图片描述

注:查看netstat /ss 的原理:
strace -e open netstat -apn ; 发现 netstat 其实是 打开/proc/net/tcp, /proc/net/tcp6,/proc/net/udp, /proc/net/udp6, /proc/net/raw, /proc/net/unix 等文件

其他实现方式

以上 toa 的方式的 两个 hook 函数是 内核中原始存在这样的函数,只不过将原有函数给劫持了, 所以不需要重新执行 nf_register_hook 进行注册 hook函数。
如果需要在netfilter 中添加新的函数,则需要考虑将 新的函数 添加到netfilter 的哪个链中(比如:PREROUTING链),以及对应的优先级的设置(相比较于该链中其他表/函数的优先级,比如:raw/mangle/nat/filter)。
【具体实现可参考 uoa 的实现以及百度 bcettm 的实现】

其他实现方法
比如:直接注册两个 hook 函数,执行 source nat操作,而不是劫持原有的函数。

具体行为是:将sip/sport 替换为 tcp option中的client-ip, client-port, 并且建立 session,后续的包中没有携带 clinet-ip、port,直接查找session,也可以进行snat 替换。

注:此时需要保证 lvs发给后端的 syn 包中携带有 client-ip, client-port 信息,而不仅仅是 三次握手的第三个包中携带有 client-ip, client-port。
这也是为什么 lvs会在 syn 以及三次握手的 ack中都携带有 client-ip、port信息了。因为后端可以通过nat方式,也可通过hook原有的函数的方式来获取 client-ip、port。

参考

https://blog.csdn.net/liwei0526vip/article/details/106108844
  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值