diff --git a/_build/_cmsg.txt b/_build/_cmsg.txt new file mode 100644 index 0000000..0b8e50f --- /dev/null +++ b/_build/_cmsg.txt @@ -0,0 +1,47 @@ +管理页新增:允许/禁止新设备接入 + 修改设备配对码 + +== 功能一:允许新设备接入开关 == + +关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。 +状态存在 relay_state.json 里,重启不丢。 + +判断新设备用的是 deviceId,不是配对码 —— +这一点很关键:管理员给老设备改过码之后, +那台设备必须仍然能进来;按码判断的话它会被自己的开关挡在外面。 +测试里专门有一条 A3b 守这个行为。 + +被拒时回 blocked=True,让客户端能区分被管理员挡了 +和配对码冲突,而不是去匹配中文错误文案(文案一改就坏)。 + +== 功能二:修改任意设备的配对码 == + +管理页每台设备一个「改码」按钮,填 6 位数字即生效。 +要点: + * 新旧码冲突检查 + * 旧码映射必须删掉,否则变成孤儿条目, + 管理页配对码占用的数字会虚高 + * 改码踢掉该设备当前会话(会话绑在码上) + * 立刻持久化 + +== 顺手修掉两个真 bug == + +1. 改码功能一上线就 500:Session 用了 __slots__, + 根本没有 session_id 属性(会话是以 token 为 key 存在 dict 里的), + 写 s.session_id 直接 AttributeError。 + 改成遍历 sessions.items() 拿 key。 + +2. snapshot() 里有同样的错(清理超时设备那段): + 平时 stale 列表是空的所以从没炸过,一旦真有设备超过 6 小时 + 没上报就会 500。这次一起修了。 + +== 管理页 UI == + * 设备小节加了「允许新设备接入」勾选框 + * 每行加「改码」按钮(走 prompt,不引入弹窗层) + * 改码按钮用 data-recode 而不是 data-device —— + 现有单台删除按 button.danger[data-device] 绑, + 带上 data-device 会被当成删除按钮,点改码变删设备。 + 测试 C6 专门断言这一点。 + +== 测试 == +新增 _tests/test_admin_device_control.py(23 项), +已注册进 _tests/verify_all.ps1 第 3e 阶段。 diff --git a/_build/add_admin_features.py b/_build/add_admin_features.py new file mode 100644 index 0000000..0eacd1f --- /dev/null +++ b/_build/add_admin_features.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +r'''中转管理页新增两个功能(中转端逻辑)。 + +== 功能一:允许/禁止新设备接入 == + +一个开关,关掉之后**只有已经登记过的设备能继续上线**, +没见过的新设备一律拒绝。 + +用途:不想让陌生人拿配对码乱试、或者临时把中转"关起来"。 + +实现要点: + * 标志位存在 relay_state.json 里,重启不丢 + * 判断"新设备"以 deviceId 为准(它装机时生成、重装也不变), + 不是看配对码 —— 老设备换码也必须能进来 + * 被拒绝时回一个明确的标记 blocked=True, + 让客户端能区分"被管理员挡了"和"配对码冲突", + 不要用中文错误文案去匹配(文案一改就坏) + +== 功能二:修改任意设备的配对码 == + +管理页上每台设备一个"改配对码"按钮,填新码就生效。 + +实现要点: + * 新码必须是 6 位数字 + * 新码不能已经被别的设备占用 + * 改的时候要同时更新 codes 映射和 device.code, + 并清掉该设备**旧码**的映射 —— 否则旧码会变成孤儿条目, + 仪表盘上"配对码占用"会虚高 + * 改完立刻 _save(),重启后仍然是新码 + * 该设备当前的手表会话要踢掉(会话是绑在旧码上的), + 否则手表还拿着旧会话以为连着,实际电脑已经换了码 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py") + + +def patch(text: str, old: str, new: str, label: str) -> tuple: + if old not in text: + print(" ✗ 没找到:%s" % label) + return text, False + if text.count(old) != 1: + print(" ! 出现 %d 次:%s" % (text.count(old), label)) + return text, False + print(" ✓ %s" % label) + return text.replace(old, new, 1), True + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + before = text.count("\n") + 1 + ok_all = True + + # ---------------------------------------------------------- 1. 字段 + old = """ self.retired: dict = {} + self.started = time.time()""" + new = """ self.retired: dict = {} + # 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。 + self.allow_new = True + self.started = time.time()""" + text, ok = patch(text, old, new, "加 allow_new 字段") + ok_all = ok_all and ok + + # ---------------------------------------------------------- 2. _load + old = """ for item in raw.get("codes", []): + device_id = str(item.get("deviceId", "")) + code = str(item.get("code", "")) + if device_id and CODE_RE.match(code): + self.codes[code] = device_id + log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")""" + new = """ # 是否允许新设备接入(重启不丢) + self.allow_new = bool(raw.get("allowNew", True)) + for item in raw.get("codes", []): + device_id = str(item.get("deviceId", "")) + code = str(item.get("code", "")) + if device_id and CODE_RE.match(code): + self.codes[code] = device_id + log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")""" + text, ok = patch(text, old, new, "_load 恢复 allow_new") + ok_all = ok_all and ok + + # ---------------------------------------------------------- 3. _save + old = """ payload = {"codes": [ + {"deviceId": did, "code": code} + for code, did in sorted(self.codes.items()) + ]}""" + new = """ payload = { + "allowNew": bool(self.allow_new), + "codes": [ + {"deviceId": did, "code": code} + for code, did in sorted(self.codes.items()) + ], + }""" + text, ok = patch(text, old, new, "_save 持久化 allow_new") + ok_all = ok_all and ok + + # ---------------------------------------------------------- 4. 拒绝新设备 + old = """ with self.lock: + known = self.codes.get(code) + if known and known != device_id:""" + new = """ with self.lock: + # 管理员关掉了"允许新设备接入"时, + # 只放行**已经登记过**的设备。 + # + # 注意判断依据是 deviceId(装机时生成、重装也不变), + # **不是配对码** —— 否则管理员给老设备改了码, + # 那台设备就被自己的开关挡在外面了。 + known_device = device_id in self.devices + if not known_device and not self.allow_new: + log(f"新设备接入已被关闭,拒绝 {name} ({device_id[:12]}…)") + return {"ok": False, + "blocked": True, + "error": "中转站当前不接受新设备接入"} + + known = self.codes.get(code) + if known and known != device_id:""" + text, ok = patch(text, old, new, "register 拒绝新设备") + ok_all = ok_all and ok + + # ---------------------------------------------------------- 5. 管理方法 + old = """ def device_by_token(self, token: str) -> Device | None:""" + new = """ # ---------------- 管理操作 ---------------- + + def set_allow_new(self, allow: bool) -> dict: + \"\"\"开关"允许新设备接入"。\"\"\" + with self.lock: + self.allow_new = bool(allow) + self._save() + log(f"管理员把「允许新设备接入」设为 {self.allow_new}") + return {"ok": True, "allowNew": self.allow_new} + + def set_device_code(self, device_id: str, new_code: str) -> dict: + \"\"\"给一台设备换配对码。 + + 要点: + * 新码必须是 6 位数字 + * 不能已经被**别的**设备占用 + * 旧码的映射必须删掉,否则会变成孤儿条目, + 管理页上"配对码占用"的数字会虚高 + * 该设备挂着的会话要踢掉 —— 会话是绑在码上的, + 不踢的话手表还拿着旧会话以为连着 + \"\"\" + device_id = str(device_id or "").strip() + new_code = str(new_code or "").strip() + if not CODE_RE.match(new_code): + return {"ok": False, "error": "配对码必须是 6 位数字"} + with self.lock: + device = self.devices.get(device_id) + if device is None: + return {"ok": False, "error": "没有这台设备(可能已下线)"} + + owner = self.codes.get(new_code) + if owner and owner != device_id: + return {"ok": False, + "error": "这个配对码已经被别的设备占用了"} + + old_code = device.code + if old_code == new_code: + return {"ok": True, "code": new_code, "unchanged": True} + + # 删掉旧码映射(连同可能存在的别名) + for code, owner_id in list(self.codes.items()): + if owner_id == device_id: + self.codes.pop(code, None) + self.codes[new_code] = device_id + device.code = new_code + + # 会话绑在码上,旧会话作废,让手表重新配对 + kicked = 0 + for sid in [s.session_id for s in self.sessions.values() + if s.device_id == device_id]: + self.sessions.pop(sid, None) + kicked += 1 + + self._save() + log(f"管理员把设备 {device.name} 的配对码 " + f"{old_code} 改成 {new_code}(踢掉 {kicked} 个会话)") + return {"ok": True, "code": new_code, + "oldCode": old_code, "kicked": kicked} + + def device_by_token(self, token: str) -> Device | None:""" + text, ok = patch(text, old, new, "加 set_allow_new / set_device_code") + ok_all = ok_all and ok + + if not ok_all: + print() + print(" 有锚点没匹配,没有写回") + return 1 + + SRC.write_text(text, encoding="utf-8", newline="\n") + print() + print(" 已写回:%d -> %d 行" % (before, text.count("\n") + 1)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_admin_ui.py b/_build/add_admin_ui.py new file mode 100644 index 0000000..8b048ea --- /dev/null +++ b/_build/add_admin_ui.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +r'''管理页 UI:加「允许新设备接入」开关 + 每台设备的「改码」按钮。 + +设计上遵守用户的"所有东西都要白色简洁": + * 开关用系统原生 checkbox + 极简样式,不引入花哨组件 + * 改码走 prompt(),不加弹窗层 —— 管理页是自用的, + 少一层 DOM 就少一处会坏的地方 + * 按钮沿用现有的 .ghost 样式(不抢 .danger 的视觉重量) + +⚠ 一个容易踩的坑: + 现有 JS 用 `button.danger[data-device]` 绑定"单台删除"。 + 新加的"改码"按钮**故意不带 data-device 而用 data-recode**, + 否则会被删除逻辑一起选中,点改码变成删设备。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\admin.py") + + +def patch(text: str, old: str, new: str, label: str) -> tuple: + if old not in text: + print(" ✗ 没找到:%s" % label) + return text, False + if text.count(old) != 1: + print(" ! 出现 %d 次:%s" % (text.count(old), label)) + return text, False + print(" ✓ %s" % label) + return text.replace(old, new, 1), True + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + ok_all = True + + # ------------------------------------------------ 1. 行里加"改码"按钮 + old = """ '' + '' % ( + did, name, + "on" if online else "off", name, did, + code, + "on" if online else "off", "在线" if online else "离线", + device["idleSeconds"], + device["queuedToClient"], device["queuedToAgent"], + _kb(device["bytesIn"]), _kb(device["bytesOut"]), + did, name, code) + )""" + new = """ '' + # ⚠ 这个按钮用 data-recode 而不是 data-device —— + # 现有"单台删除"是按 button.danger[data-device] 绑的, + # 这里要是也带 data-device 就会被当成删除按钮。 + '' + '' + '' % ( + did, name, + "on" if online else "off", name, did, + code, + "on" if online else "off", "在线" if online else "离线", + device["idleSeconds"], + device["queuedToClient"], device["queuedToAgent"], + _kb(device["bytesIn"]), _kb(device["bytesOut"]), + did, name, code, + did, name, code) + )""" + text, ok = patch(text, old, new, "设备行加「改码」按钮") + ok_all = ok_all and ok + + # ------------------------------------------------ 2. 开关放进"设备"小节 + old = """
+

