Istio的流量劫持和Linux下透明代理实现

一般情况下,如果一个程序需要使用代理服务器,那么需要在运行的时候设置一下参数,或者,在Linux下,大部分的程序支持http_proxy这个环境变量,设置这个环境变量,意味着程序将使用设置值作为代理。
这样的问题在于,设置代理这个操作是不透明的,也就是说,客户端必须要知道代理的存在,需要手动设置将流量导入到代理,如果程序本身不支持代理,或者我们不希望执行所有程序的时候都手动设置代理,那么就需要一个相对“透明”的代理办法了。

同样的,作为ServiceMesh界的当红实现Istio,也会遇到类似的问题,如何在程序完全没有感知的情况下悄无声息的将程序的流量劫持到自己的代理呢?

借助Istio的两种实现方式,也说一下目前Liunx下两种透明代理的实现。

REDIRECT

首先是使用iptables的REDIRECT模式,通过iptables,可以将所有的流量都重定向到一个特定的端口上,如果配置过ss-redir的话,应该会对这种实现非常的熟悉,具体的,在iptables里对应一条规则:

iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-port 5000

即将所有流量都重定向到5000端口,仔细看一下,是不是和iptables实现DNAT有点相似?没错!本质上REDIRECT就是一个特殊的DNAT规则,一般情况下,我们利用iptables做DNAT的时候,需要指定目标的IP和端口,这样iptables才能知道需要将数据包的目的修改成什么,而REDIRECT模式下,只需要指定端口就可以,iptables会自动帮我们判断需要设置的IP地址。

继续思考,会发现另一个问题,那就是,既然是做了DNAT,也就意味着数据包里已经没有原始的目的地址了,那数据包到了代理程序,代理程序是如何知道这个数据包应该往什么地方发送呢?这个是个非常核心的问题,因为如果不知道原始的目的IP端口信息,代理完全不能起作用啊!

当然问题是有办法的,conntrack在这时候起作用了,conntrack会记录原始的地址,而在用户侧,内核提供了一个接口,可以让应用程序获取到原始的IP端口,可以参考一下ss-redir的实现:

static int
getdestaddr(int fd, struct sockaddr_storage *destaddr)
{
    socklen_t socklen = sizeof(*destaddr);
    int error         = 0;

    error = getsockopt(fd, SOL_IPV6, IP6T_SO_ORIGINAL_DST, destaddr, &socklen);
    if (error) { // Didn't find a proper way to detect IP version.
        error = getsockopt(fd, SOL_IP, SO_ORIGINAL_DST, destaddr, &socklen);
        if (error) {
            return -1;
        }
    }
    return 0;
}

利用getsockoptSO_ORIGINAL_DST参数,可以获取到原始的连接IP和端口,好了,目前代理所需要的所有的信息都完整了,整个代理理论上就可以工作了,剩下的就是代理如何实现的问题了,这里就不探讨了。

TPROXY

除了利用REDIRECT模式,Istio还提供TPROXY模式,当然也是借助Linux内核提供的功能实现的,对于TPROXY模式,实现的原理要相对复杂不少,需要借助iptables和路由:

iptables -t mangle -A PREROUTING -p tcp -j TPROXY --tproxy-mark 0x1/0x1 --on-port 8888
ip rule add fwmark 0x1/0x1 pref 100 table 100
ip route add local default dev lo table 100

通过iptables将数据包打上mark,然后使用一个特殊的路由,将数据包指向本地,由于使用了mangle表,所以数据包的原始和目的地址都是不会被修改的。

那么问题来了,应用程序怎么编写?假如需要连接1.2.3.4:80这个端口,就算数据包到了本地,但是本地并没有1.2.3.4这个IP地址啊,程序是怎么能拿到数据的?不是应该直接丢弃这个数据包么?
针对这个问题,可以看一个例子tproxy-example,这个例子实现了一个简单的基于TPROXY的代理。

