Files
ykxmz/relay/install.sh
T
xmz7891 6adf7acbfb 修「网站容易挂」的真因:nettune 和隧道之间的死锁
用户报「网站还是容易挂」。查到最后是个 systemd 顺序死锁 ——
而且**是我自己引入的**(换掉设备上那份过期 unit 之后才露出来)。

== 死锁链条 ==

    lanremote-nettune.service:
        Before=lanremote-tunnel.service        <- 我必须先于隧道启动完
        ExecStart=/bin/bash net-tune.sh

    net-tune.sh 第 4 步("让已经建立的隧道用上新参数"):
        systemctl restart lanremote-tunnel     <- 在这里永久阻塞

  nettune 在**自己还没结束**的时候去 restart 一个"必须等自己结束
  才能启动"的服务 —— systemd 判定顺序冲突,restart 一直不返回。

  证据(设备上抓的):
      CGroup: /system.slice/lanremote-nettune.service
              ├─13388 /bin/bash net-tune.sh
              └─13428 systemctl restart lanremote-tunnel
      那个 systemctl 的 etime 是 09:32 —— 卡了 9 分半。

  后果:nettune 一直 activating -> 隧道被 Before= 挡着起不来 ->
        公网 530。而 journalctl 里只看得到 nettune 停在 activating,
        没有任何报错,特别难查。

