0
0
0

客户端 Docker 部署

2026-09-13
2026-09-13
文章摘要
|

来源: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 是容器自己

最后一行是唯一需要动脑的地方,第六节专门讲。


二、前置准备

  1. 服务端已部署并可通过公网访问控制端口(默认 47800)。
  2. 在服务端面板「客户端」页新建一个客户端,记下只显示一次的 Token
  3. 导入客户端镜像(客户端是独立交付物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 里的条数,见第七节);同时在服务端面板 /healthzonline 计数会 +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

授权其实是两层的,只做其中一层就会退回上面的症状:

保证什么由谁完成
镜像内的 chownclient/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:47802docker port tunnel-client 显示 -> 127.0.0.1:47802。 当时的理由是"管理界面不默认对局域网敞开",但它与真实用法冲突(那台 NAS 上没法开浏览器), 所以改成默认绑所有接口。

默认放开,但不等于可以随便暴露。 界面自身有这几层保护:

  • 访问口令 —— 打开界面就要登录(口令来源见下一小节,首次访问需要它);
  • 会话 CookieHttpOnly + 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-recreaterestart 不重读 env_file,见第五节)。

⚠️ WEB_ENABLE 的默认值是 false 容器里是靠镜像自带的 client/.env.docker 把它打开的,本目录的 client.env.example 也已显式写上 WEB_ENABLE=true。但如果你自己维护 client.env(例如从旧版本手工写过来的), 就必须自己补上这一行——否则用的是内置默认值 false, 容器照常运行,47802没有任何监听

判断当前值:看启动日志那一行的 "web_ui":truefalse 即未开启,见第三节的日志样例)。

口令从哪来

顺序来源说明
1WEB_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 只在创建容器时读取,所以 restartclient.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.internalcompose 里已配 extra_hosts: host.docker.internal:host-gateway,开箱可用
内网另一台机器它的 IP,如 <内网IP>与裸机部署完全一样,最通用
同一 Docker 网络里的另一个容器那个容器的服务名,如 nginx需要把两者放进同一个 network

填写位置有两个:服务端面板(隧道管理 → 编辑 → 内网地址)或 Web 配置界面(静态隧道区)。两边都行,库中已有记录时以客户端配置库为准。

也可以在客户端侧用静态隧道声明(TUNNELSTUNNELS_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 -drestart 不重读 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 useup 被中断后 dockerd 里残留的名字占用,而 docker inspect / docker rm / docker ps -a 都看不到它:先 kill -9 残留的 compose 进程,再 docker rm -f tunnel-clientup -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 -d

data/ 是 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
  • 服务端 /healthzonline 由 0 变 1。
  • 三条隧道端到端可用(响应头均带 X-Proxy-By: intranet-tunnel):

uptime-kuma、飞牛 fnOS 面板(5666)、MoviePilot(40900)。

  • /healthztunnels 是「本地静态 + 服务端下发」的合计:本次 3 条本地
    • 1 条服务端下发 = 4,比 tunnels.json 里的条数多(见第七节)。
  • 新增隧道不需要改云端任何配置:域名都落在 *.tunnel.sushike.cloud 泛域内,

边缘 nginx 按泛域复用,配置与隧道条数解耦。改完 tunnels.json 只需 docker compose restart tunnel-client,不必 down

两个容易吃亏的地方:

  1. 域名不能跨客户端复用。服务端按 custom_domain 查占用,同域名已属于

另一个客户端时,静态隧道会被忽略(日志: 域名已被其它客户端占用,忽略静态隧道)。要接管旧前缀,先在服务端删掉原记录。

  1. client.env 必须 up -d --force-recreateenv_file 只在创建容器时读取),

而改 tunnels.jsonrestart 就够——两者要求不同,别互相套用。

自 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.cloud TLS 握手失败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=trueTLS_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 = ... 的笔误。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论