为什么要流量分流

  在x墙技术出现的早期(十五六年前?),基本上没有流量分流的做法,代理程序开始工作后,绝大多数场合都是把所有流量都通过代理程序走的,这就会导致绕远路的情况出现,比如访问国内的淘宝,先去美国绕了一圈再回来,速度慢不说,可能还会被淘宝识别为海外用户,直接跳转到海外版页面了。所以最基本的要求是国内网站就不要代理了,直连就行,海外站点才走代理线路。

  最早流行的流量分流技术大概是用浏览器扩展实现的,有一个著名的gfwlist列表,记录了绝大多数被墙的网址,浏览器识别到后自动调用代理或直连。如果不用浏览器扩展,pac格式的代理文件也能通过域名分流。这种方法的前提是,代理程序提供的是一个http或socks代理端口。

  如果代理程序是以VPN形式提供全局代理,常用的流量分流方法是修改路由表。网上可以找到中国大陆被分配使用的IP地址范围表,比如这个这个,或这个。在连接上VPN后,再将所有这些IP段在路由表中修改为直连原网关,就能达到分流的效果。

  一些代理程序比如v2rayclash影梭等,自带了分流的功能,基本原理也是根据gfwlist或IP地址表识别是要代理还是直连。

我为什么要流量分流

  从7元/月开始入了喵帕斯的坑(早已跑路,并亏了刚充值的700多元钱),才知道很多机场是同时提供多个地区的网络出口的,于是想着怎么把这种资源利用起来。其实只要线路足够好,一条就基本够了,但实际上往往并没这么好,于是我就想照不同的目的IP使用不同的线路的方式来分流,比如目标IP是美国的,就用美国出口的线路,目标IP是澳洲的,就用澳洲出口的线路。事实证明这样的做法还是有点用的,例如我在新加坡买了一个VPS,如果用Boom,已跑路在香港的线路测速结果就很差,如果换成新加坡的线路测速就跑上去了。最重要的是这个几乎不花费额外的成本,只需要一点设置即可。

实现步骤

  在基于通用Linux发行版的科学上网网关一文中已经说过,我是用iptables来做分流的,其中有一段中国大陆区IP的条目在那篇文章中并没有详细列出来,这次正好一起写了。

  首先要注意一点,iptables的匹配过程是从上到下依次匹配,如果条目很多的话,比如几万几十万(光是中国大陆的IP段就有6000多条),那会拖慢整个系统的性能,所以我引入了ipset,它使用了hash表来存储和查询,效率非常高,按地区把所有IP段都存入一个ipset中,iptables只要匹配几个ipset就可以了。

  再看我之前使用的Boom,已跑路套餐,有香港、日本、台湾、韩国、新加坡、美国、意大利、俄罗斯、英国、法国、德国、西班牙、荷兰、瑞典、澳洲这些出口,所以把全世界已分配的IP段按这些地区拆分出来,并从欧洲、亚洲、美洲IP段里把上述这些地区的IP段剔除出去。

  我是从MaxMind下载到IP地区Geoip数据库的,下载需要license,可以免费在线申请,如果不想自己申请的话,可以在GitHub上搜一个,有些人不注意就把他申请的license一起提交了。用以下命令下载,记得把{license}替换成有效的license:

curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&license_key={license}&suffix=zip" -o GeoLite2-Country-CSV.zip

  得到按国家地区分布的IP数据库后,解压,再写几行shell脚本,生成前面说的ipset配置文件:

#!/bin/bash
unzip GeoLite2-Country-CSV.zip
find . -name 'GeoLite2-Country-CSV_*' -type d | while read dir; 
do 
    mv $dir "GeoLite2-Country-CSV"; 
    break;
