故障排查

admin account does not exist

该错误表示当前配置连接的数据库中没有 role=admin 的用户。新库首次初始化应执行:

tunnelmesh-server --config /etc/tunnelmesh/server.yaml admin bootstrap

管理员已存在但凭据遗失时才使用 admin regenerate-credentials --confirm。如果预期管理员已经存在,请先用 print-config 核对当前命令与 systemd 服务是否使用同一配置文件和数据库;不要反复执行 bootstrap 掩盖连错数据库的问题。

启动失败

先执行:

tunnelmesh-server --config tunnelmesh.yaml check-config
tunnelmesh-server --config tunnelmesh.yaml print-config

print-config 输出的是脱敏后的有效配置。重点检查 mode、storage.driver、MySQL DSN、TLS、node.id 和 registry.type。

服务日志位置和 systemd/Docker/macOS/Windows 查看命令见 日志位置与查看方式。

Server 停止后没有执行优雅下线

先取上一次停止的真实原因,不要只看服务状态是 active 还是 inactive:

systemctl show tunnelmesh-server.service -p ExecMainCode -p ExecMainStatus -p NRestarts
  • ExecMainCode=exited、ExecMainStatus=0:收到 SIGTERM 后走完了 drain(HTTP 关停、Agent 流关闭、relay 停止、可选的 VPN 数据面排空),这是期望结果。
  • ExecMainCode=killed:进程是被信号直接带走的,drain 一行都没执行,在途请求被硬切断。修复版本之前的 tunnelmesh-server 就是这个表现:全仓只有 cli.Main 安装 SIGINT/SIGTERM 处理器,而 Server 入口走的是 cli.Execute(context.Background(), …) + log.Fatal,于是根本没有信号被接住,systemctl stop 一律算异常终止。升级到当前版本即修复,无需改配置。
  • 旧版本上「崩溃」「被停掉」「正常退出」三件事看起来一模一样(要么 1,要么 143),所以任何按退出码写的自动化都不可信;这也是三个角色的单元都不再以退出码表达「是否该拉起」的原因。

再确认退出码是否还是「塌缩的 1」。用一份故意写坏的配置探一下(check-config 不开端口、不碰数据库,是安全的探针;不要用 run,它会把服务起起来):

printf 'mode: [unclosed\n' > /tmp/broken.yaml
/usr/local/bin/tunnelmesh-server --config /tmp/broken.yaml check-config; echo "exit=$?"
rm -f /tmp/broken.yaml

应当是 exit=78(run 同样返回 78)。若仍是 1,说明跑的还是旧二进制,交付单元里的 RestartPreventExitStatus=78 77 对它无效,坏配置会以 RestartSec 的周期无限重启。三个角色的 run 与 check-config 现在遵循同一套退出码语义,见配置参考 的「退出码」小节。

容器里的表现更极端:官方镜像以 exec 形式把 Server 作为 PID 1 运行(distroless,无 init),而内核不会把「默认行为是终止」的信号投递给 PID 1,所以没有安装处理器的旧二进制根本收不到 SIGTERM——docker stop 一定等满 stop_grace_period(默认 10 秒)再 SIGKILL。判别方法就是这 10 秒:docker stop -t 60 <id> 计时,旧二进制必然耗满宽限期。当前版本会立即响应并在宽限期内干净退出;启用 VPN 数据面且把 server.vpn.shutdown_timeout 调到 10 秒以上时,记得同步调大 stop_grace_period。

schema 错误

  • 自动初始化关闭时,确认数据库已经执行 migrations/ddl.sql。
  • 检查 schema_meta 版本是否与当前程序一致。
  • SQLite 检查挂载目录是否可写;容器中通常是 /var/lib/tunnelmesh。
  • MySQL 检查账号是否有建表、索引和事务权限。

MySQL:迁移注释分号导致 Error 1064

现象:升级到 Schema v15 时 Server 启动失败,日志出现:

apply migration v0014_to_v0015: Error 1064 (42000):
You have an error in your SQL syntax ...
near 'every later write filters on ...'

根因:v0014_to_v0015/mysql.sql 的注释中包含 insert; every later write...,旧版迁移执行器使用 strings.Split(script, ";"),把注释里的分号当成语句边界。第二段因此以注释正文开头,被 MySQL 当成非法 SQL。第一条 ALTER TABLE 位于该非法片段之后,尚未执行,schema_meta 仍停留在 14。

止损:回退到上一个可启动的 Server 版本。因为迁移未执行、版本未推进,旧版二进制可以直接打开当前数据库。

处置:升级到包含安全 SQL 分割器的 Server 版本后重试。该版本会忽略 --、#、/* ... */ 注释中的分号,并保留字符串和引用标识符中的分号;已发布的 v0014_to_v0015 脚本本身不变,retry 会继续执行两条 ALTER TABLE。不要手工修改 schema_meta.version。

MySQL 5.6:Error 1071 Specified key was too long

MySQL 5.6 在默认 InnoDB 配置下通常只有 767 字节的索引前缀上限。旧版 DDL 中的 utf8mb4 VARCHAR(255) 主键或联合索引可能超过该上限;如果不能 修改 MySQL 服务参数(例如 innodb_large_prefix),请使用包含字节长度受控 键列的新版 tunnelmesh-server,其 DDL 不依赖 innodb_large_prefix。

首次初始化失败后,先停止服务并确认该数据库没有需要保留的数据:

systemctl stop tunnelmesh-server
mysql -h 10.228.128.81 -P 4963 -u '<user>' -p tunnelmesh \\
  -e "SHOW TABLES;"

如果只是失败初始化留下的空库,备份后清理该 TunnelMesh 数据库(或创建一个 新的空数据库)再启动服务,让 storage.auto_init: true 重新执行新版 DDL。 已有业务数据时不要直接 DROP DATABASE;先做完整备份,并安排停机窗口执行 经过验证的表结构迁移。CREATE TABLE IF NOT EXISTS 不会自动把已有旧列改成 新的键类型。

不要在命令行、日志或工单中粘贴真实密码;此前已经暴露过的数据库凭据应立即 轮换,并通过 systemd EnvironmentFile 或 Secret Manager 注入 DSN。

客户端观测全为 0 且 metadata 显示已过期

现象:/clients 页面里客户端明明在线并持续收发,列表行的「活跃 WS 连接」「活跃流」却是 0、「Server 节点」是 —、状态是「metadata 已过期」;表格上方的「在线客户端 / 活跃 WS 连接 / 活跃流」卡片同样全为 0。但打开详情抽屉,「WS 连接」里却列着若干条连接,且所有连接的「连接 Epoch」都是同一个值 2147483647。

