Files
ykxmz/_build/verify_report_verifier.md
xmz7891 8880e7033d 修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
2026-10-07 17:59:39 +08:00

23 KiB
Raw Permalink Blame History

对抗性独立验证报告

验证者:verifier(task-4) 时间:2026-10-07 17:35 起 验证对象:commit facb156 + 工作区未提交改动 方法:全部结论都由自己动手打真接口/跑真代码得出,不复述任何人的自述。 每个发现都附可复现命令。额外做了变异测试来验证测试本身可不可信。


零、结论速览

# 严重度 问题 状态
1 🔴 严重 TTL 清理 + 关掉「允许新设备接入」= 设备永久锁死,重启也救不回来 已复现
2 🔴 高 管理页 JS 语法错误 → 整段 script 不执行 → 所有按钮失效 已复现
3 🔴 高 --relay / --relay-status 命令行崩溃 / 永远报失败 已复现
4 🟡 中 deploy.sh --prefix 沙箱泄漏,会动真实系统(含重启线上服务) 已取证
5 🟡 中 test_net_tune.py 断言过时,和实现故意反转的决策矛盾,永久假失败 已确认
6 🟡 中 test_admin_device_control.py 的注释描述了它并不验证的规则(变异测试证明) 已证明
7 🟢 低 deviceId 接受控制字符(\x00、\r\n),可伪造日志行 已确认
8 🟢 低 enable_relay() 第二次调用返回 True 谎报成功(当前无调用方触发,潜伏) 已确认
9 ⚪ 提示 打包 EXE 过期(16:17 构建,源文件 17:10~17:42 有改动),e2e 失败是这个原因 已确认

验证过但没问题的攻击面:见第四节。


一、🔴 严重:TTL 清理 + 开关 = 设备永久锁死

复现

python _tests/test_verify_ttl_lockout.py

证据

═══ 复现:管理员关掉「允许新设备接入」,电脑隔夜再开机 ═══
  [PASS] ① 电脑首次登记成功
  [PASS] ② 管理员关掉「允许新设备接入」
  [PASS] ③ 电脑当场重连仍然可以(开关只挡新设备)
    … 电脑关机了。等 4 秒模拟"隔夜"(真实是 6 小时)
  [PASS] ④ 中转把超时设备清掉了(设计中如此)
    磁盘 relay_state.json 里还有这台设备的码绑定: True
    内存里 codeCount=0
  [FAIL] ⑤ 电脑第二天开机 —— 应该能连上
         —— {'ok': False, 'blocked': True, 'error': '中转站当前不接受新设备接入'}

═══ 追问:重启中转能不能救回来? ═══
    重启后磁盘里这台设备的码绑定: True
    重启后内存 codeCount=0
  [FAIL] ⑥ 重启中转后电脑能连上
         —— {'ok': False, 'blocked': True, ...}

机制

三处代码叠加:

  1. relay.py:479 —— 判断"是不是老设备"用的是活跃设备表:

    known_device = device_id in self.devices
    
  2. relay.py:765-786 —— snapshot() 把超过 DEVICE_TTL(6 小时)的设备 从 self.devices 和 self.codes 里一起删掉。

  3. 于是设备回来时 known_device = False,撞上 relay.py:480:

    if not known_device and not self.allow_new:
        return {"ok": False, "blocked": True, ...}
    

为什么重启也救不回来(关键): snapshot() 清 codes 时没有调 _save(),所以磁盘上还留着绑定。 重启后 _load() 把 codes 恢复了 —— 但紧接着第一次 snapshot() 又把它 当孤儿清掉(owner 不在 devices 里)。自己把自己 perpetuat 住了。

影响

  • 触发条件极常见:用户下班关机、第二天开机,间隔必然超过 6 小时。
  • 永久性:不重启中转、不换 deviceId 都绕不过去。唯一的出路是 管理员全局重新打开开关。
  • 最糟的是管理员不知道要放开谁 —— 那台设备已经被清出管理页列表了, 页面上根本看不到它。

建议修法

