diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 92c9375..21bca56 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -1,3 +1,48 @@ +## 1.8.0 + +**这一版主要是「修看不见的稳定性问题」+「把中转管理后台重做」。** + +- 修**跨网络老断**(Error 1033 / 502 / 530 的真因): + 4G 链路会**静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着), + Cloudflare 把请求发到这种"僵尸连接"上就报错。 + 而 Linux 默认要 **2 小时**才发现连接死了 —— 中间这段时间 + 服务一直"看着在线、实际打不开"。 + 现在改成 30 秒探测、60 秒内发现并重连 + +- 修**跨网络抖动**:隧道连接数从 2 提到 4。 + 只剩 1 条连接时跨机房回源会失败,实测可用率 0/12; + 提到 4 条后稳定 12/12 + +- 修**掉电重启后恢复太慢**:原来开机要 **2 分 10 秒**公网才恢复, + 那段时间浏览器看到的就是 Error 1033。 + 根因是系统里 NetworkManager 和 systemd-networkd 同时在跑, + 后者等一个不归它管的网络,干等 2 分钟超时失败, + 把启动流程一起拖住了。改完 **17 秒**就绪,掉电重启后 24 秒公网可用 + +- 修**看门狗自己制造不稳定**:它每隔 50 秒重启一次隧道, + 但隧道重建 4 条连接要 30~60 秒 —— 每次还没建齐就被下一次打断, + 永远停在"只剩 1 条"的状态。现在重启后有 150 秒宽限期 + +- **中转管理后台完全重做**:原来是几百行内联代码, + 改一处样式要在一堆转义符里翻。拆成独立模块, + 界面重排成:顶栏状态 → 公网地址(最重要,给最大权重)→ + 四个数字 → 设备表 → 工具卡。窄屏自动变卡片布局 + +- 修**管理页显示成一屏源码**:响应头类型写错了, + 浏览器把整页 HTML 当纯文本显示 + +- 修**管理页标题被切掉**:顶栏用了固定高度装不下内容, + 标题被顶出容器、状态徽章压在文字上 + +- 清理**僵尸设备记录**:设备列表只增不减, + 实测一天攒了 75 条无效记录,拖慢手表和网页。 + 现在超过 6 小时没上报的自动清掉,占用的配对码一起释放 + +- **官网新增产品展示**:手表界面预览(颜色和布局与真实手表一致)、 + 电脑端真实截图、两种连接方式对比图 + +- 支持 `HEAD` 请求(原来一律返回 501) + ## 1.7.1 - 修**卸载卡死**:从控制面板卸载时进度条永远走不完。 diff --git a/_build/_c15.txt b/_build/_c15.txt new file mode 100644 index 0000000..f4b21ed --- /dev/null +++ b/_build/_c15.txt @@ -0,0 +1,73 @@ +1.8.0:隧道稳定性大修 + 中转管理后台重做 + +版本号 1.7.1 -> 1.8.0(8 个文件,13 处)。 +写了个 bump_180.py 统一处理 —— 版本号散落在 +Python / Java manifest / PowerShell / Inno Setup / 测试里, +手动改必漏;而且 .ps1 必须保留 UTF-8 BOM。 + +==================== 一、隧道稳定性(用户撞上 1033/502/530)==================== + +1) 4G 链路会**静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着), + Linux 默认要 2 小时才发现 -> 改成 keepalive 30 秒 + 每 10 秒探测。 + 实测 60 秒内发现并重连。 + +2) 隧道连接数 2 -> 4。只剩 1 条连接时跨机房回源会失败 + (实测可用率 0/12,提到 4 条后 12/12)。 + +3) 掉电重启后恢复慢:原来开机 2 分 10 秒公网才可用。 + 根因是 systemd-networkd-wait-online 等一个不归它管的网络 + (wlan0 是 NetworkManager 管的),干等 2 分钟超时失败, + 把 network-online.target 一起拖住。 + mask 掉它之后:启动 17.3 秒,掉电重启 24 秒公网可用。 + +4) 看门狗两次翻车都是我造成的,都修了: + a) 我先加了"确认故障立刻重启",但隧道重建 4 条连接要 30~60 秒, + MIN_RESTART_GAP=45 导致**还没建齐就被下一次重启打断**, + 死循环,每 50 秒重启一次 -> 加 150 秒宽限期 + b) 宽限期又带来"链路持续差时什么都不做"的问题 + -> 增加"本次重启后建齐过吗"的判断: + 建齐过又垮 = 真故障,立刻重启 + 从没建齐 = 还在努力,继续等 + +5) 顺手修:看门狗状态文件里的变量在函数定义前被引用, + set -u 下报 "BUILT: 未绑定的变量"。 + +==================== 二、中转管理后台重做 ==================== + +原来的管理页是几百行内联在 relay.py 的 f-string 里, +改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。 + +新设计(对齐官网那套白色简洁): + 吸顶顶栏 -> 公网地址(最大权重,这是用户最需要复制的) + -> 四个数字卡 -> 设备表 -> 工具卡(体检 + 下载) + 空状态给图标+引导文案;窄屏表格自动变卡片。 + relay.py 从 2089 行缩到 1500 行左右。 + +修了三个我自己引入的 bug: + a) 响应头类型写错,浏览器把整页 HTML 当纯文本显示 + (用户看到的是"一屏源码")。教训:200 不等于页面能看, + 当时只验了状态码和字节数。 + b) 顶栏用了固定 height:62px,装不下"标题+副标题+版本信息"两行, + 内容被顶出容器 -> 标题看不见、徽章压在字上。 + c) do_HEAD 没处理 /download/ 路径,HEAD 一个 APK 返回 + Content-Length: 0 + text/plain,下载器会误判成空文件。 + 同时发现 _head_info 里对 bytes 调 .encode() —— 加 _as_bytes() 兜住。 + +==================== 三、其它 ==================== + +* 设备记录只增不减:实测一天攒 75 条僵尸,/v1/health 从几 KB 涨到 20KB。 + 加 DEVICE_TTL(6 小时)自动清,占用的配对码一起释放 + (管理页曾显示"配对码占用 131"但一台设备都没有)。 +* HEAD 请求支持(原来一律 501)。 +* 官网新增产品展示:手表界面预览(颜色/布局与真实手表一致)、 + 电脑端真实截图、两种连接方式对比图。 +* 加了一份 docs/免费托管方案.md(含一个纠错:上一版推荐的 + ClawCloud Run 已在 2026-05-11 关停)。 + +==================== 验收 ==================== + +全量回归 33 个阶段全部通过 +安装包完整周期 20/20(静默装入 7 秒、卸载 0.6 秒、无残留) +网页响应头 12/12 +公网可用率 26/30 = 86%(底层 4G 丢包 30%,是这个上限) +发布产物 安装包 14.37MB / 服务端 0.20MB / 手表 0.19MB diff --git a/_build/accept_180.sh b/_build/accept_180.sh new file mode 100644 index 0000000..88905f4 --- /dev/null +++ b/_build/accept_180.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# 1.8.0 发布验收 —— 从公网走一遍用户会走的路 +set -u + +BASE=https://relay.xxmxxs.top +UA='LanRemote/1.8.0' + +echo "════════ 1. 中转版本 ════════" +curl -s --max-time 15 "$BASE/v1/health" | head -c 200 +echo + +echo +echo "════════ 2. 官网 ════════" +code=$(curl -s -o /tmp/site.html -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/") +echo " HTTP $code $(wc -c < /tmp/site.html) 字节" +echo " 版本号显示: $(grep -o '1\.8\.0' /tmp/site.html | head -1 || echo '(页面上没直接标版本,正常)')" +echo " 下载按钮指向:" +grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /' + +echo +echo "════════ 3. 三个成品能不能下(只看前 1KB 和响应头)════════" +for f in ykxmz-setup-1.8.0.exe ykxmz-server-1.8.0.zip ykxmz-watch-1.8.0.apk; do + hdr=$(curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE/download/$f") + code=$(echo "$hdr" | head -1 | awk '{print $2}') + length=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}') + kind=$(echo "$hdr" | grep -i '^content-type' | tr -d '\r' | cut -d' ' -f2) + # 真下 1KB 验证能读 + got=$(curl -s --max-time 40 -r 0-1023 -o /tmp/chunk.bin -w '%{http_code}' \ + -H "User-Agent: $UA" "$BASE/download/$f" 2>/dev/null) + real=$(wc -c < /tmp/chunk.bin 2>/dev/null || echo 0) + printf " %-32s HTTP %-4s 共 %-10s %s 取到 %s 字节\n" \ + "$f" "$code" "${length:-?}" "${kind:-?}" "$real" +done + +echo +echo "════════ 4. 管理后台 ════════" +PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null) +curl -s -c /tmp/ck.txt -o /dev/null --max-time 20 \ + -d "username=xmz7891&password=$PASS" "$BASE/admin/login" +for p in "/zhongzhuanzhan" "/admin"; do + printf " %-20s HTTP %s\n" "$p" \ + "$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt --max-time 20 -H "User-Agent: $UA" "$BASE$p")" +done +curl -s -b /tmp/ck.txt --max-time 20 "$BASE/zhongzhuanzhan" -o /tmp/adm.html +echo " 管理页 $(wc -c < /tmp/adm.html) 字节" +for k in 'class="top"' 'class="hero"' 'class="nums"' 'class="card"' 'ykxmz 中转站'; do + if grep -q "$k" /tmp/adm.html; then echo " ✓ $k"; else echo " ✗ $k"; fi +done + +echo +echo "════════ 5. 其它页面 ════════" +for p in "/shot/pc-main.png" "/favicon.ico"; do + printf " %-22s HTTP %s\n" "$p" \ + "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$BASE$p")" +done + +echo +echo "════════ 6. 公网可用率(12 次)════════" +ok=0 +for i in $(seq 1 12); do + c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/v1/health") + [ "$c" = "200" ] && ok=$((ok + 1)) + printf " %2d: %s\n" "$i" "$c" + sleep 1 +done +echo " 可用 $ok/12" + +echo +echo "════════ 7. 中转日志(看有没有报错)════════" +journalctl -u lanremote-relay --since '3 minutes ago' --no-pager 2>/dev/null \ + | grep -icE 'traceback|nameerror|typeerror' | sed 's/^/ 错误行数: /' +tail -4 /root/lanremote-relay/relay.log | sed 's/^/ /' diff --git a/_build/accept_180b.sh b/_build/accept_180b.sh new file mode 100644 index 0000000..8a300a5 --- /dev/null +++ b/_build/accept_180b.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# 1.8.0 发布验收(稳定版) +# +# 上一版脚本两个毛病: +# 1. 紧跟在中转重启之后跑,撞上隧道重连,一路 530 +# 2. `curl -o /tmp/adm.html` 在 for 循环之后没生成文件 +# (多半是前面某条 curl 把变量吃掉了) +# 这里都改掉:先等稳定,登录 cookie 单步来。 +set -u + +BASE=https://relay.xxmxxs.top +UA='LanRemote/1.8.0' + +echo "════════ 0. 先等隧道稳定 ════════" +for i in $(seq 1 10); do + c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \ + -H "User-Agent: $UA" "$BASE/v1/health") + printf " 第 %d 次: %s\n" "$i" "$c" + [ "$c" = "200" ] && break + sleep 3 +done + +echo +echo "════════ 1. 中转版本 ════════" +curl -s --max-time 15 "$BASE/v1/health" | head -c 180 +echo + +echo +echo "════════ 2. 官网 ════════" +curl -s --max-time 20 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html +echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/") $(wc -c < /tmp/site.html) 字节" +grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /' + +echo +echo "════════ 3. 三个成品(响应头 + 真下 4KB)════════" +for f in ykxmz-setup-1.8.0.exe ykxmz-server-1.8.0.zip ykxmz-watch-1.8.0.apk; do + rm -f /tmp/chunk.bin + hdr=$(curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE/download/$f") + hcode=$(echo "$hdr" | head -1 | awk '{print $2}') + hlen=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}') + htype=$(echo "$hdr" | grep -i '^content-type' | tr -d '\r' | cut -d' ' -f2) + curl -s --max-time 60 -r 0-4095 -o /tmp/chunk.bin -H "User-Agent: $UA" \ + "$BASE/download/$f" >/dev/null 2>&1 + real=$(wc -c < /tmp/chunk.bin 2>/dev/null || echo 0) + printf " %-30s HEAD %-4s 长度 %-10s %-34s 实体取到 %s 字节\n" \ + "$f" "$hcode" "${hlen:-?}" "${htype:-?}" "$real" +done + +echo +echo "════════ 4. 管理后台 ════════" +PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null) +rm -f /tmp/ck.txt +curl -s -c /tmp/ck.txt -o /dev/null --max-time 20 \ + -d "username=xmz7891&password=$PASS" "$BASE/admin/login" +echo " cookie: $(grep -c lr_admin /tmp/ck.txt 2>/dev/null || echo 0) 条" +for p in "/zhongzhuanzhan" "/admin"; do + printf " %-20s HTTP %s\n" "$p" \ + "$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt \ + --max-time 20 -H "User-Agent: $UA" "$BASE$p")" +done +curl -s -b /tmp/ck.txt --max-time 20 -H "User-Agent: $UA" \ + "$BASE/zhongzhuanzhan" -o /tmp/adm.html +if [ -f /tmp/adm.html ]; then + echo " 管理页 $(wc -c < /tmp/adm.html) 字节" + for k in 'class="top"' 'class="hero"' 'class="nums"' 'class="card"' \ + 'ykxmz 中转站' 'id="pickAll"' 'id="netBtn"'; do + if grep -q "$k" /tmp/adm.html; then + echo " ✓ $k" + else + echo " ✗ $k" + fi + done +else + echo " [失败] 抓不到管理页" +fi + +echo +echo "════════ 5. 静态资源 ════════" +for p in "/shot/pc-main.png" "/favicon.ico"; do + printf " %-22s HTTP %s\n" "$p" \ + "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$BASE$p")" +done + +echo +echo "════════ 6. 公网可用率(15 次,间隔 2 秒)════════" +ok=0 +for i in $(seq 1 15); do + c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \ + -H "User-Agent: $UA" "$BASE/v1/health") + [ "$c" = "200" ] && ok=$((ok + 1)) + printf " %2d: %s\n" "$i" "$c" + sleep 2 +done +echo " 可用 $ok/15" + +echo +echo "════════ 7. 中转有没有报错 ════════" +journalctl -u lanremote-relay --since '5 minutes ago' --no-pager 2>/dev/null \ + | grep -icE 'traceback|nameerror|typeerror|attributeerror' \ + | sed 's/^/ 错误行数: /' +tail -3 /root/lanremote-relay/relay.log | sed 's/^/ /' diff --git a/_build/bump_180.py b/_build/bump_180.py new file mode 100644 index 0000000..1a6fd75 --- /dev/null +++ b/_build/bump_180.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +r'''把全仓版本号从 1.7.1 升到 1.8.0。 + +为什么写脚本而不是手动改: + 版本号散落在 8 个文件里(Python / Java manifest / PowerShell / + Inno Setup / 测试 / 打包脚本),手动改必漏。 + 而且 .ps1 必须保留 UTF-8 BOM —— 用 edit 工具改会把 BOM 吃掉, + 导致 PowerShell 按 GBK 解析、中文变乱码、脚本报语法错(这坑踩过)。 + + 所以统一用 Python 读写字节,按后缀决定要不要 BOM。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") + +OLD = "1.7.1" +NEW = "1.8.0" +BOM = b"\xef\xbb\xbf" + +TARGETS = [ + "android/AndroidManifest.xml", + "android/build_apk.ps1", + "installer/ykxmz.iss", + "relay/relay.py", + "windows/lanremote/__init__.py", + "_tests/test_apk_contents.py", + "_tests/test_package_remote.py", + "_build/package_remote.py", +] + +NEEDS_BOM = {".ps1"} + + +def main() -> int: + print(" %s -> %s" % (OLD, NEW)) + print() + total = 0 + for rel in TARGETS: + path = ROOT / rel + if not path.is_file(): + print(" [跳过] %s 不存在" % rel) + continue + + raw = path.read_bytes() + had_bom = raw.startswith(BOM) + text = raw.decode("utf-8-sig") + + if OLD not in text: + print(" [跳过] %-38s 里面没有 %s" % (rel, OLD)) + continue + + count = text.count(OLD) + text = text.replace(OLD, NEW) + + want_bom = path.suffix in NEEDS_BOM or had_bom + data = text.encode("utf-8") + if want_bom: + data = BOM + data + path.write_bytes(data) + + note = "" + if want_bom and not had_bom: + note = "(补上 BOM)" + elif had_bom and not want_bom: + note = "(去掉 BOM)" + print(" %-38s 改了 %d 处%s" % (rel, count, note)) + total += count + + print() + print(" 共改 %d 处" % total) + + print() + print(" 复核 —— 代码里还有哪里留着 %s:" % OLD) + found = False + for path in sorted(ROOT.rglob("*")): + if not path.is_file(): + continue + rel = str(path.relative_to(ROOT)) + if any(part in rel for part in + (".git\\", "dist\\", "dist_exe", "_build\\_", "toolchain", + "node_modules", "__pycache__", "_shots")): + continue + if path.suffix not in (".py", ".java", ".iss", ".ps1", ".md", + ".json", ".sh", ".bat", ".xml"): + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if OLD in text: + tag = "(文档,可保留)" if path.suffix == ".md" else "**要改**" + print(" %-52s %s" % (rel, tag)) + if path.suffix != ".md": + found = True + if not found: + print(" (代码里已经没有了)") + + print() + print(" BOM 复核(.ps1 必须有):") + for rel in TARGETS: + path = ROOT / rel + if not path.is_file(): + continue + has = path.read_bytes().startswith(BOM) + want = path.suffix in NEEDS_BOM + mark = "OK" if has == want else "**不对**" + print(" %-38s BOM=%-5s 应=%-5s %s" % (rel, has, want, mark)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/clean_avail.sh b/_build/clean_avail.sh new file mode 100644 index 0000000..c946928 --- /dev/null +++ b/_build/clean_avail.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# 干净可用率测量:没有并发下载,间隔 3 秒,30 次 +set -u + +BASE=https://relay.xxmxxs.top +UA='LanRemote/1.8.0' + +echo " 先静置 20 秒,等 4G 链路缓过来…" +sleep 20 + +echo +echo " 隧道连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')" +echo + +ok=0 +fail=0 +declare -a bad +for i in $(seq 1 30); do + c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \ + -H "User-Agent: $UA" "$BASE/v1/health") + if [ "$c" = "200" ]; then + ok=$((ok + 1)) + else + fail=$((fail + 1)) + bad+=("$i:$c") + fi + printf " %2d: %s\n" "$i" "$c" + sleep 3 +done + +echo +echo " ──────────────────────────────" +echo " 可用 $ok/30 = $((ok * 100 / 30))%" +echo " 失败 $fail 次: ${bad[*]:-无}" + +echo +echo " 失败时隧道连接数变化(看是不是掉到 1):" +echo " $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')" + +echo +echo " watchdog 最近 8 行:" +tail -8 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /' + +echo +echo " 网络质量:" +ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -2 | sed 's/^/ /' diff --git a/_build/dbg_head.py b/_build/dbg_head.py new file mode 100644 index 0000000..52be641 --- /dev/null +++ b/_build/dbg_head.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +r'''调试:管理页的 HEAD 为什么报告成 text/plain。''' +from __future__ import annotations + +import os +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +PORT = 8947 + + +def wait(seconds=12.0) -> bool: + end = time.time() + seconds + while time.time() < end: + try: + with urllib.request.urlopen( + "http://127.0.0.1:%d/v1/health" % PORT, timeout=2): + return True + except Exception: # noqa: BLE001 + time.sleep(0.5) + return False + + +def main() -> int: + # 先看代码里 _head_info 到底长什么样 + print(" 代码里的 _head_info 分支顺序:") + src = (ROOT / "relay" / "relay.py").read_text(encoding="utf-8") + start = src.find("def _head_info") + for line in src[start:start + 2600].splitlines(): + stripped = line.strip() + if stripped.startswith(("if ", "return ok", "return no", + "def ", "target =", "body =")): + print(" " + stripped[:78]) + + print() + print(" 直接 import 试调用:") + sys.path.insert(0, str(ROOT / "relay")) + os.environ["RELAY_PORT"] = str(PORT) + data = ROOT / "_build" / "_hdrdbg" + data.mkdir(parents=True, exist_ok=True) + os.environ["RELAY_DATA"] = str(data) + + import importlib + import relay as relay_mod + importlib.reload(relay_mod) + + handler = relay_mod.Handler + info = handler._head_info(None, relay_mod.ADMIN_PATH) + print(" ADMIN_PATH = %r" % relay_mod.ADMIN_PATH) + print(" 返回: %r" % (info,)) + + info2 = handler._head_info(None, "/") + print(" '/' 返回: status=%s ctype=%s len=%s" + % (info2["status"], info2["ctype"], info2["length"])) + + info3 = handler._head_info(None, "/download/ykxmz-watch-1.8.0.apk") + print(" 下载返回: status=%s ctype=%s len=%s" + % (info3["status"], info3["ctype"], info3["length"])) + + print() + print(" 真起一个中转,发 HEAD 看真实响应:") + env = dict(os.environ) + proc = subprocess.Popen([sys.executable, "relay.py"], + cwd=str(ROOT / "relay"), env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + try: + if not wait(): + print(" [失败] 没起来") + err = proc.stderr.read(2000).decode("utf-8", "replace") + print(err[-800:]) + return 1 + for path in ("/", relay_mod.ADMIN_PATH, "/v1/health", + "/download/ykxmz-watch-1.8.0.apk"): + req = urllib.request.Request( + "http://127.0.0.1:%d%s" % (PORT, path), method="HEAD") + try: + with urllib.request.urlopen(req, timeout=8) as resp: + print(" %-38s %s len=%s type=%s" + % (path, resp.status, + resp.headers.get("Content-Length"), + resp.headers.get("Content-Type"))) + except Exception as exc: # noqa: BLE001 + print(" %-38s 异常 %s" % (path, exc)) + finally: + proc.terminate() + try: + err = proc.stderr.read(3000).decode("utf-8", "replace") + except Exception: # noqa: BLE001 + err = "" + try: + proc.wait(timeout=6) + except subprocess.TimeoutExpired: + proc.kill() + if "Traceback" in err: + print() + print(" 中转报错:") + print(" " + err.strip()[-1200:].replace("\n", "\n ")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/deploy_180.py b/_build/deploy_180.py new file mode 100644 index 0000000..e03b99b --- /dev/null +++ b/_build/deploy_180.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r'''把本地 relay 代码同步到设备并重启,然后验证。 + +为什么单独一个脚本: + 之前用内联 python -c 配 PowerShell 的 @"..."@, + 里面只要出现 $(...) / 反斜杠 / 嵌套引号就会被 PowerShell 吃掉 + (这个坑今天踩了不下 10 次)。写成文件最稳。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import run, upload # noqa: E402 + +FILES = [ + ("relay/relay.py", "/root/lanremote-relay/relay.py", 0o755), + ("relay/admin.py", "/root/lanremote-relay/admin.py", 0o644), + ("relay/landing.py", "/root/lanremote-relay/landing.py", 0o644), + ("relay/showcase.py", "/root/lanremote-relay/showcase.py", 0o644), + ("relay/watchdog.sh", "/root/lanremote-relay/watchdog.sh", 0o755), + ("relay/net-tune.sh", "/root/lanremote-relay/net-tune.sh", 0o755), + ("relay/speed-up-boot.sh", "/root/lanremote-relay/speed-up-boot.sh", 0o755), + ("relay/tunnel-run.sh", "/root/lanremote-relay/tunnel-run.sh", 0o755), +] + + +def main() -> int: + print(" 同步代码:") + for local, remote, mode in FILES: + src = ROOT / local + if not src.is_file(): + print(" [跳过] %s 不存在" % local) + continue + upload(str(src), remote, mode) + print(" ✓ %-26s %d 字节" % (src.name, src.stat().st_size)) + + print() + print(" 重启中转:") + _c, out, _e = run("systemctl restart lanremote-relay; sleep 6; " + "systemctl is-active lanremote-relay", timeout=120) + print(" " + (out or "").strip().splitlines()[-1]) + + print() + print(" 验证:") + checks = [ + ("中转版本", "curl -s --max-time 8 http://127.0.0.1:8080/v1/health " + "| head -c 200"), + ("管理页响应头", "curl -sI --max-time 10 " + "http://127.0.0.1:8080/zhongzhuanzhan " + "| grep -i '^content-type'"), + ("官网响应头", "curl -sI --max-time 10 http://127.0.0.1:8080/ " + "| grep -i '^content-type'"), + ("官网大小", "curl -s --max-time 10 http://127.0.0.1:8080/ | wc -c"), + ("下载接口", "curl -s --max-time 10 " + "http://127.0.0.1:8080/v1/downloads"), + ] + for label, cmd in checks: + _c, out, _e = run(cmd, timeout=60) + text = (out or "").strip() + if len(text) > 220: + text = text[:220] + "…" + print(" %-14s %s" % (label, text)) + + print() + print(" 下载链接实测(走公网):") + links = [ + "/download/ykxmz-setup-1.8.0.exe", + "/download/ykxmz-server-1.8.0.zip", + "/download/ykxmz-watch-1.8.0.apk", + ] + for link in links: + _c, out, _e = run( + "curl -s -o /dev/null -w '%%{http_code} %%{size_download}' " + "--max-time 60 -r 0-1023 https://relay.xxmxxs.top" + link, + timeout=120) + print(" %-34s %s" % (link, (out or "").strip())) + + print() + print(" 公网首页 / 管理页:") + for path, label in (("/", "官网"), ("/zhongzhuanzhan", "管理页")): + _c, out, _e = run( + "curl -s -o /dev/null -w '%%{http_code}' --max-time 20 " + "-H 'User-Agent: LanRemote/1.8.0' https://relay.xxmxxs.top" + path, + timeout=120) + print(" %-8s HTTP %s" % (label, (out or "").strip())) + + print() + print(" 中转日志最后几行:") + _c, out, _e = run("tail -6 /root/lanremote-relay/relay.log", timeout=60) + for line in (out or "").strip().splitlines(): + print(" " + line) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_built_var.py b/_build/fix_built_var.py new file mode 100644 index 0000000..85cc167 --- /dev/null +++ b/_build/fix_built_var.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r'''把 watchdog.sh 里裸用的 $BUILT 换成 ${BUILT:-0}。 + +背景: + watchdog.sh 开头有 `set -u`(未定义变量直接报错退出)。 + 我给 restart_tunnel() 里加了 `write_state 0 0 "$BUILT"`, + 但这个函数定义在 `BUILT=$(read_state built 0)` **之前**, + 函数被调用时 BUILT 还不存在 -> 报 "BUILT: 未绑定的变量"。 + + 这么写一次就够,然后用 bash -n 和实跑验证。 +''' +from pathlib import Path + +WD = Path("/root/lanremote-relay/watchdog.sh") + + +def main() -> int: + text = WD.read_text(encoding="utf-8") + + old = 'write_state 0 0 "$BUILT"' + new = 'write_state 0 0 "${BUILT:-0}"' + count = text.count(old) + if count: + text = text.replace(old, new) + WD.write_text(text, encoding="utf-8", newline="\n") + print(" 替换 %d 处" % count) + + print() + print(" 现在所有 BUILT 用法:") + for index, line in enumerate(WD.read_text(encoding="utf-8").splitlines(), + 1): + if "BUILT" in line: + mark = "" + if "$BUILT" in line and "${BUILT:-0}" not in line: + mark = " <-- 检查这里" + print(" %3d: %s%s" % (index, line.strip(), mark)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index d1098b6..bd66fbc 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 = "1.7.1" +VERSION = "1.8.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" diff --git a/_build/smart_watchdog.sh b/_build/smart_watchdog.sh new file mode 100644 index 0000000..dc04963 --- /dev/null +++ b/_build/smart_watchdog.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# 让 watchdog 更聪明:区分「隧道还在建」和「隧道建好了又垮」 +# +# 现在的逻辑(宽限期): +# 重启后 150 秒内绝不重启 —— 防住了"还没建齐就被打断"的死循环。 +# 但这带来一个新问题: +# 如果链路是**持续性的差**,150 秒里看门狗什么都不做, +# 用户就一直打不开。 +# +# 更聪明的判断: +# 重启后记录"这次有没有建齐过"(连接数 >= 3)。 +# * 没建齐过 -> 它还在努力,继续等(宽限期有意义) +# * 建齐过又垮 -> 已经证明链路能撑起来,现在垮了 = 真故障, +# 不用再等宽限期,立刻重启 +# +# 这样两种场景都对: +# 链路慢(建得久) -> 耐心等,不打断 +# 链路抖(建好又断)-> 果断重启,不干等 +set -u + +WD=/root/lanremote-relay/watchdog.sh +cp -a "$WD" "$WD.bak.$(date +%s)" + +echo "════════ 改之前的参数 ════════" +grep -E '^MIN_RESTART_GAP|^GRACE|^THRESHOLD' "$WD" | sed 's/^/ /' + +python3 - <<'PY' +from pathlib import Path + +path = Path("/root/lanremote-relay/watchdog.sh") +text = path.read_text(encoding="utf-8") + +# ---- 1. 状态文件多存一个 "这次重启后建齐过吗" ---- +old_write = '''write_state() { printf 'zero=%s\\nfail=%s\\n' "$1" "$2" > "$STATE_FILE"; }''' +new_write = '''# 状态文件现在存三样: +# zero —— 连续降级次数 +# fail —— 连续失败次数 +# built —— 本次重启后有没有建齐过(1=建齐过) +write_state() { + printf 'zero=%s\\nfail=%s\\nbuilt=%s\\n' "$1" "$2" "${3:-0}" > "$STATE_FILE" +}''' +if old_write in text: + text = text.replace(old_write, new_write, 1) + print(" 已扩展状态文件") +else: + print(" [警告] 找不到 write_state") + +# ---- 2. 重启时把 built 清零 ---- +text = text.replace( + " echo \"$now\" > \"$LAST_RESTART_FILE\"\n" + " systemctl restart lanremote-tunnel\n" + " write_state 0 0\n", + " echo \"$now\" > \"$LAST_RESTART_FILE\"\n" + " systemctl restart lanremote-tunnel\n" + " write_state 0 0 0\n", 1) + +# ---- 3. 「先不重启」那两处也带上 built ---- +text = text.replace( + " log \"$why —— 但距上次重启才 ${gap} 秒(下限 ${MIN_RESTART_GAP} 秒),先不重启\"\n" + " write_state 0 0\n", + " log \"$why —— 但距上次重启才 ${gap} 秒(下限 ${MIN_RESTART_GAP} 秒),先不重启\"\n" + " write_state 0 0 \"${BUILT:-0}\"\n", 1) + +# ---- 4. 主判断:加「建齐过就立刻重启」 ---- +old_block = '''if [ "$DEGRADED" = "1" ]; then + # 宽限期内不重启 —— 让上一次重启有机会把连接建齐。 + # (不加这条会死循环:隧道还没建齐就被再次打断。) + LAST_R=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0) + SINCE=$(( $(date +%s) - LAST_R )) + if [ "$SINCE" -lt "$GRACE" ]; then + log "公网不通且只剩 ${CONNS} 条连接,但距上次重启才 ${SINCE} 秒(宽限 ${GRACE} 秒)—— 先让它建完" + else + restart_tunnel "公网不通且只剩 ${CONNS} 条隧道连接(确认故障,立即重启)" + fi +fi''' + +new_block = '''if [ "$DEGRADED" = "1" ]; then + # 宽限期内不重启 —— 让上一次重启有机会把连接建齐。 + # (不加这条会死循环:隧道还没建齐就被再次打断。) + LAST_R=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0) + SINCE=$(( $(date +%s) - LAST_R )) + + # 但要看「这次重启后建齐过没有」: + # 建齐过(>=3 条)又垮下来 = 链路本来能撑住,现在是真故障 + # -> 不用再等宽限期,立刻重启 + # 从没建齐过 = 它还在努力建 + # -> 继续等,别打断它 + if [ "${BUILT:-0}" = "1" ]; then + restart_tunnel "公网不通,且本次重启后曾建齐过连接(确认故障,立即重启)" + elif [ "$SINCE" -lt "$GRACE" ]; then + log "公网不通且只剩 ${CONNS} 条连接,但本次重启还没建齐过(${SINCE}/${GRACE} 秒)—— 继续等它建" + else + restart_tunnel "公网不通且 ${SINCE} 秒都没建齐(链路或边缘有问题,重启试试)" + fi +fi''' +if old_block in text: + text = text.replace(old_block, new_block, 1) + print(" 已加「建齐过就立刻重启」判断") +else: + print(" [警告] 找不到立即重启那段,没改") + +# ---- 5. 读状态 + 记录建齐 ---- +old_read = '''ZERO=$(read_state zero 0) +FAIL=$(read_state fail 0)''' +new_read = '''ZERO=$(read_state zero 0) +FAIL=$(read_state fail 0) +BUILT=$(read_state built 0)''' +if old_read in text: + text = text.replace(old_read, new_read, 1) + print(" 已读入 built 状态") +else: + print(" [警告] 找不到读状态那段") + +# 在算出 CONNS 之后,判断有没有建齐,并持久化 +old_conns = '''[ -z "$CONNS" ] && CONNS=""''' +new_conns = '''[ -z "$CONNS" ] && CONNS="" + +# 连接数达到 3 条以上就算"建齐过"。 +# 记在状态文件里,跨多次检查保留 —— 这样即使后面掉到 1 条, +# 我们也知道"它本来是能建齐的"。 +if [ -n "$CONNS" ] && [ "$CONNS" -ge 3 ] 2>/dev/null; then + if [ "$BUILT" != "1" ]; then + log "本次已建齐 ${CONNS} 条连接" + fi + BUILT=1 +fi''' +if old_conns in text: + text = text.replace(old_conns, new_conns, 1) + print(" 已加建齐检测") +else: + print(" [警告] 找不到 CONNS 那段") + +# ---- 6. 收尾写状态时带上 built ---- +text = text.replace('write_state "$ZERO" "$FAIL"', + 'write_state "$ZERO" "$FAIL" "$BUILT"') +text = text.replace(' write_state 0 0\n exit 0', + ' write_state 0 0 "$BUILT"\n exit 0') + +path.write_text(text, encoding="utf-8", newline="\n") +print(" 已写回") +PY + +echo +echo "════════ 改之后 ════════" +bash -n "$WD" && echo " 语法 OK" || echo " **语法有错**" +grep -E '^MIN_RESTART_GAP|^GRACE|^THRESHOLD' "$WD" | sed 's/^/ /' +grep -c 'BUILT' "$WD" | sed 's/^/ BUILT 出现次数: /' + +echo +echo "════════ 立即跑一次看输出 ════════" +bash "$WD" 2>&1 | tail -4 | sed 's/^/ /' + +echo +echo " 状态文件:" +cat /root/lanremote-relay/watchdog.state 2>/dev/null | sed 's/^/ /' diff --git a/_build/sync_watchdog_repack.py b/_build/sync_watchdog_repack.py new file mode 100644 index 0000000..c1f0a3a --- /dev/null +++ b/_build/sync_watchdog_repack.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +r'''把设备上改好的 watchdog.sh 同步回仓库,重新打服务端 zip 并上传。 + +为什么要同步回来: + watchdog 的「建齐过就立刻重启」逻辑是在设备上直接改的, + 本地仓库还是旧版。不同步回来,打出来的 zip 就是旧的, + 用户装到新设备上就没有这个改进。 +''' +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import run, upload # noqa: E402 + +WD_LOCAL = ROOT / "relay" / "watchdog.sh" + + +def main() -> int: + # ---- 1. 从设备拉回 watchdog ---- + print(" 1. 从设备拉回 watchdog.sh") + _c, out, _e = run("cat /root/lanremote-relay/watchdog.sh", timeout=120) + if not out or "MIN_RESTART_GAP" not in out: + print(" [失败] 拉不到") + return 1 + old = WD_LOCAL.read_text(encoding="utf-8") if WD_LOCAL.is_file() else "" + WD_LOCAL.write_text(out, encoding="utf-8", newline="\n") + print(" %d 行 -> relay/watchdog.sh(原来 %d 行)" + % (out.count("\n"), old.count("\n"))) + + # 关键内容自检 + for token in ("GRACE", "BUILT", "built=", "立即重启"): + print(" %s %s" % ("有" if token in out else "**缺**", token)) + + # ---- 2. 重新打服务端 zip ---- + print() + print(" 2. 重打服务端 zip") + r = subprocess.run([sys.executable, "_build/package_remote.py"], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace") + for line in (r.stdout or "").strip().splitlines()[-8:]: + print(" " + line) + + # ---- 3. 重新上传 ---- + print() + print(" 3. 重新上传服务端 zip") + src = ROOT / "dist" / "ykxmz-server-1.8.0.zip" + if not src.is_file(): + print(" [失败] 没打出 zip") + return 1 + upload(str(src), "/root/lanremote-relay/downloads/ykxmz-server-1.8.0.zip", + 0o644) + print(" ✓ ykxmz-server-1.8.0.zip %.2f MB" + % (src.stat().st_size / 1024 / 1024)) + + # ---- 4. 确认 ---- + print() + print(" 4. 服务端 zip 内容自检") + import zipfile + with zipfile.ZipFile(src) as archive: + names = archive.namelist() + wd = None + for name in names: + if name.endswith("watchdog.sh"): + wd = archive.read(name).decode("utf-8") + break + print(" 条目 %d 个" % len(names)) + if wd: + for token in ("GRACE=150", "BUILT", "built="): + print(" %s %s" % ("有" if token in wd else "**缺**", token)) + + print() + print(" 5. 设备上下载目录") + _c, out, _e = run("ls -la /root/lanremote-relay/downloads/", timeout=60) + for line in (out or "").strip().splitlines(): + print(" " + line) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/test_installer_cycle.py b/_build/test_installer_cycle.py index fb37546..4ec2ce1 100644 --- a/_build/test_installer_cycle.py +++ b/_build/test_installer_cycle.py @@ -25,7 +25,7 @@ import time from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -SETUP = ROOT / "dist" / "ykxmz-setup-1.7.1.exe" +SETUP = ROOT / "dist" / "ykxmz-setup-1.8.0.exe" APP = Path(r"C:\Program Files\ykxmz") DATA = Path(r"C:\ProgramData\ykxmz") EXE = APP / "ykxmz.exe" diff --git a/_build/test_response_headers.py b/_build/test_response_headers.py index 4ef5273..b9b889c 100644 --- a/_build/test_response_headers.py +++ b/_build/test_response_headers.py @@ -151,6 +151,22 @@ def main() -> int: check("管理页不是 application/json", "json" not in admin["ctype"].lower(), admin["ctype"]) + print() + print(" 二b、HEAD 下载文件要报对长度(不能是 0)") + # 这条路原来漏了:HEAD 一个 APK 会返回 Content-Length: 0 + # + text/plain,下载器据此认为文件是空的。 + dldir = ROOT / "relay" / "downloads" + dldir.mkdir(parents=True, exist_ok=True) + probe = dldir / "probe-1.8.0.apk" + probe.write_bytes(b"PK\\x03\\x04" + b"x" * 4096) + info = head("/download/probe-1.8.0.apk") + check("HEAD 下载返回 200", info["status"] == 200, info["status"]) + check("HEAD 下载 Content-Length 不为 0", + info["len"] not in ("", "0"), info["len"]) + check("HEAD 下载类型是 android 包", + "android" in info["ctype"].lower() + or "octet-stream" in info["ctype"].lower(), info["ctype"]) + print() print(" 三、其它资源的类型") icon = head("/favicon.ico") diff --git a/_build/upload_180.py b/_build/upload_180.py new file mode 100644 index 0000000..726074b --- /dev/null +++ b/_build/upload_180.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +r'''把 1.8.0 的三个成品上传到中转的下载目录。 + +发布规则(用户定的,不能违反): + * Windows 成品必须是**安装包 .exe**,不是 agent zip + (用户要"双击就能装",不能让他去解压) + * 服务端给 zip + * 手表给 APK + * 上一个版本的成品要删掉,别让用户下错 + +顺手做的事: + 给 verify_all.ps1 补 UTF-8 BOM(edit 工具会吃掉 BOM, + PowerShell 按 GBK 读就会中文乱码 + 语法报错) +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import run, upload # noqa: E402 + +VERSION = "1.8.0" +OLD_VERSIONS = ["1.7.0", "1.7.1", "1.6.0", "1.6.1", "1.5.0"] + +REMOTE_DIR = "/root/lanremote-relay/downloads" + +# 顺序就是官网上显示的优先级 +UPLOADS = [ + (f"dist/ykxmz-setup-{VERSION}.exe", f"{REMOTE_DIR}/ykxmz-setup-{VERSION}.exe"), + (f"dist/ykxmz-server-{VERSION}.zip", f"{REMOTE_DIR}/ykxmz-server-{VERSION}.zip"), + (f"dist/ykxmz-watch-{VERSION}.apk", f"{REMOTE_DIR}/ykxmz-watch-{VERSION}.apk"), + (f"dist/ykxmz-watch-{VERSION}-说明.txt", + f"{REMOTE_DIR}/ykxmz-watch-{VERSION}-说明.txt"), +] + +VERIFY = ROOT / "_tests" / "verify_all.ps1" + + +def fix_bom() -> None: + raw = VERIFY.read_bytes() + if raw.startswith(b"\xef\xbb\xbf"): + print(" verify_all.ps1: BOM 正常") + else: + VERIFY.write_bytes(b"\xef\xbb\xbf" + raw) + print(" verify_all.ps1: 补回了 UTF-8 BOM") + + +def main() -> int: + fix_bom() + + # 本地先确认三个成品都在 + print() + print(" 本地成品检查:") + missing = [] + for local, _remote in UPLOADS: + src = ROOT / local + if src.is_file(): + print(" ✓ %-38s %7.2f MB" + % (src.name, src.stat().st_size / 1024 / 1024)) + else: + print(" ✗ %-38s 不存在" % local) + missing.append(local) + if missing: + print() + print(" [失败] 少了 %d 个成品,先构建" % len(missing)) + return 1 + + # 清掉旧版本,免得用户下错 + print() + print(" 清理设备上的旧版本:") + run("mkdir -p " + REMOTE_DIR, timeout=30) + for old in OLD_VERSIONS: + _c, out, _e = run( + "ls %s/ 2>/dev/null | grep -F '%s' || true" + % (REMOTE_DIR, old), timeout=30) + for name in (out or "").strip().splitlines(): + if not name.strip(): + continue + run("rm -f '%s/%s'" % (REMOTE_DIR, name.strip()), timeout=30) + print(" - 删掉 %s" % name.strip()) + + # 上传 + print() + print(" 上传:") + for local, remote in UPLOADS: + src = ROOT / local + try: + upload(str(src), remote, 0o644) + print(" ✓ %s (%.2f MB)" + % (src.name, src.stat().st_size / 1024 / 1024)) + except Exception as exc: # noqa: BLE001 + print(" ✗ %s: %s" % (src.name, exc)) + return 1 + + # 确认 + print() + print(" 设备上现在的下载目录:") + _c, out, _e = run("ls -la %s/" % REMOTE_DIR, timeout=30) + for line in (out or "").strip().splitlines(): + print(" " + line) + + # 确认中转能列出来 + print() + print(" 中转接口返回的下载列表:") + _c, out, _e = run( + "curl -s --max-time 10 http://127.0.0.1:8080/v1/downloads", timeout=60) + print(" " + (out or "").strip()[:600]) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/shots/1-屏幕中间.png b/_tests/shots/1-屏幕中间.png index 04bcb0b..549668f 100644 Binary files a/_tests/shots/1-屏幕中间.png and b/_tests/shots/1-屏幕中间.png differ diff --git a/_tests/shots/3-自定义640x400.png b/_tests/shots/3-自定义640x400.png index d0daadc..4e38939 100644 Binary files a/_tests/shots/3-自定义640x400.png and b/_tests/shots/3-自定义640x400.png differ diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index c8d8420..9da0112 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-1.7.1.apk" + ROOT / "dist" / "ykxmz-watch-1.8.0.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index 3f7a40c..be9a256 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-1.7.1.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.7.1.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.7.1.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.8.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.8.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.8.0.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-1.7.1-说明.txt")): + ROOT / "dist" / "ykxmz-watch-1.8.0-说明.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 b60b611..103f525 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="1.8.0"> diff --git a/android/build_apk.ps1 b/android/build_apk.ps1 index 0c6a291..001ba42 100644 --- a/android/build_apk.ps1 +++ b/android/build_apk.ps1 @@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat" $work = Join-Path $android "build" $outDir = Join-Path $root "dist" -$apkName = "ykxmz-watch-1.7.1.apk" +$apkName = "ykxmz-watch-1.8.0.apk" function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } @@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk" --min-sdk-version 24 ` --target-sdk-version 34 ` --version-code 5 ` - --version-name "1.7.1" ` + --version-name "1.8.0" ` --auto-add-overlay if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss index d89e503..6f3b671 100644 --- a/installer/ykxmz.iss +++ b/installer/ykxmz.iss @@ -27,7 +27,7 @@ ; ============================================================================ #define MyAppName "ykxmz" -#define MyAppVersion "1.7.1" +#define MyAppVersion "1.8.0" #define MyAppPublisher "小麦子" #define MyAppExeName "ykxmz.exe" @@ -149,7 +149,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \ ; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住 ; (PowerShell 的 Start-Process -Wait 就是)。 ; 学校批量部署请用 cmd: -; cmd /c ykxmz-setup-1.7.1.exe /VERYSILENT /CODE=123456 +; cmd /c ykxmz-setup-1.8.0.exe /VERYSILENT /CODE=123456 ; cmd 只等安装器本身,不等孙子进程,不会卡。 Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Description: "立即启动(后台运行,不显示窗口)"; \ diff --git a/relay/downloads/probe-1.8.0.apk b/relay/downloads/probe-1.8.0.apk new file mode 100644 index 0000000..8d3752f --- /dev/null +++ b/relay/downloads/probe-1.8.0.apk @@ -0,0 +1 @@ +PK\x03\x04xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file diff --git a/relay/relay.py b/relay/relay.py index 2677e34..98fc5eb 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -59,7 +59,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -VERSION = "1.7.1" +VERSION = "1.8.0" # ---------------- 参数(都能用环境变量覆盖)---------------- @@ -1032,45 +1032,130 @@ class Handler(BaseHTTPRequestHandler): 之前没实现,HEAD 一律 501 Unsupported method。 浏览器用 GET 所以页面照开,但: * Cloudflare 的健康检查、很多监控/代理探测会用 HEAD - * curl -I 也是 HEAD,排查时看不到响应头很别扭 - 这里按 GET 算好状态和长度,把体丢掉就行。 + * curl -I 也是 HEAD,排查时看不出响应头很别扭 + + ⚠ 第一版实现有个 bug:它把响应体整个算出来再取长度。 + 对下载路由(15 MB 的安装包)等于要 read 一遍整个文件 —— + 既浪费内存又慢。更糟的是当时**没给 /download/ 写分支**, + 于是 HEAD 一个 APK 会返回 `Content-Length: 0` + `text/plain`, + 下载器(IDM、迅雷、部分浏览器)据此会认为文件是空的。 + + 现在改成只算**元数据**(状态码 / 类型 / 长度 / 附加头), + 一个字节都不读。 """ RELAY.total_requests += 1 path = self.path.split("?")[0] try: - body, ctype = self._head_info(path) + info = self._head_info(path) except Exception: # noqa: BLE001 - body, ctype = b"", "text/plain; charset=utf-8" - self.send_response(200 if body is not None else 404) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(body or b""))) - self.send_header("Cache-Control", "no-store") + info = {"status": 500, "ctype": "text/plain; charset=utf-8", + "length": 0, "extra": {}} + + self.send_response(info["status"]) + self.send_header("Content-Type", info["ctype"]) + self.send_header("Content-Length", str(info["length"])) + for name, value in (info.get("extra") or {}).items(): + self.send_header(name, value) + # 静态图可以缓存,跟 GET 那边保持一致 + if path.startswith("/shot/"): + self.send_header("Cache-Control", "public, max-age=86400") + else: + self.send_header("Cache-Control", "no-store") self.end_headers() - def _head_info(self, path: str) -> tuple: - """给 HEAD 用:算这个路径的响应类型和长度(不真的发体)。""" + def _as_bytes(self, value) -> bytes: + """把页面函数的结果统一转成 bytes。 + + 为什么需要:`_site_page()` 返回 str,`_login_page()` 返回 bytes, + 两个同类的函数返回类型不一致。直接对 bytes 调 .encode() + 会 AttributeError,而且会被上层兜底悄悄吃掉, + 表现为"页面类型莫名变成 text/plain",很难查。 + 这个函数把两种情况都兜住。 + """ + if isinstance(value, bytes): + return value + if isinstance(value, str): + return value.encode("utf-8") + return b"" + + def _head_info(self, path: str) -> dict: + """给 HEAD 用:算这个路径的响应元数据,不读任何大文件。 + + 注意:**不能标 @staticmethod**。 + 登录页要走 self._login_page(),静态方法里没有 self, + 一调用就抛异常,被 do_HEAD 的兜底吃掉, + 结果管理页的 HEAD 报告成 text/plain(今天真踩了这次)。 + """ + + def ok(ctype: str, length: int, extra: dict | None = None) -> dict: + return {"status": 200, "ctype": ctype, "length": length, + "extra": extra or {}} + + def no(status: int = 404) -> dict: + return {"status": status, "ctype": "text/plain; charset=utf-8", + "length": 0, "extra": {}} + if path == "/": - return _site_page().encode("utf-8"), "text/html; charset=utf-8" - if path == "/v1/health": - return b"{}", "application/json" + body = self._as_bytes(_site_page()) + return ok("text/html; charset=utf-8", len(body)) + if path in ("/v1/health", "/v1/downloads"): + return ok("application/json", 2) if path in ("/url", "/v1/url"): - return (public_url() or "").encode("utf-8"), \ - "text/plain; charset=utf-8" - if path == "/favicon.ico": + return ok("text/plain; charset=utf-8", len(public_url() or "")) + if path in ("/favicon.ico", "/shot/favicon.ico"): icon = Path(__file__).resolve().parent / "favicon.ico" - return (icon.read_bytes() if icon.is_file() else b""), \ - "image/x-icon" - if path == ADMIN_PATH: - return b"", "text/html; charset=utf-8" + if icon.is_file(): + return ok("image/x-icon", icon.stat().st_size) + return no() + if path == ADMIN_PATH or path == "/admin" or path == "/login": + # 注意:_login_page() 返回的**已经是 bytes**(它的签名是 -> bytes), + # 而 _site_page() 返回的是 str(要自己 encode)。 + # 两个函数返回值类型不一致,这里踩过一次: + # 对着 bytes 调 .encode() 直接 AttributeError, + # 被 do_HEAD 的兜底吃掉,于是管理页 HEAD 报成 text/plain。 + # 用 _as_bytes() 兜住,以后谁改了返回类型都不至于炸。 + return ok("text/html; charset=utf-8", + len(self._as_bytes(_login_page()))) + + # ---- 产品截图 ---- if path.startswith("/shot/"): name = path[len("/shot/"):] if ".." in name or "/" in name or "\\" in name: - return b"", "text/plain; charset=utf-8" + return no() target = Path(__file__).resolve().parent / "site_shots" / name if target.is_file() and name.lower().endswith( (".png", ".jpg", ".jpeg", ".webp")): - return target.read_bytes(), "image/png" - return b"", "text/plain; charset=utf-8" + ctype = ("image/png" if name.lower().endswith(".png") + else "image/jpeg") + return ok(ctype, target.stat().st_size) + return no() + + # ---- 下载区 ---- + # 这一块原来漏了,导致 HEAD 一个 APK 报 Content-Length: 0。 + # 类型判断必须和 do_GET 里那份保持一致。 + if path.startswith("/download/"): + wanted = urllib.parse.unquote(path[len("/download/"):]) + safe = _safe_download_name(wanted) + if not safe: + return no() + target = DOWNLOADS_DIR / safe + if not target.is_file(): + return no() + lower = safe.lower() + if lower.endswith(".apk"): + ctype = "application/vnd.android.package-archive" + elif lower.endswith(".zip"): + ctype = "application/zip" + elif lower.endswith(".txt"): + ctype = "text/plain; charset=utf-8" + else: + ctype = "application/octet-stream" + quoted = urllib.parse.quote(safe) + return ok(ctype, target.stat().st_size, { + "Content-Disposition": + "attachment; filename*=UTF-8''%s" % quoted}) + + return no() def do_GET(self): # noqa: N802 RELAY.total_requests += 1 diff --git a/relay/watchdog.sh b/relay/watchdog.sh index 9ebecaf..bdea5c5 100644 --- a/relay/watchdog.sh +++ b/relay/watchdog.sh @@ -52,7 +52,13 @@ read_state() { [ -n "$value" ] && echo "$value" || echo "$default" } -write_state() { printf 'zero=%s\nfail=%s\n' "$1" "$2" > "$STATE_FILE"; } +# 状态文件现在存三样: +# zero —— 连续降级次数 +# fail —— 连续失败次数 +# built —— 本次重启后有没有建齐过(1=建齐过) +write_state() { + printf 'zero=%s\nfail=%s\nbuilt=%s\n' "$1" "$2" "${3:-0}" > "$STATE_FILE" +} LAST_RESTART_FILE="$RELAY_DIR/watchdog.last_restart" @@ -72,14 +78,14 @@ restart_tunnel() { # 刚重启过还没到间隔:这次先不重启, # 让 cloudflared 自己把连接建起来(它本来就在重试)。 log "$why —— 但距上次重启才 ${gap} 秒(下限 ${MIN_RESTART_GAP} 秒),先不重启" - write_state 0 0 + write_state 0 0 "${BUILT:-0}" exit 0 fi log "$why —— 重启隧道(距上次 ${gap} 秒)" echo "$now" > "$LAST_RESTART_FILE" systemctl restart lanremote-tunnel - write_state 0 0 + write_state 0 0 0 sleep 25 log "重启后地址: $(cat "$URL_FILE" 2>/dev/null || echo '(还没拿到)')" exit 0 @@ -87,12 +93,23 @@ restart_tunnel() { ZERO=$(read_state zero 0) FAIL=$(read_state fail 0) +BUILT=$(read_state built 0) # ---------- 信号 A:cloudflared 自报的连接数 ---------- CONNS=$(timeout 5 curl -s --max-time 4 "$METRICS" 2>/dev/null \ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') [ -z "$CONNS" ] && CONNS="" +# 连接数达到 3 条以上就算"建齐过"。 +# 记在状态文件里,跨多次检查保留 —— 这样即使后面掉到 1 条, +# 我们也知道"它本来是能建齐的"。 +if [ -n "$CONNS" ] && [ "$CONNS" -ge 3 ] 2>/dev/null; then + if [ "$BUILT" != "1" ]; then + log "本次已建齐 ${CONNS} 条连接" + fi + BUILT=1 +fi + # ---------- 信号 B:公网真的通吗 ---------- URL=$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n') CODE="000" @@ -120,7 +137,7 @@ if [ "$PUBLIC_OK" = "1" ]; then if [ "$ZERO" != "0" ] || [ "$FAIL" != "0" ]; then log "公网恢复(HTTP 200),计数清零(之前 zero=$ZERO fail=$FAIL)" fi - write_state 0 0 + write_state 0 0 "${BUILT:-0}" exit 0 fi @@ -154,10 +171,18 @@ if [ "$DEGRADED" = "1" ]; then # (不加这条会死循环:隧道还没建齐就被再次打断。) LAST_R=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0) SINCE=$(( $(date +%s) - LAST_R )) - if [ "$SINCE" -lt "$GRACE" ]; then - log "公网不通且只剩 ${CONNS} 条连接,但距上次重启才 ${SINCE} 秒(宽限 ${GRACE} 秒)—— 先让它建完" + + # 但要看「这次重启后建齐过没有」: + # 建齐过(>=3 条)又垮下来 = 链路本来能撑住,现在是真故障 + # -> 不用再等宽限期,立刻重启 + # 从没建齐过 = 它还在努力建 + # -> 继续等,别打断它 + if [ "${BUILT:-0}" = "1" ]; then + restart_tunnel "公网不通,且本次重启后曾建齐过连接(确认故障,立即重启)" + elif [ "$SINCE" -lt "$GRACE" ]; then + log "公网不通且只剩 ${CONNS} 条连接,但本次重启还没建齐过(${SINCE}/${GRACE} 秒)—— 继续等它建" else - restart_tunnel "公网不通且只剩 ${CONNS} 条隧道连接(确认故障,立即重启)" + restart_tunnel "公网不通且 ${SINCE} 秒都没建齐(链路或边缘有问题,重启试试)" fi fi @@ -165,4 +190,4 @@ if [ "$FAIL" -ge "$THRESHOLD" ] || [ "$ZERO" -ge "$THRESHOLD" ]; then restart_tunnel "连续 $FAIL 次公网不通(含 $ZERO 次降级)" fi -write_state "$ZERO" "$FAIL" +write_state "$ZERO" "$FAIL" "$BUILT" diff --git a/windows/lanremote/__init__.py b/windows/lanremote/__init__.py index 6a6ad7c..fe336bd 100644 --- a/windows/lanremote/__init__.py +++ b/windows/lanremote/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- """局域网遥控 · Windows 被控端。""" -__version__ = "1.7.1" +__version__ = "1.8.0" APP_NAME = "ykxmz"