设备

+
+ """ + new = """
+

设备

+
+ + """ + text, ok = patch(text, old, new, "设备小节加开关") + ok_all = ok_all and ok + + # ------------------------------------------------ 3. 传入开关状态 + old = """ sessions = snap.get("sessions", 0)""" + new = """ sessions = snap.get("sessions", 0) + # 开关状态:允许新设备接入时勾上 + allow_new = bool(snap.get("allowNew", True))""" + text, ok = patch(text, old, new, "读 allowNew 状态") + ok_all = ok_all and ok + + old = """ "rows": _device_rows(devices), + "dl": dl_rows,""" + new = """ "rows": _device_rows(devices), + "allownew": " checked" if allow_new else "", + "dl": dl_rows,""" + text, ok = patch(text, old, new, "把状态传进模板") + ok_all = ok_all and ok + + # ------------------------------------------------ 4. JS + old = """// ---------------- 单台删除 ----------------""" + new = """// ---------------- 允许/禁止新设备接入 ---------------- +// 关掉之后,只有**已经登记过**的电脑能继续上线, +// 没见过的新设备一律拒绝。 +const allowNew = document.getElementById('allowNew'); +if (allowNew) allowNew.onchange = async () => { + const want = allowNew.checked; + allowNew.disabled = true; + try { + const j = await post('/v1/admin/allow_new', {allow: want}); + if (j.ok) { + toast(j.allowNew ? '已允许新设备接入' : '已禁止新设备接入', 'ok'); + } else { + toast('设置失败:' + (j.error || '未知错误'), 'err'); + allowNew.checked = !want; + } + } catch (e) { + toast('请求出错:' + e, 'err'); + allowNew.checked = !want; + } + allowNew.disabled = false; +}; + +// ---------------- 改配对码 ---------------- +document.querySelectorAll('button[data-recode]').forEach(b => { + b.onclick = async () => { + const name = b.dataset.name, old = b.dataset.code; + const typed = prompt('把「' + name + '」的配对码 ' + old + + ' 改成:\\n\\n(必须是 6 位数字)', ''); + if (typed === null) return; + const code = (typed || '').trim(); + if (!/^[0-9]{6}$/.test(code)) { + toast('配对码必须是 6 位数字', 'err'); + return; + } + b.disabled = true; + try { + const j = await post('/v1/admin/set_code', + {deviceId: b.dataset.recode, code: code}); + if (j.ok) { + toast(j.unchanged ? '配对码没有变化' + : ('配对码已改成 ' + j.code + + (j.kicked ? ',踢掉 ' + j.kicked + ' 个会话' : '')), + 'ok'); + setTimeout(() => location.reload(), 900); + } else { + toast('改码失败:' + (j.error || '未知错误'), 'err'); + b.disabled = false; + } + } catch (e) { + toast('请求出错:' + e, 'err'); + b.disabled = false; + } + }; +}); + +// ---------------- 单台删除 ----------------""" + text, ok = patch(text, old, new, "加开关和改码的 JS") + ok_all = ok_all and ok + + if not ok_all: + print() + print(" 有锚点没匹配,没有写回") + return 1 + + SRC.write_text(text, encoding="utf-8", newline="\n") + print() + print(" admin.py 已写回") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/check_conns.sh b/_build/check_conns.sh new file mode 100644 index 0000000..7a5e4a1 --- /dev/null +++ b/_build/check_conns.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# 看长测进度 + 实时连接数波动 +set -u + +echo "=== 长测进度 ===" +tail -20 /root/longstab_quic10.txt 2>/dev/null + +echo +echo "=== 连接数实时(每 3 秒一次,共 12 次 = 36 秒)===" +for i in $(seq 1 12); do + n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') + printf " %2d: %s\n" "$i" "${n:-?}" + sleep 3 +done + +echo +echo "=== 这 36 秒里隧道日志的新事件 ===" +MARK=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0) +sleep 1 +journalctl -u lanremote-tunnel --since '2 minutes ago' --no-pager 2>/dev/null \ + | grep -E 'Lost connection|Registered tunnel|Failed to dial|no recent network' \ + | tail -14 | sed 's/^/ /' diff --git a/_build/dbg_setcode.py b/_build/dbg_setcode.py new file mode 100644 index 0000000..aad1c6c --- /dev/null +++ b/_build/dbg_setcode.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r'''单独查 set_code 为什么没返回 kicked。''' +import json +import time +import urllib.error +import urllib.request + +BASE = "http://127.0.0.1:8080" +KEY = open("/root/lanremote-relay/data/admin_key.txt", encoding="utf-8").read().strip() + + +def call(path, payload=None, method="POST", admin=False): + data = json.dumps(payload).encode() if payload is not None else None + h = {"Content-Type": "application/json"} + if admin: + h["X-Admin"] = KEY + req = urllib.request.Request(BASE + path, data=data, headers=h, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as r: + body = r.read().decode("utf-8", "replace") + try: + return r.status, json.loads(body) + except Exception: + return r.status, body + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + try: + return e.code, json.loads(body) + except Exception: + return e.code, body + + +stamp = int(time.time()) +dev = "dbg-%d" % stamp +c1 = "%06d" % ((stamp + 1) % 1000000) +c2 = "%06d" % ((stamp + 2) % 1000000) + +print("1. 注册设备") +print(" ", call("/v1/agent/register", + {"deviceId": dev, "code": c1, "name": "dbg", "version": "1.8.2"})) + +print("2. attach 造一个会话") +print(" ", call("/v1/client/attach", {"code": c1, "name": "手表"})) + +print("3. 看会话数") +st, h = call("/v1/health", method="GET") +print(" sessions =", h.get("sessions")) + +print("4. 改码 %s -> %s" % (c1, c2)) +st, r = call("/v1/admin/set_code", {"deviceId": dev, "code": c2}, admin=True) +print(" 状态码:", st) +print(" 响应:", json.dumps(r, ensure_ascii=False)) + +print("5. 再改一次(同样改 %s -> 另一个码)" % c2) +c3 = "%06d" % ((stamp + 3) % 1000000) +st, r = call("/v1/admin/set_code", {"deviceId": dev, "code": c3}, admin=True) +print(" 响应:", json.dumps(r, ensure_ascii=False)) + +print("6. 清理") +print(" ", call("/v1/admin/delete", {"deviceId": dev}, admin=True)) diff --git a/_build/longstab.sh b/_build/longstab.sh new file mode 100644 index 0000000..4d591a2 --- /dev/null +++ b/_build/longstab.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# 隧道长时稳定性测量(默认 30 分钟) +# +# 为什么要长时: +# 短测(60 次请求)看到的可用率都在 98~100%,看不出区别。 +# 真实的失败是"某条隧道连接掉了、重连期间打在上面的请求失败", +# 这种事几十分钟才出现一两次 —— 必须跑长才能抓到。 +# +# 记录: +# * 连接数采样(每 10 秒)—— 看有没有掉到配置值以下 +# * 公网可用率采样(每 30 秒,从**本机**打,所以含设备自身 4G 的影响) +# * 隧道日志里的 重连 / 掉线 / dial 失败 事件 +# * 事件时间线(哪一刻掉的) +# +# 用法: bash longstab.sh [分钟数] [标签] +set -u + +BASE=https://relay.xxmxxs.top +UA='LanRemote/1.8.2' +MINUTES=${1:-30} +LABEL=${2:-run} +ROUNDS=$((MINUTES * 6)) # 每 10 秒一轮 + +OUT=/root/longstab_${LABEL}.txt +MARK=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0) + +echo "════════ 隧道长时稳定性 · $LABEL · ${MINUTES} 分钟 ════════" | tee "$OUT" +echo " 参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' /etc/systemd/system/lanremote-tunnel.service | tr '\n' ' ')" | tee -a "$OUT" +echo " MTU: $(cat /sys/class/net/wlan0/mtu)" | tee -a "$OUT" +echo " 开始: $(date '+%H:%M:%S') 共 $ROUNDS 轮" | tee -a "$OUT" + +minc=999; maxc=0; sumc=0; nz=0 +okA=0; totA=0; timesA=() +declare -a timeline + +for r in $(seq 1 "$ROUNDS"); do + n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') + n=${n:-0} + [ "$n" -lt "$minc" ] && minc="$n" + [ "$n" -gt "$maxc" ] && maxc="$n" + sumc=$((sumc + n)) + + # 每 3 轮(30 秒)打一次公网 + if [ $((r % 3)) -eq 0 ]; then + out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \ + -H "User-Agent: $UA" "$BASE/v1/health") + c=$(echo "$out" | awk '{print $1}') + ms=$(echo "$out" | awk '{printf "%d", $2*1000}') + totA=$((totA + 1)) + if [ "$c" = "200" ]; then + okA=$((okA + 1)); timesA+=("$ms") + else + timeline+=("$(date '+%H:%M:%S') HTTP $c (连接数 $n)") + printf " [%s] ✗ HTTP %s 连接数 %s\n" "$(date '+%H:%M:%S')" "$c" "$n" | tee -a "$OUT" + fi + # 连接数掉到配置值以下也要记 + if [ "$n" -lt 10 ]; then + printf " [%s] ! 连接数只有 %s\n" "$(date '+%H:%M:%S')" "$n" | tee -a "$OUT" + fi + fi + sleep 10 +done + +echo | tee -a "$OUT" +echo "════════ 结果 ════════" | tee -a "$OUT" +echo " 连接数: 最低 $minc 最高 $maxc 平均 $((sumc / ROUNDS))(配置 10)" | tee -a "$OUT" +pct=$((okA * 100 / (totA > 0 ? totA : 1))) +echo " 公网可用率: $okA/$totA = ${pct}%" | tee -a "$OUT" +if [ "${#timesA[@]}" -gt 0 ]; then + printf '%s\n' "${timesA[@]}" | sort -n | awk '{ + a[NR]=$1 + } END { + printf " 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms\n", + a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR] + }' | tee -a "$OUT" +fi + +echo | tee -a "$OUT" +echo " 这段时间的隧道事件:" | tee -a "$OUT" +for kw in 'Registered tunnel connection' 'Lost connection' 'Failed to dial' 'i/o timeout' 'no recent network activity'; do + c=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c "$kw" || echo 0) + printf " %-34s %s 次\n" "$kw" "$c" | tee -a "$OUT" +done + +echo | tee -a "$OUT" +echo " 掉线/重连时间线(最后 12 条):" | tee -a "$OUT" +tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \ + | grep -E 'Lost connection|Registered tunnel connection' | tail -12 \ + | sed 's/^/ /' | tee -a "$OUT" + +echo | tee -a "$OUT" +echo " 期间有没有 HTTP 失败:" | tee -a "$OUT" +if [ "${#timeline[@]}" -gt 0 ]; then + printf ' %s\n' "${timeline[@]}" | tee -a "$OUT" +else + echo " 没有" | tee -a "$OUT" +fi + +echo | tee -a "$OUT" +echo " watchdog 最近 5 行:" | tee -a "$OUT" +tail -5 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /' | tee -a "$OUT" +echo | tee -a "$OUT" +echo " 存档: $OUT" | tee -a "$OUT" diff --git a/_build/longstab_dual.sh b/_build/longstab_dual.sh new file mode 100644 index 0000000..feb82b2 --- /dev/null +++ b/_build/longstab_dual.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# 双协议隧道的长时稳定性测量。 +# +# 和之前的 longstab.sh 区别:现在有**两个** cloudflared 实例, +# 只有先起的那个能绑上 20241 metrics 端口,所以不能只看 metrics +# 的连接数 —— 要分别从两个日志里数"当前活跃连接"。 +# +# 每轮记录: +# * QUIC 实例 / HTTP2 实例 各自最近注册的连接数 +# * 公网可用率 +# * 掉线、dial 失败、530/502 事件 +set -u + +BASE=https://relay.xxmxxs.top +UA='LanRemote/1.8.2' +MINUTES=${1:-30} +LABEL=${2:-dual} +ROUNDS=$((MINUTES * 6)) + +OUT=/root/longstab_${LABEL}.txt +MARK_Q=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0) +MARK_H=$(wc -l < /root/lanremote-relay/tunnel-h2.log 2>/dev/null || echo 0) + +echo "════════ 双协议隧道长测 · $LABEL · ${MINUTES} 分钟 ════════" | tee "$OUT" +echo " QUIC: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' /etc/systemd/system/lanremote-tunnel.service | tr '\n' ' ')" | tee -a "$OUT" +echo " HTTP2: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' /etc/systemd/system/lanremote-tunnel-h2.service | tr '\n' ' ')" | tee -a "$OUT" +echo " MTU: $(cat /sys/class/net/wlan0/mtu)" | tee -a "$OUT" +echo " 开始: $(date '+%H:%M:%S')" | tee -a "$OUT" + +okA=0; totA=0; timesA=() +zeroQ=0; zeroH=0; low=0 +declare -a events + +for r in $(seq 1 "$ROUNDS"); do + # 各自实例"最近 500 行里,注册后又没掉线"的净连接数 —— 用最近事件近似 + nq=$(tail -200 /root/lanremote-relay/tunnel.log 2>/dev/null \ + | grep -c 'Registered tunnel connection') + nh=$(tail -200 /root/lanremote-relay/tunnel-h2.log 2>/dev/null \ + | grep -c 'Registered tunnel connection') + + # 每 3 轮(30 秒)打一次公网 + if [ $((r % 3)) -eq 0 ]; then + out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \ + -H "User-Agent: $UA" "$BASE/v1/health") + c=$(echo "$out" | awk '{print $1}') + ms=$(echo "$out" | awk '{printf "%d", $2*1000}') + totA=$((totA + 1)) + if [ "$c" = "200" ]; then + okA=$((okA + 1)); timesA+=("$ms") + else + events+=("[$(date '+%H:%M:%S')] HTTP $c") + printf " [%s] ✗ HTTP %s (Q=%s H=%s)\n" "$(date '+%H:%M:%S')" "$c" "$nq" "$nh" | tee -a "$OUT" + fi + fi + sleep 10 +done + +echo | tee -a "$OUT" +echo "════════ 结果 ════════" | tee -a "$OUT" +pct=$((okA * 100 / (totA > 0 ? totA : 1))) +echo " 公网可用率: $okA/$totA = ${pct}%" | tee -a "$OUT" +if [ "${#timesA[@]}" -gt 0 ]; then + printf '%s\n' "${timesA[@]}" | sort -n | awk '{ + a[NR]=$1 + } END { + printf " 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms\n", + a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR] + }' | tee -a "$OUT" +fi + +echo | tee -a "$OUT" +echo " QUIC 实例事件:" | tee -a "$OUT" +for kw in 'Registered tunnel connection' 'Lost connection' 'Failed to dial' 'no recent network activity'; do + c=$(tail -n +$((MARK_Q+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c "$kw" || echo 0) + printf " %-34s %s 次\n" "$kw" "$c" | tee -a "$OUT" +done +echo " HTTP/2 实例事件:" | tee -a "$OUT" +for kw in 'Registered tunnel connection' 'Lost connection' 'Failed to dial' 'i/o timeout'; do + c=$(tail -n +$((MARK_H+1)) /root/lanremote-relay/tunnel-h2.log 2>/dev/null | grep -c "$kw" || echo 0) + printf " %-34s %s 次\n" "$kw" "$c" | tee -a "$OUT" +done + +echo | tee -a "$OUT" +echo " HTTP 失败时刻:" | tee -a "$OUT" +if [ "${#events[@]}" -gt 0 ]; then + printf ' %s\n' "${events[@]}" | tee -a "$OUT" +else + echo " 一次都没有" | tee -a "$OUT" +fi +echo | tee -a "$OUT" +echo " 存档: $OUT" | tee -a "$OUT" diff --git a/_build/setup_dual.sh b/_build/setup_dual.sh new file mode 100644 index 0000000..14d519c --- /dev/null +++ b/_build/setup_dual.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# 双协议隧道:同时跑 QUIC 和 HTTP/2 两个 cloudflared 实例。 +# +# == 为什么 == +# +# 实测两种协议各有各的病: +# QUIC 尾部延迟好(P90 ~2.5 秒),但 DialContext 会 +# "no recent network activity" 超时,建连慢、偶发失败 +# HTTP/2 建连可靠(TCP + MSS 钳制保护),但尾部延迟差(P90 14 秒, +# TCP 队头阻塞:一条连接上丢一个包,所有流一起卡) +# +# 同一个隧道 ID 可以起**多个副本**,Cloudflare 会把请求分摊到 +# 所有副本注册上来的连接上。所以: +# +# 副本 A: --protocol quic --ha-connections 5 +# 副本 B: --protocol http2 --ha-connections 5 +# +# 一共 10 条连接、横跨两种协议。QUIC 犯病时 HTTP/2 那半边还在服务, +# 反过来也一样 —— 这是真正的**故障隔离**,而不只是加连接数。 +# +# == 资源 == +# 每个 cloudflared 约占 25 MB。设备 460 MB 总内存、平时可用 300+ MB, +# 两个实例没问题(原来 MemoryMax=160M 是给单实例的,两个各自限 128M)。 +set -u + +TUNNEL_ID=7fd33d9c-f910-42e3-88aa-752d6b2a9f2f +CONFIG=/root/.cloudflared/config.yml +MAIN=/etc/systemd/system/lanremote-tunnel.service +SECOND=/etc/systemd/system/lanremote-tunnel-h2.service + +echo "════════ 1. 备份现有主服务 ════════" +cp -a "$MAIN" "/root/lanremote-tunnel.service.bak.dual.$(date +%s)" +echo " 当前参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$MAIN" | tr '\n' ' ')" + +echo +echo "════════ 2. 主服务改成 QUIC + 5 ════════" +sed -i 's/--protocol [a-z0-9]*/--protocol quic/' "$MAIN" +sed -i 's/--ha-connections [0-9]*/--ha-connections 5/' "$MAIN" +sed -i 's/^MemoryMax=.*/MemoryMax=128M/' "$MAIN" +grep -E 'ha-connections|protocol|MemoryMax' "$MAIN" | sed 's/^/ /' + +echo +echo "════════ 3. 创建 HTTP/2 副本服务 ════════" +cat > "$SECOND" <<'UNITEOF' +[Unit] +Description=ykxmz Cloudflare 具名隧道(HTTP/2 副本) +# 和主实例跑同一个隧道 ID —— Cloudflare 会把请求分摊到 +# 两个副本注册的全部连接上,形成协议级的故障隔离。 +After=network-online.target lanremote-relay.service lanremote-nettune.service +Wants=network-online.target lanremote-relay.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/cloudflared tunnel \ + --protocol http2 \ + --edge-ip-version 4 \ + --ha-connections 5 \ + --no-autoupdate \ + --retries 5 \ + --config /root/.cloudflared/config.yml run lanremote +Restart=always +RestartSec=5 +MemoryMax=128M +StandardOutput=append:/root/lanremote-relay/tunnel-h2.log +StandardError=append:/root/lanremote-relay/tunnel-h2.log + +[Install] +WantedBy=multi-user.target +UNITEOF +echo " 已写入 $SECOND" +cat "$SECOND" | grep -E 'protocol|ha-connections|MemoryMax' | sed 's/^/ /' + +echo +echo "════════ 4. 启动,等连接建齐 ════════" +systemctl daemon-reload +systemctl enable lanremote-tunnel-h2 > /dev/null 2>&1 +systemctl restart lanremote-tunnel +systemctl restart lanremote-tunnel-h2 + +for i in $(seq 1 40); do + sleep 4 + n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') + # 两个实例各自暴露 metrics 端口会冲突,靠下面分别看进程 + [ "${n:-0}" -ge 10 ] && break +done +echo " metrics 报的连接数: ${n:-?}" + +echo +echo "════════ 5. 两个实例的进程和内存 ════════" +for svc in lanremote-tunnel lanremote-tunnel-h2; do + pid=$(systemctl show $svc -p MainPID --value) + mem=$(systemctl show $svc -p MemoryCurrent --value) + proto=$(grep -oE '\-\-protocol [a-z0-9]+' /etc/systemd/system/$svc.service | head -1) + conns=$(grep -oE '\-\-ha-connections [0-9]+' /etc/systemd/system/$svc.service | head -1) + printf " %-26s PID=%-7s 内存=%s MB %s %s 状态=%s\n" \ + "$svc" "$pid" "$(( ${mem:-0} / 1048576 ))" "$proto" "$conns" \ + "$(systemctl is-active $svc)" +done + +echo +echo " ⚠ 两个实例都想占 20241 metrics 端口,后起的会失败 —— 这是正常的。" +echo " 实际连接数看 cloudflared 日志里的 Registered 行。" +echo " ---" +echo " 主实例(QUIC)最近注册:" +tail -60 /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Registered tunnel connection' | sed 's/^/ /' +echo " 副本实例(HTTP/2)最近注册:" +tail -60 /root/lanremote-relay/tunnel-h2.log 2>/dev/null | grep -c 'Registered tunnel connection' | sed 's/^/ /' + +echo +echo "════════ 6. 等公网可用 ════════" +for i in $(seq 1 25); do + c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 -H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health) + printf " 第 %d 次: %s\n" "$i" "$c" + [ "$c" = "200" ] && break + sleep 3 +done + +echo +echo "════════ 7. 快速验收(40 次)════════" +ok=0; times=() +for i in $(seq 1 40); do + out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health) + c=$(echo "$out" | awk '{print $1}'); ms=$(echo "$out" | awk '{printf "%d", $2*1000}') + [ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); } +done +stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}') +echo " 可用率 $ok/40 = $((ok*100/40))% $stats ms" diff --git a/_build/test_admin_features.py b/_build/test_admin_features.py new file mode 100644 index 0000000..14091a7 --- /dev/null +++ b/_build/test_admin_features.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r'''测试管理页新增的两个功能(在设备上跑,直连 127.0.0.1:8080)。 + +覆盖: + A. 「允许新设备接入」开关 + A1 默认是开的 + A2 关掉之后,**新**设备注册被拒(blocked=True) + A3 关掉之后,**已登记过的**设备仍然能上线 + A4 重新打开之后,新设备又能注册 + B. 「修改配对码」 + B1 换成合法的 6 位数字 -> 成功 + B2 新码立刻生效(能拿来注册/attach) + B3 旧码的映射被清掉(不会变成孤儿占位) + B4 换成已被别的设备占用的码 -> 被拒 + B5 换成非 6 位数字 -> 被拒 + B6 换成同一个码 -> 成功但 unchanged + B7 改码会把该设备的会话踢掉 + C. 管理页 HTML 里有新控件 +''' +from __future__ import annotations + +import json +import sys +import time +import urllib.error +import urllib.request + +BASE = "http://127.0.0.1:8080" +KEY_FILE = "/root/lanremote-relay/data/admin_key.txt" + +PASS = 0 +FAIL = 0 + + +def check(ok: bool, label: str) -> None: + global PASS, FAIL + if ok: + PASS += 1 + print(" [PASS] %s" % label) + else: + FAIL += 1 + print(" [FAIL] %s" % label) + + +def call(path: str, payload: dict | None = None, method: str = "POST", + admin: bool = False, key: str = "") -> tuple: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") \ + if payload is not None else None + headers = {"Content-Type": "application/json"} + if admin: + headers["X-Admin"] = key + req = urllib.request.Request(BASE + path, data=data, headers=headers, + method=method) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + body = resp.read().decode("utf-8", "replace") + try: + return resp.status, json.loads(body) + except Exception: # noqa: BLE001 + return resp.status, body + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + try: + return exc.code, json.loads(body) + except Exception: # noqa: BLE001 + return exc.code, body + except Exception as exc: # noqa: BLE001 + return 0, {"error": str(exc)} + + +def main() -> int: + try: + key = open(KEY_FILE, encoding="utf-8").read().strip() + except OSError as exc: + print(" 读不到管理密钥: %s" % exc) + return 1 + + stamp = int(time.time()) + dev_a = "featA-%d" % stamp + dev_b = "featB-%d" % stamp + dev_c = "featC-%d" % stamp + code_a = "%06d" % ((stamp + 11) % 1000000) + code_b = "%06d" % ((stamp + 22) % 1000000) + code_c = "%06d" % ((stamp + 33) % 1000000) + code_a2 = "%06d" % ((stamp + 44) % 1000000) + + def reg(dev: str, code: str, name: str = "feat") -> tuple: + return call("/v1/agent/register", { + "deviceId": dev, "code": code, "name": name, "version": "1.8.2"}) + + print(" ================ A. 允许新设备接入开关 ================") + + # A1 默认开 + st, snap = call("/v1/health", method="GET") + check(snap.get("allowNew") is True, "A1 默认允许新设备接入") + + # 先登记设备 A(此时开关是开的) + st, r = reg(dev_a, code_a, "设备A") + check(r.get("ok") is True, "A0 设备A 初始登记成功(码 %s)" % code_a) + + # A2 关掉开关 + 已有会话 + st, r = call("/v1/admin/allow_new", {"allow": False}, admin=True, key=key) + check(r.get("ok") is True and r.get("allowNew") is False, + "A2a 关闭开关成功") + + st, r = reg(dev_b, code_b, "设备B") + check(r.get("ok") is False and r.get("blocked") is True, + "A2b 新设备B 被拒(blocked=True): %s" % r.get("error", "")) + + # A3 已登记过的 A 仍能上线 + st, r = reg(dev_a, code_a, "设备A") + check(r.get("ok") is True, + "A3 已登记的设备A 仍能上线(关开关不影响老设备)") + + # A4 重新打开 + st, r = call("/v1/admin/allow_new", {"allow": True}, admin=True, key=key) + check(r.get("ok") is True and r.get("allowNew") is True, "A4a 重新打开成功") + st, r = reg(dev_c, code_c, "设备C") + check(r.get("ok") is True, "A4b 新设备C 又能注册了") + + # ---------------- B ---------------- + print() + print(" ================ B. 修改配对码 ================") + + # B5 非 6 位 -> 拒 + st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": "12ab"}, + admin=True, key=key) + check(r.get("ok") is False, "B5 非 6 位数字被拒: %s" % r.get("error", "")) + + # B4 已被占用 -> 拒 + st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_c}, + admin=True, key=key) + check(r.get("ok") is False, "B4 已被设备C占用的码被拒: %s" % r.get("error", "")) + + # B1 正常改码 + st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_a2}, + admin=True, key=key) + check(r.get("ok") is True and r.get("code") == code_a2, + "B1 改码成功 %s -> %s" % (code_a, code_a2)) + + # B2 新码立刻生效 + st, r = reg(dev_a, code_a2, "设备A") + check(r.get("ok") is True and r.get("code") == code_a2, + "B2 新码立刻可用于注册") + + # B6 改成同一个 -> unchanged + st, r = call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_a2}, + admin=True, key=key) + check(r.get("ok") is True and r.get("unchanged") is True, + "B6 改成同一个码 -> unchanged=True") + + # B3 旧码不该还占着 + st, snap = call("/v1/health", method="GET") + codes_used = {d["code"] for d in snap.get("devices", [])} + check(code_a2 in codes_used and code_a not in codes_used, + "B3 设备列表里只有新码(旧码 %s 已清)" % code_a) + + # B7 会话被踢 —— 先 attach 一个 + st, r = call("/v1/client/attach", + {"code": code_a2, "name": "测试手表"}) + attached = r.get("ok") is True + if attached: + st2, r2 = call("/v1/admin/set_code", + {"deviceId": dev_a, + "code": "%06d" % ((stamp + 55) % 1000000)}, + admin=True, key=key) + check(r2.get("ok") is True and r2.get("kicked", 0) >= 1, + "B7 改码踢掉了 %s 个会话" % r2.get("kicked")) + # 改回去,方便下面收尾 + call("/v1/admin/set_code", {"deviceId": dev_a, "code": code_a2}, + admin=True, key=key) + else: + check(True, "B7 跳过(attach 没成功:%s)" % r.get("error", "")) + + # ---------------- C ---------------- + print() + print(" ================ C. 管理页 HTML ================") + req = urllib.request.Request(BASE + "/zhongzhuanzhan", + headers={"X-Admin": key}) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + html = resp.read().decode("utf-8", "replace") + check('id="allowNew"' in html, "C1 页面有开关控件") + check("允许新设备接入" in html, "C2 页面有开关文案") + check("data-recode=" in html, "C3 设备行有改码按钮") + check("/v1/admin/allow_new" in html, "C4 JS 里调 allow_new 接口") + check("/v1/admin/set_code" in html, "C5 JS 里调 set_code 接口") + # 改码按钮不能带 data-device,否则会被删除逻辑吃掉 + import re + recode_btns = re.findall(r']*data-recode="[^"]*"[^>]*>', html) + bad = [b for b in recode_btns if "data-device" in b] + check(not bad, "C6 改码按钮没有 data-device(不会误触发删除)") + except Exception as exc: # noqa: BLE001 + check(False, "C 取管理页失败: %s" % exc) + + # ---------------- 收尾 ---------------- + print() + print(" 清理测试设备…") + for dev in (dev_a, dev_b, dev_c): + call("/v1/admin/delete", {"deviceId": dev}, admin=True, key=key) + call("/v1/admin/allow_new", {"allow": True}, admin=True, key=key) + + print() + print("=" * 56) + print(" 通过 %d 项,失败 %d 项" % (PASS, FAIL)) + return 0 if FAIL == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/out_app/com/lanremote/watch/RelayTransport$1.class b/_tests/out_app/com/lanremote/watch/RelayTransport$1.class index b3e981d..cc685e6 100644 Binary files a/_tests/out_app/com/lanremote/watch/RelayTransport$1.class and b/_tests/out_app/com/lanremote/watch/RelayTransport$1.class differ diff --git a/_tests/out_app/com/lanremote/watch/RelayTransport$2.class b/_tests/out_app/com/lanremote/watch/RelayTransport$2.class index 7004679..33d3f02 100644 Binary files a/_tests/out_app/com/lanremote/watch/RelayTransport$2.class and b/_tests/out_app/com/lanremote/watch/RelayTransport$2.class differ diff --git a/_tests/out_app/com/lanremote/watch/RelayTransport$3.class b/_tests/out_app/com/lanremote/watch/RelayTransport$3.class index 526c9c4..7dc7857 100644 Binary files a/_tests/out_app/com/lanremote/watch/RelayTransport$3.class and b/_tests/out_app/com/lanremote/watch/RelayTransport$3.class differ diff --git a/_tests/out_app/com/lanremote/watch/RelayTransport$4.class b/_tests/out_app/com/lanremote/watch/RelayTransport$4.class index 8471ba0..4ab228e 100644 Binary files a/_tests/out_app/com/lanremote/watch/RelayTransport$4.class and b/_tests/out_app/com/lanremote/watch/RelayTransport$4.class differ diff --git a/_tests/out_app/com/lanremote/watch/RelayTransport$HttpResult.class b/_tests/out_app/com/lanremote/watch/RelayTransport$HttpResult.class index dafcc5a..763f6b6 100644 Binary files a/_tests/out_app/com/lanremote/watch/RelayTransport$HttpResult.class and b/_tests/out_app/com/lanremote/watch/RelayTransport$HttpResult.class differ diff --git a/_tests/out_app/com/lanremote/watch/RelayTransport.class b/_tests/out_app/com/lanremote/watch/RelayTransport.class index 31dbae6..f3f01dc 100644 Binary files a/_tests/out_app/com/lanremote/watch/RelayTransport.class and b/_tests/out_app/com/lanremote/watch/RelayTransport.class differ diff --git a/_tests/shots/1-屏幕中间.png b/_tests/shots/1-屏幕中间.png index 2a74794..549668f 100644 Binary files a/_tests/shots/1-屏幕中间.png and b/_tests/shots/1-屏幕中间.png differ diff --git a/_tests/shots/3-自定义640x400.png b/_tests/shots/3-自定义640x400.png index d404904..4e38939 100644 Binary files a/_tests/shots/3-自定义640x400.png and b/_tests/shots/3-自定义640x400.png differ diff --git a/_tests/test_admin_device_control.py b/_tests/test_admin_device_control.py new file mode 100644 index 0000000..23d882e --- /dev/null +++ b/_tests/test_admin_device_control.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +"""验证中转管理页的两台设备控制功能。 + +要验证: + A. 「允许新设备接入」开关 + 1. 接口 /v1/admin/allow_new 存在 + 2. 默认是允许的(/v1/health 的 allowNew=True) + 3. 关掉之后**新**设备注册被拒,且回 blocked=True + (客户端要靠这个标记区分"被管理员挡了"和"配对码冲突") + 4. 关掉之后**已登记过**的设备仍然能上线 + —— 这一条最容易写错:如果按配对码判断, + 管理员给老设备改过码之后,老设备会被自己的开关挡在外面 + 5. 重新打开之后新设备又能注册 + 6. 开关状态要能持久化(重启不丢) + + B. 修改设备的配对码 + 1. 接口 /v1/admin/set_code 存在 + 2. 合法新码 -> 成功,且立刻生效 + 3. 旧码的映射被清掉(不能留成孤儿占位) + 4. 已被别的设备占用 -> 拒 + 5. 非 6 位数字 -> 拒 + 6. 改成同一个码 -> 成功但 unchanged + 7. 改码会踢掉该设备当前的会话 + + C. 管理页面上有对应控件 + 1. 有开关元素和文案 + 2. 每台设备有「改码」按钮 + 3. 「改码」按钮**不能**带 data-device —— + 现有"单台删除"是按 button.danger[data-device] 绑的, + 改码按钮要是也带上,点改码就会变成删设备 +""" +from __future__ import annotations + +import http.cookiejar +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +_UA = {"User-Agent": "LanRemote/1.8.2"} + +ROOT = Path(__file__).resolve().parent.parent +PORT = 8105 +BASE = f"http://127.0.0.1:{PORT}" + +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: + work = Path(tempfile.mkdtemp(prefix="devctl_")) + relay = None + try: + data = work / "d" + data.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env.update({"RELAY_PORT": str(PORT), "RELAY_BIND": "127.0.0.1", + "RELAY_DATA": str(data), + "PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"}) + relay = subprocess.Popen( + [sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + up = False + for _ in range(40): + time.sleep(0.5) + try: + req = urllib.request.Request(BASE + "/v1/health", headers=_UA) + with urllib.request.urlopen(req, timeout=3) as resp: + if resp.status == 200: + up = True + break + except Exception: # noqa: BLE001 + continue + check("本地中转起来了", up) + if not up: + return 1 + + # 登录拿 cookie + jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar)) + form = urllib.parse.urlencode( + {"username": "xmz7891", "password": "@REVAedu120830"}).encode() + req = urllib.request.Request(BASE + "/admin/login", data=form, + method="POST", headers=_UA) + req.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + opener.open(req, timeout=15).read() + except urllib.error.HTTPError: + pass + + def admin_post(path, payload): + r = urllib.request.Request( + BASE + path, data=json.dumps(payload).encode(), + method="POST", headers=_UA) + r.add_header("Content-Type", "application/json") + try: + resp = opener.open(r, timeout=20) + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + try: + return exc.code, json.loads(raw) + except Exception: # noqa: BLE001 + return exc.code, {"raw": raw[:120]} + + def health(): + r = urllib.request.Request(BASE + "/v1/health", headers=_UA) + with urllib.request.urlopen(r, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + + def reg(dev, code, name="测试机"): + body = json.dumps({"deviceId": dev, "code": code, + "name": name, "version": "1.8.2"}).encode() + r = urllib.request.Request(BASE + "/v1/agent/register", data=body, + method="POST", + headers={**_UA, + "Content-Type": + "application/json"}) + try: + with urllib.request.urlopen(r, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + try: + return json.loads(raw) + except Exception: # noqa: BLE001 + return {"ok": False, "raw": raw[:120]} + + stamp = int(time.time()) + A, B, C = "ctlA-%d" % stamp, "ctlB-%d" % stamp, "ctlC-%d" % stamp + cA = "%06d" % ((stamp + 1) % 1000000) + cB = "%06d" % ((stamp + 2) % 1000000) + cC = "%06d" % ((stamp + 3) % 1000000) + cA2 = "%06d" % ((stamp + 4) % 1000000) + + # ------------------------------------------------ A + print(" ---- A. 允许新设备接入 ----") + check("A1 默认允许接入", + health().get("allowNew") is True) + check("A0 设备A 初始登记", + reg(A, cA, "设备A").get("ok") is True) + + st, j = admin_post("/v1/admin/allow_new", {"allow": False}) + check("A2a 关闭开关", j.get("ok") is True and j.get("allowNew") is False, + j) + + j = reg(B, cB, "设备B") + check("A2b 新设备被拒且带 blocked 标记", + j.get("ok") is False and j.get("blocked") is True, j) + + j = reg(A, cA, "设备A") + check("A3 已登记的老设备仍能上线", j.get("ok") is True, j) + + # A3b 老设备**改过码之后**也必须还能上线 + # (这一条专门防"按配对码判断新旧"这个错) + admin_post("/v1/admin/set_code", {"deviceId": A, "code": cA2}) + j = reg(A, cA2, "设备A") + check("A3b 老设备换码后仍不受开关影响", j.get("ok") is True, j) + + st, j = admin_post("/v1/admin/allow_new", {"allow": True}) + check("A4a 重新打开", j.get("ok") is True and j.get("allowNew") is True, j) + check("A4b 新设备又能注册", reg(C, cC, "设备C").get("ok") is True) + + # A6 持久化:状态文件里要有 allowNew + state = data / "relay_state.json" + text = state.read_text(encoding="utf-8") if state.is_file() else "" + check("A6 allowNew 写进了状态文件", '"allowNew"' in text, text[:80]) + + # ------------------------------------------------ B + print(" ---- B. 修改配对码 ----") + j = admin_post("/v1/admin/set_code", {"deviceId": A, "code": "abc"})[1] + check("B5 非 6 位数字被拒", j.get("ok") is False, j) + + j = admin_post("/v1/admin/set_code", {"deviceId": A, "code": cC})[1] + check("B4 已被占用的码被拒", j.get("ok") is False, j) + + newc = "%06d" % ((stamp + 9) % 1000000) + st, j = admin_post("/v1/admin/set_code", {"deviceId": A, "code": newc}) + check("B2 改码成功", j.get("ok") is True and j.get("code") == newc, j) + check("B2b 新码立刻可注册", reg(A, newc, "设备A").get("ok") is True) + + j = admin_post("/v1/admin/set_code", {"deviceId": A, "code": newc})[1] + check("B6 改成同一个码 -> unchanged", + j.get("ok") is True and j.get("unchanged") is True, j) + + codes_used = {d["code"] for d in health().get("devices", [])} + check("B3 旧码已清(列表里只有新码)", + newc in codes_used and cA not in codes_used + and cA2 not in codes_used, codes_used) + + # B7 改码踢会话 + r = urllib.request.Request( + BASE + "/v1/client/attach", + data=json.dumps({"code": newc, "name": "测试手表"}).encode(), + method="POST", + headers={**_UA, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(r, timeout=10) as resp: + att = json.loads(resp.read().decode("utf-8")) + except Exception as exc: # noqa: BLE001 + att = {"ok": False, "error": str(exc)} + if att.get("ok"): + nxt = "%06d" % ((stamp + 10) % 1000000) + j = admin_post("/v1/admin/set_code", + {"deviceId": A, "code": nxt})[1] + check("B7 改码踢掉了会话", + j.get("ok") is True and j.get("kicked", 0) >= 1, j) + else: + check("B7 改码踢掉会话(attach 未成功,跳过)", True, att) + + # ------------------------------------------------ C + print(" ---- C. 管理页控件 ----") + try: + req = urllib.request.Request(BASE + "/zhongzhuanzhan", headers=_UA) + with opener.open(req, timeout=15) as resp: + html = resp.read().decode("utf-8", "replace") + except Exception as exc: # noqa: BLE001 + html = "" + check("C 取管理页", False, exc) + + def has(token): + if token in html: + return True + m = re.match(r"^([A-Za-z-]+)=(.+)$", token) + return bool(m) and ('%s="%s"' % (m.group(1), m.group(2))) in html + + check("C1 有开关控件", has("id=allowNew")) + check("C2 有开关文案", "允许新设备接入" in html) + check("C3 有改码按钮", "data-recode=" in html or "data-recode" in html) + check("C4 JS 调 allow_new", "/v1/admin/allow_new" in html) + check("C5 JS 调 set_code", "/v1/admin/set_code" in html) + btns = re.findall(r"]*data-recode[^>]*>", html) + check("C6 改码按钮不带 data-device(不会误触发删除)", + bool(btns) and not any("data-device" in b for b in btns), + btns[:2]) + + print() + print("=" * 56) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + finally: + if relay is not None: + try: + relay.terminate() + relay.wait(timeout=8) + except Exception: # noqa: BLE001 + try: + relay.kill() + except Exception: # noqa: BLE001 + pass + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index 8d6d69f..2ebb27a 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -455,6 +455,18 @@ $stageOut = & python (Join-Path $root "_tests\test_admin_js.py") 2>&1 Show-Fails $stageOut Record "管理页 JS 合法性" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3e +Banner "3e/7 管理页设备控制(接入开关 + 改配对码)" +# 两个功能各自最容易写错的地方: +# * 判断"新设备"必须按 deviceId,不能按配对码 —— +# 否则管理员给老设备改过码之后,老设备会被开关挡在外面 +# * 改码按钮不能带 data-device —— +# 现有"单台删除"按 button.danger[data-device] 绑, +# 带上就会被当成删除按钮,点改码变删设备 +$stageOut = & python (Join-Path $root "_tests\test_admin_device_control.py") 2>&1 + Show-Fails $stageOut +Record "管理页设备控制" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 4b Banner "4b/7 手表 Activity 骨架(防白屏)" # onCreate 被删掉过一次:照样编译通过,但打开就是白屏。 diff --git a/relay/admin.py b/relay/admin.py index 9449d71..cefc661 100644 --- a/relay/admin.py +++ b/relay/admin.py @@ -300,7 +300,13 @@ def _device_rows(devices: list) -> str: '%ss' '%d / %d' '%s / %s' - '' + '' '' % ( did, name, @@ -310,6 +316,7 @@ def _device_rows(devices: list) -> str: device["idleSeconds"], device["queuedToClient"], device["queuedToAgent"], _kb(device["bytesIn"]), _kb(device["bytesOut"]), + did, name, code, did, name, code) ) return "".join(out) @@ -333,6 +340,61 @@ const post = async (path, payload) => { return r.json(); }; +// ---------------- 允许/禁止新设备接入 ---------------- +// 关掉之后,只有**已经登记过**的电脑能继续上线, +// 没见过的新设备一律拒绝。 +const allowNew = document.getElementById('allowNew'); +if (allowNew) allowNew.onchange = async () => { + const want = allowNew.checked; + allowNew.disabled = true; + try { + const j = await post('/v1/admin/allow_new', {allow: want}); + if (j.ok) { + toast(j.allowNew ? '已允许新设备接入' : '已禁止新设备接入', 'ok'); + } else { + toast('设置失败:' + (j.error || '未知错误'), 'err'); + allowNew.checked = !want; + } + } catch (e) { + toast('请求出错:' + e, 'err'); + allowNew.checked = !want; + } + allowNew.disabled = false; +}; + +// ---------------- 改配对码 ---------------- +document.querySelectorAll('button[data-recode]').forEach(b => { + b.onclick = async () => { + const name = b.dataset.name, old = b.dataset.code; + const typed = prompt('把「' + name + '」的配对码 ' + old + + ' 改成:\n\n(必须是 6 位数字)', ''); + if (typed === null) return; + const code = (typed || '').trim(); + if (!/^[0-9]{6}$/.test(code)) { + toast('配对码必须是 6 位数字', 'err'); + return; + } + b.disabled = true; + try { + const j = await post('/v1/admin/set_code', + {deviceId: b.dataset.recode, code: code}); + if (j.ok) { + toast(j.unchanged ? '配对码没有变化' + : ('配对码已改成 ' + j.code + + (j.kicked ? ',踢掉 ' + j.kicked + ' 个会话' : '')), + 'ok'); + setTimeout(() => location.reload(), 900); + } else { + toast('改码失败:' + (j.error || '未知错误'), 'err'); + b.disabled = false; + } + } catch (e) { + toast('请求出错:' + e, 'err'); + b.disabled = false; + } + }; +}); + // ---------------- 单台删除 ---------------- document.querySelectorAll('button.danger[data-device]').forEach(b => { b.onclick = async () => { @@ -538,6 +600,8 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int, url_class = "url empty" if not url_text else "url" url_display = _esc(url_text) if url_text else "还没有地址 —— 隧道可能没起来" sessions = snap.get("sessions", 0) + # 开关状态:允许新设备接入时勾上 + allow_new = bool(snap.get("allowNew", True)) # 下载列表 try: @@ -597,6 +661,11 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int,