判断依据要和"码绑定"解耦,且不能被超时清理抹掉。可选:

  • 方案 A(推荐):register() 里把"是否老设备"改成
    known_device = (device_id in self.devices
                    or device_id in self.codes.values())
    
    —— codes 里的绑定同样被清的话不解决问题,所以要配合方案 B。
  • 方案 B(推荐):relay_state.json 里单独存一份"曾经登记过的 deviceId" (knownDevices),超时清理时不动它。这才是"已登记过"的准确语义 —— 它讲的是"历史上被允许过",不是"此刻在线"。
  • 无论选哪个,snapshot() 清 codes 后应该调 _save(), 否则磁盘和内存会长期不一致。

二、🔴 高:管理页 JS 语法错误 → 所有按钮失效

复现

python _tests/test_admin_js.py          # 官方 3d 阶段,会 FAIL
python _tests/test_verify_js_syntax.py  # 我写的精确取证

证据

渲染出来的 <script> 里:

    const typed = prompt('把「' + name + '」的配对码 ' + old
                       + ' 改成:
(必须是 6 位数字)', '');        ← 单引号字符串里有**真实换行**

状态机扫描结果:

    🔴 状态机在下列位置发现「字符串里的裸换行」:
       第 46 行(引号 ')
       第 47 行(引号 ')
    共 2 处 —— 这就是 <script> 语法错误的来源。

根因

relay/admin.py:370:

+ ' 改成:\n\n(必须是 6 位数字)', '');

ADMIN_JS 是 Python 三引号字符串,Python 先把 \n 转成真实换行, 再原样拼进 <script>。作者本意是 JS 的换行转义,应该写 \\n。

引入时间(已归因)

git log --oneline -3 -- relay/admin.py
# d4f335b 管理页新增:允许/禁止新设备接入 + 修改设备配对码   ← 就是这一笔
# 5bf108f 修管理页顶栏被切掉(标题看不见、徽章压在字上)
# 160dc37 修管理页测试的引号敏感 + 补 netPanel id

git show HEAD:relay/admin.py | grep '改成:'
# + ' 改成:\n\n(必须是 6 位数字)', '');

注意:test_admin_device_control.py(23 项)在同一个提交里, 它通过了 —— 因为它只检查 JS 里有没有 data-recode、allow_new 这些 token,不检查 JS 语法是否成立。 "23 项测试通过"和"这段 JS 能跑"是两件不同的事。

影响

一个 SyntaxError 会让整段 <script> 不执行。页面上绑定的按钮:

button[data-recode]          ← 「改配对码」
button.danger[data-device]   ← 「删除设备」
input.pick                   ← 「批量删除」

全部变成幽灵按键(点了没反应)。这不是"改码功能坏了", 是整个管理页的交互全废。

建议修法

# 二选一:
+ ' 改成:\\n\\n(必须是 6 位数字)', '');     # 转义成 JS 的 \n
+ ' 改成:' + '\n' + '(必须是 6 位数字)', '')  # 或者用模板字符串

并建议把 _tests/test_admin_js.py 接进 CI 的必过项 —— 它确实抓到了 (这次全量回归里 3d 阶段报 FAIL),说明这个测试是有效的。


三、🔴 高:--relay / --relay-status 命令行崩溃

复现

python _tests/test_verify_relay_cli.py

证据

═══ 1. cli_relay_status()(不在 try 里)═══
  它打印的内容:
    中转地址:https://relay.example.invalid
    正在测…
  🔴 未捕获异常: TypeError: RelayLink.start() missing 1 required positional argument: 'url'
     File "C:\...\windows\run_agent.py", line 338, in cli_relay_status

═══ 2. cli_relay(url)(在 try 里)═══
      ✗ 测试失败:RelayLink.start() missing 1 required positional argument: 'url'
  返回值: 1

根因

relay.py:247 的签名是:

def start(self, url: str) -> bool:      # url 必填,没有默认值

