Why

为什么要终结一个已经存在的 tcp 连接?

  1. 强制实施新策略,如安全组、多活规则等。传统的防火墙基于 conntrack 模块(有状态),对于已经存在的连接一般不会实施新策略,比如我们想要即刻关掉某端口的对外开放,新策略只会对新连接生效,老连接则会忽略新策略,与我们预期不符。该场景下,我们可以人为终结不符合预期的连接来落实新策略
  2. 负载均衡,尤其是长连接。某些场景下,负载不均衡了,我们可以通过终结某些连接,让 client 重新发起请求,给 server 一次重新负载均衡的机会。

上一篇文章,我们讲到了如何使用 tcpkill 和 killcx/hping3 来分别关闭活跃和非活跃连接。然而无论是 tcpkill 还是 killcx/hping3,可编程性都比较弱,并且不是内核原生支持,限制较多。
其实我们完全可以用 ebpf 来做这个事,闲话不多说,首先我们就来看看最终效果。

效果

server 监听 9099 端口,client 与其连接并发送消息,稍后运行我们基于 eBPF 的连接终结程序,结果:

可见在 server 侧终结了 tcp 连接,client 侧的连接也被 reset 了。

实现

首先,采用 ebpf 终结 tcp 连接的 idea 来自 lpc1,顺着里面的关键字,我们可以找到对应的 commit2,从中找到了我们需要的 bpf_sock_destroy

这里强调一下:学习 ebpf,Linux 的 selftests 是一个好素材。于是我们去找到对应的 test34,结合相关的文档5构造出了一个在 server 端终结特定 tcp 连接的程序

内核态关键代码:

SEC("iter/tcp")
int iter_tcp_server(struct bpf_iter__tcp *ctx)
{
    struct sock_common *skc = ctx->sk_common;

    if (skc == (void *)0){
        bpf_printk("sk_common not valid");
        return 0;
    }

    if (skc->skc_family != AF_INET){
        return 0;
    }

    int dport = bpf_ntohs(skc->skc_dport);
    int sport = (skc->skc_num);
    bpf_printk("sock_iter,sip4:%pI4n,dip4:%pI4n,sport:%u,dport:%u,server_port:%u,protocol:%u",
        &skc->skc_rcv_saddr,&skc->skc_daddr,sport,dport,server_port,skc->skc_family);
    // dport == 0 means serversocket
    if (sport == server_port && dport > 0){
        bpf_printk("sock_destroy,sport:%u,dport:%u,server_port:%u,cookie:%u",sport,dport,server_port,sock_cookie);
        bpf_sock_destroy(skc);
    }

    return 0;
}

这里面的关键点:

  • 当我们从 ctx 中获得 socket 后,发现是 server 端目标进程(server_port 标识)所属的 socket,即利用 bpf_sock_destroy 终结连接。
  • 引入 bpf_sock_destroy 的 commit 时间是 2023.5,所以要想跑这个demo,内核要非常新(你可以查看这个 commit 合并到的内核版本,我直接升级到了 6.6.0)

用户态关键代码(基于 bpf skeleton):

    skel = killer_bpf__open();
    if (!skel) {
        fprintf(stderr, "Failed to open and load BPF skeleton\n");
        return 1;
    }

    skel->rodata->server_port = env.server_port;
    
    /* Load & verify BPF programs */
    err = killer_bpf__load(skel);
    if (err) {
        fprintf(stderr, "Failed to load and verify BPF skeleton\n");
        goto cleanup;
    }

    struct bpf_link *link;
    char buf[1024] = {};
    int iter_fd = -1, len;
    link = bpf_program__attach_iter(skel->progs.iter_tcp_client,NULL);
    if (!link) {
        fprintf(stderr, "Failed to attach_iter\n");
        goto cleanup;
    }
    iter_fd = bpf_iter_create(bpf_link__fd(link));
    if (iter_fd < 0) {
        fprintf(stderr, "Failed to create_iter\n");
        goto cleanup;
    }

    while (!exiting) {
        sleep(20);
        fprintf(stderr, "Begin to read attach_iter\n");
        while ((len = read(iter_fd, buf, sizeof(buf) - 1)) > 0) {
            buf[len] = 0;
            fprintf(stderr, "%s\n", buf);
          }
    }

cleanup:
    if (iter_fd >= 0)
        close(iter_fd);
    ring_buffer__free(rb);
    bpf_link__destroy(link);
    killer_bpf__destroy(skel);

这里核心有几点:

  • iter/tcp 类型的 ebpf 程序不像 kprobe/uprobe 那样:auto attach 后就等着事件触发回调了,而是需要 bpf_program__attach_iterbpf_link__fdbpf_iter_create 等一系列操作获得一个描述符。然后对这个描述符发起 read 系统调用,从而触发回调(本文的 iter_tcp_server
  • skel->rodata->server_port = env.server_port; 将参数传给内核程序,以便其按需终结 tcp 连接

原理

其实我们从 bpf 程序和 server/client 程序的输出,已经大概能够猜出实现原理了,不过我们还是从源码的角度来分析下。
首先是:

__bpf_kfunc int bpf_sock_destroy(struct sock_common *sock)
{
    struct sock *sk = (struct sock *)sock;

    /* The locking semantics that allow for synchronous execution of the
     * destroy handlers are only supported for TCP and UDP.
     * Supporting protocols will need to acquire sock lock in the BPF context
     * prior to invoking this kfunc.
     */
    if (!sk->sk_prot->diag_destroy || (sk->sk_protocol != IPPROTO_TCP &&
                       sk->sk_protocol != IPPROTO_UDP))
        return -EOPNOTSUPP;

    return sk->sk_prot->diag_destroy(sk, ECONNABORTED);
}

可见目前只对 tcp/udp socket 生效。

然后搜索 ECONNABORTED,得到

#define    ECONNABORTED    103    /* Software caused connection abort */
#define    ECONNRESET    104    /* Connection reset by peer */

这里和 server 与 client 的输出对应上了。

最后,tcp v4 对应的 diag_destroytcp_abort6,关键代码7

    if (!sock_flag(sk, SOCK_DEAD)) {
        WRITE_ONCE(sk->sk_err, err);
        sk_error_report(sk);
        if (tcp_need_reset(sk->sk_state))
            tcp_send_active_reset(sk, GFP_ATOMIC);
        tcp_done(sk);
    }
    static inline bool tcp_need_reset(int state)
    {
        return (1 << state) &
               (TCPF_ESTABLISHED | TCPF_CLOSE_WAIT | TCPF_FIN_WAIT1 |
            TCPF_FIN_WAIT2 | TCPF_SYN_RECV);
    }

可见,本质就是 abort 了本端连接(sk_err),并向对端发送了 reset 报文,实现了连接双向终结的目的,连接活跃与否都可行。

引用


MageekChiu
4.4k 声望1.7k 粉丝

T