== 现象 ==
用户报:
* 局域网能搜到电脑,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 矛盾;按实测改成「两种协议同时在跑(故意的)」
23 KiB
对抗性独立验证报告
验证者: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, ...}
机制
三处代码叠加:
-
relay.py:479—— 判断"是不是老设备"用的是活跃设备表:known_device = device_id in self.devices -
relay.py:765-786——snapshot()把超过DEVICE_TTL(6 小时)的设备 从self.devices和self.codes里一起删掉。 -
于是设备回来时
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 条
根因
两条独立的泄漏:
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 # ← 真系统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(一个很自然的"先试跑一下"动作)会:
systemctl restart lanremote-relay→ 重启正在服务的中转, 清空内存里的设备注册和会话,所有客户端要几秒才重连sysctl -w net.ipv4.tcp_mtu_probing=1→ 改真实内核参数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 项
影响
这个测试钉死了一个已被新证据推翻的旧结论。后果有两层:
- 它永久失败 → 每次都贡献一条噪声,真失败容易被淹
- 更糟:后人为了让测试过,可能把真正的改进改回 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()转义到位。实测渲染产物里是<img src=x onerror=alert("...")>,没有原始标签。 - 是日志伪造:
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 |
十一、我自己在验证过程中犯的错(如实记录)
测试自己也会骗人,这几处我修正后才拿到干净结论:
-
场景 4 的归因错误:五种非法 deviceId 用了同一个配对码, 第一个成功注册后,后面几种其实是被"码已被占用"拒的,不是"deviceId 非法"。 → 改成每次用全新码才测出真实行为(控制字符被接受)。
-
把"已格式化的值"当成"源文件"来扫:我扫
admin.ADMIN_CSS(导入后 的值,%%已变%),把合法的单个%误判成"漏写转义"。 → 改成扫源文件三引号字面量,并检查定义后有没有%运算。 -
render_admin的downloads_fn形状传错:我传了元组列表, 实际要[{"name":..., "size":...}],报TypeError是我的错。 → 修正后 20/20 通过。 -
变异盲区的触发条件构造错:第一次我让设备带新码重连, 两版都成功,看起来"变异无害"。实际分歧点是设备带旧码重连 (它不知道管理员在服务端改了码)。→ 修正后才证明盲区是真 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(TTL 锁死) —— 影响面最大、触发最自然、后果不可自愈
- 再修 #2(管理页 JS) —— 修完管理页才真的能用(现在按钮全废)
- 修 #3(CLI 崩溃) —— 改动最小(两处加参数),收益直接
- 修 #5(过时测试) —— 不然它会一直掩盖真失败,还诱导错误修改
- 修 #4(沙箱泄漏) —— 至少先把
systemctl加守卫,避免误重启线上 - 补 #6 的用例;#7/#8 按余量处理
- 重建 EXE 后复跑全量回归(#9)