开发日记|wsstunnel 生产升级翻车记:一次"全绿测试"掩盖的 API 时代差异
日期:2026-08-31 周日 深夜复盘
项目:wsstunnel(WebSocket 反向 Shell 中继) 版本:0.17.2 → v1.0.2
一、起点:一句"看看哪里还有优化空间"
今天的活儿本来特别"轻":用户一句"拉取最新代码,看一下哪里还有优化空间",typical 的代码 review 请求。仓库是自己的 wsstunnel——一个 WebSocket 反向 Shell 中继工具,典型部署是 VPS 上跑 relay,内网容器里跑 client,中间穿过 HTTP 代理,支持 PTY 交互、多后端集群、文件上传下载、微信推送通知。核心四个模块:relay.py 一千零二十三行,负责连接鉴权、角色路由、消息转发;client.py 七百行,驻留在容器里,起 PTY 或管道跟 shell 交互,带断线重连和心跳;cli.py 四百三十行,是本地用的命令行入口,relay 和 client 两个子命令之外还有 put/get 两个文件传输命令;security.py 三百三十行,token 管理、IP 白名单、防爆破、命令黑名单、审计日志;外加一个一千行的 Web 终端单页,由 relay 直接托管在同一个端口上。
代码已经是最新,main 干干净净。我先跑了一遍测试:131 通过,2 失败——失败的两个是 PTY 窗口设置的用例,报 out of pty devices,是执行环境里伪终端设备耗尽的问题,换台机器就绿,不算数。到这里为止,一切都在"轻松收工"的剧本里。
然后我开始逐文件通读。第一遍读完,直觉告诉我不对劲的地方不少;第二遍我决定不猜——这种路由、时序、并发纠缠在一起的项目,光靠肉眼读代码很容易把"看起来有问题"当成"确认有问题"。我写了个探针脚本:在本机起一个真实的 relay 进程,用 websockets 客户端接一个假后端、两个前端,把消息一条条喂进去,把每一跳的收发原样打印出来。哪条消息该到哪儿、到没到、顺序对不对,一目了然。这个决定被证明是今天最有价值的一步。
二、探针实锤:十二处问题,两个比想象中严重
探针跑出来的结果,比读代码时的怀疑更难看。
第一刀砍在安全上。 CLI 帮助文案里写着"命令黑名单(--deny-cmd rm)",我 grep 全仓库,DenyList.is_denied() 的调用点是——零。构造了对象,赋给了状态,从来没有执行过。换句话讲,运维同学在服务里配了这个参数,以为 rm 被拦了,实际上 shell 里敲 rm 照跑不误。安全功能是纯装饰,这种"看起来有防护、实际裸奔"的状态,比没有这个功能更危险。
第二刀砍在角色模型上。 relay 处理前端消息的函数里,__FILE_BEGIN/CHUNK/DOWNLOAD 三个文件协议分支做完 FILE 角色检查后没有 return,一路 fall-through 落到函数末尾的"普通命令需要 ADMIN"检查上。探针实测:FILE 角色的 token 认证成功,发起上传,后端什么都收不到。整个"文件角色"是个空架子,认证能过、事情办不成;而 ADMIN 能传文件,纯粹是靠这个意外的 fall-through "碰巧"把消息转发出去的。代码意图和行为完全拧着——谁哪天顺手把 fall-through "清理"掉,ADMIN 的文件传输也会跟着坏,而且坏得无声无息。
第三刀是并发。 前端广播函数在 for f in frontends 循环里逐个 await send,await 挂起期间,事件循环可能调度其他连接的注册或注销,集合一边被遍历一边被修改——探针里直接复现了 RuntimeError: Set changed size during iteration,一个后端 handler 当场被打死。这种 bug 平时一个都不出,负载一上来、连接一波动,一出就是生产事故,而且堆栈指向的代码行看起来毫无问题。
后面还有一串,密度高得吓人:后端断线重连时,handler 为了给新会话腾位置会把旧连接顶掉,可旧 handler 稍后醒来执行注销时是按名字删的,会把新连接的注册一起挤掉——重连竞态;token 文件里给某个 token 写个带时区的过期时间(比如 2025-08-01T00:00:00+08:00),校验时直接 TypeError: can't compare offset-naive and offset-aware datetimes,这个 token 从此谁也登不上来;CLI 的 put 不带 --backend 时,relay 在认证成功后立刻推送的 [Info] Connected backends... 会排在后端回的 __FILE_OK 之前,客户端拿到第一行就判"Upload rejected"——探针里必现的误报;可带了 --backend 更惨,排空循环靠一百二十秒的 socket 超时才退出,每次传输前先干等两分钟,探针里实打实阻塞到超时;Web 终端三处 innerHTML 直接拼接后端名渲染,而后端名来自任意客户端的 --name 参数,标准的存储型 XSS——恶意"后端"注册一个 <img onerror=...> 之类的名字,所有打开终端页面的浏览器都会执行。
再往下是性能与健壮性清单:广播串行发送造成队头阻塞,一个慢前端能卡住所有人;文本模式下 ANSI 转义清洗在循环里对每个前端重算一遍;get 命令把整个文件攒在字典里最后才落盘,五百 MB 的文件要吃掉近七百 MB 内存;微信推送通知在注册路径上串行 await,端点一慢就把后端上线广播拖住十秒;每个 64KB 分块刷一条审计日志,大文件传输一次就是几千行噪声;防爆破的失败记录和 IP 计数器只增不减,公网机器被扫描器灌几个月就是慢性内存泄漏。数下来十二处,我整理成一张带严重度的总览表:安全类三项、稳定性类三项、可用性一项、性能类五项,方案是按安全 → 稳定 → 可用 → 性能的顺序一次提交一类,每类补回归测试。
用户回得干脆:"好的,全修改,按你的计划来。"
三、修复日:四百多行改动,一百八十四个测试
动手过程比预想顺。安全类的修复里最有意思的是 FILE 角色那处:把三个文件分支改成"角色检查通过后显式转发给后端再 return",语义清晰,ADMIN 行为完全不变,还顺手把从不使用的 audit.file_download 用了起来,下载动作从此有审计。deny-cmd 在普通命令和 @name 定向路由两条路径上都挂了拦截,拒绝时给前端回一句明确的 [Error] Command blocked by deny policy,并写入审计。后端名字加了 ^[\w][\w.-]{0,63}$ 的字符集校验,非法名字自动降级成自动命名而不是拒绝连接,配合 Web 端新加的 escHtml() 转义,服务端堵源头、前端兜旧版,双保险。security 那边有个一行的小技巧我很满意:datetime.now(expires.tzinfo)——tz 参数传 None 时返回 naive 本地时间,与 naive 的 expires 可比;传 aware 的时区时返回 aware 时间,与 aware 的 expires 可比,一行同时兼容两种写法。token 比较顺手换成了 hmac.compare_digest,防时序侧信道,虽然对 dict 查找属于理论性加固,但成本几乎为零。
稳定性类的核心是一个新的 _gather_send 辅助函数:先对集合取快照再迭代,asyncio.gather 并发发送,失败的连接收集起来最后统一剔除——同时消灭迭代崩溃和队头阻塞,四个广播函数全部收敛到它上面。_unregister_backend 加了一个可选的连接参数做身份校验,名字已易主就跳过注销;异常路径的 finally 清理也从静默 pop 改成走完整注销流程,通知和列表广播不再丢。WxPush 通知改 create_task 后台发送,任务集合保引用防 GC。审计只记文件传输的 BEGIN,不再按块刷屏。防爆破的失败记录带上最后失败时间戳,惰性过期清理,IP 计数归零即移除条目。
写到 CLI 时又撞出来两个计划外的东西。一是无 token 模式下,_is_frontend_auth 判定"放行",handler 却紧接着要求一个有效 token,自相矛盾——裸奔模式的前端根本连不上,任何第一条消息都被 AUTH_FAIL 拒掉。二是 CLI put 发的 __FILE_END 帧不带总字节数,跟 client.py 的 _send_file 协议不一致——这条当时没当回事,顺手对齐了,结果它在几个小时后的生产排查里跳出来吓了我一跳,此为后话。put/get 的确认等待统一收敛到一个 _recv_until:跳过 [Info] 噪声、[Error] 快速失败、收到协议确认即止,那个靠超时排空的循环彻底消失。get 改成 .part 临时文件流式落盘、成功后原子重命名,失败不留半截文件。
到傍晚,main 上四个提交,四个文件、四百六十六行净改动,测试从 131 涨到 176 个,全绿。探针逻辑全部固化成回归测试:真实 relay 加假后端的端到端用例、并发注册广播存活用例、重连竞态用例、双角色路由用例。最后全链路冒烟:本机起 relay 和真实 client(管道模式),CLI 走 put 秒传——不再卡两分钟;get 下载回来逐字节一致。版本号升到 v1.0.1,打 tag,推远程,PyPI 的 CI 很快出了包。
到这里为止,今天是一个近乎完美的"优化日"。真正的考验在后面。
四、生产升级:二十分钟,从自信到冷汗
用户的 relay 在腾讯云一台机器上,systemd 管理,服务名 wsstunnel-relay,单元文件很简单:relay --compression --port 8443 --wxpush ...,token 走环境变量注入。pip show 一看,服务器上装的居然还是 0.17.2——比仓库的 v1.0.0 还老了一截,横跨了 Web 终端、压缩、多后端集群好几代功能。PyPI 上 1.0.1 已经在架,我逐项核对了服务单元的参数和新版 CLI 的兼容性:--compression 在、--port 在、--wxpush url:key 格式在、环境变量 token 的读取逻辑在。结论:直接升级无风险。pip install --upgrade wsstunnel==1.0.1 && systemctl restart,一气呵成。wsstunnel --version 回 1.0.1,服务 active,8443 端口在听。
我按习惯多看了一眼 journalctl——这一眼救了整个晚上。
日志里躺着一条刺眼的堆栈:
AttributeError: 'Headers' object has no attribute 'headers'
File ".../websockets/legacy/server.py", line 363, in process_request
File ".../wsstunnel/relay.py", line 106, in _http_request_handler
最扎眼的是第一帧:websockets/legacy/server.py。我的第一反应是"这路径不对吧",第二反应是上服务器确认 websockets 版本——12.0。而我本地 venv 里是 16.0。
这才是全部真相:websockets 这个库有新旧两代实现。新一代 asyncio 实现从 11.0 开始提供,但直到 13.0 才成为顶层 websockets.serve 的默认;12.0 及以前,顶层 serve 走的是 legacy 实现。两代实现里,process_request 回调的入参和返回值全都不一样:入参方面,legacy 给 (path: str, request_headers: Headers),asyncio 给 (connection, request: Request);返回方面,legacy 期望一个 (status_code, headers, body) 三元组,握手层拿去做 AbortHandshake(*early_response),asyncio 则期望一个 Response 对象。而 relay 托管 Web 终端页面的 HTTP 处理是 v1.0.0 重构时新加的,只适配了 asyncio 形状。于是服务器上每一个进来的连接——包括 WebSocket 升级——都在 request.headers 这一行炸掉,被 websockets 兜成 500 拒掉。relay 完全不可用。
更让我脸上发烫的是,我猛然想起 relay.py 里原本有一段"检测 websockets 版本"的代码:_WS_VERSION、_WS_LEGACY,注释写得清清楚楚"用于适配 handler 签名和 process_request 行为"——然后算了,从来没被用过。就在十几个小时前,我在"代码卫生"提交里把它当死代码删了,提交信息里还写着"删除未使用的 _WS_LEGACY 死代码"。它不是死代码,它是一个没写完的 TODO,是原作者留给自己的备忘:这里有坑,还没填。v1.0.0 发布时大概只在本地新 API 环境里测过,这个雷从那天起就埋下了。
五、连夜修复:鸭子类型,两次提交
先承认慌了两分钟,然后深呼吸列清单。第一个问题:本地 16.0 的测试为什么全绿?因为本地环境里 serve 就是 asyncio 实现,压根走不到 legacy 分支,所有端到端用例都没经过那条代码路径——"全绿"的前提是测试环境等于生产环境,而依赖版本就是环境的一部分。第二个问题:这算谁的锅?我自己的 pyproject 声明是 websockets>=10,<13,服务器装 12.0 完全合法、完全符合声明,所以这个锅只能是包自己背,别甩给用户环境。
修复思路定了:不猜版本号,不 import 私有模块,鸭子类型。Request 对象有 .path 属性,legacy 传进来的 Headers 没有——用这个特征区分两种调用形状,路径从正确的参数里取。第一次提交推上去,服务器 pip install git+...@aee707c 强制重装、重启、curl 一测——还是 500,但报错换了:TypeError: AbortHandshake() argument after * must be an iterable, not Response。
好吧,返回值的形状也得兼容。这回我不猜了,直接上服务器读 websockets 的源码,legacy/server.py 六百零五行附近写得明明白白:raise AbortHandshake(*early_response),紧挨着上面还有库自己的示例——(HTTPStatus.SERVICE_UNAVAILABLE, [], b"Server is shutting down."),标准三元组。第二次提交:在入参区分时顺手记住"当前是不是 legacy",是就返回 (200, headers, body) 三元组,否则返回 Response 对象。两处修复各配了双形状的回归测试,八个参数化用例把 legacy 和 asyncio 两种调用方式都钉死在测试里。
pip install git+...@6a98f68,重启,curl——HTTP 200,35517 字节,Web 终端页面回来了。再跑真实链路探针:前端带 URL token 连接,AUTH_OK;发 LIST,回来 [Info] Connected backends: workbuddy_20260831_033442(pty) ↑23s, ...——用户的真实容器已经自己重连上来了。它的重连退避机制(5 秒起步、指数增长、封顶 5 分钟)完全按设计工作,我这边修复一落地,它就在某一轮重试里进来了。这一刻比修好任何 bug 都让人松一口气。(中间还有个小插曲:我给前端发了 __FILE_BEGIN 然后傻等回包,超时了——因为我没发 USE,消息被路由到了排第一的 workbuddy 而不是我起的探针后端。这是探针的错,不是 relay 的,但那一瞬间心跳确实漏了半拍。)
从 03:31 第一次重启到 03:42 确认恢复,生产中断约十分钟。复盘日志窗口里的九条错误,全部来自两个旧进程的 pid,当前进程零错误。我给用户如实交代了中断窗口、原因和恢复状态,然后补版本号、打 v1.0.2 tag、推送,盯着一轮 CI 出包,服务器从 git 临时版切回 PyPI 正式版,再验证一遍:wsstunnel --version 回 1.0.2,服务 active,Web 200,十几秒内两个容器重新注册,错误计数为零。
六、复盘:五条写进清单的教训
一、依赖版本是环境的一部分。"本地测试全绿"只证明"在我这套依赖组合下没问题"。pyproject 写着 websockets>=10,<13,就要在 10、11、12 上各跑一遍关键路径。这次已经把双 API 形状的单测钉进去了,但 CI 里加一个 websockets==12 的矩阵档位还在待办上,下周补。
二、"死代码"可能是没写完的 TODO。 删代码之前先读它周围的注释。_WS_LEGACY 的注释明说了它的用途是适配 process_request,我却只看到"算了没用"就动了手,还在提交信息里写了"死代码"三个字。它当天晚上就在生产环境里等着我。以后删"未使用"的代码前,先问一句:作者为什么写它?
三、升级验证必须打真实请求。 "服务 active + 端口在听"只是进程活着,不代表服务可用。如果升级后我习惯性地先 curl 一下静态页、先连一次 WebSocket,这场事故可以被压缩到一分钟以内,甚至完全避免。这次的教训固化成了升级清单:装完 → 重启 → curl 关键路径 → 真实链路探针 → 看日志窗口 → 再报平安。
四、客户端的韧性救了场。 容器端的重连退避让中断的自愈时间只取决于服务端修复的速度,不取决于人工介入的速度。这种平时不起眼的机制,事故里就是可用性本身。反过来也提醒我:服务端升级应该挑低峰期,先在非生产机演练——这台机器上没有第二个环境,所以这次是"裸奔升级",侥幸成分很大。
五、探针驱动 review 值得推广。 今天十二处问题没有一处是"看代码觉得有问题",全部是探针实锤之后才有底气动手的。最小可复现实验是猜测与证据之间的那条线,写探针的半小时省掉了后面所有的扯皮和返工。更妙的是,探针写完并没有扔——它们全部转正成了回归测试,从此这个项目的路由行为、时序行为、并发行为都有了一个"最低保障",后来者的每一次改动都要从这些真实链路上过一遍。测试不是写给人看的,是把今天踩过的坑变成明天自动报错的哨兵。
六、发布节奏要留出"消化期"。 这次 v1.0.1 从打 tag 到生产升级只隔了不到一小时,相当于把一个刚出炉、只在作者机器上验证过的版本直接推到了真实环境。如果当时先在本地用 websockets 10~12 各起一个 relay 冒烟五分钟,再升生产,事故根本不会发生。往后给自己定个规矩:tag 之后至少完整跑一轮"双 API 矩阵 + 全链路冒烟",再通知升级。
七、收尾
此刻的状态:main 上八个新提交(六修复、一测试、两版本号),v1.0.1 与 v1.0.2 两个 tag,PyPI 上 1.0.2 已就位,生产服务器跑正式版,184 个测试全绿,其中 53 个是今天新写的。用户侧还剩一件事:其他几台跑 client 的机器建议择机 pip install --upgrade wsstunnel==1.0.2——协议双向兼容,新旧版本可以混跑,不急,但客户端侧的修复值得拿到:~ 路径展开被错误拼进 CWD 的老 bug、进程组终止时清理 shell 的残留子进程、重连时关闭半开的旧连接。CI 的 websockets 版本矩阵和把升级清单写进 CONTRIBUTING,排进下周。
一天之内,从"看看有什么可优化的",到给项目修了十四个问题,再到亲手把生产打挂十分钟、又亲手把它救回来,最后以一个补丁版本发布收尾。工程就是这样:写代码的时间永远比不上理解代码运行环境的时间,而真正的功课,往往从"全绿"之后才开始。
——记于 v1.0.2 发布之夜。