管理页新增:允许/禁止新设备接入 + 修改设备配对码

== 功能一:允许新设备接入开关 ==

关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 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 阶段。
This commit is contained in:
xmz7891
2026-10-07 17:01:14 +08:00
parent 25e7780272
commit d4f335b8f9
21 changed files with 1520 additions and 7 deletions
+47
View File
@@ -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 阶段。
+200
View File
@@ -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())
+183
View File
@@ -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 = """ '<td class="ops"><button class="danger sm" '
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
'</tr>' % (
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 = """ '<td class="ops">'
# ⚠ 这个按钮用 data-recode 而不是 data-device ——
# 现有"单台删除"是按 button.danger[data-device] 绑的,
# 这里要是也带 data-device 就会被当成删除按钮。
'<button class="ghost sm" data-recode="%s" '
'data-name="%s" data-code="%s">改码</button>'
'<button class="danger sm" '
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
'</tr>' % (
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 = """ <div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>"""
new = """ <div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" id="allowWrap"
style="cursor:pointer;display:flex;align-items:center;gap:7px;margin:0 18px 0 0">
<input type="checkbox" id="allowNew"%(allownew)s>
<span>允许新设备接入</span>
</label>
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>"""
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())
+23
View File
@@ -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/^/ /'
+61
View File
@@ -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))
+104
View File
@@ -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"
+91
View File
@@ -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"
+128
View File
@@ -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"
+211
View File
@@ -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'<button[^>]*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())
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

+275
View File
@@ -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"<button[^>]*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())
+12
View File
@@ -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 被删掉过一次:照样编译通过,但打开就是白屏。
+71 -1
View File
@@ -300,7 +300,13 @@ def _device_rows(devices: list) -> str:
'<td data-l="空闲"><span class="mono">%ss</span></td>'
'<td data-l="待发 表/机"><span class="mono">%d / %d</span></td>'
'<td data-l="流量 入/出"><span class="mono">%s / %s</span></td>'
'<td class="ops"><button class="danger sm" '
'<td class="ops">'
# ⚠ 这个按钮用 data-recode 而不是 data-device ——
# 现有"单台删除"是按 button.danger[data-device] 绑的,
# 这里要是也带 data-device 就会被当成删除按钮。
'<button class="ghost sm" data-recode="%s" '
'data-name="%s" data-code="%s">改码</button>'
'<button class="danger sm" '
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
'</tr>' % (
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,
<div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" id="allowWrap"
style="cursor:pointer;display:flex;align-items:center;gap:7px;margin:0 18px 0 0">
<input type="checkbox" id="allowNew"%(allownew)s>
<span>允许新设备接入</span>
</label>
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>
@@ -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,
}
+112 -4
View File
@@ -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": [
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