+
+
"""
+ 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())
diff --git a/_build/check_conns.sh b/_build/check_conns.sh
new file mode 100644
index 0000000..7a5e4a1
--- /dev/null
+++ b/_build/check_conns.sh
@@ -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/^/ /'
diff --git a/_build/dbg_setcode.py b/_build/dbg_setcode.py
new file mode 100644
index 0000000..aad1c6c
--- /dev/null
+++ b/_build/dbg_setcode.py
@@ -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))
diff --git a/_build/longstab.sh b/_build/longstab.sh
new file mode 100644
index 0000000..4d591a2
--- /dev/null
+++ b/_build/longstab.sh
@@ -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"
diff --git a/_build/longstab_dual.sh b/_build/longstab_dual.sh
new file mode 100644
index 0000000..feb82b2
--- /dev/null
+++ b/_build/longstab_dual.sh
@@ -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"
diff --git a/_build/setup_dual.sh b/_build/setup_dual.sh
new file mode 100644
index 0000000..14d519c
--- /dev/null
+++ b/_build/setup_dual.sh
@@ -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"
diff --git a/_build/test_admin_features.py b/_build/test_admin_features.py
new file mode 100644
index 0000000..14091a7
--- /dev/null
+++ b/_build/test_admin_features.py
@@ -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'