toa 内核模块分析

TOA 的由来 我们知道 LVS 之前有三种负载均衡模式:DR、NAT 和 Tunnel,但都有各自的缺陷,比如 DR 和 NAT 要求 virtual server 与 real server 在同一子网下,而 Tunnel 运维起来比较复杂。因此,为了灵活部署,开发了第四种模式,即 FULLNAT。
FULLNAT 模式是 NAT 模式的一种扩展,不仅会替换目的 IP,也会替换源 IP。带来的好处是,使得 virtual server 和 real server 摆脱后端网络的束缚,不再要求它们位于同一子网下。
但是,这种模式也带来了一个问题,real server 无法获取真实的客户端 IP 地址,而在很多业务场景下,我们在对外提供服务时,需要检查服务请求方的 IP 地址,来针对IP地址做一些业务处理,最常见的一个例子就是:做白名单校验,只有在白名单列表中的 IP 地址,我们才允许它访问我们的服务;还有一种应用场景,那就是基于客户端的请求 IP 来进行调度,譬如 CDN 服务,那么就需要根据客户端的请求 IP,来调度最近最适合的资源提供服务。
为了解决上述问题,TOA 应运而生,它实际是一个 TCP option filed,使用了 8 字节(kind = 0xfe,Length = 0x08,Value = https://www.it610.com/article/4B client's IP + 2B port),源码如下,

/* MUST be 4 bytes alignment */ struct toa_data { __u8 opcode; __u8 opsize; __u16 port; __u32 ip; };

服务端机器打上 patch 后,在 lvs FULLNAT 模式下能够通过系统调用 getsockopt 拿到真实的 client IP 地址。
TOA 的使用 为了支持 TOA,FULLNAT 直接修改了内核代码,如果要重新编译内核,那使用起来就很麻烦了,我们可以以 .ko 文件的形式加载到内核,通过以下命令查看当前机器是否加载了 toa 模块,
lsmod | grep toa

toa 模块的编译可以参考文档 TOA插件配置。
TOA 的实现原理 TOA 主要通过 hook 系统函数,进而从 tcp option 解析出 toa data。
注意:以下说明中用到的 linux 源码版本为 3.2.101。
toa_init 函数是 toa 模块的初始化函数,
/* module init */ static int __init toa_init(void) { ... /* hook funcs for parse and get toa */ hook_toa_functions(); ... }

以上省略了一些处理细节,重点代码是 hook 的处理函数 hook_toa_functions,以 ipv4 协议为例进行说明。
/* replace the functions with our functions */ static inline int hook_toa_functions(void) { /* hook inet_getname for ipv4 */ struct proto_ops *inet_stream_ops_p = (struct proto_ops *)&inet_stream_ops; /* hook tcp_v4_syn_recv_sock for ipv4 */ struct inet_connection_sock_af_ops *ipv4_specific_p = (struct inet_connection_sock_af_ops *)&ipv4_specific; ... inet_stream_ops_p->getname = inet_getname_toa; ... ipv4_specific_p->syn_recv_sock = tcp_v4_syn_recv_sock_toa; return 0; }

在linux 源码中 ipv4 协议各处理函数有如下定义,
/* net/ipv4/tcp_ipv4.c */ const struct inet_connection_sock_af_ops ipv4_specific = { .. .send_check= tcp_v4_send_check, .conn_request= tcp_v4_conn_request, .syn_recv_sock= tcp_v4_syn_recv_sock, .get_peer= tcp_v4_get_peer, }; EXPORT_SYMBOL(ipv4_specific);

stream 类型 socket 各处理函数有如下定义,
/* net/ipv4/af_inet.c */ const struct proto_ops inet_stream_ops = { .family= PF_INET, .bind= inet_bind, .connect= inet_stream_connect, .accept= inet_accept, .getname= inet_getname, .listen= inet_listen, .shutdown= inet_shutdown, ... }; EXPORT_SYMBOL(inet_stream_ops);

结合 linux 源码和 toa 代码,发现了两个关键 hook:
  • syn_recv_sock 函数指针 tcp_v4_syn_recv_sock -> tcp_v4_syn_recv_sock_toa
  • getname 函数指针 inet_getname -> inet_getname_toa
syn_recv_sock 调用
syn_recv_sock 函数在 server 收到第三次握手的 ack 包后触发调用逻辑,调用路径为 tcp_v4_do_rcv -> tcp_v4_hnd_req -> tcp_check_req -> syn_recv_sock。
/* net/ipv4/tcp_minisocks.c */ struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb, struct request_sock *req, struct request_sock **prev) { ... child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL); if (child == NULL) goto listen_overflow; ... }

