OpenConnect部署——将网卡代理纳入路由

许多公司与高校都在使用Cisco’s AnyConnect作为为员工师生提供外部上网的解决方案,然而,这类VPN客户端通过创建虚拟网络接口并修改路由策略来实现网络代理,开启后将会接管设备上所有网络流量。如此一来,不但正常的网络连接会被意外代理,导致上网体验受到影响,在开关虚拟网络时也会导致短暂断网,着实不够优雅。

作为一种解决思路,我们可以通过在远程Linux设备上运行OpenConnect创建虚拟网络接口,并使用自定义脚本配置路由规则来实现更加灵活的分流,同时为其他设备快速接入,实现无感上网提供支持。

本文,我们将以虹梅师专的校园网VPN为例,演示如何将网卡代理纳入个人网络路由。


准备开始

插入一条来自未来的Hacinsl的友情提醒:如果您也在尝试为自己的,尤其是和鄙人同一所学校的校园网配置VPN,可能会更想要先跳转到这个位置看一看踩坑实录。

首先,在Linux设备上安装openconnect软件包。

Bash
sudo apt install openconnect

尝试连接。

Bash
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的源代码,解压到合适的目录,执行以下操作进行编译。

Bash
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的源码,并尝试修改其中的版本标识符来绕过客户端版本检测。

这次成功了(至少对我来说),以下内容可供实践。

先把之前编译失败的废品删掉,然后下载解压新的源码,运行官方的生成脚本。

Bash
cd ./openconnect
./autogen.sh
./version.sh version.c

到这一步打住。手动修改刚刚生成的version.c,将其中的版本号改为5.02,致敬经典。

然后继续编译操作。

Bash
./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)读取密码, 方便用户通过脚本自动化登录流程。

编写登录脚本。

Bash
#!/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启动时调用。

Bash
#!/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链上透明代理的方式。也有可能有更优雅的姿势但是我不知道

Bash
# 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 accept

Xray相关配置如下。

JSONC
{
  "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的标记。

Bash
# 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进程的不太靠谱的思路。

Bash
# 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服务器可能会自动关闭一段时间内没有流量的连接,为了保证服务随时可用,我们可以创建一个定时任务,不断请求目标网段,防止连接被意外关闭。

Bash
#!/bin/bash
# keep_alive.sh
curl "<properwebsite>"
Bash
sudo crontab -e
# 防止掉线
*/3 * * * * /opt/keep_alive.sh

# 开机自启
@reboot /opt/start_vpn.sh