diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md
index 1f7d9d3..0a8e702 100644
--- a/_build/CHANGELOG.md
+++ b/_build/CHANGELOG.md
@@ -1,3 +1,27 @@
+## 2.1.3
+
+- **支持「自己搭的、没有证书的中转」**(新服务器就是这种)。
+
+ 中转不再是必须挂在 Cloudflare 隧道后面了。现在可以直接跑在
+ 一台有公网 IP 的机器上,客户端填 `IP:端口` 就能连 —— 延迟、稳定性
+ 都比隧道好得多。为此改了两处客户端:
+
+ - **手表端**:填 `13.54.30.196:13245` 这种**没写协议头**的地址时,
+ 现在会按「IP / localhost / 本地域名 → 明文,域名 → 加密」来猜,
+ **和电脑端规则一致**。原来一律补成 `https://`,而那台机器没有证书,
+ 结果是**永远连不上**,报的还是 SSL 错误,很容易让人白查半天证书。
+
+ - **手表端**:允许明文流量。Android 9 以上默认禁止,不加这一条的话
+ 请求会被系统直接掐掉,日志里只有一句含糊的
+ 「Cleartext HTTP traffic to ... not permitted」。
+
+ > 说明:中转上跑的内容(配对令牌、画面、文件)本来就是加密过的,
+ > 明文只暴露地址和设备名。想要全程加密的话,把域名解析到这台
+ > 服务器再签一张免费证书即可。
+
+- 顺手清掉了电脑端一段函数内的重复代码(`return` 之后还有一份一样的
+ 拷贝,缩进仍在函数体内所以不报错,但读起来很迷惑)。
+
## 2.1.2
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
diff --git a/_build/_cmsg_213.txt b/_build/_cmsg_213.txt
new file mode 100644
index 0000000..b976d53
--- /dev/null
+++ b/_build/_cmsg_213.txt
@@ -0,0 +1,50 @@
+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 个阶段全部通过。
diff --git a/_build/allow_plain_http.py b/_build/allow_plain_http.py
new file mode 100644
index 0000000..15cf093
--- /dev/null
+++ b/_build/allow_plain_http.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+r'''让客户端能用"纯 IP + 明文 HTTP"的中转(新服务器就是这种)。
+
+== 背景 ==
+
+2026-10-08 换了服务器:AWS Lightsail,**有公网 IP**(13.54.30.196),
+中转直接监听 13245,**不再走 Cloudflare 隧道**。
+
+好处一大堆(延迟从 1000ms 降到 1ms、不再有 7844 阻断、内存 4 倍),
+但有个直接后果:**现在是明文 HTTP,没有 HTTPS 证书**。
+
+而两个客户端原来都默认往 https 上走:
+
+ 1. Android 的 RelayTransport:用户填 `13.54.30.196:13245`
+ (没写协议头)会被强制加上 `https://` -> 连不上(那台机器没证书)
+
+ 2. Android 9+ **默认禁止明文 HTTP**。没有
+ `android:usesCleartextTraffic="true"` 的话,请求会被系统直接掐掉,
+ 报的还是很含糊的 "Cleartext HTTP traffic not permitted"。
+
+Windows 那边已经是对的:relay.py 的 _PRIVATE_HOST 正则里
+`\d{1,3}(?:\.\d{1,3}){3}` 把**任意纯 IP(含公网)**都当"用明文",
+所以裸 IP 会正确走 http://。
+
+== 这个补丁做什么 ==
+
+ 1. RelayTransport 的协议猜测改成和 Windows 一致:
+ 纯 IP / localhost / *.local -> http://
+ 域名 -> https://
+
+ 2. AndroidManifest 加 usesCleartextTraffic="true"
+
+ 3. 顺手清掉 windows/lanremote/relay.py 里一段**函数内死代码**
+ (_guess_scheme 的 return 之后还有一份一模一样的拷贝,
+ 缩进仍属于函数体所以不报错,但看着很迷惑)
+
+== 关于 HTTPS ==
+
+明文 HTTP 意味着**同网络的人能看到流量**(虽然配对令牌、内容本来
+就是加密的,但 URL、设备名这些会露)。真正的解法是上 HTTPS:
+
+ 把域名 relay.xxmxxs.top 的 A 记录指到 13.54.30.196,
+ 然后 certbot 签一张 Let's Encrypt 证书,confd 挂上。
+
+那一步要用户动 DNS,所以先让明文能用(这个补丁),
+HTTPS 等 DNS 指过来再做。
+'''
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+JAVA = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
+ / "RelayTransport.java")
+MANIFEST = ROOT / "android" / "AndroidManifest.xml"
+WINRELAY = ROOT / "windows" / "lanremote" / "relay.py"
+
+
+def patch(path: Path, old: str, new: str, what: str, out: list) -> str:
+ raw = path.read_bytes()
+ bom = raw.startswith(b"\xef\xbb\xbf")
+ text = raw.decode("utf-8-sig")
+ if old not in text:
+ out.append(("✗", what, "找不到锚点"))
+ return text
+ if text.count(old) != 1:
+ out.append(("!", what, "锚点 %d 处" % text.count(old)))
+ text = text.replace(old, new, 1)
+ path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ + text.encode("utf-8"))
+ out.append(("✓", what, ""))
+ return text
+
+
+# ---------------------------------------------------------------- 1. Java
+JAVA_OLD = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ url = "https://" + url;
+ }'''
+
+JAVA_NEW = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ // 没写协议头时猜一个。规则要**和 Windows 端一致**
+ // (windows/lanremote/relay.py 的 _guess_scheme):
+ //
+ // IP / localhost / *.local -> http://
+ // 域名 -> https://
+ //
+ // 为什么纯 IP 走明文:IP 地址的中转基本都是自己搭的
+ // (局域网那台,或者像现在这样一台有公网 IP 的服务器),
+ // 上面往往没有证书。原来一律加 https:// 的结果是**永远连不上**,
+ // 而且报的是 SSL 错误,会把人带去查证书查半天。
+ //
+ // ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
+ // 之后中转就是 http://13.54.30.196:13245,
+ // 只把 192.168/10./172.16 当私有的话这里会走错。
+ String hostOnly = url;
+ int slash = hostOnly.indexOf('/');
+ if (slash > 0) {
+ hostOnly = hostOnly.substring(0, slash);
+ }
+ int colon = hostOnly.indexOf(':');
+ if (colon > 0) {
+ hostOnly = hostOnly.substring(0, colon);
+ }
+ boolean plain = hostOnly.equalsIgnoreCase("localhost")
+ || hostOnly.toLowerCase().endsWith(".local")
+ || hostOnly.matches("\\\\d{1,3}(\\\\.\\\\d{1,3}){3}");
+ url = (plain ? "http://" : "https://") + url;
+ }'''
+
+# ---------------------------------------------------------------- 2. 清单
+MAN_OLD = ''' '''
+
+MAN_NEW = '''
+
+
+ '''
+
+# ---------------------------------------------------------------- 3. 死代码
+DEAD_OLD = ''' host = address.split("/", 1)[0]
+ return "http://" if _PRIVATE_HOST.match(host) else "https://"
+
+
+
+ """用户没写 http/https 时,猜一个。
+
+ IP 地址 / localhost -> http://
+ 局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
+ 而且报的是 SSL 错误,很容易误导人去查证书。
+ 域名 -> https://
+ 走公网必须加密(Cloudflare 隧道本来就是 https)。
+ """
+ host = address.split("/", 1)[0]
+ return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
+
+DEAD_NEW = ''' host = address.split("/", 1)[0]
+ return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
+
+
+def main() -> int:
+ out: list = []
+
+ patch(JAVA, JAVA_OLD, JAVA_NEW, "RelayTransport 协议猜测改成和 Windows 一致",
+ out)
+
+ patch(MANIFEST, MAN_OLD, MAN_NEW, "AndroidManifest 允许明文 HTTP", out)
+
+ patch(WINRELAY, DEAD_OLD, DEAD_NEW, "清掉 relay.py 里的函数内死代码", out)
+
+ print(" ── 结果 ──")
+ for m, what, why in out:
+ print(" %s %s%s" % (m, what, (" —— " + why) if why else ""))
+ return 0 if not any(m == "✗" for m, _w, _y in out) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/check_lightsail.py b/_build/check_lightsail.py
new file mode 100644
index 0000000..1e24498
--- /dev/null
+++ b/_build/check_lightsail.py
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+r'''看一眼 Lightsail 上中转服务器的完整状态。
+
+为什么写成文件:用 `python -c @"..."@` 传带 `$(...)` 和引号的远端命令,
+PowerShell 会做变量插值、还会在本机执行 `$(systemctl ...)`
+(报 "无法将 systemctl 项识别为 cmdlet")。凡是超过两行的远端命令,
+一律写成文件跑。
+
+用法:
+ python _build/check_lightsail.py
+ python _build/check_lightsail.py --fix # 顺便修文件属主
+'''
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "_build"))
+from lssh import run # noqa: E402
+
+PORT = "13245"
+PUBLIC = "http://13.54.30.196:%s" % PORT
+RELAY_DIR = "/opt/ykxmz-relay"
+
+REMOTE = r'''
+R=__DIR__
+P=__PORT__
+echo "════ 服务 ════"
+for s in ykxmz-relay ykxmz-monitor.timer; do
+ printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
+done
+
+echo
+echo "════ 健康 ════"
+printf ' %-24s %s\n' "本机 health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:$P/v1/health)"
+printf ' %-24s %s\n' "公网 health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 __PUBLIC__/v1/health)"
+printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 __PUBLIC__/)"
+printf ' %-24s %s\n' "本机延迟" "$(curl -s -o /dev/null -w '%{time_total}s' --max-time 8 http://127.0.0.1:$P/v1/health)"
+printf ' %-24s %s\n' "公网延迟" "$(curl -s -o /dev/null -w '%{time_total}s' --max-time 15 __PUBLIC__/v1/health)"
+
+echo
+echo "════ 首页角标 ════"
+echo " 最新版: $(curl -s --max-time 10 http://127.0.0.1:$P/ | grep -o '最新版 [0-9.]*' | head -1)"
+
+echo
+echo "════ 监听 / 进程 ════"
+ss -tlnp 2>/dev/null | grep ":$P " | sed 's/^/ /'
+ps -eo pid,rss,etime,cmd --sort=-rss 2>/dev/null | grep 'relay\.py' | grep -v grep | head -2 | sed 's/^/ /'
+
+echo
+echo "════ 监测单元 ════"
+cat $R/monitor_state.txt 2>/dev/null | sed 's/^/ /'
+echo " --- monitor.log 尾部 ---"
+tail -4 $R/monitor.log 2>/dev/null | sed 's/^/ /'
+
+echo
+echo "════ 抢救报告 ════"
+echo " 共 $(ls $R/rescue_reports 2>/dev/null | wc -l) 份"
+ls -la $R/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{print " " $6" "$7" "$8" "$9}'
+
+echo
+echo "════ 知识库 ════"
+if [ -f $R/rescue_knowledge.md ]; then
+ echo " $(wc -l < $R/rescue_knowledge.md) 行"
+else
+ echo " (还没有)"
+fi
+
+echo
+echo "════ 资源 ════"
+free -m 2>/dev/null | sed -n 2p | awk '{printf " 内存 %s/%s MB\n", $3, $2}'
+df -h / | tail -1 | awk '{printf " 磁盘 %s 用 %s\n", $2, $5}'
+uptime | sed 's/^/ /'
+
+echo
+echo "════ 定时器下次触发 ════"
+systemctl list-timers ykxmz-monitor.timer --no-pager 2>/dev/null | head -3 | sed 's/^/ /'
+
+echo
+echo "════ 文件属主(admin 要能写日志)════"
+ls -la $R/*.log $R/monitor_state.txt 2>/dev/null | awk '{print " " $3":"$4" "$9}'
+'''
+
+
+def main() -> int:
+ if "--fix" in sys.argv:
+ print(" ── 修属主 ──")
+ out = run("sudo chown -R admin:admin %s && sudo chmod 755 %s && echo OK"
+ % (RELAY_DIR, RELAY_DIR), timeout=120)
+ print(" " + out[1].strip())
+
+ cmd = (REMOTE.replace("__DIR__", RELAY_DIR)
+ .replace("__PORT__", PORT)
+ .replace("__PUBLIC__", PUBLIC))
+ out = run(cmd, timeout=300, quiet=True)
+ print(out[1] if isinstance(out, tuple) else out)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/deploy_lightsail.py b/_build/deploy_lightsail.py
new file mode 100644
index 0000000..ec7da5d
--- /dev/null
+++ b/_build/deploy_lightsail.py
@@ -0,0 +1,175 @@
+# -*- coding: utf-8 -*-
+r'''把中转服务器部署到 AWS Lightsail 那台新机器上。
+
+== 为什么要搬 ==
+
+原来中转跑在一台"随身 WiFi"小机器上(Debian aarch64、460MB 内存、
+走 4G),一直有一堆搞不定的问题:
+
+ * 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**
+ —— 443 通、7844 不通,隧道每 3~10 分钟就掉一次,公网跟着 530
+ (这个查了很久,cloudflared 自己的 precheck 才说清楚)
+ * 只有 460MB 内存,跑什么都得省着来
+ * 会掉电/硬挂(实测 18:42 一次非正常关机,19:58 才起来)
+ * 全靠 Cloudflare 隧道兜,隧道一抖网站就抖
+
+新机器(Lightsail)**有公网 IP**,直接监听就行 ——
+不需要隧道、不经过 Cloudflare 的 7844、内存是 4 倍、磁盘 59G。
+上面那些问题**一次性全部消失**。
+
+== 关键配置 ==
+
+ RELAY_PORT=13245 用户指定(防火墙那边他会开)
+ RELAY_BIND=0.0.0.0 有公网 IP,直接对外
+
+ 13245 大于 1024,所以**不需要** CAP_NET_BIND_SERVICE,
+ 用普通用户跑就行 —— 比听 80 干净。
+
+== 为什么不用 root 跑 ==
+
+relay 是个只读文件的 HTTP 服务,没有任何理由给它 root。
+用 admin 用户跑,万一被打穿,损失也小得多。
+(2026-10-08 之前那个仓库里还硬编码过管理员密码,
+ 这种教训够一次就行了。)
+'''
+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
+
+# 用户指定的端口
+PORT = "13245"
+REMOTE_DIR = "/opt/ykxmz-relay"
+DATA_DIR = REMOTE_DIR + "/data" # relay.py 现在默认就用 data/ 了
+
+# 服务端要传的文件(.py 是服务本体,.ico 是管理页图标)
+FILES = ["relay.py", "admin.py", "landing.py", "showcase.py", "favicon.ico"]
+
+UNIT = '''[Unit]
+Description=ykxmz 中转服务器
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=admin
+Group=admin
+WorkingDirectory=%(dir)s
+
+# ⚠ 监听地址和端口都从这里来。
+# 13245 是用户指定的(他会在 Lightsail 防火墙那边开)。
+# BIND 用 0.0.0.0 是因为新机器**有公网 IP**,不再需要
+# Cloudflare 隧道(原来那个隧道在这条线路上每 3~10 分钟掉一次,
+# 是"网站容易挂"的主因之一)。
+Environment=RELAY_PORT=%(port)s
+Environment=RELAY_BIND=0.0.0.0
+
+ExecStart=/usr/bin/python3 %(dir)s/relay.py
+Restart=always
+RestartSec=3
+
+# 兜底:内存保护(这台有 2G,给 512M 足够且不会拖垮整机)
+MemoryMax=512M
+# 别让它写爆磁盘
+LimitNOFILE=4096
+
+StandardOutput=append:%(dir)s/relay.log
+StandardError=append:%(dir)s/relay.log
+
+[Install]
+WantedBy=multi-user.target
+'''
+
+
+def main() -> int:
+ print("=" * 62)
+ print(" 部署到 Lightsail(13.54.30.196),端口 %s" % PORT)
+ print("=" * 62)
+
+ print()
+ print(" ── 1. 建目录 ──")
+ out = run("sudo mkdir -p %s %s && sudo chown -R admin:admin %s && "
+ "echo OK" % (REMOTE_DIR, DATA_DIR, REMOTE_DIR),
+ timeout=120)
+ for ln in out[1].strip().splitlines():
+ print(" " + ln)
+
+ print()
+ print(" ── 2. 传服务端文件 ──")
+ for name in FILES:
+ local = ROOT / "relay" / name
+ if not local.is_file():
+ print(" [跳过] %s 不存在" % name)
+ continue
+ upload(str(local), "%s/%s" % (REMOTE_DIR, name))
+ # 产品截图(官网要用)
+ shots = ROOT / "relay" / "site_shots"
+ if shots.is_dir():
+ run("mkdir -p %s/site_shots" % REMOTE_DIR, timeout=60, quiet=True)
+ for f in sorted(shots.iterdir()):
+ if f.is_file():
+ upload(str(f), "%s/site_shots/%s" % (REMOTE_DIR, f.name))
+ else:
+ print(" [警告] 没有 relay/site_shots,官网截图会 404")
+
+ print()
+ print(" ── 3. 写 systemd unit ──")
+ unit = UNIT % {"dir": REMOTE_DIR, "port": PORT}
+ tmp = Path(tempfile.gettempdir()) / "ykxmz-relay.service"
+ tmp.write_text(unit, encoding="utf-8", newline="\n")
+ upload(str(tmp), "/tmp/ykxmz-relay.service")
+ out = run("sudo mv /tmp/ykxmz-relay.service /etc/systemd/system/ && "
+ "sudo systemctl daemon-reload && echo ' unit 已装'",
+ timeout=120)
+ for ln in out[1].strip().splitlines():
+ print(" " + ln)
+
+ print()
+ print(" ── 4. 语法检查 + 启动 ──")
+ out = run(
+ "cd %s && for f in relay.py admin.py landing.py showcase.py; do "
+ "python3 -c \"import ast,sys;ast.parse(open(sys.argv[1],"
+ "encoding='utf-8').read())\" $f || exit 1; done && echo PY_OK"
+ % REMOTE_DIR, timeout=120)
+ for ln in out[1].strip().splitlines():
+ print(" " + ln)
+
+ out = run("sudo systemctl enable ykxmz-relay >/dev/null 2>&1; "
+ "sudo systemctl restart ykxmz-relay; sleep 5; "
+ "systemctl is-active ykxmz-relay", timeout=180)
+ for ln in out[1].strip().splitlines():
+ print(" " + ln)
+
+ print()
+ print(" ── 5. 自测 ──")
+ out = run(
+ "echo \" 本机 health: $(curl -s -o /dev/null -w '%%{http_code}' "
+ "--max-time 8 http://127.0.0.1:%(p)s/v1/health)\"; "
+ "echo \" 监听: $(ss -tlnp 2>/dev/null | grep %(p)s | head -1)\"; "
+ "echo ' --- 启动日志 ---'; "
+ "tail -12 %(d)s/relay.log 2>/dev/null"
+ % {"p": PORT, "d": REMOTE_DIR}, timeout=120)
+ for ln in out[1].strip().splitlines():
+ print(" " + ln)
+
+ print()
+ print(" ── 6. 管理密码(登录后台用)──")
+ out = run("cat %s/data/admin_password.txt 2>/dev/null; echo; "
+ "cat %s/data/admin_key.txt 2>/dev/null" % (DATA_DIR, DATA_DIR),
+ timeout=60)
+ for ln in out[1].strip().splitlines():
+ print(" " + ln)
+
+ print()
+ print(" 提示:防火墙要先放行 TCP %s(用户来开)。" % PORT)
+ print(" 之后公网地址就是 http://13.54.30.196:%s/" % PORT)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/deploy_lightsail_monitor.py b/_build/deploy_lightsail_monitor.py
new file mode 100644
index 0000000..d23acf0
--- /dev/null
+++ b/_build/deploy_lightsail_monitor.py
@@ -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())
diff --git a/_build/lssh.py b/_build/lssh.py
new file mode 100644
index 0000000..1dd1f67
--- /dev/null
+++ b/_build/lssh.py
@@ -0,0 +1,128 @@
+# -*- coding: utf-8 -*-
+r'''连 AWS Lightsail 那台新服务器的 SSH 助手。
+
+== 为什么单独一个模块,不改 devssh.py ==
+
+devssh.py 是连**随身 WiFi 那台 4G 小机器**的(192.168.2.156,密码登录)。
+那台还在跑(网站暂时还挂在上面),不能动它的连接逻辑。
+
+新服务器用**密钥**登录、IP 是公网 13.54.30.196,认证方式完全不同,
+所以单独一个模块更清楚。
+
+== 用法 ==
+
+ from lssh import run, upload, download, probe
+ run("uname -a") # 跑命令
+ upload("relay/relay.py", "/opt/ykxmz-relay/relay.py")
+'''
+from __future__ import annotations
+
+import os
+import socket
+from pathlib import Path
+
+import paramiko
+
+HOST = os.environ.get("YKXMZ_LS_HOST", "13.54.30.196")
+USER = os.environ.get("YKXMZ_LS_USER", "admin")
+PORT = int(os.environ.get("YKXMZ_LS_PORT", "22"))
+KEY = Path(os.environ.get(
+ "YKXMZ_LS_KEY", str(Path.home() / ".ssh" / "lightsail-ykxmz.pem")))
+
+_client = None
+
+
+def _connect(timeout: int = 25):
+ global _client
+ if _client is not None:
+ try:
+ # 探一下还活着没
+ _client.exec_command("true", timeout=5)
+ return _client
+ except Exception: # noqa: BLE001
+ _client = None
+
+ if not KEY.is_file():
+ raise RuntimeError(
+ "找不到 SSH 密钥:%s\n"
+ "(把 Lightsail 的 .pem 放到那里,或者设 YKXMZ_LS_KEY)" % KEY)
+
+ cli = paramiko.SSHClient()
+ cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ cli.connect(HOST, port=PORT, username=USER,
+ key_filename=str(KEY), timeout=timeout,
+ allow_agent=False, look_for_keys=False,
+ banner_timeout=30, auth_timeout=30)
+ _client = cli
+ return cli
+
+
+def run(command: str, timeout: int = 300, quiet: bool = False) -> tuple:
+ """跑一条命令,返回 (退出码, 输出)。"""
+ cli = _connect()
+ if not quiet:
+ print(" [ls] %s" % command.splitlines()[0][:90])
+ stdin, stdout, stderr = cli.exec_command(command, timeout=timeout,
+ get_pty=False)
+ out = stdout.read().decode("utf-8", "replace")
+ err = stderr.read().decode("utf-8", "replace")
+ code = stdout.channel.recv_exit_status()
+ return code, (out + err)
+
+
+def upload(local: str, remote: str, mode: int = 0o644) -> None:
+ """传一个文件上去。"""
+ cli = _connect()
+ sftp = cli.open_sftp()
+ try:
+ # 先把远端目录建出来
+ d = os.path.dirname(remote)
+ if d:
+ try:
+ sftp.stat(d)
+ except IOError:
+ # 逐级建
+ parts = d.strip("/").split("/")
+ cur = ""
+ for p in parts:
+ cur += "/" + p
+ try:
+ sftp.stat(cur)
+ except IOError:
+ try:
+ sftp.mkdir(cur)
+ except IOError:
+ pass
+ sftp.put(local, remote)
+ sftp.chmod(remote, mode)
+ finally:
+ sftp.close()
+ print(" 已上传 %s -> %s" % (os.path.basename(local), remote))
+
+
+def download(remote: str, local: str) -> None:
+ cli = _connect()
+ sftp = cli.open_sftp()
+ try:
+ Path(local).parent.mkdir(parents=True, exist_ok=True)
+ sftp.get(remote, local)
+ finally:
+ sftp.close()
+ print(" 已下载 %s -> %s" % (remote, local))
+
+
+def probe() -> str | None:
+ """通不通。返回一句话描述,或 None。"""
+ try:
+ code, out = run("uname -sm; nproc; free -m | sed -n 2p",
+ timeout=30, quiet=True)
+ if code != 0:
+ return None
+ return out.strip().replace("\n", " | ")
+ except Exception as exc: # noqa: BLE001
+ return "连接失败:%s" % exc
+
+
+if __name__ == "__main__":
+ print(" 目标: %s@%s:%d 密钥 %s" % (USER, HOST, PORT, KEY))
+ print(" " + (probe() or "不通"))
diff --git a/_build/package_remote.py b/_build/package_remote.py
index 9dbff12..262150e 100644
--- a/_build/package_remote.py
+++ b/_build/package_remote.py
@@ -17,7 +17,7 @@ import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
-VERSION = "2.1.2"
+VERSION = "2.1.3"
NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
diff --git a/_build/verify_cleartext.py b/_build/verify_cleartext.py
new file mode 100644
index 0000000..eadcc39
--- /dev/null
+++ b/_build/verify_cleartext.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+r'''验证 APK 里真的开了明文流量。
+
+Android 的 usesCleartextTraffic 是编译进 binary manifest 的,
+光看源码不算数 —— 万一构建脚本没把新清单打进去,源码改对了也没用。
+所以直接解 APK 看里面的 AndroidManifest。
+'''
+from __future__ import annotations
+
+import re
+import sys
+import zipfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def main() -> int:
+ apk = ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
+ if not apk.is_file():
+ print(" ✗ 找不到 %s" % apk)
+ return 1
+
+ print(" APK: %s(%d 字节)" % (apk.name, apk.stat().st_size))
+ with zipfile.ZipFile(apk) as z:
+ blob = z.read("AndroidManifest.xml")
+
+ # binary manifest 里字符串以 UTF-16LE 存在字符串池里
+ # 简单办法:直接在字节里找关键词
+ text = blob.decode("utf-16-le", "ignore")
+ for key in ("usesCleartextTraffic", "uses-permission", "INTERNET",
+ "com.lanremote.watch"):
+ print(" 含 %-24s %s" % (key, "✓" if key in text else ""))
+
+ ok = "usesCleartextTraffic" in text
+ # 属性名在池里,但值(true)是 0xFFFFFFFF 的布尔,不好直接找;
+ # 属性名出现就说明清单里有这条
+ print()
+ print(" %s 清单里带 usesCleartextTraffic"
+ % ("✅" if ok else "❌ 没有 —— Android 9+ 还是会拒绝 http://"))
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py
index a2ec396..4f17220 100644
--- a/_tests/test_apk_contents.py
+++ b/_tests/test_apk_contents.py
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
(ROOT / "dist").glob("*watch*.apk"),
key=lambda p: p.stat().st_mtime, reverse=True)
return candidates[0] if candidates else \
- ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
+ ROOT / "dist" / "ykxmz-watch-2.1.3.apk"
APK = _latest_apk()
diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py
index 57f6eda..716a7b0 100644
--- a/_tests/test_package_remote.py
+++ b/_tests/test_package_remote.py
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
# 被控端 -> 一个 zip
# 服务器端 -> 一个 zip
# 控制端 -> 直接给 APK(不打 zip)
-AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.2.zip"
-SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.2.zip"
-WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
+AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.3.zip"
+SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.3.zip"
+WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.3.apk"
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
@@ -89,7 +89,7 @@ def main() -> int:
for label, path in (("被控端", agent_dir / "README-agent.txt"),
("服务器端", relay_dir / "README-server.txt"),
("控制端(旁边那份)",
- ROOT / "dist" / "ykxmz-watch-2.1.2-说明.txt")):
+ ROOT / "dist" / "ykxmz-watch-2.1.3-说明.txt")):
raw = path.read_bytes() if path.is_file() else b""
check(f"{label}说明有 UTF-8 BOM",
raw[:3] == b"\xef\xbb\xbf", raw[:3])
diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml
index 3e2e3d1..ec581d3 100644
--- a/android/AndroidManifest.xml
+++ b/android/AndroidManifest.xml
@@ -2,8 +2,18 @@
+ android:versionName="2.1.3">
+
@@ -31,6 +41,7 @@
android:allowBackup="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:hardwareAccelerated="true"
+ android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true">
http://
+ // 域名 -> https://
+ //
+ // 为什么纯 IP 走明文:IP 地址的中转基本都是自己搭的
+ // (局域网那台,或者像现在这样一台有公网 IP 的服务器),
+ // 上面往往没有证书。原来一律加 https:// 的结果是**永远连不上**,
+ // 而且报的是 SSL 错误,会把人带去查证书查半天。
+ //
+ // ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
+ // 之后中转就是 http://13.54.30.196:13245,
+ // 只把 192.168/10./172.16 当私有的话这里会走错。
+ String hostOnly = url;
+ int slash = hostOnly.indexOf('/');
+ if (slash > 0) {
+ hostOnly = hostOnly.substring(0, slash);
+ }
+ int colon = hostOnly.indexOf(':');
+ if (colon > 0) {
+ hostOnly = hostOnly.substring(0, colon);
+ }
+ boolean plain = hostOnly.equalsIgnoreCase("localhost")
+ || hostOnly.toLowerCase().endsWith(".local")
+ || hostOnly.matches("\\d{1,3}(\\.\\d{1,3}){3}");
+ url = (plain ? "http://" : "https://") + url;
}
this.baseUrl = url;
this.pairingCode = pairingCode == null ? "" : pairingCode.trim();
diff --git a/docs/AFTER-INSTALL.txt b/docs/AFTER-INSTALL.txt
index 4d6daeb..18c9dad 100644
--- a/docs/AFTER-INSTALL.txt
+++ b/docs/AFTER-INSTALL.txt
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
-版本:2.1.2
+版本:2.1.3
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -197,7 +197,7 @@
八、版本记录
--------------------------------------------------------
- 2.1.2
+ 2.1.3
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务
@@ -218,6 +218,6 @@
--------------------------------------------------------
作者:小麦子
- 软件:ykxmz 2.1.2
+ 软件:ykxmz 2.1.3
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
diff --git a/docs/BEFORE-INSTALL.txt b/docs/BEFORE-INSTALL.txt
index a76a9a5..2a656db 100644
--- a/docs/BEFORE-INSTALL.txt
+++ b/docs/BEFORE-INSTALL.txt
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
-版本:2.1.2
+版本:2.1.3
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
- 软件:ykxmz 2.1.2
+ 软件:ykxmz 2.1.3
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt
index d5249ae..6a7500e 100644
--- a/docs/LICENSE.txt
+++ b/docs/LICENSE.txt
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
-版本:2.1.2
+版本:2.1.3
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。**
>
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
-> 那和 AGPL 冲突,所以 2.1.2 起拆开了:代码授权归 LICENSE,
+> 那和 AGPL 冲突,所以 2.1.3 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。)
@@ -160,7 +160,7 @@
--------------------------------------------------------
作者:小麦子
- 软件:ykxmz 2.1.2
+ 软件:ykxmz 2.1.3
如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
diff --git a/installer/license-combined.txt b/installer/license-combined.txt
index b83e78d..3610fb6 100644
--- a/installer/license-combined.txt
+++ b/installer/license-combined.txt
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
-版本:2.1.2
+版本:2.1.3
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。**
>
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
-> 那和 AGPL 冲突,所以 2.1.2 起拆开了:代码授权归 LICENSE,
+> 那和 AGPL 冲突,所以 2.1.3 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。)
@@ -160,7 +160,7 @@
--------------------------------------------------------
作者:小麦子
- 软件:ykxmz 2.1.2
+ 软件:ykxmz 2.1.3
如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
@@ -173,7 +173,7 @@
========================================================
软件名称:ykxmz
-版本:2.1.2
+版本:2.1.3
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
- 软件:ykxmz 2.1.2
+ 软件:ykxmz 2.1.3
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
@@ -345,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
========================================================
软件名称:ykxmz
-版本:2.1.2
+版本:2.1.3
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
八、版本记录
--------------------------------------------------------
- 2.1.2
+ 2.1.3
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
- 软件:ykxmz 2.1.2
+ 软件:ykxmz 2.1.3
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss
index c52e3de..6ee9548 100644
--- a/installer/ykxmz.iss
+++ b/installer/ykxmz.iss
@@ -27,7 +27,7 @@
; ============================================================================
#define MyAppName "ykxmz"
-#define MyAppVersion "2.1.2"
+#define MyAppVersion "2.1.3"
#define MyAppPublisher "小麦子"
#define MyAppExeName "ykxmz.exe"
@@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
; (PowerShell 的 Start-Process -Wait 就是)。
; 学校批量部署请用 cmd:
-; cmd /c ykxmz-setup-2.1.2.exe /VERYSILENT /CODE=123456
+; cmd /c ykxmz-setup-2.1.3.exe /VERYSILENT /CODE=123456
; cmd 只等安装器本身,不等孙子进程,不会卡。
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
Description: "立即启动(后台运行,不显示窗口)"; \
diff --git a/relay/relay.py b/relay/relay.py
index 6319137..5c8642f 100644
--- a/relay/relay.py
+++ b/relay/relay.py
@@ -59,13 +59,27 @@ from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
-VERSION = "2.1.2"
+VERSION = "2.1.3"
# ---------------- 参数(都能用环境变量覆盖)----------------
PORT = int(os.environ.get("RELAY_PORT", "8080"))
BIND = os.environ.get("RELAY_BIND", "127.0.0.1")
-DATA_DIR = Path(os.environ.get("RELAY_DATA", "/root/lanremote-relay"))
+
+# ⚠ 数据目录的默认值曾经**写死成 /root/lanremote-relay**。
+# 原来那台机器上没问题(服务就跑在 /root 下、以 root 跑),
+# 但 2026-10-08 搬到 Lightsail 之后立刻炸了:
+#
+# PermissionError: [Errno 13] Permission denied: '/root/lanremote-relay'
+#
+# 新机器上服务以 admin 普通用户跑(它没理由要 root),
+# 而 /root 是 0700 —— mkdir 直接被拒。
+#
+# 改成本脚本所在目录下的 data/:搬到哪都跟着走,
+# 不用每换一台机器就记得设环境变量。
+# (要用别的路径照样可以,RELAY_DATA 优先级更高。)
+_SCRIPT_DIR = Path(__file__).resolve().parent
+DATA_DIR = Path(os.environ.get("RELAY_DATA", str(_SCRIPT_DIR / "data")))
# 长轮询最多挂多久。太长会占着 Cloudflare 的连接,太短则空转请求多。
# 长轮询挂多久。
@@ -375,6 +389,11 @@ def public_url() -> str:
candidates = [
DATA_DIR.parent / "public_url.txt",
DATA_DIR / "public_url.txt",
+ # 旧那台机器把 public_url.txt 放在 /root/lanremote-relay/。
+ # 保留这条是为了**从旧机器迁过来时**还能读到已有的地址;
+ # 新机器上这个路径根本不存在,is_file() 会直接跳过,无害。
+ # (之前 DATA_DIR 默认值也写死成这个路径,搬迁时踩过 ——
+ # 见文件顶部 DATA_DIR 那段注释。)
Path("/root/lanremote-relay/public_url.txt"),
]
for path in candidates:
diff --git a/relay/rescue-lightsail.sh b/relay/rescue-lightsail.sh
new file mode 100644
index 0000000..8d27977
--- /dev/null
+++ b/relay/rescue-lightsail.sh
@@ -0,0 +1,285 @@
+#!/bin/bash
+# ============================================================================
+# 紧急预案(Lightsail 版)—— 网站打不开时按阶梯自动抢救
+# ============================================================================
+#
+# 和随身 WiFi 那版的区别:
+#
+# 那版是**围着 Cloudflare 隧道**转的(第 1 级就是重启隧道、
+# 第 3 级重施 MSS/MTU 调优)。现在这台新机器**有公网 IP**,
+# 中转直接对外监听 13245,没有任何隧道 —— 所以:
+#
+# * 第 1 级从"重启隧道"改成"重启中转服务"
+# * 去掉 MSS/MTU 那两级(那是 4G 线路的问题,这里没有)
+# * 加一级"看端口有没有被占/进程死没死"
+#
+# 保留下来的核心设计没变:
+#
+# 1. **先记录现场,再动手**(重启会把证据冲掉)
+# 2. **阶梯式,每级都验**:从代价最小到最大,好了就停
+# 3. **全失败要留证据**:生成 txt 报告,后台可下载
+# 4. **成功的招数记进知识库**:下次同类问题优先试
+#
+# 谁调用:monitor.sh(连续 N 次不通就调这里)
+# 也可以人工跑:bash /opt/ykxmz-relay/rescue.sh
+# ============================================================================
+
+set -u
+
+RELAY_DIR="${RELAY_DIR:-/opt/ykxmz-relay}"
+PORT="${RELAY_PORT:-13245}"
+# 公网健康检查地址。没有域名就填 IP。
+PUBLIC_URL="${PUBLIC_URL:-http://13.54.30.196:$PORT}"
+REPORTS_DIR="$RELAY_DIR/rescue_reports"
+KNOWLEDGE="$RELAY_DIR/rescue_knowledge.md"
+LOG="$RELAY_DIR/rescue.log"
+LOCKFILE="/tmp/ykxmz-rescue.lock"
+
+SETTLE_FAST=6
+SETTLE_SLOW=15
+
+# 是否允许"重启整机"这张底牌。默认关。
+RESCUE_REBOOT="${RESCUE_REBOOT:-0}"
+
+mkdir -p "$REPORTS_DIR"
+
+if ! mkdir "$LOCKFILE" 2>/dev/null; then
+ echo "[rescue] 已经有一个预案在跑,本次退出"
+ exit 0
+fi
+trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT
+
+STAMP="$(date +%Y%m%d-%H%M%S)"
+REPORT="$REPORTS_DIR/$STAMP.txt"
+
+say() {
+ echo "$*"
+ echo "$(date '+%F %T') $*" >> "$LOG"
+}
+
+# 公网健康检查 —— **看公网,不看本机**
+is_healthy() {
+ local code
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
+ "$PUBLIC_URL/v1/health" 2>/dev/null)
+ [ "$code" = "200" ]
+}
+
+local_health() {
+ local code
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 \
+ "http://127.0.0.1:$PORT/v1/health" 2>/dev/null)
+ [ "$code" = "200" ]
+}
+
+# ---------------------------------------------------------------------------
+# 现场记录
+# ---------------------------------------------------------------------------
+collect() {
+ {
+ echo "================================================================================"
+ echo " ykxmz 紧急预案诊断报告"
+ echo " 时间:$(date '+%F %T %Z')"
+ echo " 主机:$(hostname) 运行时长:$(uptime -p 2>/dev/null || uptime)"
+ echo " 触发原因:$1"
+ echo "================================================================================"
+ echo
+ echo "---- 一、结论速览 ----"
+ echo
+ echo " 公网 $PUBLIC_URL/v1/health -> $(is_healthy && echo 200-正常 || echo 不通)"
+ echo " 本机 http://127.0.0.1:$PORT/v1/health -> $(local_health && echo 200-正常 || echo 不通)"
+ echo
+ if local_health && ! is_healthy; then
+ echo " 判断:**本机是好的,问题在公网那一侧**(防火墙规则、安全组、上游线路)。"
+ elif ! local_health; then
+ echo " 判断:**本机的中转服务就没起来**,先看第二、三节。"
+ else
+ echo " 判断:两边都通(可能是刚才已经自愈,或者只是偶发抖动)。"
+ fi
+ echo
+ echo "---- 二、服务状态 ----"
+ echo
+ printf ' %-24s %s\n' "ykxmz-relay" "$(systemctl is-active ykxmz-relay 2>&1)"
+ printf ' %-24s %s\n' "ykxmz-monitor.timer" "$(systemctl is-active ykxmz-monitor.timer 2>&1)"
+ echo
+ echo " --- 最近 20 行服务日志 ---"
+ journalctl -u ykxmz-relay --no-pager -n 20 2>/dev/null | sed 's/^/ /'
+ echo
+ echo "---- 三、进程与端口 ----"
+ echo
+ echo " 监听 $PORT 的进程:"
+ ss -tlnp 2>/dev/null | grep ":$PORT " | sed 's/^/ /' \
+ || echo " **没有任何进程在监听 $PORT**"
+ echo
+ echo " 相关进程:"
+ ps -eo pid,rss,etime,cmd --sort=-rss 2>/dev/null \
+ | grep -E 'relay\.py|python3' | grep -v grep | head -6 | sed 's/^/ /'
+ echo
+ echo "---- 四、系统资源 ----"
+ echo
+ free -m 2>/dev/null | sed 's/^/ /'
+ echo
+ uptime | sed 's/^/ /'
+ echo
+ df -h / /opt 2>/dev/null | sed 's/^/ /'
+ echo
+ echo "---- 五、网络 ----"
+ echo
+ ip -brief addr show 2>/dev/null | sed 's/^/ /'
+ echo
+ echo " 默认路由:"
+ ip route show default 2>/dev/null | sed 's/^/ /'
+ echo
+ echo " 本机自测各端口:"
+ for p in "$PORT" 80 443 22; do
+ printf ' %-6s ' "$p"
+ timeout 3 bash -c "/dev/null \
+ && echo "通" || echo "不通"
+ done
+ echo
+ echo "---- 六、日志尾部 ----"
+ echo
+ echo "### 中转服务 relay.log"
+ tail -30 "$RELAY_DIR/relay.log" 2>/dev/null | sed 's/^/ /'
+ echo
+ echo "### 监测单元 monitor.log"
+ tail -20 "$RELAY_DIR/monitor.log" 2>/dev/null | sed 's/^/ /'
+ echo
+ echo "### 内核异常(OOM / 硬件)"
+ dmesg -T 2>/dev/null \
+ | grep -i -E 'oom|killed process|error|panic' | tail -15 | sed 's/^/ /'
+ echo
+ echo "---- 七、最近一次启动 ----"
+ echo
+ who -b 2>/dev/null | sed 's/^/ /'
+ echo " 运行时长:$(cut -d. -f1 /proc/uptime 2>/dev/null) 秒"
+ echo
+ echo "================================================================================"
+ echo " 报告结束。可以在中转站后台的「抢救报告」分区下载。"
+ echo "================================================================================"
+ } > "$REPORT" 2>&1
+ say " 现场已记录 -> $REPORT"
+}
+
+# ---------------------------------------------------------------------------
+# 阶梯
+# ---------------------------------------------------------------------------
+
+step_1_restart_relay() {
+ say " [1/4] 重启中转服务(最轻的一招,不动系统其他部分)"
+ systemctl restart ykxmz-relay 2>/dev/null
+ sleep "$SETTLE_FAST"
+}
+
+step_2_kill_and_start() {
+ say " [2/4] 强杀残留进程再拉起(进程卡死/端口被占时用)"
+ # 有时候 systemd restart 会因为老进程没退干净而失败,
+ # 表现为"服务说 active 但端口没人听"。
+ pkill -f "$RELAY_DIR/relay.py" 2>/dev/null
+ sleep 3
+ systemctl reset-failed ykxmz-relay 2>/dev/null
+ systemctl start ykxmz-relay 2>/dev/null
+ sleep "$SETTLE_FAST"
+}
+
+step_3_check_firewall() {
+ say " [3/4] 检查本机防火墙有没有挡住 $PORT"
+ # Lightsail 有两层:网页上的防火墙规则(实例级)+ 系统内的 ufw/iptables。
+ # 网页那层我们改不了,但系统内这层能看。
+ if command -v ufw >/dev/null 2>&1; then
+ echo " ufw 状态:$(ufw status 2>/dev/null | head -1)" >> "$LOG"
+ ufw status 2>/dev/null | grep -q "$PORT" \
+ || say " ⚠ ufw 里没有放行 $PORT 的规则"
+ fi
+ local n
+ n=$(iptables -L INPUT -n 2>/dev/null | grep -c "$PORT")
+ say " iptables INPUT 里提到 $PORT 的规则:$n 条"
+ # 重新绑一次端口(有时是监听 socket 坏了)
+ systemctl restart ykxmz-relay 2>/dev/null
+ sleep "$SETTLE_SLOW"
+}
+
+step_4_reboot() {
+ if [ "$RESCUE_REBOOT" != "1" ]; then
+ say " [4/4] 重启整机 —— **已跳过**(默认不开)"
+ say " 要允许自动重启,把 RESCUE_REBOOT=1 写进 ykxmz-monitor.service。"
+ return 1
+ fi
+ say " [4/4] 重启整机(最后的底牌)"
+ sync
+ systemctl reboot 2>/dev/null
+ return 0
+}
+
+remember() {
+ local step="$1" reason="$2"
+ {
+ echo
+ echo "## $(date '+%F %T') 第 $step 级奏效"
+ echo
+ echo "- 症状:$reason"
+ echo "- 有效步骤:$step"
+ echo "- 当时公网:$(is_healthy && echo 通 || echo 不通);本机:$(local_health && echo 通 || echo 不通)"
+ echo "- 详情见 rescue_reports/$(basename "$REPORT")"
+ } >> "$KNOWLEDGE"
+ say " 已把「第 $step 级奏效」记进 $KNOWLEDGE"
+}
+
+# ===========================================================================
+# 主流程
+# ===========================================================================
+
+REASON="${1:-monitor 触发}"
+
+say ""
+say "================================================================================"
+say " 紧急预案启动:$REASON"
+say "================================================================================"
+
+# ⚠ 先复查再记录现场。
+# collect() 会写一份报告文件;如果网站其实已经好了,
+# 那一次也会留下报告,把真正出事时的报告淹掉(后台只显示最近 30 份)。
+if is_healthy; then
+ say " 复查:公网现在是通的,不需要动手 —— 不采现场、不留报告。"
+ exit 0
+fi
+
+collect "$REASON"
+
+STEP_NAMES=("重启中转服务" "强杀残留再拉起" "查防火墙并重绑" "重启整机")
+
+for i in 1 2 3 4; do
+ case $i in
+ 1) step_1_restart_relay ;;
+ 2) step_2_kill_and_start ;;
+ 3) step_3_check_firewall ;;
+ 4) step_4_reboot || continue ;;
+ esac
+
+ if is_healthy; then
+ say ""
+ say " ✅ 第 $i 级(${STEP_NAMES[$((i-1))]})救回来了,公网已恢复。"
+ remember "$i" "$REASON"
+ exit 0
+ fi
+ say " 第 $i 级没解决,继续下一级"
+done
+
+say ""
+say " ❌ 四级都没解决。再采一次现场(状态已经变了)"
+collect "$REASON(四级抢救后仍不通)"
+
+{
+ echo "⚠ 这份报告是「自动抢救全部失败」之后生成的。"
+ echo " 机器自己试过了:重启中转 / 强杀重拉 / 查防火墙。"
+ echo " (重启整机默认没做。)"
+ echo " 需要人工看一眼 —— 特别是 Lightsail **网页上的防火墙规则**,"
+ echo " 那一层系统内部改不了。"
+ echo
+ cat "$REPORT"
+} > "$REPORT.tmp" && mv "$REPORT.tmp" "$REPORT"
+
+ls -1t "$REPORTS_DIR"/*.txt 2>/dev/null | tail -n +31 | xargs -r rm -f
+
+say " 报告已生成:$REPORT"
+exit 1
diff --git a/windows/lanremote/__init__.py b/windows/lanremote/__init__.py
index dc96ef1..a206c93 100644
--- a/windows/lanremote/__init__.py
+++ b/windows/lanremote/__init__.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
-__version__ = "2.1.2"
+__version__ = "2.1.3"
APP_NAME = "ykxmz"
diff --git a/windows/lanremote/relay.py b/windows/lanremote/relay.py
index 30fc1b6..0dc6514 100644
--- a/windows/lanremote/relay.py
+++ b/windows/lanremote/relay.py
@@ -92,19 +92,6 @@ def _guess_scheme(address: str) -> str:
return "http://" if _PRIVATE_HOST.match(host) else "https://"
-
- """用户没写 http/https 时,猜一个。
-
- IP 地址 / localhost -> http://
- 局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
- 而且报的是 SSL 错误,很容易误导人去查证书。
- 域名 -> https://
- 走公网必须加密(Cloudflare 隧道本来就是 https)。
- """
- host = address.split("/", 1)[0]
- return "http://" if _PRIVATE_HOST.match(host) else "https://"
-
-
def _ssl_context() -> ssl.SSLContext:
"""给 HTTPS 用的 SSL 上下文。
diff --git a/windows/version_info.txt b/windows/version_info.txt
index 290b0fd..aa2a6b0 100644
--- a/windows/version_info.txt
+++ b/windows/version_info.txt
@@ -27,12 +27,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
- StringStruct("FileVersion", "2.1.2.0"),
+ StringStruct("FileVersion", "2.1.3.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
- StringStruct("ProductVersion", "2.1.2.0"),
+ StringStruct("ProductVersion", "2.1.3.0"),
],
)
]