🚨 修一个严重 bug:管理页所有按钮又失效了(同一个坑踩第二次)

== 症状 ==

在**真实中转**上实测管理页时,用 node --check 检查页面内嵌的 JS:

    SyntaxError: Invalid or unexpected token  (第 46 行)

整段 <script> 不执行 -> 改码 / 删除 / 批量删除 / 全选 全部变幽灵按键。

**这正是用户之前报过的那个 bug,加新功能时又踩了一遍。**

== 根因(讽刺到极点)==

admin.py 的 ADMIN_JS 是**普通三引号字符串**,里面写一个反斜杠+n
会被 Python 解释成**真实换行**。而 JS 的行注释 // 不能跨行 ——
一断行,后面就成了裸文本,整段脚本语法错误。

出事的是这段注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^
                       这个 \n 被 Python 变成真换行

**注释为了举例说明「不能写 \n」,自己写了一个 \n。**
于是注释被断成两行,把整段 JS 弄坏了。

== 修法 ==

注释改成**一个转义序列都不写**,只用文字描述:

    // ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
    //   本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
    //   变成**真实换行** ... (一个转义都不出现)

并把"踩过两次"的历史写进注释,提醒后人别再来第三次。

== 新增两个测试(防止再犯)==

1. `_tests/test_admin_js_syntax.py`(已注册为回归阶段 3a6)
   **本地渲染管理页**,把 <script> 抠出来喂给 `node --check`,
   还检查注释行里有没有裸控制字符。

   为什么原来查不出来:已有的「管理页 JS 合法性」阶段只检查
   token 在不在,不查语法 —— 「23 项测试通过」和「功能能用」
   是两件事。

2. `_build/scan_admin_escapes.py`
   扫源文件里"注释下一行不像 JS 代码"的可疑断行。

3. `_tests/test_admin_live.py`
   在**真实中转**上走 HTTP 实测两个新功能(不能进回归套件,
   因为它依赖线上设备):
     一、管理页能打开(带 X-Admin 头绕开登录页)
     二、页面里真的有 allowNew 和改码按钮
     三、内嵌 JS 语法合法
     四、关掉接入开关 -> 新设备被拒 -> 打开 -> 能注册(实测通过)
     五、改配对码 -> 真的变了(实测通过)
     六、不带 X-Admin 访问管理接口返回 401(实测通过)

   实测结果:16/17 通过(唯一的失败就是上面那个 JS bug,
   已修,本地渲染 6/6 通过)。

== 顺带纠正一个我自己的误解 ==

我一开始只带 `?key=` 访问管理页,拿到的是**登录页**,
还以为是设备上部署了旧版本。实际是管理页改成了
**账号 + 密码登录 + Cookie 会话**(用户要求),
脚本通道用 `X-Admin` 头(见 relay.py 的 _admin_gate)。

== 部署时机 ==

