在 macOS 上用 Docker 运行 EasyConnect,并用 Clash TUN 做分流
深信服没有提供 Apple Silicon 原生的 EasyConnect,本文用 Docker 跑图形版客户端,再借 Clash Verge Rev 的 TUN 模式和全局扩展脚本,把内网 IP、内网域名和无后缀短名精准分流进 VPN。
macOS 上装 EasyConnect 一直是件别扭事:深信服只提供 x86_64 的安装包,Apple Silicon 机器要么装 Rosetta 2 转译,要么另想办法。我这边的方案是把它扔进 Docker 里跑,宿主机只暴露一个 SOCKS5 端口,再用 Clash Verge Rev 的 TUN 模式配合全局扩展脚本来决定哪些流量走 VPN。
这套东西和 Linux 上的做法完全不同——在 Debian 13 安装 EasyConnect 里是直接修本地依赖库,macOS 上是把整个客户端隔离进容器。
为什么选 Docker
- 没有原生 arm64 包:官方 macOS 客户端是 x86_64 的,靠 Rosetta 2 转译能跑,但为了一个 VPN 客户端常驻一套转译层不太划算。
- 沙箱更安心:EasyConnect 会改路由表、接管 DNS、装后台进程,在容器里折腾,退出容器就干净了。
- 天然支持按需分流:容器登录后自带
1080(SOCKS5)和8888(HTTP)两个代理端口,宿主机不用把全部流量交给它。 - 跨平台复用:同一份 compose 在 Linux 上也能直接跑。
代价也说清楚:图形界面得通过 VNC 看,登录要手动点一次;容器重启后 VPN 会话就没了,需要在 VNC 里重新登录。
目录结构
easyconnect/
├── docker-compose.yml
├── .env # VNC 密码,不进版本库
├── custom_hosts # 内网域名 → 内网 IP,挂进容器的 /etc/hosts
├── certs/
│ └── mycert.p12 # 登录用的证书,不进版本库
└── .ecdata/ # 容器 /root 的持久化目录,里面有登录态,不进版本库
docker-compose.yml
services:
easyconnect:
image: hagb/docker-easyconnect:7.6.7
container_name: easyconnect
restart: unless-stopped
cap_add:
- NET_ADMIN # VPN 要建 tun 网卡,必需
devices:
- /dev/net/tun:/dev/net/tun # 同上,必需
environment:
PASSWORD: "${EC_PASSWORD}" # VNC 密码,最多 8 位
URLWIN: "1" # VPN 想调浏览器时弹框给出链接
DISABLE_PKG_VERSION_XML: "1" # arm64 必加,否则版本校验过不去
CLIP_TEXT: "https://<vpn-portal>" # 预置到 VNC 剪贴板,登录时直接粘贴
PING_ADDR: "10.20.0.10" # 内网地址,定时 ping 保活
volumes:
- ./.ecdata:/root # 登录态持久化
- ./custom_hosts:/etc/hosts:ro # 内网域名映射
- ./certs/mycert.p12:/root/mycert.p12:ro # 数字证书
ports:
- "127.0.0.1:5901:5901" # VNC
- "127.0.0.1:1080:1080" # SOCKS5
- "127.0.0.1:8888:8888" # HTTP 代理
.env:
EC_PASSWORD=<8 位以内的密码>
几个参数值得说明:
| 参数 | 作用 |
|---|---|
EC_PASSWORD / PASSWORD |
VNC 密码。VNC 协议限制最多 8 位,写长了会被截断 |
DISABLE_PKG_VERSION_XML |
arm64 镜像必需,绕过客户端版本号校验 |
CLIP_TEXT |
把登录地址塞进 VNC 剪贴板,省得在 VNC 窗口里手敲一长串地址(也可用来粘中文用户名) |
PING_ADDR |
定时 ping 一个只有通过 VPN 才能到达的内网地址,把会话保活;留空则不 ping |
ports 一律绑 127.0.0.1 |
这些端口等于半个内网入口,绝不能对外暴露 |
Tip
镜像默认走 Docker Hub。国内拉不动可以在 image 前面加加速前缀(我用的是gh-proxy.org/docker/),拉取成功后再去掉也行。所有 -e 参数写成 ${XX} 形式、密码放进 .env,比直接把密码硬写在 compose 里干净。
custom_hosts:内网域名在容器里解析
公司内网的服务大多是这样三类:
- 正经域名,但公网 DNS 查不到,比如
nexus.internal.example.com - Kubernetes 集群内部域名,形如
xxx.stable.svc.cluster.local - 完全没有后缀的短名:
ftp、ks、gateway这种,只有内网 DNS 认识
宿主机上我不写这些映射——写上去就等于把域名提前解析成 IP,Clash 只能看到 IP 看不到域名,按域名分流就废了。映射只放在容器里:
# 研发/中间件集群
10.20.x.10 nexus.internal.example.com jenkins.internal.example.com
10.20.x.10 git.internal.example.com wiki.internal.example.com
10.20.x.10 gateway.stable gateway-service.test gateway.internal.example.com
10.20.x.20 logs.internal.example.com grafana.internal.example.com
10.20.x.30 ftp ks audit paymentplatform
# 公网映射的特殊地址
203.0.113.7 web.example.com
203.0.113.10 y.example.com
# 固定几个 GitHub raw 的 IP,避免被 VPN 抢走
185.199.108.133 raw.githubusercontent.com
185.199.109.133 raw.githubusercontent.com
Warning
挂载的/etc/hosts 会整个替换容器自带的 hosts 文件,记得自己补上 127.0.0.1 localhost,否则容器内 localhost 可能解析不出来。
启动并登录
docker compose up -d
docker logs -f easyconnect # 看启动过程
然后用 macOS 自带的「屏幕共享」连 VNC:
open vnc://127.0.0.1:5901
也可以直接用 Finder → 前往 → 连接服务器,输入 vnc://127.0.0.1:5901,密码填 .env 里那个。
连上后就是一个完整的 EasyConnect 登录窗口:填写 VPN 地址(CLIP_TEXT 已经放到剪贴板里,直接 Cmd+V)、账号密码,需要证书的话选中挂载进来的 .p12。登录成功后终端里会看到路由和 DNS 被改写的日志。
先单独验证 SOCKS5
在把所有东西交给 Clash 之前,先确认容器这条链路本身是通的:
# 域名交给代理端解析(容器内的 /etc/hosts 才会生效)
curl --socks5-hostname 127.0.0.1:1080 -I http://nexus.internal.example.com/
这里有个很容易踩的坑:--socks5 是本地解析域名再把 IP 交给代理,宿主机根本不认识 nexus.internal.example.com,会直接报解析失败;必须用 --socks5-hostname(或 socks5h://)让域名发到容器里再解析。
接进 Clash Verge Rev
1. TUN 模式
TUN 是这套方案能成立的前提,原因有两条:
- 只看域名的规则要能命中:内网短名、
*.svc.cluster.local这些名字公网查不到,只有让 Clash 自己接管 DNS 才能拿到域名做匹配;纯系统代理模式下很多程序(终端里的nc、mysql、redis-cli)压根不读代理设置。 - 非 HTTP 协议也得走 VPN:内网一堆 MySQL、Redis、Kafka、ES 端口,这些没法用 HTTP 代理,TUN 才能一层层兜住。
配合 fake-ip(默认 198.18.0.0/16)使用:应用访问 gateway.stable 时,Clash 返回一个假 IP 并记住「这个假 IP 对应 gateway.stable」,规则就能按域名把它送去 SOCKS5;真正的解析在容器里由 /etc/hosts 完成。不要在宿主机 /etc/hosts 里写内网映射,会把这条链路掐断。
2. 全局扩展脚本
与其每次改订阅都手写一遍规则,不如用 Clash Verge Rev 的「全局扩展脚本」把所有 profile 统一加工一遍。脚本入口固定是 main(config),返回改写后的配置:
// Define main function (script entry)
function main(config) {
// 1. 定义 EasyConnect 的 SOCKS5 代理
const easyConnectProxy = {
name: "EasyConnect-VPN",
type: "socks5",
server: "127.0.0.1",
port: 1080,
udp: true,
};
if (!config.proxies) config.proxies = [];
if (!config.proxies.some(p => p.name === easyConnectProxy.name)) {
config.proxies.push(easyConnectProxy);
}
// 2. 内网分流规则
const internalRules = [
// 【防误伤】容易撞车的公网域名优先直连
"DOMAIN-SUFFIX,raw.githubusercontent.com,DIRECT",
// 【内网 IP 段】注意 /8 的范围,见下面的坑
"IP-CIDR,10.0.0.0/8,EasyConnect-VPN,no-resolve",
"IP-CIDR,192.0.2.0/24,EasyConnect-VPN,no-resolve",
// 排错用:单个 IP 的 /32 路由(已被上面 /8 覆盖,先留着)
"IP-CIDR,10.20.0.10/32,EasyConnect-VPN,no-resolve",
// 【公网映射的内网服务】换成你自己的映射地址
"IP-CIDR,203.0.113.10/32,EasyConnect-VPN,no-resolve",
"IP-CIDR,198.51.100.20/32,EasyConnect-VPN,no-resolve",
// 【内网域名后缀】
"DOMAIN-SUFFIX,example.com,EasyConnect-VPN",
"DOMAIN-SUFFIX,example-corp.cn,EasyConnect-VPN",
"DOMAIN-SUFFIX,cluster.local,EasyConnect-VPN", // K8s 内部域名
"DOMAIN-SUFFIX,svc,EasyConnect-VPN", // K8s 简写后缀
// 【没有后缀的短名】只能精确匹配
"DOMAIN,ftp,EasyConnect-VPN",
"DOMAIN,ks,EasyConnect-VPN",
"DOMAIN,audit,EasyConnect-VPN",
"DOMAIN,gateway.stable,EasyConnect-VPN",
"DOMAIN,gateway-service.test,EasyConnect-VPN",
"DOMAIN,paymentplatform,EasyConnect-VPN",
];
// 3. 清理旧规则再注入,避免反复加载后一堆重复
let originalRules = config.rules || [];
originalRules = originalRules.filter(rule => !rule.includes("EasyConnect-VPN"));
config.rules = [...internalRules, ...originalRules];
return config;
}
几点设计考虑:
- 节点由脚本注入:订阅更新会把
proxies整个换掉,写死在脚本里就不会丢。 - 规则前置:
config.rules是数组,前面的先匹配,所以内网规则必须排在订阅自带规则之前。 no-resolve不是可选项:IP-CIDR命中前 Clash 可能要先把域名解析成 IP,加no-resolve表示「别为了匹配这条规则去查 DNS」,既快,也避免把内网域名泄漏给公网 DNS。- 精确短名用
DOMAIN:gateway.stable这种名字用DOMAIN-SUFFIX容易误伤,精确匹配更稳。 - 重复加载要幂等:脚本每次重载都会跑,先用
filter剔掉旧规则再拼,不然规则会越滚越多。
3. 确认分流生效
TUN 打开后,在终端直接访问就行,不需要 --proxy:
curl -I http://gateway.stable/
curl -I https://nexus.internal.example.com/
在 Clash 的「连接」面板里能看到每条连接命中的规则和出口是 EasyConnect-VPN 还是直连——比看日志猜快得多。
踩过的坑
IP-CIDR,10.0.0.0/8 会顺手带走家里的设备。 这个段太宽,NAS、打印机、路由器管理页只要在 10.x 里,就一起被塞进 VPN,然后打不开。要么收窄到公司实际使用的段(比如 10.20.0.0/16、10.30.0.0/16),要么在前面补几条 IP-CIDR,10.x.x.x/32,DIRECT 例外。
.local / svc 这类后缀要显式写规则。 这类名字不会走公网 DNS,Clash 的默认规则里也没有,不写就是直连然后超时。cluster.local、svc 两个后缀基本能覆盖绝大多数 K8s 内部服务,剩下的按需补 DOMAIN,。
容器重启后要重新登录。 restart: unless-stopped 只保证容器起来,VPN 会话不会自动恢复。PING_ADDR 能让会话活得久一点(服务端通常按「无流量超时」断线),但断线后还是得连 VNC 手动登录一次。
不要把 .ecdata、certs、.env 提交到版本库。 .ecdata 里有登录态和会话配置,certs 里是证书私钥,.env 里是密码。.gitignore 三行解决。
VNC 密码只能 8 位。 这是 VNC 协议的限制,不是镜像问题;写长了会被悄悄截断,然后你以为是密码错了。
端口一律绑 127.0.0.1。 1080、8888 相当于一个不用认证的内网入口,0.0.0.0 绑出去等于把公司内网挂到公网上。
小结
这套方案的分工其实很清楚:
- Docker 容器 负责「连上 VPN」和「解析内网域名」,对外只暴露
127.0.0.1:1080 - Clash Verge Rev 负责「决定哪些流量走 VPN」,靠 TUN 接管全流量、靠 fake-ip 保留域名、靠全局扩展脚本注入规则
- 分流规则 负责精准:内网 IP 段、内网域名后缀、无后缀短名,三类分别用
IP-CIDR、DOMAIN-SUFFIX、DOMAIN收口
好处是不用装 Rosetta、不用把整台机器交给 VPN,坏处是登录要手动点一次,而且规则维护是长期活儿——每多一个内网服务,就多一条规则或者一行 hosts。