mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 19:30:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e64bc7920 | ||
|
|
c5fa4eb517 | ||
|
|
05d2287fbf | ||
|
|
6adf7acbfb | ||
|
|
4ba18fdae9 | ||
|
|
3f18e6bb40 |
@@ -1,3 +1,23 @@
|
|||||||
|
ykxmz —— 教室多媒体辅助工具
|
||||||
|
Copyright (C) 2026 小麦子
|
||||||
|
|
||||||
|
本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的
|
||||||
|
GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。
|
||||||
|
|
||||||
|
本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或
|
||||||
|
特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。
|
||||||
|
|
||||||
|
你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本
|
||||||
|
(见本文件下半部分)。如果没有,见 <https://www.gnu.org/licenses/>。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》):
|
||||||
|
本软件是一套远程控制工具,禁止用于未经同意的他人设备、
|
||||||
|
禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**,
|
||||||
|
和本文件(约束**代码**的授权)互不冲突。
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
@@ -633,8 +653,8 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Affero General Public License as published
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
by the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
|
|||||||
@@ -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
|
## 2.0.0
|
||||||
|
|
||||||
- **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 ——
|
- **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 ——
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -17,7 +17,7 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
VERSION = "2.0.0"
|
VERSION = "2.1.1"
|
||||||
NAME = f"ykxmz-{VERSION}"
|
NAME = f"ykxmz-{VERSION}"
|
||||||
|
|
||||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||||
@@ -584,12 +584,20 @@ def main() -> int:
|
|||||||
server_dir = work / server_name
|
server_dir = work / server_name
|
||||||
server_dir.mkdir(parents=True)
|
server_dir.mkdir(parents=True)
|
||||||
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
|
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
|
||||||
# 改成:relay 目录里所有 .py / .sh 都带上。
|
# 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。
|
||||||
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
|
# 后缀白名单:
|
||||||
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
|
# .py 服务本体
|
||||||
|
# .sh 部署/运维脚本
|
||||||
|
# .ico 管理页图标(漏了的话新设备上管理页是默认样式)
|
||||||
|
# .service /
|
||||||
|
# .timer systemd 单元。**这两个是后加的** ——
|
||||||
|
# 加监测单元的时候才发现白名单里没有它们,
|
||||||
|
# 于是 monitor.sh 进了包、拉它的 timer 却没进,
|
||||||
|
# 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。
|
||||||
wanted = sorted(
|
wanted = sorted(
|
||||||
[f for f in RELAY_SRC.iterdir()
|
[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)
|
key=lambda f: f.name)
|
||||||
for src in wanted:
|
for src in wanted:
|
||||||
shutil.copy2(src, server_dir / src.name)
|
shutil.copy2(src, server_dir / src.name)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"java-test-device": {
|
||||||
|
"name": "JVM测试端",
|
||||||
|
"token": "4ed3ec96a8e2fb0337de88aa9212d7f5",
|
||||||
|
"paired_at": 1791452868.0075707,
|
||||||
|
"last_seen": 1791452868.011561
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user