目录
背景
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。
- toa 模块会将此函数通过 tcp_v4_syn_recv_sock_toa 函数进行劫持,也就是说第三次握手的 ack 到达协议栈后调用的是 tcp_v4_syn_recv_sock_toa 函数,而不是tcp_v4_syn_recv_sock 。
- 在 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。
- 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