但 run_agent.py 有两处不传参数:

  • run_agent.py:338(cli_relay_status)—— 不在 try 里 → 未捕获异常,报栈
  • run_agent.py:296(cli_relay)—— 在 try 里 → 被吞成"测试失败"

影响

  • ykxmz.exe --relay-status → 直接抛栈
  • ykxmz.exe --relay https://... → 地址存下去了(第 281 行先存后测), 但用户每次都看到"✗ 测试失败:…missing 1 required positional argument: 'url'"。 用户会以为自己填的地址不对,反复试别的地址,实际是代码的锅。

为什么没被测出来

全仓库搜索 --relay / cli_relay 的测试引用:

_tests\ 下只有我新写的 test_verify_relay_cli.py 提到 cli_relay
verify_all.ps1 的 35 个阶段里没有任何一个碰 --relay / --relay-status

这两条 CLI 路径零覆盖。

建议修法

  • 最小修:两处都改成 link.start(store.get("relay_url") or "") (cli_relay 里直接用刚存下的 url 更合适)
  • 补测试:在 verify_all.ps1 里加一个阶段,真调 run_agent.cli_relay_status() 并把"不抛异常 + 返回值合理"钉住

四、🟡 中:deploy.sh --prefix 沙箱泄漏

复现

# 在设备上(用垫片 PATH 保证安全,垫片只记日志不执行)
PATH=/tmp/sbxim:$PATH bash /root/dep/deploy.sh --prefix /tmp/sbx --skip-tunnel

证据

deploy.sh:212-213 的承诺:

#   测试时 --prefix /tmp/xxx,所有写入都关在沙箱里,不碰真系统。

实测调用记录(垫片日志,各命令带调用者):

