6 Commits
Author SHA1 Message Date
xmz7891 4e64bc7920 remove temp 2026-10-08 19:52:09 +08:00
xmz7891 c5fa4eb517 2.1.1:修一个会让网站整个挂掉的死锁(「网站容易挂」的真因)
2.1.0 的服务端包里带着这个死锁,所以要单独发一版让部署的人拿到修好的包。

== 死锁 ==

    lanremote-nettune.service   Before=lanremote-tunnel.service
    net-tune.sh 第 4 步         systemctl restart lanremote-tunnel

  nettune 在**自己还没结束**的时候去 restart 一个"必须等自己结束
  才能启动"的服务 -> systemd 判定顺序冲突 -> restart 永不返回
  -> nettune 卡在 activating -> 隧道被 Before= 挡着起不来 -> 公网 530。

  设备上实测卡了 9 分半(CGroup 里能看到 net-tune.sh 下面挂着
  一个 etime 09:32 的 systemctl restart lanremote-tunnel)。
  journalctl 里只有 nettune 停在 activating、**一句报错都没有**;
  手动跑 net-tune.sh 却完全正常(那时没有 Before= 约束)。
  是靠这次新加的监测单元抓到的(fails=3 / last_code=530)。

== 改法 ==

  unit 加 Environment=NETTUNE_SERVICE_CALL=1,脚本据此跳过第 4 步。
  第 4 步只在人工重跑时才有意义(改完 MTU/MSS 让已建隧道用上新参数),
  开机时顺序就是调优先、隧道后,不需要它。

  三处都改了:net-tune.sh 认开关;deploy.sh / install.sh 生成的 unit 带上它。

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

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

  设备上那份旧 unit 的 ExecStart 里有 iptables ... || ...,
  systemd 不支持 ||,所以它在第 2 条 ExecStart 就报错退出,
  根本没走到会死锁的那一步。把 unit 换成正确形状之后才露出来。
2026-10-08 19:52:08 +08:00
xmz7891 05d2287fbf remove temp 2026-10-08 18:56:44 +08:00
xmz7891 6adf7acbfb 修「网站容易挂」的真因:nettune 和隧道之间的死锁
用户报「网站还是容易挂」。查到最后是个 systemd 顺序死锁 ——
而且**是我自己引入的**(换掉设备上那份过期 unit 之后才露出来)。

== 死锁链条 ==

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

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

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

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

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

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

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

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

== 改法 ==

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

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

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

== 另外 ==

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

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

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

全量回归 46 个阶段全部通过。
2026-10-08 18:56:44 +08:00
xmz7891 4ba18fdae9 remove temp 2026-10-08 18:36:50 +08:00
xmz7891 3f18e6bb40 2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默
按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。

== 一、网站挂了会自己救 ==

用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立
检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。

新增 relay/monitor.sh + lanremote-monitor.timer:
    每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1)

    为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是
    经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过
    隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。

    连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败),
    救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上,
    下一次检查又救一遍,来回重启反而永远连不上)。

新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停
    1 重启隧道          不动中转,在线设备不掉线
    2 重启中转服务      设备要重连,但很快
    3 重新施加网络调优  MSS / MTU 跑偏时用
    4 重启无线网卡      4G / WiFi 卡死时用
    5 重启整机          最后的底牌,**默认不开**(要 RESCUE_REBOOT=1)

    理由:如果问题出在供电或上游线路,重启只会让恢复更慢;
    而且半夜自动重启一台没人管的机器风险太大。

== 二、抢救报告 + 后台分区 ==

用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在
中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题
的方法加入紧急预案」。

    * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、
      三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt
    * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载
    * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变,
      也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的:
      那个是给用户的安装包,文件名带版本号、内容永不改变,
      所以敢发 604800 长缓存
    * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部,
      是运维信息。第一版把这条路由插在 _admin_gate() 之前,
      等于匿名可下,自己查出来补上了
    * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md;
      监测单元读它,下次同类症状优先试历史上管用的那一级
      (这就是用户说的"解决后将解决问题的方法加入紧急预案")

== 三、网络热门音效 ==

用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,
各种老牧师音效,可以直接远程播放」。

**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音**
(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权,
也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次
"内容可能含有违规信息")。

所以:
    * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、
      不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 /
      警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身
    * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在
      列表里,照样能远程播。目录里放了一份说明
    * 手表首页新增「音效」按钮,点开选一个,电脑就响
      (复用现成的 showChooser,不新建面板)

协议加 sfx_list / sfx_play 两条命令。

== 四、修「安卓设置页一片空白」 ==

用户报的。根因:设置页**建好了但忘了 root.addView()** ——
showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。

这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言
_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、
并且纳入 showOnly()。

== 五、全量测试改成后台静默 ==

用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。

会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows
隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、
可见、几何、Win32 枚举都真实,测试有效性一点没损失 ——
只是不在用户眼前,全屏也不会锁屏。

顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了
(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。
现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。

== 六、顺手修的真 bug ==

* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body
  (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的,
  说明"未定义名"这条检查有用。
* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico,
  **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动,
  而且不报错、只是静默不跑。已加。
* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。
* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行
  (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。
* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台",
  触发对外材料清洁度检查。改成不提管理页(用户定的规矩:
  对外只能提官网)。

全量回归 46 个阶段全部通过。
2026-10-08 18:36:50 +08:00
208 changed files with 6929 additions and 1997 deletions
+681 -661
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -1,3 +1,83 @@
## 2.1.1
- **修一个会让网站整个挂掉的死锁**(这是「网站还是容易挂」的真因)。
在装了中转服务器的那台机器上,网络调优服务和隧道之间会互相等:
```
网络调优服务(nettune): "我必须先于隧道启动完"
网络调优脚本第 4 步: "重启隧道,让新参数生效" ← 卡在这里
```
它在**自己还没结束**的时候去重启一个「必须等自己结束才能启动」的服务,
系统判定成顺序冲突,那一步就永远不返回。结果网络调优一直卡在
「正在启动」,隧道被挡着起不来,**公网就是 530**。
实测卡了 9 分半。而且**日志里一句报错都没有**,
手动跑那个脚本却完全正常(那时没有顺序约束)——
所以特别难查,是靠这次新加的监测单元抓到的
(它记录到「连续 3 次不通」并把现场留了下来)。
**改法**:让脚本知道「这次是服务在调我」,那就跳过重启隧道那一步。
开机时本来也不需要它 —— 顺序就是调优先、隧道后,那时隧道还没起来。
> 这个坑以前一直藏着:设备上那份旧的服务定义里有一个写法错误,
> 导致它在更早的步骤就报错退出了,反而没走到会死锁的那一步。
> 把服务定义改对之后,死锁才露出来。
- 另外这一版的服务端包也带上了上面那个修复。
**2.1.0 的服务端包是有问题的**,请用 2.1.1 重新部署。
## 2.1.0
- **网站挂了会自己救**:中转服务器上常驻一个监测单元,每 60 秒从**公网**
查一次(不是查本机 —— 本机通不代表用户能打开)。连续 3 次不通就按阶梯
自动抢救:
| 级 | 动作 | 代价 |
|---|---|---|
| 1 | 重启隧道 | 不动中转,在线设备不掉线 |
| 2 | 重启中转服务 | 设备要重连,但很快 |
| 3 | 重新施加网络调优 | MSS / MTU 跑偏时用 |
| 4 | 重启无线网卡 | 4G / WiFi 卡死时用 |
| 5 | 重启整机 | 最后的底牌,**默认不开** |
每做完一级立刻复验,好了就停。救完 10 分钟内不再动手(防抖 ——
否则预案刚重启完隧道还没连上,下一次检查又救一遍,
来回重启反而永远连不上)。
- **网站出问题会自动留下诊断报告**:万一五级都没救回来,服务端会把现场
(内存、进程、隧道连接数、路由和 MTU、三份日志尾部、内核异常)
整理成一份 txt 留在服务端,需要排查时直接取来看就行。
只保留最近 30 份,不会把设备塞满。
每次自动抢救成功,也会把「第几级奏效」记进知识库,
下次同类问题优先试历史上管用的那一级。
- **网络热门音效**:手表上新增「音效」按钮,点一下电脑就响。
内置 8 个(冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / 警报 / 鼓点 / 弹簧)。
> 说明一下**版权**:网上那些「冰冰冰」「啊哈哈哈」是有版权的真实录音,
> 不能打包进安装包。所以内置的是**程序合成**的版本(正弦/方波现场算),
> 名字对着感觉来,但不是那些录音本身。
> 想要一模一样的一手素材,把 mp3/wav 丢进电脑的 `data\sfx\` 目录,
> 点「刷新」就出现在列表里,照样能远程播。目录里放了一份说明。
- **修「安卓设置页一片空白」**:设置页建好了但**忘了挂到窗口上**
(少了 `root.addView`),`showOnly()` 让它可见也没用 —— 它根本不在
视图树里。已修,并加了断言盯着「每个面板都必须挂到 root、
并且纳入 showOnly()」—— 这类漏挂编译不报错、只在运行时表现为空白。
- **全量测试改成后台静默**:会开窗口的 6 个阶段(含开**全屏**那个)
现在跑在独立的 Windows 隐藏桌面上 —— 窗口是**真的**(全屏、可见、
几何、Win32 枚举都真实),但屏幕上什么都看不到,也不会锁你的屏。
顺带修了一个一直误判的检测:判断「锁屏」时只看 `OpenInputDesktop`
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
所以截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了。
改成看输入桌面的**名字**。现在锁屏也能跑全量:该 SKIP 的 SKIP,
该跑的照跑。
## 2.0.0
- **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 ——
+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
r'''让 deploy.sh 在装新机器时把「监测单元」也装上。
== 为什么必须做这一步 ==
监测单元是我新加的:monitor.sh 每 60 秒查一次公网,
挂了就叫 rescue.sh 抢救。但如果 deploy.sh 不认识它,
**新装的机器上监测根本不会启动** —— 而且不会报任何错,
只是静默地不跑。等网站真挂了才发现"预案没触发",那就晚了。
部署脚本里 watchdog 已经是个现成的样板(同样是 timer + oneshot),
照着加一份即可。
== 顺便 ==
deploy.sh 里本来就有"包里有才装"的写法(`if [ -f ... ]`),
这里也沿用 —— 老的服务端 zip(没有 monitor.sh)重跑部署时
不会因为找不到文件而报错。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "relay" / "deploy.sh"
ANCHOR = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true'''
NEW = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
fi
# ---- 网站监测单元(如果包里有)----
#
# 和看门狗的区别很重要:
# 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好"
# 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开"
#
# 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200,
# 看门狗也可能觉得没事;只有从公网测才发现 502。
if [ -f "$RELAY_DIR/monitor.sh" ]; then
mkdir -p "$RELAY_DIR/rescue_reports"
chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true
if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then
cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF'
# 紧急预案知识库
每次自动抢救成功,这里会追加一条「第几级奏效」。
以后遇到同类症状,监测单元会优先试历史上奏效的那一级。
分级(从代价最小到最大):
1 重启隧道 不动中转,在线设备不掉线
2 重启中转服务 设备要重连,但很快
3 重新施加网络调优 MSS / MTU 跑偏时用
4 重启无线网卡 4G / WiFi 卡死时用
5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1)
KEOF
fi
# 包里直接带了单元文件就用包里的(内容随版本更新);
# 没有就现场生成一份等价的 —— 兼容老 zip。
if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then
cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true
else
write_unit "$UNIT_DIR/lanremote-monitor.service" <<EOF
[Unit]
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=360
ExecStart=/bin/bash $RELAY_DIR/monitor.sh
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
StandardOutput=append:$RELAY_DIR/monitor.log
StandardError=append:$RELAY_DIR/monitor.log
[Install]
WantedBy=multi-user.target
EOF
fi
if [ -f "$RELAY_DIR/lanremote-monitor.timer" ]; then
cp "$RELAY_DIR/lanremote-monitor.timer" "$UNIT_DIR/" 2>/dev/null || true
else
write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF'
[Unit]
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=lanremote-monitor.service
[Install]
WantedBy=timers.target
EOF
fi
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true
systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true
say " 网站监测单元已启用(每 60 秒查一次公网)"'''
def main() -> int:
raw = DEPLOY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "lanremote-monitor.timer" in text:
print(" · deploy.sh 已经加过了")
return 0
if ANCHOR not in text:
print(" ✗ 找不到锚点")
return 1
if text.count(ANCHOR) != 1:
print(" ! 锚点出现 %d 次" % text.count(ANCHOR))
text = text.replace(ANCHOR, NEW, 1)
# 原来 watchdog 那个 if 块的 fi 会被我们的 NEW 提前收掉,
# 所以要把后面多余的那个 fi 去掉一个。先看看形状。
import re
DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ deploy.sh 加了监测单元的安装")
return 0
if __name__ == "__main__":
sys.exit(main())
+261
View File
@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
r'''后台加一个「抢救报告」分区。
用户要求:
「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区
专门放还有下载这个 txt」
rescue.sh 抢救失败时会往 relay/rescue_reports/ 写一份诊断报告 .txt。
这里让后台能:
* 列出这些报告(时间、大小、一句话结论)
* 点一下下载看内容
== 为什么不复用 /download/ ==
/download/ 是**给用户**的(安装包、APK),带 cf 长缓存
(max-age=604800, immutable)——因为文件名带版本号、内容永不改变。
抢救报告正相反:内容会变、是给管理员看的、不该被边缘缓存。
所以单独开一条 /rescue/<名字>,发 no-store。
'''
from __future__ import annotations
import html
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
ADMIN = ROOT / "relay" / "admin.py"
def patch(path: Path, pairs, label: str):
raw = path.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
results = []
for old, new, what in pairs:
if old not in text:
results.append(("✗", what, "找不到锚点"))
continue
if text.count(old) != 1:
results.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
results.append(("✓", what, ""))
print(" ── %s ──" % label)
for m, w, why in results:
print(" %s %s%s" % (m, w, (" —— " + why) if why else ""))
if any(m == "✗" for m, _w, _y in results):
return False
path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.encode("utf-8"))
return True
# ===========================================================================
# relay.py
# ===========================================================================
RELAY_LIST = '''
# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。
# 和 downloads/ 分开:那个是给用户的安装包(带长缓存),
# 这个是给管理员看的诊断(必须不缓存)。
RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports"
RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满)
def _safe_rescue_name(name: str) -> str:
"""校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。"""
name = (name or "").strip()
if not name or len(name) > 80:
return ""
if "/" in name or "\\\\" in name or ".." in name:
return ""
if not name.endswith(".txt"):
return ""
for ch in name:
if ch.isalnum() or ch in "._-":
continue
return ""
return name
def _list_rescue_reports() -> list:
"""列出抢救报告(按时间倒序)。
顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示,
不用一个个点进去看。
"""
items = []
try:
if not RESCUE_DIR.is_dir():
return items
for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True):
if not entry.is_file():
continue
safe = _safe_rescue_name(entry.name)
if not safe:
continue
stat = entry.stat()
summary = ""
try:
with entry.open("r", encoding="utf-8", errors="replace") as fh:
head = fh.read(1200)
for line in head.splitlines():
s = line.strip()
if s.startswith("判断:"):
summary = s.replace("判断:", "").strip()
break
if not summary:
for line in head.splitlines():
s = line.strip()
if s.startswith("公网 ") and "->" in s:
summary = s
break
except OSError:
pass
items.append({
"name": entry.name,
"size": stat.st_size,
"mtime": stat.st_mtime,
"summary": summary[:120],
})
except OSError:
return items
items.sort(key=lambda i: i["mtime"], reverse=True)
return items[:RESCUE_KEEP]
'''
ADMIN_CALL = ''' admin_path=ADMIN_PATH,'''
ADMIN_CALL_NEW = ''' admin_path=ADMIN_PATH,
rescue_fn=_list_rescue_reports,'''
# 下载路由(GET):插在 /download/ 之前
RESCUE_ROUTE_ANCHOR = ''' if path.startswith("/download/"):
wanted = urllib.parse.unquote(path[len("/download/"):])
safe = _safe_download_name(wanted)'''
RESCUE_ROUTE = ''' if path.startswith("/rescue/"):
# 抢救报告下载。单独一条路由,**不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
'''
# ===========================================================================
# admin.py
# ===========================================================================
ADMIN_SIG = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int,
downloads_fn) -> str:'''
ADMIN_SIG_NEW = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int,
downloads_fn, rescue_fn=None) -> str:'''
ADMIN_CARD_ANCHOR = ''' <div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
</div>'''
ADMIN_CARD = ''' <div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
<div class="tool">
<h4>抢救报告</h4>
<div class="d">网站挂了、自动预案也没救回来时,机器会把现场
打包成一份 txt 放在这里。点开看看是哪一段断了。</div>
<div id="rescueBody">%(rescue)s</div>
</div>
</div>'''
def _rescue_rows(items: list) -> str:
"""抢救报告列表的 HTML。照 dl_rows 的风格(页面里直接渲染)。"""
if not items:
return ('<div class="out">还没有报告 —— 说明网站一直没出过'
'「自动救不回来」的问题。</div>')
import time as _t
import urllib.parse as _up
rows = []
for it in items:
when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"]))
size = it["size"] / 1024
size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"])
summary = html.escape(it.get("summary") or "(没有结论行)")
href = "/rescue/" + _up.quote(it["name"])
rows.append(
'<div class="dlrow"><span class="nm">'
'<a href="%s" style="color:#0a84ff">%s</a>'
'<span style="color:#6b7280;font-size:12px;margin-left:8px">'
'%s</span></span>'
'<span class="sz" style="max-width:46%%;text-align:right">'
'%s</span></div>'
% (href, when, size_s, summary))
return "".join(rows)
def main() -> int:
ok = True
ok &= patch(RELAY, [
("DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"",
"DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"\n"
+ RELAY_LIST, "加 rescue_reports 列表函数"),
(RESCUE_ROUTE_ANCHOR, RESCUE_ROUTE + RESCUE_ROUTE_ANCHOR,
"加 /rescue/ 下载路由(no-store)"),
(ADMIN_CALL, ADMIN_CALL_NEW, "把 rescue_fn 传给后台页"),
], "relay.py")
ok &= patch(ADMIN, [
(ADMIN_SIG, ADMIN_SIG_NEW, "render_admin 加 rescue_fn 参数"),
(ADMIN_CARD_ANCHOR, ADMIN_CARD, "后台加「抢救报告」卡片"),
# 算报告列表(照 dl_rows 的写法)
(''' return """<!doctype html>''',
''' # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉)
try:
rescue_items = rescue_fn() if rescue_fn else []
except Exception: # noqa: BLE001
rescue_items = []
rescue_rows = _rescue_rows(rescue_items)
return """<!doctype html>''',
"算抢救报告列表"),
('"dl": dl_rows,', '"dl": dl_rows,\n "rescue": rescue_rows,',
"把报告列表塞进模板"),
], "admin.py")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+137
View File
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
r'''手表端加「音效」—— 用现成的 showChooser(),不新建面板。
== 为什么改方案 ==
第一版我给音效页写了完整的新面板(FrameLayout + ScrollView + 动态列表),
后来发现 MainActivity 里**已经有 showChooser(title, labels, Chooser)**
这个现成的选择器 —— pickVoice() 就是这么选 TTS 音色的。
用选择器的话:
* 不用新建面板 -> 不用碰 root.addView / showOnly(那两个地方
刚因为"漏挂"出过一次全白的事故)
* 代码量少一半,出错面小得多
* 而且和"选音色"的操作手感一致,用户不用学两套
所以改成:首页加一个「音效」按钮 -> 弹出音效列表 -> 点哪个放哪个。
== 服务端配合 ==
服务端返回的是 {"sfx":[{"name":...},...]} 这种嵌套结构,
而手表的 stringList(reply, key) 只认扁平的字符串数组。
audio_list 早就这么做了(同时返回 files / names / paths),
这里跟着加一个 "names",手表直接用。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
/ "MainActivity.java")
# ---------------------------------------------------------------- 方法
METHOD_ANCHOR = " /** 选择电脑上的 TTS 音色。 */"
METHOD_NEW = ''' /** 让电脑放一个音效(网络热门音效那类)。
*
* 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明:
* pickVoice() 已经是这个套路,复用省事也更不容易出错。
*/
private void pickSfx() {
appendLog("正在获取音效…");
io.execute(new Runnable() {
@Override
public void run() {
try {
final Map<String, Object> reply =
client.call(Json.obj("cmd", "sfx_list"), 15000);
// 服务端同时给了扁平的 names(和 audio_list 一个约定)
final List<String> names = stringList(reply, "names");
final String dir = Json.str(reply, "dir", "");
ui.post(new Runnable() {
@Override
public void run() {
if (names.isEmpty()) {
toast("电脑上还没有音效(或没连上)");
appendLog("音效列表是空的。"
+ (dir.isEmpty() ? "" : "素材目录:" + dir));
return;
}
showChooser("音效(点一下电脑就响)", names,
new Chooser() {
@Override
public void onPick(int index) {
String one = names.get(index);
sendCommand(Json.obj("cmd", "sfx_play",
"name", one), "音效 " + one);
}
});
}
});
} catch (final IOException e) {
postError("获取音效", e);
}
}
});
}
''' + METHOD_ANCHOR
def main() -> int:
text = SRC.read_text(encoding="utf-8")
out: list = []
def patch(old: str, new: str, what: str) -> None:
nonlocal text
if old not in text:
out.append(("✗", what, "找不到锚点"))
return
if text.count(old) != 1:
out.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
out.append(("✓", what, ""))
# 1) 插方法
patch(METHOD_ANCHOR, METHOD_NEW, "插入 pickSfx()")
# 2) 首页加按钮(放在「音效」该在的位置:设置按钮之后)
patch(''' box.addView(sfxButton);''', ''' box.addView(sfxButton);''',
"(占位,见下)") if False else None
patch(''' box.addView(relayButton);''',
''' box.addView(relayButton);
// 音效:点一下电脑就响。用户要求"可以直接远程播放"。
Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14);
LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(52));
sfxParams.setMargins(0, 0, 0, dp(8));
sfxButton.setLayoutParams(sfxParams);
sfxButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
pickSfx();
}
});
box.addView(sfxButton);''',
"首页加「音效」按钮")
print(" ── 补丁结果 ──")
for mark, what, why in out:
print(" %s %s%s" % (mark, what, (" —— " + why) if why else ""))
if any(m == "✗" for m, _w, _y in out):
print()
print(" 有锚点没匹配,没有写回")
return 1
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 已写回 MainActivity.java")
return 0
if __name__ == "__main__":
sys.exit(main())
+104
View File
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
r'''给 /rescue/ 加上管理鉴权。
== 问题 ==
我把 /rescue/ 路由插在 do_GET 的第 1531 行,而管理页的登录校验
(self._admin_gate())在第 1723 行 —— **路由在鉴权之前**。
也就是说:任何人只要知道
https://relay.xxmxxs.top/rescue/20261008-173000.txt
就能直接下载诊断报告。那里面有什么:
* 内网 IP(192.168.2.x)、主机名
* 进程列表、内存占用
* 中转和隧道的日志尾部
* MTU / 路由 / iptables 规则
这些是**运维信息**,不该公开。对比一下:/download/ 是给用户的
安装包,公开是对的;/rescue/ 是给自己看的,必须登录。
(同样的问题 /shot/ 也有 —— 那个是屏幕共享截图,本来就该拦。
这次先修 /rescue/,顺手看一眼 /shot/。)
== 改法 ==
在 /rescue/ 分支开头加一句 self._admin_gate(),
和 /v1/admin/* 那些接口一样。do_GET 和 do_HEAD 都要加。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
# do_GET 里的(有 body 的那份)
GET_OLD = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
GET_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
#
# ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、
# MTU/路由/iptables —— 是运维信息,不是给所有人看的。
# 第一版把这条路由插在 _admin_gate() **之前**,
# 等于匿名可下,实测踩到。
if not self._admin_gate():
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
# do_HEAD 里的(只有 headers)
HEAD_OLD = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
# 同样要登录(见 do_GET 那段注释)。
if not self._admin_gate():
return no()
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
def main() -> int:
raw = RELAY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
changed = []
for old, new, what in ((GET_OLD, GET_NEW, "do_GET"),
(HEAD_OLD, HEAD_NEW, "do_HEAD")):
if new.split("\n")[0] in text and "_admin_gate" in text.split(old)[0][-600:]:
changed.append(("·", what, "好像已经加过了"))
continue
if old not in text:
changed.append(("✗", what, "找不到锚点"))
continue
text = text.replace(old, new, 1)
changed.append(("✓", what, "已加鉴权"))
print(" ── 结果 ──")
for m, w, why in changed:
print(" %s %-10s %s" % (m, w, why))
if any(m == "✗" for m, _w, _y in changed):
return 1
RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
print(" 已写回 relay.py")
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
r'''解开"nettune 死锁",然后把根因修掉。
== 死锁是怎么回事 ==
lanremote-nettune.service:
Before=lanremote-tunnel.service <- 关键
ExecStart=/bin/bash net-tune.sh
net-tune.sh 第 4 步:
systemctl restart lanremote-tunnel <- 在这里永远阻塞
为什么阻塞:systemd 正在启动 nettune,而 nettune 声明了"我必须在
隧道之前启动完"。脚本却在**自己还没结束的时候**去 restart 隧道 ——
那要求隧道先停、再等 nettune 结束……systemd 检测到这是顺序冲突,
restart 就一直挂着不返回。
证据:CGroup 里能看到 net-tune.sh(13388)下面挂着
systemctl restart lanremote-tunnel(13428),
etime 09:32 —— 卡了 9 分半,nettune 一直是 activating。
后果:nettune 卡住 -> 隧道被 Before= 挡着起不来 -> 公网 530。
这就是用户说的"网站还是容易挂"的真因之一。
== 为什么以前没暴露 ==
设备上原来那份 unit 的 ExecStart 里有个 `iptables ... || ...`,
systemd 不支持 ||,所以它**在第 2 条 ExecStart 就报错退出了**,
根本没走到"重启隧道"那一步 —— 死锁被那个 bug 遮住了。
我把 unit 换成正确形状之后,死锁才露出来。
== 这一步做什么 ==
1. 杀掉卡住的 systemctl 和 net-tune.sh(解开当前死锁)
2. 拉起隧道(网站先恢复)
3. 把 net-tune.sh 的"重启隧道"改成**只在手动跑时做**
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
echo "════ 1. 解开死锁 ════"
# 先杀卡住的 systemctl(不然它会一直拽着 systemd 的 job)
pkill -f 'systemctl restart lanremote-tunnel' 2>/dev/null && echo " 杀了卡住的 systemctl" || echo " 没有卡住的 systemctl"
sleep 2
# 再让 nettune 的脚本结束(杀它的子进程已经让主脚本继续往下走了)
systemctl stop lanremote-nettune 2>/dev/null
systemctl reset-failed lanremote-nettune 2>/dev/null
sleep 2
echo " nettune 现在: $(systemctl is-active lanremote-nettune)"
echo
echo "════ 2. 拉起隧道(网站先恢复)════"
systemctl reset-failed lanremote-tunnel lanremote-tunnel-h2 2>/dev/null
systemctl start lanremote-tunnel lanremote-tunnel-h2
sleep 15
echo " tunnel: $(systemctl is-active lanremote-tunnel)"
echo " tunnel-h2: $(systemctl is-active lanremote-tunnel-h2)"
echo
echo "════ 3. 验公网 ════"
for i in 1 2 3; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health)
echo " 第 $i 次: $code"
[ "$code" = "200" ] && break
sleep 6
done
echo
echo "════ 4. 隧道连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
echo " $p -> ${n:-拿不到}"
done
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+96
View File
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
r'''看一眼中转服务器的完整状态。
为什么写成文件:这一轮我用 `python -c @"..."@` 传长命令失败了好几次 ——
PowerShell 的双引号 heredoc 会做变量插值、还会把 `$(...)` 当子表达式
在本机执行(systemctl / iptables 跑到 Windows 上了)。凡是超过两行
的远端命令,一律写成文件跑。
用法:
python _build/check_device.py
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
echo "════ 服务 ════"
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
done
printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer 2>&1)"
printf ' %-24s %s\n' "watchdog.timer" "$(systemctl is-active lanremote-watchdog.timer 2>&1)"
echo
echo "════ 网络 ════"
printf ' %-24s %s\n' "wlan0 MTU" "$(cat /sys/class/net/wlan0/mtu 2>/dev/null)"
printf ' %-24s %s\n' "MSS 规则" "$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条"
printf ' %-24s %s\n' "tcp_mtu_probing" "$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)"
printf ' %-24s %s\n' "默认路由" "$(ip route show default 2>/dev/null | head -1)"
echo
echo "════ 内存 / 运行时长 ════"
printf ' %-24s %s\n' "运行时长" "$(uptime -p 2>/dev/null)"
free -m 2>/dev/null | sed -n '2p' | awk '{printf " %-24s %s/%s MB\n", "内存 used/total", $3, $2}'
echo
echo "════ 隧道连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 "http://127.0.0.1:$p/metrics" 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
printf ' %-24s %s\n' "指标端口 $p" "${n:-拿不到}"
done
echo
echo "════ 公网自测 ════"
printf ' %-24s %s\n' "公网 /v1/health" "$(curl -s -o /dev/null -w '%{http_code} (%{time_total}s)' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)"
printf ' %-24s %s\n' "本机 /v1/health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/v1/health 2>/dev/null)"
printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/ 2>/dev/null)"
printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/rescue/none.txt 2>/dev/null)"
echo
echo "════ 监测单元状态 ════"
cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' || echo " (还没有状态文件)"
echo " ── monitor.log 尾部 ──"
tail -5 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' || echo " (还没有日志)"
echo
echo "════ 抢救报告 ════"
n=$(ls $RELAY/rescue_reports 2>/dev/null | wc -l)
echo " 共 $n 份"
ls -la $RELAY/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{printf " %s %s\n", $6" "$7" "$8, $9}'
echo
echo "════ 知识库 ════"
if [ -f $RELAY/rescue_knowledge.md ]; then
echo " 行数:$(wc -l < $RELAY/rescue_knowledge.md)"
tail -6 $RELAY/rescue_knowledge.md | sed 's/^/ /'
else
echo " (没有)"
fi
echo
echo "════ 定时器下次触发 ════"
systemctl list-timers lanremote-monitor.timer lanremote-watchdog.timer --no-pager 2>/dev/null | head -4 | sed 's/^/ /'
echo
echo "════ 后台页有没有「抢救报告」分区 ════"
printf ' 匹配次数: %s\n' "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告')"
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
r'''查 /rescue/ 为什么返回 HTTP 000。
000 是 curl 的"没拿到任何响应"。但同一个端口上 /v1/health 是好的、
目录穿越请求也能拿到 404 —— 说明不是端口不通,是**特定路径**出问题。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 造一份报告 ════"
printf '测试报告\n\n 判断:测试结论\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
ls -la $RELAY/rescue_reports/ | sed 's/^/ /'
echo
echo "════ 对照:几个路径的返回 ════"
for p in /v1/health /rescue/19700101-000000-demo.txt /rescue/nonexistent.txt /rescue/; do
printf ' %-42s -> %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 "$B$p")"
done
echo
echo "════ 详细看 /rescue/ 那次请求 ════"
curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | head -25 | sed 's/^/ /'
echo
echo "════ relay 日志尾部(有没有 Traceback)════"
journalctl -u lanremote-relay --no-pager -n 30 2>/dev/null | tail -30 | sed 's/^/ /'
echo
echo "════ 后台密码文件在不在 ════"
ls -la $RELAY/data/admin_password.txt 2>&1 | sed 's/^/ /'
echo " (长度 $(wc -c < $RELAY/data/admin_password.txt 2>/dev/null || echo 0))"
echo
echo "════ 清理 ════"
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt
'''
def main() -> int:
out = run(REMOTE, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
r'''把「网站监测 + 紧急预案 + 抢救报告分区」部署到中转服务器。
部署的东西:
relay.py / admin.py 后台多一个「抢救报告」分区 + /rescue/ 下载
monitor.sh / rescue.sh 监测单元 + 紧急预案
lanremote-monitor.service/.timer 每 60 秒拉一次 monitor.sh
lanremote-nettune.service 顺手修掉设备上那份过期的(见下)
== 顺手修的设备问题 ==
设备上那份 lanremote-nettune.service 是**很早以前的版本**,有两个毛病:
1. ExecStart 里写了 `iptables ... || iptables ...`
systemd 的 ExecStart **不支持 `||`** —— 它把 `||` 当成一个参数,
iptables 报 `Bad argument '||'`,服务直接 exit 2 失败。
2. 里面有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己
实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。
**两条 ExecStart 顺序是 net-tune.sh 先、1400 后**,所以最终是 1400 ——
也就是说这份过期配置一直在主动损害稳定性。
本地 relay/deploy.sh 生成的是对的(单入口 -> deploy-tune.sh)。
这里直接按那个正确形状重写设备上的 unit。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import upload, run # noqa: E402
RELAY_DIR = "/root/lanremote-relay"
UNIT_DIR = "/etc/systemd/system"
FILES = [
"relay.py", "admin.py", "landing.py", "showcase.py",
"monitor.sh", "rescue.sh", "net-tune.sh", "watchdog.sh",
"deploy-tune.sh",
]
NETTUNE_UNIT = '''[Unit]
Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测)
After=network-online.target
Wants=network-online.target
Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service
[Service]
Type=oneshot
RemainAfterExit=yes
# ⚠ 只跑 net-tune.sh 这一个入口 —— MSS / 接口 MTU / 路径探测的唯一出处。
#
# 设备上原来那份 unit 有两条毛病:
# * ExecStart 里写了 `iptables ... || iptables ...`
# systemd 的 ExecStart **不支持 `||`**,它把 `||` 当参数传给
# iptables,报 `Bad argument '||'`,服务直接 exit 2 失败。
# * 还有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己
# 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。
# 两条 ExecStart 是 net-tune.sh 先、1400 后,所以最终停在 1400 ——
# 这份过期配置一直在主动损害稳定性。
#
# (仓库里的 deploy.sh 生成的是 `ExecStart=/bin/bash deploy-tune.sh`
# 那种形状,但设备上并没有 deploy-tune.sh,所以这里直接用
# net-tune.sh,和设备的实际情况对齐。
# deploy-tune.sh 也一并传上去了,以后想切回单入口随时可以。)
ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh
[Install]
WantedBy=multi-user.target
'''
def main() -> int:
print("=" * 62)
print(" 部署 监测单元 + 紧急预案 + 抢救报告分区")
print("=" * 62)
print()
print(" ── 1. 上传文件 ──")
for name in FILES:
local = ROOT / "relay" / name
if not local.is_file():
print(" [跳过] %s 不存在" % name)
continue
upload(str(local), "%s/%s" % (RELAY_DIR, name))
print(" ✓ %s" % name)
for unit in ("lanremote-monitor.service", "lanremote-monitor.timer"):
local = ROOT / "relay" / unit
upload(str(local), "%s/%s" % (UNIT_DIR, unit))
print(" ✓ %s -> %s" % (unit, UNIT_DIR))
print()
print(" ── 2. 修掉过期的 nettune 服务(MTU 打架 + ExecStart 里的 ||)──")
import tempfile
tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service"
tmp.write_text(NETTUNE_UNIT, encoding="utf-8", newline="\n")
upload(str(tmp), "%s/lanremote-nettune.service" % UNIT_DIR)
print(" ✓ 已替换")
print()
print(" ── 3. 语法检查 ──")
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; "
"bash -n monitor.sh && bash -n rescue.sh && echo SH_OK; "
"systemd-analyze verify %s/lanremote-monitor.service 2>&1 | head -3 || true"
% (RELAY_DIR, UNIT_DIR), timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 4. 建目录 + 知识库 + 重载并启用 ──")
out = run(
"mkdir -p %s/rescue_reports && chmod +x %s/monitor.sh %s/rescue.sh && "
"[ -f %s/rescue_knowledge.md ] || printf '# 紧急预案知识库\\n\\n"
'每次自动抢救成功,这里会记一条"第几级奏效"。\\n'
'以后遇到同类症状,监测单元会优先试历史上奏效的那一级。\\n" > %s/rescue_knowledge.md; '
"systemctl daemon-reload && "
"systemctl restart lanremote-nettune.service; sleep 3; "
"systemctl enable --now lanremote-monitor.timer 2>&1 | tail -1; "
"sleep 2; systemctl is-active lanremote-monitor.timer"
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 5. 复核 ──")
out = run(
"echo 'nettune: '$(systemctl is-active lanremote-nettune.service); "
"echo 'monitor.timer: '$(systemctl is-active lanremote-monitor.timer); "
"echo 'MTU: '$(cat /sys/class/net/wlan0/mtu); "
"echo 'MSS 规则: '$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss)' 条'; "
"echo '报告目录: '$(ls %s/rescue_reports 2>/dev/null | wc -l)' 份'; "
"echo '后台分区: '$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:8080/rescue/nonexistent.txt)"
% RELAY_DIR, timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" 提示:监测单元默认每 60 秒查一次公网,连续 3 次不通才动手;")
print(" 抢救完 10 分钟内不再动手(防抖,避免来回重启)。")
print(" 日志在 %s/monitor.log 和 %s/rescue.log。" % (RELAY_DIR, RELAY_DIR))
return 0
if __name__ == "__main__":
sys.exit(main())
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
r'''查后台「抢救报告」分区为什么不显示。
可能原因:
1. 后台页要登录,curl 直接拿是登录页(那 0 次是正常的,得带 cookie)
2. relay.py 没真正重新加载(旧进程还在)
3. 模板参数没传进去
一条条排除。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
echo "════ 1. 直接 curl 后台页,看返回的是什么 ════"
curl -s --max-time 8 -D- http://127.0.0.1:8080/zhongzhuanzhan -o /tmp/adm.html 2>&1 | head -8
echo " --- 页面大小: $(wc -c < /tmp/adm.html) 字节 ---"
echo " --- 前 3 行 ---"
head -3 /tmp/adm.html | sed 's/^/ /'
echo " --- 含关键词统计 ---"
for k in 登录 密码 password 抢救报告 下载区 设备; do
printf ' %-12s %s\n' "$k" "$(grep -c "$k" /tmp/adm.html 2>/dev/null)"
done
echo
echo "════ 2. relay.py 里到底有没有 rescue_fn ════"
grep -n 'rescue_fn' $RELAY/relay.py | head -5 | sed 's/^/ /'
echo " admin.py 里 _rescue_rows:"
grep -n 'def _rescue_rows\|def render_admin' $RELAY/admin.py | head -4 | sed 's/^/ /'
echo
echo "════ 3. 进程是什么时候起的(比文件新还是旧)════"
ps -o pid,lstart,cmd -p $(systemctl show -p MainPID --value lanremote-relay) 2>/dev/null | sed 's/^/ /'
echo " relay.py 修改时间: $(stat -c '%y' $RELAY/relay.py)"
echo " admin.py 修改时间: $(stat -c '%y' $RELAY/admin.py)"
echo
echo "════ 4. 无登录直接调 render_admin 看看(绕过 HTTP)════"
cd $RELAY && python3 -c "
import admin
html = admin.render_admin({'version':'2.0.0','uptimeSeconds':1,'devices':[],'allowNew':True},
'https://x','/zhongzhuanzhan',0, lambda: [],
rescue_fn=lambda: [{'name':'20261008-120000.txt','size':1234,
'mtime':1791400000,
'summary':'测试摘要'}] )
print(' 渲染长度:', len(html))
print(' 含抢救报告:', '抢救报告' in html)
print(' 含测试摘要:', '测试摘要' in html)
print(' 含下载链接:', '/rescue/20261008-120000.txt' in html)
" 2>&1 | sed 's/^/ /'
'''
def main() -> int:
out = run(REMOTE, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
r'''查 nettune 为什么卡在 activating(它挡着隧道,隧道一挂网站就 530)。
发现:
lanremote-nettune activating <- 卡住
lanremote-tunnel inactive <- 在等 nettune
lanremote-tunnel-h2 inactive
公网 530
nettune 的 unit 里有 Before=lanremote-tunnel.service,
所以它卡住 = 隧道永远起不来。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
echo "════ 1. nettune 的完整状态 ════"
systemctl status lanremote-nettune --no-pager -n 25 2>&1 | tail -25
echo
echo "════ 2. 有没有卡住的子进程 ════"
systemctl show -p MainPID -p ControlPID -p ExecMainStatus -p StateChangeTimestamp lanremote-nettune 2>&1
echo " --- 所有相关进程 ---"
ps -eo pid,ppid,stat,etime,cmd 2>/dev/null | grep -E 'iptables|net-tune|deploy-tune|ip link' | grep -v grep
echo
echo "════ 3. net-tune.sh 里会不会卡住的那步 ════"
echo " --- 脚本第 4 步(重启隧道)会 wait 吗 ---"
grep -n 'systemctl restart' /root/lanremote-relay/net-tune.sh
echo
echo "════ 4. 手动跑一次 net-tune.sh 看卡在哪(30 秒超时)════"
timeout 30 bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -15
echo " 退出码: $?"
echo
echo "════ 5. 卡住的时候隧道是什么状态 ════"
systemctl is-active lanremote-tunnel lanremote-tunnel-h2
journalctl -u lanremote-tunnel --no-pager -n 12 2>/dev/null | tail -12
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
r'''修 deploy.sh 里 watchdog 块被我吃掉的两行。
== 出什么事了 ==
我用 heredoc(python -c @"..."@)改 _build/add_monitor_to_deploy.py 的锚点,
PowerShell 把里面的引号和 $ 打散了,脚本没改成;而**上一次**
(锚点写错的那次)已经把 deploy.sh 改了 —— 那次用的锚点是
systemctl enable lanremote-watchdog.timer ... || true
它把后面两行(start timer + say 状态)连带 `fi` 一起卷进了替换,
结果这两行就没了:
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
bash -n 仍然通过(语法没错,只是少了两行执行语句),
所以光靠语法检查发现不了 —— 这就是为什么改完还要**读回来核对**。
这个脚本把这两行补回去。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "relay" / "deploy.sh"
OLD = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
fi
# ---- 网站监测单元(如果包里有)----"""
NEW = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
fi
# ---- 网站监测单元(如果包里有)----"""
def main() -> int:
raw = DEPLOY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "systemctl start lanremote-watchdog.timer" in text:
print(" · 那两行已经在了")
return 0
if OLD not in text:
print(" ✗ 找不到锚点,需要人工看一眼")
return 1
text = text.replace(OLD, NEW, 1)
DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ 补回了 start timer 和 say 那两行")
return 0
if __name__ == "__main__":
sys.exit(main())
+148
View File
@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
r'''永久修掉 nettune 死锁。
== 死锁回顾 ==
lanremote-nettune.service Before=lanremote-tunnel.service
net-tune.sh 第 4 步 systemctl restart lanremote-tunnel
nettune 在"自己还没结束"的时候去 restart 一个"必须等自己结束才能启动"
的服务 —— systemd 认为顺序冲突,restart 永久阻塞。
实测卡了 9 分半,nettune 一直 activating,隧道被 Before= 挡着起不来,
公网 530。这就是"网站容易挂"的真因之一。
== 修法 ==
第 4 步的本意是"让**已经建好**的隧道用上新参数"(改完 MTU/MSS 之后
手动重跑脚本时用)。**开机时根本不该走到那一步** ——
开机顺序本来就是 nettune 先、隧道后,那时隧道还没起。
所以:unit 里加 Environment=NETTUNE_SERVICE_CALL=1,
脚本看到这个变量就跳过第 4 步。
为什么不干脆删掉第 4 步:
手动改完网络参数后,已经建立的隧道还在用旧参数,
需要重启它才生效。这个功能是有用的,只是不该在服务启动时跑。
为什么不用 `systemctl restart --no-block`:
那只是把阻塞变成异步,nettune 立刻退出、隧道随后重启 ——
开机时仍然是无谓的一次重启(隧道刚起来就被停掉),
而且没解决"不该在开机时重启"这个语义问题。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import upload, run # noqa: E402
TUNE = ROOT / "relay" / "net-tune.sh"
OLD = '''echo " 4) 让已经建立的隧道用上新参数"'''
NEW = '''# ════════════════════════════════════════════════════════════════════
# 4) 让已经建立的隧道用上新参数 —— **只在手动跑的时候做**
# ════════════════════════════════════════════════════════════════════
#
# ⚠ 这一步绝对不能由 lanremote-nettune.service 执行。
#
# 那个 unit 里写着 Before=lanremote-tunnel.service
# (意思是"我必须先于隧道启动完")。如果脚本在自己还没结束的时候
# 去 `systemctl restart lanremote-tunnel`,systemd 会认为存在
# 顺序冲突 —— restart 永久阻塞、nettune 一直卡在 activating、
# 隧道被 Before= 挡着永远起不来。
#
# 实测卡了 9 分半,表现就是"网站又挂了",而日志里什么都看不出来
# (只有 nettune 停在 activating)。手动跑本脚本却完全正常
# (因为那时没有 Before= 约束)—— 所以这个坑特别难发现。
#
# 开机时本来也不需要这一步:顺序就是 nettune 先、隧道后,
# 那时候隧道还没起来。
if [ "${NETTUNE_SERVICE_CALL:-0}" = "1" ]; then
echo " 4) 由 nettune 服务调用,**跳过重启隧道**"
echo " (unit 里有 Before=lanremote-tunnel.service,"
echo " 在这里 restart 会和 systemd 的顺序约束死锁)"
echo " (开机顺序本来就是本脚本先、隧道后,不需要这一步)"
elif [ "${SKIP_TUNNEL_RESTART:-0}" = "1" ]; then
echo " 4) SKIP_TUNNEL_RESTART=1,跳过重启隧道"
else
echo " 4) 让已经建立的隧道用上新参数"'''
def main() -> int:
raw = TUNE.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "NETTUNE_SERVICE_CALL" in text:
print(" · net-tune.sh 已经修过了")
elif OLD not in text:
print(" ✗ 找不到锚点")
return 1
else:
text = text.replace(OLD, NEW, 1)
# 原来的 for 循环 + RESTARTED 逻辑要收进 else 分支
# 找到 "echo \" 网络调优已应用" 之前的部分,补一个 fi
tail_anchor = 'echo " 网络调优已应用'
i = text.find(tail_anchor)
if i < 0:
print(" ✗ 找不到结尾锚点")
return 1
# 在结尾那行之前插入 fi(缩进对齐 for 循环之外)
text = text[:i] + "fi\n\n" + text[i:]
TUNE.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ net-tune.sh 加了 NETTUNE_SERVICE_CALL 判断")
# ---- 推到设备 + 给 unit 加环境变量 ----
upload(str(TUNE), "/root/lanremote-relay/net-tune.sh")
print(" ✓ 已上传 net-tune.sh")
unit = '''[Unit]
Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测)
After=network-online.target
Wants=network-online.target
Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service
[Service]
Type=oneshot
RemainAfterExit=yes
# ⚠ NETTUNE_SERVICE_CALL=1 告诉 net-tune.sh:这次是**服务**在调它,
# 不是人工跑的。脚本据此跳过"重启隧道让新参数生效"那一步。
#
# 为什么必须跳过:本 unit 有 Before=lanremote-tunnel.service,
# 脚本在自己还没结束时去 restart 隧道,systemd 会判定顺序冲突,
# restart 永久阻塞 -> nettune 卡在 activating -> 隧道起不来 -> 公网 530。
# 实测卡过 9 分半。
Environment=NETTUNE_SERVICE_CALL=1
ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh
[Install]
WantedBy=multi-user.target
'''
import tempfile
tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service"
tmp.write_text(unit, encoding="utf-8", newline="\n")
upload(str(tmp), "/etc/systemd/system/lanremote-nettune.service")
print(" ✓ 已更新 unit(加了 NETTUNE_SERVICE_CALL=1)")
out = run("bash -n /root/lanremote-relay/net-tune.sh && echo SH_OK; "
"systemctl daemon-reload && "
"systemctl restart lanremote-nettune && sleep 6 && "
"echo \"nettune: $(systemctl is-active lanremote-nettune)\"; "
"echo \"tunnel: $(systemctl is-active lanremote-tunnel)\"; "
"echo \"h2: $(systemctl is-active lanremote-tunnel-h2)\"; "
"echo \"公网: $(curl -s -o /dev/null -w '%{http_code}' "
"--max-time 20 https://relay.xxmxxs.top/v1/health)\"",
timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
return 0
if __name__ == "__main__":
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
r'''修 /rescue/ 鉴权失败时"不发响应"导致的挂起。
== 现象 ==
加上鉴权之后,不带登录请求 /rescue/xxx.txt 会**挂到超时**:
> GET /rescue/19700101-000000-demo.txt HTTP/1.1
* Operation timed out after 8001 milliseconds with 0 bytes received
curl 报 HTTP 000。
== 根因 ==
_admin_gate() **只返回布尔值,不负责发响应** —— 它是个判断函数。
所以每个调用点都必须自己在 false 时把响应发出去。
看其它调用点,全都是这么写的:
if not self._admin_gate():
self._json(401, {"ok": False, "error": "请先登录"})
return
而我加的那两处写成了:
if not self._admin_gate():
return <- do_GET:一个字节都没发
...
if not self._admin_gate():
return no() <- do_HEAD:no() 默认 404,状态码也不对
裸 return 在 BaseHTTPRequestHandler 里就是"什么都不做" ——
连接保持着、没有响应头、客户端一直等。表现成"网站卡住",
比报 401 严重得多。
== 改法 ==
do_GET -> self._json(401, ...) 然后 return
do_HEAD -> return no(401) (no() 本来就支持传状态码)
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
GET_OLD = ''' if not self._admin_gate():
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
GET_NEW = ''' # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** ——
# 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler
# 里等于"什么都不做":连接保持着、没有响应头,
# 客户端一直等到超时(curl 报 HTTP 000)。
# 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。
if not self._admin_gate():
self._json(401, {"ok": False, "error": "请先登录"})
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_OLD = ''' if not self._admin_gate():
return no()
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_NEW = ''' # 同上:必须自己发响应,而且状态码要是 401 不是 404
# (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。
if not self._admin_gate():
return no(401)
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
def main() -> int:
raw = RELAY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
ok = True
for old, new, what in ((GET_OLD, GET_NEW, "do_GET"),
(HEAD_OLD, HEAD_NEW, "do_HEAD")):
if old not in text:
print(" ✗ %s: 找不到锚点" % what)
ok = False
continue
text = text.replace(old, new, 1)
print(" ✓ %s: 鉴权失败时现在会发响应" % what)
if not ok:
return 1
RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
print(" 已写回 relay.py")
return 0
if __name__ == "__main__":
sys.exit(main())
+218
View File
@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
r'''补上 add_rescue_section.py 漏掉/插错的三处。
前一个补丁的问题:
1. _rescue_rows() 只在补丁脚本里定义,**没插进 admin.py** ——
后台一渲染就 NameError(页面直接 500)
2. /rescue/ 路由被插进了 **do_HEAD**,而且用了 `self.wfile.write(body)` ——
HEAD 本来就不该有 body。而且 do_GET 那边一条都没加,
等于浏览器点下载根本走不到。
(锚点 "/download/" 出现两次:do_HEAD 和 do_GET 各一份,
replace(...,1) 只命中了前面那个。这就是那个 "! 锚点 2 处" 警告。)
这个脚本按 do_HEAD 自己的 ok()/no() 风格把两处都写对。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
ADMIN = ROOT / "relay" / "admin.py"
def load(p: Path):
raw = p.read_bytes()
return raw, raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig")
def save(p: Path, text: str, bom: bool):
p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
# ===========================================================================
# 1. do_HEAD:换成 ok()/no() 风格(不能有 body)
# ===========================================================================
BAD_HEAD = ''' if path.startswith("/rescue/"):
# 抢救报告下载。单独一条路由,**不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
'''
GOOD_HEAD = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
return no()
target = RESCUE_DIR / safe
if not target.is_file():
return no()
quoted = urllib.parse.quote(safe)
headers = {"Content-Disposition":
"attachment; filename*=UTF-8''%s" % quoted,
"Cache-Control": "no-store"}
return ok("text/plain; charset=utf-8", target.stat().st_size,
headers)
'''
# ===========================================================================
# 2. do_GET:加同一条路由(这里才真正回 body)
# ===========================================================================
GET_ANCHOR = ''' if path.startswith("/download/"):
wanted = urllib.parse.unquote(path[len("/download/"):])
safe = _safe_download_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return'''
GET_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
''' + GET_ANCHOR
# ===========================================================================
# 3. admin.py:把 _rescue_rows 真正定义进去
# ===========================================================================
RESCUE_FN = '''def _rescue_rows(items: list) -> str:
"""抢救报告列表的 HTML。
照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 ——
这台机器只有 460MB 内存,多个接口就多份开销,而且后台页
本来就只是一次性的展示。
没有报告时给一句正面的话("一直没出过问题"),
而不是干巴巴的"暂无数据"—— 没报告是好事。
"""
if not items:
return ('<div class="out">还没有报告 —— 说明网站一直没出过'
'「自动救不回来」的问题。</div>')
import time as _t
import urllib.parse as _up
rows = []
for it in items:
when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"]))
size = it["size"] / 1024
size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"])
summary = _esc(it.get("summary") or "(没有结论行)")
href = "/rescue/" + _up.quote(it["name"])
rows.append(
'<div class="dlrow"><span class="nm">'
'<a href="%s" style="color:#0a84ff">%s</a>'
'<span style="color:#6b7280;font-size:12px;margin-left:8px">'
'%s</span></span>'
'<span class="sz" style="max-width:46%%;text-align:right">'
'%s</span></div>'
% (href, when, size_s, summary))
return "".join(rows)
def render_admin('''
def main() -> int:
ok = True
# ---- relay.py ----
raw, bom, text = load(RELAY)
if BAD_HEAD in text:
text = text.replace(BAD_HEAD, GOOD_HEAD, 1)
print(" ✓ do_HEAD 里的 /rescue/ 换成 ok()/no() 风格(不再写 body)")
elif GOOD_HEAD in text:
print(" · do_HEAD 已经是对的")
else:
print(" ✗ do_HEAD 里找不到那段 /rescue/")
ok = False
if 'if path.startswith("/rescue/")' in text.split("def do_GET")[1]:
print(" · do_GET 里已经有了")
else:
if GET_ANCHOR in text:
# 只替换 do_GET 里那一处(do_HEAD 那处已经在上面被换掉了,
# 所以现在全文只剩一处)
n = text.count(GET_ANCHOR)
if n != 1:
print(" ! do_GET 锚点还有 %d 处" % n)
text = text.replace(GET_ANCHOR, GET_NEW, 1)
print(" ✓ do_GET 加了 /rescue/ 路由(真正回 body 的那条)")
else:
print(" ✗ do_GET 里找不到 /download/ 锚点")
ok = False
save(RELAY, text, bom)
# ---- admin.py ----
raw, bom, text = load(ADMIN)
if "def _rescue_rows(" in text:
print(" · admin.py 里已经定义了 _rescue_rows")
else:
if "def render_admin(" not in text:
print(" ✗ admin.py 里找不到 render_admin")
ok = False
else:
text = text.replace("def render_admin(", RESCUE_FN, 1)
save(ADMIN, text, bom)
print(" ✓ admin.py 里补上了 _rescue_rows 的定义")
ok = ok and True
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
r'''放根目录的 LICENSE(AGPL-3.0)。
== 为什么要有这个文件 ==
Gitee 对一个公开仓库会检查"有没有公认的开源协议",没有就弹许可证向导,
而且远控类工具**没有协议**在合规视角里比有协议更容易被盯上
(这个仓库已经被判过一次"内容可能含有违规信息")。
原来仓库里只有 docs/LICENSE.txt —— 那是**自定义协议**,
里面写着"复制并分发完整副本(须保持文件完整、**不得修改**)",
这**不符合 OSI 开源定义**(开源必须允许修改),所以 Gitee 不认。
(Gitee 上这个仓库的 license 字段实测就是空的。)
== 为什么选 AGPL-3.0(用户拍板)==
* OSI 认可、Gitee 认,公开仓库不会再被提示
* 强 copyleft:别人改了必须以同样协议开源,**不能拿去做闭源商用** ——
最接近作者原本"免费给人用、但不许别人改名变成自己的"的想法
* 含"网络服务"条款:把它做成在线服务对外提供也必须开源,
正好匹配这个项目"中转服务器"的形态
(GPL 就没有这一条)
== 两份文件的分工(不冲突)==
LICENSE 管**代码**:可以改、可以再发布,但衍生作品必须同样开源
docs/LICENSE.txt 管**使用行为**:禁止偷窥、禁止私自装到别人设备、
禁止伪装隐藏等红线 + 免责声明
一个管"代码怎么用",一个管"行为不能做什么"。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = Path(__import__("os").environ.get("TEMP", "/tmp")) / "agpl.txt"
DST = ROOT / "LICENSE"
HEADER = """ykxmz —— 教室多媒体辅助工具
Copyright (C) 2026 小麦子
本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的
GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。
本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或
特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。
你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本
(见本文件下半部分)。如果没有,见 <https://www.gnu.org/licenses/>。
--------------------------------------------------------------------------
⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》):
本软件是一套远程控制工具,禁止用于未经同意的他人设备、
禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**,
和本文件(约束**代码**的授权)互不冲突。
--------------------------------------------------------------------------
"""
def main() -> int:
if not SRC.is_file():
print(" ✗ 找不到 AGPL 原文:%s" % SRC)
print(" (先跑一遍取原文的那步)")
return 1
body = SRC.read_text(encoding="utf-8")
if "GNU AFFERO GENERAL PUBLIC LICENSE" not in body:
print(" ✗ 原文看着不对,不含许可证标题")
return 1
DST.write_text(HEADER + body, encoding="utf-8", newline="\n")
print(" ✓ 已写 %s(%d 字节)" % (DST.name, DST.stat().st_size))
print(" 含 AGPL 标题: %s" % ("GNU AFFERO GENERAL PUBLIC LICENSE" in
DST.read_text(encoding="utf-8")))
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
r'''写监测单元的两个 systemd 单元文件。
用户要求「在后台设立检测单元」。
为什么用 timer 而不是常驻服务:
* 这台机器只有 460MB 内存,常驻 bash 循环白占一份内存
* 常驻脚本万一自己挂了就永远不检查了;timer 到点拉起来一次,
崩了下一分钟自己就回来
* 而且每次都是全新进程,不会有"跑久了状态变脏"的问题
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "relay"
SERVICE = '''[Unit]
# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。
#
# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量
TimeoutStartSec=360
# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 ——
# 部署时文件权限被改掉是最常见的"脚本不执行"原因。
ExecStart=/bin/bash /root/lanremote-relay/monitor.sh
# 环境变量
# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟)
# COOLDOWN 救完之后多少秒内不再救(默认 600 秒)
# 不设的话:预案刚重启完隧道还没连上,下一次检查
# 又判失败又救一遍,来回重启反而永远连不上
# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。
# 想开就把下面这行的 0 改成 1 —— 但先想清楚:
# 如果问题出在供电或上游线路,重启只会让恢复更慢,
# 而且半夜自动重启一台没人管的机器风险很大。
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
StandardOutput=append:/root/lanremote-relay/monitor.log
StandardError=append:/root/lanremote-relay/monitor.log
[Install]
WantedBy=multi-user.target
'''
TIMER = '''[Unit]
# 每 60 秒检查一次公网。
#
# 为什么是 60 秒不是 5 秒:
# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到
# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里
# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。
#
# OnBootSec=90s:开机后等 90 秒再开始查。
# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=lanremote-monitor.service
[Install]
WantedBy=timers.target
'''
def main() -> int:
(OUT / "lanremote-monitor.service").write_text(
SERVICE, encoding="utf-8", newline="\n")
(OUT / "lanremote-monitor.timer").write_text(
TIMER, encoding="utf-8", newline="\n")
for n in ("lanremote-monitor.service", "lanremote-monitor.timer"):
p = OUT / n
print(" ✓ %s(%d 字节)" % (n, p.stat().st_size))
return 0
if __name__ == "__main__":
sys.exit(main())
+13 -5
View File
@@ -17,7 +17,7 @@ import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERSION = "2.0.0"
VERSION = "2.1.1"
NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
@@ -584,12 +584,20 @@ def main() -> int:
server_dir = work / server_name
server_dir.mkdir(parents=True)
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
# 改成:relay 目录里所有 .py / .sh 都带上。
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
# 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。
# 后缀白名单:
# .py 服务本体
# .sh 部署/运维脚本
# .ico 管理页图标(漏了的话新设备上管理页是默认样式)
# .service /
# .timer systemd 单元。**这两个是后加的** ——
# 加监测单元的时候才发现白名单里没有它们,
# 于是 monitor.sh 进了包、拉它的 timer 却没进,
# 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。
wanted = sorted(
[f for f in RELAY_SRC.iterdir()
if f.suffix in (".py", ".sh", ".ico") and f.is_file()],
if f.suffix in (".py", ".sh", ".ico", ".service", ".timer")
and f.is_file()],
key=lambda f: f.name)
for src in wanted:
shutil.copy2(src, server_dir / src.name)
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
r'''修设备上的几个卡住的状态。
发现的问题:
1. lanremote-tunnel(主隧道)inactive —— 只剩 h2 副本在跑,
公网返回 530。这是网站"挂了"的直接原因。
2. lanremote-nettune 卡在 activating —— 所以 MTU 还是 1400
(而实测 1400 反而掉到 50%)。
3. monitor.timer 没起来。
按顺序修,每步都验。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
STEPS = [
("1. 看 nettune 为什么卡住(它挡着 MTU)",
"systemctl status lanremote-nettune --no-pager -n 12 2>&1 | tail -12; "
"echo '--- 有没有残留的 iptables 进程 ---'; pgrep -a iptables || echo 无"),
("2. 把卡住的 nettune 停掉、直接跑一次 net-tune.sh",
"systemctl stop lanremote-nettune 2>/dev/null; "
"systemctl reset-failed lanremote-nettune 2>/dev/null; "
"bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -12; "
"echo '--- 结果 ---'; "
"echo \"MTU=$(cat /sys/class/net/wlan0/mtu)\"; "
"echo \"MSS 规则=$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条\""),
("3. 拉起主隧道(网站 530 的直接原因)",
"systemctl reset-failed lanremote-tunnel 2>/dev/null; "
"systemctl restart lanremote-tunnel; sleep 12; "
"systemctl is-active lanremote-tunnel; "
"systemctl is-active lanremote-tunnel-h2"),
("4. 重启中转(让新的 admin.py 生效,后台才有「抢救报告」分区)",
"systemctl restart lanremote-relay; sleep 4; "
"systemctl is-active lanremote-relay"),
("5. 启用监测定时器",
"systemctl enable lanremote-monitor.timer >/dev/null 2>&1; "
"systemctl start lanremote-monitor.timer; sleep 3; "
"systemctl is-active lanremote-monitor.timer"),
("6. 验证:公网通了没 / 隧道连接数 / 后台分区",
"echo \"公网 /v1/health: $(curl -s -o /dev/null -w '%{http_code}' "
"--max-time 20 https://relay.xxmxxs.top/v1/health)\"; "
"echo \"公网首页: $(curl -s -o /dev/null -w '%{http_code}' "
"--max-time 20 https://relay.xxmxxs.top/)\"; "
"for p in 20241 20242; do "
"n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null "
"| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1); "
"echo \" 连接数($p): ${n:-拿不到}\"; done; "
"echo \"后台「抢救报告」出现次数: "
"$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan "
"| grep -c '抢救报告')\""),
]
def main() -> int:
for title, cmd in STEPS:
print()
print("=" * 66)
print(" " + title)
print("=" * 66)
try:
out = run(cmd, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
except Exception as exc: # noqa: BLE001
text = " [执行失败] %s: %s" % (type(exc).__name__, exc)
for ln in text.strip().splitlines():
print(" " + ln)
return 0
if __name__ == "__main__":
sys.exit(main())
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
r'''重启中转并验证 /rescue/,同时查 deploy_release.py 为什么没重启成功。
== 发现的问题 ==
PID=7255 进程启动于 18:04:35
relay.py 文件改于 18:07:41
进程比文件旧 -> 跑的还是老代码 -> /rescue/ 还是挂。
deploy_release.py 的 --code-only 分支里应该有 systemctl restart,
但显然没生效。两个可能:
1. 那一步的代码路径没走到(--code-only 把 restart 也跳过了)
2. 走了但失败被吞掉
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 1. 重启前 ════"
PID=$(systemctl show -p MainPID --value lanremote-relay)
echo " PID=$PID 启动于 $(ps -o lstart= -p $PID 2>/dev/null)"
echo
echo "════ 2. 重启 ════"
systemctl restart lanremote-relay
sleep 5
PID2=$(systemctl show -p MainPID --value lanremote-relay)
echo " 新 PID=$PID2 启动于 $(ps -o lstart= -p $PID2 2>/dev/null)"
echo " relay.py 改于 $(stat -c '%y' $RELAY/relay.py)"
echo " 服务状态: $(systemctl is-active lanremote-relay)"
echo
echo "════ 3. 重启后测 /rescue/(不带登录,应该立刻 401 而不是挂住)════"
printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
echo -n " 不带登录: "
curl -s -o /dev/null -w 'HTTP %{http_code} 用时 %{time_total}s\n' --max-time 8 \
"$B/rescue/19700101-000000-demo.txt"
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
curl -s -c /tmp/ck2.txt -o /dev/null --max-time 8 -d "password=$PASS" $B/login
echo -n " 带登录: "
curl -s -b /tmp/ck2.txt -o /tmp/rep2.txt -w 'HTTP %{http_code} 用时 %{time_total}s\n' \
--max-time 8 "$B/rescue/19700101-000000-demo.txt"
echo " 内容: $(head -1 /tmp/rep2.txt 2>/dev/null)"
echo
echo "════ 4. 后台页里的「抢救报告」分区 ════"
N=$(curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告')
echo " 匹配 $N 次"
curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /'
echo
echo "════ 5. 公网还通吗(重启不能把网站弄挂)════"
echo " 公网 health: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/v1/health)"
echo " 公网首页: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/)"
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt /tmp/ck2.txt /tmp/rep2.txt
'''
def main() -> int:
# ---- 先看 deploy_release.py 的 --code-only 分支到底走不走 restart ----
src = (ROOT / "_build" / "deploy_release.py").read_text(encoding="utf-8")
print(" ── deploy_release.py 里 restart 相关的代码 ──")
for i, ln in enumerate(src.splitlines(), 1):
if "restart" in ln or "files_only" in ln or "code_only" in ln:
print(" %4d: %s" % (i, ln.strip()))
print()
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+79
View File
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
r'''完整验证:登录 -> 后台页看到「抢救报告」-> 下载报告。
上一轮失败是因为我 curl 用错了接口(POST /login 只传 password),
实际是 POST /admin/login 且要 username + password 两个字段。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 0. 账号与密码 ════"
grep -m1 '^ADMIN_USER' $RELAY/relay.py | sed 's/^/ /'
echo " 密码文件长度: $(wc -c < $RELAY/data/admin_password.txt) 字节"
echo
echo "════ 1. 造报告 ════"
printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' \
> "$RELAY/rescue_reports/19700101-000000-demo.txt"
ls $RELAY/rescue_reports/ | sed 's/^/ /'
echo
echo "════ 2. 登录(POST /admin/login,username + password)════"
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
rm -f /tmp/ck3.txt
curl -s -c /tmp/ck3.txt -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
-d "username=" -d "password=$PASS" $B/admin/login
echo " cookie 文件:"
grep -c lr_admin /tmp/ck3.txt 2>/dev/null | sed 's/^/ 找到 lr_admin: /'
echo
echo "════ 3. 带登录拿后台页 ════"
curl -s -b /tmp/ck3.txt -o /tmp/adm3.html -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \
--max-time 10 $B/zhongzhuanzhan
echo " 含「抢救报告」: $(grep -c '抢救报告' /tmp/adm3.html)"
echo " 含演练报告文件名: $(grep -c '19700101-000000-demo.txt' /tmp/adm3.html)"
echo " 含「下载区」: $(grep -c '下载区' /tmp/adm3.html)"
echo
echo "════ 4. 带登录下载报告 ════"
curl -s -b /tmp/ck3.txt -o /tmp/rep3.txt -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \
--max-time 10 "$B/rescue/19700101-000000-demo.txt"
echo " 内容第一行: $(head -1 /tmp/rep3.txt)"
echo " 缓存头: $(curl -s -b /tmp/ck3.txt -D- -o /dev/null --max-time 10 \
"$B/rescue/19700101-000000-demo.txt" | grep -i 'cache-control' | tr -d '\r')"
echo
echo "════ 5. 不带登录(应该 401)════"
curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
"$B/rescue/19700101-000000-demo.txt"
echo
echo "════ 6. X-Admin 头(脚本通道)也要能下 ════"
curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
-H "X-Admin: $PASS" "$B/rescue/19700101-000000-demo.txt"
echo
echo "════ 清理 ════"
rm -f "$RELAY/rescue_reports/19700101-000000-demo.txt" /tmp/ck3.txt /tmp/adm3.html /tmp/rep3.txt
'''
def main() -> int:
out = run(REMOTE, timeout=200)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
r'''验证 /rescue/ 鉴权修复是否真的生效。
上一轮部署后还是 HTTP 000。要分清是:
* 代码没传上去 / 进程没重启(改动没生效)
* 还是代码有新问题
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 1. 设备上 relay.py 里那段代码长什么样 ════"
grep -n -A6 'if path.startswith("/rescue/")' $RELAY/relay.py | head -40 | sed 's/^/ /'
echo
echo "════ 2. 进程是什么时候起的 vs relay.py 修改时间 ════"
PID=$(systemctl show -p MainPID --value lanremote-relay)
echo " PID=$PID 启动: $(ps -o lstart= -p $PID 2>/dev/null)"
echo " relay.py 改于: $(stat -c '%y' $RELAY/relay.py)"
echo
echo "════ 3. 再来一次详细请求 ════"
printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | tail -20 | sed 's/^/ /'
echo
echo "════ 4. 对照:一个不存在的普通路径 ════"
curl -s -o /dev/null -w ' /nothing-here -> %{http_code}\n' --max-time 6 "$B/nothing-here"
echo
echo "════ 5. 对照:/download/ 上不存在的文件(那条没加鉴权)════"
curl -s -o /dev/null -w ' /download/none.txt -> %{http_code}\n' --max-time 6 "$B/download/none.txt"
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt
'''
def main() -> int:
out = run(REMOTE, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
r'''端到端验证「监测 + 预案 + 报告分区」。
验这些:
1. /rescue/ 不带登录 -> 401(不能匿名下诊断报告)
2. /rescue/ 带登录 -> 200 且内容正确
3. 报告文件名的目录穿越被挡住
4. monitor.sh 能跑通并写状态
5. rescue.sh 在网站正常时**什么都不做**就退出(不能瞎折腾)
6. 后台页里有「抢救报告」分区
7. 监测定时器在跑
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
B=http://127.0.0.1:8080
echo "════ 准备:造一份演练报告 ════"
DEMO=$RELAY/rescue_reports/19700101-000000-demo.txt
printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' > $DEMO
echo " $(ls $RELAY/rescue_reports | wc -l) 份"
echo
echo "════ 1. 不带登录下载(应该 401)════"
printf ' HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 $B/rescue/19700101-000000-demo.txt)"
echo
echo "════ 2. 带登录下载(应该 200 且有内容)════"
# 先登录拿 cookie
curl -s -c /tmp/ck.txt -o /dev/null --max-time 8 \
-d "password=$PASS" $B/login 2>/dev/null
CODE=$(curl -s -b /tmp/ck.txt -o /tmp/rep.txt -w '%{http_code}' --max-time 8 \
$B/rescue/19700101-000000-demo.txt)
printf ' HTTP %s\n' "$CODE"
echo " 内容:$(head -2 /tmp/rep.txt | tail -1)"
printf ' 缓存头: %s\n' "$(curl -s -b /tmp/ck.txt -D- -o /dev/null --max-time 8 $B/rescue/19700101-000000-demo.txt | grep -i cache-control | tr -d '\r')"
echo
echo "════ 3. 目录穿越(应该 400/404,绝不能读到别的文件)════"
for bad in "../relay.py" "..%2Frelay.py" "x/../../etc/passwd"; do
printf ' %-24s -> HTTP %s\n' "$bad" "$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 8 "$B/rescue/$bad")"
done
echo
echo "════ 4. monitor.sh 跑一次 ════"
rm -f $RELAY/monitor_state.txt
bash $RELAY/monitor.sh
echo " 状态文件:"
cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /'
echo " 日志尾部:"
tail -3 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /'
echo
echo "════ 5. rescue.sh 在网站正常时应该**什么都不做** ════"
BEFORE=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1)
timeout 120 bash $RELAY/rescue.sh "端到端验证(网站正常)" 2>&1 | tail -8 | sed 's/^/ /'
AFTER=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1)
if [ "$BEFORE" = "$AFTER" ]; then
echo " ✅ 网站正常时没有误触发抢救(知识库没变)"
else
echo " ❌ 网站正常却动了抢救"
fi
echo " 报告份数:$(ls $RELAY/rescue_reports | wc -l)(正常时不该新增)"
echo
echo "════ 6. 后台页里的「抢救报告」分区 ════"
N=$(curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告')
printf ' 匹配 %s 次\n' "$N"
curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /'
echo
echo "════ 7. 定时器 ════"
printf ' monitor.timer: %s\n' "$(systemctl is-active lanremote-monitor.timer)"
systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | sed -n '1,2p' | sed 's/^/ /'
echo
echo "════ 收尾:删掉演练报告 ════"
rm -f $DEMO /tmp/ck.txt /tmp/rep.txt
echo " 剩 $(ls $RELAY/rescue_reports | wc -l) 份"
'''
def main() -> int:
out = run(REMOTE, timeout=400)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
r'''把音效功能接进协议层和服务端。
加两条命令:
sfx_list 列出可用音效
sfx_play 播放指定的音效
复用现成的 play_audio(MCI),不另造一套播放器。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
SERVER = ROOT / "windows" / "lanremote" / "server.py"
def load(p: Path):
raw = p.read_bytes()
return raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig")
def save(p: Path, text: str, bom: bool):
p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
# ---------------------------------------------------------------- protocol
PROTO_ANCHOR = 'CMD_AUDIO_LIST = "audio_list"'
PROTO_NEW = '''CMD_AUDIO_LIST = "audio_list"
CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的)
CMD_SFX_PLAY = "sfx_play" # 播放某个音效'''
# ---------------------------------------------------------------- server
# 挂在 CMD_AUDIO_LIST 那条分支后面
SERVER_ANCHOR = ''' if cmd == P.CMD_AUDIO_LIST:'''
SERVER_NEW = ''' if cmd == P.CMD_SFX_LIST:
items = self.actions.list_sfx()
return {"ok": True, "sfx": items, "count": len(items),
"dir": self.actions.sfx_dir_hint()}
if cmd == P.CMD_SFX_PLAY:
name = str(body.get("name") or "").strip()
if not name:
return {"ok": False, "error": "没说要放哪个音效"}
try:
msg = self.actions.play_sfx(name)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
return {"ok": True, "message": msg, "name": name}
''' + SERVER_ANCHOR
def main() -> int:
ok = True
bom, text = load(PROTO)
if "CMD_SFX_LIST" in text:
print(" · protocol.py 已经加过了")
elif PROTO_ANCHOR not in text:
print(" ✗ protocol.py 找不到锚点")
ok = False
else:
text = text.replace(PROTO_ANCHOR, PROTO_NEW, 1)
save(PROTO, text, bom)
print(" ✓ protocol.py 加了两条命令")
bom, text = load(SERVER)
if "CMD_SFX_LIST" in text:
print(" · server.py 已经加过了")
elif SERVER_ANCHOR not in text:
print(" ✗ server.py 找不到锚点")
ok = False
else:
text = text.replace(SERVER_ANCHOR, SERVER_NEW, 1)
save(SERVER, text, bom)
print(" ✓ server.py 加了两个处理分支")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
r'''把会弹窗的测试阶段包进隐藏桌面。
== 用户要求 ==
「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」
== 现状 ==
有 6 个阶段会真的开窗口,其中 test_image_modes 还会开**全屏**
(直接把用户的屏幕锁住):
1e/7 新增功能 test_new_features.py
1f/7 电脑功能注册表 test_features.py
1g/7 功能模糊测试 fuzz_features.py
1k/7 首次安装设置窗口 test_first_run_setup.py
2b/7 投图显示方式 test_image_modes.py <- 全屏,锁屏
3/7 屏幕共享可见提示 test_indicator_visible.py
套件里原来只有一个 Close-TestWindows,那是**事后收尾**
(杀掉没退干净的进程),窗口在跑的时候照样在屏幕上闪。
== 改法 ==
把这些调用前面套一层 _tests/hidden_desktop.py --,
那个脚本会新建一个 Windows 桌面、把子进程放上去。
子进程里所有窗口都创建在那个桌面上 —— 用户完全看不到,
而窗口本身是**真的**(全屏、可见、几何、Win32 枚举全都真实),
测试有效性一点没损失。
& python hidden_desktop.py -- python test_image_modes.py
== 保留什么 ==
* `*>` 重定向照旧(hidden_desktop 会把子进程输出转写到自己的
stdout,套件照常能抓到 FAIL)
* 退出码照旧(hidden_desktop 用子进程的退出码退出)
* Close-TestWindows 留着 —— 它是兜底,还有用
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SUITE = ROOT / "_tests" / "verify_all.ps1"
# 要把调用包进隐藏桌面的测试
GUIs = [
"test_new_features.py",
"test_features.py",
"fuzz_features.py",
"test_first_run_setup.py",
"test_image_modes.py",
"test_indicator_visible.py",
]
def main() -> int:
raw = SUITE.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "hidden_desktop.py" in text:
print(" 已经接过了")
return 0
changes = []
for name in GUIs:
# 两种形状:
# & python (Join-Path $root "_tests\X") *> ...
# $stageOut = & python (Join-Path $root "_tests\X") 2>&1
pat = re.compile(
r'(& python \(Join-Path \$root "_tests\\%s"\))'
% re.escape(name))
n = len(pat.findall(text))
if n != 1:
changes.append(("!", name, "出现 %d 次" % n))
continue
# ⚠ 替换串里含反斜杠("_tests\hidden_desktop.py"),
# 直接当 repl 字符串会被 re 当成转义序列,
# 报 "bad escape \h"。所以用 lambda 返回字面量。
text = pat.sub(
lambda m: ('& python (Join-Path $root "_tests\\'
'hidden_desktop.py") -- ' + m.group(1)),
text, count=1)
changes.append(("✓", name, ""))
# 在第一个被包的调用之前插一段说明
note = '''# ---------------------------------------------------------------- 隐藏桌面
# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** ——
# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。
#
# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程
# 放上去,所有窗口都创建在那个不显示的桌面上。
# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实),
# 只是用户看不到 —— 测试有效性没有损失。
#
# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。
'''
anchor = 'Banner "1e/7 新增功能'
if anchor in text:
text = text.replace(anchor, note + anchor, 1)
changes.append(("✓", "插入说明注释", ""))
else:
changes.append(("✗", "插入说明注释", "找不到锚点"))
print(" ── 结果 ──")
for mark, what, why in changes:
print(" %s %-28s %s" % (mark, what, why))
if any(m == "✗" for m, _w, _y in changes):
print()
print(" 有没匹配上的,没有写回")
return 1
SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.encode("utf-8"))
print()
print(" 已写回 verify_all.ps1(BOM 保留:%s)" % had_bom)
return 0
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
{
"java-test-device": {
"name": "JVM测试端",
"token": "4ed3ec96a8e2fb0337de88aa9212d7f5",
"paired_at": 1791452868.0075707,
"last_seen": 1791452868.011561
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"port": 8799,
"server_name": "DESKTOP-RN5I625",
"allow_run": true,
"allow_power": true,
"allow_screen": true,
"start_minimized": false,
"allow_new_pairing": true,
"image_timeout": 0,
"image_display": "window",
"image_custom_width": 0,
"image_custom_height": 0,
"image_window_title": "",
"tts_voice": "",
"tts_rate": 0,
"mp3_dir": "",
"file_roots": "",
"extra_apps": "",
"extra_commands": "",
"download_dir": "received",
"screen_max_width": 480,
"screen_quality": 72,
"screen_fps": 12,
"pairing_code": "246813"
}
+256
View File
@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
r'''在**隐藏的 Windows 桌面**上跑命令,用户屏幕不受打扰。
== 为什么需要 ==
用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。
有几个测试必须开**真实的窗口**才测得出东西,不能改成假的:
test_image_modes 投图窗口的三种显示方式(含**全屏**)
test_indicator_visible 屏幕共享提示条 + Win32 窗口枚举
fuzz_features 功能模糊测试
test_first_run_setup 首次设置窗口
问题是它们跑的时候会在用户屏幕上一通闪,**全屏那个还会把屏幕锁住**,
用户根本没法用电脑。
== 解法 ==
Windows 支持多个"桌面"(Desktop)—— 同一个登录会话里可以有若干个,
但**只有一个是当前显示给用户的**(叫 Default)。
我们新建一个桌面,把测试进程放上去:
CreateDesktopW("ykxmz-test", ...) 建一个不显示的桌面
CreateProcessW(..., lpDesktop=那个名字) 把子进程放上去
子进程里所有窗口都创建在那个桌面上 -> 用户完全看不到
跑完 CloseDesktop
这样窗口是**真的**(窗口句柄、几何、可见性、Win32 枚举全都真实),
只是不在用户眼前 —— 测试有效性一点没损失。
== 为什么不用别的方法 ==
* "把窗口设成透明/最小化" —— 那就测不出全屏了,而且还是会闪
* "跳过这些测试" —— 投图和提示条是核心功能,不能因为烦就不测
* "用无头模式" —— 那测的就不是真实窗口了
== 用法 ==
命令行:
python _tests/hidden_desktop.py -- <要跑的命令...>
代码里:
from hidden_desktop import run_hidden
code, out = run_hidden(["python", "test_image_modes.py"], timeout=300)
'''
from __future__ import annotations
import ctypes
import ctypes.wintypes as wt
import os
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
IS_WINDOWS = os.name == "nt"
# ---- Win32 常量和结构 ----
# 桌面权限:给足,否则 CreateProcess 放上去之后子进程建不了窗口
DESKTOP_ALL_ACCESS = 0x000F01FF
GENERIC_ALL = 0x10000000
CREATE_NEW_CONSOLE = 0x00000010
CREATE_UNICODE_ENVIRONMENT = 0x00000400
WAIT_TIMEOUT = 0x00000102
INFINITE = 0xFFFFFFFF
class STARTUPINFOW(ctypes.Structure):
_fields_ = [
("cb", wt.DWORD),
("lpReserved", wt.LPWSTR),
("lpDesktop", wt.LPWSTR),
("lpTitle", wt.LPWSTR),
("dwX", wt.DWORD),
("dwY", wt.DWORD),
("dwXSize", wt.DWORD),
("dwYSize", wt.DWORD),
("dwXCountChars", wt.DWORD),
("dwYCountChars", wt.DWORD),
("dwFillAttribute", wt.DWORD),
("dwFlags", wt.DWORD),
("wShowWindow", wt.WORD),
("cbReserved2", wt.WORD),
("lpReserved2", ctypes.POINTER(ctypes.c_byte)),
("hStdInput", wt.HANDLE),
("hStdOutput", wt.HANDLE),
("hStdError", wt.HANDLE),
]
class PROCESS_INFORMATION(ctypes.Structure):
_fields_ = [
("hProcess", wt.HANDLE),
("hThread", wt.HANDLE),
("dwProcessId", wt.DWORD),
("dwThreadId", wt.DWORD),
]
def _api():
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
user32.CreateDesktopW.argtypes = [
wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD,
wt.DWORD, ctypes.c_void_p]
user32.CreateDesktopW.restype = wt.HANDLE
user32.CloseDesktop.argtypes = [wt.HANDLE]
user32.CloseDesktop.restype = wt.BOOL
kernel32.CreateProcessW.argtypes = [
wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL,
wt.DWORD, ctypes.c_void_p, wt.LPCWSTR,
ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)]
kernel32.CreateProcessW.restype = wt.BOOL
kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
kernel32.WaitForSingleObject.restype = wt.DWORD
kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE,
ctypes.POINTER(wt.DWORD)]
kernel32.GetExitCodeProcess.restype = wt.BOOL
kernel32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT]
kernel32.CloseHandle.argtypes = [wt.HANDLE]
return user32, kernel32
def run_hidden(command, timeout: float = 600.0, cwd: str | None = None,
log_path: Path | None = None) -> tuple:
"""在隐藏桌面上跑命令。
返回 (退出码, 输出文本)。退出码 -1 表示建桌面失败(会自动退回
直接在普通桌面跑,并说明原因 —— 宁可闪一下也不能让测试跑不了)。
"""
if not IS_WINDOWS:
# 非 Windows 直接跑
r = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=timeout)
return r.returncode, (r.stdout or "") + (r.stderr or "")
if log_path is None:
log_path = Path(tempfile.gettempdir()) / (
"ykxmz_hidden_%s.log" % uuid.uuid4().hex[:8])
user32, kernel32 = _api()
name = "ykxmz-test-%s" % uuid.uuid4().hex[:8]
desk = user32.CreateDesktopW(name, None, None, 0,
DESKTOP_ALL_ACCESS | GENERIC_ALL, None)
if not desk:
err = ctypes.get_last_error()
# 建不了桌面(少见,通常是权限)—— 退回普通方式,但要说清楚
r = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=timeout)
msg = (" [警告] 建隐藏桌面失败(Win32 错误 %d),"
"这次只能在普通桌面上跑 —— 屏幕上可能会闪窗口。\n" % err)
return r.returncode, msg + (r.stdout or "") + (r.stderr or "")
try:
# 让子进程自己把输出重定向到文件 —— 走 CreateProcess 时
# 管道不好接管,重定向到文件最简单可靠。
# 用 cmd /c 是为了让 > 生效。
quoted = subprocess.list2cmdline([str(c) for c in command])
full = 'cmd.exe /c %s > "%s" 2>&1' % (quoted, log_path)
# cmd /c 后面整串要作为一个参数传
cmdline = ctypes.create_unicode_buffer(full)
si = STARTUPINFOW()
si.cb = ctypes.sizeof(STARTUPINFOW)
si.lpDesktop = name # <<< 关键:把子进程放到隐藏桌面
pi = PROCESS_INFORMATION()
ok = kernel32.CreateProcessW(
None, cmdline, None, None, False,
CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
None, cwd, ctypes.byref(si), ctypes.byref(pi))
if not ok:
err = ctypes.get_last_error()
r = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, encoding="utf-8",
errors="replace", timeout=timeout)
return r.returncode, (" [警告] 在隐藏桌面上启动失败"
"(Win32 错误 %d)\n" % err
) + (r.stdout or "") + (r.stderr or "")
try:
waited = kernel32.WaitForSingleObject(pi.hProcess,
int(timeout * 1000))
if waited == WAIT_TIMEOUT:
kernel32.TerminateProcess(pi.hProcess, 124)
code = 124
else:
code = wt.DWORD()
kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code))
code = code.value
finally:
kernel32.CloseHandle(pi.hThread)
kernel32.CloseHandle(pi.hProcess)
# 给文件一点时间落盘
for _ in range(40):
if log_path.is_file() and log_path.stat().st_size > 0:
break
time.sleep(0.05)
try:
out = log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
out = ""
return code, out
finally:
user32.CloseDesktop(desk)
try:
log_path.unlink()
except OSError:
pass
def main() -> int:
if not IS_WINDOWS:
print(" 这个工具只在 Windows 上有意义")
return 1
args = sys.argv[1:]
if args and args[0] == "--":
args = args[1:]
if not args:
print(__doc__)
return 1
print(" 在隐藏桌面上运行:%s" % " ".join(args))
started = time.time()
code, out = run_hidden(args, timeout=900)
took = time.time() - started
# 原样把输出打出来(套件靠它判断)
sys.stdout.write(out)
if not out.endswith("\n"):
sys.stdout.write("\n")
# ⚠ 总结这行**只在终端里打**。
# 套件是用 `*> 日志文件` 接输出的,再 `Select-Object -Last 2/3`
# 取摘要 —— 如果这里多打一行,就会把真正的摘要顶掉,
# 汇总表上显示的是"隐藏桌面运行结束"而不是测试结果。
if sys.stdout.isatty():
print(" —— 隐藏桌面运行结束:退出码 %d,用时 %.1f 秒" % (code, took))
return code
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More