toa 核心模組分析

happen發表於2022-03-14

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 = 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 = 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 <sys/socket.h>
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 data 解析到 sk_user_data 變數裡,然後,每次在需要的時候進行相應的替換。

相關文章