mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 10:10:43 +00:00
🚨 修一个严重 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:
@@ -0,0 +1,75 @@
|
||||
🚨 修一个严重 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)再部署并复测。
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# 看配比对照有没有开始
|
||||
set -u
|
||||
|
||||
echo " ── 隧道当前配比 ──"
|
||||
for s in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
printf " %-24s %s (%s)\n" "$s" \
|
||||
"$(grep -oE -- '--ha-connections [0-9]+' "/etc/systemd/system/$s.service" 2>/dev/null | tr '\n' ' ')" \
|
||||
"$(systemctl is-active "$s" 2>/dev/null)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo " ── 对照脚本/进程 ──"
|
||||
pgrep -af 'ab_long|rp_ab|collapse|ratio' 2>/dev/null | head -5 | sed 's/^/ /' || echo " (没在跑)"
|
||||
|
||||
echo
|
||||
echo " ── 最近 20 分钟内设备上改过的文件 ──"
|
||||
find /root -maxdepth 1 -type f -mmin -20 2>/dev/null | head -10 | sed 's/^/ /' || true
|
||||
|
||||
echo
|
||||
echo " ── 有没有对照的产物 ──"
|
||||
ls -la --time-style=+%H:%M /root/*ab*.tsv /root/*ab*.out /root/*ratio*.tsv /root/*collapse*.tsv 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo " ── 公网 ──"
|
||||
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
|
||||
|
||||
echo
|
||||
echo " ── 时间 ──"
|
||||
date '+ %H:%M:%S'
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# 对比设备上的 relay 文件和仓库里的版本
|
||||
set -u
|
||||
|
||||
D=/root/lanremote-relay
|
||||
echo " ── 设备上的文件 ──"
|
||||
ls -la --time-style=+%m-%d_%H:%M "$D"/*.py 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " ── relay.py 版本号 ──"
|
||||
grep -n '^VERSION' "$D/relay.py" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " ── admin.py 里有没有新功能 ──"
|
||||
for kw in allowNew data-recode set_allow_new set_device_code; do
|
||||
n=$(grep -c "$kw" "$D/admin.py" 2>/dev/null || echo 0)
|
||||
n2=$(grep -c "$kw" "$D/relay.py" 2>/dev/null || echo 0)
|
||||
printf " %-16s admin.py:%s relay.py:%s\n" "$kw" "$n" "$n2"
|
||||
done
|
||||
|
||||
echo
|
||||
echo " ── admin.py 大小和 md5 ──"
|
||||
md5sum "$D/admin.py" 2>/dev/null | sed 's/^/ /'
|
||||
wc -c "$D/admin.py" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " ── 服务状态 ──"
|
||||
systemctl is-active lanremote-relay 2>/dev/null | sed 's/^/ relay: /'
|
||||
echo " 启动于: $(systemctl show -p ActiveEnterTimestamp --value lanremote-relay 2>/dev/null)"
|
||||
|
||||
echo
|
||||
echo " ── 实际返回的管理页开头(看是不是管理页)──"
|
||||
KEY=$(cat "$D/data/admin_key.txt" 2>/dev/null)
|
||||
curl -s --max-time 15 "http://127.0.0.1:8080/zhongzhuanzhan?key=$KEY" 2>/dev/null | head -c 400
|
||||
echo
|
||||
echo " (上面这段的头 400 字节)"
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把管理页的内嵌 JS 抠出来,看第 46 行为什么语法错。
|
||||
|
||||
背景:_tests/test_admin_live.py 用 node --check 检查页面上的内嵌脚本,
|
||||
报第 46 行 SyntaxError: Invalid or unexpected token。
|
||||
但同一个页面上的管理接口(/v1/admin/allow_new、/v1/admin/set_code)
|
||||
是能用的 —— 说明后端没问题,问题在浏览器端。
|
||||
|
||||
两种可能:
|
||||
A. 真的有语法错 -> 用户点按钮没反应(严重)
|
||||
B. 我抠脚本的方式不对(比如页面里字符串里含 </script>,
|
||||
或者 JS 用到了 node 不认的语法)-> 假警报
|
||||
|
||||
这个脚本把可疑的那一行原样打出来,人肉判断是哪种。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
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"
|
||||
UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_c, out, _e = ssh_run(
|
||||
"cat /root/lanremote-relay/data/admin_key.txt", timeout=60, quiet=True)
|
||||
key = (out or "").strip().splitlines()[0]
|
||||
|
||||
req = urllib.request.Request(BASE + "/zhongzhuanzhan",
|
||||
headers={**UA, "X-Admin": key})
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
html = resp.read().decode("utf-8", "replace")
|
||||
|
||||
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
|
||||
inline = [s for s in scripts if s.strip()]
|
||||
print(" 页面上有 %d 段内嵌脚本,长度: %s"
|
||||
% (len(inline), [len(s) for s in inline]))
|
||||
|
||||
js = inline[0]
|
||||
lines = js.splitlines()
|
||||
print(" 第 1 段共 %d 行" % len(lines))
|
||||
print()
|
||||
print(" ════ 第 40~54 行原样 ════")
|
||||
for i in range(39, min(54, len(lines))):
|
||||
marker = " <<<< 报错行" if i == 45 else ""
|
||||
print(" %3d| %s%s" % (i + 1, lines[i], marker))
|
||||
print()
|
||||
|
||||
# 把这一段单独跑 node --check,确认能复现
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
|
||||
encoding="utf-8") as fh:
|
||||
fh.write(js)
|
||||
path = fh.name
|
||||
proc = subprocess.run(["node", "--check", path],
|
||||
capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
print(" node --check 退出码: %d" % proc.returncode)
|
||||
if proc.stderr:
|
||||
print(" stderr:")
|
||||
for line in proc.stderr.splitlines()[:12]:
|
||||
print(" " + line)
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
# 也检查每一行里有没有裸控制字符
|
||||
print()
|
||||
print(" ════ 有没有裸控制字符 ════")
|
||||
bad = 0
|
||||
for i, line in enumerate(lines, 1):
|
||||
for ch in line:
|
||||
if ord(ch) < 32 and ch not in "\t":
|
||||
print(" 第 %d 行有控制字符 0x%02x" % (i, ord(ch)))
|
||||
bad += 1
|
||||
break
|
||||
if not bad:
|
||||
print(" ✓ 没有")
|
||||
|
||||
# 反引号 / 模板字面量 / 正则 里可能藏着截断问题
|
||||
print()
|
||||
print(" ════ 源 HTML 里 </script> 出现几次 ════")
|
||||
print(" %d 次(正常应该等于 <script> 的个数)" % html.count("</script>"))
|
||||
print(" <script 出现 %d 次" % html.lower().count("<script"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# 起 A 组测量(不动配置,零干扰)
|
||||
set -u
|
||||
|
||||
echo "════════ 等隧道稳定 ════════"
|
||||
for i in $(seq 1 30); do
|
||||
sleep 6
|
||||
q=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
h=$(curl -s --max-time 5 http://127.0.0.1:20242/metrics 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
q=${q:-0}; h=${h:-0}
|
||||
echo " 第 $i 次: quic=$q http2=$h 合计=$((q + h))"
|
||||
[ $((q + h)) -ge 8 ] && break
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 公网确认 ════════"
|
||||
for i in $(seq 1 10); do
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health)
|
||||
echo " 第 $i 次: $c"
|
||||
[ "$c" = "200" ] && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 当前配置(A 组)════════"
|
||||
for svc in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
echo " $svc: $(systemctl cat $svc 2>/dev/null | grep -oE '\-\-protocol [a-z0-9]+|\-\-ha-connections [0-9]+' | tr '\n' ' ')"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 启动 A 组 45 分钟 ════════"
|
||||
if pgrep -f 'rpab.sh A' > /dev/null 2>&1; then
|
||||
echo " 已经在跑,跳过"
|
||||
else
|
||||
cd /root && setsid bash /root/rpab.sh A 45 < /dev/null > /root/ab_A.out 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
echo " 进程: $(pgrep -af 'rpab.sh A' | head -1)"
|
||||
echo " 已写入:$(wc -l < /root/ab_A_health.tsv 2>/dev/null || echo 0) 条健康样本"
|
||||
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''扫 admin.py 里所有会被 Python 变成真换行的 \n(在注释和 JS 里)。
|
||||
|
||||
== 为什么 ==
|
||||
|
||||
admin.py 的 ADMIN_JS 是**普通三引号字符串**。
|
||||
里面写 `\n`(一个反斜杠 + n)会被 Python 解释成**真实换行**。
|
||||
在 JS 里,行注释 `// ...` 不能跨行 —— 一旦注释中间出现真实换行,
|
||||
后面的内容就变成一行裸文本,整段 <script> 语法错误,
|
||||
管理页**所有**按钮失效。
|
||||
|
||||
这个坑在同一个文件里踩了两次,第二次还是"注释为了举例说明
|
||||
不能写 \n,自己写了一个 \n"。所以写个扫描器钉住它。
|
||||
|
||||
规则:
|
||||
* 在 ADMIN_JS 这类普通字符串里,**任何** \n \r \t 之类的转义
|
||||
都只应该出现在「要输出给 JS 的字符串字面量」里,
|
||||
而且必须写成 \\n(双反斜杠)。
|
||||
* 注释里一个转义都不该有。
|
||||
|
||||
这个扫描器逐行看:如果某行是 // 注释,却含有单个反斜杠转义,报警。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "relay" / "admin.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
tree = ast.parse(text)
|
||||
|
||||
# 找出所有大段的字符串常量(ADMIN_JS / HTML 模板)
|
||||
found = 0
|
||||
problems = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
|
||||
continue
|
||||
val = node.value
|
||||
if len(val) < 500 or "<script" not in val and "querySelector" not in val:
|
||||
continue
|
||||
found += 1
|
||||
start = node.lineno
|
||||
# 逐行检查「注释行里有没有被吃掉的转义」
|
||||
for i, line in enumerate(val.splitlines()):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("//"):
|
||||
continue
|
||||
# 注释里出现了真实的制表符/回车,或者其它控制字符
|
||||
for ch in line:
|
||||
if ord(ch) < 32 and ch != "\t":
|
||||
problems.append(
|
||||
"第 %d 行附近的注释里有裸控制字符 0x%02x:%r"
|
||||
% (start + i, ord(ch), line[:60]))
|
||||
break
|
||||
|
||||
# 再直接扫源文件:在 JS 段里,行尾恰好断在奇怪位置的注释
|
||||
print(" 找到 %d 段 JS/HTML 模板常量" % found)
|
||||
print()
|
||||
|
||||
# 更直接的办法:把源文件里三引号字符串的原样行拿出来,
|
||||
# 找「// 注释行后面紧跟着一行不以 // 开头、也不像代码」的
|
||||
src_lines = text.splitlines()
|
||||
suspicious = []
|
||||
for i, line in enumerate(src_lines, 1):
|
||||
s = line.strip()
|
||||
if not s.startswith("//"):
|
||||
continue
|
||||
# 下一行
|
||||
if i < len(src_lines):
|
||||
nxt = src_lines[i].strip()
|
||||
# 注释的下一行既不是注释、也不像 JS 语句开头
|
||||
if (nxt and not nxt.startswith("//")
|
||||
and not re.match(r"^(const|let|var|if|for|while|function|"
|
||||
r"document|window|return|}|\)|else|await|"
|
||||
r"try|catch|toast|setTimeout|\w+\s*[=(.]|"
|
||||
r"<|/\*|\*|--)",
|
||||
nxt)):
|
||||
suspicious.append((i, s[:70], nxt[:70]))
|
||||
|
||||
if suspicious:
|
||||
print(" ⚠ 可疑的注释断行(注释下一行不像 JS):")
|
||||
for ln, a, b in suspicious[:10]:
|
||||
print(" 第 %d 行: %s" % (ln, a))
|
||||
print(" 下一行: %s" % b)
|
||||
else:
|
||||
print(" ✓ 没有可疑的注释断行")
|
||||
|
||||
if problems:
|
||||
print()
|
||||
for p in problems:
|
||||
print(" ⚠ " + p)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)"
|
||||
# 这一层能抓到"运行到某行才炸"的问题。
|
||||
|
||||
+11
-6
@@ -366,12 +366,17 @@ if (allowNew) allowNew.onchange = async () => {
|
||||
document.querySelectorAll('button[data-recode]').forEach(b => {
|
||||
b.onclick = async () => {
|
||||
const name = b.dataset.name, old = b.dataset.code;
|
||||
// ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
|
||||
// 本文件是普通三引号字符串,写 \n 会被 Python 变成**真实换行**,
|
||||
// 拼进 JS 就是字符串里的裸换行 -> 整段 <script> 语法错误 ->
|
||||
// 页面上所有按钮(改码/删除/批量删除/全选)全部失效。
|
||||
// 这个坑踩过一次(为此专门建了"管理页 JS 合法性"测试阶段),
|
||||
// 加改码功能时又踩了第二次。
|
||||
// ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
|
||||
// 本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
|
||||
// 变成**真实换行**,拼进 JS 就是字符串里的裸换行 ->
|
||||
// 整段 <script> 语法错误 -> 页面上所有按钮(改码/删除/
|
||||
// 批量删除/全选)全部失效。
|
||||
//
|
||||
// 这个坑踩过两次:
|
||||
// 1) 第一次是 prompt 里的换行本身;
|
||||
// 2) 第二次就是**这段注释自己** —— 为了举例说明「不能那样写」,
|
||||
// 注释里原样敲了那个转义,结果注释被断成两行,把整段
|
||||
// JS 又弄坏了一次。所以这里一个转义序列都不写。
|
||||
const typed = prompt('把「' + name + '」的配对码 ' + old
|
||||
+ ' 改成:\\n\\n(必须是 6 位数字)', '');
|
||||
if (typed === null) return;
|
||||
|
||||
Reference in New Issue
Block a user