针对上面的情况,Linux提供了一个选项IP_TRANSPARENT,这个选项很神奇,可以让程序bind一个不属于本机的地址,作为客户端,它可以使用一个不属于本机地址的IP地址作为源IP发起连接,作为服务端,它可以侦听在一个不属于本机的IP地址上,而这正是透明代理所必须的。我们看下例子程序里的代码:

if((listen_fd = socket(res->ai_family, res->ai_socktype,
                res->ai_protocol)) == -1){
    perror("socket: ");
    exit(EXIT_FAILURE);
}

if(setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes))
        == -1){
    perror("setsockopt (SO_REUSEADDR): ");
    close(listen_fd);
    exit(EXIT_FAILURE);
}

//Mark that this socket can be used for transparent proxying
//This allows the socket to accept connections for non-local IPs
if(setsockopt(listen_fd, SOL_IP, IP_TRANSPARENT, &yes, sizeof(yes))
        == -1){
    perror("setsockopt (IP_TRANSPARENT): ");
    close(listen_fd);
    exit(EXIT_FAILURE);
}

if(bind(listen_fd, res->ai_addr, res->ai_addrlen) == -1){
    perror("bind: ");
    close(listen_fd);
    exit(EXIT_FAILURE);
}

if(listen(listen_fd, BACKLOG) == -1){
    perror("listen: ");
    close(listen_fd);
    exit(EXIT_FAILURE);
}

也确实是设置了IP_TRANSPARENT,有了这个选项,也就意味着代理绑定了所有的IP,当然1.2.3.4这个IP也在范围内,所以可以正常的接受连接。
而由于TPROXY模式并没有改变数据包,所以直接通过getsockname获取到原始的IP端口信息:

//Store the original destination address in remote_addr
//Return 0 on success, <0 on failure
static int get_org_dstaddr(int sockfd, struct sockaddr_storage *orig_dst){
    char orig_dst_str[INET6_ADDRSTRLEN];
    socklen_t addrlen = sizeof(*orig_dst);

    memset(orig_dst, 0, addrlen);

    //For UDP transparent proxying:
    //Set IP_RECVORIGDSTADDR socket option for getting the original
    //destination of a datagram

    //Socket is bound to original destination
    if(getsockname(sockfd, (struct sockaddr*) orig_dst, &addrlen)
            < 0){
        perror("getsockname: ");
        return -1;
    } else {
        if(orig_dst->ss_family == AF_INET){
            inet_ntop(AF_INET,
                    &(((struct sockaddr_in*) orig_dst)->sin_addr),
                    orig_dst_str, INET_ADDRSTRLEN);
            fprintf(stderr, "Original destination %s\n", orig_dst_str);
        } else if(orig_dst->ss_family == AF_INET6){
            inet_ntop(AF_INET6,
                    &(((struct sockaddr_in6*) orig_dst)->sin6_addr),
                    orig_dst_str, INET6_ADDRSTRLEN);
            fprintf(stderr, "Original destination %s\n", orig_dst_str);
        }

        return 0;
    }
}

总结

上面就是两种Linux下实现透明代理的方式,透过现象看本质,无论实现方式是什么,其实都定位到一个核心问题,即在没有代理的情况下,连接的五元组是什么?数据包最核心的源地址源端口,目的地址目的端口,无论是通过NAT方式修改数据包重定向,或者借助内核的一些特殊特性,都必须要知道这4个关键信息,一旦搞清楚这些,那理论上代理就能工作了,剩下的就是如何将代理本身做好,那就是一个业务逻辑的问题了。

参考:

  1. https://serverfault.com/questions/179200/difference-beetween-dnat-and-redirect-in-iptables
  2. https://vvl.me/2018/06/09/from-ss-redir-to-linux-nat/
  3. https://blog.csdn.net/dog250/article/details/13161945
  4. https://www.kernel.org/doc/Documentation/networking/tproxy.txt