发布与回滚流程
来源:
intranet-tunnel/release/README.md→## 九、版本变更(原文 14908 字符)
九、版本变更
⚠️ 本目录只保留当前版本的归档(服务端 1.1.1 / 客户端 1.2.1), 历史版本的 tar/zip 已清理。下面历史小节里的
docker load命令保留原样, 仅作为当时的记录——那些归档现在本机并不存在,照着执行会报「文件不存在」, 需要时按「镜像更新流程」自行重新构建。服务端与客户端的当前版本号不同:服务端是 1.1.1(本版未改服务端), 客户端是 1.2.1。因此
intranet-tunnel-server-*-1.1.1.tar/zip与intranet-tunnel-client-*-1.2.1.tar/zip都是当前版本、不要删; 1.2.0 的客户端归档已从本目录清理,需要时从 Gitea 的 v1.2.0 Release 下载 (那两个附件作为历史记录保留、不要删)。1.0.5 没有单独打包发布:它只有服务端变更,且随即被 1.0.6 覆盖, 单独发一次 148 MB 的归档没有意义。需要 1.0.5 的部署按 1.1.1 走。
1.2.1(客户端)
修复「Web 配置界面登录成功后界面没反应」。纯客户端版本,服务端不受影响 (服务端仍是 1.1.1,不必升级)。
- 根因(
assets/app.css):.login-layer { display: flex }覆盖了 HTML 的
hidden 属性。hidden 是靠 UA 样式表的 [hidden] { display: none } 生效的, 而级联里 来源(origin)优先级高于选择器特异性 —— 作者样式表的任何 display 声明都会压过 UA 样式表。于是登录成功后 showApp() 里的 $('login').hidden = true 只是把属性设上了,登录层依然 display: flex、 又以 min-height: 100vh 占满首屏,主界面被挤到折叠线以下。 不报错、Console 干净、后端日志还显示「登录成功」,表现就是「点了登录没反应」。 修法是在 app.css 里显式兜住:[hidden] { display: none !important; } ——这一条同时兜住以后所有同类地雷,server_test.go 里有回归断言守着它。
- 顺带修掉在 CSP 下失效的内联事件:
index.html里原写成
<form id="config-form" onsubmit="return false">,而 CSP 是 script-src 'self' (无 'unsafe-inline'),浏览器会直接拦掉内联事件属性 —— 等于没有防护: 在配置项输入框里按回车,表单会真的提交并跳转页面。改为在 app.js 的 bindEvents() 里用 addEventListener('submit', …) 兜住。
- 静态资源不再可能吃到旧副本:
index.html里app.css/app.js的引用
加 ?v=1.2.1 版本串;server.go 给内嵌静态资源加 Cache-Control: no-cache。 为什么不用长缓存://go:embed 资源的 ModTime() 是零值, http.ServeContent 既不写 Last-Modified 也不处理 If-Modified-Since, FileServer 也不生成 ETag —— 响应没有任何校验器,此时用 max-age 会让「前端改了但浏览器还在用旧副本」静默发生,排查成本极高。 no-cache(每次使用前回源校验)的代价可忽略:三个内嵌文件合计约 34 KB, 读的是内存。
验证(先验证再发布):修前/修后用同一套 headless 浏览器装置对照 —— 修前 login.hidden=true 而 display 仍是 flex、首屏正中命中登录表单(复现症状); 修后 display: none、登录层盒子高度 0、主界面 top=0。另外起真实容器端到端验证 (引用带版本串、Cache-Control 存在、页面无 onsubmit、浏览器完成登录后 主界面上到首屏)。
1.2.0(客户端)
客户端新增 Web 配置界面 + SQLite 配置持久化,并修复一个状态显示缺陷。 纯客户端版本,服务端不受影响(服务端仍是 1.1.1,不必升级)。
- 新增 Web 配置界面(容器内监听
0.0.0.0:47802,compose 里
"47802:47802",默认绑所有接口、局域网可直接访问)。 在此之前,改客户端配置的唯一办法是改 client.env 再重建容器; 现在可以直接在界面上看连接状态与实时日志、改配置与静态隧道,保存即生效。
首次访问需要口令:建议先在
client.env里设好WEB_PASSWORD,再docker compose up -d --force-recreate(restart不会重读 env_file, 新加的环境变量不生效)。未设置时首次启动会随机生成并打印到容器日志, 而那一行是它唯一的明文出现——之后只以哈希入库,抄不下来就只能改库重来。 取它的命令:docker compose logs tunnel-client | grep 访问口令。暴露面(默认对内网开放):界面有口令 + 会话 Cookie + 所有写操作要求 CSRF 令牌 + 单 IP 登录失败限流(1 分钟 10 次)。要收紧就把 compose 的 ports 改成
"<内网IP>:47802:47802"(只允许从该网卡访问)或在防火墙限制来源。 ⚠️ NAS 上若还有其它网络接口(例如 VPN 网段、Docker 网桥),绑0.0.0.0会同时在那些接口上一并监听——它们各自是一个独立的可达入口。 ⚠️ 容器内必须监听0.0.0.0:写127.0.0.1只会绑在容器自己的回环上, 宿主机的端口映射连不进去。
旧取舍(已改):本版最初写成
"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。
- 新增 SQLite 配置持久化:配置与隧道落在部署目录的
./data/client.db
(compose 的 ./data:/app/data)。界面里保存的改动 restart 后依然在, 且配置来源为 sqlite——不再"改完一重启就回到 client.env 的值"。
刻意不用命名卷:命名卷落在 /var/lib/docker/volumes/ 下,不在项目目录里, 备份与迁移时容易漏掉;放在部署目录下,备份时把 data/ 一起带走即可。
⚠️ 首次部署必须先用 init.sh:Docker 对不存在的 bind mount 源会自动创建, 但属主是 root,而容器以 tunnel(10001) 运行 → 写 SQLite 报 permission denied。 症状很隐蔽——容器 healthy、界面能开、只有一行 WARN「降级为无持久化」, 而配置一重启就没了。init.sh 幂等,可反复执行。
- 新增
init.sh(部署第一步,做三件事):建./data并chown给 uid 10001;
client.env 不存在时从模板生成;体检仍是模板占位值的必填项。 用法是 sh init.sh(Windows 解压出来的包没有可执行位)。
- 修复客户端状态恒为
connecting的缺陷:登录成功、隧道已加载之后,
状态从未被置为 connected。表现为 /healthz 与界面上永远显示"正在连接", 而实际上隧道早就通了(NAS 实测时发现)——运维据此会去排查一个并不存在的问题。 现在状态有 stopped / connecting / connected / error 四态,且 message 带上 服务端版本与隧道条数("已连接 xxx(服务端版本 1.1.1,隧道 4 条)")。
已验证(先验证再发布):镜像 intranet-tunnel/client:1.2.0 已部署到飞牛 NAS 实测:
- 4 条隧道经公网访问的响应体与后端直连 SHA256 逐字节一致,且都带
X-Proxy-By: intranet-tunnel;
- 配置持久化:界面改动能持久化,
restart后仍在,来源为 sqlite; /healthz显示state=connected(此前恒为connecting);- 局域网可访问:从另一台机器访问
http://<内网IP>:47802/healthz返回 200
且 {"state":"connected","tunnels":4},首页 200 / 6982 字节;
- 并已完成独立审查 + 缺陷修复 + race 检测(零 data race)。
从 1.0.4 升级客户端(服务端不动):
# 把 intranet-tunnel-client-deploy-1.2.0.zip 传到内网侧之后:
unzip intranet-tunnel-client-deploy-1.2.0.zip
cd client
docker load -i intranet-tunnel-client-images-1.2.0.tar
sh init.sh # 建 ./data 并授权(幂等)
vi client.env # 建议顺手设 WEB_PASSWORD
# compose 里的 image 指向 1.2.0
sed -i 's|intranet-tunnel/client:1.0.4|intranet-tunnel/client:1.2.0|' docker-compose.yml
# ⚠️ 1.0.4 的 compose 既没有 ports 也没有 ./data 挂载,请按包内新 compose 补齐
# (或直接用包内那份 compose 覆盖,再重新填 client.env)
docker compose up -d
docker compose logs -f验证:curl -s http://127.0.0.1:47802/healthz(或从局域网另一台机器用 http://<该机器IP>:47802/healthz)应显示 state=connected; 浏览器打开界面改一项配置、docker compose restart 后应仍在。
⚠️ 升级时保留现有的
./data;静态隧道文件tunnels.json只在首次建库时播种, 之后以配置库为准。
1.1.1
重做内置反向代理的对外错误页(纯展示层,无接口与数据变更)。
以前访问一个没有匹配规则或上游挂掉的域名,会看到一段约 250 字节的裸 HTML: 一行标题、一条分隔线、一句灰色小字。它其实是对外可见的页面—— 陌生访客、误打域名的用户都会看到它。
现在四种状态码(404 无匹配规则、502 上游不可达、504 超时、503 暂时不可用)共用一套外观:
- 卡片式排版:状态码徽标 + 中文标题 + 一句话说明 + 键值详情(域名/规则)+ 排查提示;
- 亮/暗色自适应:跟随访问者的系统设置(深色系统下刺眼的白底很业余);
- 零外部依赖:不引字体、图片,不发任何额外请求——用户此刻正在排查「为什么打不开」,
再让他等几个 CDN 请求很荒唐;
- 文案从"陈述"改为"可操作":404 页给出核对域名、检查隧道域名与客户端是否在线;
502/504/503 分别说明"服务没启动""响应过慢""稍后重试"。
顺带修掉两个真实问题:
- 404 页缺
X-Proxy-By: intranet-tunnel响应头。这个头是「响应来自本项目」的判据,
缺了它会把"域名没配规则"误判成"根本不是本隧道服务返回的",排障方向直接跑偏。
- 错误页没有
Cache-Control:域名修好后不该还被缓存里的 404 挡着,现在为no-store。
从 1.1.0 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
docker load -i intranet-tunnel-server-upgrade-1.1.1.tar
sed -i 's|intranet-tunnel/server:1.1.0|intranet-tunnel/server:1.1.1|' docker-compose.yml
docker compose up -d tunnel-server验证:curl -s http://127.0.0.1:47801/healthz 应返回 "version":"1.1.1"; 用一个没有配置规则的域名访问,应看到新版错误页(并带 X-Proxy-By: intranet-tunnel 头)。
1.1.0
新增两处面板能力,并修复一个「界面承诺了但实现没有」的缺陷。纯增量, 不改既有接口行为,数据与配置都不需要调整。
- 「备份恢复」页显示系统版本。
以前恢复一个备份时,无从知道它来自哪个版本——而版本差异恰恰是恢复最需要 被提醒的风险(旧备份里的配置项可能已经改过含义,导入却总会成功、不报错)。
现在页面顶部显示当前运行版本,备份历史新增「版本」列,导入结果带出来源版本, 跨版本导入时明确标注。
细节:版本号其实一直写在备份 ZIP 的
meta.json里,只是从没有任何代码读过它。 新增backup_history.app_version列记录新备份;此前产生的老备份由服务端 回读 ZIP 内的 meta 兜底,所以不会出现"老备份永远显示未知"。
- 新增「检查更新」。
在面板上直接看到上游是否发布了新版本,不必再去翻 Release 页面。
默认关闭,且必须配了 UPDATE_CHECK_URL 才会启用——本功能需要服务端出网, 而更新源是什么属于部署事实(自建 Gitea / GitHub / 内网地址),服务端猜不出来。 不配时页面只显示当前版本,不会出现一个"点了就报错"的按钮。
# 自建 Gitea(本项目自己的发布就在那里)
UPDATE_CHECK_URL=http://<gitea-host>/api/v1/repos/<owner>/<repo>/releases
UPDATE_CHECK_TOKEN=<私有仓库需要>⚠️ 填的是 API 地址,不是 Release 网页地址(填错会提示"响应不是 releases 数组")。 ⚠️ 公网服务器访问不到内网 Gitea;要让云端也能检查,需要把更新源暴露到公网 (例如用本项目自己的隧道放出去),或改指向 GitHub。
本功能只提示、不升级:显示新版本号与 Release 链接,不下载、不替换镜像、 不重建容器。自动升级需要把 docker.sock 交给容器,等于把宿主机 root 权限 交出去,风险远大于省下的几下点击——升级仍走下面的人工流程。
- 修复 JSON 备份无法导入(既有缺陷)。
面板的导出提供 JSON 格式、上传组件写着 accept=".zip,.json"、「浏览器本地同步」 导出与恢复的也是 JSON——但服务端解析只认 ZIP,于是这几条路径都会报 导入失败:备份不是合法的 ZIP 文件。现象是"界面摆着入口,点下去得到一个 看不懂的报错",很容易让人以为自己的备份文件坏了。现在两种格式都支持。
从 1.0.7 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
# 只升级服务端时,导这个 13 MB 的包就够;首次部署才需要 -server-images- 那个
docker load -i intranet-tunnel-server-upgrade-1.1.0.tar
sed -i 's|intranet-tunnel/server:1.0.7|intranet-tunnel/server:1.1.0|' docker-compose.yml
docker compose up -d tunnel-server验证:curl -s http://127.0.0.1:47801/healthz 应返回 "version":"1.1.0"; 面板进入「备份恢复」页,能看到「系统版本」卡片与备份历史里的「版本」列。
1.0.7
修复并发写数据库导致面板 500 的两个既有缺陷。两者都是「事务内先查后改」的加锁问题, 只在并发写时出现,单次点按不会遇到。
- SQLite 部署下并发写报 500(
database is locked (5) (SQLITE_BUSY))。
典型表现:登录之后紧接着保存设置,后一次偶发失败。本地与单机部署默认就是 SQLite, 所以踩到的人不少;生产用的 postgres 不受这条影响。
根因是事务的加锁方式,不是连接池参数——这一点很容易误判:驱动其实对每个新连接 都执行了 BUSY_TIMEOUT(5000)(实测 6 个物理连接全部生效)。真正的问题是 GORM 的「先查后改」事务(先 First 再 Updates)在 DEFERRED 模式下先取读锁、 写时再升级,而 SQLite 对锁升级冲突直接返回错误、根本不等待, busy_timeout 在这条路径上形同虚设。
修法:让事务 BEGIN IMMEDIATE(DSN 加 _txlock=immediate),开始即取写锁; 同时把 busy_timeout 从 5 秒提到 10 秒。 ⚠️ 实测:8 并发 × 20 轮「先查后改」,修复前 140/140 全部失败且 783ms 就返回, 修复后 0 失败。
- postgres 下并发保存设置报 500(pg 日志为
deadlock detected)。
批量保存设置时,待写入项的顺序来自 Go map 的随机遍历,两个并发请求可能以 相反顺序锁同一批行而互相等待成环;postgres 在 1 秒(deadlock_timeout)后 检测到死锁并回滚。修法是落库前按设置项 key 排序,保证所有事务的加锁顺序一致。
不受影响的场景:只读接口、单次操作、以及与数据库并发无关的一切功能。
从 1.0.6 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
# 只升级服务端时,导这个 13 MB 的包就够;首次部署才需要 -server-images- 那个
docker load -i intranet-tunnel-server-upgrade-1.0.7.tar
sed -i 's|intranet-tunnel/server:1.0.6|intranet-tunnel/server:1.0.7|' docker-compose.yml
docker compose up -d tunnel-server验证:curl -s http://127.0.0.1:47801/healthz 应返回 "version":"1.0.7"。
SQLite 用户不需要改任何配置:修复在连接串里,不在
.env里。 若此前按社区建议加过DB_MAX_OPEN_CONNS=1,可以删掉——那是绕过而非修复, 代价是把并发读也一并串行化。
1.0.6
修复两步验证(2FA)形同虚设的既有缺陷,并把移动端接口一并带上。
- 开了 2FA 实际没有任何防护效果。
设置页能把 2FA 绑上、开关也显示「已启用」,但登录流程里从来没有校验分支—— SettingAuth2FEnabled 这个设置项在全仓库只有写入、没有任何一处读取。 拿到口令的人照常登录,2FA 只是个界面上的装饰。
本版把口令登录改成两阶段:校验口令通过后,若账号开启了 2FA, 登录接口返回质询而不是令牌({mfa_required, mfa_ticket}), 需再调 /api/auth/2fa/verify 用 6 位验证码换取令牌 (移动端为 /api/mobile/auth/2fa/verify,复用同一份实现)。
几个刻意的设计:票据一次性(用完即废,被截获也无法反复试码)、 有效期 120 秒、单票据错误上限 5 次、同一验证码不可重放 (TOTP 在 ±1 窗口下有效长达 90 秒,不做去重就能被复用)。 管理端登录页同步增加了验证码输入步骤——只保护移动端入口的话, 拿到口令的人直接调 /api/auth/login 就能绕过,等于只修了一半。
未开启 2FA 时,两端行为与旧版一字不差(已用回归断言覆盖)。
- 移动端接口随本版一并交付(1.0.5 的内容):
/api/mobile/*,
受 MOBILE_ENABLED 控制,关闭时整组返回 404。 含告警中心(alerts 表 + 已读/处置)与精简摘要接口。
- 管理端登录页:开启 2FA 时在口令框下提示,并在登录后进入第二步。
⚠️ 丢失认证器怎么办:设置页的「关闭 2FA」同样需要验证码,因此无法从界面关闭, 只能由运维改库并重启容器。步骤见 docs/AGENTS-ARCHIVE.md 的「两步验证」一节 (有两个反直觉的点:改 .env 无效、改库后必须重启)。
从 1.0.4 / 1.0.5 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
# 只升级服务端时,导这个 13 MB 的包就够;首次部署才需要 -server-images- 那个
docker load -i intranet-tunnel-server-upgrade-1.0.6.tar
sed -i 's|intranet-tunnel/server:1.0.[45]|intranet-tunnel/server:1.0.6|' docker-compose.yml
docker compose up -d tunnel-server验证:curl -s http://127.0.0.1:47801/healthz 应返回 "version":"1.0.6"; 面板登录页在「系统设置 → 安全」里绑定 2FA 后,重新登录应出现验证码输入步骤。
1.0.4
修复「实时流量恒为 0」(1.0.1 起的缺陷),并补齐两处界面信息。
- 隧道级别的实时流量与连接数一直是 0。
根因是记账代码留在了数据面,而 1.0.1 起 HTTP/HTTPS 流量全部由 内置反向代理承接(数据面不再监听公网端口)——这条真正承载流量的路径 一次都没记过账。表现是面板「实时流量」恒为 0 B、流量统计页全是 0, 而隧道访问一切正常,极易被当成"还没人访问"。
修法是在内置反代的隧道连接上记账,而不是包 ResponseWriter: WebSocket 升级会走 Hijack 绕过 ResponseWriter,包在外层会漏掉这类连接; 包在 net.Conn 上与协议无关,HTTP / SSE / WebSocket 一视同仁, 统计口径也与数据面完全一致。
- SSL 证书页新增「存储目录中的证书」。
面板此前只列数据库里的证书记录。用 acme.sh 等外部工具签发的证书从未进过数据库, 于是界面显示「已装载 0 张」而 nginx 正拿着证书服务——使用者只能靠猜。 证书的真相在文件里,数据库只是索引。 新表只读,并明确标注续期由该工具自己负责: 两套续期机制互相覆盖是真实发生过的故障。
- 隧道管理的「公网入口」支持一键复制与在新窗口打开,长域名不再折行。
从 1.0.3 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
# 只升级服务端时,导这个 13 MB 的包就够;首次部署才需要 -server-images- 那个
docker load -i intranet-tunnel-server-upgrade-1.0.4.tar
sed -i 's|intranet-tunnel/server:1.0.3|intranet-tunnel/server:1.0.4|' docker-compose.yml
docker compose up -d tunnel-server验证:访问任意一条 http/https 隧道后,面板「实时流量」应立刻出现非 0 计数 (此前无论访问多少次都是 0)。
服务器上 1.0.0 ~ 1.0.3 的镜像仍在,可随时把 compose 的 tag 改回去回滚; 但本目录只保留当前版本的归档,历史 tar/zip 已清理。
1.0.3
修复 1.0.2 的一处展示缺陷,只影响面板显示,不改动任何数据与访问行为:
隧道列表的「公网入口」不再显示裸前缀。
1.0.2 起允许隧道只填前缀声明域名(如 portainer),保存时服务端按 TUNNEL_DOMAIN 补全为 <服务名>.tunnel.sushike.cloud;但列表展示是直接拿库里存的声明值拼 URL 的, 于是一条前缀隧道会显示成 https://portainer —— 一个既不存在、也解释不通的地址。
危害不在"不好看":保存走补全、展示不走补全,同一份数据会呈现两种域名, 使用者只能靠猜哪个是真的,排查时还会反过来怀疑域名没配好。
1.0.3 让展示走与反代规则、边缘配置同一套解析逻辑(config.ResolveTunnelDomain), 并补了单元测试,覆盖前缀 / 完整域名 / tcp / 无域名四种形态。
隧道记录里仍然保留前缀,这是有意为之——换服务器时只改
TUNNEL_DOMAIN一行。 所以「列表显示完整域名」与「编辑框显示前缀」两者都对,不是不一致。
从 1.0.2 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
docker load -i intranet-tunnel-images-1.0.3.tar
sed -i 's|intranet-tunnel/server:1.0.2|intranet-tunnel/server:1.0.3|' docker-compose.yml
docker compose up -d tunnel-server也可以只换服务端镜像(intranet-tunnel-server-1.0.3.tar 仅约 13 MB), postgres:16-alpine 与 nginx:alpine 不必重新导入。
验证两点:curl -s https://<面板域名>/healthz 的 version 应为 1.0.3; 面板「隧道管理」里前缀隧道的公网入口应显示完整域名。
1.0.0 ~ 1.0.2 的镜像均未删除,可随时把 compose 的 tag 改回去回滚。
1.0.2
新增「边缘配置自动同步」:隧道与证书变更时自动渲染 nginx 配置并触发重载, 新增隧道不必再手工改 nginx。
- 泛域内零配置:域名落在
EDGE_BASE_DOMAINS泛域内的隧道共用一段
通配 server 块。实测新增这样的隧道时,配置文件内容与 nginx 重载次数都不变, 而域名立刻可用。
- 泛域外自动生成:不在泛域内的自定义域名会自动生成专属 server 块,
并使用该域名自己的证书;缺证书时只跳过它自己并给出告警, 不会让整份配置失效。
- 隧道域名可用前缀声明:配置
TUNNEL_DOMAIN=tunnel.example.com后,
隧道里只填 portainer 即可,服务端补全为 portainer.tunnel.example.com; 换服务器时只改这一个变量,不必逐条改隧道。
- 服务端只写文件,reload 交给边缘侧:宝塔场景用随包提供的 systemd
path unit;纯 Docker 场景由 nginx 容器内的看门狗负责。这样服务端不需要 docker socket,也不扩大权限面。
- 安全与稳健:原子写入(临时文件 + rename,监听者不会读到半份配置);
重载前先校验;校验失败时把坏配置移出 include 目录而不是留在原地—— 留在原地会让下一次 nginx 启动直接失败,那比"这次改动没生效"严重得多。
面板新增「边缘配置」页面:查看同步状态、预览渲染结果(与写入内容逐字节一致)、 手动触发同步。
从 1.0.1 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
docker load -i intranet-tunnel-images-1.0.2.tar
sed -i 's|intranet-tunnel/server:1.0.1|intranet-tunnel/server:1.0.2|' docker-compose.yml
docker compose up -d tunnel-server升级后如需启用边缘同步,再按 deploy/edge/README.md 配置 EDGE_* 环境变量 与边缘侧的 reload 触发器(宝塔场景需安装 systemd path unit)。 该功能默认关闭,未启用时行为与 1.0.1 完全一致。
1.0.0 与 1.0.1 的镜像均未删除,可随时把 compose 的 tag 改回去回滚。 ⚠️ 回滚到 1.0.1 及更早时,用前缀声明的隧道域名不会被解析, 需要先改回完整域名。
1.0.1
修复两个缺陷,建议所有 1.0.0 部署升级:
- 隧道变更后反代规则不自动更新
面板新增/修改隧道、或客户端上线新建隧道后,内置反向代理不会装载对应规则, 访问域名得到 404「没有匹配的反向代理规则」。 根因是反代规则同步接口(EnsureTunnelRule / RemoveTunnelRule)一直没有调用点: proxy 与 control 互相依赖,无法直接互相引用。 1.0.1 用依赖倒置解决——control 定义窄接口、proxy 实现、装配阶段注入, 并在隧道路由登记的汇聚点(registerRoute)同步规则。
- 内置反代与数据面争抢 48080
两者都监听 DataPort,同时启动必然 bind: address already in use, 内置反代起不来、隧道域名全部 502。 1.0.1 恢复「二选一」:内置反代启用时数据面不再另起公网监听器。
从 1.0.0 升级(数据与配置都不受影响):
cd /home/docker/intranet-tunnel
docker load -i intranet-tunnel-images-1.0.1.tar
# compose 里的 image 需指向 1.0.1
sed -i 's|intranet-tunnel/server:1.0.0|intranet-tunnel/server:1.0.1|' docker-compose.yml
docker compose up -d tunnel-server升级后日志应出现 公网流量入口由内置反向代理接管, 且不再有 address already in use。 客户端上线时会打印 隧道路由已同步到内置反向代理。
1.0.0 的镜像未删除,可随时把 compose 的 tag 改回 1.0.0 回滚。
来源:
intranet-tunnel/docs/AGENTS-ARCHIVE.md→## Gitea Release v1.0.1 发布记录(2026-09-12)(原文 3046 字符)
Gitea Release v1.0.1 发布记录(2026-09-12)
已发布:tag v1.0.1(已推送 backup 与 gitea),Release id=2,含 3 个附件。
| 附件 | 大小 | 说明 |
|---|---|---|
README.md | 7.7 KB | 部署说明(release/README.md) |
intranet-tunnel-images-1.0.1.tar | 148.6 MB | 镜像归档,含 server:1.0.1 + postgres:16-alpine + nginx:alpine |
intranet-tunnel-deploy-1.0.1.zip | 146.4 MB | 自包含部署包:tar + compose + .env + 部署说明 |
部署包的构成(与 v1.0.0 保持一致)
zip 内无目录前缀,4 个文件平铺:
.env ← 项目根 .env(部署前必须按说明第三节改凭据)
docker-compose.yml ← 项目根 compose
intranet-tunnel-images-1.0.1.tar
README.md ← release/README.md打包命令(用 bsdtar;Compress-Archive 处理 148 MB 文件吃力):
# 注意:`-C <dir> .` 会产生 ./ 前缀,要显式列文件名
tar.exe -a -c -f release/intranet-tunnel-deploy-1.0.1.zip -C $stage `
'.env' 'docker-compose.yml' 'intranet-tunnel-images-1.0.1.tar' 'README.md'打包前务必校验归档里有 nginx(最容易漏的一个):
tar -xf release/intranet-tunnel-images-1.0.1.tar -C $tmp manifest.json
(Get-Content $tmp\manifest.json -Raw | ConvertFrom-Json).RepoTags
# 期望三项:intranet-tunnel/server:1.0.1 / postgres:16-alpine / nginx:alpine⚠️ 私有仓库的 Release 附件:未认证下载返回 404(不是 403)
验证附件"能否下载"时必须带 Authorization 头,否则一律 404:
# 未认证 -> HTTP 404(Gitea 用 404 而非 403,避免泄露资源是否存在)
# 带 token -> HTTP 206(Range 请求成功)
curl.exe -s --noproxy '*' -H "Authorization: token $tok" `
-o NUL -w '%{http_code}' -r 0-63 `
"http://<内网IP>:3234/sushike/intranet-tunnel/releases/download/v1.0.1/<文件名>"v1.0.0 的附件表现完全一致,所以看到 404 时先怀疑"没带认证", 不要误判成附件损坏或 release 没建好。 另:Gitea 附件不支持 HEAD 方法(HEAD 也是 404),要用 GET + Range 验证。
PowerShell 向 curl.exe 传参会拆散带空格的参数
创建 Release 时用这种写法必然失败并返回 {"message":"not found","url":".../api/swagger"}(路由没匹配上):
# ✗ 失败:-H 'Content-Type: application/json' 被拆成多个参数
curl.exe -s --noproxy '*' -X POST -H "Authorization: token $tok" `
-H 'Content-Type: application/json' --data-binary '@body.json' "$url/releases"改用 Node 的 fetch(H:\Works\.recover\gitea-api.js 就是为此写的), 完全绕开转义问题,且能直接读 JSON 响应判断成败:
$env:GITEA_TOKEN = ($cred | Where-Object { $_ -like 'password=*' }) -replace '^password=', ''
node gitea-api.js POST '/api/v1/repos/sushike/intranet-tunnel/releases' body.json上传附件反倒用 curl 没问题(-F 形式):
curl.exe -s --noproxy '*' -X POST -H "Authorization: token $tok" `
-F "attachment=@<本地文件>" `
"http://<内网IP>:3234/api/v1/repos/sushike/intranet-tunnel/releases/2/assets?name=<文件名>"148.6 MB 用时 2.3s(64 MB/s),146.4 MB 用时 2.2s(67 MB/s),无需分片。
Out-File -Encoding UTF8 同样会写 BOM
PS 5.1 下用 $msg | Out-File x.txt -Encoding UTF8 写提交信息文件, BOM 会变成 commit message 的第一个字符(git log --pretty=%s 首字符为 U+FEFF)。
改用 write 工具直接落文件,或:
[System.IO.File]::WriteAllText($p, $msg, (New-Object System.Text.UTF8Encoding($false)))验证:('git log -1 --pretty=%s')[0] 的码点应为 U+0064(d)而不是 U+FEFF。 万一已推送到远程,用 git commit --amend -F <无BOM文件> 后 git push --force-with-lease 修正(本仓库为单人使用,可安全强推)。
来源:
halo-kb/gitea-集成方案.md→## D) 部署与安全(原文 3562 字符)
D) 部署与安全
D.1 这些脚本/服务应该跑在哪里
| 组件 | 建议位置 | 网络可达性要求 |
|---|---|---|
gitea_to_halo.py(手动/定时) | NAS(有 Python 3.11.2,且与 Gitea、Halo 同机/同网段) | 出站可达 Gitea <内网IP>:3234 与 Halo <内网IP>:28090 |
webhook_server.py | NAS,与 Gitea 同一 Docker 网络或同机 | 需要 Gitea → 服务的入站可达(这是唯一一个需要入站的方向) |
为什么放 NAS 而不是本机:本机(Windows)会休眠、会关机,Webhook 服务必须 7×24 在。 NAS 上的 40822 是 SSH 端口,不要用它做回调地址。
网络可达性检查清单:
# 1) NAS 能否访问 Gitea
curl -s -m 5 http://<内网IP>:3234/api/v1/version
# 2) NAS 能否访问 Halo
curl -s -m 5 http://<内网IP>:28090/actuator/health
# 3) 服务起来后,从 Gitea 所在的位置能否打到它
curl -s -m 5 http://127.0.0.1:8234/healthzNAS 上
curl 7.88.1【实测】;NAS 直连内网不需要--noproxy '*'(那条是本机 Windows 上 git 配了 127.0.0.1:7899 代理才需要的)。
D.2 凭据如何存放
三条铁律:
- 凭据只出现在环境变量 / env 文件里,不写进脚本、命令行参数、日志、提交历史。
- env 文件权限
chmod 600,属主为运行用户:
cp .env.example .env && chmod 600 .env.env必须进.gitignore。本项目已有的忽略规则(.env、**/.env、
client/.env.*,放行 *.example)已覆盖这一点 —— 新增目录时不要忘了补规则。 判据:git status --short 里不该出现任何 .env;提交前用 git check-ignore -v .env 确认规则确实命中。
各凭据的最小权限建议:
| 变量 | 最小权限 | 说明 |
|---|---|---|
GITEA_TOKEN | 仅 read:repository | 本方案不写 Gitea,不要给 write。Gitea 侧只需读元数据。 |
HALO_TOKEN | 仅 portfolio「管理项目」 | 不要用超级管理员 PAT。Halo 的角色模板已提供 管理项目 这一档。 |
GITEA_WEBHOOK_SECRET | — | 随机长字符串;与 Gitea 侧填写值一致。泄露等于允许任何人伪造写触发。 |
轮换:Gitea/Halo 令牌与 webhook secret 都属于可随时吊销的凭据。 轮换步骤 = 建新令牌 → 改 .env → systemctl restart / docker compose up -d → 吊销旧令牌。
D.3 私有仓库如何避免泄露
风险模型:仓库是私有的,但项目展示页是公开的。要防的是 "把不该公开的内容(token、内网地址、源码片段、部署命令)搬到了公开页面上"。
本方案的四道防线:
- token 从不下发到浏览器。
GITEA_TOKEN 只存在于服务端进程的环境变量里。脚本产出的 content 字段 是纯文本 Markdown,只由仓库元数据拼接,不含任何凭据。 前端走的是 GET /apis/public.portfolio.muyin.site/...,请求里不带任何 Gitea 凭据。 → 用浏览器开发者工具查看网络请求,只会看到 Halo 的地址和公开的项目 JSON。
repoUrl指向的是 Gitea 的私有仓库地址。
点进去匿名用户会看到 404(私有仓库对匿名隐藏存在性,A.4 已实测)。 这本身不泄露内容,但会泄露"这台 Gitea 的存在与端口"。 若连这点也不想暴露,把 repoUrl 置空即可(改 build_project() 一行)。
- README 默认不搬运。 README 是最容易夹带内网地址、部署命令、示例口令的地方。
默认关闭;开启后仍有敏感行过滤(password= / token= / 内网 IP / DSN / PRIVATE KEY)。 ⚠️ 过滤是启发式的,只能降低风险、不能保证。搬运私有仓库 README 前请人工过一遍。
- 默认
draft。 脚本第一次跑不会把任何东西推到公开页面,
需要人工审核后改成 published(或加 --publish)。 这一条挡住的正是"脚本配错了字段、把半成品推上首页"。
D.4 创建 Webhook 的实际步骤(本次未执行,按硬约束只给步骤)
方式一:网页(推荐首次使用) —— 见 C.2 的"回调地址怎么填"。
方式二:API(需要仓库管理员权限的 token):
# 注意:body 用文件传入,避免 shell 转义问题;secret 从环境变量注入
cat > /tmp/hook.json <<'JSON'
{
"type": "gitea",
"name": "webhook",
"active": true,
"events": ["push", "release", "repository", "create", "delete"],
"config": {
"url": "http://<本服务地址>:8234/webhook/gitea",
"content_type": "json",
"http_method": "post",
"secret": "__SECRET__"
}
}
JSON
sed -i "s|__SECRET__|$GITEA_WEBHOOK_SECRET|" /tmp/hook.json
curl -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/hook.json \
http://<内网IP>:3234/api/v1/repos/sushike/intranet-tunnel/hooks
# 期望 HTTP 201
rm -f /tmp/hook.json # 含 secret,用完即删⚠️ 两点提醒:
config.content_type用json(脚本按 JSON 解析正文)。- 上面的
sed写临时文件是权宜做法 —— 更干净的是让接收服务也从环境变量读 secret,而这一步本来就是必须的,所以其实可以先用
--print-sample在本地把整条链路验证完, 再决定要不要真的去 Gitea 建 hook。
验证是否真的投递成功:Gitea 仓库 → 设置 → Webhooks → 点进去看「最近投递」, 状态码应为 202(服务是异步处理,立刻回 202 再后台跑同步)。 若看到 401,检查两边 secret 是否一致;若看到连接超时,检查 D.1 的入站可达性。
