mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +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:
+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