客户端 Docker 部署
来源:
intranet-tunnel/deploy/docker/client/README.md(整篇)(原文 15426 字符)
内网穿透客户端 · Docker 部署
把客户端跑在容器里,用来把宿主机或内网里的服务暴露到公网。 客户端只发起出站连接(服务端控制端口 47800),不需要额外的网络能力—— 正是这一点让它适合容器化。
唯一的入站端口是 47802,且仅当 WEB_ENABLE=true 时才监听: 那是内置的 Web 配置界面(浏览器里改配置、管隧道、看日志、启停连接)。 默认绑所有接口,便于从局域网浏览器访问(那台 NAS 上没法开浏览器); 如需限制来源,见第四节。关闭它时客户端不监听任何端口。
一、和其他部署方式的区别
| 裸机 / exe | 本容器 | |
|---|---|---|
| 安装 | 下载二进制或安装包 | sh init.sh + docker compose up -d |
| 配置 | client.env 放在程序同目录 | client.env 由 compose 注入(优先级更高) |
| 改配置 | 改文件后重启进程 | 改文件要重建容器;改 Web 界面即时生效 |
| 开机自启 | systemd / 计划任务 | restart: unless-stopped |
| 日志 | 文件或控制台 | docker logs(建议 LOG_FORMAT=json) |
| 持久化数据 | 程序同目录 | 部署目录的 ./data/(bind mount) |
| 内网地址 | 127.0.0.1 就是宿主机 | ⚠️ 127.0.0.1 是容器自己 |
最后一行是唯一需要动脑的地方,第六节专门讲。
二、前置准备
- 服务端已部署并可通过公网访问控制端口(默认
47800)。 - 在服务端面板「客户端」页新建一个客户端,记下只显示一次的 Token。
- 导入客户端镜像(客户端是独立交付物:
intranet-tunnel-client-deploy-1.2.1.zip
里含镜像、compose、配置模板、init.sh 与本说明):
docker load -i intranet-tunnel-client-images-1.2.1.tar
docker images | grep intranet-tunnel/client有源码时也可以自己构建(在仓库根目录执行):
docker build -f client/Dockerfile -t intranet-tunnel/client:1.2.1 .构建上下文必须是仓库根目录——客户端依赖
../shared模块。
三、部署
cd <本目录>
# ① 第一步:整理目录(建 data/ 并授权、生成 client.env)
sh init.sh
# ② 填写必填三项
vi client.env # SERVER_ADDR / CLIENT_ID / TOKEN
# ③ 启动
docker compose up -d
docker compose logs -f连接成功的日志形如:
{"level":"INFO","msg":"内网穿透客户端启动","version":"1.2.1","client_id":"my-server","server":"tunnel.example.com:47800","tls":true,"mux":true,"static_tunnels":0,"web_ui":true}
{"level":"INFO","msg":"配置库就绪","file":"/app/data/client.db","dir":"/app/data","wal":"/app/data/client.db-wal"}
{"level":"INFO","msg":"客户端登录成功","tunnels":3}tunnels=N 是本次登录后生效的隧道总数(本地静态 + 服务端下发, 所以可能多于 tunnels.json 里的条数,见第七节);同时在服务端面板 /healthz 的 online 计数会 +1。
⚠️ 为什么必须有 init.sh 这一步
Docker 对不存在的 bind mount 源目录会自动创建,但属主是 root; 而客户端容器以 tunnel(uid 10001)运行 → 写 SQLite 配置库时 permission denied。
这个故障的症状非常隐蔽:
- 容器
healthy(进程活着、Web 界面也能开); - 日志里只有一行
WARN ... 配置库打开失败,降级为无持久化(穿透主功能不受影响); - 界面里改的配置、新建的隧道,重启后全部丢失。
服务端为同类问题踩过「无限重启」,所以数据目录必须显式创建并授权:
mkdir -p ./data
sudo chown -R 10001:10001 ./data授权其实是两层的,只做其中一层就会退回上面的症状:
| 层 | 保证什么 | 由谁完成 |
|---|---|---|
镜像内的 chown(client/Dockerfile 里的 chown -R tunnel:tunnel /app) | 只保证不挂卷时 /app/data 可写 | 构建镜像时一次做完 |
宿主机 ./data 的属主 | bind mount 之后,容器看到的是宿主机目录的属主,镜像里那次 chown 不再起作用 | 部署时 chown 10001:10001 ./data,即 init.sh |
最容易漏的就是第二层——「镜像里已经 chown 过了」是很自然但错误的推断: /app/data 一旦被宿主机目录覆盖,就以宿主机目录的属主为准了。 而这一层漏掉的症状恰好最不显眼:容器照样 healthy、Web 界面照常打开, 日志里只有一行 WARN ... 配置库打开失败,降级为无持久化,配置一重启就没了。
init.sh 做的就是这两件事(外加生成 client.env 与体检),而且是幂等的, 可以反复执行。用 sh init.sh 调用即可,不依赖文件的可执行位 (Windows 解压出来的包没有它)。
四、Web 配置界面
WEB_ENABLE=true(部署模板默认开启)后,浏览器访问:
http://<运行客户端的机器 IP>:47802 # 例如 http://<内网IP>:47802
http://127.0.0.1:47802 # 只在该机器本机上端口绑在哪里:默认绑所有接口
compose 里的端口映射默认是:
ports:
- "47802:47802" # 等价于 0.0.0.0:47802:47802即绑定所有接口,便于从局域网里的浏览器直接访问 —— 部署客户端的 NAS 上 通常没有浏览器,这个界面本来就是给局域网里的用户用的。
旧取舍(已改):更早的默认值是
"127.0.0.1:47802:47802",只绑宿主机回环, 于是从局域网根本连不上。典型现象是 NAS 上ss -lnt | grep 47802显示127.0.0.1:47802、docker port tunnel-client显示-> 127.0.0.1:47802。 当时的理由是"管理界面不默认对局域网敞开",但它与真实用法冲突(那台 NAS 上没法开浏览器), 所以改成默认绑所有接口。
默认放开,但不等于可以随便暴露。 界面自身有这几层保护:
- 访问口令 —— 打开界面就要登录(口令来源见下一小节,首次访问需要它);
- 会话 Cookie 带
HttpOnly+SameSite=Lax,有效期 12 小时; - 所有写操作要求 CSRF 令牌(与会话绑定);
- 单 IP 登录失败限流(1 分钟内 10 次)。
仍然建议按需限制来源,两种做法:
# ① 绑特定地址:只有从该网卡/该地址能访问
ports:
- "<内网IP>:47802:47802"② 或在 NAS 防火墙上限制来源网段(例如只放行 <内网IP>/24)。
⚠️ 若 NAS 上还有其它网络接口(例如 VPN 网段、Docker 网桥),绑
0.0.0.0会同时在那些接口上监听 —— VPN 里的对端也能访问到这个界面。 请按自己的需要选择绑定方式,不要默认"内网就等于可信"。
首次访问需要口令
口令默认是首次启动时随机生成并打印在容器日志里(之后入库,重启不变):
docker logs tunnel-client 2>&1 | grep 口令
# 或:docker compose logs tunnel-client | grep 口令日志形如:
msg="首次启动已生成 Web 界面访问口令,请立即保存(也可用 WEB_PASSWORD 环境变量固定)" password=xxxxxxxxxxxx source=generated想把它固定下来,就在 client.env 里设 WEB_PASSWORD=<你自己设的值>, 然后 docker compose up -d --force-recreate(restart 不重读 env_file,见第五节)。
⚠️
WEB_ENABLE的默认值是false。 容器里是靠镜像自带的client/.env.docker把它打开的,本目录的client.env.example也已显式写上WEB_ENABLE=true。但如果你自己维护client.env(例如从旧版本手工写过来的), 就必须自己补上这一行——否则用的是内置默认值false, 容器照常运行,47802却没有任何监听。判断当前值:看启动日志那一行的
"web_ui":true(false即未开启,见第三节的日志样例)。
口令从哪来
| 顺序 | 来源 | 说明 | |
|---|---|---|---|
| 1 | WEB_PASSWORD 环境变量(client.env) | 设了就始终以它为准,界面里不可修改 | |
| 2 | 配置库里的 bcrypt 哈希 | 界面里改过口令时存在这里 | |
| 3 | 首次启动随机生成 | 打印到容器日志,用 `docker compose logs \ | grep 访问口令` 取 |
留空(即模板里的 # WEB_PASSWORD=)就是第 3 种:随机生成并入库, 重启不变。首次启动的日志形如:
msg="首次启动已生成 Web 界面访问口令,请立即保存(也可用 WEB_PASSWORD 环境变量固定)" password=xxxxxxxxxxxx source=generated界面能做什么
| 区域 | 能力 |
|---|---|
| 连接状态 | state / 运行时长 / 服务端 / TLS / 在线隧道数 / 配置库路径 |
| 配置 | 表单式编辑并保存;保存后逐项回读比对,结果直接显示在页面上 |
| 静态隧道 | 增、删、改、启用/停用(含"容器里 127.0.0.1 是容器自己"的提示) |
| 连接控制 | 启动 / 停止 / 重启连接 |
| 日志 | 实时推送(SSE),缓冲上限 1000 行 |
| 生效配置 | 与命令行 -print-config 同一口径,敏感项已掩码,并标注每项的来源 |
口令与会话
- 会话用
HttpOnly+SameSite=Lax的 Cookie,有效期 12 小时; - 所有写操作要求 CSRF 令牌(与会话绑定);
- 单 IP 登录失败限流(1 分钟内 10 次)。
五、配置与数据落在哪里
数据文件
<部署目录>/
├── docker-compose.yml
├── client.env # 由你维护(init.sh 不会覆盖已存在的)
├── init.sh
└── data/ # bind mount 到容器内 /app/data
├── client.db # SQLite 配置库(界面保存的配置与隧道)
├── client.db-wal # WAL 日志(与库同目录,别单独删)
└── client.db-shm # 共享内存索引用 bind mount ./data:/app/data 而不是命名卷,因为命名卷落在 /var/lib/docker/volumes/ 下、不在项目部署目录里——备份与迁移时最容易漏掉。
启动日志会打印实际生效的绝对路径,可直接确认它落在部署目录下:
msg=配置库就绪 file=/app/data/client.db dir=/app/data wal=/app/data/client.db-wal备份
docker compose stop tunnel-client
tar czf client-data-$(date +%F).tar.gz data/ # 连 -wal / -shm 一起打包
docker compose start tunnel-client也可以在线用
sqlite3 data/client.db ".backup 'backup.db'"(宿主机需有 sqlite3)。 不要在容器运行时只拷client.db而丢掉-wal:未 checkpoint 的数据都在 WAL 里。
配置优先级(务必理解)
显式环境变量(client.env / compose) > SQLite 配置库(Web 界面) > .env 文件 > 内置默认值由此推出两条使用规则:
| 你改的是 | 生效方式 | 命令 |
|---|---|---|
client.env | 必须重建容器 | docker compose up -d --force-recreate |
| Web 界面 | 立即生效(连接在跑时点「重启连接」) | 界面里点保存 |
env_file 只在创建容器时读取,所以 restart 对 client.env 的改动无效—— 这是本项目反复踩到的一类坑。
反过来,在 client.env 里设置过的键,在 Web 界面上是只读的,并会标注 「由环境变量接管」。这是刻意设计:让它们可编辑、保存后又静默不生效, 是最难排查的一类故障。想改用界面管理某项配置,就把它从 client.env 里删掉 (注释掉不算,键仍然存在),再 up -d --force-recreate。
WEB_ENABLE/WEB_LISTEN/DB_FILE是启动期参数,界面里只读展示、不可编辑 ——若允许在界面上关掉 Web,就再没有入口把它开回来了。
六、⚠️ 内网地址(local_addr)怎么填
这是容器化后唯一容易踩的坑。 隧道的「内网地址」是客户端进程所在环境的地址, 而容器里的 127.0.0.1 指向容器自己,不是宿主机。
按被穿透服务的位置分三种情况:
| 服务在哪里 | 内网地址填 | 说明 |
|---|---|---|
| 宿主机上(最常见) | host.docker.internal | compose 里已配 extra_hosts: host.docker.internal:host-gateway,开箱可用 |
| 内网另一台机器 | 它的 IP,如 <内网IP> | 与裸机部署完全一样,最通用 |
| 同一 Docker 网络里的另一个容器 | 那个容器的服务名,如 nginx | 需要把两者放进同一个 network |
填写位置有两个:服务端面板(隧道管理 → 编辑 → 内网地址)或 Web 配置界面(静态隧道区)。两边都行,库中已有记录时以客户端配置库为准。
也可以在客户端侧用静态隧道声明(
TUNNELS或TUNNELS_FILE), 但服务端已配置隧道时没必要——客户端登录后会自动拉取。
想让 127.0.0.1 保持"就是宿主机"的语义?
在 Linux 上可以让容器直接用宿主网络:
services:
tunnel-client:
network_mode: host
# 注意:host 模式下 extra_hosts 与 ports 都不再适用,需要删掉这样 127.0.0.1 与裸机部署语义一致,已有隧道的内网地址一个都不用改。
⚠️ Docker Desktop(Windows / macOS)对 host 网络的支持与 Linux 不同, 不建议在那里使用;用 host.docker.internal 更稳妥。
用静态隧道声明时,local_addr 怎么填
隧道若不是写在服务端面板、而是客户端侧声明,两种写法能力并不相同:
| 写法 | 能否指定 local_addr | 容器场景 |
|---|---|---|
TUNNELS_FILE(JSON 文件) | ✅ 可以 | 用这个,写 host.docker.internal |
TUNNELS(内联简写 name:type:port:domain) | ❌ 固定 127.0.0.1 | 容器里那是容器自己,不可用 |
内联简写不能写 JSON —— 写了会在启动时报 config: TUNNELS 项 ... 的本地端口非法。所以容器场景一律走 TUNNELS_FILE:
部署模板的 volumes: 段里已经以注释形式保留了这一行,按需解开即可 (用途与注意事项就写在那段注释里,解开时一起看):
volumes:
- ./data:/app/data
- ./tunnels.json:/app/tunnels.json:ro # 模板里默认注释掉⚠️ 解开注释前先确认宿主机的
./tunnels.json确实是个文件: 源路径不存在时 Docker 会自动建一个同名目录,容器里读到的是目录, 播种一样读不到东西。用ls -l tunnels.json确认。
TUNNELS_FILE指向的隧道只在首次建库(配置库为空)时播种进配置库; 之后以库为准(界面上改的不会被文件覆盖回来),再改这个文件也不会生效。 想让文件重新生效只有一条路:删掉配置库重新播种—— 那会一并丢掉界面上保存的配置与隧道,别轻易做。
七、验证与排查
# 看实时日志
docker compose logs -f
# 打印生效配置(Token 已脱敏)——判断"配的到底是什么"最直接的办法
docker compose exec tunnel-client /usr/local/bin/tunnel-client -env /app/.env -print-config
# 版本
docker compose exec tunnel-client /usr/local/bin/tunnel-client -version
# 容器是否健康
docker compose ps
# Web 配置界面与健康检查端点
curl -s http://127.0.0.1:47802/healthz/healthz 无需鉴权(容器健康检查拿不到口令),返回形如:
{"persistence":true,"running":true,"state":"connecting","status":"ok","tunnels":0,"uptime":"12s","version":"1.2.1"}persistence:false 意味着配置库不可用(多半就是 data/ 权限问题,见第三节)。
tunnels 是当前生效的隧道总数,等于「本地静态(tunnels.json / 界面上建的) + 服务端下发」之和 —— 所以它可能多于 tunnels.json 里的条数: 服务端会额外下发自己的隧道。实测 NAS 上就是 3 条本地 + 1 条服务端下发 = 4, 看到数字比文件里的多,属正常,不是重复注册。
怎么确认访问真的走了本方案
判据是响应头 X-Proxy-By: intranet-tunnel(内置反代添加的):
curl -sI https://<隧道域名>/ | grep -i '^x-proxy-by'⚠️ 只看状态码会被应用自带的响应头带偏:被穿透的应用自己也会发一堆头 (Portainer 就有 CSP、X-Csrf-Token 等),看着「像另一个系统」, 但它们只证明「应用收到了请求」,不证明请求是经由本方案进来的。
更硬的判据是与后端直连做 SHA256 逐字节比对:
# ① 后端直连(在客户端所在机器上执行;端口换成被穿透服务的端口)
curl -s http://host.docker.internal:18080/ | sha256sum
# ② 经隧道(域名要解析到服务端公网 IP;不要 --resolve 指到 127.0.0.1,
# 那会绕过公网入口的 SNI 处理,实测会返回 404,容易被误判成配置没生效)
curl -s https://<隧道域名>/ | sha256sum两条哈希一致,才说明隧道端到端逐字节透传。页面含动态内容(时间戳、CSRF 令牌)时 哈希本来就会不同,这时改用 curl -sI 比对响应头。
常见问题
| 现象 | 原因与处理 | |
|---|---|---|
客户端启动失败: config: TOKEN 不能为空 | client.env 没填或名字不对;compose 读的是 ./client.env,不是 .env | |
Token 校验失败 | Token 失效——服务端面板里重置后更新 client.env,再 docker compose up -d(restart 不重读 env_file) | |
connection refused / i/o timeout | 服务端地址或端口不对,或服务端 47800 未放行;先在宿主机上 nc -vz <SERVER_ADDR> 验证 | |
x509: certificate is valid for ... | 服务端用自签证书,需 TLS_INSECURE=true;若有受信任证书则应为 false | |
| 隧道在面板里显示在线,但访问域名 502 | 大概率是内网地址问题:容器里的 127.0.0.1 指向容器自己,见第六节 | |
| 日志刷屏 | 调 LOG_LEVEL=warn | |
改了 client.env 不生效 | env_file 只在创建容器时读取:docker compose up -d --force-recreate | |
| 界面打不开 | ①WEB_ENABLE=true 了吗;②WEB_LISTEN 必须是 0.0.0.0:47802(写 127.0.0.1 只绑容器自己的回环);③从局域网访问时确认 compose 的 ports 是 "47802:47802",不是只绑回环的 "127.0.0.1:47802:47802";改完必须 up -d 重建(restart 不重建容器,端口映射不会变);④在 NAS 上 `ss -lnt \ | grep 47802` 看实际绑在哪个地址 |
| 界面能开,但保存的配置重启就没了 | data/ 没有授权给 uid 10001,配置库降级为无持久化;日志搜「配置库打开失败」,重跑 sh init.sh 并确认 chown 成功 | |
容器一直 unhealthy | 健康检查优先探 /healthz,失败才退回 pgrep 进程存活;两者都失败说明进程真的有问题,看 docker compose logs | |
| 界面里有字段是灰的、写着「由环境变量接管」 | 该键在 client.env 里设置过,优先级更高;见第五节 | |
up / restart 卡住约 60 秒后报 mkdir /home/<用户名>/.docker: permission denied | 家目录不归你所有(NAS 实测属主是 root:root 755)→ 先 export DOCKER_CONFIG=/tmp/dsh-docker-config 绕过,根治用 root 把家目录 chown 还给自己。⚠️ ps / config / logs 等只读子命令不受影响,容易误判成 compose 没问题;见第九节 | |
Conflict. The container name "/<旧ID>_tunnel-client" is already in use | up 被中断后 dockerd 里残留的名字占用,而 docker inspect / docker rm / docker ps -a 都看不到它:先 kill -9 残留的 compose 进程,再 docker rm -f tunnel-client 后 up -d;见第九节 |
关于 EXPOSE / ports
EXPOSE 47802是 Web 配置界面端口,仅当WEB_ENABLE=true时才真正监听;- compose 里的
ports: ["47802:47802"]绑所有接口(等价于0.0.0.0:47802),
局域网内可直接访问;只绑回环的旧写法 "127.0.0.1:47802:47802" 已不再使用, 详见第四节(含如何限制来源);
- 关闭 Web 界面时客户端不监听任何端口,这个端口映射不会有任何作用——
那是正常形态,不是配置漏写。
八、升级
# 1) 导入新镜像
docker load -i intranet-tunnel-client-images-<新版本>.tar
# 2) 改 compose 里的 image tag
sed -i 's|intranet-tunnel/client:1.2.1|intranet-tunnel/client:<新版本>|' docker-compose.yml
docker compose up -ddata/ 是 bind mount,升级不会动它——配置与隧道都在里面,无需重新配置。
客户端与服务端的版本可以不同:控制协议保持兼容, 但新增能力通常需要新版客户端(例如内置 Web 配置界面需要 1.2.0+)。
九、实测记录:飞牛 OS(FNOS)NAS
2026-09-13 在一台飞牛 OS(FNOS,Linux 6.18)NAS 上按本说明部署并跑通。 机型无关,前提是 Linux + Docker Engine 28.5 / Compose v5.1,且当前用户在 docker 组内。
落点与文件:
/vol1/1000/docker/intranet-tunnel-client/ # 沿用 NAS 既有的 /volN/<uid>/docker/<项目> 约定
├── client.env # 权限 600;SERVER_ADDR / CLIENT_ID / TOKEN / TLS_INSECURE
├── docker-compose.yml # 本目录的模板(volumes 里多解开一行 tunnels.json 挂载)
├── tunnels.json # 静态隧道,local_addr 写 host.docker.internal
└── intranet-tunnel-client-images-1.0.4.tar上面是当时(1.0.4)的落点记录,如实保留。自 1.2.0 起部署目录还应有
init.sh,并会生成data/(配置库),见第三节与第五节。
实测结果:
- 容器
Up (healthy)、RestartCount=0;日志已连接服务端并登录成功、static_tunnels=N。 - 服务端
/healthz的online由 0 变 1。 - 三条隧道端到端可用(响应头均带
X-Proxy-By: intranet-tunnel):
uptime-kuma、飞牛 fnOS 面板(5666)、MoviePilot(40900)。
/healthz的tunnels是「本地静态 + 服务端下发」的合计:本次 3 条本地- 1 条服务端下发 = 4,比
tunnels.json里的条数多(见第七节)。 - 新增隧道不需要改云端任何配置:域名都落在
*.tunnel.sushike.cloud泛域内,
边缘 nginx 按泛域复用,配置与隧道条数解耦。改完 tunnels.json 只需 docker compose restart tunnel-client,不必 down。
两个容易吃亏的地方:
- 域名不能跨客户端复用。服务端按
custom_domain查占用,同域名已属于
另一个客户端时,静态隧道会被忽略(日志: 域名已被其它客户端占用,忽略静态隧道)。要接管旧前缀,先在服务端删掉原记录。
- 改
client.env必须up -d --force-recreate(env_file只在创建容器时读取),
而改 tunnels.json 用 restart 就够——两者要求不同,别互相套用。
自 1.2.0 起,隧道的权威来源是部署目录
data/下的配置库:tunnels.json只在首次建库时播种,之后在 Web 界面上增删改即可, 改完点「重启连接」或等下一次自动重连生效(比改文件 +restart更直接)。
⚠️ 升级 / 重启前先 export DOCKER_CONFIG=/tmp/dsh-docker-config
根因不在 compose,而在家目录的属主。 实测那台 NAS 上 /home/ZYJ 的属主是 root:root(权限 755),普通用户建不了 ~/.docker,于是 compose 报
mkdir /home/ZYJ/.docker: permission denied并挂起约 60 秒才返回。⚠️ 只读子命令(ps / config / logs)完全不受影响, 所以很容易据此判定「compose 没问题」,把排查方向带偏。
# 临时绕过:把 Docker 的配置目录指到一个一定可写的位置
export DOCKER_CONFIG=/tmp/dsh-docker-config
# 根治(需 root):把家目录还给该用户。
# 用户名换成你部署时用的那个,组名以你的系统为准(这台 NAS 上是 Users)。
sudo chown <用户名>:Users /home/<用户名>
export只对当前 shell 有效:换个终端(或每次新开会话)都要重新设一遍。 想一劳永逸,就走上面那条chown根治。
⚠️ stale 容器名:up 一直报 Conflict,可那个容器「看不见」
compose up 被中断之后(工具超时、但远程进程没被杀掉也会造成同样的结果), dockerd 的名字注册表里可能留下 <旧容器ID前12位>_<服务名> 这种占用,症状很反直觉:
docker inspect/docker rm都报 no such container;docker ps -a里也列不出它;- 但
up一直报
Conflict. The container name "/<旧ID>_tunnel-client" is already in use by container "..."。
处置顺序是先删再 up:
pgrep -af 'docker compose' # 先看有没有残留的 compose 进程
kill -9 <PID> # 有就杀掉
docker rm -f tunnel-client # 再删掉现有容器
docker compose up -d为什么要「先删」:容器还在时,compose 会先把新容器建成 <旧ID>_<名字> 再改名, 正好撞上那个 stale 名;先把容器删掉,compose 就不必做这次重命名,从而绕开它。
⚠️ 隧道域名有两支泛域,实测只有一支生效
*.t.sushike.cloud 与 *.tunnel.sushike.cloud 是两支不同的泛域, DNS、证书、边缘 nginx 三层各自独立 —— 配了一支不等于另一支可用。 本次 NAS 验证中的实测结果:
<服务名>.t.sushike.cloudTLS 握手失败(SEC_E_WRONG_PRINCIPAL),
且这个名字解析到两个 IP;
- 全部验证最终走
*.tunnel.sushike.cloud成功。
隧道域名的完整形式要与服务端的 TUNNEL_DOMAIN 对应:客户端侧只写前缀时, 由服务端按 TUNNEL_DOMAIN 补全 —— 所以换域名改的是服务端那一处, 不是逐条隧道去改。
来源:
intranet-tunnel/docs/AGENTS-ARCHIVE.md→## 客户端连接云端测试(2026-09-12 实测打通)(原文 2758 字符)
客户端连接云端测试(2026-09-12 实测打通)
结论:本地 Windows 客户端经公网连到云服务器、并由隧道域名访问到本机服务,全链路已验证。
curl https://<服务名>.t.sushike.cloud → 宝塔 nginx(443) → 内置反代(48080)
→ 隧道规则(16777219) → 服务端 47800 → 客户端(TLS+yamux) → 本机 127.0.0.1:8000客户端「双击无法启动」的真正原因
bin/tunnel-client-cli.exe 本身没问题(实测 -version 输出 1.0.0)。 它是 CLI 程序:双击时工作目录是 exe 所在目录,读不到配置 → CLIENT_ID 为空 → 校验失败退出 → 控制台窗口一闪而过,看起来像"无法启动"。
正确用法(工作目录要是配置所在目录,TUNNELS_FILE 用的是相对路径):
cd H:\Works\intranet-tunnel\client
..\bin\tunnel-client-cli.exe -env .env.cloud-test双击场景用 bin\start-client-cloud-test.bat(内部已 cd 到 client 并加 pause)。
配置要点
- 服务端
TLS_AUTO_SELF_SIGNED=true且TLS_CERT_HOSTS=localhost,127.0.0.1
→ 自签证书 SAN 不含公网 IP → 客户端必须 TLS_INSECURE=true,否则握手失败。
- 客户端只需出站连
47800,不监听任何入站端口,因此对容器化很友好。 .env.cloud-test(真实凭据,已被client/.env.*规则忽略).env.cloud-test.example(脱敏模板,入库);沿用项目既有的
.env.test / .env.dockertest / .env.pentest 命名惯例。
没有面板凭据时如何创建客户端
面板登录要过图形验证码(AUTH_CAPTCHA_ENABLED=true),不利于自动化。 可直接写库,Token 哈希算法(auth.HashToken):
token_hash = hex(HMAC-SHA256(key=TOKEN_SALT, msg=token明文))TOKEN_SALT 从容器环境变量取(48 字符)。让盐值留在服务器上算,不要把盐值拉回本地:
SALT=$(docker inspect tunnel-server --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep '^TOKEN_SALT=' | cut -d= -f2-)
HASH=$(printf '%s' "$TOKEN" | openssl dgst -sha256 -hmac "$SALT" | awk '{print $NF}')再 INSERT clients(列:client_id / name / token_hash / enabled / max_tunnels …)。 不要覆盖已有客户端的 token_hash,新建一个专用测试客户端更安全。
静态隧道会自动入库,无需手工建 tunnels
客户端 TUNNELS_FILE 指向的 JSON 在登录时由 ensureStaticTunnels 自动写入 tunnels 表 (同名则跳过),字段为 name / type / local_addr / local_port / custom_domain。
[{ "name":"cloud-test-http", "type":"http", "local_addr":"127.0.0.1",
"local_port":8000, "custom_domain":"<服务名>.t.sushike.cloud" }]tunnels 表没有 domain 列,域名列名是 custom_domain(唯一索引)。
⚠️ 已知缺陷:隧道变更后反代规则不会自动更新
proxy.Manager 提供了 EnsureTunnelRule / RemoveTunnelRule, 但全项目没有任何调用点(control 包不引用 proxy)。后果:
- 客户端上线新建隧道、或在面板新增/修改隧道后,内置反代不会装载对应规则,
访问域名得到 404「没有匹配的反向代理规则」(该 404 页面来自项目内置反代,不是 nginx)。
- 启动服务端时会
reload并打印
反向代理规则已重载 rules=0 tunnel_rules=N —— 这一行是判断规则是否装载的关键日志。
当前 workaround:改完隧道后调用 POST /api/proxies/reload(面板的"重载反向代理"), 或 docker compose restart tunnel-server。
根治方向:在 control 包拿到 proxy.Manager 引用,于隧道增删改处调用 EnsureTunnelRule / RemoveTunnelRule,并在 ensureStaticTunnels 落库后触发一次。
本机 PowerShell 调用 workbench 的注意点
workbench不在当前进程 PATH 中(虽已加入用户 PATH),要用完整路径:
%LOCALAPPDATA%\Programs\workbench\workbench.exe。
- 别写成
$env out = ...($env:是环境变量驱动器,会解析报错),那是$out = ...的笔误。
