背景

好消息: 我校数据结构课程有 OJ 辣!

坏消息: OJ 搭在校内, 然而现在疫情在上网课 :(

要想访问校内网, 最正常的方法是启动学校发的 VPN, 直接回内网. 然而我校使用的 VPN 是臭名昭著的深信服, 因而我并不愿意让它安装我的 Windows 上. 幸好, 有大佬制作了深信服 VPN 的 Docker 版. 但是不知道为什么, 这个 Docker 版跟 WSL 不是很搭, 不管怎么调都是登录成功, 访问失败. 按常理来讲, 我那时应该开一个虚拟机, 然后在虚拟机上跑这个的. 但是那时候突发奇想, 能不能换种思路, 我不回校园网, 让服务走出校园网?

事实证明, 这是一个大坑, 这个想法 ruin 了我一整个上午, 人都炸毛了. 自疫情开学以来我就没有这么愤怒过 (无能狂怒.jpg). 不过在祭献了一个上午之后, 下午的配置过程出奇地顺利, 最后我还是成功配好了一套看起来非常扭曲的服务.

总结一下需求: 在校外访问 ak.origami404.top 就能访问到校内的 10.1.1.1 上跑着的服务, 并且既能收包也能发包. 此服务为一前后端分离的 Web 项目, 前端运行在 9000 端口, 后端运行在 9001 端口. 我拥有域名所对应的服务器的 root 权限, 且拥有域名所有权; 但此校内服务非我所有. 学校提供深信服的 VPN.

出于安全考虑, 上面提到的所有地址与端口信息都不是真实的, 但是场景是真的.

那么开始折腾吧!

问题一: 如何使用 VPN?

正如上文提到的, 我校使用深信服 VPN. 根据 docker 版深信服的配置说明, 我首先写出了下面的 docker-compose.yml:

version: '3'
services:
  easyconn:
    image: hagb/docker-easyconnect:cli
    restart: always
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      - EC_VER=7.6.3
      - CLI_OPTS="-d <vpnaddress> -u <username> -p <password>"

然后就死活登录不上了…

经过一阵疑神疑鬼的检查(包括进容器里查 iptables), 最后才怀疑到是我的密码里包含的特殊字符可能被转义了. 因为我其实不太会 yml 的语法, 我觉得我是配不好这个 yml+bash 的多重转义了. 所幸天无绝人之路, 从学长的口中我知道了深信服 VPN 会直接把地址/用户名/密码等信息全部保存到一个文件里供下次使用, 所以可以直接在终端运行手动输入密码, 然后将文件挂载出来保存, 然后再拿保存起来的文件挂载进正式的环境里用.

具体方法是:

$ touch easyconn
$ docker run --device /dev/net/tun --cap-add NET_ADMIN -ti -v "$PWD/easyconn:/root/.easyconn" -e EC_VER=7.6.3 hagb/docker-easyconnect:cli

在挂载之前, 首先要创建起这个文件. 因为 Docker 的挂载在本地和容器内的该路径都不存在的情况下, 会选择在本地这个路径创建一个 文件夹, 然后把文件夹挂进去. 这样当容器里的 easy-connect 想创建配置文件的时候就创建不出来了. 所以要先在本机这个路径创建出一个文件, 然后 Docker 才会把这个路径当成文件挂载进去.

接下来容器应该会询问你的 VPN 地址, 用户名跟密码. 输入后看见 login successfully 就可以直接停止容器了. 随后可以使用下面的 docker-compose 启动一个长期跑的容器:

version: '3'
services:
  easyconn:
    image: hagb/docker-easyconnect:cli
    restart: always
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      - EC_VER=7.6.3
    volumes:
      - ./easyconn:/root/.easyconn

问题二: 如何让反向代理的上游经过 socks5 代理?

这个问题可难搜了. 因为 reverse proxysocks/http proxy 这两个关键字天然犯冲, 直接去搜多半是搜到一堆跟你讲正向代理与反向代理区别的文章. 在经过一阵挣扎疯狂替换搜索词搜索后, 终于被我找到了一个 GitHub Repo: rsocks. 这个 python 写的小脚本可以作为一个反向代理, 并且经过某个 socks5 代理再连接上游.

问题三: 如何处理写死的 baseUrl?

当我兴冲冲地打开 ak.origami404.top, 看到登录界面的时候, 我那叫一个激动啊. 然而当我尝试登录的时候, 发现根本登录不上去…

一开 F12, 发现这个 OJ 项目是一个前后端分离的项目, 在传过来的前端 JS 文件里, 写死了一个后端地址: http://10.1.1.1:9001, 这下麻烦了.

一开始, 我想找找有没有能够拦截并修改收到的 HTML/JS 文件内容的浏览器扩展, 我倒是找到了一个叫 GooReplacer 的, 但是看起来不大好用. 于是我就开始折腾 MITM 了. 等到晚上我水群的时候, 群友给我发了一个 JsProxy. 这个跑在浏览器里, 利用 ServiceWorker 的玩意看起来比我配的服务器端 MITM 靠谱多了, 但是由于我已经配好了, 也没有去尝试这个. 如果读者有兴趣的话可以尝试一下.

那么, MITM 是什么呢? MITM, Man-In-The-Middle, 中间人攻击, 是一种常见的攻击手段, 详见维基百科. 在这里我选择了 mitmproxy 作为一个 “中间人服务器”. 选它的主要原因是其支持 Python 脚本, 并且可以对外作为一个反向代理服务器使用.

首先来写一个 mitmproxy addon:

import mitmproxy.http
from mitmproxy import ctx

# 辅助变量: 需要修改的 HTTP 响应的 MIME 类型
target_mime = {
    'text/html', 'text/javascript', 
    'text/css', 'application/json',
    'application/javascript',
}


# 辅助变量: 需要替换的字符串
replacements = {
    b'http://10.1.1.1:9000': b'https://ak.origami404.top',
    b'http://10.1.1.1:9001': b'https://ak.origami404.top/api',
}


# 辅助函数: 根据 MIME 头判断是否应该修改这个响应 
def is_proper_mime(ct: str) -> bool:
    # 有些框架的 MIME 类型会类似这样: "text/css; charset=utf-8"
    # 所以需要做下面的处理
    return ct.split(';')[0] in target_mime


# Addon 的主体部分, 定义了当发生特点事件时动作
class Interceptor:
    # 当 mitmproxy 拦截到一个响应时
    def response(self, flow: mitmproxy.http.HTTPFlow):
        # 获得响应本身
        response = flow.response
        if not response:
            return

        # 根据响应的 MIME 头先筛选一次, 防止脚本对很多 jpg/png 等二进制文件也做替换
        if ct := response.headers['Content-Type']:
            if is_proper_mime(ct) and response.content:
                # 依次修改想要修改的字符串
                for old, new in replacements.items():
                    response.content = response.content.replace(old, new)


# 注册 Interceptor
addons = [ Interceptor() ]

将脚本保存为 run.py 并且通过环境变量 UPSTREAM 指定上游服务器, 随后使用命令即可运行这个脚本. 这样做的主要是为了方便将这个 mitmproxy 做成 Docker 镜像.

mitmdump --mode "reverse:${UPSTREAM}" -s /run.py

HTTPS 与重写 URL

既然是要把校内的服务暴露到公网, 怎么着都得开一个 HTTPS 吧. 并且由于前后端跨域问题, 我选择手动把 https://ak.origami404.top/api/* 重写到 http://10.1.1.1:9001/*, 其他的请求都直接发送到 http://10.1.1.1:9000. 由于之前我的服务器上就跑着一个 Caddy-docker-proxy, 所以我只需要在各个容器的 labels 里写上对应的规则就行了.

加起来

最终, 我的全套流程大概如下:

+-----------+    +-----------+    +------------------+    +-----------+
|  easy     |    |  rsocks   |    |mitmproxy-backend |    | Caddy     |
| connect   |    |           |    |                  |    |           |
|           |    |      10001+--->|              8080+--->| provide   |
|           |    |           |    |   change         |    | HTTPS     |
|           |    |           |    |   baseUrl        |    | and       |
|           |    | reverse   |    +------------------+    | rewrite   |
|           |    | proxy     |                            | URL       |
|           |    | though    |                            |           |
|       1080+--->| socks5    |                            |         80|
|           |    | proxy     |                            |        443|
|           |    |           |    +------------------+    |           |
|           |    |           |    |mitmproxy-frontend|    |           |
|           |    |           |    |                  |    |           |
|           |    |      10002+--->|              8080+--->|           |
|VPN->socks5|    |           |    |   change         |    |           |
|           |    |           |    |   baseUrl        |    |           |
+-----------+    +-----------+    +------------------+    +-----------+

具体的配置文件详见这个仓库: WiredSetup.

使用体验

意外地还行. 我其实对这一套配置没有一点信心, 但出乎我意料的是, 它运行得很好 – 除了偶尔会断连之外(虽然我觉得断连应该都是深信服的问题). 学校内网里的大部分服务都是 HTTP (毕竟内网里申不到 HTTPS 证书), 所以有了这套设施, 可以把学校里绝大部分服务都搬到外网来了. 我也可以安心等学校安全中心给我打电话了(大雾).