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

== 现象 ==

用户报:
  * 局域网能搜到电脑,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
+224 -51
View File
@@ -73,7 +73,9 @@ PREFIX="${PREFIX:-}" # 安装根,默认 /(测试时可
TOKEN="${TUNNEL_TOKEN:-}" # 隧道 Token,默认空
HOSTNAME_ARG="" # 域名,默认沿用已有 config.yml
TUNNEL_NAME="${TUNNEL_NAME:-lanremote}" # 具名隧道名
PROTOCOL="${PROTOCOL:-quic}" # quic 或 http2
# 隧道协议。默认 both = 两条隧道一起跑(实测最优,见下面的说明)。
# 传 quic 或 http2 才是"只跑那一种"。
PROTOCOL="${PROTOCOL:-both}"
SKIP_TUNNEL=0
DOWNLOADS_SRC="" # 可选:把发布产物拷进下载目录
PORT="${RELAY_PORT:-8080}"
@@ -89,11 +91,21 @@ ykxmz 服务器端 · 一键部署 v$VERSION
选项:
--token TOKEN 用隧道 Token 部署(等价于环境变量 TUNNEL_TOKEN)
--hostname NAME 公网域名,例如 relay.example.com
--hostname NAME 公网域名,例如 relay.你的域名
不填则沿用 /root/.cloudflared/config.yml 里已有的
**脚本没有任何默认域名** —— 猜不出来会明确提示你填
--tunnel-name NAME 具名隧道名字(默认 lanremote)
--protocol P 隧道协议:quic(默认)或 http2
quic 尾部延迟更好;http2 走 TCP、建连更稳
--protocol P quic / http2 / both(默认 both)
默认**两条隧道一起跑**,这是实测最优:
主 quic + 8 条 副本 http2 + 2 条
为什么两条一起跑 —— 运营商对 UDP 有限制
(12 个 edge IP:TCP 12/12 通、UDP 0/12 通),
所以 quic 拨号成功率只有约 72%;但连上之后
它的丢包恢复好于 http2。
两个一起 = http2 兜住 quic 拨号失败的那部分,
而且 8+2 的配比在 40 次/组对照里拿到了
100% 可用率 + 最低的 P90/P99。
传 quic 或 http2 就只跑那一种。
--port N 中转监听端口(默认 8080)
--downloads DIR 把 DIR 里的发布产物拷进下载目录(官网下载按钮用)
--skip-tunnel 只装中转,不碰隧道
@@ -103,12 +115,15 @@ ykxmz 服务器端 · 一键部署 v$VERSION
环境变量:
TUNNEL_TOKEN 和 --token 等价(推荐用这个,不留在 shell 历史里)
RELAY_PORT 和 --port 等价
QUIC_CONNS 主隧道(quic)连接条数,默认 8
H2_CONNS 副本隧道(http2)连接条数,默认 2
例子:
bash deploy.sh # 全自动
bash deploy.sh # 全自动(双隧道)
TUNNEL_TOKEN=eyJh... bash deploy.sh # 用 Token
bash deploy.sh --hostname relay.mydomain.com # 换域名
bash deploy.sh --hostname relay.你的域名 # 换域名
bash deploy.sh --skip-tunnel # 只装中转
bash deploy.sh --protocol http2 # 只跑 HTTP/2(UDP 被挡的环境)
做完之后:
浏览器打开 https://<你的域名>/ —— 就是官网
@@ -138,11 +153,60 @@ while [ $# -gt 0 ]; do
esac
done
if [ "$PROTOCOL" != "quic" ] && [ "$PROTOCOL" != "http2" ]; then
echo "协议只能是 quic 或 http2,收到的是: $PROTOCOL" >&2
if [ "$PROTOCOL" != "quic" ] && [ "$PROTOCOL" != "http2" ] \
&& [ "$PROTOCOL" != "both" ]; then
echo "协议只能是 quic / http2 / both,收到的是: $PROTOCOL" >&2
echo "(不传这个参数就是 both —— 两条隧道一起跑)" >&2
exit 2
fi
# ────────────────────────────────────────────────────────────────
# 两条隧道怎么分工(默认两条都跑)
# ────────────────────────────────────────────────────────────────
# 为什么**两条一起跑**才是最优(真机 40 次/组的受控对照,MTU 1400):
#
# quic 5 + h2 5 可用率 100% 中位 1490 P90 2346 P99 3295
# quic 3 + h2 7 可用率 97% 中位 1271 P90 2558 P99 13934
# quic 8 + h2 2 可用率 100% 中位 1250 P90 1715 P99 2632 <- 采用
#
# 根因也查清了:**运营商对 UDP 有限制**。
# 对 12 个 Cloudflare edge IP 探测:
# TCP:7844 12/12 可达
# UDP:7844 0/12 有回应
# 所以 QUIC 的拨号成功率只有约 72%;但只要拨上了,
# 它的丢包恢复明显好于 HTTP/2(QUIC 没有队头阻塞)。
#
# 于是:单独用 QUIC,会有近三成的时候连不上;
# 单独用 HTTP/2,队头阻塞在高丢包下会拖长尾延迟。
# 两个一起跑,HTTP/2 正好兜住 QUIC 拨号失败的那部分 ——
# 这就是**协议级故障隔离**,比在同一个协议上堆连接数有效得多。
#
# (早先这里写的是"两条同时跑会互相争抢",那个结论是错的,已作废。)
MAIN_PROTO=quic
MAIN_CONNS="${QUIC_CONNS:-8}"
ALT_PROTO=http2
ALT_CONNS="${H2_CONNS:-2}"
# metrics 端口必须**显式钉死**。
# cloudflared 默认都用 20241:正常启动顺序下看着像"主 20241 / 副 20242",
# 但实测**停掉主实例再重启副实例,副实例会抢到 20241**。
# 于是 watchdog 和所有按端口读指标的脚本全部读错实例,还不会有任何报错。
MAIN_METRICS="127.0.0.1:20241"
ALT_METRICS="127.0.0.1:20242"
# --protocol 用来强制只跑一种(默认 both)
MAIN_ENABLED=1
ALT_ENABLED=1
if [ "$PROTOCOL" = "quic" ]; then
# 只跑 QUIC(它拨号失败率约三成,只在明确知道 UDP 通的时候用)
MAIN_ENABLED=1
ALT_ENABLED=0
elif [ "$PROTOCOL" = "http2" ]; then
# 只跑 HTTP/2(UDP 被封的环境)
MAIN_ENABLED=0
ALT_ENABLED=1
fi
# ────────────────────────────────────────────────────────────────
# 路径:全部挂在 $PREFIX 下面
# 真实部署时 PREFIX 是空的,于是这些就是标准的绝对路径;
@@ -422,7 +486,7 @@ write_unit() {
}
# ---- 中转服务 ----
cat > "$UNIT_DIR/lanremote-relay.service" <<EOF
write_unit "$UNIT_DIR/lanremote-relay.service" <<EOF
[Unit]
Description=ykxmz 中转服务器
After=network-online.target
@@ -452,9 +516,10 @@ StandardError=append:$RELAY_DIR/relay.log
[Install]
WantedBy=multi-user.target
EOF
[ $? -eq 0 ] && RELAY_UNIT_CHANGED=1
# ---- 网络调优(开机先跑,排在 relay / tunnel 前面)----
cat > "$UNIT_DIR/lanremote-nettune.service" <<EOF
write_unit "$UNIT_DIR/lanremote-nettune.service" <<EOF
[Unit]
Description=ykxmz 网络调优(keepalive + MSS + MTU)
After=network-online.target
@@ -473,15 +538,16 @@ ExecStart=/bin/bash $RELAY_DIR/deploy-tune.sh
[Install]
WantedBy=multi-user.target
EOF
[ $? -eq 0 ] && UNITS_CHANGED=1
# ---- 隧道(主用:协议由 --protocol 决定)----
# 参数都是实测调出来的,改动前先看 deploy-tune.sh 顶部的说明:
# ---- 隧道(默认两条一起跑,见文件前面 MAIN_PROTO / ALT_PROTO 的说明)----
# 参数都是实测调出来的:
# --edge-ip-version 4 这条线路有 IPv6 地址但实际不通,
# 边缘默认优先 IPv6 会一直超时
# --ha-connections 10 连接多 = 单条掉线影响小(实测 10 条时
# P99 从 13.7 秒降到 2.99 秒)
# --ha-connections N 每个单元各自多少条连接(默认 quic 8 / h2 2)
# --retries 5 连边缘失败时多重试
TUNNEL_COMMON="--no-autoupdate --edge-ip-version 4 --ha-connections 10 --retries 5"
# --metrics ADDR:PORT **必须显式钉死**,见下面的注释
TUNNEL_BASE="--no-autoupdate --edge-ip-version 4 --retries 5"
if [ -n "$TOKEN" ]; then
# Token 模式:Token 存单独的 600 权限文件,不写进 unit
@@ -494,15 +560,22 @@ else
fi
write_tunnel_unit() {
local unit="$1" proto="$2"
local unit="$1" proto="$2" conns="$3" metrics="$4"
# ⚠ 文件名必须带 .service 后缀。
# systemd 是按「<名字>.service」找文件的,
# `systemctl enable lanremote-tunnel` 找的是 lanremote-tunnel.service。
# 少这个后缀的话文件是写下去了,但 systemctl 完全看不见它 ——
# 而且不会有任何报错(enable 会安静地失败),非常难查。实测踩过。
cat > "$UNIT_DIR/$unit.service" <<EOF
#
# ⚠ --metrics 必须显式写死端口:
# cloudflared 的默认 metrics 端口两个实例都是 20241。
# 正常启动顺序下看着像"主 20241 / 副 20242"(后启动的发现端口被占
# 就自己往后挪),但实测**停掉主实例再重启副实例时,
# 副实例会抢到 20241** —— 于是 watchdog、看连接数的脚本、
# 以及所有按端口读指标的排查动作全部读错实例,而且不报任何错。
write_unit "$UNIT_DIR/$unit.service" <<EOF
[Unit]
Description=ykxmz Cloudflare 隧道($proto)
Description=ykxmz Cloudflare 隧道($proto,$conns 条连接)
After=network-online.target lanremote-relay.service
Wants=network-online.target
Requires=lanremote-relay.service
@@ -510,7 +583,7 @@ Requires=lanremote-relay.service
[Service]
Type=simple
$ENV_LINE
ExecStart=$CF_BIN tunnel $TUNNEL_COMMON --protocol $proto $TUNNEL_ARGS
ExecStart=$CF_BIN tunnel $TUNNEL_BASE --protocol $proto --ha-connections $conns --metrics $metrics $TUNNEL_ARGS
Restart=always
RestartSec=5
MemoryMax=160M
@@ -522,19 +595,23 @@ WantedBy=multi-user.target
EOF
}
# 装两份:一份主用、一份 HTTP/2 备用。
# 不自动同时启用 —— 两条隧道同时跑会互相争抢,实测反而更不稳。
# 需要切换时:
# systemctl disable --now lanremote-tunnel
# systemctl enable --now lanremote-tunnel-h2
write_tunnel_unit lanremote-tunnel "$PROTOCOL"
ALT_PROTO="http2"
[ "$PROTOCOL" = "http2" ] && ALT_PROTO="quic"
write_tunnel_unit lanremote-tunnel-h2 "$ALT_PROTO"
# 两个单元都写出来(即使用户选了只跑一种 —— 留着方便随时切回来)
write_tunnel_unit lanremote-tunnel "$MAIN_PROTO" "$MAIN_CONNS" "$MAIN_METRICS"
[ $? -eq 0 ] && TUNNEL_UNIT_CHANGED=1
write_tunnel_unit lanremote-tunnel-h2 "$ALT_PROTO" "$ALT_CONNS" "$ALT_METRICS"
[ $? -eq 0 ] && TUNNEL_UNIT_CHANGED=1
say " 主隧道: $MAIN_PROTO + $MAIN_CONNS 条 metrics $MAIN_METRICS (启用: $MAIN_ENABLED)"
say " 副本隧道: $ALT_PROTO + $ALT_CONNS 条 metrics $ALT_METRICS (启用: $ALT_ENABLED)"
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
say " 已写入 4 个服务单元到 $UNIT_DIR"
# 只有 unit 真的变了才 daemon-reload ——
# 每次都 reload 会顺带触发 systemd 重新评估依赖,没必要。
if [ "$UNITS_CHANGED" = "1" ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
say " 已写入 4 个服务单元到 $UNIT_DIR(有变化,已 reload)"
else
say " 4 个服务单元内容没变,跳过 reload"
fi
else
say " [跳过] 没有 systemctl,服务单元已写好但没加载"
fi
@@ -546,6 +623,9 @@ step "6/7 配置隧道"
TUNNEL_OK=0
PUBLIC_URL=""
# config.yml 这次是不是真的被改写了 —— 隧道不吃旧配置,
# 改了就必须重启隧道,没改就别去动它。
CONFIG_CHANGED=0
if [ "$SKIP_TUNNEL" = "1" ]; then
say " --skip-tunnel:不配置隧道"
@@ -603,11 +683,32 @@ else
[ -z "$HOST" ] && HOST="$CUR_HOST"
if [ -z "$HOST" ]; then
warn "认不出公网域名(既没给 --hostname,config.yml 里也没有)"
warn "补上域名再跑一次:"
warn " bash $0 --hostname relay.你的域名"
warn "如果这个域名还没指向隧道,先执行:"
warn " $CF_BIN tunnel route dns $TUNNEL_NAME relay.你的域名"
# ⚠ 绝不猜域名、也绝不内置任何默认值。
# 这个中转是自用的,域名只有机器主人知道。
# 脚本里写死一个域名的话,别人照抄就指到你的机器上去了 ——
# 所以这里**明确停下来要输入**,而不是随便挑一个继续。
say
say " ┌────────────────────────────────────────────────────┐"
say " │ 需要你提供公网域名(脚本不会替你猜,也没有默认值) │"
say " └────────────────────────────────────────────────────┘"
say
say " 机器上有隧道凭据,但是:"
say " · 命令行没给 --hostname"
say " · 已有的 config.yml 里也没有 hostname 行"
say " 所以定不下来公网地址。"
say
say " 补上域名再跑一次:"
say " bash $0 --hostname relay.你的域名"
say
say " 如果这个域名还没指向隧道,先执行一次:"
say " $CF_BIN tunnel route dns $TUNNEL_NAME relay.你的域名"
say
say " 或者改用 Token 模式(域名信息在 Token 里,不用手填):"
say " TUNNEL_TOKEN=<Cloudflare 给的 Token> bash $0"
say
say " ── 本次结果 ──"
say " 中转已经装好并启动了 —— **局域网内现在就能用**。"
say " 只是跨网络(走公网)还差这一步域名。"
else
# config.yml 只在「不存在」或「用户明确给了 --hostname」时才重写,
# 免得把别人手工调过的配置冲掉。
@@ -632,6 +733,7 @@ ingress:
- service: http_status:404
EOF
chmod 600 "$CF_DIR/config.yml"
CONFIG_CHANGED=1
say " 已写入 $CF_DIR/config.yml(域名 $HOST)"
else
say " 沿用已有 $CF_DIR/config.yml(域名 $HOST)"
@@ -658,25 +760,85 @@ if command -v systemctl >/dev/null 2>&1; then
systemctl enable lanremote-nettune >/dev/null 2>&1 || true
systemctl start lanremote-nettune >/dev/null 2>&1 || true
say " 启动中转服务"
systemctl restart lanremote-relay >/dev/null 2>&1 || true
sleep 3
# ── 中转服务 ──
# ⚠ 只在「程序变了」「unit 变了」「本来就没在跑」这三种情况下才重启。
# 用户在**已经跑着的机器上**重跑本脚本是为了升级,
# 如果每次都不管三七二十一 restart,正在服务的连接就被白打断一次 ——
# 重启会清掉内存里的设备注册和会话,客户端要几秒才重新接上。
RELAY_NEED_RESTART=0
[ "$RELAY_CHANGED" = "1" ] && RELAY_NEED_RESTART=1
[ "$RELAY_UNIT_CHANGED" = "1" ] && RELAY_NEED_RESTART=1
systemctl is-active --quiet lanremote-relay 2>/dev/null \
|| RELAY_NEED_RESTART=1
if [ "$RELAY_NEED_RESTART" = "1" ]; then
if [ "$RELAY_CHANGED" = "1" ] || [ "$RELAY_UNIT_CHANGED" = "1" ]; then
say " 程序或配置有更新,重启中转服务"
else
say " 中转服务没在跑,启动它"
fi
systemctl restart lanremote-relay >/dev/null 2>&1 || true
sleep 3
else
say " 程序和配置都没变,中转服务保持运行(不打断在线连接)"
fi
say " lanremote-relay: $(systemctl is-active lanremote-relay 2>&1)"
if [ "$TUNNEL_OK" = "1" ]; then
say " 启动隧道服务"
systemctl enable lanremote-tunnel >/dev/null 2>&1 || true
# 备用隧道不自动启用 —— 两个同时跑会互相争抢
systemctl disable lanremote-tunnel-h2 >/dev/null 2>&1 || true
systemctl restart lanremote-tunnel >/dev/null 2>&1 || true
say " lanremote-tunnel: $(systemctl is-active lanremote-tunnel 2>&1)"
# ── 按开关启用/停用两个单元 ──
# 默认两个都启用(协议级故障隔离,见文件前面的实测对照)。
# 只有用户用 --protocol 明确指定单一协议时才关掉另一个。
if [ "$MAIN_ENABLED" = "1" ]; then
systemctl enable lanremote-tunnel >/dev/null 2>&1 || true
else
systemctl disable --now lanremote-tunnel >/dev/null 2>&1 || true
say " --protocol 指定只跑 $ALT_PROTO,已停用主隧道"
fi
if [ "$ALT_ENABLED" = "1" ]; then
systemctl enable lanremote-tunnel-h2 >/dev/null 2>&1 || true
else
systemctl disable --now lanremote-tunnel-h2 >/dev/null 2>&1 || true
say " --protocol 指定只跑 $MAIN_PROTO,已停用副本隧道"
fi
TUNNEL_NEED_RESTART=0
[ "$TUNNEL_UNIT_CHANGED" = "1" ] && TUNNEL_NEED_RESTART=1
# 新写的 config.yml 也算配置变更 —— 隧道不吃旧配置
[ "${CONFIG_CHANGED:-0}" = "1" ] && TUNNEL_NEED_RESTART=1
# 启用的那几个单元,只要有一个没在跑就得拉起来
for svc in lanremote-tunnel lanremote-tunnel-h2; do
case "$svc" in
lanremote-tunnel) [ "$MAIN_ENABLED" = "1" ] || continue ;;
lanremote-tunnel-h2) [ "$ALT_ENABLED" = "1" ] || continue ;;
esac
systemctl is-active --quiet "$svc" 2>/dev/null \
|| TUNNEL_NEED_RESTART=1
done
if [ "$TUNNEL_NEED_RESTART" = "1" ]; then
if [ "$TUNNEL_UNIT_CHANGED" = "1" ] \
|| [ "${CONFIG_CHANGED:-0}" = "1" ]; then
say " 隧道配置有更新,重启隧道服务"
else
say " 隧道服务没在跑,启动它"
fi
[ "$MAIN_ENABLED" = "1" ] \
&& systemctl restart lanremote-tunnel >/dev/null 2>&1
[ "$ALT_ENABLED" = "1" ] \
&& systemctl restart lanremote-tunnel-h2 >/dev/null 2>&1
else
say " 隧道配置没变,保持运行(不重建隧道连接)"
fi
[ "$MAIN_ENABLED" = "1" ] && say " lanremote-tunnel: $(systemctl is-active lanremote-tunnel 2>&1)"
[ "$ALT_ENABLED" = "1" ] && say " lanremote-tunnel-h2: $(systemctl is-active lanremote-tunnel-h2 2>&1)"
elif [ "$SKIP_TUNNEL" != "1" ]; then
say " 隧道没配好,先不启动"
fi
# 看门狗(如果包里有)
if [ -f "$RELAY_DIR/watchdog.sh" ]; then
cat > "$UNIT_DIR/lanremote-watchdog.service" <<EOF
write_unit "$UNIT_DIR/lanremote-watchdog.service" <<EOF
[Unit]
Description=ykxmz 隧道看门狗
After=lanremote-relay.service lanremote-tunnel.service
@@ -688,7 +850,7 @@ MemoryMax=64M
StandardOutput=append:$RELAY_DIR/watchdog.log
StandardError=append:$RELAY_DIR/watchdog.log
EOF
cat > "$UNIT_DIR/lanremote-watchdog.timer" <<'EOF'
write_unit "$UNIT_DIR/lanremote-watchdog.timer" <<'EOF'
[Unit]
Description=定期检查隧道
@@ -701,7 +863,10 @@ Unit=lanremote-watchdog.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload >/dev/null 2>&1 || true
# 看门狗的 unit 也可能刚被改过
if [ "$UNITS_CHANGED" = "1" ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
@@ -819,12 +984,20 @@ say " 常用命令"
say " lanremote-status 看整体状态"
say " lanremote-url 看公网地址"
say " systemctl status lanremote-relay 看中转服务"
say " systemctl status lanremote-tunnel 看隧道"
say " systemctl status lanremote-tunnel 看主隧道(quic)"
say " systemctl status lanremote-tunnel-h2 看副本隧道(http2)"
say " journalctl -u lanremote-relay -f 跟日志"
say
say " 换隧道协议(quic 慢一点但尾延迟好 / http2 建连更稳)"
say " systemctl disable --now lanremote-tunnel"
say " systemctl enable --now lanremote-tunnel-h2"
say " 两个隧道服务默认**都开着**(协议级故障隔离,实测最优配比 quic 8 + h2 2)"
say " 看各自的连接数和实时指标:"
say " curl -s http://127.0.0.1:20241/metrics | grep ha_connections # 主"
say " curl -s http://127.0.0.1:20242/metrics | grep ha_connections # 副本"
say " (两个实例的 metrics 端口是分开钉死的,不会互相抢)"
say
say " 只想跑一种(比如确认这台机器 UDP 被封):"
say " bash deploy.sh --protocol http2 # 只跑 HTTP/2"
say " bash deploy.sh --protocol quic # 只跑 QUIC"
say " bash deploy.sh # 不传就是两条一起跑"
say
say " 重跑本脚本 = 升级,已有配置和数据都不会动"
say