故障排查
故障排查
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 不在线
- 确认 Agent 能访问 Server 的
wss://地址。 - 确认证书链、SNI 和系统时间正确。
- 检查 Agent ID 是否稳定且没有重复注册。
- 检查 Server 节点租约是否过期,以及集群节点时间是否同步。
- 确认目标服务从 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参数错误。
- 终端上方出现红色告警
- 处理:
- 确认 Server 与 Agent 是同一版本;流控窗口是内置协议约定,混版本必然失败。
- 经 Nginx 反代时确认
/ws/webssh/关闭了proxy_buffering且proxy_read_timeout足够长,见 Nginx/WSS 推荐配置。 - 终端乱码是 pty 回显了传输中的协议字节,
reset或stty sane即可恢复,不代表会话损坏。 - 超大文件优先用 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)→ 目标目录权限或磁盘问题,与通道无关。
- 处理:
- Server、Agent、Web 产物必须同版本发布。中继修复只在 Server 侧,只刷新前端 bundle 无效;只升级 Server 也不会修复浏览器的短写截断。
- 大文件优先用 SFTP 而不是终端
rz:SFTP 分块写入不会在浏览器内存里缓冲整个文件。 - 经 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 只消费部分字节,部分写的续写被另一个写者插队,对端收到坏帧:
rzCRC 失败回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 会给出正常结束态),写路径不再越权关通道。 - 终端停空行是次生缺陷:协议抛错时解析器缓冲区里的提示符字节曾随会话一起被丢弃。现在这些字节会回放给终端,报错横幅出现的同时提示符立即恢复。
- 处理:
- 升级 Web 前端 bundle 与嵌入它的 Server 二进制到包含单写者闸门的版本;Server/Agent 无需改配置。
- 进度卡死请先确认线上 bundle 是否已包含单写者闸门(该修复只在前端):旧 bundle 上卡进度、
ZRPOS、OO报错是同一缺陷的三种表现。 - 终端乱码是 lrzsz 中止后对未消费在途字节的 pty 回显,属 lrzsz 固有行为;传输不再异常中止后自然消失,已出现的乱码用
reset或stty sane恢复。 - 升级后仍出现
ZRPOS或进度卡死,说明链路存在真实丢包或中间层缓冲,按 Nginx/WSS 推荐配置 检查/ws/webssh/的proxy_buffering与超时设置,并保留 Server 日志与浏览器控制台输出用于定位。 - 遇到“卡住后通道关闭”请先升级前端:新版本下该序列只中止传输、保留终端,
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 句柄。
- 协议抛错后桥接重建 Sentry 实例(
- 处理:
- 这三项修复全部只在前端 bundle:必须
npm run build+rsync到internal/server/web_dist/后重建 Server 二进制。只替换二进制不重建嵌入产物等于没有发布。 - 若仍看到
SSH shell write stalled after …,先确认线上 bundle 是否包含停滞豁免(在产物中检索stallLimitMs);旧 bundle 上“大文件中途被中止”是必然结果。 - 豁免只覆盖零进度等待,不覆盖真实错误:隧道断开时 libssh2 返回负值错误码,仍会抛错并按“传输活跃则中止传输、保留终端”的既有策略处置,通道是否真的死亡由读循环裁决。
- 传输结束后若终端仍有残留乱码,是 lrzsz 对未消费在途字节的 pty 回显,
reset或stty sane恢复。
- 这三项修复全部只在前端 bundle:必须
- 回归验证:
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 秒只是兜底上限。
- 传输结束后的迟到协议写拒绝不再关闭会话;通道存活性归读循环与按键路径裁决。
- 处理:
- 该修复只在前端 bundle:
npm run build+rsync到internal/server/web_dist/后重建 Server 二进制;只换二进制不重建嵌入产物等于没有发布。 - Server 侧无需改配置;若曾为排查问题把
server.webssh.idle_timeout调小,请恢复默认 5m 或保证不小于传输最久暂停时间。 - 经 Nginx 反代时仍按 Nginx/WSS 推荐配置 关闭
/ws/webssh/的proxy_buffering并放宽读写超时,避免中间层先于 30 秒窗口断开连接。 - 行为变化知会:死隧道的写路径兜底 surfaced 从 5 秒变为 30 秒;期间终端可能短暂无回显,属预期。
- 该修复只在前端 bundle:
- 回归验证:
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 不可达。
- 处理:
- 加回兜底反代,见 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。 nginx -t通过后 reload;确认/、任意前端路由深链、/api/与/ws/webssh/四类路径都正常。- 该 location 是最短前缀,不会吞掉
/api/与/ws/*;若曾为“修欢迎页”加过try_files $uri $uri/ /index.html;指向本地目录,请一并删除,避免静态托管与反代并存。
- 加回兜底反代,见 Nginx 推荐配置 最后一段:
- 回归:配置类问题,无代码回归测试;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 不在 ACLproxy_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 RowHTTP 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必须等于 Serverserver.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-serverhttp2 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通过)。