设备

+ @@ -654,5 +723,6 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int, "oncls": "live" if online_count else "", "sescls": "busy" if sessions else "", "rows": _device_rows(devices), + "allownew": " checked" if allow_new else "", "dl": dl_rows, } diff --git a/relay/relay.py b/relay/relay.py index 08679cf..66fcd74 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -391,6 +391,8 @@ class Relay: # 留着是为了能明确告诉旧客户端"你是被顶掉的", # 而不是含糊的 401 —— 否则它会以为自己只是过期了,然后抢回来。 self.retired: dict = {} + # 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。 + self.allow_new = True self.started = time.time() self.total_requests = 0 self.state_file = DATA_DIR / "relay_state.json" @@ -403,6 +405,8 @@ class Relay: raw = json.loads(self.state_file.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 return + # 是否允许新设备接入(重启不丢) + self.allow_new = bool(raw.get("allowNew", True)) for item in raw.get("codes", []): device_id = str(item.get("deviceId", "")) code = str(item.get("code", "")) @@ -413,10 +417,13 @@ class Relay: def _save(self) -> None: try: DATA_DIR.mkdir(parents=True, exist_ok=True) - payload = {"codes": [ - {"deviceId": did, "code": code} - for code, did in sorted(self.codes.items()) - ]} + payload = { + "allowNew": bool(self.allow_new), + "codes": [ + {"deviceId": did, "code": code} + for code, did in sorted(self.codes.items()) + ], + } tmp = self.state_file.with_suffix(".tmp") tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") @@ -463,6 +470,19 @@ class Relay: return {"ok": False, "error": "配对码必须是 6 位数字"} with self.lock: + # 管理员关掉了"允许新设备接入"时, + # 只放行**已经登记过**的设备。 + # + # 注意判断依据是 deviceId(装机时生成、重装也不变), + # **不是配对码** —— 否则管理员给老设备改了码, + # 那台设备就被自己的开关挡在外面了。 + known_device = device_id in self.devices + if not known_device and not self.allow_new: + log(f"新设备接入已被关闭,拒绝 {name} ({device_id[:12]}…)") + return {"ok": False, + "blocked": True, + "error": "中转站当前不接受新设备接入"} + known = self.codes.get(code) if known and known != device_id: # codeTaken 让客户端能精确识别这种冲突, @@ -507,6 +527,70 @@ class Relay: return {"ok": True, "token": device.token, "code": code, "note": note, "pollSeconds": POLL_SECONDS} + # ---------------- 管理操作 ---------------- + + def set_allow_new(self, allow: bool) -> dict: + """开关"允许新设备接入"。""" + with self.lock: + self.allow_new = bool(allow) + self._save() + log(f"管理员把「允许新设备接入」设为 {self.allow_new}") + return {"ok": True, "allowNew": self.allow_new} + + def set_device_code(self, device_id: str, new_code: str) -> dict: + """给一台设备换配对码。 + + 要点: + * 新码必须是 6 位数字 + * 不能已经被**别的**设备占用 + * 旧码的映射必须删掉,否则会变成孤儿条目, + 管理页上"配对码占用"的数字会虚高 + * 该设备挂着的会话要踢掉 —— 会话是绑在码上的, + 不踢的话手表还拿着旧会话以为连着 + """ + device_id = str(device_id or "").strip() + new_code = str(new_code or "").strip() + if not CODE_RE.match(new_code): + return {"ok": False, "error": "配对码必须是 6 位数字"} + with self.lock: + device = self.devices.get(device_id) + if device is None: + return {"ok": False, "error": "没有这台设备(可能已下线)"} + + owner = self.codes.get(new_code) + if owner and owner != device_id: + return {"ok": False, + "error": "这个配对码已经被别的设备占用了"} + + old_code = device.code + if old_code == new_code: + return {"ok": True, "code": new_code, "unchanged": True} + + # 删掉旧码映射(连同可能存在的别名) + for code, owner_id in list(self.codes.items()): + if owner_id == device_id: + self.codes.pop(code, None) + self.codes[new_code] = device_id + device.code = new_code + + # 会话绑在码上,旧会话作废,让手表重新配对。 + # + # ⚠ 会话是以 token 为 key 存在 self.sessions 里的, + # Session 用了 __slots__,**根本没有 session_id 属性**。 + # 必须遍历 items() 拿 key;写 s.session_id 会 AttributeError, + # 而且会被上层兜底成"服务器内部错误"(500),很难定位。 + kicked = 0 + for sid in [t for t, s in self.sessions.items() + if s.device_id == device_id]: + self.sessions.pop(sid, None) + kicked += 1 + + self._save() + log(f"管理员把设备 {device.name} 的配对码 " + f"{old_code} 改成 {new_code}(踢掉 {kicked} 个会话)") + return {"ok": True, "code": new_code, + "oldCode": old_code, "kicked": kicked} + def device_by_token(self, token: str) -> Device | None: if not token: return None @@ -682,8 +766,12 @@ class Relay: if now - getattr(dev, "last_seen", now) > DEVICE_TTL] for did in stale: self.devices.pop(did, None) - # 它的会话也一起收掉,免得挂着一堆死会话 - for sid in [s.session_id for s in self.sessions.values() + # 它的会话也一起收掉,免得挂着一堆死会话。 + # ⚠ 同样要用 items() 拿 token 当 key —— + # Session 没有 session_id 属性(见 set_device_code 里的说明)。 + # 这段以前写的是 s.session_id,只是因为平时 stale 是空的 + # 才一直没炸;一旦真有设备超时就会 500。 + for sid in [t for t, s in self.sessions.items() if s.device_id == did]: self.sessions.pop(sid, None) @@ -726,6 +814,9 @@ class Relay: "sessions": len(self.sessions), "totalRequests": self.total_requests, "memoryKB": _rss_kb(), + # 管理页上的"允许新设备接入"开关就显示这个值 + "allowNew": bool(self.allow_new), + "codeCount": len(self.codes), } def kick(self, device_id: str) -> int: @@ -1653,6 +1744,23 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "forgotten": done}) return + # ---- 允许/禁止新设备接入 ---- + if path == "/v1/admin/allow_new": + body = self._json_body() + allow = bool(body.get("allow", True)) + result = RELAY.set_allow_new(allow) + self._json(200, result) + return + + # ---- 改某台设备的配对码 ---- + if path == "/v1/admin/set_code": + body = self._json_body() + result = RELAY.set_device_code( + str(body.get("deviceId") or ""), + str(body.get("code") or "")) + self._json(200 if result.get("ok") else 400, result) + return + self._json(404, {"ok": False, "error": "没有这个管理接口"}) return