判定:这是 MySQL 下 client_connection_leases.connection_epoch 只有 32 位导致的。Server 用 8 字节随机数生成 int64 的连接 Epoch 作为 fencing token,INTEGER 列在写入时把它钳制成 2147483647;之后 Renew/UpdateStats/Release 都以真实 Epoch 作为 WHERE 条件,永远匹配 0 行。租约 90 秒后过期,于是活跃连接与活跃流归零;而心跳在租约续期失败时提前返回,连带跳过了 metadata 续期,5 分钟后被 sweeper 标记 stale,于是显示「metadata 已过期」。汇总卡片完全由列表行派生,所以一起归零。「多条互不相关的连接 Epoch 完全相同且等于 2147483647」就是该缺陷的确诊特征。

快速确认(MySQL):

SHOW COLUMNS FROM client_connection_leases LIKE 'connection_epoch';
-- 返回 int(11) 即为受影响版本,bigint(20) 为已修复
SELECT COUNT(DISTINCT connection_epoch) AS distinct_epochs, COUNT(*) AS rows_total
  FROM client_connection_leases;
-- distinct_epochs 远小于 rows_total 说明 Epoch 被钳制成同一个值

处置:升级到 Schema v15 的 Server 版本(见计划 docs/superpowers/plans/2026-09-23-client-lease-epoch-observability-sync.md 与升级指南)。该版本把两张租约表的 connection_epoch 加宽为 BIGINT,并在心跳里增加自愈:续期匹配不到行时,用内存中的权威 Epoch 重新登记租约。因此已连接的客户端在下一个心跳周期(默认 30 秒)内自动恢复,不需要重启客户端,Agent 与 Client 二进制也不需要升级。详情抽屉同时会区分「活跃」与「已过期」租约,过期行不再提供「关闭连接」入口。

若升级后仍然全为 0:按下面的「集群连接查询或关闭失败」检查 Server 节点注册与数据库连通性,并确认 schema_meta.version 已经是 15。

客户端活跃流只增不减

现象:/clients 页面的「活跃流」随时间单调上涨,而该客户端的并发并没有变多;空闲很久后刷新仍然是很大的数字;表格上方的「活跃流」卡片一起虚高。把该客户端断开重连后,数字立刻回落到与并发相符的量级。

判定:这一列是所属 Server 节点内存中的活跃流计数,随心跳(默认 30 秒)写入 client_connection_leases.active_streams。旧版本在一条流被异常退役时只加不减——上游 reset、双向半关闭完成、Agent 侧读写失败、发送窗口耗尽、Client 侧发送队列满都走这条路径,而正常结束(半关闭)路径会归还。于是一条长连接每异常结束一条流就多记 1,直到该 WebSocket 断开才归零。表象很像「把已过期的连接也统计进去了」,实际是过期前那个数字本身就是错的:行内与卡片的聚合一直只累加 expires_at 未到的租约。

处置:升级到修复后的 Server 版本(决策与验证记录见 docs/pull-requests/2026-09-26-client-active-stream-accounting.md)。不需要重启客户端,Agent 与 Client 二进制也不需要升级。修复后租约已过期的行在后台显示 — 而不再显示最后一次上报的历史值,详情抽屉的 WS 连接列表按最近心跳倒序,与列表页一致。

口径提醒:该数字始终是心跳粒度的快照,不是实时读数;跨节点的连接由其所属 Server 节点写入,本节点只汇总不校正。

客户端详情里大量 WS 连接显示「已过期」

现象:某个客户端早已断开或只有一条活跃连接,但 /clients 详情抽屉的「WS 连接」里列着十几条甚至上百行「已过期」的租约,刷新永远不会变少;把浏览器窗口拉大也找不到删除它们的入口。

判定:这是修复版本之前的遗留死行。租约只有两个删除路径——客户端正常关闭 WebSocket 时按 connection_id + connection_epoch 删除,以及显式「关闭连接」操作。进程被 kill -9、宿主掉电、网络硬中断都走不到前者,而后者要求先知道这条连接存在。旧版本既没有按 expires_at 删除的语句,读取路径也没有过滤过期行,所以每个意外退出的客户端都会在表里留下永久记录,且抽屉把它们全部列出。「已过期」标签本身是对的(status 与统计卡片只累加未过期租约),问题在于这些行无人回收、无人限量。

处置:升级到带租约清扫器的 Server 版本。升级后 不需要手工 DELETE:

  • Server 每 1 分钟清扫一次,只删 expires_at 早于「当前时间减 24 小时」的行,每轮最多 1000 行,启动时先立即跑一轮;
  • 因此历史行按每分钟 1000 行的速度收敛(10 万行约 100 分钟),第一轮之后的当天数据仍在历史视图里可读;
  • 详情默认只显示未过期租约,勾选「显示历史租约」才读取保留窗口内的历史,单次最多 200 条。

确认清扫是否在跑:看 tunnelmesh_client_connection_lease_sweep_total{result="error"} 是否为 0,以及 Server 日志里的 client connection lease sweep(含 cutoff、reaped、elapsed_ms)。指标与窗口口径见可观测性。

清扫器遇到数据库错误时只记录并等下一轮,进程存活不等于还在回收,所以确认动作必须落到指标上。若 result="error" 持续非零,或 10 分钟窗口内一轮都没有(sum(increase(tunnelmesh_client_connection_lease_sweep_total[10m])) == 0),按 client_connection_lease_sweep_failed 检索 Server 日志定位是哪一轮、哪类错误,再检查数据库可达性与连接池;两条告警的口径见可观测性。同一条日志事件名也适用于 client_metadata_sweep_failed、webssh_session_sweep_failed、audit_retention_sweep_failed、auth_maintenance_sweep_failed。

不要手工删除的理由:与上文「集群连接查询或关闭失败」一致,租约是跨节点的事实,DELETE 绕过 epoch fencing 与审计链;正在使用的租约被删掉后,客户端要等到重连才会重新登记,期间该连接在后台消失、活跃流统计归零,排障证据也随之丢失。若只想停止删除动作,摘掉 Server 的清扫器启动即可(见计划 docs/superpowers/plans/2026-11-05-client-connection-lease-cleanup.md 的回滚说明),数据侧无需补偿。

代理节点列表显示“在线”但详情没有元数据

现象:Agents 列表里某个 Agent 显示“在线”,进入详情却是“暂无元数据”或加载失败,且该 Agent 实际上从未连接过。

判定:列表的“在线/离线”以未过期的 agent_connection_leases 租约为准,与启用状态分列展示。旧版本把启用状态渲染成了“在线”,所以新建未连接的 Agent 也显示在线;同时元数据接口对“存在但从未上报”的 Agent 返回 404,前端把它显示成加载失败横幅。