另外,在阅读这部分 linux 源码时发现,server socket 在收到第三次握手时状态仍为 TCP_LISTEN。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb) { ... if (sk->sk_state == TCP_LISTEN) { struct sock *nsk = tcp_v4_hnd_req(sk, skb); if (!nsk) goto discard; /* 在第三次握手时产生了一个新的 socket,进入该逻辑 */ if (nsk != sk) { sock_rps_save_rxhash(nsk, skb); if (tcp_child_process(sk, nsk, skb)) { rsk = nsk; goto reset; } return 0; } } ... }

第三次握手会产生一个新 socket,初始状态为 TCP_SYN_RECV,随后转换成 TCP_ESTABLISHED。
下面来看一下替代函数 tcp_v4_syn_recv_sock_toa 代码逻辑,
static struct sock * tcp_v4_syn_recv_sock_toa(struct sock *sk, struct sk_buff *skb, struct request_sock *req, struct dst_entry *dst) { struct sock *newsock = NULL; /* 先走原有的逻辑 */ newsock = tcp_v4_syn_recv_sock(sk, skb, req, dst); /* 解析 toa data 放到 newsock->sk_user_data */ if (NULL != newsock && NULL == newsock->sk_user_data) { newsock->sk_user_data = https://www.it610.com/article/get_toa_data(skb); .. } return newsock; }

解析 toa data 的函数为 get_toa_data,代码关键是找到 tcp option 的相应字段并解析到一个 toa_data 类型的变量 sk_user_data 里,这里不展开分析。
inet_getname 调用
当我们需要从 socket 里拿 client ip 时,就会调用到 inet_getname 函数。
一种使用方式是通过 accept 系统调用。
#include int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict addrlen);

如果传入 sockaddr 类型变量,就会触发 inet_getname 函数调用逻辑,
/* net/socket.c */ SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr, int __user *, upeer_addrlen, int, flags) { ... if (upeer_sockaddr) { if (newsock->ops->getname(newsock, (struct sockaddr *)&address, &len, 2) < 0) { err = -ECONNABORTED; goto out_fd; } ... } ... }

另外,也可以通过 getpeernamegetsockopt 等系统调用触发。
那么,下面来看一下替代函数 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_data tdata; /* 调用原来的逻辑 */ retval = inet_getname(sock, uaddr, uaddr_len, peer); /* sk_user_data 有数据会进行数据拷贝 */ if (retval == 0 && NULL != sk->sk_user_data && peer) { if (sk_data_ready_addr == (unsigned long) sk->sk_data_ready) { memcpy(&tdata, &sk->sk_user_data, sizeof(tdata)); if (TCPOPT_TOA == tdata.opcode && TCPOLEN_TOA == tdata.opsize) { sin->sin_port = tdata.port; sin->sin_addr.s_addr = tdata.ip; } ... } ... } return retval; }

当 sk_user_data 变量里有数据时,且未 toa 数据时,会替换相应的 ip 和 port,这样就能拿到正常的 client ip 和 port 了。
【toa 内核模块分析】通过以上分析可以看到,toa 模块的工作模式是,在第三次握手时,将 toa data 解析到 sk_user_data 变量里,然后,每次在需要的时候进行相应的替换。

    推荐阅读