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

614 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 对抗性独立验证报告
**验证者**: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 清理 + 开关 = 设备永久锁死
### 复现
```bash
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` —— 判断"是不是老设备"用的是**活跃设备表**:
```python
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`:
```python
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()` 里把"是否老设备"改成
```python
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 语法错误 → 所有按钮失效
### 复现
```bash
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`:
```python
+ ' 改成:\n\n(必须是 6 位数字)', '');
```
`ADMIN_JS` 是 Python 三引号字符串,**Python 先把 `\n` 转成真实换行**,
再原样拼进 `<script>`。作者本意是 JS 的换行转义,应该写 `\\n`。
### 引入时间(已归因)
```bash
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 ← 「批量删除」
```
**全部变成幽灵按键**(点了没反应)。这不是"改码功能坏了",
是**整个管理页的交互全废**。
### 建议修法
```python
# 二选一:
+ ' 改成:\\n\\n(必须是 6 位数字)', ''); # 转义成 JS 的 \n
+ ' 改成:' + '\n' + '(必须是 6 位数字)', '') # 或者用模板字符串
```
并建议把 `_tests/test_admin_js.py` 接进 CI 的必过项 —— 它**确实抓到了**
(这次全量回归里 3d 阶段报 FAIL),说明这个测试是有效的。
---
## 三、🔴 高:`--relay` / `--relay-status` 命令行崩溃
### 复现
```bash
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` 的签名是:
```python
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` 沙箱泄漏
### 复现
```bash
# 在设备上(用垫片 PATH 保证安全,垫片只记日志不执行)
PATH=/tmp/sbxim:$PATH bash /root/dep/deploy.sh --prefix /tmp/sbx --skip-tunnel
```
### 证据
`deploy.sh:212-213` 的承诺:
```bash
# 测试时 --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`):
```bash
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` 调用加守卫:
```bash
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` 断言过时,和实现故意反转的决策矛盾
### 证据
```python
# _tests/test_net_tune.py:53
check("不把接口 MTU 调小(写回了 1500)",
"mtu 1500" in text, "接口 MTU 降到 1400 实测会掉到 50%")
```
```bash
# 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`)的根因之一
### 建议修法
把这个断言**反过来**,并保留它作为"防回归"的价值:
```python
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` 的注释描述了它并不验证的规则
### 方法:变异测试
```bash
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:**
```python
# 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` 加白名单:
```python
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`:
```python
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`,**用户以为启用成功,实际什么都没连**。
### 建议修法
```python
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)