== 为什么以前没暴露 ==

  设备上原来那份 unit 的 ExecStart 里有个 iptables ... || ...,
  systemd 不支持 ||,所以它**在第 2 条 ExecStart 就报错退出**,
  根本走不到"重启隧道"那一步 —— 死锁被那个 bug 遮住了。
  我把 unit 换成正确形状之后,死锁才露出来。

  另一个迷惑点:**手动跑 net-tune.sh 完全正常**(输出"没有正在跑的
  隧道,跳过"),因为手动跑时不在服务上下文里、没有 Before= 约束。

== 改法 ==

  第 4 步的本意是"改完 MTU/MSS 后让已建立的隧道用上新参数",
  这个功能有用,但**只该在人工重跑时做** —— 开机时顺序本来就是
  nettune 先、隧道后,那时隧道还没起来。

  所以 unit 里加 Environment=NETTUNE_SERVICE_CALL=1,
  脚本看到就跳过第 4 步。三个地方都改了:
      relay/net-tune.sh    认这个开关
      relay/deploy.sh      生成的 unit 带上它
      relay/install.sh     同上(老机器走的安装路径)

  没选 systemctl restart --no-block:那只是把阻塞变异步,
  开机时仍然做一次无谓的重启(隧道刚起来就被停掉),
  也没解决"不该在开机时重启"这个语义问题。

== 另外 ==

  setup-monitor.sh / monitor.sh / rescue.sh 这一整套这次也真的
  在线上跑起来并验证过了:

      18:13:03  紧急预案启动:公网连续 3 次不通
      18:13:07  现场已记录 + [1/5] 重启隧道
      18:13:59  ✅ 第 1 级救回来了,公网已恢复
      18:13:59  已把「第 1 级奏效」记进知识库

  死锁这次就是被监测单元抓到的(fails=3 / last_code=530)。

全量回归 46 个阶段全部通过。
2026-10-08 18:56:44 +08:00

432 lines
15 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 局域网遥控 · 中转服务器 —— 安装脚本
#
# 在一台能上外网的 Linux 机器(这里是随身 WiFi / Debian 12)上跑:
# bash install.sh
#
# 做完这些:
# 1. 把 relay.py 装到 /root/lanremote-relay
# 2. 注册两个 systemd 服务,开机自启:
# lanremote-relay 中转服务
# lanremote-tunnel Cloudflare 隧道(公网入口)
# 3. 打印公网地址和管理页地址
#
# 面向 512MB 内存的小机器:两个进程加起来约 60MB。
set -eu
RELAY_DIR=/root/lanremote-relay
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
echo "============================================================"
echo " 局域网遥控 · 中转服务器 安装"
echo "============================================================"
echo
# ---------- 0. 环境检查 ----------
echo "[0/5] 检查环境"
if [ "$(id -u)" != "0" ]; then
echo " 需要 root 权限运行"
exit 1
fi
command -v python3 >/dev/null || { echo " 没有 python3"; exit 1; }
echo " python3 $(python3 -V 2>&1 | awk '{print $2}')"
if ! command -v cloudflared >/dev/null; then
echo " 没有 cloudflared,尝试安装…"
ARCH=$(uname -m)
case "$ARCH" in
aarch64) CF=cloudflared-linux-arm64 ;;
armv7l) CF=cloudflared-linux-arm ;;
x86_64) CF=cloudflared-linux-amd64 ;;
*) echo " 不认识的架构 $ARCH"; exit 1 ;;
esac
curl -fsSL --http1.1 -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/latest/download/$CF"
chmod +x /usr/local/bin/cloudflared
fi
echo " cloudflared $(cloudflared --version 2>&1 | head -1)"
echo " 内存: $(free -m | awk 'NR==2{print $2" MB 总量 / "$7" MB 可用"}')"
# ---------- 0b. 检查 Cloudflare API 有没有被 DNS 劫持 ----------
# 踩过的坑:这条 4G 线路把 api.cloudflare.com 解析到运营商设备
# (141.193.154.210,自签名证书)。cloudflared 下载登录证书必须走这个域名,
# 于是永远报 Failed to fetch resource,具名隧道根本建不起来。
echo
echo "[0b] 检查 Cloudflare API"
CF_HOSTS=/etc/hosts
CF_MARK="# LanRemote: 钉住 Cloudflare API(网络会 DNS 劫持)"
resolve_cf() {
getent ahostsv4 api.cloudflare.com 2>/dev/null | awk 'NR==1{print $1}'
}
is_real_cf() {
case "$1" in
104.16.*|104.17.*|104.18.*|104.19.*|104.20.*|104.21.*|172.64.*|172.66.*|172.67.*|162.159.*|188.114.*)
return 0 ;;
*) return 1 ;;
esac
}
CF_IP=$(resolve_cf)
echo " api.cloudflare.com 现在解析到: ${CF_IP:-(解析不出来)}"
CF_CODE=$(timeout 12 curl -4 -s -o /dev/null -w '%{http_code}' \
https://api.cloudflare.com/client/v4/user 2>/dev/null)
CF_CODE=${CF_CODE:-000}
echo " 实测 HTTP: ${CF_CODE}"
CF_BODY=$(timeout 12 curl -4 -s https://api.cloudflare.com/client/v4/user 2>/dev/null | head -c 200)
if is_real_cf "$CF_IP" && echo "$CF_BODY" | grep -q '"success"'; then
echo " ✓ 正常(返回的是真正的 API JSON)"
else
echo " ⚠ 检测到 Cloudflare API 被 DNS 劫持或不可达"
echo " 具名隧道需要访问它来下载证书,先修掉。"
cp -n "$CF_HOSTS" "${CF_HOSTS}.lanremote.bak" 2>/dev/null || true
# 清掉所有和本程序相关的 hosts 记录(含早期版本留下的不同文案)
sed -i '/LanRemote.*Cloudflare API/d' "$CF_HOSTS" 2>/dev/null || true
sed -i '/api\.cloudflare\.com/d' "$CF_HOSTS" 2>/dev/null || true
# 顺手删掉清理后可能剩下的空行
sed -i '/^[[:space:]]*$/d' "$CF_HOSTS" 2>/dev/null || true
echo "" >> "$CF_HOSTS"
{
echo ""
echo "$CF_MARK"
# 这几个是用公共 DNS(1.1.1.1 / 8.8.8.8 / 223.5.5.5)查出来的
# 真实地址。之前随手挑的 104.16.132.229 是错的 ——
# 它返回 Cloudflare 错误页(error code: 1034)而不是 API,
# 会导致 cloudflared tunnel create 报 unauthorized。
echo "104.19.192.174 api.cloudflare.com"
echo "104.19.192.175 api.cloudflare.com"
echo "104.19.192.176 api.cloudflare.com"
echo "104.19.192.177 api.cloudflare.com"
echo "104.17.0.1 api.cloudflare.com"
echo "104.18.0.1 api.cloudflare.com"
} >> "$CF_HOSTS"
sleep 1
CF_IP2=$(resolve_cf)
CF_CODE2=$(timeout 12 curl -4 -s -o /dev/null -w '%{http_code}' \
https://api.cloudflare.com/client/v4/user 2>/dev/null)
CF_CODE2=${CF_CODE2:-000}
echo " 修完解析到: ${CF_IP2:-?} HTTP: ${CF_CODE2}"
CF_BODY2=$(timeout 12 curl -4 -s https://api.cloudflare.com/client/v4/user 2>/dev/null | head -c 200)
if echo "$CF_BODY2" | grep -q '"success"'; then
echo " ✓ 修好了(返回的是真正的 API JSON)"
else
echo " ✗ 还是不通 —— 具名隧道可能建不起来,"
echo " 但匿名隧道仍然可用(它走的是另一条链路)"
fi
fi
# ---------- 1. 放文件 ----------
echo
echo "[1/5] 安装文件到 $RELAY_DIR"
mkdir -p "$RELAY_DIR/data"
# 脚本可能就在 RELAY_DIR 里(自己拷自己 cp 会报错),所以先比一下真实路径
SRC="$SCRIPT_DIR/relay.py"
DST="$RELAY_DIR/relay.py"
SAME=no
if [ -f "$SRC" ] && [ -f "$DST" ]; then
if [ "$(readlink -f "$SRC")" = "$(readlink -f "$DST")" ]; then
SAME=yes
fi
fi
if [ "$SAME" = "yes" ]; then
echo " relay.py 已在位(源和目标同一个文件)"
elif [ -f "$SRC" ]; then
cp -f "$SRC" "$DST"
echo " 已复制 relay.py"
elif [ -f "$DST" ]; then
echo " relay.py 已在位"
else
echo " 找不到 relay.py"
exit 1
fi
python3 -m py_compile "$RELAY_DIR/relay.py"
echo " 语法检查通过"
# ---------- 2. 停掉旧的 ----------
echo
echo "[2/5] 停掉旧的实例"
systemctl stop lanremote-tunnel lanremote-relay 2>/dev/null || true
pkill -f 'relay.py' 2>/dev/null || true
pkill -f 'cloudflared tunnel' 2>/dev/null || true
sleep 2
# ---------- 3. systemd ----------
echo
echo "[3/5] 注册开机自启服务"
cat > /etc/systemd/system/lanremote-relay.service <<EOF
[Unit]
Description=LanRemote 中转服务器
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$RELAY_DIR
Environment=RELAY_PORT=8080
Environment=RELAY_BIND=127.0.0.1
Environment=RELAY_DATA=$RELAY_DIR/data
Environment=RELAY_POLL=25
Environment=RELAY_QUEUE_BYTES=2097152
ExecStart=/usr/bin/python3 $RELAY_DIR/relay.py
Restart=always
RestartSec=3
# 小内存机器:限制住,别把系统拖死
MemoryMax=128M
CPUQuota=60%
StandardOutput=append:$RELAY_DIR/relay.log
StandardError=append:$RELAY_DIR/relay.log
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/lanremote-tunnel.service <<EOF
[Unit]
Description=LanRemote Cloudflare 隧道
After=network-online.target lanremote-relay.service
Wants=network-online.target
Requires=lanremote-relay.service
[Service]
Type=simple
# --- 下面这几个参数都是实测调出来的,不是拍脑袋 ---
#
# IPv4:这条线路**有 IPv6 地址但实际不通**,
# Cloudflare 边缘默认优先 IPv6,于是建隧道一直超时
# (日志里是 context deadline exceeded,对端 2606:4700:a8::10)。
# 所以必须 --edge-ip-version 4。
#
# 协议:用 HTTP/2(走 TCP 7844)。
# 实测对比(同一设备、各跑 90 秒):
# QUIC(UDP) 可用 48%,断开 16 次,拨号失败 16 次
# HTTP/2 可用 80%,断开 2 次,拨号失败 0 次
# 这条线路在丢 UDP 包 —— 日志里全是
# 'Failed to dial a quic connection: no recent network activity'
# 'Connection terminated: Failed to accept QUIC stream'
# TCP 有重传,慢一点但稳得多。
# 注意:早先在 192.168.2.x 的出口上 TCP 7844 是被挡的,
# 换了出口就通了 —— 换网络必须重测,别照搬旧结论。
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate \
--protocol http2 --edge-ip-version 4 --ha-connections 2 --retries 5 \
--url http://127.0.0.1:8080
Restart=always
RestartSec=5
MemoryMax=128M
StandardOutput=append:$RELAY_DIR/tunnel.log
StandardError=append:$RELAY_DIR/tunnel.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable lanremote-relay lanremote-tunnel >/dev/null 2>&1
systemctl start lanremote-relay
sleep 3
systemctl start lanremote-tunnel
echo " 两个服务已注册并启动"
# ---------- 3b. 装隧道看门狗 ----------
# 匿名隧道建立之后中途失效没人管,用户只能自己发现、自己上来重启。
# 看门狗每 5 分钟检查一次公网可达性,连续 3 次不通就重启隧道。
echo
echo "[3b] 装隧道看门狗"
if [ -f "$RELAY_DIR/watchdog.sh" ] || [ -f "$SCRIPT_DIR/watchdog.sh" ]; then
if [ -f "$SCRIPT_DIR/watchdog.sh" ] && \
[ "$(readlink -f "$SCRIPT_DIR/watchdog.sh")" != \
"$(readlink -f "$RELAY_DIR/watchdog.sh" 2>/dev/null || echo x)" ]; then
cp -f "$SCRIPT_DIR/watchdog.sh" "$RELAY_DIR/watchdog.sh"
fi
chmod +x "$RELAY_DIR/watchdog.sh"
cat > /etc/systemd/system/lanremote-watchdog.service <<'WDUNIT'
[Unit]
Description=LanRemote 隧道看门狗(检查公网可达性)
After=lanremote-relay.service lanremote-tunnel.service
[Service]
Type=oneshot
ExecStart=/bin/bash /root/lanremote-relay/watchdog.sh
MemoryMax=64M
StandardOutput=append:/root/lanremote-relay/watchdog.log
StandardError=append:/root/lanremote-relay/watchdog.log
WDUNIT
cat > /etc/systemd/system/lanremote-watchdog.timer <<'WDTIMER'
[Unit]
Description=每 5 分钟检查一次隧道
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
AccuracySec=30s
Unit=lanremote-watchdog.service
[Install]
WantedBy=timers.target
WDTIMER
systemctl daemon-reload
systemctl enable lanremote-watchdog.timer >/dev/null 2>&1
systemctl start lanremote-watchdog.timer
echo " 看门狗已启用(每 5 分钟检查一次)"
else
echo " [跳过] 没找到 watchdog.sh"
fi
# ---------- 4. 取地址 ----------
echo
echo
echo "[3c] 网络调优(修 MTU —— 这是可用率从 48% 提到 90% 的关键)"
# 实测:接口 MTU 1500 时,ping 包 1488 就丢、1500 全丢。
# TLS 握手要发大证书包 -> 被丢 -> 新建连接约 30% 失败。
# 夹 MSS 到 1360 + 打开路径 MTU 探测之后,可用率 48%~75% -> 90%~92%。
# 注意:**不要改接口 MTU**,实测降到 1400 反而掉到 50%。
if [ -f "$SCRIPT_DIR/net-tune.sh" ]; then
cp -f "$SCRIPT_DIR/net-tune.sh" "$RELAY_DIR/net-tune.sh"
elif [ -f "$RELAY_DIR/net-tune.sh" ]; then
: # 已经在位
fi
if [ -f "$RELAY_DIR/net-tune.sh" ]; then
chmod +x "$RELAY_DIR/net-tune.sh"
# 内核参数持久化
cat > /etc/sysctl.d/99-lanremote-mtu.conf <<'MTUEOF'
# LanRemote: 修路径 MTU 问题。
# 接口 MTU 1500 时大包会被丢(1488/1500 全丢),导致 TLS 握手约 30% 失败。
net.ipv4.tcp_mtu_probing = 1
MTUEOF
sysctl -p /etc/sysctl.d/99-lanremote-mtu.conf >/dev/null 2>&1
# 开机自动夹 MSS
cat > /etc/systemd/system/lanremote-nettune.service <<NUNIT
[Unit]
Description=LanRemote 网络调优(修 MTU:夹 MSS + 路径探测)
After=network-online.target
Wants=network-online.target
Before=lanremote-relay.service lanremote-tunnel.service
[Service]
Type=oneshot
RemainAfterExit=yes
# ⚠ NETTUNE_SERVICE_CALL=1 让 net-tune.sh 跳过"重启隧道"那一步。
# 本 unit 有 Before=lanremote-tunnel.service,脚本在自己还没结束时
# 去 restart 隧道会和 systemd 的顺序约束死锁 —— 实测卡过 9 分半,
# 表现是"网站挂了"而日志里什么都看不出来。
Environment=NETTUNE_SERVICE_CALL=1
ExecStart=$RELAY_DIR/net-tune.sh
[Install]
WantedBy=multi-user.target
NUNIT
systemctl daemon-reload
systemctl enable lanremote-nettune.service >/dev/null 2>&1
systemctl start lanremote-nettune.service
sleep 1
echo " tcp_mtu_probing = $(cat /proc/sys/net/ipv4/tcp_mtu_probing)"
echo " MSS 规则 $(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条"
echo " 已注册 lanremote-nettune.service(开机自动应用)"
else
echo " 跳过:找不到 net-tune.sh"
fi
echo "[4/5] 等隧道拿到公网地址"
URL=""
for i in $(seq 1 45); do
sleep 2
URL=$(grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' \
"$RELAY_DIR/tunnel.log" 2>/dev/null | tail -1)
[ -n "$URL" ] && break
done
if [ -z "$URL" ]; then
echo " 还没拿到地址,隧道日志:"
tail -15 "$RELAY_DIR/tunnel.log" 2>/dev/null || true
exit 1
fi
echo "$URL" > "$RELAY_DIR/public_url.txt"
# 便捷命令
cat > /usr/local/bin/lanremote-url <<'EOF'
#!/bin/bash
# 打印中转服务器的公网地址(隧道地址每次重启会变)
cat /root/lanremote-relay/public_url.txt 2>/dev/null || echo "(还没有地址)"
EOF
chmod +x /usr/local/bin/lanremote-url
cat > /usr/local/bin/lanremote-key <<'EOF'
#!/bin/bash
# 打印管理页钥匙
cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null || echo "(还没有钥匙)"
EOF
chmod +x /usr/local/bin/lanremote-key
cat > /usr/local/bin/lanremote-status <<'EOF'
#!/bin/bash
# 一眼看清中转 + 隧道的状态
echo "=== 服务 ==="
for s in lanremote-relay lanremote-tunnel lanremote-watchdog.timer; do
printf " %-28s %s\n" "$s" "$(systemctl is-active "$s" 2>/dev/null)"
done
echo
echo "=== 公网地址 ==="
cat /root/lanremote-relay/public_url.txt 2>/dev/null || echo " (还没有)"
echo
echo "=== 可达性 ==="
URL=$(cat /root/lanremote-relay/public_url.txt 2>/dev/null)
if [ -n "$URL" ]; then
C=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' "$URL/v1/health" 2>/dev/null)
echo " HTTP ${C:-超时}"
fi
echo
echo "=== 看门狗最近记录 ==="
tail -3 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /' || echo " (还没有)"
echo
echo "=== 内存 ==="
free -m | awk 'NR==2{printf " 已用 %s MB / 可用 %s MB\n", $3, $7}'
EOF
chmod +x /usr/local/bin/lanremote-status
# ---------- 5. 自检 ----------
echo
echo "[5/5] 自检"
sleep 3
if curl -s --max-time 8 http://127.0.0.1:8080/v1/health | grep -q '"ok"'; then
echo " 本机健康检查: 通过"
else
echo " 本机健康检查: 失败"
systemctl status lanremote-relay --no-pager -l | tail -10
fi
if curl -s --max-time 20 "$URL/v1/health" | grep -q '"ok"'; then
echo " 公网访问: 通过"
else
echo " 公网访问: 还没通(隧道可能要再等一会)"
fi
KEY=$(cat "$RELAY_DIR/data/admin_key.txt" 2>/dev/null || echo "")
echo
echo "============================================================"
echo " 装好了"
echo "============================================================"
echo
echo " 公网地址 $URL"
echo " 管理页 $URL/?key=$KEY"
echo
echo " 服务状态 systemctl status lanremote-relay"
echo " systemctl status lanremote-tunnel"
echo " 看地址 lanremote-url"
echo " 看钥匙 lanremote-key"
echo
echo " 注意:快速隧道的地址**每次重启会变**。"
echo " 想固定地址要用带域名的具名隧道,见 使用说明。"
echo
free -m | head -2
echo
ps -eo rss,comm --sort=-rss 2>/dev/null | head -4 | awk '{printf " %-8s %s\n", $1, $2}'