处置:升级到列表状态按租约计算、元数据缺失返回空集合的 Server 版本(见计划 docs/superpowers/plans/2026-09-23-agent-online-status-and-empty-metadata.md)。升级后未连接 Agent 显示“离线 + 启用”,详情页显示“暂无元数据”空态。若升级后仍显示“在线”却无元数据,按下面的“Agent 不在线”排查租约与心跳。

Agent 不在线

  1. 确认 Agent 能访问 Server 的 wss:// 地址。
  2. 确认证书链、SNI 和系统时间正确。
  3. 检查 Agent ID 是否稳定且没有重复注册。
  4. 检查 Server 节点租约是否过期,以及集群节点时间是否同步。
  5. 确认目标服务从 Agent 所在网络可达,而不是只在 Server 主机可达。

连接池部署中,Agent 详情应同时展示 instance 和 connection。一个 instance 离线不代表逻辑 Agent 离线;只要还有健康连接,流量会选择剩余连接。若全部连接为 0,检查 Agent token、instance_id 文件权限和 server_url,并观察 tunnelmesh_agent_connection_errors_total。

全链路探针可通过 POST /api/v1/agents/{agentId}/diagnose 发起 TCP/HTTP/UDP 诊断,并通过 GET /api/v1/agents/{agentId}/probes 查看受限结果摘要;目标响应体不会被保存或返回。详见 全链路网络探针。

Client 隧道突然全部不可用,但服务显示“正常停止”

  • 症状:本地监听端口全部消失,systemctl status tunnelmesh-client 显示 inactive (dead),退出记录是 Deactivated successfully,NRestarts=0;日志尾部是一批 client: tcp forward bridge failed: ... client session closed,而 supervisor 没有 worker exited; restarting。
  • 判别:systemctl show tunnelmesh-client -p Result -p ExecMainStatus -p NRestarts。Result=success 且 ExecMainStatus=0 说明进程以 0 退出,Restart=on-failure 不会拉起——enabled 只决定开机自启,与进程退出后的拉起无关。
  • 已修复的形态:连接池缩容曾把被取消 slot 的 context.Canceled 当成链路终止故障,连带拆除整个连接池并让进程 0 退出。现在缩容不再终止连接池,任何“没人要求停止而角色已退出”都会以 client: tunnel session ended while the client was still running 非 0 退出,supervisor 退避重启 worker。
  • 适用范围:只有显式配置 client.connections.max > min 的 Client 会命中该窗口(默认 max=1 永不缩容)。
  • 应急:systemctl restart tunnelmesh-client 恢复;若单元仍是 Restart=on-failure,加 drop-in 写 Restart=always 并保留 RestartPreventExitStatus=78 77。长期暴露的 SSH 端口会持续被公网扫描,这类 burst→idle 抖动是主要触发源,收紧 allow_remote 与来源 ACL 能降低频率。

集群连接查询或关闭失败

  • 看不到远端连接:确认所有 Server 都已升级到支持连接租约的版本,node.id 唯一且稳定,server.relay.enabled=true,集群时间同步。
  • 关闭返回 503:owner Server 的 relay 不可达。检查连接列表中的 Server 地址是否可从当前节点路由、mTLS 证书 SAN 是否精确匹配、CA 是否一致、server-node token 是否有效、节点 epoch 是否一致。数据库租约会保留,不要手工删除。
  • 关闭返回 409:传入的 connectionEpoch 已过期,说明该连接 ID 被替换。刷新 GET /api/v1/agents/{agentId}/connections,使用新的 epoch 重试。
  • 关闭成功后连接又出现:这是预期行为。关闭只断开一条物理 WebSocket,不会禁用 Agent 或阻止其按连接池策略重连;需要长期停止时禁用 Agent 或撤销 token。
  • 查询返回 503:检查数据库连接注册表可用性和管理 API 日志;不要用本地 metadata 连接数推断集群状态。

HTTP 路由失败

  • 404:检查域名、pathPrefix、wildcard DNS 和 API 路由是否匹配。
  • 401/403:检查 Token、角色和资源 owner。
  • 409:域名和路径已存在,重用原配置或删除旧路由。
  • 502/504:检查 Agent session、目标地址、目标端口和 policy。

托管路由的 /api/ 或 /ws/ 路径返回管理 API 的 404

现象:托管路由域名下以 /api/ 开头的接口返回 TunnelMesh 自己的响应体,而不是上游服务的响应:

{"code":404,"msg":"Not Found","data":{"error":"not found"}}

