修「经中转几次后永久哑掉」——「按一下没反应」的真因

== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
This commit is contained in:
xmz7891
2026-10-07 17:59:39 +08:00
parent b2bfa6ffbf
commit 8880e7033d
290 changed files with 13216 additions and 1159 deletions
+275 -14
View File
@@ -111,11 +111,45 @@ mk() { # mk <名字> <<'EOF' ...
mk systemctl <<'EOF'
#!/bin/bash
# 桩:会**记住**哪些服务被启动过。
# 如果这里 is-active 一律返回 active,那么"无变化就不该重启"这类
# 断言永远测不出来(net-tune.sh 会以为隧道在跑,然后去重启它)。
echo "systemctl $*" >> "$STUB_LOG"
STATE="$STUB_LOG.state"
touch "$STATE"
# ⚠ 服务名要自己从参数里挑出来。
# 直接取 $2 是错的:`systemctl is-active --quiet foo` 里 $2 是 --quiet,
# 不是服务名 —— 这样挑出来的是 "--quiet",grep 永远不中,
# 于是所有服务都被当成"没在跑"。
SVC=""
for a in "$@"; do
case "$a" in
-*) continue ;;
is-active|is-enabled|start|stop|restart|reload|enable|disable|status|daemon-reload|mask|unmask)
continue ;;
*) SVC="$a" ;;
esac
done
case "$1" in
is-active) echo "active"; exit 0 ;;
is-active)
if [ -n "$SVC" ] && grep -qxF "$SVC" "$STATE" 2>/dev/null; then
echo "active"; exit 0
fi
echo "inactive"; exit 3 ;;
is-enabled) echo "enabled"; exit 0 ;;
*) exit 0 ;;
start|restart)
[ -n "$SVC" ] && { grep -qxF "$SVC" "$STATE" 2>/dev/null \
|| echo "$SVC" >> "$STATE"; }
exit 0 ;;
stop)
if [ -n "$SVC" ]; then
grep -vxF "$SVC" "$STATE" > "$STATE.t" 2>/dev/null
mv "$STATE.t" "$STATE" 2>/dev/null
fi
exit 0 ;;
*) exit 0 ;;
esac
EOF
@@ -247,6 +281,10 @@ fi
REL="$SANDBOX/root/lanremote-relay"
UNIT="$SANDBOX/etc/systemd/system"
# 把第一遍的桩日志留一份 —— 第二遍会被清空,
# 但"第一次装的时候确实做了 daemon-reload"这事得留证据。
cp "$STUB_LOG" "$STUB_LOG.run1" 2>/dev/null || true
exists "relay.py 已部署" "$REL/relay.py"
exists "admin.py 已部署" "$REL/admin.py"
exists "landing.py 已部署" "$REL/landing.py"
@@ -273,23 +311,45 @@ for kw in "RELAY_POLL=60" "RELAY_PORT=8080" "python3"; do
bad "relay unit 缺 $kw"
fi
done
if grep -q 'http2' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
ok "h2 单元用的是 http2"
else
bad "h2 单元没写 http2"
fi
if grep -q 'quic' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
ok "主隧道单元用的是 quic"
echo
echo " 两个隧道单元的内容(默认主 quic+8 / 副本 http2+2):"
# 主隧道
if grep -q -- '--protocol quic' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
ok "主隧道单元用 quic"
else
bad "主隧道单元没写 quic"
fi
if grep -q 'ha-connections 10' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
ok "隧道单元含 ha-connections 10"
if grep -q -- '--ha-connections 8' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
ok "主隧道 8 条连接(实测最优配比)"
else
bad "隧道单元缺 ha-connections 10"
bad "主隧道连接数不是 8"
fi
# 副本隧道
if grep -q -- '--protocol http2' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
ok "副本隧道单元用 http2"
else
bad "副本隧道单元没写 http2"
fi
if grep -q -- '--ha-connections 2' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
ok "副本隧道 2 条连接(实测最优配比)"
else
bad "副本隧道连接数不是 2"
fi
# ★ metrics 端口必须显式钉死且不重复
# 两边都用默认 20241 的话,停掉主实例再启副实例时副实例会抢到 20241,
# 所有按端口读指标的脚本就读错实例了,而且不报错。
if grep -q -- '--metrics 127.0.0.1:20241' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
ok "主隧道 metrics 钉死 20241"
else
bad "主隧道没显式指定 metrics 端口"
fi
if grep -q -- '--metrics 127.0.0.1:20242' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
ok "副本隧道 metrics 钉死 20242"
else
bad "副本隧道没显式指定 metrics 端口"
fi
if grep -q 'ExecStart=/bin/bash' "$UNIT/lanremote-nettune.service" 2>/dev/null; then
ok "nettune 顺序执行两个调优脚本"
ok "nettune 只有一个入口脚本"
else
bad "nettune 单元不对"
fi
@@ -314,6 +374,17 @@ echo "device-sentinel" > "$REL/data/devices.json"
echo "key-sentinel" > "$REL/data/admin_key.txt"
echo "pw-sentinel" > "$REL/data/admin_password.txt"
echo "https://keep.me" > "$REL/public_url.txt"
# allowNew 是管理员在管理页上拨的开关,存在 relay_state.json 里。
# 用户重跑脚本升级时**绝对不能**把它拨回去 —— 那是安全设置。
cat > "$REL/data/relay_state.json" <<'EOF'
{
"allowNew": false,
"codes": [
{"deviceId": "desktop-sentinel-abc123", "code": "789113"}
]
}
EOF
STATE_BEFORE=$(md5sum "$REL/data/relay_state.json" | awk '{print $1}')
# 手工改过的 config.yml 也不能被无脑覆盖
mkdir -p "$SANDBOX/root/.cloudflared"
cat > "$SANDBOX/root/.cloudflared/config.yml" <<'EOF'
@@ -327,6 +398,8 @@ EOF
BEFORE_HASH=$(md5sum "$SANDBOX/root/.cloudflared/config.yml" | awk '{print $1}')
# 记下部署文件的指纹,第二遍后应该一致(内容相同)
BEFORE_RELAY=$(md5sum "$REL/relay.py" | awk '{print $1}')
# 清空桩日志,好判断第二遍**有没有多此一举地重启服务**
: > "$STUB_LOG"
OUT2="$TMP/run2.log"
bash "$DEPLOY" --prefix "$SANDBOX" --skip-tunnel --port 8080 > "$OUT2" 2>&1
@@ -341,6 +414,14 @@ check "设备数据没被冲掉" "$(cat "$REL/data/devices.json" 2>/dev/null)"
check "管理钥匙没被冲掉" "$(cat "$REL/data/admin_key.txt" 2>/dev/null)" "key-sentinel"
check "管理密码没被冲掉" "$(cat "$REL/data/admin_password.txt" 2>/dev/null)" "pw-sentinel"
check "公网地址没被冲掉" "$(cat "$REL/public_url.txt" 2>/dev/null)" "https://keep.me"
# ★ allowNew 必须原样保留
STATE_AFTER=$(md5sum "$REL/data/relay_state.json" | awk '{print $1}')
check "relay_state.json 没被改(allowNew 保住了)" "$STATE_AFTER" "$STATE_BEFORE"
if grep -q '"allowNew": false' "$REL/data/relay_state.json" 2>/dev/null; then
ok "allowNew 仍然是 false(没被拨回 true)"
else
bad "allowNew 被改了"
fi
AFTER_HASH=$(md5sum "$SANDBOX/root/.cloudflared/config.yml" | awk '{print $1}')
check "手工 config.yml 没被覆盖" "$AFTER_HASH" "$BEFORE_HASH"
AFTER_RELAY=$(md5sum "$REL/relay.py" | awk '{print $1}')
@@ -351,6 +432,35 @@ W1=$(grep -c '✗' "$OUT1" 2>/dev/null || echo 0)
W2=$(grep -c '✗' "$OUT2" 2>/dev/null || echo 0)
check "第二遍没有新增 ✗ 错误" "$W2" "$W1"
# ────────────────────────────────────────────────────────────────
echo
echo "── 6b. 没变化时不该重启服务(别打断在线连接)──"
# 第二遍什么都没变,桩日志里就不该出现 restart。
# 用户是在**已经跑着的机器上**重跑本脚本升级的,
# 每次无条件 restart 会清掉内存里的设备注册和会话,
# 正在遥控的连接要几秒才重新接上。
if grep -q 'systemctl restart lanremote-relay' "$STUB_LOG" 2>/dev/null; then
bad "第二遍无变化却重启了中转(会打断在线连接)"
grep 'restart' "$STUB_LOG" | head -3 | sed 's/^/ /'
else
ok "第二遍没有重启中转(无变化)"
fi
if grep -q 'systemctl restart lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
bad "第二遍无变化却重启了隧道"
else
ok "第二遍没有重启隧道(无变化)"
fi
if grep -q 'systemctl daemon-reload' "$STUB_LOG" 2>/dev/null; then
bad "第二遍无变化却做了 daemon-reload"
else
ok "第二遍没有多余的 daemon-reload"
fi
if grep -q '内容没变' "$OUT2" 2>/dev/null; then
ok "输出里明确说了「内容没变、保持运行」"
else
bad "输出没说明为什么不重启"
fi
# ════════════════════════════════════════════════════════════════
echo
echo "── 7. 凭据模式:给出域名时要能生成 config.yml ──"
@@ -386,6 +496,82 @@ else
bad "config.yml 回源地址不对"
fi
# ────────────────────────────────────────────────────────────────
echo
echo "── 7c. 默认:两个隧道单元都要启用(协议级故障隔离)──"
# 真机实测(40 次/组):quic 8 + h2 2 拿到 100% 可用率 + 最低 P90/P99。
# 单独跑一个协议都不如两个一起跑:
# 运营商限 UDP(12 个 edge IP:TCP 12/12 通、UDP 0/12 通)
# -> quic 拨号成功率只有约 72%,需要 http2 兜住失败的那部分。
: > "$STUB_LOG"
printf 'lanremote-relay\n' > "$STUB_LOG.state"
bash "$DEPLOY" --prefix "$SANDBOX" > "$TMP/run7c.log" 2>&1
check "默认(不传 --protocol)退出码为 0" "$?" "0"
if grep -qxF 'systemctl enable lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
ok "默认启用了主隧道"
else
bad "默认没启用主隧道"
fi
if grep -qxF 'systemctl enable lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
ok "默认启用了副本隧道"
else
bad "默认没启用副本隧道(双跑才对)"
fi
if grep -qxF 'systemctl disable --now lanremote-tunnel' "$STUB_LOG" 2>/dev/null \
|| grep -qxF 'systemctl disable --now lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
bad "默认把某个隧道关掉了(应该两个都跑)"
else
ok "默认没有关掉任何一个隧道"
fi
echo
echo "── 7d. --protocol quic:只跑主隧道 ──"
: > "$STUB_LOG"
printf 'lanremote-relay\n' > "$STUB_LOG.state"
bash "$DEPLOY" --prefix "$SANDBOX" --protocol quic \
> "$TMP/run7d.log" 2>&1
check "--protocol quic 退出码为 0" "$?" "0"
if grep -qxF 'systemctl enable lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
ok "quic 模式启用了主隧道"
else
bad "quic 模式没启用主隧道"
fi
if grep -qxF 'systemctl disable --now lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
ok "quic 模式停用了副本隧道"
else
bad "quic 模式没停用副本隧道"
fi
if grep -q '只跑 quic' "$TMP/run7d.log" 2>/dev/null; then
ok "输出说明了为什么停用副本"
else
bad "输出没说清楚"
fi
echo
echo "── 7e. --protocol http2:只跑副本隧道 ──"
: > "$STUB_LOG"
printf 'lanremote-relay\n' > "$STUB_LOG.state"
bash "$DEPLOY" --prefix "$SANDBOX" --protocol http2 \
> "$TMP/run7e.log" 2>&1
check "--protocol http2 退出码为 0" "$?" "0"
if grep -qxF 'systemctl enable lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
ok "http2 模式启用了副本隧道"
else
bad "http2 模式没启用副本隧道"
fi
if grep -qxF 'systemctl disable --now lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
ok "http2 模式停用了主隧道"
else
bad "http2 模式没停用主隧道"
fi
echo
echo "── 7f. --protocol 的非法值要报错 ──"
bash "$DEPLOY" --prefix "$SANDBOX" --protocol udp >/dev/null 2>&1
check "非法协议退出码为 2" "$?" "2"
bash "$DEPLOY" --prefix "$SANDBOX" --protocol both >/dev/null 2>&1
check "both 是合法值" "$?" "0"
echo
echo "── 7b. 再跑一次(不给 --hostname),config.yml 不该被改 ──"
H3=$(md5sum "$CFD/config.yml" | awk '{print $1}')
@@ -468,13 +654,88 @@ fi
# ════════════════════════════════════════════════════════════════
echo
echo "── 10. 桩命令确实被调用过(证明流程真的走了)──"
for kw in "systemctl daemon-reload" "systemctl enable" "iptables" ; do
for kw in "systemctl enable" "iptables" ; do
if grep -q "$kw" "$STUB_LOG" 2>/dev/null; then
ok "调用过 $kw"
else
bad "没调用过 $kw"
fi
done
# daemon-reload 只在 unit 真变了的时候才该发生,
# 所以拿第一遍的日志来验(第一遍是全新安装,unit 肯定是新的)
if grep -q 'systemctl daemon-reload' "$STUB_LOG.run1" 2>/dev/null; then
ok "第一次安装做了 daemon-reload"
else
bad "第一次安装没做 daemon-reload"
fi
# ════════════════════════════════════════════════════════════════
echo
echo "── 11. 脚本里不许有写死的域名 ──"
# 这个中转是自用的。脚本里烧一个域名进去,别人一照抄就指到别人机器上了。
for f in "$DEPLOY" "$TUNE" "$REPO/relay/net-tune.sh"; do
base=$(basename "$f")
if grep -qE 'relay\.[a-z0-9-]+\.(top|com|net|org|cn)' "$f" 2>/dev/null; then
bad "$base 里有写死的真实域名: $(grep -oE 'relay\.[a-z0-9-]+\.(top|com|net|org|cn)' "$f" | head -1)"
else
ok "$base 里没有写死的域名"
fi
done
# 示例里的占位符必须是「你的域名」这种明显是占位的写法
if grep -q '你的域名' "$DEPLOY" 2>/dev/null; then
ok "示例用的是「你的域名」占位符"
else
bad "示例没看到占位符"
fi
# ════════════════════════════════════════════════════════════════
echo
echo "── 12. 有凭据但没给域名 → 不许乱猜,必须明确提示 ──"
CFD2="$TMP/nohost/root/.cloudflared"
SANDBOX2="$TMP/nohost"
mkdir -p "$CFD2"
echo '{"TunnelID":"deadbeef-0000-1111-2222-333344445555"}' \
> "$CFD2/deadbeef-0000-1111-2222-333344445555.json"
OUT7="$TMP/run7.log"
: > "$STUB_LOG"
# 这是台"新机器":中转在跑,隧道从没配过。
# 把桩的服务状态重置成这个样子 —— 否则前面几节把隧道标成 active 了,
# net-tune.sh 会以为它在跑然后去重启,断言就测不准。
printf 'lanremote-relay\n' > "$STUB_LOG.state"
bash "$DEPLOY" --prefix "$SANDBOX2" > "$OUT7" 2>&1
RC7=$?
check "没域名时退出码为 0(中转照装,只是隧道不启)" "$RC7" "0"
if [ -f "$CFD2/config.yml" ]; then
bad "没域名却凭空生成了 config.yml(内容是: $(head -3 "$CFD2/config.yml" | tr '\n' ' '))"
else
ok "没域名时没有生成 config.yml(没有乱猜)"
fi
if grep -q '需要你提供公网域名' "$OUT7" 2>/dev/null; then
ok "明确提示了「需要你提供公网域名」"
else
bad "提示不够明确"
fi
if grep -q -- '--hostname' "$OUT7" 2>/dev/null; then
ok "提示里给了怎么补域名"
else
bad "提示里没说怎么补"
fi
if grep -q 'TUNNEL_TOKEN' "$OUT7" 2>/dev/null; then
ok "提示里给了 Token 模式这个替代办法"
else
bad "提示里没提 Token 模式"
fi
if grep -q 'systemctl restart lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
bad "隧道没配好却还是启动了隧道服务"
else
ok "隧道没配好时没有去启动它"
fi
# 但中转本身要照常起来
if grep -q 'systemctl enable lanremote-relay' "$STUB_LOG" 2>/dev/null; then
ok "中转仍然正常安装并启用"
else
bad "中转没被启用"
fi
# ════════════════════════════════════════════════════════════════
echo