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

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

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