From 5fc2f97be7232f6c6cd518ca70d17fc8323487bc Mon Sep 17 00:00:00 2001 From: xmz7891 Date: Thu, 8 Oct 2026 22:29:56 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF2.1.3=EF=BC=9A=E6=90=AC=E5=88=B0=20Lig?= =?UTF-8?q?htsail=20=E7=9C=9F=E6=9C=8D=E5=8A=A1=E5=99=A8=20+=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81"=E6=B2=A1=E6=9C=89=E8=AF=81=E4=B9=A6=E7=9A=84?= =?UTF-8?q?=E4=B8=AD=E8=BD=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户买了 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 个阶段全部通过。 --- _build/CHANGELOG.md | 24 ++ _build/_cmsg_213.txt | 50 +++ _build/allow_plain_http.py | 166 ++++++++++ _build/check_lightsail.py | 102 +++++++ _build/deploy_lightsail.py | 175 +++++++++++ _build/deploy_lightsail_monitor.py | 285 ++++++++++++++++++ _build/lssh.py | 128 ++++++++ _build/package_remote.py | 2 +- _build/verify_cleartext.py | 45 +++ _tests/test_apk_contents.py | 2 +- _tests/test_package_remote.py | 8 +- android/AndroidManifest.xml | 13 +- android/build_apk.ps1 | 4 +- .../com/lanremote/watch/RelayTransport.java | 28 +- docs/AFTER-INSTALL.txt | 6 +- docs/BEFORE-INSTALL.txt | 4 +- docs/LICENSE.txt | 6 +- installer/license-combined.txt | 16 +- installer/ykxmz.iss | 4 +- relay/relay.py | 23 +- relay/rescue-lightsail.sh | 285 ++++++++++++++++++ windows/lanremote/__init__.py | 2 +- windows/lanremote/relay.py | 13 - windows/version_info.txt | 4 +- 24 files changed, 1349 insertions(+), 46 deletions(-) create mode 100644 _build/_cmsg_213.txt create mode 100644 _build/allow_plain_http.py create mode 100644 _build/check_lightsail.py create mode 100644 _build/deploy_lightsail.py create mode 100644 _build/deploy_lightsail_monitor.py create mode 100644 _build/lssh.py create mode 100644 _build/verify_cleartext.py create mode 100644 relay/rescue-lightsail.sh 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"), ], ) ]