2.1.3:搬到 Lightsail 真服务器 + 支持"没有证书的中转"

用户买了 AWS Lightsail(2G 内存 / 2 vCPU / 60G SSD / 公网 IP
13.54.30.196),中转从随身 WiFi 那台小机器搬过去。

== 为什么这次搬迁解决了一堆老问题 ==

原机器(Debian aarch64、460MB、走 4G)一直有几件搞不定的:

  * 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**(443 通、7844 不通),
    隧道每 3~10 分钟掉一次、公网跟着 530 —— 查了很久才从
    cloudflared 自己的 precheck 里看清
  * 只有 460MB 内存
  * 会掉电硬挂(实测 2026-10-08 18:42 一次非正常关机)

新机器**有公网 IP**,中转直接监听,不经隧道、不过 7844。
实测公网延迟从 **1000~2000 ms 降到 1.1 ms**。

== 代码改动 ==

1. **relay.py 的 DATA_DIR 默认值不再写死**
   原来是 /root/lanremote-relay。新机器上服务以 admin 普通用户跑
   (它没理由要 root),而 /root 是 0700 —— 部署时直接
   PermissionError: '/root/lanremote-relay'。
   改成"本脚本所在目录下的 data/",搬到哪都跟着走。

2. **客户端支持"没有证书的中转"**
   新服务器是明文 HTTP(没签证书),而两个客户端原来都往 https 走:
     * 手表端:裸 IP 会被强制补 https:// -> 永远连不上,
       报的还是 SSL 错误,很容易让人白查半天证书
     * Android 9+ 默认禁止明文流量,没 usesCleartextTraffic
       请求会被系统直接掐掉
   Windows 端本来就是对的(正则里把任意纯 IP 都当"用明文")。
   现在手表端规则和它一致,并加了 usesCleartextTraffic。

3. 清掉电脑端一段函数内的重复代码(return 之后还有一份拷贝,
   缩进仍在函数体内所以不报错,但读起来很迷惑)。

== 新服务器上的部署 ==

    /opt/ykxmz-relay/            服务端(relay/admin/landing/showcase)
    ykxmz-relay.service          监听 0.0.0.0:13245
    ykxmz-monitor.service/.timer 每 60 秒查公网,连续 3 次不通就叫预案
    rescue-lightsail.sh          四级阶梯抢救(重启中转 / 强杀重拉 /
                                 查防火墙 / 重启整机[默认关])

  预案针对新架构重写过:原来围着隧道转(重启隧道、重施 MSS/MTU),
  现在没有隧道,改成重启中转 + 查端口/防火墙。

