透明代理的旁路由实现、原理和常见的误区

首先介绍旁路由是什么,旁路由也叫旁路网关,比较典型的架构是通过一个单网口的设备(N1盒子、开发板等)接入主路由,然后在主路由的的DHCP选项里将LAN口网关设置为该设备的IP(也就是所谓的“网关互指”,其实根本就没有互指,主路由的网关依然是原来的网关,只是接入主路由的设备的网关变成了旁路由),流量经由该设备分流,实现科学研究的目的。

提醒,使用旁路由时必须关闭主路由的硬件加速,否则会出现各种各样的问题!!!

具体的网络拓扑如下图所示。

img

(图片来自: Openwrt 作为旁路网关(不是旁路由、单臂路由)的终极设置方法,破解迷思 - 少数派 (sspai.com)

再分析流量的走向,在理想情况下,我们会有下图的流量走向。

旁路由架构的数据流转示意图

(图片来自: 旁路由的原理与配置一文通 - Eason Yang’s Blog

可是事实真的如此么?其实不然,要达到这种理想情况,目前大部分的教程给出的方法都多多少少存在的问题,实际上的网络走向不会是这样,下行流量也会经过旁路由,导致跑国内 speedtest 的时候旁路由的利用率暴涨。可以用 htop 或者 btop观察到这一点。

造成这种情况的有两种原因:

  1. 在旁路由上进行了SNAT,导致所有的连接都必须经过旁路由,即iptables -t nat -I POSTROUTING -j MASQUERADE规则
  2. 透明代理的实现原理决定了实现透明代理必须将两条TCP流拼接在一起(UDP未验证,不是本文的重点),这样所有的连接都也必须经过旁路由

第一点的情况比较常见,其实也是情有可原,因为如果不在旁路由上开启SNAT,很可能主路由对流量的处理会出现问题,即:不开启透明代理的情况下无法访问WAN区域的主机。

造成此情况的原因有以下:

接下来说说第二点,即在正确设置了上面的内核参数的情况下,为什么下行流量还是会经过旁路由。

为了证明这一点,我们需要了解透明代理的原理:

简单来说,透明代理就相当于DNAT,其将流量重定向到本地的服务,由本地的服务再发起真正的连接,然后其将两条连接拼在一起,起到了中间人的作用。

C语言示例:

#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <linux/netfilter_ipv4.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>

#define PORT 12345
#define ADDR "127.0.0.1"
#define BUFFER_SIZE 4096

/*
# 设置策略路由
ip rule add fwmark 1 table 100
ip route add local 0.0.0.0/0 dev lo table 100

# PREROUTING
iptables -t mangle -N V2RAY
iptables -t mangle -A V2RAY -d 127.0.0.1/32 -j RETURN # 透明代理响应局域网(本机)的请求
iptables -t mangle -A V2RAY -d 192.168.0.0/16 -j RETURN # 透明代理响应局域网(本机)的请求
iptables -t mangle -A V2RAY -p tcp -j TPROXY --on-port 12345 --tproxy-mark 1 # 给 TCP 打标记 1,转发至 12345 端口
iptables -t mangle -A PREROUTING -j V2RAY # 应用规则

# OUTPUT
iptables -t mangle -N V2RAY_MASK
iptables -t mangle -A V2RAY_MASK -j RETURN -m mark --mark 0xff    # 直连 SO_MARK 为 0xff 的流量,此规则目的是避免代理本机(网关)流量出现回环问题
iptables -t mangle -A V2RAY_MASK -p tcp -j MARK --set-mark 1   # 给 TCP 打标记,重路由
iptables -t mangle -A OUTPUT -j V2RAY_MASK # 应用规则
 */

void forward_data(int from_sock, int to_sock)
{
	char buffer[BUFFER_SIZE];
	ssize_t bytes_read;

	while ((bytes_read = read(from_sock, buffer, sizeof(buffer))) > 0)
	{
		ssize_t bytes_written = write(to_sock, buffer, bytes_read);
		if (bytes_written <= 0)
		{
			perror("write");
			break;
		}
	}

	close(from_sock);
	close(to_sock);
}

void handle_client(int client_sock, struct sockaddr_in client_addr)
{
	int server_sock;
	struct sockaddr_in intended_dest_addr;
	socklen_t dest_addr_len = sizeof(intended_dest_addr);

	server_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (server_sock == -1)
	{
		perror("error creating server socket");
		close(client_sock);
		return;
	}

	// set so_mark
	int mark = 0xff;
	setsockopt(server_sock, SOL_SOCKET, SO_MARK, &mark, sizeof(mark));

	getsockname(client_sock, (struct sockaddr *)&intended_dest_addr, &dest_addr_len);
	// REDIRECT mode:
	// getsockopt (client_fd, SOL_IP, SO_ORIGINAL_DST, &intended_dest_addr, &dest_addr_len);

	printf("they think they're talking to %s:%d\n",
		   inet_ntoa(intended_dest_addr.sin_addr),
		   ntohs(intended_dest_addr.sin_port));

	if (connect(server_sock, (struct sockaddr *)&intended_dest_addr, dest_addr_len) == -1)
	{
		perror("error connecting to destination server");
		close(client_sock);
		close(server_sock);
		return;
	}

	// Create separate threads or processes to handle data forwarding in both directions
	if (fork() == 0)
	{
		forward_data(client_sock, server_sock);
		exit(0);
	}

	if (fork() == 0)
	{
		forward_data(server_sock, client_sock);
		exit(0);
	}

	exit(0);
}

int main(void)
{
	signal(SIGCHLD, SIG_IGN); // kernel reap the exited child

	int listener_fd = socket(AF_INET, SOCK_STREAM, 0);

	int value = 1;
	setsockopt(listener_fd, SOL_IP, IP_TRANSPARENT, &value, sizeof(value)); // 允许发送来源地址非本机的数据报

	struct sockaddr_in name;
	name.sin_family = AF_INET;						 // TCP
	name.sin_port = htons(PORT);					 // 本地监听端口
	inet_pton(AF_INET, ADDR, &name.sin_addr.s_addr); // 绑定的IP地址

	if (bind(listener_fd, (struct sockaddr *)&name, sizeof(name)) < 0)
		perror("bind failed");

	if (listen(listener_fd, 10) < 0)
		perror("listen failed");

	printf("now TPROXY-listening on %s:%d", ADDR, PORT);
	printf("...\nbut actually accepting any TCP SYN with dport 80, regardless of"
		   " dest IP, that hits the loopback interface!\n\n");

	while (1)
	{
		struct sockaddr_in client_addr;
		socklen_t client_addr_len = sizeof(client_addr);

		int client_fd = accept(listener_fd, (struct sockaddr *)&client_addr,
							   &client_addr_len);
		pid_t pid = fork();
		if (pid < 0)
		{
			perror("Error forking");
			exit(1);
		}
		else if (pid == 0)
		{
			printf("accepted socket from %s:%d; ", inet_ntoa(client_addr.sin_addr),
				   ntohs(client_addr.sin_port));
			puts("handling requst");
			handle_client(client_fd, client_addr);
		}
	}
}

测试:

➜  ~ sudo ./transparent_proxy
now TPROXY-listening on 127.0.0.1:12345...
but actually accepting any TCP SYN with dport 80, regardless of dest IP, that hits the loopback interface!

accepted socket from 192.168.204.134:49852; handling requst
they think they're talking to 1.1.1.1:80
➜  ~ curl 1.1.1.1
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>

旁路由真机验证:

使用 https://10000.gd.cn/ 测速,bashtop可以看到跑满流量。

image-20230813230103577

解决办法:

使用ipset前置分流,对于大陆IP的流量直接由内核转发,不流入透明代理。

v2raya hooks:

#!/bin/bash
# parse the arguments
for i in "$@"; do
        case $i in
                --transparent-type=*)
                        TYPE="${i#*=}"
                        shift
                        ;;
                --stage=*)
                        STAGE="${i#*=}"
                        shift
                        ;;
                --v2raya-confdir=*)
                        CONFDIR="${i#*=}"
                        shift
                        ;;
                -*|--*)
                        echo "Unknown option $i"
                        shift
                        ;;
                *)
                        ;;
        esac
