许多公司与高校都在使用Cisco’s AnyConnect作为为员工师生提供外部上网的解决方案,然而,这类VPN客户端通过创建虚拟网络接口并修改路由策略来实现网络代理,开启后将会接管设备上所有网络流量。如此一来,不但正常的网络连接会被意外代理,导致上网体验受到影响,在开关虚拟网络时也会导致短暂断网,着实不够优雅。
作为一种解决思路,我们可以通过在远程Linux设备上运行OpenConnect创建虚拟网络接口,并使用自定义脚本配置路由规则来实现更加灵活的分流,同时为其他设备快速接入,实现无感上网提供支持。
本文,我们将以虹梅师专的校园网VPN为例,演示如何将网卡代理纳入个人网络路由。
准备开始
插入一条来自未来的Hacinsl的友情提醒:如果您也在尝试为自己的,尤其是和鄙人同一所学校的校园网配置VPN,可能会更想要先跳转到这个位置看一看踩坑实录。
首先,在Linux设备上安装openconnect软件包。
sudo apt install openconnect尝试连接。
sudo /usr/sbin/openconnect "<yourvpnserver>"回车!
hacinsl@Misaka:~> /usr/sbin/openconnect vpn-cn.ecnu.edu.cn
POST https://vpn-cn.ecnu.edu.cn/
Connected to 202.120.88.66:443
SSL negotiation with vpn-cn.ecnu.edu.cn
Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite (TLS1.2)-(ECDHE-X25519)-(RSA-SHA256)-(AES-256-GCM)
Got HTTP response: HTTP/1.1 404 Not Found
Unexpected 404 result from server
GET https://vpn-cn.ecnu.edu.cn/
Connected to 202.120.88.66:443
SSL negotiation with vpn-cn.ecnu.edu.cn
Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite (TLS1.2)-(ECDHE-X25519)-(RSA-SHA256)-(AES-256-GCM)
Got HTTP response: HTTP/1.0 302 Object Moved
GET https://vpn-cn.ecnu.edu.cn/+webvpn+/index.html
SSL negotiation with vpn-cn.ecnu.edu.cn
Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite (TLS1.2)-(ECDHE-X25519)-(RSA-SHA256)-(AES-256-GCM)
Got HTTP response: HTTP/1.1 301 Moved Permanently
GET https://vpn-cn.ecnu.edu.cn/+CSCOU+/anyconnect_unsupported_version.html
Please upgrade your AnyConnect Client
Failed to complete authentication
Boom!
Why Boom?
怎么回事呢?原来是Please upgrade your AnyConnect Client,提示所用的客户端版本不受支持。然而问题不在我,并不是客户端版本太低,而是不在受支持的版本列表内,换句话说,是太高了。
那么去看一眼虹梅师专的使用说明里用的是什么版本呢,哦,是v5.02,发布于2014年……
虽然很想吐槽,但对于一个能正常使用的系统,不去乱动它显然是明智之举(?)。
对于这个问题,最直接的解决方案显然是换用老版本客户端,但Debian12的官方软件源显然不会支持如此古典的版本,比起寻找和添加新软件源的麻烦,也许自己编译才更为省事。
是的没错我又踩坑了,以下内容仅供参考。
在官方的发布页面找到v5.02的源代码,解压到合适的目录,执行以下操作进行编译。
cd ./openconnect
./autogen.sh
./version.sh version.c
./configure
make回车!
In file included from openconnect-internal.h:35,
from dtls.c:37:
/usr/include/openssl/ssl.h:2041:50: note: declared here
2041 | OSSL_DEPRECATEDIN_1_1_0 __owur const SSL_METHOD *DTLSv1_client_method(void);
| ^~~~~~~~~~~~~~~~~~~~
dtls.c:161:38: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
161 | vpninfo->dtls_session->ssl_version = 0x0100; /* DTLS1_BAD_VER */
| ^~
dtls.c:165:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
165 | vpninfo->dtls_session->master_key_length = sizeof(vpninfo->dtls_secret);
| ^~
dtls.c:166:37: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
166 | memcpy(vpninfo->dtls_session->master_key, vpninfo->dtls_secret,
| ^~
dtls.c:169:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
169 | vpninfo->dtls_session->session_id_length = sizeof(vpninfo->dtls_session_id);
| ^~
dtls.c:170:37: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
170 | memcpy(vpninfo->dtls_session->session_id, vpninfo->dtls_session_id,
| ^~
dtls.c:187:21: warning: assignment discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
187 | dtls_cipher = sk_SSL_CIPHER_value(ciphers, 0);
| ^
dtls.c:190:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
190 | vpninfo->dtls_session->cipher = dtls_cipher;
| ^~
dtls.c:191:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
191 | vpninfo->dtls_session->cipher_id = dtls_cipher->id;
| ^~
dtls.c:191:55: error: invalid use of incomplete typedef ‘SSL_CIPHER’ {aka ‘struct ssl_cipher_st’}
191 | vpninfo->dtls_session->cipher_id = dtls_cipher->id;
| ^~
dtls.c:200:51: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
200 | vpninfo->dtls_session->ssl_version);
| ^~
openconnect-internal.h:338:75: note: in definition of macro ‘vpn_progress’
338 | #define vpn_progress(vpninfo, ...) (vpninfo)->progress((vpninfo)->cbdata, __VA_ARGS__)
| ^~~~~~~~~~~
make[1]: *** [Makefile:815: openconnect-dtls.o] Error 1
make[1]: Leaving directory '/home/hacinsl/openconnect'
make: *** [Makefile:969: all-recursive] Error 1
Boom!
这次又怎么了呢?原来,用小了十岁(指版本发布日期)的OpenSSL去编译OpenConnect会因为ssl-dtls的兼容性而产生问题。
难道为此还要再去编译一份古典OpenSSL吗?
也许不至于此。抱着试一试的心态,我下载了最新版OpenConnect的源码,并尝试修改其中的版本标识符来绕过客户端版本检测。
这次成功了(至少对我来说),以下内容可供实践。
先把之前编译失败的废品删掉,然后下载解压新的源码,运行官方的生成脚本。
cd ./openconnect
./autogen.sh
./version.sh version.c到这一步打住。手动修改刚刚生成的version.c,将其中的版本号改为5.02,致敬经典。
然后继续编译操作。
./configure
make这次没出什么幺蛾子。连接VPN试试。
hacinsl@hacserver:~> /home/hacinsl/openconnect/openconnect vpn-cn.ecnu.edu.cn
POST https://vpn-cn.ecnu.edu.cn/
Connected to 202.120.88.66:443
SSL negotiation with vpn-cn.ecnu.edu.cn
Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite TLSv1.2-ECDHE-RSA-AES256-GCM-SHA384
XML POST enabled
GROUP: [ECNU]:ECNU
POST https://vpn-cn.ecnu.edu.cn/
XML POST enabled
Username:
这次顺利来到了登录阶段。搞定。
流程自动化
如果每次连接VPN都要手动输入账户密码就丧失了这项工程的意义,好在OpenConnect支持从标准输入(stdin)读取密码, 方便用户通过脚本自动化登录流程。
编写登录脚本。
#!/bin/bash
# start_vpn.sh
echo "<yourpasswd>" | /usr/sbin/openconnect \
--user=<yourid> \
--passwd-on-stdin \
--authgroup=<yourgroup> \
--script=</path/to/your/configscript.sh> \
--interface=tun0 \
"<yourvpnhost>"通过自定义OpenConnect启动时调用的脚本,可以防止OpenConnect在建立连接后修改设备默认路由导致的意外代理,但也同时要求用户合理配置设备路由表和路由规则,让目标流量正确通过虚拟网络接口出站。
为实现这一需求,我们编写一个网络配置脚本,在OpenConnect启动时调用。
#!/bin/bash
# configscript.sh
TUNNAME="${TUNNAME:-tun0}" # 为防止openconnect在未建立连接时不能正确传递接口名称,在此设定默认值。
VPN_SERVER="<vpnhost>" # 如果VPN服务器本身也在目标网段内,则需要特别注明,让通往服务器的流量通过物理网络接口出站。
CERNET_IP="<targetip>" # 将希望通过虚拟网络接口出站的目标网段写入,若为校园网配置,则可直接将学校所在教育网网段写入。
echo "设置接口和IP地址"
ip link set "$TUNNAME" up
ip addr add "$INTERNAL_IP4_ADDRESS" dev "$TUNNAME" # 这里只配置了接口的IPv4地址,如有需要也可配置IPv6,前提是VPN服务器提供了IPv6地址,反正虹梅师专没有。
echo "创建自定义路由表"
TABLE_NAME="vpn"
TABLE_NUM=1016
echo "检查表是否存在"
grep -q "^$TABLE_NUM $TABLE_NAME" /etc/iproute2/rt_tables 2>/dev/null || {
echo "$TABLE_NUM $TABLE_NAME" | sudo tee -a /etc/iproute2/rt_tables
}
echo "清空自定义表中的默认路由"
ip route flush table $TABLE_NUM 2>/dev/null
echo "在自定义路由表中添加默认路由,指向VPN接口"
ip route add default dev "$TUNNAME" table $TABLE_NUM # 让进入该路由表的流量默认都从VPN接口出站。
echo "添加路由规则"
ip rule add to "$VPN_SERVER" table main priority 100 # 设置高优先级规则,对于和VPN服务器直接通信的流量通过物理接口出站。
ip rule add to "$CERNET_IP" table $TABLE_NUM priority 200 # 次高优先级规则,对于目标地址为目标网段的流量通过自定义路由表走VPN接口出站。
echo "VPN 接口 $TUNNAME 已启动,策略路由已配置。"到这一步,理论上就可以通过登录脚本创建VPN接口了,本设备上去往目标网段的流量都会通过VPN接口出站。
接下来就是用适当的方式让自己的上网设备将相关流量转发到这台Linux设备上处理,方法不一而足,本文不再讨论。
更进一步
通过制定教育网网段的方法分流已经能避免大部分问题,但还不够好,部分部署于公网的学术资源/教育优惠仍然会查验访问者的IP。鉴于此,我们需要一个更强大的,最好是基于域名分流的方案。
下面演示一个基于Xray和nftables的实现路径。
为什么不用iptables?这年头连OpenWrt都用上nftables了,还是赶紧拥抱更新更好的防火墙罢。
Linux的路由规则/Netfilter框架显然不具备处理域名的能力(指嗅探流量中的域名而不是在配置规则时用域名代替IP),需要Xray的辅助。
一种常见的用于网关设备的代理方案是在PREROUTING链上添加规则,将网关接受的流量通过TPROXY发往本机地址,并配合标记(mark)和策略路由实现透明代理。为了代理本机流量,这种方案选择在OUTPUT链上将本机发出的流量也打上相同的标记,通过策略路由再次导入PREROUTING链处理。
由于nftables不允许在目标地址转换(DNAT)和重定向(REDIRECT)后继续更多操作(如标记),所以本文采用PREROUTING链上透明代理的方式。也有可能有更优雅的姿势但是我不知道
# create route table for xray
echo "Create xray table..."
grep -q "^114 xray" /etc/iproute2/rt_tables 2>/dev/null || {
echo "114 xray" | tee -a /etc/iproute2/rt_tables
} # add 114 table if not exist
grep -q "^116 xray6" /etc/iproute2/rt_tables 2>/dev/null || {
echo "116 xray6" | tee -a /etc/iproute2/rt_tables
} # add 116 table if not exist
ip route flush table 114 2>/dev/null # flush 114 table
ip route add local 0.0.0.0/0 dev lo table 114 # add default route for 114
ip rule add fwmark 1 table 114 priority 99 # add rule to mark package
ip -6 route flush table 116 2>/dev/null # flush 116 table
ip -6 route add local ::/0 dev lo table 116 # add default route for 116
ip -6 rule add fwmark 1 table 116 priority 99 # add rule to mark package
# add nft rules
echo "Create nft rules..."
nft add table inet xray
nft add chain inet xray prerouting { type filter hook prerouting priority mangle \; }
nft flush chain inet xray prerouting
nft add rule inet xray prerouting ip daddr { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } return
nft add rule inet xray prerouting ip6 daddr { ::/128, ::1/128, ::ffff:0:0/96, 64:ff9b::/96, 100::/64, 2001::/32, 2001:20::/28, 2001:db8::/32, 2002::/16, fc00::/7, fe80::/10, ff00::/8 } return
nft add rule inet xray prerouting tcp dport 0-65535 tproxy ip to 0.0.0.0:12345 meta mark set 1 accept
nft add chain inet xray output { type filter hook output priority mangle \; }
nft flush chain inet xray output
nft add rule inet xray output ip daddr { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } return
nft add rule inet xray output ip6 daddr { ::/128, ::1/128, ::ffff:0:0/96, 64:ff9b::/96, 100::/64, 2001::/32, 2001:20::/28, 2001:db8::/32, 2002::/16, fc00::/7, fe80::/10, ff00::/8 } return
nft add rule inet xray output meta mark 9 return
nft add rule inet xray output tcp dport 0-65535 meta mark set 1 acceptXray相关配置如下。
{
"inbounds": [
{
"port": 12345,
"protocol": "dokodemo-door", // 任意门(迫真)
"settings": {
"network": "tcp",
"followRedirect": true
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls"]
},
"streamSettings": {
"sockopt": {
"tproxy": "tproxy"
}
},
"tag": "tproxy"
}
],
"outbounds": [
{
"protocol": "freedom",
"tag": "direct",
"streamSettings": {
"sockopt": {
"mark": 9 // 标记直连流量,该outbound置顶作为默认出站
}
}
},
{
"protocol": "freedom",
"tag": "proxy-ecnu",
"streamSettings": {
"sockopt": {
"mark": 3
}
}
}
],
"dns": {
"servers": ["223.5.5.5"]
},
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"outboundTag": "proxy-ecnu",
"domain": [
"domain:lib.ecnu.edu.cn"
]
}
]
}
}由此,我们可以更新将流量导入openconnect的方式,从匹配出站流量到匹配Xray的标记。
# create route table for ecnu
echo "Create ecnu table..."
grep -q "^1016 ecnu" /etc/iproute2/rt_tables 2>/dev/null || {
echo "1016 ecnu" | tee -a /etc/iproute2/rt_tables
} # add 1016 table if not exist
ip route flush table 1016 2>/dev/null # flush 1016 table
ip route add default dev tun0 table 1016 # add default route for 1016
ip rule add fwmark 3 table 1016 priority 90 # add rule to mark package添加默认路由的操作会因对应虚拟网卡接口不存在而无法进行,因此,应当把上述内容放在openconnect的配置脚本里,或者在启动时检测接口是否存在。以下是一种检测接口和Xray进程的不太靠谱的思路。
# wait for openconnect and xray to start
while ! pidof xray > /dev/null 2>&1; do
sleep 1
done
echo "xray started"
while ! ip addr show 2>/dev/null | grep -q "tun0"; do
sleep 1
done
sleep 5
echo "tun0 found"因为接口出现并不代表就能被使用,因此添加了一定的延时以确保执行后续操作时接口已经就绪。
连接保持
有些VPN服务器可能会自动关闭一段时间内没有流量的连接,为了保证服务随时可用,我们可以创建一个定时任务,不断请求目标网段,防止连接被意外关闭。
#!/bin/bash
# keep_alive.sh
curl "<properwebsite>"sudo crontab -e# 防止掉线
*/3 * * * * /opt/keep_alive.sh
# 开机自启
@reboot /opt/start_vpn.sh