[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl daemon-reload
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl restart lanremote-relay
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl enable lanremote-nettune
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl start lanremote-nettune
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl enable lanremote-watchdog.timer

[caller: bash /tmp/sbxroot2/root/lanremote-relay/net-tune.sh] sysctl -w net.ipv4.tcp_mtu_probing=1
[caller: bash /tmp/sbxroot2/root/lanremote-relay/net-tune.sh] iptables -t mangle -A POSTROUTING ... --set-mss 1360
[caller: ... net-tune.sh] iptables ...   ← 共 213 条

根因

两条独立的泄漏:

  1. deploy.sh 自己的 systemctl 没有 prefix 守卫 (deploy.sh:606、758-780、868-871):
    if command -v systemctl >/dev/null 2>&1; then   # 只判断"有没有 systemctl"
        systemctl daemon-reload
        systemctl restart lanremote-relay            # ← 真系统
    
  2. deploy-tune.sh 收到了 --prefix 但没往下传 (deploy.sh:448 传了,deploy-tune.sh:100-106 调 net-tune.sh 时没传), 而 net-tune.sh 完全不支持 --prefix(第 81、109、122 行无条件改真实系统)。

影响

在线上设备上执行 deploy.sh --prefix /tmp/test(一个很自然的"先试跑一下"动作)会:

  1. systemctl restart lanremote-relay → 重启正在服务的中转, 清空内存里的设备注册和会话,所有客户端要几秒才重连
  2. sysctl -w net.ipv4.tcp_mtu_probing=1 → 改真实内核参数
  3. iptables -A mangle POSTROUTING --set-mss 1360 → 往真实防火墙追加规则

本次取证因为用了垫片,真实系统未被改动(逐项比对 md5 全部一致), 所以这条是静态+动态取证确认的潜在破坏,不是已经发生的破坏。

建议修法

  • net-tune.sh 加 --prefix 支持;加 DRY_RUN/--dry 更彻底
  • deploy-tune.sh 把 --prefix 透传下去
  • deploy.sh 里所有 systemctl 调用加守卫:
    if [ -n "$PREFIX" ]; then
        say "  [沙箱] 跳过 systemctl ..."
    elif command -v systemctl >/dev/null 2>&1; then
        ...
    fi
    
  • 把这个守卫本身写进测试(用垫片 PATH 是可行的办法,_build/test_deploy_sandbox.sh 可直接复用)

五、🟡 中:test_net_tune.py 断言过时,和实现故意反转的决策矛盾

证据

# _tests/test_net_tune.py:53
check("不把接口 MTU 调小(写回了 1500)",
      "mtu 1500" in text, "接口 MTU 降到 1400 实测会掉到 50%")
# relay/net-tune.sh:29-31(实现已故意反转,并写了更正说明)
# 为什么接口 MTU 也要压到 1400(早期结论是错的,这里更正)
# 早先这里写的是「不要改接口 MTU,实测降到 1400 反而掉到 50%」。
# 那个结论只考虑了 TCP,**漏掉了 QUIC**:
#   * MSS 钳制走的是 iptables 的 TCPMSS 目标,**只对 TCP 生效**;
#   * 隧道用 QUIC 时跑在 UDP 上,整套 TCPMSS 规则对它毫无作用。

# net-tune.sh:54
MTU_VALUE=${MTU_VALUE:-1400}

运行结果:

[FAIL] 不把接口 MTU 调小(写回了 1500)  接口 MTU 降到 1400 实测会掉到 50%
通过 20 项,失败 1 项

影响

这个测试钉死了一个已被新证据推翻的旧结论。后果有两层:

  1. 它永久失败 → 每次都贡献一条噪声,真失败容易被淹
  2. 更糟:后人为了让测试过,可能把真正的改进改回 1500 —— 而那正是 QUIC 建连不稳(failed to dial to edge with quic: timeout: no recent network activity)的根因之一

建议修法

把这个断言反过来,并保留它作为"防回归"的价值:

check("接口 MTU 压到 1400(QUIC 用 UDP,MSS 钳制管不到它)",
      "mtu \"$MTU_VALUE\"" in text or "MTU_VALUE=1400" in text
      or "MTU_VALUE:-1400" in text,
      "QUIC 的包超路径 MTU 会被静默丢弃,而 UDP 不回 ICMP")

六、🟡 中:test_admin_device_control.py 的注释描述了它并不验证的规则

方法:变异测试

python _tests/test_verify_mutation.py      # 注入变异,看现有测试抓不抓得到
python _tests/test_verify_coverage_gap.py  # 证明盲区是真 bug,不是理论问题

证据

对 relay/relay.py 注入 4 个变异,跑现有 test_admin_device_control.py(23 项):

变异 现有测试是否抓到
A. known_device 改成按配对码判断 ❌ 23 项全部通过 —— 没抓到
B. 改码后不删旧码(留孤儿) ✅ 抓到
C. 改码不踢会话 ✅ 抓到
D. 改码不校验占用 ✅ 抓到

而测试文件第 11-12 行的注释白纸黑字写着它就是要防 A:

# 4. 关掉之后**已登记过**的设备仍然能上线
#    —— 这一条最容易写错:如果按配对码判断,
#       管理员给老设备改过码之后,老设备会被自己的开关挡在外面

为什么没抓到 + 证明盲区是真 bug

它的用例里设备用的还是原来那个码,而 self.codes[那个码] == device_id, 所以 code in self.codes 也为真 → 变异在这个路径上表现不出来。

变异真正现形的路径是:「管理员改过码 + 设备配置里还是旧码 + 开关已关」。 test_verify_coverage_gap.py 构造了这个组合,对照跑原版/变异版:

  原版「带旧码重连」: 成功
  变异「带旧码重连」: 失败

  ✅ 结论:变异确实造成真实功能退化(原版成功、变异失败),
     而现有 test_admin_device_control.py 的 23 项**全部通过**。

所以"23 项测试通过"这句自述是真的,但它不等于"这条规则被验证了"。

建议修法

在 test_admin_device_control.py 里补一条用例:

注册(码 A)-> 管理员改成码 B -> 关掉开关 -> 设备带着码 A 重连 -> 必须成功

七、🟢 低:deviceId 接受控制字符

证据

[PASS] deviceId 超长 200 字符 应拒绝
[PASS] deviceId 超长 81 字符 应拒绝
[PASS] deviceId 正好 80 字符 应接受
    · 控制字符 NUL           -> 接受
    · 控制字符 \r\n          -> 接受
    · HTML 标签              -> 接受

(第一版测试我用同一个配对码试了 5 种非法值,后面几种其实是被"码已占用"拒的, 归因是错的;第二版每次用全新码才拿到干净结论。)

影响

  • 不是 XSS:admin.py 的 _esc() 转义到位。实测渲染产物里是 &lt;img src=x onerror=alert(&quot;...&quot;)&gt;,没有原始标签。
  • 是日志伪造:deviceId 里的 \r\n 会被写进日志, 可以伪造出整行"看起来像系统输出"的日志。 对一台只给自己用的中转站危害有限,但属于输入校验缺失。

建议修法

register() 里给 device_id 加白名单:

if not re.fullmatch(r"[A-Za-z0-9._:-]{1,80}", device_id):
    return {"ok": False, "error": "deviceId 不合法"}

八、🟢 低:enable_relay() 第二次调用谎报成功(潜伏)

证据

    第 1 次 enable_relay() -> False
    第 2 次 enable_relay() -> True      ← relay 实际没在跑(candidates=[])

根因

server.py:321-322:

if self._relay is not None:
    return True
self._relay = RelayLink(...)     # 先赋值
started = self._relay.start(target)

第一次调用时 start("") 提前返回 False(不起线程),但 self._relay 已经被赋值成对象了。第二次调用就撞上 is not None → 直接 return True。

影响

当前无调用方触发 —— 全仓库只有 server.py:185 调一次,且忽略返回值。 所以我如实标注为潜伏而非现实问题。

但它是脆的:将来任何"用户在设置里填了中转地址、想当场启用"的路径 都会命中这个 return True,用户以为启用成功,实际什么都没连。

建议修法

if self._relay is not None and self._relay.url:
    return True

或者把赋值挪到 start() 成功之后。


九、⚪ 提示:打包 EXE 过期(e2e 失败的真正原因)

dist_exe/ykxmz/ykxmz.exe   16:17:02 构建

更晚被改动的源文件:
  17:10:22  windows/lanremote/discovery.py
  17:18:13  windows/run_agent.py
  17:22:29  windows/lanremote/server.py
  17:35:56  windows/lanremote/relay.py
  17:42:17  windows/lanremote/instance.py

全量回归里 端到端(打包 EXE) 和 Java <-> 打包后的 EXE 两项失败, 根因是 EXE 没跟着源文件重建,不是代码缺陷。重建后应复跑。


十、验证过、没有发现问题的攻击面

这些我都真打过,结论是干净的,列出来是为了让"没提"不等于"没查":

攻击面 做法 结果
admin.py 的 % 格式化撞车 扫源文件字面量 + 真调 render_admin() ✅ 干净。ADMIN_CSS 结尾是 """ % {}(第 231 行)在导入时就被格式化,%% 正确变 %;ADMIN_JS 不参与格式化,单个 % 无害
管理页 XSS(deviceId/name) 注册 <img onerror=...> / <script>...,抓页面 ✅ 干净,_esc() 转义到位(含 data 属性里的引号)
设备名里的 %s / %d 把格式化搞崩 造 name = "百分号 %s %d %% 测试" ✅ 干净,原样保留,没有二次格式化
管理接口鉴权绕过 不带 cookie / 不带 X-Admin 打 4 个接口 ✅ 全部 401/403
改码后 codes/devices/state.json 自洽 连续改码 A→B→C,查三处 ✅ 一台设备只占一个码,旧码被释放,state.json 只有一条绑定
并发改码 两线程同时改同一台设备 ✅ 无异常,设备还在,最终码是其中之一,"输掉"的码没变孤儿
超时清理的顺序 检查 codes 与 devices 的成对清理 ✅ 成对清理,没有"清一个留一个"的中间态
空设备列表时管理页 delete_all 后抓页面 ✅ 200,无 traceback
allowNew 重启后是否保留 关掉 → 重启中转 → 查 ✅ 保留(持久化生效)
资源泄漏 / 崩溃 上述所有场景跑完检查 500 ✅ 没有 500

十一、我自己在验证过程中犯的错(如实记录)

测试自己也会骗人,这几处我修正后才拿到干净结论:

  1. 场景 4 的归因错误:五种非法 deviceId 用了同一个配对码, 第一个成功注册后,后面几种其实是被"码已被占用"拒的,不是"deviceId 非法"。 → 改成每次用全新码才测出真实行为(控制字符被接受)。

  2. 把"已格式化的值"当成"源文件"来扫:我扫 admin.ADMIN_CSS(导入后 的值,%% 已变 %),把合法的单个 % 误判成"漏写转义"。 → 改成扫源文件三引号字面量,并检查定义后有没有 % 运算。

  3. render_admin 的 downloads_fn 形状传错:我传了元组列表, 实际要 [{"name":..., "size":...}],报 TypeError 是我的错。 → 修正后 20/20 通过。

  4. 变异盲区的触发条件构造错:第一次我让设备带新码重连, 两版都成功,看起来"变异无害"。实际分歧点是设备带旧码重连 (它不知道管理员在服务端改了码)。→ 修正后才证明盲区是真 bug。


十二、我新增的验证测试

都在 _tests/ 下,可单独运行,也可接进 verify_all.ps1:

文件 覆盖
test_verify_relay_edges.py 状态机边界:TTL 锁死 / 改码孤儿 / 奇怪 deviceId / 并发改码 / 鉴权 / 空列表 / 持久化
test_verify_relay_xss.py 修正归因后的非法输入 + 管理页 XSS + deviceId 撞车 + 改码边界
test_verify_ttl_lockout.py 钉死 TTL 锁死 bug(含"重启能不能救"这一问)
test_verify_admin_format.py admin.py 的 % 格式化 + 转义 + 极端输入
test_verify_mutation.py 变异测试:验证现有 3e 测试能不能抓到 bug
test_verify_coverage_gap.py 证明变异盲区是真 bug
test_verify_js_syntax.py 精确指出管理页 JS 的裸换行位置
test_verify_relay_cli.py 钉死 --relay / --relay-status 崩溃
test_verify_blank_relay.py 默认地址清空后的回归面

辅助脚本(在 _build/):test_deploy_sandbox.sh(垫片 PATH 测沙箱泄漏)、 test_deploy_leak.sh(带调用者归因的取证)。


十三、没做到 / 无法验证的部分

  • 真机管理页的浏览器行为:本机没有 node,test_admin_js.py 的 --check 被跳过了。我是用自写的字符串状态机证明 JS 有裸换行的, 没有用真正的 JS 引擎跑过。建议在有 node 的环境复跑一次确认。
  • 设备上的真实 TTL 行为:我用 RELAY_DEVICE_TTL=2 在本地加速复现, 没有在设备上等 6 小时。逻辑路径是同一条,但真机没验证。
  • deploy.sh 在真机上的实际破坏:我用垫片 PATH 拦截了所有危险命令 (这正是为了不破坏线上),所以"会造成破坏"是取证推断, 不是"已经破坏过"的实测。
  • 稳定性/性能数值:本次任务聚焦正确性,没有对中转做吞吐/延迟压测 (那是另一条线的任务)。

十四、给出建议的修复优先级

  1. 先修 #1(TTL 锁死) —— 影响面最大、触发最自然、后果不可自愈
  2. 再修 #2(管理页 JS) —— 修完管理页才真的能用(现在按钮全废)
  3. 修 #3(CLI 崩溃) —— 改动最小(两处加参数),收益直接
  4. 修 #5(过时测试) —— 不然它会一直掩盖真失败,还诱导错误修改
  5. 修 #4(沙箱泄漏) —— 至少先把 systemctl 加守卫,避免误重启线上
  6. 补 #6 的用例;#7/#8 按余量处理
  7. 重建 EXE 后复跑全量回归(#9)