mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 11:20:43 +00:00
管理页新增:允许/禁止新设备接入 + 修改设备配对码
== 功能一:允许新设备接入开关 ==
关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 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:
@@ -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 阶段。
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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/^/ /'
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 |
@@ -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())
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
+114
-6
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user