判定:{code, msg, data} 是管理 API 的统一信封,说明请求没有进入 Agent 转发链路,而是被 Server 的控制面处理器接走了。同一个 Host 上不带保留前缀的路径(例如 /skills)正常返回上游内容,即可确认是路径前缀被控制面劫持,与路由配置、Agent 在线状态、目标服务都无关。旧版本的 Server 按“先路径、后 Host”分派,会劫持托管路由主机上的 /api/*、/ws/*、/health/* 与 /metrics。

处置:升级到按 Host 作用域分派的 Server 版本(见计划 docs/superpowers/plans/2026-09-23-managed-route-reserved-path-shadowing.md)。升级后 tm-* 命名空间的域名把全部路径交给上游;显式域名路由上仍保留 /ws/agent、/ws/client、/health/*、/metrics 四个控制面端点,上游同名路径需要改名或改用 tp-* 代理入口。Server 单独升级即可生效,Agent 与 Client 不需要升级。

SSH / websocat 失败

  • 必须使用 websocat --binary。
  • 检查 ProxyCommand URL 是否使用 wss:// 和正确的域名/端口编码。
  • 看到 WebSocket 文本帧错误时,检查客户端是否误用了 text 模式。
  • 使用 ssh -vvv 和 websocat -v 获取握手与关闭原因。

Service token 失败

  • 401:检查 token 是否为空、类型是否正确、是否已过期或已撤销;agent 连接 /ws/agent,client 连接 /ws/client。
  • 403:检查 token owner、Agent 绑定、scope 与 Agent Policy 的交集。
  • 409:轮换/撤销并发冲突时,复用同一个 Idempotency-Key 重试;不要期待再次返回 secret。
  • Server-node relay 失败:检查 server.relay.node_token 是否有效、节点是否在 scope.serverNodeIds 允许列表内、节点是否被禁用或逻辑删除、epoch 是否一致,以及 server.relay.listen 是否已监听。mTLS 模式还需检查证书 SAN 是否精确匹配、共同 server_name 是否存在、CA 是否正确;明文模式需确认网络确实处于受控内网。

明文 secret 只在创建/轮换响应出现一次。不要从日志、审计记录或数据库恢复 token;遗失时直接轮换并撤销旧 token。

Agent Policy 逻辑删除

  • 删除后默认列表不显示:这是预期行为。管理员在 Agent 详情页切换到“已删除”或“全部”筛选查看记录。
  • 删除后新流被拒绝:deleted_at 非空的策略不参与授权;已有连接的关闭策略仍由协议和会话生命周期决定。
  • 修改已删除策略返回 409:必须先恢复,再修改,避免误以为修改了一条仍在生效的策略。
  • 恢复后仍被拒绝:刷新列表确认 deletedAt 为空,再检查 Token scope、Agent owner 和目标 CIDR/端口。
  • 升级到 Schema v10 前确认版本为 9;MySQL ALTER TABLE 失败后版本不会提前推进,确认具体错误后可重试。
  • 回滚到 v9 前必须恢复需要继续生效的策略;旧版本会把带 deleted_at 的记录当作有效策略。

数据安全

排障日志中可以保留 trace ID、Agent ID、路由 ID 和错误码,但不能输出密码、Bearer Token、私钥或完整 DSN。

子账号与 Schema v6

  • username_invalid:用户名需为 3–64 个 ASCII 字符,只允许字母、数字、.、_、-。
  • account_deleted:已删除账号只能在管理员页面的“已删除”筛选中恢复;恢复不会改变原用户名或关联资源。
  • admin_account_protected:管理员账号不能通过子账号接口禁用、重置、删除或恢复。
  • 升级前确认 schema_meta.version=5、增量链完整且数据库账号可执行 ALTER TABLE/CREATE INDEX。脚本失败时不会推进版本;MySQL DDL 可能隐式提交,按日志修复前置状态后可安全重试。
  • v6 的 users.deleted_at 是可空列,应用回滚时保留该列;不要直接删除列或复用已删除用户名。

子账号与 Schema v7 连接池

  • 升级前确认 schema_meta.version=6、v6→v7 增量脚本存在且数据库账号可执行 CREATE TABLE/CREATE INDEX。
  • v7 新增 agent_connection_leases 与 agent_instance_metadata;旧 agent_runtime_leases、agent_runtime_metadata 保留用于回滚,不要手工删除。
  • 迁移失败时先查看具体 SQL 错误和已创建对象;MySQL DDL 可能已隐式提交,修复后可重试,版本号不会在失败前推进。
  • 回滚应用可保留 v7 新表;如需精确回滚业务数据,使用升级前备份恢复。
  • 连接池扩容发布顺序、指标和止血步骤见逻辑 Agent 连接池运维指南。

Server 节点与 Schema v8

  • Server 未出现在 /servers 页面:确认已完成 node.id 初始化并成功启动;自注册失败会在 journal 中输出 register server node 相关错误。
  • 节点显示 offline:检查 Server 进程、数据库连通性、集群时间同步,以及 last_seen_at/expires_at 是否持续更新。
  • relay 认证拒绝:同时检查共享 token、允许列表、节点启用状态、逻辑删除状态和 epoch;mTLS 模式还需检查 SAN。任意一项不满足都会失败。
  • 升级前确认 schema_meta.version=7、v7→v8 增量脚本存在且数据库账号可执行 ALTER TABLE/UPDATE。
  • v8 只为 server_nodes 增加 name、enabled、deleted_at 并回填名称;失败时版本不会提前推进,MySQL DDL 可能已隐式提交,确认已完成的列后可重试。
  • 回滚应用时保留 v8 新增列;如需精确恢复 Schema,使用升级前备份,不要手工删除列或修改版本号。

授权缓存与 Schema v9

  • /ready 中 authorization_cache 为 unhealthy:检查 SQLite 文件或 MySQL 可用性、网络超时和数据库账号 SELECT authorization_revision 权限。组件错误只提示修订号源不可用,不会暴露 DSN 或完整数据库错误。
  • Token 已撤销但个别节点仍放行新流:确认所有节点使用 Schema v9 或更高版本;检查 server.authorization_cache.revision_poll_interval 是否被调大;观察 Prometheus 的 tunnelmesh_authorization_revision_poll_total。本节点创建/轮换/撤销 token 会立即失效本地缓存,其它节点依赖轮询。
  • 轮询失败超过 max_stale_on_poll_error 后,Server 会 fail-closed,不再使用正向缓存;负向缓存仍受 negative_ttl 限制。若需要立即排障,可临时设置 server.authorization_cache.enabled=false 并滚动重启。
  • 升级前确认 schema_meta.version=8、v8→v9 增量脚本存在,数据库账号可创建表并更新 schema_meta。
  • v9 的 authorization_revision 是授权缓存的一致性依据。回滚应用可保留该表;不要手工删除表、修改 revision 或回退版本号。需要精确恢复时使用升级前备份。

客户端观测与 Schema v15

  • 升级前确认 schema_meta.version=14、migrations/incremental/v0014_to_v0015/ 下 mysql.sql 与 sqlite.sql 均存在,且数据库账号可对两张租约表执行 ALTER TABLE。
  • v15 只把 client_connection_leases.connection_epoch 与 agent_connection_leases.connection_epoch 从 INTEGER 加宽为 BIGINT,不新增表、不新增列、不改索引,也不重写任何数据。
  • MySQL 的 INT → BIGINT 需要重建表:8.0 可走 INPLACE,5.6/5.7 重建期间允许并发 DML。两张表每行对应一条活跃物理 WebSocket,量级通常在百行以内,预计锁表时间为秒级。
  • SQLite 侧无结构变更(其 INTEGER 本就是 64 位),增量脚本只执行一条幂等空操作,保持迁移链在两种驱动上一致。
  • 迁移不会回写升级前已被钳制成 2147483647 的历史行——那些租约早已过期,且无法可靠还原原始 token;活跃连接由心跳自愈负责修正。
  • 回滚应用时保留 v15 Schema:v14 二进制读 BIGINT 列仍得到 int64,功能正常。切勿把列改回 INTEGER,那会重新触发钳制;确需还原结构只能用升级前备份恢复,不要手工回退 schema_meta.version。
  • 5 分钟止损:新版 Server 启动失败时直接回退二进制即可恢复服务,数据库无需回滚。
  • 详细步骤与校验 SQL 见 Schema 升级指南。

WebSSH 大文件传输中断

  • 症状:终端里执行 sz <几 MB 文件> 传到一半就断,页面显示“远端会话已结束 / 远端退出码:0”,浏览器没有下载到文件;或 rz 选完文件后远端报 removed. 且终端出现乱码。
  • 根因是链路上某一跳没有把背压传回发送方,快的一端打爆了慢的一端的缓冲区,随后中继把该 stream RESET,SSH 通道随之 EOF。退出码 0 是 SSH 通道被本地关闭的假象,不是远端真的退出。
  • 判别方法:
    • 终端上方出现红色告警 SSH 通道异常中断(本地 WebSocket 传输失败) → 浏览器侧 WebSocket 失败(网络切换、反代超时、发送缓冲耗尽)。
    • Server 日志出现 relay: backpressure 或 protocol: window exhausted → 中继接收/发送窗口被超额,通常是 Server 与 Agent 版本不一致(一端不懂窗口协商)。
    • 页面显示真实的 远端退出码:N(N≠0)→ 远端程序自己失败,例如磁盘满、sz 参数错误。
  • 处理:
    1. 确认 Server 与 Agent 是同一版本;流控窗口是内置协议约定,混版本必然失败。
    2. 经 Nginx 反代时确认 /ws/webssh/ 关闭了 proxy_buffering 且 proxy_read_timeout 足够长,见 Nginx/WSS 推荐配置。
    3. 终端乱码是 pty 回显了传输中的协议字节,reset 或 stty sane 即可恢复,不代表会话损坏。
    4. 超大文件优先用 SFTP 页面传输:ZMODEM 接收方向会在浏览器内存中缓冲整个文件后才触发下载。
  • 流控默认额度与三层背压说明见大文件传输与通道流控,均为内置行为,没有可调参数。
  • 复现与回归验证:node test/e2e/webssh/run.mjs(4 MiB 下载 + 1.5 MiB 上传,逐字节校验),详见 WebSSH 浏览器端到端测试。

WebSFTP 上传失败

  • 症状:SFTP 页面上传文件后弹出红色 文件传输失败,或上传中途提示 SFTP 通道已关闭;目标主机上文件已创建但被截断,落盘大小常见停在 768 KiB 或 2 MiB 附近。
  • 两种提示对应两类原因,排查时先看提示文案:
    • 文件传输失败:浏览器侧 libssh2_sftp_write 只消费了部分字节。SSH 会话是非阻塞的,短写返回的是“已写入字节数”而不是 EAGAIN,旧实现把它当致命错误直接失败。现已按 pointer + written 循环补写剩余字节,连续 5 秒零进度才判定停滞并抛错。
    • SFTP 通道已关闭:Server 侧中继 stream 被判死。入站 WINDOW_UPDATE 除了给发送窗口加额度,还会镜像进容量 16 的 control 队列供 ReadControl 使用,而 WebSSH/SFTP 桥接只调用 Read、从不消费 control 队列;上传累计超过 16 次窗口更新即溢出并以 protocol: flow-control window exhausted 失败整条流。镜像现已改为尽力而为(队列满则丢弃,权威额度保存在发送窗口里),回归测试为 TestAgentRelayStreamSurvivesUndrainedWindowUpdates。
  • 判别方法:
    • 截断点固定且与窗口额度相关(实测复现时消耗约 16 × 128 KiB ≈ 2 MiB 额度后 stream 被杀,sshd 落盘滞后,磁盘上只剩 768 KiB)→ 中继 stream 死亡,确认 Server 与 Agent 都已包含该修复。
    • 失败大小随机,且浏览器控制台或页面出现 SFTP write stalled after N of M bytes → 通道真的卡住(网络中断、Agent 离线、Nginx 超时),按上一节的链路排查。
    • 只有上传失败、下载正常 → 属于本节问题;下载方向的额度走 WriteControl,不经过 control 镜像队列,因此不受影响。
    • 报错是 SFTP 状态码(permission denied、no space left)→ 目标目录权限或磁盘问题,与通道无关。
  • 处理:
    1. Server、Agent、Web 产物必须同版本发布。中继修复只在 Server 侧,只刷新前端 bundle 无效;只升级 Server 也不会修复浏览器的短写截断。
    2. 大文件优先用 SFTP 而不是终端 rz:SFTP 分块写入不会在浏览器内存里缓冲整个文件。
    3. 经 Nginx 反代时同样需要 /ws/webssh/ 关闭 proxy_buffering,见 Nginx/WSS 推荐配置。
  • 复现与回归验证:node test/e2e/webssh/run.mjs 中的 SFTP upload writes the file byte-for-byte 检查(默认 3 MiB,可用 TM_E2E_SFTP_UPLOAD_BYTES 调整,逐字节 sha256 校验),详见 WebSSH 浏览器端到端测试。

ZMODEM 在真实网络上报 ZRPOS / OO 错误

  • 症状(任一):
    • rz 上传时弹 ZMODEM 传输失败:Unhandled header: ZRPOS,随后 Peer aborted session,终端出现大段乱码(内容正是正在上传的文件)。
    • sz 下载完成后敲回车弹 ZMODEM 传输失败:PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: 27,93,…,终端停在空行,看似卡死。
    • 传输面板的进度长时间停在某个百分比且无报错(例如 291 KiB / 17.2 MiB):浏览器回送的 ZACK 被插队写破坏后,lrzsz 会暂停发送等待合法 ZACK,直到自身超时。与上面两种报错同根因。
    • sz 卡住几十秒后整页变成“SSH 通道已关闭”空态:卡住是上述暂停,而旧前端把出站写失败(5 秒零进度停滞保护)连带关闭了整条通道;新版本只中止传输并保留终端。
    • 本机或同机房测试一切正常,只在真实网络、经 Nginx WSS 反代的链路上出现。
  • 根因是浏览器侧同一条 SSH 通道上存在多个并发出站写入(ZMODEM 的同步回调是 fire-and-forget)。通道窗口紧张时 libssh2 只消费部分字节,部分写的续写被另一个写者插队,对端收到坏帧:rz CRC 失败回 ZRPOS,sz 收不到合法收尾直接退出、不发 OO。错误里的 27,93 是 ESC ],即 shell 的 OSC 标题序列——提示符跑到了本该是 OO 的位置。
  • 进度事件洪泛是卡住的诱因之一:progress 原先每个 ZDATA 子包触发一次(17 MiB 文件约上万个 Vue 更新与进度条重绘),主线程被占用时 ZACK 写出被推迟,sz 随即暂停。现在按 64 KiB 字节步进或 500 ms 安静间隔上报,首个子包无条件上报。
  • “通道被关闭”是次生缺陷:toPeer 的写失败 catch 曾无条件关闭通道。现在传输仍活跃时只本地中止传输(abortLocal,不向已坏的管道发 ZABORT)并弹 ZMODEM 传输失败:…;通道是否真的死亡由读循环裁决(EOF 会给出正常结束态),写路径不再越权关通道。
  • 终端停空行是次生缺陷:协议抛错时解析器缓冲区里的提示符字节曾随会话一起被丢弃。现在这些字节会回放给终端,报错横幅出现的同时提示符立即恢复。
  • 处理:
    1. 升级 Web 前端 bundle 与嵌入它的 Server 二进制到包含单写者闸门的版本;Server/Agent 无需改配置。
    2. 进度卡死请先确认线上 bundle 是否已包含单写者闸门(该修复只在前端):旧 bundle 上卡进度、ZRPOS、OO 报错是同一缺陷的三种表现。
    3. 终端乱码是 lrzsz 中止后对未消费在途字节的 pty 回显,属 lrzsz 固有行为;传输不再异常中止后自然消失,已出现的乱码用 reset 或 stty sane 恢复。
    4. 升级后仍出现 ZRPOS 或进度卡死,说明链路存在真实丢包或中间层缓冲,按 Nginx/WSS 推荐配置 检查 /ws/webssh/ 的 proxy_buffering 与超时设置,并保留 Server 日志与浏览器控制台输出用于定位。
    5. 遇到“卡住后通道关闭”请先升级前端:新版本下该序列只中止传输、保留终端,sz 自身超时退出后提示符会回来;若通道确实已死,读循环会随后给出正常结束态而不是静默悬挂。
  • 回归验证:交错不变式由 never interleaves concurrent shell writes when libssh2 consumes partially 与 never interleaves an SFTP write with a concurrent SFTP request 两个单元测试固化。该缺陷在 localhost E2E 中不可复现(通道窗口从不紧张),不要用 E2E 绿作为已修复的依据。

ZMODEM 传输结束后终端不回显、同一条错误反复弹出

  • 症状(成组出现):
    • sz 传输结束后终端不再回显,敲回车没有任何输出,看似卡死;页面顶部堆叠多条完全相同的 ZMODEM 传输失败:PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: …,敲一次回车多一条。
    • 文件其实已经下载完成(浏览器下载目录里字节完整),但页面报了失败。
    • 大文件(几十 MiB 以上)sz 在传输中途被本地中止,弹出 ZMODEM 传输失败:SSH shell write stalled after N of M bytes,进度面板消失,终端仍在但传输没了。
  • 与上一节的区别:上一节是并发写插队造成对端收到坏帧(ZRPOS、乱码、对端中止),本节是解析器状态没有随会话一起释放与停滞期限对大文件过严,两者可以叠加出现。判别要点是错误文案:Only thing after ZFIN 反复出现且终端完全静默 → 本节;ZRPOS/Peer aborted session 且终端有大段乱码 → 上一节。
  • 根因一(横幅堆叠 + 终端静默):vendored zmodem.js 的 Sentry.consume() 只要内部 _zsession 非空就把所有输入投喂给它,而 _zsession 只由会话自身的 session_end 事件清空。_consume_first() 在 _got_ZFIN 后收到非 OO 字节时是直接 throw,session_end 永不触发,库也没有公开的复位入口。于是会话虽然在桥接里被置空,Sentry 仍持有那个死会话:之后每个字节都被再次投喂、再次抛出,既产生新横幅又永远到不了终端。
  • 根因二(把已完成的传输报成失败):ZMODEM 的 ZFIN 只在 ZEOF 之后交换、OO 只在 ZFIN 之后打印。能触发这个 throw 说明文件字节早已全部 spool 完、completed 已上报、浏览器已存盘,缺的只是对端的两个字节收尾。它属收尾噪声,不是传输失败。
  • 根因三(大文件中途被中止):大文件传输期间浏览器回送的 ZACK 会在出站方向堆积数兆字节,lrzsz 在等待重传确认时暂停读取,通道窗口收紧后协议写出现零进度;旧的 5 秒停滞保护把这个合法暂停判成死链路并抛错,toPeer 的处置策略随即本地中止传输。
  • 现在的行为:
    • 协议抛错后桥接重建 Sentry 实例(abort()、abortLocal() 同样重建),终端立即恢复普通回显,同一条错误不再重复上报。
    • receive 会话在 _got_ZFIN 之后抛错时按已完成处理:直接发 ended 收起面板、把解析器缓冲区里的提示符回放给终端,并保留吃掉晚到 OO 的逻辑,不弹任何错误。传输中途抛错仍然照常报 error。
    • ZMODEM 协议写不再继承 5 秒停滞期限(write(data, { stallLimitMs: 0 })),按键写的默认期限不变。同时 close() 不再排在写闸门之后,否则一个无期限的停滞写者会让 closeTerminal() 永久悬挂并泄漏 libssh2 句柄。
  • 处理:
    1. 这三项修复全部只在前端 bundle:必须 npm run build + rsync 到 internal/server/web_dist/ 后重建 Server 二进制。只替换二进制不重建嵌入产物等于没有发布。
    2. 若仍看到 SSH shell write stalled after …,先确认线上 bundle 是否包含停滞豁免(在产物中检索 stallLimitMs);旧 bundle 上“大文件中途被中止”是必然结果。
    3. 豁免只覆盖零进度等待,不覆盖真实错误:隧道断开时 libssh2 返回负值错误码,仍会抛错并按“传输活跃则中止传输、保留终端”的既有策略处置,通道是否真的死亡由读循环裁决。
    4. 传输结束后若终端仍有残留乱码,是 lrzsz 对未消费在途字节的 pty 回显,reset 或 stty sane 恢复。
  • 回归验证:reports a finished receive as ended when the peer skips the OO trailer、returns to plain terminal passthrough after the parser session dies、parses shell output normally after abortLocal、waits for window credit without a deadline when the stall limit is disabled、still reports a stalled shell write once the default deadline passes 五个单元测试固化;E2E 的 transfer panel closes and the prompt returns 覆盖“传输完成 → 面板收起 → 提示符回归且控制台洁净”。

sz 下载中偶现“SSH 通道已关闭”空态

  • 症状:大文件 sz 下载过程中或刚结束时,页面突然变成“已断开 / SSH 通道已关闭”空态;没有红色传输告警,也没有“远端会话已结束 / 远端退出码”提示;浏览器下载目录里的文件可能已经完整。
  • 判别:该空态的渲染条件是本地 closeTerminal() 且读循环从未报告远端退出。传输期间按键被丢弃、协议写失败只中止传输不关通道,因此空态只可能来自浏览器侧把“拥塞”误判成“死亡”:
    • 传输尾部出站窗口拥塞,传输结束瞬间的按键写零进度达到旧判定窗口(5 秒)即抛错并关闭会话;
    • 或传输已结束、但一条仍在等窗口额度的协议写随后拒绝,旧逻辑在“传输非活跃”分支直接关闭会话。 若页面带红色 SSH 通道异常中断(本地 WebSocket 传输失败) 告警或“远端退出码”,则不是本节问题,按 WebSSH 大文件传输中断 排查。
  • 根因:浏览器侧“通道已死”的判定窗口曾是 5 秒零进度(shell 写与 SFTP 写共用),而大文件传输的尾部拥塞 routinely 超过这个量级;同时第十五轮让协议写无期限等待窗口额度,传输结束后的迟到拒绝也会走到关会话分支。Server 侧唯一的时间型判定 server.webssh.idle_timeout 默认 5 分钟,不是本问题来源。
  • 现在的行为:
    • “通道已死”的判定窗口提高到 30 秒零进度(shell 写与 SFTP 写共用)。真正死掉的隧道通常由 WebSocket 关闭或读循环 EOF 更早给出结束态,30 秒只是兜底上限。
    • 传输结束后的迟到协议写拒绝不再关闭会话;通道存活性归读循环与按键路径裁决。
  • 处理:
    1. 该修复只在前端 bundle:npm run build + rsync 到 internal/server/web_dist/ 后重建 Server 二进制;只换二进制不重建嵌入产物等于没有发布。
    2. Server 侧无需改配置;若曾为排查问题把 server.webssh.idle_timeout 调小,请恢复默认 5m 或保证不小于传输最久暂停时间。
    3. 经 Nginx 反代时仍按 Nginx/WSS 推荐配置 关闭 /ws/webssh/ 的 proxy_buffering 并放宽读写超时,避免中间层先于 30 秒窗口断开连接。
    4. 行为变化知会:死隧道的写路径兜底 surfaced 从 5 秒变为 30 秒;期间终端可能短暂无回显,属预期。
  • 回归验证:only reports a stalled shell write after thirty seconds of zero progress、fails an SFTP write only after thirty seconds of zero progress 两个单元测试(20 秒不报、35/36 秒报),以及源码断言 fails the transfer instead of the channel when a protocol write fails(toPeer 片段不得包含 closeTerminal())。

管理后台打开是 Nginx / OpenResty 欢迎页

  • 症状:访问管理后台域名返回 “Welcome to OpenResty!”(或 Nginx 默认页),但 /api/v1/… 接口和 WebSocket 可能依旧正常;刷新前端路由(如 /remote-servers)同样是欢迎页。
  • 根因:server 块里缺少兜底 location / { proxy_pass …; }。SPA history fallback 由 Server 内嵌静态服务完成,前提是请求先到达 Server;没有兜底反代时,/ 与所有前端路由命中 Nginx 默认 root(OpenResty 常为 /usr/local/openresty/nginx/html)下的 index.html,即欢迎页。该块常被误认为“前端路由配置”而在精简配置时删掉。
  • 判别:
    • curl -sI https://<管理后台域名>/ 返回 200 且正文是欢迎页,而 curl -sI https://<管理后台域名>/api/v1/health 或登录接口正常 → 兜底反代缺失。
    • 检查 nginx 配置:server 块只有 /api/、/ws/*、/health/* 等 location,没有 location /。
    • 若 / 返回 404 而非欢迎页,则不是本节问题,多为 Server 未启动或 upstream 不可达。
  • 处理:
    1. 加回兜底反代,见 Nginx 推荐配置 最后一段:location / { proxy_pass http://tunnelmesh_server; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }。不需要 try_files 或 rewrite。
    2. nginx -t 通过后 reload;确认 /、任意前端路由深链、/api/ 与 /ws/webssh/ 四类路径都正常。
    3. 该 location 是最短前缀,不会吞掉 /api/ 与 /ws/*;若曾为“修欢迎页”加过 try_files $uri $uri/ /index.html; 指向本地目录,请一并删除,避免静态托管与反代并存。
  • 回归:配置类问题,无代码回归测试;Nginx 推荐配置 的推荐配置注释与关键约束已写明“兜底反代不能删”,本节作为识别与恢复手册。

tp-* HTTP 代理入口失败

排障顺序固定为四段:OpenResty error_log(info 级)→ Server 日志 proxy_entry_* → Prometheus tunnelmesh_proxy_entry_* → 审计日志 proxy_tunnel_opened / proxy_tunnel_closed。

OpenResty 的隧道关闭行包含 route/target/client/上下行字节数/时长/结束原因,是定位“隧道为什么断”的 唯一现场;Server 日志给出稳定错误码与拒绝原因;指标给出趋势与量级;审计给出谁在什么时候被拒。 这三处都不得出现密码或完整 Proxy-Authorization 头,若出现按安全事故处理(轮换凭据 + 清理日志)。

客户端侧只能看到状态码,稳定错误码与使用者自助表见 HTTP 代理入口。

curl 报 Received HTTP code 403 from proxy after CONNECT

  • 现象:三类原因(路由身份非法 proxy_route_identity_invalid、路由不存在或停用 proxy_route_unavailable、源 IP 不在 ACL proxy_source_denied)对外文案完全一致,这是刻意的 防枚举设计,客户端无法区分。
  • 定位:
    # 看是哪一类:error_class 标签区分三种 403(地址按 server.http_addr,默认 :80)
    curl -s http://127.0.0.1/metrics | grep tunnelmesh_proxy_entry_requests_total | grep 'result="denied"'
    

    或查 Server 日志 proxy_entry_denied 行的 error_class 字段,取值固定为:identity(路由身份 非法)、route_unavailable(路由不存在或已停用)、source_acl(来源不在 ACL)、target(目标 非法或被策略拒绝)、auth(认证失败)、capacity(并发超限)、egress / egress_write / egress_read(出口不可用)、internal(无稳定公开码的内部故障)。审计侧只有 ACL 与认证拒绝会 落库:proxy_route_denied(reason=source_acl)与 proxy_auth_failed(reason 是具体错误码)。

  • 根因与处理:
    • identity:SNI 缺失或不是 tp-<name>.<domain_suffix> 形式。确认客户端填的是 https:// 完整 域名,且 DNS 泛解析、通配证书、server.proxy_entry.domain_suffix 三者一致。
    • route_unavailable:后台没有这条路由,或已停用。
    • source_acl:Server 看到的是 OpenResty 传来的 $remote_addr。如果 OpenResty 前面还有 一层 LB,$remote_addr 是 LB 的地址,ACL 会按 LB 判定。处理办法:让 LB 透传真实客户端 IP (如 X-Forwarded-For),把 server.proxy_entry.client_ip_header 改成对应的头,并让 OpenResty 把这个头透传到内部入口;同时确认 trusted_proxies 覆盖了 LB。
  • 回归:internal/proxyentry/acl_test.go、internal/proxyentry/identity_test.go。

407 且连续失败后长时间不恢复

  • 现象:密码明明正确,仍然持续 407。
  • 定位:指标 tunnelmesh_proxy_entry_auth_failures_total{reason="proxy_auth_backoff"} 在涨; 审计事件 proxy_auth_failed 的 reason 是 proxy_auth_backoff。
  • 根因:server.proxy_entry.auth_backoff_threshold(默认 5)触发退避,30 秒起每次翻倍、上限 15 分钟。退避期内不做密码比对,直接返回 407,所以“改对密码也没用”。
  • 处理:停止重试等窗口过去;确认不是有人在爆破(_auth_failures_total 按 route 分组看是否集中在 单条路由);必要时重启 Server 清空退避状态(退避是进程内状态,不入库)。
  • 回归:internal/proxyentry/auth_test.go 的退避阈值与指数上限用例。

405 Method Not Allowed

  • 现象:所有 CONNECT 一律 405,access_by_lua 完全没有日志。
  • 定位:
    nginx -V 2>&1 | tr ' ' '\n' | grep proxy_connect
    bash deploy/openresty/spike-connect-check.sh 18443
    
  • 根因:OpenResty 内核没有打 proxy_connect_rewrite_102101.patch,或模块与补丁版本不匹配。 未打补丁时 nginx 在 ngx_http_process_request_header 阶段就对 CONNECT 回 405,$connect_host 这个变量也根本不存在。注意只看 nginx -V 会误判:模块单独编进去时一样能看到 --add-module。
  • 处理:用 deploy/openresty/Dockerfile.proxy-connect 重建内核,或按上游 Compatibility 表为当前 OpenResty 版本选对补丁;重建后两级检查都要过。
  • 回归:deploy/openresty/openresty_artifacts_test.go 守护版本 pin 与 ./configure → patch → make 的构建顺序。

502 proxy_egress_unavailable

  • 现象:Received HTTP code 502 from proxy,或 OpenResty error.log 里 tunnelmesh: dial internal entry failed。
  • 定位(按顺序):
    # 1. Server 内部入口是否活着(应得到 Server 的 403,而不是 connection refused)
    curl -sv http://127.0.0.1:8089/
    # 2. 配置是否开启、地址是否与模板渲染结果一致
    tunnelmesh-server --config /etc/tunnelmesh/server.yaml check-config
    
  • 根因:server.proxy_entry.enabled=false;或 listen 与 conf 模板 __INTERNAL_UPSTREAM__(以及 Lua 的 -- __TM_INTERNAL_HOST__ / -- __TM_INTERNAL_PORT__ 标记)渲染结果不一致;或出口 Agent 离线、集群跨节点 relay 失败。
  • 处理:对齐三处地址;确认 Agent 在线;集群模式查 relay 与 tunnelmesh_relay_total。
  • 回归:internal/server/proxy_entry_listener_test.go(enabled=false 不监听、不可信 peer 直接关闭)。

503 + Retry-After: 5

  • 现象:proxy_capacity_exhausted,客户端按 Retry-After 退避。
  • 定位:tunnelmesh_proxy_entry_tunnels_active{route="tp-…"} 是否贴住上限;Grafana Row HTTP Proxy Entry 的 Proxy tunnels active 面板。
  • 根因:全局 server.proxy_entry.max_concurrent_tunnels(默认 512,0 表示不限)或该路由 config.maxConcurrentTunnels 被打满。
  • 处理:先确认是不是隧道泄漏(客户端断开但 tunnels_active 不降,见下面 client aborted 一节), 再考虑调高上限并同步评估 OpenResty 的 worker_connections(每条隧道 2 个 socket)。
  • 注意:nginx 侧的 limit_conn 只是兜底,配得比 Server 更紧时看到的是 nginx 自己的 503, 审计与指标里不会有对应记录。

504 proxy_egress_timeout

  • 根因:server.proxy_entry.connect_timeout(默认 10s)内没能通过 Agent 建立到目标的连接。
  • 定位:Agent 是否在线;集群模式下目标 Agent 是否挂在别的节点、relay 是否正常 (tunnelmesh_relay_total{result=~"failed|error"});目标本身是否可达(Agent 侧还有一次 SSRF/私网/端口校验)。
  • 处理:修 Agent 或 relay;确实需要更长握手时间才调 connect_timeout。

隧道建立后很快断开

  • 现象:CONNECT 返回 200,随后几十秒到几分钟内断开,error.log 里结束原因是 upstream:timeout 或 downstream:timeout。
  • 根因:Lua 的 read_timeout_ms 必须等于 Server server.proxy_entry.idle_timeout + 30s(默认 300s → 330000ms),让 Server 先判定空闲并关闭,Lua 只兜底。配反了会表现为 nginx 先掐断。
  • 定位:
    go test ./deploy/openresty -count=1   # 失败即 Lua 默认值与 Go 配置默认值已漂移
    grep -n '__TM_READ_TIMEOUT_MS__' deploy/openresty/tunnelmesh_proxy_entry.lua
    
  • 处理:按 idle_timeout + 30s 重算并替换标记处的值;改了 idle_timeout 就要同步改 Lua。

reload 后所有代理连接断开

  • 根因:main 上下文缺 worker_shutdown_timeout 300s;。旧 worker 收到 reload 后按默认窗口强制退出, 在途隧道被立刻掐断。
  • 处理:把 worker_shutdown_timeout 写进 nginx.conf 的 main 上下文(不能写在 server 块里, nginx -t 会报错),发布选低峰期,集群模式先扩 Server 再 reload nginx。

浏览器提示代理不支持 / 协商到 h2 失败

  • 根因一:tp-* server 块被加了 http2。ngx.req.socket(true) 在 HTTP/2 下游不可用, proxy_connect 模块的 Known Issues 也明确不支持 HTTP/2 的 CONNECT。
  • 根因二:客户端只能填 http:// 代理地址。路由身份来自 TLS SNI,明文入口无法识别路由。
  • 处理:删掉 tp-* 块里的 http2 on;(既有 admin 块的 per-server http2 on; 不用动,要求内核 nginx >= 1.25.1);客户端改用 https://tp-<name>.<domain_suffix>。

error.log 里大量 client aborted

  • 单独的 client aborted 属正常:客户端主动断开,ngx.on_abort 立刻关闭上游 socket。
  • 异常特征:只有 client aborted 而没有对应的 tunnelmesh: tunnel closed route=… 行。这说明 lua_check_client_abort 未开或 ngx.on_abort 未注册,隧道要挂到读超时(默认 330s)才回收, 表现为 tunnels_active 持续上涨、worker connection 被半开隧道占满。
  • 处理:确认渲染后的 server 块里有 lua_check_client_abort on;,Lua 是仓库里的原版 (go test ./deploy/openresty -count=1 通过)。