mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 16:00:43 +00:00
== 现象 ==
用户报:
* 局域网能搜到电脑,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 矛盾;按实测改成「两种协议同时在跑(故意的)」
614 lines
23 KiB
Markdown
614 lines
23 KiB
Markdown
# 对抗性独立验证报告
|
||
|
||
**验证者**: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()` 转义到位。实测渲染产物里是
|
||
`<img src=x onerror=alert("...")>`,没有原始标签。
|
||
* **是日志伪造**:`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)
|