done
cd GeoLite2-Country-CSV
curl -o cnroute.txt -L https://fastly.jsdelivr.net/gh/17mon/china_ip_list@master/china_ip_list.txt
n=`grep -r -a ',IT,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > itroute.txt
n=`grep -r -a ',US,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > usroute.txt
n=`grep -r -a ',JP,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > jproute.txt
n=`grep -r -a ',TW,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > twroute.txt
n=`grep -r -a ',HK,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > hkroute.txt
n=`grep -r -a ',SG,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > sgroute.txt
n=`grep -r -a ',KR,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > krroute.txt
n=`grep -r -a ',RU,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > ruroute.txt
n=`grep -r -a ',GB,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > gbroute.txt
n=`grep -r -a ',FR,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > frroute.txt
n=`grep -r -a ',DE,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > deroute.txt
n=`grep -r -a ',SE,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > seroute.txt
n=`grep -r -a ',ES,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > esroute.txt
n=`grep -r -a ',NL,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > nlroute.txt
n=`grep -r -a ',AS,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > asroute.txt
cat cnroute.txt | while read ip; do sed -i "\|$ip|d" asroute.txt; done
cat jproute.txt | while read ip; do sed -i "\|$ip|d" asroute.txt; done
cat twroute.txt | while read ip; do sed -i "\|$ip|d" asroute.txt; done
cat hkroute.txt | while read ip; do sed -i "\|$ip|d" asroute.txt; done
cat krroute.txt | while read ip; do sed -i "\|$ip|d" asroute.txt; done
cat sgroute.txt | while read ip; do sed -i "\|$ip|d" asroute.txt; done
n=`grep -r -a ',EU,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > euroute.txt
cat itroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat ruroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat gbroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat frroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat nlroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat seroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat esroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
cat deroute.txt | while read ip; do sed -i "\|$ip|d" euroute.txt; done
n=`grep -r -a ',OC,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > ocroute.txt
n=`grep -r -a ',AF,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > afroute.txt
n=`grep -r -a ',SA,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > saroute.txt
n=`grep -r -a ',NA,' GeoLite2-Country-Locations-zh-CN.csv | awk -F ',' '{print $1}'`
cat GeoLite2-Country-Blocks-IPv4.csv | awk -F ',' '{ $3=null;print $0}' | grep "$n" | awk -F ' ' '{print $1}' > naroute.txt
cat usroute.txt | while read ip; do sed -i "\|$ip|d" naroute.txt; done
mkdir ../routes
mv *route.txt ../routes/
cd ../routes
find . -name '*route.txt' -type f | while read f; 
do 
    basename=${f##*/}; 
    basename=${basename%.txt}; 
    sed -i "s|^|add $basename |" f; 
done

  这个脚本的算法没有优化过,所以运行需要很长时间,我把它放到GitHub Actions上跑也要一个多小时,好在这个脚本并不需要经常跑,大概一个月跑一次也就差不多了,而且放在GitHub Actions上并不占用自己的机器时间。如果自己跑脚本嫌麻烦,可以直接从GitHub下载我定期跑的结果。

  脚本跑完后会生成21个形如*route.txt的文件,*是诸如uscnas等的地区代码。文件内容大体是这个样子:

add cnroute 1.0.1.0/24
add cnroute 1.0.2.0/23
add cnroute 1.0.8.0/21
……

  文件名和地区的对应关系如下表所示:

文件名 地区
cnroute.txt 中国大陆
afroute.txt 非洲
asroute.txt 亚洲
deroute.txt 德国
esroute.txt 西班牙
euroute.txt 欧洲
frroute.txt 法国
gbroute.txt 英国
hkroute.txt 香港
itroute.txt 意大利
jproute.txt 日本
krroute.txt 韩国
naroute.txt 北美洲
nlroute.txt 荷兰
ocroute.txt 澳洲
ruroute.txt 俄罗斯
saroute.txt 南美洲
seroute.txt 瑞典
sgroute.txt 新加坡
twroute.txt 台湾
usroute.txt 美国

  有了这些文件,再写一个shell脚本,把ipset创建起来:

#!/bin/bash
if [[ $EUID -ne 0 ]]; then
   echo "This script must be run as root"
   exit 1
fi

ipset list | grep route | while read line;
do
   name=`echo $line | awk -F ' ' '{print $2}'`;
   echo "flush $name"
   ipset flush $name;
done

find . -name '*route.txt' -type f | while read f
do
  basename=${f##*/}
  basename=${basename%.txt}
  elemcount=`cat $f | wc -l`
  has_route=`ipset list | grep -o $basename`
  if [ "$has_route"x = "$basename"x ]
  then
    echo "$basename exists"
  else
    echo "create $basename with max element count $elemcount"
    ipset create $basename hash:net family inet hashsize 1024 maxelem $elemcount
  fi
  echo "restore $basename"
  ipset restore -f $f
done

  这个脚本一定要用root账号跑,所以一开始检测到不是root就打印一个提示信息然后退出。之后便是先列出所有当前系统中名如*route的ipset,并清空其内容。后面再按文件名按需创建新的ipset,并导入文件内容。之所以不每次直接删掉ipset再统一创建后导入内容,是因为iptables如果正在使用ipset,是删不掉ipset的,只能清空内容,除非先把iptables停掉。

  跑完这个脚本,可以用命令看一下当前系统中已有的ipset:

sudo ipset list | grep route | sort -n

  一切正常的话应该能列出21个ipset的名字,只会多,不会少:

Name: afroute
Name: asroute
Name: cnroute
Name: deroute
Name: esroute
Name: euroute
Name: frroute
Name: gbroute
Name: hkroute
Name: itroute
Name: jproute
Name: krroute
Name: naroute
Name: nlroute
Name: ocroute
Name: ruroute
Name: saroute
Name: seroute
Name: sgroute
Name: twroute
Name: usroute

  然后运行多个ss-redir进程,将不同路线监听在不同的端口:

ss-redir -s 123.123.123.123 -p 8100 -k qwerasdfzxcv -m chacha20-ietf -l 58100 -b 0.0.0.0 -f hk.pid
ss-redir -s 123.123.123.123 -p 8101 -k qwerasdfzxcv -m chacha20-ietf -l 58101 -b 0.0.0.0 -f jp.pid
ss-redir -s 123.123.123.123 -p 8102 -k qwerasdfzxcv -m chacha20-ietf -l 58102 -b 0.0.0.0 -f us.pid
ss-redir -s 123.123.123.123 -p 8103 -k qwerasdfzxcv -m chacha20-ietf -l 58103 -b 0.0.0.0 -f tw.pid
ss-redir -s 123.123.123.123 -p 8104 -k qwerasdfzxcv -m chacha20-ietf -l 58104 -b 0.0.0.0 -f kr.pid
ss-redir -s 123.123.123.123 -p 8105 -k qwerasdfzxcv -m chacha20-ietf -l 58105 -b 0.0.0.0 -f sg.pid
ss-redir -s 123.123.123.123 -p 8106 -k qwerasdfzxcv -m chacha20-ietf -l 58106 -b 0.0.0.0 -f ru.pid
ss-redir -s 123.123.123.123 -p 8107 -k qwerasdfzxcv -m chacha20-ietf -l 58107 -b 0.0.0.0 -f it.pid
ss-redir -s 123.123.123.123 -p 8108 -k qwerasdfzxcv -m chacha20-ietf -l 58108 -b 0.0.0.0 -f gb.pid
ss-redir -s 123.123.123.123 -p 8109 -k qwerasdfzxcv -m chacha20-ietf -l 58109 -b 0.0.0.0 -f oc.pid
ss-redir -s 123.123.123.123 -p 8110 -k qwerasdfzxcv -m chacha20-ietf -l 58110 -b 0.0.0.0 -f de.pid
ss-redir -s 123.123.123.123 -p 8111 -k qwerasdfzxcv -m chacha20-ietf -l 58111 -b 0.0.0.0 -f nl.pid
ss-redir -s 123.123.123.123 -p 8112 -k qwerasdfzxcv -m chacha20-ietf -l 58112 -b 0.0.0.0 -f se.pid
ss-redir -s 123.123.123.123 -p 8113 -k qwerasdfzxcv -m chacha20-ietf -l 58113 -b 0.0.0.0 -f es.pid
ss-redir -s 123.123.123.123 -p 8114 -k qwerasdfzxcv -m chacha20-ietf -l 58114 -b 0.0.0.0 -f fr.pid

  最后修改一下iptables设置:

iptables -t nat -A SS -m set --match-set cnroute dst -j RETURN
iptables -t nat -A SS -p tcp -m set --match-set hkroute dst -j REDIRECT --to-ports 58100
iptables -t nat -A SS -p tcp -m set --match-set asroute dst -j REDIRECT --to-ports 58100
iptables -t nat -A SS -p tcp -m set --match-set jproute dst -j REDIRECT --to-ports 58101
iptables -t nat -A SS -p tcp -m set --match-set usroute dst -j REDIRECT --to-ports 58102
iptables -t nat -A SS -p tcp -m set --match-set afroute dst -j REDIRECT --to-ports 58102
iptables -t nat -A SS -p tcp -m set --match-set saroute dst -j REDIRECT --to-ports 58102
iptables -t nat -A SS -p tcp -m set --match-set naroute dst -j REDIRECT --to-ports 58102
iptables -t nat -A SS -p tcp -m set --match-set twroute dst -j REDIRECT --to-ports 58103
iptables -t nat -A SS -p tcp -m set --match-set krroute dst -j REDIRECT --to-ports 58104
iptables -t nat -A SS -p tcp -m set --match-set sgroute dst -j REDIRECT --to-ports 58105
iptables -t nat -A SS -p tcp -m set --match-set ruroute dst -j REDIRECT --to-ports 58106
iptables -t nat -A SS -p tcp -m set --match-set itroute dst -j REDIRECT --to-ports 58107
iptables -t nat -A SS -p tcp -m set --match-set gbroute dst -j REDIRECT --to-ports 58108
iptables -t nat -A SS -p tcp -m set --match-set deroute dst -j REDIRECT --to-ports 58111
iptables -t nat -A SS -p tcp -m set --match-set nlroute dst -j REDIRECT --to-ports 58112
iptables -t nat -A SS -p tcp -m set --match-set seroute dst -j REDIRECT --to-ports 58113
iptables -t nat -A SS -p tcp -m set --match-set esroute dst -j REDIRECT --to-ports 58114
iptables -t nat -A SS -p tcp -m set --match-set frroute dst -j REDIRECT --to-ports 58115
iptables -t nat -A SS -p tcp -m set --match-set euroute dst -j REDIRECT --to-ports 58111
iptables -t nat -A SS -p tcp -m set --match-set ocroute dst -j REDIRECT --to-ports 58110
iptables -t nat -A SS -p tcp -j REDIRECT --to-ports 58102

  可以看到中国大陆IP直接返回,不走代理,另外亚洲除了香港、日本、韩国、新加坡分别有代理节点后,其余的统一走香港出口。欧洲没有出口节点的地区统一走德国节点。美洲和非洲,以及其余如果还有没指定的统一走美国节点。要注意的是,鉴于iptables规则从上往下依次匹配的特点,要把亚洲规则放在所有亚洲地区规则之后,欧洲规则放在所有欧洲地区规则之后。至于其他走美国线路的规则,写不写区别不大。

  这个需求用v2ray或clash也很容易实现,这些代理程序内置了Geoip数据库,可以在配置文件中写规则进行分流,效果几乎相同。用iptables分流只是提供了一种不依赖特定程序的另一种选择,这是非常Unix风格的做法,即一个程序(或工具)只做一件事,且把这件事做到最好,可以看到我这里脚本生成ipset配置,脚本更新ipset配置,ss-redir代理,iptables分流,看起来步骤繁多,但每一步的目标都很清晰和明确,且可以单独拿出去在其他地方重复利用。

打赏

  如果你觉得本文对你有所帮助,心里默默点赞即可~