全量回归 46 个阶段全部通过。
This commit is contained in:
xmz7891
2026-10-08 22:29:56 +08:00
parent bc289c5c21
commit 5fc2f97be7
24 changed files with 1349 additions and 46 deletions
+285
View File
@@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
r'''在 Lightsail 上装好「监测单元 + 紧急预案」。
== 部署什么 ==
/opt/ykxmz-relay/monitor.sh 每 60 秒查一次公网
/opt/ykxmz-relay/rescue-lightsail.sh 四级阶梯抢救
/etc/systemd/system/ykxmz-monitor.service
/etc/systemd/system/ykxmz-monitor.timer
== 和随身 WiFi 那版的区别 ==
那版围着 Cloudflare 隧道转:查隧道连接数、重启隧道、重施 MSS/MTU。
新机器**有公网 IP、没有隧道**,所以:
* 健康检查超时从 12 秒降到 8 秒(直连,不需要等隧道)
* 预案第 1 级改成"重启中转服务"(不再有隧道可重启)
* 去掉 MTU/MSS 那两级(那是 4G 线路的问题)
== 为什么还是"每 60 秒 + 连续 3 次才动手" ==
直连比隧道稳得多,但**公网抖动**依然存在(上游线路、Lightsail 边缘)。
一次失败就救会天天瞎折腾,所以保留防抖。
现在预期是**基本不会触发** —— 触发了就说明真出事了。
'''
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run, upload # noqa: E402
RELAY_DIR = "/opt/ykxmz-relay"
PORT = "13245"
PUBLIC_URL = "http://13.54.30.196:%s" % PORT
MONITOR = r'''#!/bin/bash
# ============================================================================
# 网站监测单元(Lightsail 版)
# ============================================================================
#
# 每 60 秒从**公网**查一次,连续 3 次不通就叫紧急预案。
#
# 为什么不查 127.0.0.1:
# 本机 200 只说明进程活着。用户真正关心的是"打开
# http://13.54.30.196:13245 能不能出页面" —— 那要经过
# Lightsail 的防火墙 / 安全组 / 上游线路。实测踩过:
# 隧道两条实例都掉了,本机 curl 照样 200,但公网 502。
#
# 为什么用 timer 不用常驻进程:
# 崩了下一分钟自己就回来,不会有"跑久了状态变脏"的问题。
# ============================================================================
set -u
RELAY_DIR="${RELAY_DIR:-/opt/ykxmz-relay}"
PUBLIC_URL="${PUBLIC_URL:-__PUBLIC_URL__}"
FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}"
COOLDOWN="${COOLDOWN:-600}"
CHECK_TIMEOUT="${CHECK_TIMEOUT:-8}"
STATE="$RELAY_DIR/monitor_state.txt"
LOG="$RELAY_DIR/monitor.log"
LOCKFILE="/tmp/ykxmz-monitor.lock"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
exit 0
fi
trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
FAILS=0
LAST_RESCUE=0
if [ -f "$STATE" ]; then
FAILS=$(grep -m1 '^fails=' "$STATE" 2>/dev/null | cut -d= -f2)
LAST_RESCUE=$(grep -m1 '^last_rescue=' "$STATE" 2>/dev/null | cut -d= -f2)
FAILS=${FAILS:-0}
LAST_RESCUE=${LAST_RESCUE:-0}
fi
NOW=$(date +%s)
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CHECK_TIMEOUT" \
"$PUBLIC_URL/v1/health" 2>/dev/null)
DUR=$(curl -s -o /dev/null -w '%{time_total}' --max-time "$CHECK_TIMEOUT" \
"$PUBLIC_URL/v1/health" 2>/dev/null)
DUR=${DUR:-0}
write_state() {
{
echo "fails=$1"
echo "last_rescue=$2"
echo "last_code=$CODE"
echo "last_check=$NOW"
echo "last_dur=$DUR"
} > "$STATE"
}
if [ "$CODE" = "200" ]; then
if [ "$FAILS" != "0" ]; then
log "公网恢复(HTTP 200,${DUR}s)—— 之前连续失败 $FAILS 次"
fi
PREV=$(grep -m1 '^last_code=' "$STATE" 2>/dev/null | cut -d= -f2)
[ "$PREV" != "200" ] && log "公网正常(HTTP 200,${DUR}s)"
write_state 0 "$LAST_RESCUE"
exit 0
fi
FAILS=$((FAILS + 1))
log "公网异常 第 $FAILS 次(HTTP ${CODE:-无响应},${DUR}s)"
write_state "$FAILS" "$LAST_RESCUE"
if [ "$FAILS" -lt "$FAIL_THRESHOLD" ]; then
exit 0
fi
SINCE=$((NOW - LAST_RESCUE))
if [ "$LAST_RESCUE" != "0" ] && [ "$SINCE" -lt "$COOLDOWN" ]; then
log " 已连续失败 $FAILS 次,但距上次抢救只过了 ${SINCE}s(冷却 ${COOLDOWN}s)"
exit 0
fi
HINT=""
if [ -f "$RELAY_DIR/rescue_knowledge.md" ]; then
HINT=$(grep -oE '第 [0-9] 级奏效' "$RELAY_DIR/rescue_knowledge.md" 2>/dev/null \
| sort | uniq -c | sort -rn | head -1 | grep -oE '[0-9]')
fi
log "连续失败 $FAILS 次,启动紧急预案${HINT:+(知识库提示:第 $HINT 级常奏效)}"
write_state "$FAILS" "$NOW"
timeout 300 bash "$RELAY_DIR/rescue-lightsail.sh" \
"监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1
RC=$?
if [ "$RC" = "0" ]; then
log " 预案完成:已恢复"
else
log " 预案完成:仍未恢复(退出码 $RC),报告在 rescue_reports/"
fi
exit 0
'''
SERVICE = '''[Unit]
# ykxmz 网站监测单元(Lightsail 版)
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target ykxmz-relay.service
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=360
# 用普通用户跑 —— 它只读状态、调 systemctl restart,不需要 root。
# (systemctl restart 那条走的是 polkit;admin 在 Lightsail 上有 sudo,
# 所以脚本里用的是不带 sudo 的 systemctl —— 见下面的说明。)
User=admin
Group=admin
Environment=RELAY_PORT=__PORT__
Environment=PUBLIC_URL=__PUBLIC_URL__
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
ExecStart=/bin/bash __DIR__/monitor.sh
StandardOutput=append:__DIR__/monitor.log
StandardError=append:__DIR__/monitor.log
[Install]
WantedBy=multi-user.target
'''
TIMER = '''[Unit]
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=ykxmz-monitor.service
[Install]
WantedBy=timers.target
'''
def main() -> int:
print("=" * 62)
print(" 在 Lightsail 上装监测单元 + 紧急预案")
print("=" * 62)
monitor = (MONITOR.replace("__PUBLIC_URL__", PUBLIC_URL))
tmp = Path(tempfile.gettempdir())
print()
print(" ── 1. 传脚本 ──")
mp = tmp / "monitor.sh"
mp.write_text(monitor, encoding="utf-8", newline="\n")
upload(str(mp), "%s/monitor.sh" % RELAY_DIR, mode=0o755)
rp = ROOT / "relay" / "rescue-lightsail.sh"
if rp.is_file():
upload(str(rp), "%s/rescue-lightsail.sh" % RELAY_DIR, mode=0o755)
else:
print(" ✗ 找不到 relay/rescue-lightsail.sh")
return 1
print()
print(" ── 2. 传 systemd 单元 ──")
sp = tmp / "ykxmz-monitor.service"
sp.write_text(SERVICE.replace("__PORT__", PORT)
.replace("__PUBLIC_URL__", PUBLIC_URL)
.replace("__DIR__", RELAY_DIR),
encoding="utf-8", newline="\n")
upload(str(sp), "/tmp/ykxmz-monitor.service")
tp = tmp / "ykxmz-monitor.timer"
tp.write_text(TIMER, encoding="utf-8", newline="\n")
upload(str(tp), "/tmp/ykxmz-monitor.timer")
print()
print(" ── 3. 装单元 + 建目录 ──")
out = run(
"sudo mv /tmp/ykxmz-monitor.service /tmp/ykxmz-monitor.timer "
"/etc/systemd/system/ && "
"mkdir -p %s/rescue_reports && chmod +x %s/*.sh && "
"if [ ! -f %s/rescue_knowledge.md ]; then "
"printf '# 紧急预案知识库\\n\\n每次自动抢救成功会记一条「第几级奏效」。"
"\\n下次同类症状优先试历史上奏效的那一级。\\n' > %s/rescue_knowledge.md; "
"fi && sudo systemctl daemon-reload && echo OK"
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 4. 启用定时器 ──")
out = run("sudo systemctl enable --now ykxmz-monitor.timer 2>&1 | tail -2; "
"sleep 2; systemctl is-active ykxmz-monitor.timer",
timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 5. 手动跑一次监测(验证它能工作)──")
out = run("bash %s/monitor.sh; echo '--- 状态 ---'; "
"cat %s/monitor_state.txt 2>/dev/null; "
"echo '--- 日志 ---'; tail -3 %s/monitor.log 2>/dev/null"
% (RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 6. 演练预案(网站正常时应该什么都不做)──")
out = run("bash %s/rescue-lightsail.sh '安装验证(网站正常)' 2>&1 | tail -6; "
"echo '--- 报告数(应该是 0)---'; "
"ls %s/rescue_reports 2>/dev/null | wc -l"
% (RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 7. 总览 ──")
out = run(
"printf ' %-26s %s\\n' 'ykxmz-relay' \"$(systemctl is-active ykxmz-relay)\"; "
"printf ' %-26s %s\\n' 'ykxmz-monitor.timer' \"$(systemctl is-active ykxmz-monitor.timer)\"; "
"printf ' %-26s %s\\n' '本机 health' \"$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:%s/v1/health)\"; "
"printf ' %-26s %s\\n' '公网 health' \"$(curl -s -o /dev/null -w '%%{http_code}' %s/v1/health)\""
% (PORT, PUBLIC_URL), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" 说明:预案里用 systemctl 重启服务,而 monitor.service 以 admin")
print(" 身份跑 —— admin 在 Lightsail 上有免密 sudo,但脚本里没加")
print(" sudo。如果实测发现重启失败,把它改成 sudo systemctl 即可。")
return 0
if __name__ == "__main__":
sys.exit(main())