声明
本文章仅供学习探讨相关技术之用,不保证实际环境中的可用性与合规性,请勿部署在实际环境中。同时,请遵守 VPN 提供方的相关规定(例如,学校提供的 VPN 绝对不可以分享给他人,并且只有本人可以使用)。
此外,本系统会保存登录 vpn 的用户名、密码与 2FA 于树莓派中,尽管可以以各种形式加强安全性(例如设置文件权限、禁止密码登录树莓派),但安全性与不部署此系统相比仍有降低,请参考 4.2 节关于安全性的措施。
2022 Jan 29 更新:
- 实际使用中发现存在 OpenConnect 进程还在,但是 vpn 连接并没有生效的情况,所以新增了一个通过ping实现的保活的 daemon,详见 3.3.4
- 更新了
myvpn.service
,密码不会再明文暴露其中- 优化了一些脚本
1 需求分析
只在树莓派上登陆学校的 Cisco Anyconnect VPN,让同一个子网内的其他设备通过树莓派访问学校内网资源(ssh、远程桌面、查论文)(我的主要需求是访问内网工作站上的 PVE web portal 和使用 SPICE 远程连接工作专用的虚拟机)。同时在树莓派上实现 VPN 的自动登录和重连,保证高可用性。
近期主力科研设备转移到了一台放在实验室的 ML Workstation,由于这个 workstation 处于学校内网,所以必须要连接上学校的 VPN 再通过远程桌面使用(不喜欢 teamviewer)。考虑到 VPN 的特殊性,我并不想在个人设备上时时开着 vpn 进行科研(说得我好像经常在科研一样),所以我总是打开放在家里的一台学校的 workstation,再连接vpn、远程桌面。这台 workstation 发热量极大,导致房间温度总是很高,所以上述需求也就应运而生。如果我在树莓派上登录了 vpn,那我子网里其他所有设备就可以通过树莓派来连接学校内网了,并且只有在规则中设定好的请求才会被转发至内网,也就是说在个人设备上进行科研不再会有各方面的顾虑了。
2 实现分析
以树莓派为核心,这套系统分成了两个部分:流量转发和 VPN 的配置(主要是自动登录与重连)。
流量转发其实是一个老生常谈的话题(毕竟这种 routing 和科学上网密不可分),简单的静态路由加树莓派上开个 nat 就足够了。
Cisco Anyconnect 的 client 在树莓派上可以用 openconnect 替代。vpn 的自动登录主要难点在于学校启用了 Duo 2FA,需要在自动登录脚本里自动生成 2FA passcode,好在我在一番搜索之后找到了在命令行生成 Duo Passcode 的办法。除了一些网络连接错误之外, VPN 会有两种情况自动断线,一种是 idle 超过一定时间(据我观测大约是1小时),另一种是达到了最大连接时长(大约是24或25小时),所以需要实现自动重新连接,来保证 vpn 服务不断。这个需求用 crontab 或者 systemd 均可,权衡一番之后我最终给 openconnect 写了个 service 文件,用 systemd 作为 daemon。
总的来说,这套系统包含:
- 流量转发(3.2)
- 树莓派开启包转发,并添加 NAT 将内网的请求交给 vpn tunnel 处理(3.2.1-3)
- 设置静态路由,将目的地为学校内网 ip 的请求转发到树莓派(3.2.4)
- OpenConnect VPN 配置(3.3)
- Duo 2FA Passcode 命令行生成(3.3.1)
- 自动登录脚本(3.3.2)
- 用 systemd 守护进程(3.3.3,3.3.4)
警告:本系统会保存登录 vpn 的用户名、密码与 2FA 于树莓派中,尽管可以以各种形式加强安全性(例如设置文件权限、禁止密码登录树莓派),但安全性与不部署此系统相比仍有降低,请参考 4.2 节关于安全性的措施。
3 系统实现
3.1 准备工作
这两年芯片短缺,连树莓派都断货了,我在家找了半天只找到一个闲置的 Raspberry Pi 3B+。不过考虑到这个树莓派只负责连回学校这一个任务,并且只有我一个人用,负载不会很大,所以 3B+ 虽然有点老但还是能用。sd 卡方面,因为要长时间开机,推荐购买标有 U3 及 A2 的卡,Samsung Pro Endurance 也可(毕竟 MLC,我最近可太喜欢这张卡了),32GB 足够。不过树莓派在这里只负责转发和连接 vpn,不会存储重要数据,用菜一点的 sd 卡我觉得问题也不大。电源需要 5V2A+ 保证稳定。系统我选择了 Ubuntu Server,毕竟不需要桌面环境,也比 Raspbian 好使。此外最好使用有线连接,比无线连接稳定太多,也不用配置。
在用 Win32DiskImager 烧写 Ubuntu Server 20.04 LTS 到 SD 卡之后,在 SD 卡根目录下新建文件名为 SSH
的文件来启用 ssh。插好网线和电源,在路由器上设置静态 ip,如此等等,网上教程太多了,按下不表。用 ssh 连接到树莓派并更新到最新版(我在 Windows 上用的 XShell,家庭和个人使用是免费的)。
1 | sudo apt update |
3.2 流量转发
3.2.1 安装 OpenConnect 并进行测试
将 <VPN URL>
替换为你的 VPN 连接地址,前面不用加 http://
或 https://
。我校强制启用了 Duo 2FA,所以在输入完用户名和密码之后,会要求用户输入第二个密码,即 Duo 生成的6位 passcode。
1 | sudo apt install openconnect |
连接成功之后会有如下类似输出,实测最后一行的 Error 没啥影响。
1 | <...> |
这时候 openconnect 是在前台运行的,要查看 vpn 的连接情况,可以开启一个新的 ssh 连接,并使用命令 ip addr
,可以看到如下类似输出,其中的 <Your IPv4 Adress>
应该和上面输出的 ip 相同。这里的 tun0
设备就是 vpn 使用的 tunnel 了。
1 | 1: lo: <...> |
为了确保 vpn 的正确连接,可以 ping
一个内网 ip 试一下,能 ping 通即说明连接成功。
不要关闭 vpn 连接,刚刚新建的 ssh 连接里进行接下来的配置。
3.2.2 允许包转发与 bbr 优化
先查看内核的包转发有没有启用,如果已启用输出应该是1,否则为0。
1 | cat /proc/sys/net/ipv4/ip_forward |
编辑配置文件启用包转发
1 | sudo nano /etc/sysctl.conf |
找到下面的配置,并取消注释 net.ipv4.ip_forward=1
。
1 | # Uncomment the next line to enable packet forwarding for IPv4 |
保存生效
1 | sudo sysctl -p /etc/sysctl.conf |
这样树莓派就开启了包转发的功能。同时,还可以开启 bbr 优化,来优化 tcp 拥塞,可以提高流量的处理效率。对算法有兴趣可以搜一下,介绍的文章很多。
方便起见,我们切换为 root 修改配置文件。
1 | sudo passwd root # 设置root密码 |
返回值应该如下所示,可以看到这三行反映了我们上面对 /etc/sysctl.conf
的修改。
1 | net.ipv4.ip_forward = 1 |
确认 bbr 已经开启
1 | sysctl net.ipv4.tcp_available_congestion_control |
最后,用 exit
命令退出 root身份。
3.2.3 配置 NAT
使用 iptables 进行配置,其中 <内网ip地址[/mask]>
可以为一个地址段,比如 172.16.0.0/24
,如果是单个 ip 则比如 172.16.0.2
,也可以直接并列的写很多 ip 地址。 <vpn 设备名>
就是上面 ip addr
命令中看到的 tun0
。
1 | sudo iptables -t nat -A POSTROUTING -d <内网ip地址[/mask]> -o <vpn 设备名> -j MASQUERADE |
简单的说,这条命令是在 nat
表里(-t
)添加了(-A
)一个 POSTROUTING
(外到内)规则,该规则负责处理目标(-d
) ip 为 <内网ip地址[/mask]>
的请求,将其发送(-o
)到 <vpn 设备名>
,处理动作(-j
)是动态伪装(MASQUERADE
),即自动获取 <vpn 设备名>
的 ip,并将请求伪装成是该 ip 发出的。
上述规则将会在重启后失效。要让规则永久生效,我个人比较喜欢使用一个小工具
1 | sudo apt install iptables-persistent # 安装 |
可以使用如下命令查看已生效的 iptables 规则
1 | sudo iptables -L -t nat |
输出类似如下所示
1 | Chain PREROUTING (policy ACCEPT) |
3.2.4 配置静态路由
这里我们假设所有设备都在同一个子网里,出口只有一个路由器,且该子网内不与他人共享(其他情况见本段末)。最简单的配置静态路由的方法就是在路由器上进行设置了。
警告:如果配置该静态路由的路由器被共享,则你的 vpn 连接也很可能被同内网的不属于你的设备共享。所以最好是自己有一个内网,只有自己的设备可以接入。
设置可能因路由器型号、固件而不同。在我的 k2p padavan 上,该设置在 Advanced Settings - LAN - Route - Enable Static Routes? - Static Route List 里。而在 Netgear R7450 上,该设置应该在 Advanced Settings - Static Route。
Destination IP
应该设置为 <内网ip地址[/mask]>
中的 ip 地址,而 Netmask
即是子网掩码,Gateway
应设置为树莓派的静态 ip 地址,Metric
设置为 15 即可。
以网段 172.16.0.0/24
为例,
Destination IP
-172.16.0.0
Netmask
-255.255.255.0
以单个 ip 172.16.0.3
为例,(mask 实际上是32)
Destination IP
-172.16.0.3
Netmask
-255.255.255.255
如果有多个 ip,添加多条规则并保存即可。
这时候就可以测试策略路由有没有生效了。在一台与树莓派同内网的设备上,尝试 ping
一下某个内网 ip,或者尝试一下 ssh 连接,应该没有问题了。如果连不上,在 windows 上使用 tracert <ip>
,Mac/Linux上使用 traceroute <ip>
,可以看到在哪一步出现问题,然后再排查故障。
测试成功之后,在 vpn 连接的 ssh 会话里使用 Ctrl + C
断开 vpn 连接。
如果你没有一个独享的内网环境,大概有以下四种方法:
(详见 4.1)在树莓派上装一个 Clash,就可以很方便的搭建起 HTTP/HTTPS/SOCKS server,然后在本机配置相应代理即可。
你可以对发起请求的设备 ip 进行过滤,确保 nat 规则只对你的设备生效,具体做法是在3.2.3节添加 iptables 规则时,添加
-s
参数指定请求来源。不过这要求你的设备都有静态 ip,不然 ip 地址总变动,规则也会随之失效。在自己的设备上直接使用
route
命令配置静态路由。具体可以自行 google。你也可以将你的设备的网关设置为树莓派的静态 ip,由树莓派而不是路由器处理你的设备的网络流量(类似于
PiHole
那种做法)。
3.3 VPN 配置
所有代码与配置文件均已在 https://github.com/tangbao/Openconnect-VPN-RPi-Gateway 开源。
将代码保存在树莓派的 ~/myvpn
目录下。
1 | cd ~ |
使用 pwd
命令查看当前目录的绝对路径(一般是 /home/ubuntu/myvpn
),下文中将使用 <WorkDir>
指代此路径。
3.3.1 Duo 2FA Passcode 命令行生成
首先确认 python
是否安装。在 ubuntu 上 python 2 或者 3 需要显式地指定(当然你也可以自己软连接 python
命令到 2 或者 3 上)。
1 | python3 --version |
可以看到输出的版本号即为已安装 python 3。
1 | Python 3.8.10 |
安装 pip3
和脚本所需的库
1 | sudo apt install python3-pip |
(图片来源 https://github.com/AvikRao/duo-extension/wiki/Setup-and-Usage)
接着获取 Duo 2FA 打开 Duo 2FA 的管理界面,选择 Add a new device
- Tablet
- Android
- I have Duo Mobile installed
,这时候会跳出一个二维码,右击二维码,选择 Copy image address
。
(图片来源 https://github.com/AvikRao/duo-extension/wiki/Setup-and-Usage)
回到 <WorkDir>
下,运行脚本激活二维码,并提取 secret token、生成passcode。
1 | python3 duo.py -qr <image address> |
一切顺利的话,就可以获得一个6位的 passcode 了。
这个二维码和常见的二维码(可以离线扫的)不同,它包含的是一个向服务器发起注册设备的请求,服务器收到请求之后会返回用于生成 passcode 的 secret token。另外这是一个 HOTP passcode,可以离线扫的那种一般是 TOTP,有兴趣的可以搜一下差异,这里就不展开了。
3.3.2 openconnect 自动登录
自动登录脚本中,<root password>
就是 root 用户的密码,<NetID>
和 <NetID password>
是登录 vpn 的用户名和密码,而 <VPN URL>
则是连接 vpn 的网址,例如 vpn.university.edu
。
直接在 clone 下来的 myvpn.sh
中进行修改,然后 sudo bash myvpn.sh
运行进行测试。这里密码使用了单引号,但是生成 2fa passcode 使用了双引号,
1 | # myvpn.sh 的内容 |
从 myvpn.sh
的内容中可以看到,脚本将密码和上一节中生成的 2fa passcode 用 printf
丢到 stdin
,然后通过管道传给 openconnect,实现自动输入密码和 passcode。
接着按照 3.2.1 中所说的方法进行测试是否成功连接。测试完成之后断开连接。
3.3.3 systemd 守护进程
上面提到,vpn 在 idle (大约 1 hr)或达到最大连接时长(大约 24 hrs)之后会自动断开连接,各种各样的意外也会让 vpn 断开连接,我们也希望 vpn 在树莓派重启之后自动启动。综上所述,最合适的守护进程可能就是 systemd
了。为此,我编写了如下 service
配置文件(即 clone 下来的 myvpn.service
),来实现 vpn 进程的保活
1 | [Unit] |
可以看到核心部分是 ExecStart=
之后的部分,即是我们在上节中测试的自动登录脚本。按照上节所述替换 <WorkDir>
,并保存。
将 myvpn.service
挪到正确的位置,并启用。
1 | sudo mv myvpn.service /etc/systemd/system/ |
使用如下命令检查 vpn 服务的运行情况
1 | sudo systemctl status myvpn |
如果看到绿色的 active (running)
,那么 vpn 服务就启动成功了。systemd
会负责在 vpn 断线时重连。在与树莓派同一子网内的设备上尝试连接内网设备,如果可以连接那就大功告成。
如下两个命令可能会有用
1 | sudo systemctl disable myvpn # 取消 vpn 开机自启 |
3.3.4 双重保险:解决 vpn 进程存活,但连接失败的情况
在将近一个月的实际使用中,我发现会存在 openconnect 进程存活,但 vpn 连接已断开(即失去了内网 ip 地址)或 vpn 连接依然存在,但无法访问内网的情况。为此,我添加了另一个保活服务,通过 ping 内网 ip 地址来判断 vpn 的连接是否正常,若不正常,则立即重启上节中的 myvpn
服务。
判断 vpn 情况的脚本 ping2live.sh
如下所示,需要将 <some intranet ip>
换成内网中的某个 ip 地址。
1 | # ping2live.sh 内容 |
这个实现很简单,就是用一个死循环,每 60 秒 ping 一次内网 ip 地址;&> /dev/null
等同于 > /dev/null 2>&1
,即将 stdout
和 stderr
全部扔给 /dev/null
(也就是 discard 掉)。如果 ping 命令失败,脚本就会自动重启 myvpn
服务。
很显然,我们也需要一个新的 service 来保活这个脚本(ping2live.service
)
1 | [Unit] |
类似地,将 <WorkDir>
替换之后,我们将它放到正确的地方,并启用
1 | sudo mv ping2live.service /etc/systemd/system/ |
这样我们就有双重保险来增强 vpn 连接的稳定性了。
4 扩展
4.1 使用 vpn 访问其他资源
上面的方法仅讨论了使用部署在树莓派上的 vpn 来访问内网机器的情况,但是,如果我们需要访问一些仅内网 ip 才能访问的网站(如下载论文)时,用同样的方法进行设置会变得非常繁琐。你需要准确的找到论文网站的 ip 地址并且设置路由,而这些网站数目繁多,ip 地址也并不一定固定,所以这一方法并不适用。这时候,我们可以在树莓派上搭一个 HTTP(S)/SOCKS 代理,再通过该代理连接到树莓派上,便可由挂了 vpn 的树莓派来处理这些请求。
这一方法也适用于 3.2.4 中提到的一些特殊情况的解决。下文中会有提到。
具体做法很简单,自然是 clash 大法好。clash 本身就提供了 HTTP(S)/SOCKS 服务器的功能,所以只需要允许局域网内的设备连接即可,无需配置其他。这里的 clash 也是 go 编写的命令行版 clash,无需使用封装好的 gui 版。
1 | cd ~ |
第一次启动时会看到如下 log,可以看到程序自动创建了配置文件并下载了一些必须的数据库文件。
1 | INFO[0000] Can't find config, create a initial config file |
按 Ctrl + C
退出 clash,cd ~/.config/clash
进入 clash 存配置的文件夹,并编辑配置文件 nano config.yaml
,仅需添加一行 allow-lan: true
。
编辑完成后的配置文件如下所示。第一行表示 clash 的 HTTP(S)/SOCKS 代理端口为 7890
,第二行表示允许局域网设备连接。
1 | mixed-port: 7890 |
接下来就可以测试 clash的代理是否生效了。
1 | sudo systemctl start myvpn # 启动 openconnect,如果没有启动的话 |
接着切换到个人设备上使用浏览器进行测试。打开 chrome 并安装 Proxy SwitchyOmega
插件(https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif)。安装完成后点击插件图标 - Options
- 左侧 Profiles
- proxy
,然后添加 Proxy servers
:
Scheme
-(default)
Protocol
- 随便Server
- 树莓派的静态 ip 地址Port
- 7890 (你在配置文件中的端口号)
再点击左侧的 Apply changes
。再打开一个需要内网 ip 才能访问的网站(这里就不举例了,学校 vpn 的话直接 ACM library 搜一个要 Get Access 的文章吧),点击插件图标 - proxy
,再刷新页面,即可发现论文变得可以下载。也可以开着插件访问诸如 ip.sb
之类的网站,查看自己的 ip 地址是否是内网 ip。测试完成之后,可以点击插件图标 - Direct
,停止使用代理。
如果是其他应用想要使用 vpn,可以寻找有没有内置的代理设置,对其 SOCKS5 代理进行如上设置即可。
关于 3.2.4 中所说的自己没有独立子网的情况,可以为 clash 添加如下两种配置之一来解决:
- 添加
authentication
块为 HTTP(S)/SOCKS 代理设置密码(需要注意有些应用设置代理时并不支持密码,例如 win 10 的系统代理就不可以输入密码)- 添加
bind-address
,仅允许设定的 ip 地址可以使用此代理。这里同样要求将设备设置为静态 ip。
更多可以参考 clone 下来的config.yaml
或者 clash 配置文件官方文档 https://github.com/Dreamacro/clash/wiki/Configuration
接着,同样地使用 systemd
作为 clash 的守护进程。
和 openconnect 类似,将 clash 和其配置文件拷到相应位置,并启用即可
1 | sudo cp ~/clash/clash /usr/local/bin |
更多关于 clash 守护进程,可以参考官方文档 https://github.com/Dreamacro/clash/wiki/clash-as-a-daemon
这样本系统就搭建完成了,vpn 的使用将不再局限于单一设备,而是扩展到局域网内所有设备了,并且不再需要每次都手动输入密码连接了。
4.2 安全措施的略微探讨
可以对 duo.py
生成的 secrets.json
(保存了生成 2FA passcode 必须的密钥)的权限进行限制,这样仅有 root 可以读取。你也可以对 myvpn.sh
做同样的事情,因为它保存了 vpn 的密码,仅需将下面脚本中的 secrets.json
换成 myvpn.sh
,600换成700(增加一个执行权限)。
1 | cd ~/myvpn |
同时,可以禁止所有账户使用密码登录,用户账户(比如 ubuntu)使用密钥登录之类,这里就不展开了,网上教程一大把(推荐使用 ED25519 而不是 RSA)。
此外,要经常更新系统,来修复各种各样的漏洞。各种密码也要考虑经常修改。
这样做安全性有了不少提升,在一定程度上可以保证 NetID / vpn 的用户名密码和 2FA 不被泄露。
但是安全这玩意,谁敢打包票呢。
5 总结
(我觉得怪怪的,我老觉得我在写论文,先是 abstract,然后 introduction,system design,discussion,然后又 conclusion,wtf)
本文使用树莓派、openconnect vpn、iptables、duo 2FA passcode 生成脚本、systemd、Proxy SwitchyOmega和clash,构建了一个可供与树莓派同一内网设备轻松访问内网的系统,并实现了 vpn 的 24小时不间断连接。还有一个额外的 HTTP(S)/SOCKS 代理作为补充。
(不行了我要吐了)
(祝大家网上冲浪愉快!)