done

# print $TYPE, $STAGE and $CONFDIR
echo "Transparent Type = ${TYPE}"
echo "Stage            = ${STAGE}"
echo "Config Directory = ${CONFDIR}"

if [ "$STAGE" == "post-stop" ]; then
        #清除规则
        echo "chnroute: purging rules"
        iptables -t nat -D TP_RULE  -m set --match-set chnroute dst -j RETURN 2>/dev/null
        exit 0
elif [ "$STAGE" == "post-start" ]; then
        echo "chnroute: adding rules"
        sleep 1
        #创建规则
        iptables -t nat -I TP_RULE  -m set --match-set chnroute dst -j RETURN 2>/dev/null
fi

创建ipset:

#!/usr/bin/env bash
set -ex

GREEN='\033[0;32m'
NC='\033[0m'

CHNROUTE_URL="http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest"

echo -e "${GREEN}>>> downloading chnroute...${NC}"
curl -L -o cn.zone.raw $CHNROUTE_URL

if [[ -e "cn.zone.raw" ]]; then
        # create ipset set
        ipset -q create chnroute hash:net || true
        ipset create chnroute_new hash:net
        # parse chnroute file, add to temp set
        awk -F\| '/CN\|ipv4/ { printf("%s/%d\n", $4, 32-log($5)/log(2)) }' cn.zone.raw > cn.zone
        cat cn.zone | xargs -I ip ipset add chnroute_new ip
        # swap old and new set
        ipset swap chnroute_new chnroute
        ipset destroy chnroute_new

        echo -e "${GREEN}>>> update chnroute done!!!${NC}"
        rm cn.zone.raw
else
        echo "download chnroute file failed!!!"
fi

参考链接:

Openwrt 作为旁路网关(不是旁路由、单臂路由)的终极设置方法,破解迷思 - 少数派 (sspai.com)

旁路由的原理与配置一文通 - Eason Yang’s Blog

关于旁路由设置后,主路由WIFI无法上网的问题_旁路由作为网关不能上网_锦夏挽秋的博客-CSDN博客

networking - What exactly happens when I enable net.ipv4.ip_forward=1? - Unix & Linux Stack Exchange

Transparent proxy support — The Linux Kernel documentation

tproxy package - github.com/e1732a364fed/v2ray_simple/netLayer/tproxy - Go Packages