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

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

关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 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
+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,
}
+114 -6
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": [
{"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