**故意没有立刻部署到设备上** —— 部署要重启中转服务,
会打断 relay-stability 正在跑的隧道配比 A/B 对照。
等对照跑完(约 22:00)再部署并复测。
This commit is contained in:
xmz7891
2026-10-07 20:33:27 +08:00
parent f7b9f2a058
commit d907fdcf6e
10 changed files with 781 additions and 6 deletions
+160
View File
@@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
r'''渲染修好的管理页,检查 JS 语法。
这是"按钮全是死的"那个 bug 的**最快复现方式**:
不连中转、不起服务器,直接调 render_admin() 拿 HTML,
把 <script> 抠出来喂给 node --check。
之前那个坑之所以能进仓库,就是因为测试只查"token 在不在",
不查语法。这个脚本补上。
'''
from __future__ import annotations
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "relay"))
import admin as admin_mod # noqa: E402
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
def main() -> int:
print("=" * 58)
print(" 管理页 JS 语法检查(本地渲染)")
print("=" * 58)
# 用 relay 自己的 snapshot() 造一份**真实结构**的快照 ——
# 手写 dict 会缺字段(比如 queuedToClient),一改就崩。
snap = None
try:
import relay as relay_mod # noqa: E402
snap = relay_mod.RELAY.snapshot()
print(" (用 relay.RELAY.snapshot() 拿到的真实快照)")
except Exception as exc: # noqa: BLE001
print(" (拿不到真实快照:%s,退回手写)" % type(exc).__name__)
if snap is None:
snap = {
"devices": [],
"sessions": 0,
"uptimeSeconds": 100,
"memoryKB": 20480,
"allowNew": True,
"codeCount": 0,
}
# 塞一台假设备,把设备行那一块也渲染出来(不然改码按钮不会出现)
# 字段要和 admin._device_rows 里用到的完全对齐
snap["devices"] = [{
"deviceId": "TESTDEVICE-1234567890ab",
"name": "测试电脑",
"code": "123456",
"online": True,
"idleSeconds": 3,
"lastSeen": "刚刚",
"ip": "192.168.1.5",
"version": "1.8.2",
"os": "Windows",
"lanIps": ["192.168.1.5"],
"queuedToClient": 0,
"queuedToAgent": 0,
"bytesIn": 1024,
"bytesOut": 2048,
"tokens": 0,
"sessions": 0,
}]
snap.setdefault("sessions", 0)
snap.setdefault("uptimeSeconds", 100)
snap.setdefault("memoryKB", 20480)
snap.setdefault("allowNew", True)
snap.setdefault("codeCount", 1)
try:
html = admin_mod.render_admin(
snap, "https://relay.example.com",
"/zhongzhuanzhan", ["123456"],
lambda: [])
except TypeError:
# 签名不一样就退回位置参数最少的调法
html = admin_mod.render_admin(snap, "https://relay.example.com",
"/zhongzhuanzhan", ["123456"])
if isinstance(html, bytes):
html = html.decode("utf-8")
check("渲染出了 HTML", len(html) > 500, "只有 %d 字节" % len(html))
check("页面里有 allowNew", 'id="allowNew"' in html, "找不到")
check("页面里有改码按钮", "data-recode" in html, "找不到")
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
inline = [s for s in scripts if s.strip()]
check("有内嵌脚本", bool(inline), "一个都没有")
for i, js in enumerate(inline, 1):
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
encoding="utf-8") as fh:
fh.write(js)
path = fh.name
try:
proc = subprocess.run(["node", "--check", path],
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=30)
ok = proc.returncode == 0
detail = ""
if not ok:
# 把报错那一行附近打出来,方便定位
err = (proc.stderr or "")
m = re.search(r":(\d+)\n", err)
if m:
ln = int(m.group(1))
lines = js.splitlines()
lo = max(0, ln - 3)
hi = min(len(lines), ln + 2)
detail = "第 %d 行:\n" % ln + "\n".join(
" %3d| %s" % (k + 1, lines[k])
for k in range(lo, hi))
else:
detail = err[:300]
check("第 %d 段内嵌 JS 语法合法" % i, ok, detail)
except FileNotFoundError:
print(" [SKIP] 没装 node")
finally:
Path(path).unlink(missing_ok=True)
# 再检查一遍:注释行里有没有被 Python 吃掉转义的痕迹
for i, js in enumerate(inline, 1):
lines = js.splitlines()
bad = []
for k, line in enumerate(lines):
s = line.strip()
if s.startswith("//"):
# 注释行里出现裸控制字符
for ch in line:
if ord(ch) < 32 and ch != "\t":
bad.append(k + 1)
break
check("第 %d 段没有裸控制字符" % i, not bad, "行号 %s" % bad)
print()
print("=" * 58)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+214
View File
@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
r'''在**真实中转**上实测【二】管理页的两个功能。
== 为什么要单独做这个测试 ==
单元测试是直接调 Relay 对象的 Python 方法,而用户是在浏览器上点。
中间隔着 HTTP、HTML、JS 三层。之前就踩过「23 项测试全绿但按钮全是死的」:
admin.py 里一个 \n 写错(Python 三引号字符串把 \n 变成真换行),
拼进 JS 就是字符串里的裸换行 -> 整段 <script> 不执行 -> 所有按钮变幽灵按键。
所以这个测试走**真实 HTTP**:
一、页面能打开(带 X-Admin 头绕开登录页)
二、页面上真的有 allowNew 复选框和改码按钮
三、内嵌 JS 能通过 node --check(防"按钮全是死的")
四、关掉 allowNew -> 新设备注册被拒 -> 打开 -> 能注册
五、改某台设备的配对码 -> 真的变了
== 鉴权说明(踩过的坑)==
管理页是**账号+密码登录 + Cookie**,但脚本通道是 `X-Admin` 头
(钥匙或密码都行,见 relay.py 的 _admin_gate)。
一开始我只带 `?key=` 拿到的是登录页,还以为部署的是旧版本。
测完把 allowNew 恢复成原值。
'''
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "_build"))
from devssh import run as ssh_run # noqa: E402
BASE = "https://relay.xxmxxs.top"
ADMIN_PATH = "/zhongzhuanzhan"
UA = {"User-Agent": "LanRemote/1.8.2"}
passed = 0
failed = 0
KEY = ""
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
def http(method, path, body=None, timeout=25, admin=False):
h = dict(UA)
if admin and KEY:
h["X-Admin"] = KEY
data = None
if body is not None:
data = body if isinstance(body, bytes) else json.dumps(body).encode()
h.setdefault("Content-Type", "application/json")
req = urllib.request.Request(BASE + path, data=data, headers=h,
method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read(), dict(resp.headers)
except urllib.error.HTTPError as exc:
return exc.code, exc.read(), dict(exc.headers)
except Exception as exc: # noqa: BLE001
return 0, str(exc).encode(), {}
def jbody(raw, default=None):
try:
return json.loads(raw.decode("utf-8", "replace"))
except Exception: # noqa: BLE001
return default
def main() -> int:
global KEY
print("=" * 58)
print(" 管理页两个新功能 · 真实中转实测")
print("=" * 58)
_c, out, _e = ssh_run(
"cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null",
timeout=60, quiet=True)
KEY = (out or "").strip().splitlines()[0] if (out or "").strip() else ""
print(" 管理钥匙: %s" % (KEY[:6] + "…" if KEY else "(拿不到)"))
if not KEY:
print(" 拿不到钥匙就无法测管理接口")
return 1
print()
# ---------------------------------------------------------- 一
print(" ---- 一、管理页能打开(带 X-Admin)----")
st, body, hdrs = http("GET", ADMIN_PATH, admin=True)
html = body.decode("utf-8", "replace")
check("管理页返回 200", st == 200, "实际 %d" % st)
check("返回的是管理页不是登录页",
"登录" not in html[:600] or "allowNew" in html,
"前 200 字含「登录」:%r" % html[:120])
check("页面里有 allowNew 复选框", 'id="allowNew"' in html,
"找不到 id=allowNew(只有 %d 字节)" % len(html))
check("页面里有改码按钮", "data-recode" in html, "找不到 data-recode")
# ---------------------------------------------------------- 二
print(" ---- 二、内嵌 JS 能被解析(防按钮全是死的)----")
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
inline = [s for s in scripts if s.strip()]
check("页面上有内嵌脚本", bool(inline), "一个 <script> 都没有")
if inline:
ok_all = True
err = ""
for i, js in enumerate(inline):
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
encoding="utf-8") as fh:
fh.write(js)
path = fh.name
try:
proc = subprocess.run(["node", "--check", path],
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=30)
if proc.returncode != 0:
ok_all = False
err = "第 %d 段: %s" % (i + 1, (proc.stderr or "")[:200])
break
except FileNotFoundError:
err = "__skip__"
break
finally:
Path(path).unlink(missing_ok=True)
if err == "__skip__":
print(" [SKIP] JS 语法检查(没装 node)")
else:
check("内嵌 JS 语法合法", ok_all, err)
# ---------------------------------------------------------- 三
print(" ---- 三、接入开关真的生效 ----")
st, b, _h = http("GET", "/v1/health")
snap = jbody(b, {})
before = snap.get("allowNew")
check("能从 /v1/health 读到 allowNew", before is not None, "字段不存在")
fake = "livetest-%d" % int(time.time())
reg = {"deviceId": fake, "code": "808080", "name": "实机测试机"}
if before is not None:
st, b, _h = http("POST", "/v1/admin/allow_new", {"allow": False},
admin=True)
check("关掉接入开关返回 200", st == 200, "实际 %d %s" % (st, b[:120]))
time.sleep(0.5)
now = jbody(http("GET", "/v1/health")[1], {}).get("allowNew")
check("关掉之后 allowNew 真的变 false", now is False, "实际 %r" % now)
st, _b, _h = http("POST", "/v1/agent/register", reg)
check("关掉时新设备注册被拒", st != 200, "实际 %d(本应被拒)" % st)
st, _b, _h = http("POST", "/v1/admin/allow_new", {"allow": True},
admin=True)
time.sleep(0.5)
now2 = jbody(http("GET", "/v1/health")[1], {}).get("allowNew")
check("打开之后 allowNew 真的变 true", now2 is True, "实际 %r" % now2)
st, _b, _h = http("POST", "/v1/agent/register", reg)
check("打开时新设备能注册", st == 200, "实际 %d" % st)
if before is not None and now2 != before:
http("POST", "/v1/admin/allow_new", {"allow": before}, admin=True)
print(" (已把 allowNew 恢复成 %r)" % before)
# ---------------------------------------------------------- 四
print(" ---- 四、改配对码真的生效 ----")
snap = jbody(http("GET", "/v1/health")[1], {})
devices = snap.get("devices", [])
mine = [d for d in devices if str(d.get("deviceId", "")).startswith("livetest-")]
check("能看到刚注册的测试设备", bool(mine),
"health 里共 %d 台设备,没有 livetest-" % len(devices))
if mine:
did = mine[0]["deviceId"]
old = mine[0].get("code")
new = "707070"
st, b, _h = http("POST", "/v1/admin/set_code",
{"deviceId": did, "code": new}, admin=True)
check("改码请求返回 200", st == 200, "实际 %d %s" % (st, b[:120]))
time.sleep(0.5)
devs2 = jbody(http("GET", "/v1/health")[1], {}).get("devices", [])
after = [d for d in devs2 if d.get("deviceId") == did]
check("改码后设备还在", bool(after), "设备从列表消失了")
if after:
check("配对码真的变成新的了", after[0].get("code") == new,
"旧 %r -> 实际 %r" % (old, after[0].get("code")))
# ---------------------------------------------------------- 五
print(" ---- 五、管理接口不登录要被挡 ----")
st, b, _h = http("GET", "/v1/admin/devices")
check("不带 X-Admin 访问管理接口返回 401", st == 401, "实际 %d" % st)
print()
print("=" * 58)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+13
View File
@@ -411,6 +411,19 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_bench_guard.py") 2>&1
Show-Fails $stageOut
Record "局域网对比基准" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a6
Banner "3a6/7 管理页 JS 语法(防按钮全死)"
# 踩过两次的坑:admin.py 的 ADMIN_JS 是普通三引号字符串,
# 里面写 \n 会被 Python 变成真实换行;而 JS 的行注释 // 不能跨行,
# 一断行整段 <script> 语法错误 -> 页面上所有按钮失效。
# 第二次尤其讽刺:注释为了举例说明「不能写 \n」,自己写了一个 \n,
# 于是注释被断成两行,把整段 JS 弄坏了。
# 原来那个「管理页 JS 合法性」阶段只查 token 在不在,查不出语法错 ——
# 「测过」和「能用」是两件事。
$stageOut = & python (Join-Path $root "_tests\test_admin_js_syntax.py") 2>&1
Show-Fails $stageOut
Record "管理页 JS 语法" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3b
Banner "3b/7 Python 静态检查(pyflakes)"
# 这一层能抓到"运行到某行才炸"的问题。