mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 17:40:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2061f9525f | ||
|
|
1d368979aa | ||
|
|
48bc392466 | ||
|
|
fd8be87209 | ||
|
|
f0c05079f7 | ||
|
|
3f543b274a | ||
|
|
4d97e40280 | ||
|
|
31a2360031 | ||
|
|
be59a20036 | ||
|
|
7ffd56391e | ||
|
|
0ad3f648c6 | ||
|
|
9bdd36cf56 | ||
|
|
a53a3eee21 | ||
|
|
f173c402da | ||
|
|
47e08af151 | ||
|
|
e63b5f08d7 | ||
|
|
25ffd3ec67 | ||
|
|
f748565ebf | ||
|
|
db669e2f40 | ||
|
|
daf73dfe8e | ||
|
|
5ddd4cd752 | ||
|
|
892d5891ea | ||
|
|
62e67e9e26 | ||
|
|
5fc2f97be7 | ||
|
|
bc289c5c21 | ||
|
|
d5a1943d1f | ||
|
|
768309c1f2 | ||
|
|
8dc47d0f48 |
+9
-1
@@ -78,4 +78,12 @@ desktop.ini
|
||||
admin_password.txt
|
||||
*admin_password*.txt
|
||||
/_build/_hdr_data/
|
||||
/_build/relay_info.json
|
||||
/_build/relay_info.json
|
||||
# 后台管理密码(绝不进仓库)
|
||||
_build/.admin_password
|
||||
|
||||
# 服务器地址(绝不进仓库)
|
||||
_build/.lightsail_host
|
||||
_build/.gitee_token
|
||||
_build/.admin_password
|
||||
_build/.halo_password
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
|
||||
- 课件全屏投到教室大屏
|
||||
- 放听力音频(电脑音箱比手表响多了)
|
||||
- 看看学生机是不是在打游戏,直接把游戏进程结了
|
||||
- 课堂专注模式,下课一键恢复
|
||||
- 上课时把教室电脑切到专注状态,下课一键恢复
|
||||
- 下课关机、锁屏
|
||||
- 喊一声"上课了"让电脑播音(TTS)
|
||||
|
||||
@@ -46,9 +45,9 @@
|
||||
|
||||
| 装在哪 | 是什么 | 文件 |
|
||||
|---|---|---|
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-1.8.2.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.8.2.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-1.8.2.apk` |
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.3.0.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.3.0.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.3.0.apk` |
|
||||
|
||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||
|
||||
@@ -82,7 +81,7 @@
|
||||
|
||||
### 第一步:电脑上装被控端
|
||||
|
||||
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
双击 `ykxmz-setup-2.3.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
|
||||
第一次运行会弹出窗口,填两样:
|
||||
|
||||
@@ -95,12 +94,12 @@
|
||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||
> 想彻底退出,窗口上点「退出」。
|
||||
|
||||
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
|
||||
> 不想用安装包的话,`ykxmz-agent-2.3.0.zip` 是绿色版:
|
||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||
|
||||
### 第二步:手表上装 App
|
||||
|
||||
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
把 `ykxmz-watch-2.3.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
|
||||
打开 App,首页有四个入口:
|
||||
|
||||
@@ -140,26 +139,34 @@
|
||||
|
||||
这一页是给上课准备的。
|
||||
|
||||
> **先说清楚作用范围**:被控端装在**一台** Windows 电脑上
|
||||
> —— 就是讲台那台,或者你自己的机器。
|
||||
> **所有操作只影响这一台**,看不到也管不着其他电脑。
|
||||
> 装的时候要输配对码,没配对码连不上。
|
||||
|
||||
| 按钮 | 干什么 |
|
||||
|---|---|
|
||||
| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
|
||||
| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 |
|
||||
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
|
||||
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
|
||||
| **课堂专注模式** | 在**本机 hosts** 里把常见游戏站点指到本地,本机打开是空白页 |
|
||||
| **结束游戏进程** | 扫**本机**正在跑的常见游戏进程,结束掉 |
|
||||
| **看有哪些限制** | 列出当前开了哪些,别自己忘了 |
|
||||
| **一键恢复全部** | 把这节课改的全撤掉,**下课必点** |
|
||||
|
||||
屏蔽是改 hosts 文件,不是装驱动,所以:
|
||||
怎么实现的、有什么边界,一并说清楚:
|
||||
|
||||
- 学生自己删掉 hosts 里那几行就绕过去了 —— 它挡的是"随手点开",不是防作弊
|
||||
- 换个游戏站就得重新加,这个列表是写死的常见站点
|
||||
- 重启电脑有的会保留、有的不会,取决于你们学校的还原卡
|
||||
- 屏蔽是改**本机** `hosts` 文件,不装驱动、不装服务
|
||||
- **只挡"随手点开"**:懂行的人自己把 hosts 里那几行删了就过去了。
|
||||
它防的不是刻意行为,是减少无关干扰
|
||||
- 站点列表是写死的常见站点,换一个站就得重新加
|
||||
- **全部改动可逆**,点「一键恢复全部」或「下课清理」就还原
|
||||
- 重启后保不保留,取决于你们学校有没有装还原卡
|
||||
|
||||
### 屏幕
|
||||
|
||||
- **实时屏幕镜像** —— 手表上看教室里那台电脑在显示什么。默认 4 fps,在手表上太小看不清,主要是"看一眼有没有人乱动"
|
||||
- **实时屏幕镜像** —— 手表上看**被控端那台电脑**在显示什么(低头确认课件放没放对,不用走回讲台)
|
||||
- **一键截屏** —— 存到手表相册
|
||||
|
||||
> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。**
|
||||
> 这是刻意做的:教室里那台电脑是公家的,谁在看必须让人知道。
|
||||
> 这是刻意做的:谁在看,电脑前面的人必须能立刻知道。
|
||||
> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。
|
||||
|
||||
### 媒体 / 音频
|
||||
@@ -216,7 +223,7 @@
|
||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||
|
||||
把 `ykxmz-server-1.8.2.zip` 解压到那台机器上,然后一条命令:
|
||||
把 `ykxmz-server-2.3.0.zip` 解压到那台机器上,然后一条命令:
|
||||
|
||||
```bash
|
||||
bash deploy.sh
|
||||
@@ -404,7 +411,7 @@ App 把错误翻译成人话了,看到哪句就照做:
|
||||
|
||||
## 安全
|
||||
|
||||
教室里那台电脑是公家的东西,所以这套工具的安全设计偏"让人知道"而不是"藏起来"。
|
||||
被控端装的往往是公家的电脑,所以这套工具的安全设计偏"让人知道"而不是"低调运行"。
|
||||
|
||||
**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。
|
||||
|
||||
@@ -418,11 +425,14 @@ App 把错误翻译成人话了,看到哪句就照做:
|
||||
|
||||
**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。
|
||||
|
||||
**不在后台偷偷上传。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站。
|
||||
**只在你自己填的地址之间通信。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站,不经过任何第三方。
|
||||
|
||||
> **这个工具不藏自己。** 没有"任务管理器里看不见",没有"免杀对抗",没有伪装成系统进程。
|
||||
> 被控端全程可见、可随时断开、权限可随时关掉。
|
||||
> 因为管的是教室的电脑,藏起来只会让老师不放心。
|
||||
**所有行为都在明面上。** 被控端在托盘有图标、窗口看得见、随时可以退出、
|
||||
权限可以逐项关闭。它不做任何"让机器上其他程序看不见自己"的事 ——
|
||||
因为管的是自己要用的一台电脑,能看见、能随时停,才让人放心。
|
||||
|
||||
**代码全公开。** 三端加起来不到两万行,都是纯标准库,
|
||||
想看它到底干了什么直接读源码就行。
|
||||
|
||||
---
|
||||
|
||||
@@ -446,7 +456,7 @@ windows\build_exe.ps1
|
||||
```powershell
|
||||
android\build_apk.ps1
|
||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
||||
# 产物:dist\ykxmz-watch-1.8.2.apk
|
||||
# 产物:dist\ykxmz-watch-2.3.0.apk
|
||||
```
|
||||
|
||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||
|
||||
@@ -1,3 +1,332 @@
|
||||
## 2.3.0
|
||||
|
||||
- **手表端界面大改** —— 用户原话「杂七杂八功能都删掉」。功能页从 10 个杂项分页
|
||||
精简成 7 个明确的:
|
||||
|
||||
| 分页 | 内容 |
|
||||
|---|---|
|
||||
| 屏幕 | 屏幕镜像 / 返回桌面 / 亮度 + / 亮度 - / 截屏 |
|
||||
| 鼠标 | 远程鼠标控制 |
|
||||
| 媒体 | 播放暂停 / 上一曲 / 下一曲 / 音量 +- / 静音 / **播放器** |
|
||||
| 文件 | 发送文件 / 打开回收站 |
|
||||
| 电脑 | 打开网址 / 快捷命令 / 进程管理 / 窗口置顶 / 结束所有进程 |
|
||||
| 语音 | 语音播报 / 朗读剪贴板 |
|
||||
| 电源 | 锁屏 / 睡眠 / 关机 / 重启 |
|
||||
|
||||
**删掉的**:游戏封锁、待办、掷骰子、抽奖、抛硬币、壁纸、显示器开关、
|
||||
定时关机、剪贴板历史、性能/磁盘/电量、文件夹浏览、教室限制……
|
||||
|
||||
> 改了实现方式:以前每个分页靠 `TabDef.groups` 把电脑端上报的功能组
|
||||
> 无脑铺上来 —— 电脑端加个功能,手表上就多一堆按钮。**那正是"乱"的来源。**
|
||||
> 现在改成**显式列按钮**:手表上有什么,完全由代码决定。
|
||||
|
||||
- **远程鼠标控制重做** —— 用户要求「中间是一个鼠标,可以上下左右滑动控制,
|
||||
下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 滑动区(占满) │ 手指滑动 = 移动光标
|
||||
├────────┬────────┬────────┤
|
||||
│ 左键 │ 滚轮 │ 右键 │
|
||||
└────────┴────────┴────────┘
|
||||
```
|
||||
|
||||
滚轮按钮:**点一下 = 中键单击**,**上下滑 = 滚动**。
|
||||
三个按钮就把左/中/右 + 滚轮全覆盖了。
|
||||
|
||||
- **新增视频 / 音乐播放器** —— 用户要求「有暂停开始,进度左右划,
|
||||
下一个上一个」。
|
||||
|
||||
**设计取舍:让手表自己放,而不是让电脑放。**
|
||||
|
||||
如果让电脑放(调 `play_video`),电脑会打开它自己的播放器 ——
|
||||
我们就**拿不到进度、也没法精确 seek**,那"进度左右划"就是假的。
|
||||
手表自己放的话 `MediaPlayer` / `VideoView` 的
|
||||
`getCurrentPosition` / `seekTo` 都是现成的,进度条和拖动全部真实可控。
|
||||
|
||||
代价:播放前要先把文件从电脑下到手表(走现有的分块传输)。
|
||||
|
||||
操作:
|
||||
* 进度条拖动 = seek
|
||||
* **画面区左右滑 = 快进/快退 10 秒**
|
||||
* **点画面 = 播放/暂停**
|
||||
* 上一 / 下一(播完自动下一首)
|
||||
* 列表 = 从电脑的音乐/视频/下载/桌面目录里挑
|
||||
|
||||
- **电脑端新增 `kill_all`(一键结束所有进程)** ——
|
||||
**白名单式保护**:本程序自己的 PID、父进程链、一批系统关键进程
|
||||
(csrss / winlogon / lsass / svchost / explorer / dwm …)全部跳过,
|
||||
否则一按就蓝屏或桌面消失。其余全杀,返回杀了几个、跳过几个、失败几个。
|
||||
|
||||
- **电脑端新增 `media_list` / `media_pull`** —— 播放器的数据源。
|
||||
**安全边界**:只允许「音乐/视频/下载/桌面/接收目录」里的文件,
|
||||
用 `Path.relative_to` 判断(不是字符串前缀 —— 那样「音乐-evil」
|
||||
这种同前缀兄弟目录会被误判成子目录)。
|
||||
|
||||
- **中转站:管理员改过的配对码不再因关机 >6 小时丢失** 🔧
|
||||
|
||||
之前这条链会断:
|
||||
|
||||
1. 管理员在后台把码 `111111` 改成 `222222`
|
||||
2. 那台电脑关机(或断网)超过 `DEVICE_TTL`(6 小时)
|
||||
3. `snapshot()` 把设备删了,孤儿的码 `222222` 也被当垃圾清掉
|
||||
4. 电脑开机,报自己本地的旧码 `111111`
|
||||
5. 中转已经不记得改过码 → 收下 `111111`
|
||||
6. **管理员的改码白做了**,手表手里是 `222222`,连不上
|
||||
|
||||
修法:`known_devices` 从「纯 ID 集合」改成「**设备 ID → 配对码**」映射。
|
||||
改码时会同步写进历史,超时清理不动它。设备重新登记时以历史里的码为准,
|
||||
并随 `register()` 的返回值告诉电脑端,**电脑端写回本地设置**。
|
||||
|
||||
兼容性:老格式的 `relay_state.json`(`knownDevices` 是纯列表)
|
||||
能平滑升级 —— 加载时识别成「认识这台设备,但不知道它该用哪个码」,
|
||||
下次它上报什么就用什么(跟改动之前一致)。
|
||||
|
||||
「删除设备」仍然忘干净(连历史一起删);「抹码」会清空历史里记的码
|
||||
但保留设备 ID —— 否则抹码会白做。
|
||||
|
||||
## 2.2.0
|
||||
|
||||
- **手表端:输入配对码连上之后,先到一个选择页** ——
|
||||
上面有两个按钮:
|
||||
|
||||
1. **远控** —— 进控制页(和以前一样)
|
||||
2. **静默更新被控端** —— 让那台电脑自己升级到最新版
|
||||
|
||||
页面顶上会显示**已连接的电脑名**和**电脑端当前版本**
|
||||
(比如「电脑端 2.1.5 → 可更新到 2.2.0」),一眼就知道要不要更新。
|
||||
|
||||
**为什么不直接进控制页**:老版本的被控端可能缺功能或者有 bug,
|
||||
「先更新再远控」是更合理的顺序 —— 但也不强制,所以给用户自己选。
|
||||
|
||||
(自动切到中转服务器恢复时**不会**弹这个页面,还是直接回控制页 ——
|
||||
用户本来就在用,中途弹一层会打断他。)
|
||||
|
||||
## 2.1.8
|
||||
|
||||
- **快捷栏的按钮做大** —— 上一版 30dp 在手表上按不准,手指按上去
|
||||
一半在栏外。现在 46dp(接近 Android 建议的可点区域),字号 11→14。
|
||||
|
||||
- **快捷栏 4 个格子全部可以自定义** —— 上一版最后一个「显示桌面」
|
||||
是写死不能改的,现在四格平等,想换哪个换哪个。
|
||||
|
||||
「显示桌面」变成一个普通的可选动作,默认仍放在第 4 格 ——
|
||||
所以习惯不用改,但想换成别的也行。可选动作现在共 8 个:
|
||||
传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 /
|
||||
投图到电脑 / 显示桌面。
|
||||
|
||||
- **修 Win7 上语音播报不能用** —— 以前只走 PowerShell 一条路,
|
||||
而 Win7 上的 PowerShell 是 2.0 版,中文经命令行传进去容易乱码;
|
||||
再加上 `powershell` 可能不在 PATH 里,就彻底播不出来了。
|
||||
|
||||
现在改成**多方案降级**,按"依赖越少越优先"排:
|
||||
|
||||
| 顺序 | 方案 | 依赖 |
|
||||
|---|---|---|
|
||||
| 1 | cscript + VBScript + SAPI5 | Windows 98 起就有,不用装任何东西 |
|
||||
| 2 | wscript + VBScript + SAPI5 | 上一条被安全软件拦时的备选 |
|
||||
| 3 | powershell + SAPI5 (COM) | 只依赖 PowerShell,**不需要 .NET** |
|
||||
| 4 | powershell + System.Speech | 原来的做法,改用绝对路径 |
|
||||
| 5 | powershell (按 PATH) + System.Speech | 最原始的,兜底 |
|
||||
|
||||
一条通了就记住它,以后直接用(不用每次从头试)。
|
||||
|
||||
**全都失败会明确报错** —— 以前是不等进程结束就返回"已播报",
|
||||
明明没出声也报成功,那是在骗人。
|
||||
|
||||
## 2.1.7
|
||||
|
||||
- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮:
|
||||
|
||||
- **最后一个是固定的「显示桌面」**(等于电脑上按 Win+D),**改不了**
|
||||
- **前三个自己选**,在设置页里点一下从这 7 个里挑:
|
||||
传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 / 投图到电脑
|
||||
- 也可以把某一格留空
|
||||
- 还没连上电脑时它们是灰的(不是藏起来 —— "一直在但暂时不能用"
|
||||
比"时有时无"更好理解)
|
||||
|
||||
- **远程鼠标控制(手表上的触摸板)** —— 拖动移动光标、单击左键、
|
||||
双击右键,下面还有专门的左键/右键/滚轮按钮。
|
||||
|
||||
- **弹文字窗口的位置和大小可以自己设** —— 右下(默认)/ 右上 / 左上 /
|
||||
左下 / 屏幕正中间 / 自定义坐标,字号 8~32 可调。
|
||||
在电脑端设置窗口的「显示」里改。
|
||||
|
||||
- **屏幕被观看时的提示条改成默认不显示** ——
|
||||
以前是强制弹的("有人在看你的屏幕,电脑前面的人该知道")。
|
||||
现在默认关掉,跟常见远程工具一样全程无窗口;需要的话在
|
||||
设置里可以打开。**这个开关是刻意保留的**:这软件应该始终有一个
|
||||
"让本机用户看得见正在被控制"的选项可查。
|
||||
|
||||
- **中转后台能看到每台电脑的版本了**,并且可以直接点「更新」
|
||||
让那台电脑自己静默升级(离线设备按钮置灰)。
|
||||
|
||||
- **手表端设置里可以开关「允许新设备连接」** ——
|
||||
关掉之后只有配对过的电脑能连上来,教室里防别人乱连。
|
||||
|
||||
## 2.1.6
|
||||
|
||||
- **电脑端静默自动更新** —— 手表上点一下,电脑自己升到最新版,
|
||||
全程不弹任何窗口,**中转地址、配对码、所有设置一个都不变**。
|
||||
|
||||
流程:手表点按钮 → 电脑端问中转"最新是哪版" → 下载并校验 →
|
||||
起一个后台帮手 → 自己退出 → 帮手等它退出后静默装好 → 重新拉起。
|
||||
|
||||
几个关键点:
|
||||
|
||||
- **配置为什么不会丢**:配置存在 `%ProgramData%\ykxmz\data\`,
|
||||
不在程序目录里。安装器升级只替换程序文件,不碰这个目录。
|
||||
- **更新源是用户自己的中转站**,不是作者的服务器,也不是任何
|
||||
写死的地址。中转没这功能(老版本服务端)时会明确提示
|
||||
"先手动升级一次服务端"。
|
||||
- **校验和不匹配就放弃这一轮**,保持现状(现状至少能用)。
|
||||
自更新最怕的就是装上一个坏程序、然后它还自己继续更新。
|
||||
|
||||
手表首页新增「电脑端版本」按钮,点开就能看到
|
||||
"电脑端 2.1.4 → 2.1.5",确认后才动手。
|
||||
|
||||
- **远程鼠标控制**:移动、左键、右键、中键、滚轮(含横向)。
|
||||
|
||||
这是"能直接操控电脑"的功能,所以参数校验写得很严:
|
||||
非法动作、非法按键、非数字坐标全都会被拒。
|
||||
|
||||
- **一键显示桌面**(等于 Win+D)。
|
||||
|
||||
- **Windows 7 兼容性校验关卡**:打包时和回归里都会检查
|
||||
(内嵌的是不是 3.8 运行库、**有没有**那个 Win7 上不存在的 DLL、
|
||||
exe 是不是 32 位),不过就拒绝出包。
|
||||
|
||||
- 技术说明:
|
||||
- 鼠标坐标用 SendInput 的 **0..65535 归一化**,而且**必须除以
|
||||
(屏幕宽-1)**。少了那个 -1,最右边和最下边几像素点不到。
|
||||
- `INPUT` 结构在 32/64 位下大小不同(28 / 40),代码按进程位数算。
|
||||
- 目标窗口若是管理员权限的(UAC 提升过的),普通权限的注入会被
|
||||
系统拦掉 —— 这是 Windows 的设计,不是 bug。被控端以管理员运行
|
||||
就没问题。
|
||||
|
||||
## 2.1.5
|
||||
|
||||
- **修「Windows 7 装完报 dll 缺失」** —— 学校电脑装不上。
|
||||
|
||||
这一条是用户报的:学校的电脑是 Windows 7,装完提示缺 DLL。
|
||||
|
||||
**根因不是漏打包,是 Python 版本越界**:
|
||||
|
||||
| Python | 支持的系统 |
|
||||
|---|---|
|
||||
| **3.8** | Windows 7 及以上 ← 最后一个支持 Win7 的版本 |
|
||||
| 3.9 ~ 3.13 | 只支持 Windows 8 及以上 |
|
||||
|
||||
Python 3.9 起用到了 `api-ms-win-core-path-l1-1-0.dll` 里的
|
||||
`PathCchCanonicalizeEx`,而这个 DLL **Windows 7 上根本不存在**,
|
||||
也没法用 api-set 转发(那套机制只在 Win8+ 生效)。
|
||||
|
||||
所以之前用 3.11 打出来的包,在 Win7 上必然报缺 DLL ——
|
||||
**那个 DLL 不是"忘了放进包",是"放进去也没用"。**
|
||||
|
||||
**改法:改用 Python 3.8 打包**,而且选 **32 位** ——
|
||||
32 位在 Win7 的 32 位和 64 位系统上**都能跑**,学校不用分两种发。
|
||||
|
||||
顺带加了两道自动关卡,以后不会再退化:
|
||||
- 打包脚本 `windows\build_exe.ps1` **先校验 Python 版本**,
|
||||
不是 3.8 直接报错退出(而不是默默打一个别人装不上的包)
|
||||
- `_build\check_win7_compat.py` 校验产物:
|
||||
内嵌是不是 3.8 的运行库、**有没有**那个要命的 DLL、exe 是不是 32 位。
|
||||
打包时和回归套件里都会跑,不过就拒绝出包
|
||||
|
||||
安装包体积也从 14.4 MB 降到 **9.0 MB**(32 位运行库小一圈)。
|
||||
|
||||
- **顺手修了安装包测试里一个藏了很久的坑**:
|
||||
`_build/test_installer_cycle.py` 里写死了 `ykxmz-setup-1.8.2.exe`,
|
||||
于是它一直在**装一个不存在的旧包** → 静默失败 → 报出一堆
|
||||
莫名其妙的失败("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||
改成自动找 `dist` 下版本号最新的包,以后不会再过期。
|
||||
|
||||
## 2.1.4
|
||||
|
||||
- **修一个"填了地址也连不上"的 bug**(手表端)。
|
||||
|
||||
中转地址栏里填 `yk.xxmxxs.top` 这种**没写 `http://` 开头**的地址时,
|
||||
点首页那个「检查服务器」会报:
|
||||
|
||||
```
|
||||
MalformedURLException: no protocol: yk.xxmxxs.top/v1/health
|
||||
```
|
||||
|
||||
原因是:补协议头的逻辑只写在连接那一条路上,
|
||||
「检查服务器」直接拿原始字符串拼 URL —— **同一个地址,
|
||||
连接能用,检查却崩**。
|
||||
|
||||
现在把补协议头收到**唯一一个入口**(读地址的地方),
|
||||
所有用到地址的地方都从那里取,以后再加新入口也不会漏。
|
||||
|
||||
规则和电脑端保持一致:
|
||||
- `IP` / `localhost` / `*.local` → 用 `http://`
|
||||
- 域名 → 用 `https://`
|
||||
|
||||
- **修正 README 里几处不准确的说法**,以及过时的版本号:
|
||||
- 被控端只装在**一台**电脑上(讲台那台或你自己那台),
|
||||
原文写成"看学生机"是错的 —— 它看不到别的机器
|
||||
- 课堂那几项功能如实说明:改的是**本机** hosts、结束的是**本机**进程,
|
||||
都是可逆的,下课后点「一键恢复全部」就还原
|
||||
- 成品文件名从 1.8.2 更新到当前版本,并且以后发版会自动跟着改
|
||||
|
||||
## 2.1.3
|
||||
|
||||
- **支持「自己搭的、没有证书的中转」**(新服务器就是这种)。
|
||||
|
||||
中转不再是必须挂在 Cloudflare 隧道后面了。现在可以直接跑在
|
||||
一台有公网 IP 的机器上,客户端填 `IP:端口` 就能连 —— 延迟、稳定性
|
||||
都比隧道好得多。为此改了两处客户端:
|
||||
|
||||
- **手表端**:填 `yk.xxmxxs.top:13245` 这种**没写协议头**的地址时,
|
||||
现在会按「IP / localhost / 本地域名 → 明文,域名 → 加密」来猜,
|
||||
**和电脑端规则一致**。原来一律补成 `https://`,而那台机器没有证书,
|
||||
结果是**永远连不上**,报的还是 SSL 错误,很容易让人白查半天证书。
|
||||
|
||||
- **手表端**:允许明文流量。Android 9 以上默认禁止,不加这一条的话
|
||||
请求会被系统直接掐掉,日志里只有一句含糊的
|
||||
「Cleartext HTTP traffic to ... not permitted」。
|
||||
|
||||
> 说明:中转上跑的内容(配对令牌、画面、文件)本来就是加密过的,
|
||||
> 明文只暴露地址和设备名。想要全程加密的话,把域名解析到这台
|
||||
> 服务器再签一张免费证书即可。
|
||||
|
||||
- 顺手清掉了电脑端一段函数内的重复代码(`return` 之后还有一份一样的
|
||||
拷贝,缩进仍在函数体内所以不报错,但读起来很迷惑)。
|
||||
|
||||
## 2.1.2
|
||||
|
||||
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
|
||||
|
||||
```
|
||||
| UDP Connectivity Cloudflare 边缘 FAIL 连不上 |
|
||||
| TCP Connectivity Cloudflare 边缘 FAIL 连不上或被挡住 |
|
||||
| Cloudflare API api.cloudflare.com:443 PASS 通的 |
|
||||
| ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2. |
|
||||
```
|
||||
|
||||
也就是说:**443 通,7844 不通**,而且是**一阵一阵的**
|
||||
(过几分钟自己又好了,然后再次断)。Cloudflare 隧道的边缘连接走 7844,
|
||||
这条线路对它是间歇性阻断 —— 这属于运营商那一侧,设备改不了。
|
||||
|
||||
**但可以让它自己去适应**:
|
||||
|
||||
- 两条隧道实例原来**各自写死一个协议**(一条只走 QUIC、一条只走 HTTP/2)。
|
||||
那一刻如果 7844 的 UDP 和 TCP 都不通,两条一起死,没有退路。
|
||||
现在都改成 **自动选择**,cloudflared 自己在 QUIC / HTTP/2 之间挑
|
||||
当前能通的那条。等于所有连接都有自适应能力,而不是
|
||||
"8 条赌 UDP、2 条赌 TCP"。
|
||||
|
||||
- 重试次数从 5 提到「一直重试」。原来失败 5 次就退出、靠系统重新拉起,
|
||||
每次退出到重启之间网站都是断的;而这条线路的抖动是分钟级的,
|
||||
5 次根本扛不过去(实测隧道每 3~10 分钟就被重启一次,公网跟着 530)。
|
||||
现在抖动期间连接自己恢复,整条隧道不退出。
|
||||
|
||||
**改完实测**:连接数 7+2(接近满配 10),公网连测 5 次全部 200。
|
||||
|
||||
- 顺手加了日志轮转(隧道日志已经涨到 3 MB,这台小机器硬盘不大)。
|
||||
|
||||
## 2.1.1
|
||||
|
||||
- **修一个会让网站整个挂掉的死锁**(这是「网站还是容易挂」的真因)。
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''手表端:输入配对码连上之后,先到一个「选择页」。
|
||||
|
||||
== 用户要求(原话)==
|
||||
|
||||
"安卓那个逻辑也要改一下,就是你输入配对码后,进入一个页面,
|
||||
页面有两个按钮,第一个按钮是远控,第二个按钮是静默更新被控端"
|
||||
|
||||
== 现状 ==
|
||||
|
||||
连接成功后直接 `showControlPanel()`(进控制页)。用户根本没机会
|
||||
先升级被控端 —— 而老版本的被控端可能缺功能、有 bug,
|
||||
"先更新再远控"其实是更合理的顺序。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
新增一个 `choicePanel`:
|
||||
|
||||
┌──────────────────────────┐
|
||||
│ 已连接「电脑名」 │
|
||||
│ 电脑端版本 2.1.5 │ <- 一眼看到要不要更新
|
||||
│ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ 远控 │ │ <- 第一个按钮
|
||||
│ └────────────────────┘ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ 静默更新被控端 │ │ <- 第二个按钮
|
||||
│ └────────────────────┘ │
|
||||
└──────────────────────────┘
|
||||
|
||||
**按钮顺序按用户说的来**:远控在前(主操作),更新在后。
|
||||
|
||||
之后:
|
||||
|
||||
* 连接成功(`showControlPanel()` 那个调用点)改成 `showChoicePanel()`
|
||||
* **自动切中转恢复**那个调用点**不改** —— 用户本来就在远控页,
|
||||
中途弹一个选择页会打断他
|
||||
* 「静默更新被控端」复用已经做好的 `checkPcUpdate()`
|
||||
(查版本 -> 确认框 -> 触发更新)
|
||||
* 版本号显示在这一页上:用户说"手表端连接那个电脑端也可以查看
|
||||
电脑端目前所处的版本",这里是最自然的展示位置
|
||||
|
||||
== 一些细节 ==
|
||||
|
||||
* 顶部快捷栏在这一页照样在(用户要求"不管干什么上面都有一栏")
|
||||
* 面板要加进 `showOnly` 的数组,否则它会永远压在最上面
|
||||
* 也要 `applyPanelTopMargin`,否则顶部被快捷栏盖住
|
||||
|
||||
* **版本号是异步查的**:连上后立刻查一次;查回来之前显示"正在查…"。
|
||||
不查的话用户不知道要不要点更新。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||
|
||||
# ---- 1. 字段 ----
|
||||
F_ANCHOR = " private FrameLayout mousePanel;"
|
||||
F_NEW = ''' private FrameLayout mousePanel;
|
||||
|
||||
/**
|
||||
* 连上之后先给用户看的那一页:远控 / 静默更新被控端。
|
||||
*
|
||||
* <p>用户要求:"输入配对码后,进入一个页面,页面有两个按钮,
|
||||
* 第一个按钮是远控,第二个按钮是静默更新被控端"。
|
||||
*
|
||||
* <p>为什么不让它直接进控制页:老版本的被控端可能缺功能或者有 bug,
|
||||
* "先更新再远控"是更合理的顺序 —— 但也不能强制,所以给用户选。
|
||||
*/
|
||||
private FrameLayout choicePanel;
|
||||
/** 选择页上显示"电脑端版本 X.Y.Z"那一行。 */
|
||||
private TextView choiceVersionText;
|
||||
/** 选择页上显示"已连接 XXX"。 */
|
||||
private TextView choiceWhoText;'''
|
||||
|
||||
# ---- 2. 建面板的方法,插在 showOnly 之前 ----
|
||||
M_ANCHOR = " private void showOnly(View keep) {"
|
||||
|
||||
M_NEW = ''' // ==================================================================
|
||||
// 连接后的选择页(远控 / 静默更新)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* 建选择页。
|
||||
*
|
||||
* <p>布局从上到下:连接信息 -> 版本 -> [远控] -> [静默更新被控端]。
|
||||
* 按钮顺序就是用户说的顺序(远控在前)。
|
||||
*/
|
||||
private void buildChoicePanel() {
|
||||
choicePanel = new FrameLayout(this);
|
||||
choicePanel.setBackgroundColor(C_BG);
|
||||
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setPadding(dp(14), dp(20), dp(14), dp(14));
|
||||
box.setGravity(android.view.Gravity.CENTER_HORIZONTAL);
|
||||
choicePanel.addView(box, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
choiceWhoText = new TextView(this);
|
||||
choiceWhoText.setText("已连接");
|
||||
choiceWhoText.setTextColor(C_FG);
|
||||
choiceWhoText.setTextSize(14f);
|
||||
choiceWhoText.setGravity(android.view.Gravity.CENTER);
|
||||
box.addView(choiceWhoText);
|
||||
|
||||
choiceVersionText = new TextView(this);
|
||||
choiceVersionText.setText("电脑端版本:正在查…");
|
||||
choiceVersionText.setTextColor(C_DIM);
|
||||
choiceVersionText.setTextSize(12f);
|
||||
choiceVersionText.setGravity(android.view.Gravity.CENTER);
|
||||
choiceVersionText.setPadding(0, dp(4), 0, dp(10));
|
||||
box.addView(choiceVersionText);
|
||||
|
||||
// 第一个按钮:远控
|
||||
Button ctrl = bigButton("远控", C_OK, 17);
|
||||
LinearLayout.LayoutParams ctrlLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
|
||||
ctrlLp.setMargins(0, 0, 0, dp(10));
|
||||
ctrl.setLayoutParams(ctrlLp);
|
||||
ctrl.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
showControlPanel();
|
||||
}
|
||||
});
|
||||
box.addView(ctrl);
|
||||
|
||||
// 第二个按钮:静默更新被控端
|
||||
Button upd = bigButton("静默更新被控端", C_ACCENT, 15);
|
||||
LinearLayout.LayoutParams updLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
|
||||
upd.setLayoutParams(updLp);
|
||||
upd.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
// 复用已经做好的那条链路:查版本 -> 确认 -> 触发更新
|
||||
checkPcUpdate();
|
||||
}
|
||||
});
|
||||
box.addView(upd);
|
||||
|
||||
TextView tip = new TextView(this);
|
||||
tip.setText("更新时电脑会自动下载并静默安装,"
|
||||
+ "不会弹任何窗口,设置和配对码都不变。");
|
||||
tip.setTextColor(C_DIM);
|
||||
tip.setTextSize(10f);
|
||||
tip.setGravity(android.view.Gravity.CENTER);
|
||||
tip.setPadding(0, dp(10), 0, 0);
|
||||
box.addView(tip);
|
||||
}
|
||||
|
||||
/** 显示选择页,并顺手查一下电脑端版本。 */
|
||||
private void showChoicePanel() {
|
||||
if (choicePanel == null) {
|
||||
buildChoicePanel();
|
||||
}
|
||||
if (choiceWhoText != null) {
|
||||
String who = lastDeviceName;
|
||||
choiceWhoText.setText(who == null || who.isEmpty()
|
||||
? "已连接" : "已连接「" + who + "」");
|
||||
}
|
||||
if (choiceVersionText != null) {
|
||||
choiceVersionText.setText("电脑端版本:正在查…");
|
||||
}
|
||||
showOnly(choicePanel);
|
||||
queryPcVersionForChoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查电脑端版本填到选择页上。
|
||||
*
|
||||
* <p>单独写一个(不直接用 checkPcUpdate)是因为那个会弹确认框 ——
|
||||
* 这里只是"让你看一眼版本",不该弹东西。
|
||||
*/
|
||||
private void queryPcVersionForChoice() {
|
||||
io.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Map<String, Object> reply;
|
||||
try {
|
||||
reply = client.call(Json.obj("cmd", "update",
|
||||
"action", "check"), 25000);
|
||||
} catch (final Throwable error) {
|
||||
reply = null;
|
||||
}
|
||||
final String text;
|
||||
if (reply != null && Boolean.TRUE.equals(reply.get("ok"))) {
|
||||
String cur = String.valueOf(reply.get("current"));
|
||||
String latest = String.valueOf(reply.get("latest"));
|
||||
text = Boolean.TRUE.equals(reply.get("needUpdate"))
|
||||
? "电脑端 " + cur + " → 可更新到 " + latest
|
||||
: "电脑端 " + cur + "(已是最新)";
|
||||
} else {
|
||||
text = "电脑端版本:查不到(可以点下面的更新按钮再试)";
|
||||
}
|
||||
ui.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (choiceVersionText != null) {
|
||||
choiceVersionText.setText(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---- 3. showOnly 数组 ----
|
||||
O_SO = ''' View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
|
||||
settingsPanel, mousePanel};'''
|
||||
N_SO = ''' View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
|
||||
settingsPanel, mousePanel, choicePanel};'''
|
||||
|
||||
# ---- 4. onCreate:建 + 挂 + 让位 ----
|
||||
O_OC = ''' loadQuickSlots();
|
||||
buildMousePanel(); // 只建,先不显示(showOnly 会切)
|
||||
buildQuickBar();'''
|
||||
N_OC = ''' loadQuickSlots();
|
||||
buildMousePanel(); // 只建,先不显示(showOnly 会切)
|
||||
buildChoicePanel(); // 同上:连上之后才显示
|
||||
buildQuickBar();'''
|
||||
|
||||
O_OC2 = ''' applyPanelTopMargin(mousePanel);'''
|
||||
N_OC2 = ''' applyPanelTopMargin(mousePanel);
|
||||
applyPanelTopMargin(choicePanel);'''
|
||||
|
||||
O_OC3 = ''' root.addView(mousePanel, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
mousePanel.setVisibility(View.GONE);'''
|
||||
N_OC3 = ''' root.addView(mousePanel, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
mousePanel.setVisibility(View.GONE);
|
||||
root.addView(choicePanel, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
choicePanel.setVisibility(View.GONE);'''
|
||||
|
||||
# ---- 5. 连接成功后改成进选择页 ----
|
||||
O_CONN = ''' showControlPanel();
|
||||
appendLog("已连接到 " + host);
|
||||
toast("连接成功");'''
|
||||
N_CONN = ''' // 用户要求:输入配对码连上之后先到选择页
|
||||
// (远控 / 静默更新被控端),不直接进控制页。
|
||||
//
|
||||
// ⚠ 只有**这个**调用点改。下面"自动切到中转恢复"
|
||||
// 那个 showControlPanel() 不改 —— 用户本来就在
|
||||
// 远控页,中途弹选择页会打断他。
|
||||
showChoicePanel();
|
||||
appendLog("已连接到 " + host);
|
||||
toast("连接成功");'''
|
||||
|
||||
PAIRS = [
|
||||
("字段", F_ANCHOR, F_NEW),
|
||||
("建面板方法", M_ANCHOR, M_NEW + M_ANCHOR),
|
||||
("showOnly 数组", O_SO, N_SO),
|
||||
("onCreate 建面板", O_OC, N_OC),
|
||||
("onCreate 让位", O_OC2, N_OC2),
|
||||
("onCreate 挂面板", O_OC3, N_OC3),
|
||||
("连接成功后进选择页", O_CONN, N_CONN),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 手表端:连上后先到「远控 / 静默更新」选择页")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "buildChoicePanel" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
missing = [(n, o.strip().splitlines()[0][:60]) for n, o, _w in PAIRS
|
||||
if o not in t]
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到:")
|
||||
for n, s in missing:
|
||||
print(" %s: %s" % (n, s))
|
||||
return 1
|
||||
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
|
||||
|
||||
for _n, old, new in PAIRS:
|
||||
t = t.replace(old, new, 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 choicePanel + buildChoicePanel + showChoicePanel")
|
||||
print(" ✓ 连接成功后改成进选择页(自动切中转那个没动)")
|
||||
|
||||
# 复核:确认"自动切中转"那个调用点没被误改
|
||||
back = MAIN.read_text(encoding="utf-8")
|
||||
n_choice = back.count("showChoicePanel();")
|
||||
n_ctrl = back.count("showControlPanel();")
|
||||
print()
|
||||
print(" 复核:showChoicePanel 调用 %d 处(应 >=2:定义内 + 连接成功处)"
|
||||
% n_choice)
|
||||
print(" showControlPanel 调用 %d 处(自动切中转那个应该还在)"
|
||||
% n_ctrl)
|
||||
if "已切到中转服务器,连接恢复" in back:
|
||||
i = back.find("已切到中转服务器,连接恢复")
|
||||
seg = back[i:i + 200]
|
||||
ok = "showControlPanel();" in seg
|
||||
print(" [%s] 自动切中转恢复后仍是 showControlPanel" %
|
||||
("PASS" if ok else "FAIL"))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''弹文字窗口「位置 + 大小」可配置,屏幕共享提示条默认关掉。
|
||||
|
||||
== 用户的要求 ==
|
||||
|
||||
"那个弹文字窗口,不要(现在)在电脑的右下角,(要能放在)屏幕正中间,
|
||||
还可以自定义窗口大小"
|
||||
|
||||
"现在不是要会弹一个屏幕已被共享吗?以后不需要弹,啥窗户都没有,
|
||||
就可以像 uu 远程一样"
|
||||
|
||||
== 现状(查清楚了)==
|
||||
|
||||
headless.py 里有三个窗口定位点,**全在右下角**:
|
||||
|
||||
第 190 行 notify() 里的通知窗口 ┐ 就是用户说的"弹文字窗口"
|
||||
第 231 行 notify()(代码重复了一份)┘ 6 秒自动关
|
||||
第 451 行 _show_notice() 屏幕共享提示条
|
||||
|
||||
三处都写死了 `+{screen_w - width - 24}+{screen_h - height - 72}`。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
1. **加一个统一的定位函数 `_notice_geometry(width, height)`**
|
||||
支持 6 种位置:右下 / 右上 / 左上 / 左下 / 正中间 / 自定义坐标。
|
||||
三处都改调它 —— 这样以后再调位置只用改一个地方。
|
||||
|
||||
2. **加设置项**(config.py):
|
||||
notice_position 默认 bottom-right(保持原样,不改变老用户习惯)
|
||||
notice_x / notice_y position=custom 时用
|
||||
notice_width 0 = 按内容自适应(原来就是这样)
|
||||
notice_font_size 11 = 原来的字号
|
||||
show_screen_notice **默认 False** —— 屏幕被看时不再弹提示条
|
||||
|
||||
3. **`set_screen_sharing` 尊重 `show_screen_notice`**
|
||||
关着就什么都不做(连窗口都不建)。
|
||||
|
||||
== 关于"去掉屏幕提示条"这件事 ==
|
||||
|
||||
原来那条是**故意加**的:有人在看屏幕时,电脑前面的人应该知道。
|
||||
用户现在要求去掉,理由是"像 UU 远程一样不要任何窗口"。
|
||||
|
||||
这是**用户自己的电脑、自己的教室**,属于正当的远程管理需求
|
||||
(极域电子教室、联想网络控制工具这类校园软件也都是无提示的)。
|
||||
但我在 README 和设置界面里都保留了这个选项的描述 ——
|
||||
**能力还在,只是默认关**,这样万一以后有争议,能证明这软件有可见开关。
|
||||
|
||||
== 顺带发现的一个问题 ==
|
||||
|
||||
`notify()` 里那段建窗口的代码**被复制了两份**(第 166-201 和 206-250),
|
||||
两份几乎一模一样。这次不动它(不属于本次需求,动它风险大),
|
||||
但下划线标注一下,以后重构时知道。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG = ROOT / "windows" / "lanremote" / "config.py"
|
||||
HEADLESS = ROOT / "windows" / "lanremote" / "headless.py"
|
||||
|
||||
CONFIG_ADD = ''' "screen_fps": 12,
|
||||
# ---- 弹窗外观(用户要求"位置和大小都能自定义")----
|
||||
#
|
||||
# 位置:bottom-right / top-right / top-left / bottom-left / center / custom
|
||||
# 默认保持右下角 —— 不改变老用户的使用习惯。
|
||||
"notice_position": "bottom-right",
|
||||
"notice_x": 0, # position=custom 时的横坐标(像素)
|
||||
"notice_y": 0, # position=custom 时的纵坐标(像素)
|
||||
"notice_width": 0, # 0 = 按内容自适应(240 起)
|
||||
"notice_font_size": 11, # 标题字号;正文自动小 1 号
|
||||
# 屏幕被观看时是否在本机弹提示条。
|
||||
#
|
||||
# ⚠ 原来这个提示条是**强制显示**的("有人在看你的屏幕,
|
||||
# 电脑前面的人必须知道")。用户要求去掉,理由是"像 UU 远程一样
|
||||
# 不要任何窗口"。
|
||||
#
|
||||
# 这是用户自己的电脑,属于正当的远程管理需求
|
||||
# (极域电子教室这类校园软件也都是无提示的)。
|
||||
# 所以能力保留、**默认关闭** —— 需要的话在设置里打开,
|
||||
# 这样这软件始终有一个"可见性开关"可查。
|
||||
"show_screen_notice": False,
|
||||
}'''
|
||||
|
||||
GEOMETRY_HELPER = '''
|
||||
def _notice_geometry(self, width: int, height: int) -> str:
|
||||
"""算出弹窗该放哪。返回 Tk 的 geometry 字符串 `+x+y`。
|
||||
|
||||
用户要求"位置和大小都能自定义",所以这里支持 6 种位置。
|
||||
⚠ 三处弹窗(通知 ×2、屏幕提示 ×1)都调这个函数 ——
|
||||
以前三处各写死一遍右下角,改一次要改三个地方。
|
||||
"""
|
||||
screen_w, screen_h = self.screen_size()
|
||||
pos = str(self.store.get("notice_position") or "bottom-right")
|
||||
pad = 24 # 离屏幕边缘留一点空
|
||||
pad_bottom = 72 # 底下多留些,避开任务栏
|
||||
|
||||
if pos == "custom":
|
||||
try:
|
||||
x = int(self.store.get("notice_x") or 0)
|
||||
y = int(self.store.get("notice_y") or 0)
|
||||
except (TypeError, ValueError):
|
||||
x = y = 0
|
||||
# 夹到屏幕内,免得设了个离谱的值窗口就找不到了
|
||||
x = max(0, min(screen_w - width, x))
|
||||
y = max(0, min(screen_h - height, y))
|
||||
return f"+{x}+{y}"
|
||||
|
||||
if pos == "center":
|
||||
return (f"+{max(0, (screen_w - width) // 2)}"
|
||||
f"+{max(0, (screen_h - height) // 2)}")
|
||||
|
||||
# 四个角
|
||||
left = pos in ("top-left", "bottom-left")
|
||||
top = pos in ("top-left", "top-right")
|
||||
x = pad if left else screen_w - width - pad
|
||||
y = pad if top else screen_h - height - pad_bottom
|
||||
return f"+{max(0, x)}+{max(0, y)}"
|
||||
|
||||
'''
|
||||
|
||||
SCREEN_SHARING_OLD = ''' def set_screen_sharing(self, on: bool) -> None:
|
||||
"""屏幕共享提示。
|
||||
|
||||
这是**故意保留**的:有人在看你的屏幕时,屏幕右下角会出现一条提示。
|
||||
静默模式只是不显示主控制窗口,不是"让被看的人不知道"。
|
||||
"""
|
||||
try:
|
||||
if on:
|
||||
self._show_notice("屏幕正在被共享")
|
||||
else:
|
||||
self._hide_notice()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"共享提示失败: {exc}")'''
|
||||
|
||||
SCREEN_SHARING_NEW = ''' def set_screen_sharing(self, on: bool) -> None:
|
||||
"""屏幕共享提示条 —— **默认不显示**。
|
||||
|
||||
用户要求:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
|
||||
|
||||
所以这里先看设置项 `show_screen_notice`:
|
||||
* 关着(默认)-> 什么都不做,连窗口都不建
|
||||
* 开着 -> 行为跟以前一样,右下角(或用户设的位置)一条红条
|
||||
|
||||
保留这个开关而不是删掉代码,是因为这软件应该始终有一个
|
||||
"让本机用户看得见正在被控制"的选项可查 ——
|
||||
需要的时候在设置里打开就行。
|
||||
"""
|
||||
try:
|
||||
if not self.store.get("show_screen_notice"):
|
||||
# 设置关着:确保之前残留的提示条也收掉(比如用户
|
||||
# 正在被看的时候把开关关了),然后什么都不做
|
||||
if not on:
|
||||
self._hide_notice()
|
||||
return
|
||||
if on:
|
||||
self._show_notice("屏幕正在被共享")
|
||||
else:
|
||||
self._hide_notice()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"共享提示失败: {exc}")'''
|
||||
|
||||
|
||||
def patch_file(path: Path, pairs: list, what: str) -> bool:
|
||||
"""先校验所有锚点,再一次性替换。"""
|
||||
raw = path.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
missing = [name for name, old, _new in pairs if old not in text]
|
||||
if missing:
|
||||
print(" ✗ %s 这些锚点找不到:%s" % (what, ", ".join(missing)))
|
||||
return False
|
||||
for name, old, new in pairs:
|
||||
text = text.replace(old, new, 1)
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ %s(%d 处)" % (what, len(pairs)))
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 弹窗位置/大小可配置 + 屏幕提示条默认关")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
# ---- config.py ----
|
||||
cfg_raw = CONFIG.read_bytes()
|
||||
cfg_bom = cfg_raw.startswith(b"\xef\xbb\xbf")
|
||||
cfg = cfg_raw.decode("utf-8-sig")
|
||||
if "notice_position" in cfg:
|
||||
print(" config.py 已经加过了")
|
||||
else:
|
||||
anchor = ' "screen_fps": 12,\n}'
|
||||
if anchor not in cfg:
|
||||
print(" ✗ config.py 找不到 screen_fps 结尾锚点")
|
||||
return 1
|
||||
cfg = cfg.replace(anchor, CONFIG_ADD, 1)
|
||||
CONFIG.write_bytes((b"\xef\xbb\xbf" if cfg_bom else b"")
|
||||
+ cfg.encode("utf-8"))
|
||||
print(" ✓ config.py 加了 6 个设置项")
|
||||
|
||||
# ---- headless.py ----
|
||||
html_raw = HEADLESS.read_bytes()
|
||||
print()
|
||||
ok = patch_file(HEADLESS, [
|
||||
("_notice_geometry 方法", " def screen_size(self)",
|
||||
GEOMETRY_HELPER.lstrip("\n") + " def screen_size(self)"),
|
||||
("set_screen_sharing 尊重设置项", SCREEN_SHARING_OLD, SCREEN_SHARING_NEW),
|
||||
], "headless.py 主体")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
# ---- 三处写死的 geometry 改成调 helper ----
|
||||
raw = HEADLESS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
old_line = ('win.geometry(f"+{screen_w - width - 24}'
|
||||
'+{screen_h - height - 72}")')
|
||||
n = text.count(old_line)
|
||||
if n == 0:
|
||||
print(" ⚠ 没找到写死的 geometry 行(可能已改)")
|
||||
else:
|
||||
text = text.replace(old_line, "win.geometry(self._notice_geometry(width, height))")
|
||||
HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 替换了 %d 处写死的右下角定位" % n)
|
||||
|
||||
# ---- 字号可配置 ----
|
||||
raw = HEADLESS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
n2 = 0
|
||||
for old_font, new_font in [
|
||||
('font=("Microsoft YaHei UI", 11, "bold")',
|
||||
'font=("Microsoft YaHei UI", self._notice_font(), "bold")'),
|
||||
('font=("Microsoft YaHei UI", 10)',
|
||||
'font=("Microsoft YaHei UI", self._notice_font() - 1)'),
|
||||
]:
|
||||
c = text.count(old_font)
|
||||
if c:
|
||||
text = text.replace(old_font, new_font)
|
||||
n2 += c
|
||||
if n2:
|
||||
HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 替换了 %d 处写死的字号" % n2)
|
||||
|
||||
# ---- 加 _notice_font 方法 ----
|
||||
raw = HEADLESS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if "_notice_font" in text and "def _notice_font" not in text:
|
||||
helper = '''
|
||||
def _notice_font(self) -> int:
|
||||
"""弹窗字号。用户要求"大小可以自定义",所以从设置里读。
|
||||
|
||||
夹在 8~32 之间:太小看不清,太大一个通知就把屏幕占满了。
|
||||
"""
|
||||
try:
|
||||
size = int(self.store.get("notice_font_size") or 11)
|
||||
except (TypeError, ValueError):
|
||||
size = 11
|
||||
return max(8, min(32, size))
|
||||
|
||||
'''
|
||||
text = text.replace(" def _notice_geometry(self, width: int, height: int) -> str:",
|
||||
helper.lstrip("\n") + " def _notice_geometry(self, width: int, height: int) -> str:", 1)
|
||||
HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 加了 _notice_font 方法")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把快捷栏那一关加进 verify_all.ps1。
|
||||
|
||||
== 为什么用文件而不是 python -c ==
|
||||
|
||||
要写进 .ps1 的内容里有 `$stageOut`、`$(...)`。
|
||||
用 `python -c "..."` 穿过 PowerShell 时,**PowerShell 会先解析一遍**,
|
||||
把 `$stageOut` 变成 `\` —— 上次 3a12 就是这么坏的
|
||||
(插进去变成 `\ = & python (Join-Path \ "..."`,语法直接错)。
|
||||
|
||||
凡是内容里带 `$` 的,一律写成 .py 文件再跑。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
ANCHOR = "# ---------------------------------------------------------------- 3a5"
|
||||
|
||||
STAGE = '''# ---------------------------------------------------------------- 3a13
|
||||
Banner "3a13/9 手表快捷栏 + 允许新设备连接"
|
||||
# 用户要求:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||
# 自定义,最多可以加 4 个,其中有一个按钮不能改,
|
||||
# 功能就是一键返回桌面"。
|
||||
#
|
||||
# 四个地方最容易漏,漏了都不会报错、只会让人困惑:
|
||||
# 1. buildQuickBar 没挂进 onCreate -> 栏根本不显示
|
||||
# 2. 没加进 showOnly 的数组 -> 面板永远压在上面
|
||||
# 3. 第 4 格做成了可配置 -> 用户找半天"显示桌面"没了
|
||||
# 4. 设置页没有入口 -> 说是能自定义其实改不了
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_quickbar.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "手表快捷栏" ($LASTEXITCODE -eq 0)
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "手表快捷栏" in t and "test_quickbar" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
if ANCHOR not in t:
|
||||
print(" ✗ 找不到锚点")
|
||||
return 1
|
||||
|
||||
t = t.replace(ANCHOR, STAGE + ANCHOR, 1)
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 3a13")
|
||||
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_quickbar.py")',
|
||||
'Record "手表快捷栏"'):
|
||||
ok = token in back
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56]))
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
# 顺手把那些 "3aN/7" 的分母改成 9(关卡总数变了)
|
||||
import re
|
||||
n = len(re.findall(r'Banner "3a\d+/7 ', back))
|
||||
back = re.sub(r'(Banner "3a\d+)/7 ', r'\1/9 ', back)
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + back.encode("utf-8"))
|
||||
print(" ✓ 把 %d 个 3aN/7 的分母改成 /9" % n)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,217 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''在一台服务器上加一个新网站(nginx 虚拟主机 + 证书)。
|
||||
|
||||
== 为什么需要这个 ==
|
||||
|
||||
一台服务器上跑多个网站是常态。做法是:**一个 nginx 占 80/443,
|
||||
每个域名一个 server 块**,nginx 按 Host 头分流到各自的本地端口。
|
||||
|
||||
手写这套配置每次都要复制粘贴改半天,还容易漏:
|
||||
* 忘了签证书 -> 浏览器报不安全
|
||||
* 后端端口写错 -> 502
|
||||
* 忘了把后端绑到 127.0.0.1 -> 服务直接暴露在公网
|
||||
|
||||
所以做成一键的。
|
||||
|
||||
== 用法 ==
|
||||
|
||||
python _build/add_site.py 域名 后端地址 [--php] [--root 目录]
|
||||
|
||||
例:
|
||||
# 反代到一个本地服务
|
||||
python _build/add_site.py blog.xxmxxs.top 127.0.0.1:9000
|
||||
|
||||
# WordPress 那种 PHP 站(走 php-fpm 的 unix socket 或端口)
|
||||
python _build/add_site.py xxmxxs.top 127.0.0.1:9000 --php --root /opt/1panel/www/sites/xxmxxs.top/index
|
||||
|
||||
== 设计上的几个注意 ==
|
||||
|
||||
1. **默认只回源 127.0.0.1**
|
||||
后端服务必须只绑本地。写 0.0.0.0 的话,别人直接打 IP:端口
|
||||
就绕过 nginx 和 Cloudflare 了 —— 那正是用户不想要的。
|
||||
|
||||
2. **证书用 certbot 的 nginx 插件签**
|
||||
它自己改配置、自己加 443。前提是**这个域名的 A 记录已经指过来**
|
||||
(而且最好是橙色云朵,这样 certbot 的 HTTP-01 挑战能经 Cloudflare 回源)。
|
||||
|
||||
3. **不动别的站点**
|
||||
只新建 /etc/nginx/sites-available/<域名>,再链到 sites-enabled。
|
||||
已有的站点(比如遥控中转 ykxmz)一个字都不碰。
|
||||
|
||||
4. **默认 server 的坑**
|
||||
如果有人用 IP 直接访问,nginx 会用**第一个** server 块回应。
|
||||
所以配置文件开头有个显式的 default server 把它们挡掉 ——
|
||||
这样 IP:port 扫不到任何真实站点。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
TEMPLATE = """# {domain} —— 由 _build/add_site.py 生成
|
||||
#
|
||||
# 后端: {backend}
|
||||
# 说明: nginx 按 server_name 分流;后端只应该监听 127.0.0.1,
|
||||
# 这样外面只能通过域名(也就是经过 nginx + Cloudflare)访问。
|
||||
|
||||
server {{
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name {domain};
|
||||
|
||||
access_log /var/log/nginx/{safe}_access.log;
|
||||
error_log /var/log/nginx/{safe}_error.log;
|
||||
|
||||
{body}
|
||||
server_tokens off;
|
||||
client_max_body_size {max_body};
|
||||
}}
|
||||
"""
|
||||
|
||||
PROXY_BODY = """ location / {{
|
||||
proxy_pass http://{backend};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
}}
|
||||
"""
|
||||
|
||||
PHP_BODY = """ root {root};
|
||||
index index.php index.html;
|
||||
|
||||
location / {{
|
||||
try_files $uri $uri/ /index.php?$args;
|
||||
}}
|
||||
|
||||
location ~ \\.php$ {{
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass {backend};
|
||||
fastcgi_read_timeout 300s;
|
||||
}}
|
||||
|
||||
location ~ /\\.ht {{
|
||||
deny all;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="加一个网站")
|
||||
ap.add_argument("domain", help="域名,例如 blog.xxmxxs.top")
|
||||
ap.add_argument("backend", help="后端地址,例如 127.0.0.1:9000")
|
||||
ap.add_argument("--php", action="store_true",
|
||||
help="PHP 站(用 fastcgi_pass 而不是 proxy_pass)")
|
||||
ap.add_argument("--root", default="", help="PHP 站的根目录")
|
||||
ap.add_argument("--max-body", default="64m", help="上传大小上限,默认 64m")
|
||||
ap.add_argument("--no-cert", action="store_true",
|
||||
help="只配 80,先不签证书")
|
||||
args = ap.parse_args()
|
||||
|
||||
domain = args.domain.strip()
|
||||
backend = args.backend.strip()
|
||||
safe = domain.replace(".", "_").replace("-", "_")
|
||||
|
||||
# ⚠ 后端必须是本地地址。写 0.0.0.0 或公网地址的话,
|
||||
# 等于把后端服务直接暴露出去,nginx 和 Cloudflare 都白搭。
|
||||
if not (backend.startswith("127.0.0.1:") or backend.startswith("localhost:")
|
||||
or backend.startswith("unix:")):
|
||||
print(" ⚠ 后端 %r 不是本地地址。" % backend)
|
||||
print(" 后端必须只监听 127.0.0.1 —— 否则别人直接打 "
|
||||
"IP:端口就绕过了 nginx 和 Cloudflare。")
|
||||
print(" 要继续的话请显式确认(现在中止)。")
|
||||
return 1
|
||||
|
||||
if args.php and not args.root:
|
||||
print(" ✗ --php 需要同时给 --root(网站文件目录)")
|
||||
return 1
|
||||
|
||||
body = (PHP_BODY.format(root=args.root, backend=backend)
|
||||
if args.php else PROXY_BODY.format(backend=backend))
|
||||
conf = TEMPLATE.format(domain=domain, backend=backend, safe=safe,
|
||||
body=body, max_body=args.max_body)
|
||||
|
||||
print("=" * 58)
|
||||
print(" 加站点 %s -> %s" % (domain, backend))
|
||||
print("=" * 58)
|
||||
|
||||
print()
|
||||
print(" ── 1. 写配置 ──")
|
||||
import tempfile
|
||||
tmp = Path(tempfile.gettempdir()) / ("nginx-%s.conf" % safe)
|
||||
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/%s.conf" % safe)
|
||||
out = run(
|
||||
"sudo mv /tmp/{s}.conf /etc/nginx/sites-available/{d} && "
|
||||
"sudo ln -sf /etc/nginx/sites-available/{d} "
|
||||
"/etc/nginx/sites-enabled/{d} && "
|
||||
"sudo nginx -t 2>&1 | tail -2".format(s=safe, d=domain),
|
||||
timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
if "successful" not in out[1]:
|
||||
print(" ✗ nginx 配置检查没过,撤回")
|
||||
run("sudo rm -f /etc/nginx/sites-enabled/%s" % domain,
|
||||
timeout=60, quiet=True)
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(" ── 2. 重载 nginx ──")
|
||||
out = run("sudo systemctl reload nginx && sleep 1 && "
|
||||
"systemctl is-active nginx", timeout=120, quiet=True)
|
||||
print(" nginx: " + out[1].strip())
|
||||
|
||||
if not args.no_cert:
|
||||
print()
|
||||
print(" ── 3. 签 Let's Encrypt 证书(顺带加上 443 和跳转)──")
|
||||
out = run(
|
||||
"sudo certbot --nginx -d {d} --non-interactive --agree-tos "
|
||||
"--register-unsafely-without-email --redirect "
|
||||
"--keep-until-expiring 2>&1 | tail -8".format(d=domain),
|
||||
timeout=600, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 复核 ──")
|
||||
# ⚠ 这里**不能用 .format()** —— 远端命令里有 curl 的 `%{http_code}`,
|
||||
# 而 .format() 会把 `{http_code}` 当占位符,抛
|
||||
# "KeyError/IndexError: http_code"。
|
||||
# 和之前用 % 格式化踩的是**同一类坑**(内容里有格式符)。
|
||||
# 凡是把域名等变量拼进含 `{...}` 的远端命令,一律用 replace。
|
||||
probe = (
|
||||
'printf " %-34s %s\\n" "本机带 Host 头" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 10 '
|
||||
'-H \'Host: __D__\' http://127.0.0.1:80/)"; '
|
||||
'printf " %-34s %s\\n" "本机 443 带 SNI" '
|
||||
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 10 '
|
||||
'--resolve __D__:443:127.0.0.1 https://__D__/)"; '
|
||||
'printf " %-34s %s\\n" "公网 443" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 20 '
|
||||
'https://__D__/)"'
|
||||
).replace("__D__", domain)
|
||||
out = run(probe, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" 已加好。注意:")
|
||||
print(" * 这个域名的 DNS 要指向这台服务器(Cloudflare 橙色云朵)")
|
||||
print(" * 后端 %s 必须只监听 127.0.0.1" % backend)
|
||||
print(" * 不要动 /etc/nginx/sites-available/ykxmz(那是遥控中转)",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''第一步:给服务器加 1G swap,并调好内存参数。
|
||||
|
||||
== 为什么必须先做这个 ==
|
||||
|
||||
机器只有 **1936MB 内存,而且一个 swap 都没有**。现在用了 566MB,
|
||||
看着还有富余 —— 但 Halo 是 **Java 应用**,JVM 默认会按物理内存的
|
||||
1/4 去要堆(1936/4 ≈ 470MB),加上元空间、线程栈、直接内存,
|
||||
很容易冲到 700MB+。
|
||||
|
||||
**没有 swap 的时候,内存一紧张就是 OOM Killer 直接杀进程** ——
|
||||
表现是"博客隔一阵就自己挂了",而且 systemd 里只留一句
|
||||
`Killed process (java)`,很难查。
|
||||
|
||||
有 1G swap 就多了一层缓冲:平时不用它(性能不受影响),
|
||||
突发时才借用,扛过去就没事了。
|
||||
|
||||
== 参数怎么选 ==
|
||||
|
||||
* **大小 1G**(用户指定)
|
||||
* **swappiness=10** —— 默认是 60,意思是"内存用到 60% 就开始往
|
||||
swap 里挪"。对服务器来说这太激进了,会把常用的页换出去导致变卡。
|
||||
10 表示"只有真要撑不住时才用 swap",正合适。
|
||||
* **vfs_cache_pressure=50** —— 默认 100,让内核更愿意保留
|
||||
dentry/inode 缓存(文件操作多的时候有用)。
|
||||
* **写进 /etc/fstab** —— 不然重启就没了。
|
||||
|
||||
== 一个坑 ==
|
||||
|
||||
`swapon` 之后再 `free -m` 才看得到。而且**如果 fstab 写错,
|
||||
系统可能起不来**(fstab 里有不存在的设备会进 emergency mode)。
|
||||
所以写完要用 `mount -a` 试一遍,能过才算数。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
set -e
|
||||
echo "==== 加之前 ===="
|
||||
free -m | sed 's/^/ /'
|
||||
echo
|
||||
|
||||
if swapon --show 2>/dev/null | grep -q .; then
|
||||
echo " 已经有 swap 了,跳过创建:"
|
||||
swapon --show | sed 's/^/ /'
|
||||
else
|
||||
echo "==== 创建 1G swap 文件 ===="
|
||||
# fallocate 比 dd 快得多,而且是稀疏分配 —— 但**有些文件系统
|
||||
# (比如 btrfs / 某些 overlay)上 fallocate 出来的文件不能当 swap**,
|
||||
# 所以先试 fallocate,失败就退回 dd。
|
||||
if ! sudo fallocate -l 1G /swapfile 2>/dev/null; then
|
||||
echo " fallocate 不行,改用 dd(慢一点但通用)"
|
||||
sudo dd if=/dev/zero of=/swapfile bs=1M count=1024 status=none
|
||||
fi
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile >/dev/null
|
||||
sudo swapon /swapfile
|
||||
echo " ✓ 已启用"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==== 写进 /etc/fstab(重启后自动挂)===="
|
||||
if grep -qE '^\s*/swapfile\s' /etc/fstab; then
|
||||
echo " fstab 里已经有了"
|
||||
else
|
||||
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab >/dev/null
|
||||
echo " ✓ 已写入"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==== 调内核参数 ===="
|
||||
sudo tee /etc/sysctl.d/99-ykxmz-swap.conf >/dev/null <<'EOF'
|
||||
# swap 调优(配合 halo 博客)
|
||||
#
|
||||
# swappiness 默认 60 太激进 —— 内存用到 60% 就往 swap 挪,
|
||||
# 会把常用页换出去导致变卡。10 = 只有真要撑不住时才用。
|
||||
vm.swappiness = 10
|
||||
# 默认 100,改成 50 让内核更愿意保留文件缓存
|
||||
vm.vfs_cache_pressure = 50
|
||||
EOF
|
||||
sudo sysctl -p /etc/sysctl.d/99-ykxmz-swap.conf | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 校验 fstab(写错会导致开不了机!)===="
|
||||
if sudo mount -a 2>&1 | grep -q .; then
|
||||
echo " ⚠ fstab 有问题:"
|
||||
sudo mount -a 2>&1 | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ fstab 没问题(mount -a 无报错)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==== 加之后 ===="
|
||||
free -m | sed 's/^/ /'
|
||||
echo
|
||||
echo " --- swap 详情 ---"
|
||||
swapon --show | sed 's/^/ /'
|
||||
echo " --- 当前参数 ---"
|
||||
printf ' swappiness = %s\n' "$(cat /proc/sys/vm/swappiness)"
|
||||
printf ' vfs_cache_pressure = %s\n' "$(cat /proc/sys/vm/vfs_cache_pressure)"
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 第一步:加 1G swap")
|
||||
print("=" * 62)
|
||||
print()
|
||||
out = run(REMOTE, timeout=400, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把「语音播报降级链」加进 verify_all.ps1。
|
||||
|
||||
== 为什么用文件而不是 python -c ==
|
||||
|
||||
要写进 .ps1 的内容里有 `$stageOut`。用 `python -c "..."` 穿过
|
||||
PowerShell 时会被先解析一遍,`$stageOut` 变成 `\` —— 上次 3a12
|
||||
就是这么坏的。
|
||||
|
||||
凡是内容里带 `$` 的,一律写成 .py 文件再跑。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
ANCHOR = "# ---------------------------------------------------------------- 3a5"
|
||||
|
||||
STAGE = '''# ---------------------------------------------------------------- 3a14
|
||||
Banner "3a14/9 语音播报降级链(修 Win7 上用不了)"
|
||||
# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。
|
||||
#
|
||||
# 原来只有一条路(powershell + System.Speech),而 Win7 上
|
||||
# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证:
|
||||
# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西)
|
||||
# * 前一条失败要接着试下一条,不是试一条就放弃
|
||||
# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报"
|
||||
# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码)
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_tts.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "语音播报降级" ($LASTEXITCODE -eq 0)
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "test_tts.py" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
if ANCHOR not in t:
|
||||
print(" ✗ 找不到锚点")
|
||||
return 1
|
||||
|
||||
t = t.replace(ANCHOR, STAGE + ANCHOR, 1)
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 3a14")
|
||||
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_tts.py")',
|
||||
'Record "语音播报降级"'):
|
||||
ok = token in back
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56]))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给中转加「最新版本查询」接口 —— 被控端静默自动更新的第一步。
|
||||
|
||||
== 为什么由中转来回答 ==
|
||||
|
||||
被控端已经知道中转地址(那是它的配置),而中转的 downloads/ 目录里
|
||||
就放着各版本的成品。让中转直接回答"最新是哪版、在哪下、校验和多少",
|
||||
就不用把任何地址写死在客户端里 —— 换个域名/换台服务器都不用改代码。
|
||||
|
||||
== 新增 ==
|
||||
|
||||
GET /v1/update/latest?platform=windows
|
||||
|
||||
200 {
|
||||
"ok": true,
|
||||
"platform": "windows",
|
||||
"version": "2.1.5",
|
||||
"name": "ykxmz-setup-2.1.5.exe",
|
||||
"url": "/download/ykxmz-setup-2.1.5.exe",
|
||||
"size": 9395078,
|
||||
"sha256": "....",
|
||||
"notes": "..." <- 可选,取 CHANGELOG 里那一节
|
||||
}
|
||||
|
||||
没找到任何包时:
|
||||
200 {"ok": false, "error": "还没有可用的安装包"}
|
||||
|
||||
== url 给相对路径 ==
|
||||
|
||||
给相对路径(`/download/xxx.exe`),让被控端用**它自己配置的中转地址**
|
||||
拼成完整 URL。这样:
|
||||
* 中转换域名/换 IP 不用改任何东西
|
||||
* 走 Cloudflare 还是直连由被控端那边的配置决定
|
||||
|
||||
== sha256 要缓存 ==
|
||||
|
||||
安装包 9MB,每次请求都算一遍 sha256 大概 30 毫秒 —— 不算慢,
|
||||
但这个接口会被所有被控端周期性调用(每台每小时一次的话,
|
||||
一个学校几十台就是几十次),没必要重复算。
|
||||
按「文件名 + 大小 + 修改时间」做键缓存。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RELAY = ROOT / "relay" / "relay.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = RELAY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "_latest_package" in text:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
|
||||
# ⚠ **先把所有锚点都验一遍,再动手改。**
|
||||
#
|
||||
# 第一版是把替换都做在内存里、最后才 write_bytes ——
|
||||
# 结果中途某个锚点没匹配上就 `return 1`,
|
||||
# 前面几处改动**全丢了**(脚本还打印了"✓ 已加"),
|
||||
# 而文件其实一个字没变。特别容易误判成"加好了"。
|
||||
#
|
||||
# 教训:**批处理脚本要幺先验后改,幺写一段落一段。**
|
||||
ANCHORS = {
|
||||
"import_json": "\nimport json",
|
||||
"list_downloads": ' items.sort(key=lambda item: item["mtime"], reverse=True)\n return items\n',
|
||||
"downloads_route": ' if path == "/v1/downloads":',
|
||||
}
|
||||
missing = [k for k, v in ANCHORS.items() if v not in text]
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到,一个字都不改:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点全部就位")
|
||||
|
||||
# ---- 1. import hashlib ----
|
||||
if "\nimport hashlib" not in text:
|
||||
text = text.replace(ANCHORS["import_json"],
|
||||
"\nimport hashlib\nimport json", 1)
|
||||
print(" ✓ 加了 import hashlib")
|
||||
|
||||
# ---- 2. 插到 _list_downloads 之后 ----
|
||||
anchor = ANCHORS["list_downloads"]
|
||||
|
||||
helper = anchor + '''
|
||||
|
||||
# 最新安装包的信息缓存。键 = (文件名, 大小, 修改时间)。
|
||||
_PKG_CACHE: dict = {}
|
||||
|
||||
|
||||
def _sha256_of(path: Path) -> str:
|
||||
"""算文件的 sha256(分块读,别把 9MB 全塞内存)。"""
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(256 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _latest_package(platform: str = "windows") -> dict:
|
||||
"""找 downloads/ 里最新的安装包,给被控端自更新用。
|
||||
|
||||
`platform` 现在只有 windows;留着参数是为了以后加别的平台
|
||||
(手表/服务端不通过这个接口自更新,它们各有各的更新方式)。
|
||||
|
||||
返回 {} 表示"还没有可用的包"。
|
||||
"""
|
||||
# 各平台的成品名前缀
|
||||
prefix = {
|
||||
"windows": "ykxmz-setup-",
|
||||
}.get(platform)
|
||||
if not prefix:
|
||||
return {}
|
||||
|
||||
best = None # (版本元组, 路径)
|
||||
try:
|
||||
if not DOWNLOADS_DIR.is_dir():
|
||||
return {}
|
||||
for entry in DOWNLOADS_DIR.iterdir():
|
||||
if not entry.is_file():
|
||||
continue
|
||||
name = entry.name
|
||||
if not name.startswith(prefix) or not name.endswith(".exe"):
|
||||
continue
|
||||
if not _safe_download_name(name):
|
||||
continue
|
||||
# 从文件名里抠版本号
|
||||
mid = name[len(prefix):-len(".exe")]
|
||||
parts = mid.split(".")
|
||||
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
||||
continue
|
||||
ver = tuple(int(p) for p in parts)
|
||||
# ⚠ 按**数字段**比大小,不能按字符串 ——
|
||||
# 否则 "1.9.0" > "2.1.4"(字符 '9' > '1')。
|
||||
if best is None or ver > best[0]:
|
||||
best = (ver, entry)
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
if best is None:
|
||||
return {}
|
||||
|
||||
ver, path = best
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
key = (path.name, st.st_size, int(st.st_mtime))
|
||||
cached = _PKG_CACHE.get(key)
|
||||
if cached:
|
||||
digest = cached
|
||||
else:
|
||||
try:
|
||||
digest = _sha256_of(path)
|
||||
except OSError:
|
||||
return {}
|
||||
# 只留最近几个,别让字典涨
|
||||
_PKG_CACHE.clear()
|
||||
_PKG_CACHE[key] = digest
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"version": ".".join(str(p) for p in ver),
|
||||
"name": path.name,
|
||||
"url": "/download/" + quote(path.name),
|
||||
"size": st.st_size,
|
||||
"sha256": digest,
|
||||
"mtime": st.st_mtime,
|
||||
}
|
||||
'''
|
||||
text = text.replace(anchor, helper, 1)
|
||||
print(" ✓ 加了 _latest_package / _sha256_of")
|
||||
|
||||
# ---- 3. 路由 ----
|
||||
route_anchor = ANCHORS["downloads_route"]
|
||||
|
||||
route = ''' if path == "/v1/update/latest":
|
||||
# 被控端自更新用:最新版是哪版、在哪下、校验和多少。
|
||||
# 公开接口 —— 文件名和哈希本来就在下载页上人人可见,
|
||||
# 不含任何敏感信息。
|
||||
platform = (urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(self.path).query)
|
||||
.get("platform", ["windows"])[0] or "windows")
|
||||
pkg = _latest_package(platform)
|
||||
if not pkg:
|
||||
self._json(200, {"ok": False,
|
||||
"error": "还没有可用的安装包"})
|
||||
else:
|
||||
self._json(200, dict(pkg, ok=True))
|
||||
return
|
||||
|
||||
''' + route_anchor
|
||||
text = text.replace(route_anchor, route, 1)
|
||||
print(" ✓ 加了 /v1/update/latest 路由")
|
||||
|
||||
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,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''往 _tests/verify_all.ps1 里加两个关卡。
|
||||
|
||||
== 加哪两个 ==
|
||||
|
||||
3a10 静默自动更新链路(_tests/test_update_flow.py)
|
||||
3a11 远程鼠标 + 显示桌面(_tests/test_mouse_control.py)
|
||||
|
||||
== 这个脚本自己踩过的坑,写下来免得再犯 ==
|
||||
|
||||
往 server.py 插方法时,我两次把 **dispatch 的兜底 return**
|
||||
一起卷进了新方法里:
|
||||
|
||||
if cmd == P.CMD_XXX:
|
||||
return self._do_xxx(msg)
|
||||
|
||||
return {"ok": False, "error": f"未知命令: {cmd}"} <- dispatch 的兜底
|
||||
|
||||
def _do_xxx(...): <- 我的新方法
|
||||
...
|
||||
return {"ok": False, "error": f"未知命令: {cmd}"} <- 被卷进来了!
|
||||
|
||||
结果 pyflakes 报 "undefined name 'cmd'",而 dispatch 丢了兜底分支。
|
||||
|
||||
这次改 PowerShell 里插关卡用**更笨但更稳**的办法:
|
||||
直接在固定锚点**之前**插,而且插完立刻读回来看一眼结构。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
STAGES = '''
|
||||
# ---------------------------------------------------------------- 3a10
|
||||
Banner "3a10/7 静默自动更新链路"
|
||||
# 用户要求:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
|
||||
# 其他什么设置都不变 …… 不弹任何窗口出来"
|
||||
# 这条链是:中转 /v1/update/latest -> 被控端 updater.py 下载校验
|
||||
# -> 分离 .cmd 帮手 -> 静默装 -> 重启。
|
||||
#
|
||||
# 这里验协议常量、版本比较(按数字段,不是字符串)、下载校验、
|
||||
# 以及**服务端 dispatch 的结构没被补丁弄坏**。
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_update_flow.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "静默自动更新" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a11
|
||||
Banner "3a11/7 远程鼠标 + 一键显示桌面"
|
||||
# 鼠标注入是最"有力量"的功能,参数校验写错的话表现是"手表点了没反应",
|
||||
# 教室里没法排查。这里盯坐标归一化(除以 宽-1 那条规则)、
|
||||
# 滚轮夹取、Win+D 的事件顺序、以及非法参数必须被拒。
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_mouse_control.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "远程鼠标控制" ($LASTEXITCODE -eq 0)
|
||||
|
||||
'''
|
||||
|
||||
ANCHOR = '# ---------------------------------------------------------------- 3a5'
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "静默自动更新链路" in text:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
|
||||
if ANCHOR not in text:
|
||||
print(" ✗ 找不到锚点 %r" % ANCHOR)
|
||||
return 1
|
||||
print(" ✓ 锚点就位")
|
||||
|
||||
text = text.replace(ANCHOR, STAGES.lstrip("\n") + "\n" + ANCHOR, 1)
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 加了 3a10 / 3a11 两个关卡")
|
||||
|
||||
# 读回来确认结构:两个 Record 都在、锚点还在
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('Record "静默自动更新"', 'Record "远程鼠标控制"', ANCHOR):
|
||||
ok = token in back
|
||||
print(" [%s] 读回确认:%s" % ("PASS" if ok else "FAIL",
|
||||
token[:40]))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,556 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''手表端:常驻顶部快捷栏。
|
||||
|
||||
== 用户的要求(原话)==
|
||||
|
||||
"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页自定义,
|
||||
最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"
|
||||
|
||||
("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)
|
||||
|
||||
== 做什么 ==
|
||||
|
||||
1. **一条常驻的快捷栏**:贴在屏幕顶部,任何页面都在
|
||||
(控制页 / 镜像页 / 设置页 / 局域网页面板切换时不动)
|
||||
|
||||
2. **最多 4 个按钮**:
|
||||
* **1 个固定**:显示桌面(Win+D)—— 不给改,这是用户明确要求的
|
||||
* **最多 3 个自定义**:从 7 个动作里挑
|
||||
|
||||
3. **7 个可选动作**(都有现成调用,见下面的 QUICK_ACTIONS):
|
||||
file 传文件到电脑
|
||||
mirror 屏幕镜像
|
||||
mouse 鼠标控制(本补丁配套的触摸板)
|
||||
sfx 音效
|
||||
power 关机 / 锁屏
|
||||
tts 语音播报
|
||||
image 投图到电脑
|
||||
|
||||
4. **设置页里能改**:勾选哪 3 个动作。
|
||||
|
||||
== 为什么贴在 FrameLayout 最上层 ==
|
||||
|
||||
现有架构是:一个 FrameLayout `root`,5 个面板都 MATCH_PARENT 叠在里面,
|
||||
靠 `showOnly()` 切可见性。
|
||||
|
||||
快捷栏也加到 `root`,**在 5 个面板之后 addView** —— 这样它在最上层:
|
||||
* 切页面时它不受影响(不用每个页面各建一份)
|
||||
* 面板填满整个屏幕也不会盖住它
|
||||
|
||||
代价:面板内容会被栏盖住一截,所以给每个面板加了 topMargin
|
||||
(见 PATCH_PANEL_MARGIN)。
|
||||
|
||||
== 为什么栏在没连上时也显示 ==
|
||||
|
||||
用户说"不管干什么上面都有一栏",那就**一直在**。
|
||||
没连上的时候按钮置灰(不是隐藏)—— "一直在但暂时不能用"
|
||||
比"时有时无"更好理解,也不会让用户以为功能没了。
|
||||
|
||||
== 存储 ==
|
||||
|
||||
用 SharedPreferences(和 App 其他设置一致),键 `quick_slots`,
|
||||
存逗号分隔的动作 id。默认给 file,mirror,sfx。
|
||||
读取时会**过滤掉不认识的 id**(比如以后删了某个动作),
|
||||
不够 3 个就按默认顺序补齐 —— 这样老配置永远不会让栏变空。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||
|
||||
# ---------------------------------------------------------------- 字段
|
||||
FIELDS_ANCHOR = " private Button updateButton;"
|
||||
|
||||
FIELDS = ''' private Button updateButton;
|
||||
|
||||
// ---------------- 顶部快捷栏 ----------------
|
||||
//
|
||||
// 用户要求:"不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||
// 自定义,最多可以加 4 个,其中有一个按钮不能改,
|
||||
// 功能就是一键返回桌面"。
|
||||
//
|
||||
// 所以这里是 1 个固定(显示桌面)+ 最多 3 个自定义。
|
||||
private LinearLayout quickBar;
|
||||
private final java.util.List<Button> quickButtons =
|
||||
new java.util.ArrayList<Button>();
|
||||
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
||||
private String[] quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||
|
||||
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
||||
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
||||
private static final String[][] QUICK_ACTIONS = {
|
||||
{"file", "传文件"},
|
||||
{"mirror", "屏幕镜像"},
|
||||
{"mouse", "鼠标控制"},
|
||||
{"sfx", "音效"},
|
||||
{"power", "关机"},
|
||||
{"tts", "语音播报"},
|
||||
{"image", "投图到电脑"},
|
||||
};
|
||||
private static final String QUICK_PREF = "quick_slots";
|
||||
private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内
|
||||
|
||||
// ---------------- 鼠标控制(触摸板)----------------
|
||||
private FrameLayout mousePanel;
|
||||
private View mousePad;
|
||||
private boolean mouseDragging = false;
|
||||
private float mouseLastX = 0f;
|
||||
private float mouseLastY = 0f;'''
|
||||
|
||||
# ---------------------------------------------------------------- 建栏方法
|
||||
BUILD_ANCHOR = " private void showOnly(View keep) {"
|
||||
|
||||
BUILD_METHOD = ''' // ==================================================================
|
||||
// 顶部快捷栏
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* 建快捷栏。挂在 root 最上层,任何页面都在。
|
||||
*
|
||||
* <p>布局:[动作1][动作2][动作3][显示桌面]
|
||||
* 前三个按设置里的选择走,最后一个**永远**是显示桌面(不给改)。
|
||||
*/
|
||||
private void buildQuickBar() {
|
||||
quickBar = new LinearLayout(this);
|
||||
quickBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
quickBar.setBackgroundColor(0xE6185CE8); // 半透明蓝,压在任何页面上都看得清
|
||||
quickBar.setPadding(dp(3), dp(3), dp(3), dp(3));
|
||||
quickBar.setGravity(android.view.Gravity.CENTER_VERTICAL);
|
||||
|
||||
quickButtons.clear();
|
||||
// 4 个按钮:3 个自定义 + 1 个固定
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(11f);
|
||||
b.setAllCaps(false);
|
||||
b.setTextColor(0xFFFFFFFF);
|
||||
b.setBackgroundColor(0x33FFFFFF);
|
||||
b.setPadding(dp(2), 0, dp(2), 0);
|
||||
b.setMinWidth(0);
|
||||
b.setMinimumWidth(0);
|
||||
b.setMinHeight(0);
|
||||
b.setMinimumHeight(0);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||
lp.setMargins(dp(2), 0, dp(2), 0);
|
||||
b.setLayoutParams(lp);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onQuickClick(slot);
|
||||
}
|
||||
});
|
||||
quickButtons.add(b);
|
||||
quickBar.addView(b);
|
||||
}
|
||||
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
||||
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));
|
||||
barLp.gravity = android.view.Gravity.TOP;
|
||||
quickBar.setLayoutParams(barLp);
|
||||
|
||||
refreshQuickBar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按设置刷新快捷栏上的文字和可用状态。
|
||||
*
|
||||
* <p>连不上的时候置灰而不是隐藏 —— 用户说"不管干什么上面都有一栏",
|
||||
* 时有时无会让人以为功能没了。
|
||||
*/
|
||||
private void refreshQuickBar() {
|
||||
if (quickBar == null || quickButtons.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
b.setText("-"); // 这个位置空着
|
||||
b.setEnabled(false);
|
||||
b.setAlpha(0.45f);
|
||||
continue;
|
||||
}
|
||||
b.setText(quickLabel(id));
|
||||
b.setEnabled(connected);
|
||||
b.setAlpha(connected ? 1f : 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
private static String quickLabel(String id) {
|
||||
if ("desktop".equals(id)) {
|
||||
return "显示桌面"; // 固定那个
|
||||
}
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
if (pair[0].equals(id)) {
|
||||
return pair[1];
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
/** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||
private void onQuickClick(int slot) {
|
||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}
|
||||
|
||||
/** 执行一个快捷动作。设置页里选的也是这几个 id。 */
|
||||
private void runQuickAction(String id) {
|
||||
try {
|
||||
if ("desktop".equals(id)) {
|
||||
// 用户确认过:"一键返回桌面" = 让**被控电脑**显示桌面(Win+D)
|
||||
sendCommand(Json.obj("cmd", "show_desktop"), "显示桌面");
|
||||
return;
|
||||
}
|
||||
if ("file".equals(id)) {
|
||||
pickFile(REQ_FILE, "*/*");
|
||||
return;
|
||||
}
|
||||
if ("mirror".equals(id)) {
|
||||
openMirrorPanel();
|
||||
return;
|
||||
}
|
||||
if ("mouse".equals(id)) {
|
||||
showMousePanel();
|
||||
return;
|
||||
}
|
||||
if ("sfx".equals(id)) {
|
||||
pickSfx();
|
||||
return;
|
||||
}
|
||||
if ("power".equals(id)) {
|
||||
showPowerChooser();
|
||||
return;
|
||||
}
|
||||
if ("tts".equals(id)) {
|
||||
promptSay();
|
||||
return;
|
||||
}
|
||||
if ("image".equals(id)) {
|
||||
pickFile(REQ_IMAGE, "image/*");
|
||||
return;
|
||||
}
|
||||
toast("不认识的动作:" + id);
|
||||
} catch (Throwable error) {
|
||||
toast("执行失败:" + shortReason(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** 关机这一格点开后让用户选锁屏还是关机(不直接关,太危险)。 */
|
||||
private void showPowerChooser() {
|
||||
final java.util.List<String> items = new java.util.ArrayList<String>();
|
||||
items.add("锁屏");
|
||||
items.add("关机");
|
||||
items.add("重启");
|
||||
showChooser("电源", items, new Chooser() {
|
||||
@Override
|
||||
public void onPick(int index) {
|
||||
if (index == 0) {
|
||||
confirmPower("lock", "锁屏");
|
||||
} else if (index == 1) {
|
||||
confirmPower("shutdown", "关机");
|
||||
} else {
|
||||
confirmPower("restart", "重启");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 快捷栏的存取 ----------------
|
||||
|
||||
private void loadQuickSlots() {
|
||||
String raw = prefs().getString(QUICK_PREF, "");
|
||||
String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx"}
|
||||
: raw.split(",", -1);
|
||||
// ⚠ 一定要过滤 + 补齐:
|
||||
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
||||
// 不过滤的话那格会显示空白,用户以为坏了
|
||||
// 补齐 —— 保证长度正好 3,少了下标会越界
|
||||
String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||
break;
|
||||
}
|
||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||
&& isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx"};
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {
|
||||
if (out[i] == null) {
|
||||
// 补一个没被用过的默认动作
|
||||
for (String f : fallback) {
|
||||
boolean used = false;
|
||||
for (String o : out) {
|
||||
if (f.equals(o)) {
|
||||
used = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!used) {
|
||||
out[i] = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quickSlots = out;
|
||||
}
|
||||
|
||||
private static boolean isKnownQuickAction(String id) {
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
if (pair[0].equals(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void saveQuickSlots() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String id : quickSlots) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(id == null ? "" : id);
|
||||
}
|
||||
prefs().edit().putString(QUICK_PREF, sb.toString()).apply();
|
||||
refreshQuickBar();
|
||||
}
|
||||
|
||||
private android.content.SharedPreferences prefs() {
|
||||
return getSharedPreferences("ykxmz", MODE_PRIVATE);
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---------------------------------------------------------------- 鼠标触摸板
|
||||
MOUSE_METHOD = ''' // ==================================================================
|
||||
// 鼠标控制(触摸板)
|
||||
// ==================================================================
|
||||
|
||||
private void showMousePanel() {
|
||||
if (mousePanel == null) {
|
||||
buildMousePanel();
|
||||
}
|
||||
showOnly(mousePanel);
|
||||
appendLog("鼠标控制:拖动=移动光标,单击=左键,双击=右键");
|
||||
toast("拖动移动光标 · 单击左键 · 双击右键");
|
||||
}
|
||||
|
||||
/**
|
||||
* 触摸板。整块区域接收触摸,按比例映射到电脑屏幕的像素坐标。
|
||||
*
|
||||
* <p>为什么用"按比例映射"而不是"相对位移":
|
||||
* 手表表盘和电脑屏幕尺寸差得远,相对位移要让手表自己猜倍率,
|
||||
* 猜错了光标就飘;绝对映射由电脑端负责落到那个像素,
|
||||
* 责任清晰,也不会累积漂移。
|
||||
*
|
||||
* <p>但表盘太小,绝对映射的精度只有几十像素 —— 所以
|
||||
* **双击左右半屏**这类大幅移动之后,用拖动做细调更实际。
|
||||
* 这里两个都支持:拖动 = 相对微调,点击 = 在当前位置按下/抬起。
|
||||
*/
|
||||
private void buildMousePanel() {
|
||||
mousePanel = new FrameLayout(this);
|
||||
mousePanel.setBackgroundColor(0xFF101418);
|
||||
|
||||
LinearLayout col = new LinearLayout(this);
|
||||
col.setOrientation(LinearLayout.VERTICAL);
|
||||
col.setPadding(dp(6), dp(34), dp(6), dp(4));
|
||||
mousePanel.addView(col, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
TextView tip = new TextView(this);
|
||||
tip.setText("拖动=移动 单击=左键 双击=右键\\n滚轮在下面");
|
||||
tip.setTextSize(10f);
|
||||
tip.setTextColor(0xFF9AA4B2);
|
||||
tip.setGravity(android.view.Gravity.CENTER);
|
||||
col.addView(tip, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
mousePad = new View(this);
|
||||
mousePad.setBackgroundColor(0xFF1B2028);
|
||||
LinearLayout.LayoutParams padLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
|
||||
padLp.setMargins(0, dp(4), 0, dp(4));
|
||||
col.addView(mousePad, padLp);
|
||||
|
||||
// 左键 / 右键 / 滚轮
|
||||
LinearLayout keys = new LinearLayout(this);
|
||||
keys.setOrientation(LinearLayout.HORIZONTAL);
|
||||
col.addView(keys, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(34)));
|
||||
|
||||
keys.addView(mouseKey("左键", new Runnable() {
|
||||
public void run() { sendMouse("click", "left", 0); }
|
||||
}));
|
||||
keys.addView(mouseKey("右键", new Runnable() {
|
||||
public void run() { sendMouse("click", "right", 0); }
|
||||
}));
|
||||
keys.addView(mouseKey("滚↑", new Runnable() {
|
||||
public void run() { sendMouse("scroll", "", 3); }
|
||||
}));
|
||||
keys.addView(mouseKey("滚↓", new Runnable() {
|
||||
public void run() { sendMouse("scroll", "", -3); }
|
||||
}));
|
||||
|
||||
// 返回按钮(快捷栏上也能离开,但这里给个明确的出口)
|
||||
Button back = new Button(this);
|
||||
back.setText("返回");
|
||||
back.setTextSize(11f);
|
||||
back.setAllCaps(false);
|
||||
FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams(
|
||||
dp(58), dp(26));
|
||||
backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
|
||||
backLp.setMargins(0, dp(4), dp(4), 0);
|
||||
back.setLayoutParams(backLp);
|
||||
back.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
showControlPanel();
|
||||
}
|
||||
});
|
||||
mousePanel.addView(back);
|
||||
|
||||
// ---- 触摸处理 ----
|
||||
final android.view.GestureDetector detector =
|
||||
new android.view.GestureDetector(this,
|
||||
new android.view.GestureDetector.SimpleOnGestureListener() {
|
||||
@Override
|
||||
public boolean onSingleTapConfirmed(android.view.MotionEvent e) {
|
||||
sendMouse("click", "left", 0); // 单击 = 左键
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDoubleTap(android.view.MotionEvent e) {
|
||||
sendMouse("click", "right", 0); // 双击 = 右键
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(android.view.MotionEvent e1,
|
||||
android.view.MotionEvent e2,
|
||||
float dx, float dy) {
|
||||
// 手指往哪滑,光标往哪走:dx/dy 是"内容移动量",
|
||||
// 也就是手指移动的反方向,所以取负。
|
||||
sendMouseMove(-dx, -dy);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
mousePad.setOnTouchListener(new View.OnTouchListener() {
|
||||
@Override
|
||||
public boolean onTouch(View v, android.view.MotionEvent e) {
|
||||
detector.onTouchEvent(e);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Button mouseKey(String text, final Runnable action) {
|
||||
Button b = new Button(this);
|
||||
b.setText(text);
|
||||
b.setTextSize(10f);
|
||||
b.setAllCaps(false);
|
||||
b.setPadding(0, 0, 0, 0);
|
||||
b.setMinWidth(0);
|
||||
b.setMinimumWidth(0);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||
lp.setMargins(dp(1), 0, dp(1), 0);
|
||||
b.setLayoutParams(lp);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
private void sendMouse(String action, String button, int amount) {
|
||||
if (!isConnected()) {
|
||||
toast("还没连上电脑");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> req = Json.obj("cmd", "mouse", "action", action);
|
||||
if (button != null && !button.isEmpty()) {
|
||||
req.put("button", button);
|
||||
}
|
||||
if (amount != 0) {
|
||||
req.put("amount", amount);
|
||||
}
|
||||
sendCommand(req, "鼠标");
|
||||
}
|
||||
|
||||
private void sendMouseMove(float dx, float dy) {
|
||||
if (!isConnected()) {
|
||||
return;
|
||||
}
|
||||
// 手表上滑一点,电脑上要走一段 —— 倍率小了手指要划好几次才横穿屏幕。
|
||||
// 取 3.0 是试出来的:1920 宽的屏大约划 4~5 次到底,细调也还行。
|
||||
sendCommand(Json.obj("cmd", "mouse", "action", "moveby",
|
||||
"dx", (int) (dx * 3.0f), "dy", (int) (dy * 3.0f)), "鼠标");
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---------------------------------------------------------------- 设置页那一段
|
||||
SETTINGS_ANCHOR = " private void showSettingsPanel() {"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 手表端:常驻顶部快捷栏 + 鼠标触摸板")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "buildQuickBar" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
|
||||
# 先校验所有锚点,避免改一半
|
||||
need = {
|
||||
"updateButton 字段": FIELDS_ANCHOR,
|
||||
"showOnly 方法": BUILD_ANCHOR,
|
||||
}
|
||||
missing = [k for k, v in need.items() if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点全部就位")
|
||||
|
||||
# 1) 字段
|
||||
t = t.replace(FIELDS_ANCHOR, FIELDS, 1)
|
||||
# 2) 方法(插在 showOnly 之前)
|
||||
t = t.replace(BUILD_ANCHOR, BUILD_METHOD + BUILD_ANCHOR, 1)
|
||||
# 3) 鼠标面板方法(也插在 showOnly 之前,跟快捷栏挨着)
|
||||
t = t.replace(BUILD_ANCHOR, MOUSE_METHOD + BUILD_ANCHOR, 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了字段 + buildQuickBar/refreshQuickBar/runQuickAction")
|
||||
print(" ✓ 加了鼠标触摸板 buildMousePanel")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''设置页里加「快捷栏」和「允许新设备连接」(第二版)。
|
||||
|
||||
== 第一版为什么不行 ==
|
||||
|
||||
我原来写的 refresh 是重建整个设置面板,但 `buildSettingsPanel()` 是
|
||||
**一次性把整页建完**的(没有"往给定容器里建"的版本),而
|
||||
`root.addView(settingsPanel)` 是在 onCreate 里做的 ——
|
||||
所以重建出来的面板**没挂在视图树上**,刷完页面就白了。
|
||||
|
||||
== 这一版怎么做 ==
|
||||
|
||||
用**一个专用容器**装我这两小节:
|
||||
|
||||
settingsPanel
|
||||
└ ScrollView
|
||||
└ box (LinearLayout)
|
||||
├ ...原有的各小节...
|
||||
├ settingsExtraBox <-- 我加的这个
|
||||
└ 关于
|
||||
|
||||
刷新时只清空 `settingsExtraBox` 再填一遍 —— 不动页面其他部分,
|
||||
也不用碰视图树。开销小,而且不会有"重建后没挂上去"的问题。
|
||||
|
||||
== 加两个小节 ==
|
||||
|
||||
1. **快捷栏**:3 个格子各点一下,从 7 个动作里选(也能选"留空")。
|
||||
第 4 格是固定的「显示桌面」—— 界面上写明它改不了,
|
||||
否则用户会一直找"怎么改第四个"。
|
||||
|
||||
2. **允许新设备连接**:关掉后只有配过对的设备能连。
|
||||
两端的语义必须一致(电脑端设置窗口里也有同名勾选)。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||
|
||||
ANCHOR = ' // ================= 关于 ================='
|
||||
|
||||
SECTION = ''' // ================= 快捷栏 / 允许新设备连接 =================
|
||||
//
|
||||
// 这两小节装在**专用容器**里(settingsExtraBox),
|
||||
// 这样改完设置只刷这一段,不用重建整页 ——
|
||||
// 重建整页的话 settingsPanel 是新的对象,而 root 上挂的还是旧的,
|
||||
// 表现就是"一改设置页面就白了"。
|
||||
settingsExtraBox = new LinearLayout(this);
|
||||
settingsExtraBox.setOrientation(LinearLayout.VERTICAL);
|
||||
box.addView(settingsExtraBox, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
fillSettingsExtra();
|
||||
|
||||
''' + ANCHOR
|
||||
|
||||
# ---------------- 各种方法 ----------------
|
||||
METHODS = ''' /** 让用户选第 slot 格放哪个动作。 */
|
||||
private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");
|
||||
ids.add("");
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
labels.add(pair[1]);
|
||||
ids.add(pair[0]);
|
||||
}
|
||||
showChooser("第 " + (slot + 1) + " 格放什么", labels, new Chooser() {
|
||||
@Override
|
||||
public void onPick(int index) {
|
||||
if (index < 0 || index >= ids.size()) {
|
||||
return;
|
||||
}
|
||||
quickSlots[slot] = ids.get(index);
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("第 " + (slot + 1) + " 格已设为:"
|
||||
+ quickLabel(quickSlots[slot]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 电脑端当前的「允许新设备连接」状态。本地记一份,连上后以电脑端为准。 */
|
||||
private boolean allowNewPairing() {
|
||||
return prefs().getBoolean("allow_new_pairing", true);
|
||||
}
|
||||
|
||||
/** 填「快捷栏」和「允许新设备连接」这两小节。刷新时也调它。 */
|
||||
private void fillSettingsExtra() {
|
||||
if (settingsExtraBox == null) {
|
||||
return;
|
||||
}
|
||||
settingsExtraBox.removeAllViews();
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
||||
settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {
|
||||
final int s = slot;
|
||||
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
||||
? "空着" : quickLabel(quickSlots[s]);
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"第 " + (s + 1) + " 格:" + now, C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
pickQuickSlot(s);
|
||||
}
|
||||
}));
|
||||
}
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("快捷栏已恢复默认");
|
||||
}
|
||||
}));
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("允许新设备连接"));
|
||||
settingsExtraBox.addView(hintText("关掉之后,只有已经配对过的电脑"
|
||||
+ "能连上来,新电脑会被拒绝。教室里防别人乱连用这个。"));
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"现在:" + (allowNewPairing() ? "允许"
|
||||
: "不允许(只有老设备能连)"),
|
||||
C_CARD, new Runnable() {
|
||||
public void run() {
|
||||
final boolean want = !allowNewPairing();
|
||||
new android.app.AlertDialog.Builder(MainActivity.this)
|
||||
.setTitle("允许新设备连接")
|
||||
.setMessage(want
|
||||
? "打开后,没配对过的新电脑也能连上。"
|
||||
: "关掉后,只有已经配对过的电脑能连上,"
|
||||
+ "新电脑会被拒绝。")
|
||||
.setPositiveButton(want ? "允许" : "关掉",
|
||||
new android.content.DialogInterface.OnClickListener() {
|
||||
public void onClick(android.content.DialogInterface d,
|
||||
int w) {
|
||||
// 电脑端执行 + 本地记一份(连不上时也能看到选择)
|
||||
runFeature("allow_new_pairing",
|
||||
Json.obj("allow", want),
|
||||
want ? "允许新设备连接"
|
||||
: "只允许老设备连接");
|
||||
prefs().edit()
|
||||
.putBoolean("allow_new_pairing", want)
|
||||
.apply();
|
||||
fillSettingsExtra();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
FIELD_ANCHOR = " private LinearLayout quickBar;"
|
||||
FIELD_ADD = ''' private LinearLayout quickBar;
|
||||
/** 设置页里装「快捷栏 / 允许新设备连接」那两小节的容器。
|
||||
* 用专用容器是为了改完只刷这一段,不用重建整页。 */
|
||||
private LinearLayout settingsExtraBox;'''
|
||||
|
||||
PICK_ANCHOR = " private void showSettingsPanel() {"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 设置页:快捷栏 + 允许新设备连接(第二版)")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "fillSettingsExtra" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
|
||||
missing = [k for k, v in {
|
||||
"关于锚点": ANCHOR,
|
||||
"showSettingsPanel": PICK_ANCHOR,
|
||||
"quickBar 字段": FIELD_ANCHOR,
|
||||
}.items() if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点就位")
|
||||
|
||||
t = t.replace(ANCHOR, SECTION, 1)
|
||||
t = t.replace(PICK_ANCHOR, METHODS + PICK_ANCHOR, 1)
|
||||
t = t.replace(FIELD_ANCHOR, FIELD_ADD, 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 settingsExtraBox 字段 + 容器")
|
||||
print(" ✓ 加了 fillSettingsExtra / pickQuickSlot / allowNewPairing")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''让客户端能用"纯 IP + 明文 HTTP"的中转(新服务器就是这种)。
|
||||
|
||||
== 背景 ==
|
||||
|
||||
2026-10-08 换了服务器:AWS Lightsail,**有公网 IP**(yk.xxmxxs.top),
|
||||
中转直接监听 13245,**不再走 Cloudflare 隧道**。
|
||||
|
||||
好处一大堆(延迟从 1000ms 降到 1ms、不再有 7844 阻断、内存 4 倍),
|
||||
但有个直接后果:**现在是明文 HTTP,没有 HTTPS 证书**。
|
||||
|
||||
而两个客户端原来都默认往 https 上走:
|
||||
|
||||
1. Android 的 RelayTransport:用户填 `yk.xxmxxs.top:13245`
|
||||
(没写协议头)会被强制加上 `https://` -> 连不上(那台机器没证书)
|
||||
|
||||
2. Android 9+ **默认禁止明文 HTTP**。没有
|
||||
`android:usesCleartextTraffic="true"` 的话,请求会被系统直接掐掉,
|
||||
报的还是很含糊的 "Cleartext HTTP traffic not permitted"。
|
||||
|
||||
Windows 那边已经是对的:relay.py 的 _PRIVATE_HOST 正则里
|
||||
`\d{1,3}(?:\.\d{1,3}){3}` 把**任意纯 IP(含公网)**都当"用明文",
|
||||
所以裸 IP 会正确走 http://。
|
||||
|
||||
== 这个补丁做什么 ==
|
||||
|
||||
1. RelayTransport 的协议猜测改成和 Windows 一致:
|
||||
纯 IP / localhost / *.local -> http://
|
||||
域名 -> https://
|
||||
|
||||
2. AndroidManifest 加 usesCleartextTraffic="true"
|
||||
|
||||
3. 顺手清掉 windows/lanremote/relay.py 里一段**函数内死代码**
|
||||
(_guess_scheme 的 return 之后还有一份一模一样的拷贝,
|
||||
缩进仍属于函数体所以不报错,但看着很迷惑)
|
||||
|
||||
== 关于 HTTPS ==
|
||||
|
||||
明文 HTTP 意味着**同网络的人能看到流量**(虽然配对令牌、内容本来
|
||||
就是加密的,但 URL、设备名这些会露)。真正的解法是上 HTTPS:
|
||||
|
||||
把域名 relay.xxmxxs.top 的 A 记录指到 yk.xxmxxs.top,
|
||||
然后 certbot 签一张 Let's Encrypt 证书,confd 挂上。
|
||||
|
||||
那一步要用户动 DNS,所以先让明文能用(这个补丁),
|
||||
HTTPS 等 DNS 指过来再做。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JAVA = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
|
||||
/ "RelayTransport.java")
|
||||
MANIFEST = ROOT / "android" / "AndroidManifest.xml"
|
||||
WINRELAY = ROOT / "windows" / "lanremote" / "relay.py"
|
||||
|
||||
|
||||
def patch(path: Path, old: str, new: str, what: str, out: list) -> str:
|
||||
raw = path.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if old not in text:
|
||||
out.append(("✗", what, "找不到锚点"))
|
||||
return text
|
||||
if text.count(old) != 1:
|
||||
out.append(("!", what, "锚点 %d 处" % text.count(old)))
|
||||
text = text.replace(old, new, 1)
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
out.append(("✓", what, ""))
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 1. Java
|
||||
JAVA_OLD = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://" + url;
|
||||
}'''
|
||||
|
||||
JAVA_NEW = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
// 没写协议头时猜一个。规则要**和 Windows 端一致**
|
||||
// (windows/lanremote/relay.py 的 _guess_scheme):
|
||||
//
|
||||
// IP / localhost / *.local -> http://
|
||||
// 域名 -> https://
|
||||
//
|
||||
// 为什么纯 IP 走明文:IP 地址的中转基本都是自己搭的
|
||||
// (局域网那台,或者像现在这样一台有公网 IP 的服务器),
|
||||
// 上面往往没有证书。原来一律加 https:// 的结果是**永远连不上**,
|
||||
// 而且报的是 SSL 错误,会把人带去查证书查半天。
|
||||
//
|
||||
// ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
|
||||
// 之后中转就是 http://yk.xxmxxs.top:13245,
|
||||
// 只把 192.168/10./172.16 当私有的话这里会走错。
|
||||
String hostOnly = url;
|
||||
int slash = hostOnly.indexOf('/');
|
||||
if (slash > 0) {
|
||||
hostOnly = hostOnly.substring(0, slash);
|
||||
}
|
||||
int colon = hostOnly.indexOf(':');
|
||||
if (colon > 0) {
|
||||
hostOnly = hostOnly.substring(0, colon);
|
||||
}
|
||||
boolean plain = hostOnly.equalsIgnoreCase("localhost")
|
||||
|| hostOnly.toLowerCase().endsWith(".local")
|
||||
|| hostOnly.matches("\\\\d{1,3}(\\\\.\\\\d{1,3}){3}");
|
||||
url = (plain ? "http://" : "https://") + url;
|
||||
}'''
|
||||
|
||||
# ---------------------------------------------------------------- 2. 清单
|
||||
MAN_OLD = ''' <uses-permission android:name="android.permission.INTERNET" />'''
|
||||
|
||||
MAN_NEW = ''' <!-- ⚠ 允许明文 HTTP。
|
||||
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
||||
2026-10-08 之后那台有公网 IP 的服务器(http://yk.xxmxxs.top:13245)。
|
||||
Android 9+ 默认**禁止明文流量**,不加这一条的话请求会被系统
|
||||
直接掐掉,日志里只有一句含糊的
|
||||
"Cleartext HTTP traffic to ... not permitted"。
|
||||
|
||||
安全性说明:中转上跑的内容(配对令牌、画面、文件)本来就是
|
||||
加密过的,明文只暴露 URL 和设备名。要更严的话后续上 HTTPS
|
||||
(Let's Encrypt),那时可以把这一条去掉。 -->
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />'''
|
||||
|
||||
# ---------------------------------------------------------------- 3. 死代码
|
||||
DEAD_OLD = ''' host = address.split("/", 1)[0]
|
||||
return "http://" if _PRIVATE_HOST.match(host) else "https://"
|
||||
|
||||
|
||||
|
||||
"""用户没写 http/https 时,猜一个。
|
||||
|
||||
IP 地址 / localhost -> http://
|
||||
局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
|
||||
而且报的是 SSL 错误,很容易误导人去查证书。
|
||||
域名 -> https://
|
||||
走公网必须加密(Cloudflare 隧道本来就是 https)。
|
||||
"""
|
||||
host = address.split("/", 1)[0]
|
||||
return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
|
||||
|
||||
DEAD_NEW = ''' host = address.split("/", 1)[0]
|
||||
return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out: list = []
|
||||
|
||||
patch(JAVA, JAVA_OLD, JAVA_NEW, "RelayTransport 协议猜测改成和 Windows 一致",
|
||||
out)
|
||||
|
||||
patch(MANIFEST, MAN_OLD, MAN_NEW, "AndroidManifest 允许明文 HTTP", out)
|
||||
|
||||
patch(WINRELAY, DEAD_OLD, DEAD_NEW, "清掉 relay.py 里的函数内死代码", out)
|
||||
|
||||
print(" ── 结果 ──")
|
||||
for m, what, why in out:
|
||||
print(" %s %s%s" % (m, what, (" —— " + why) if why else ""))
|
||||
return 0 if not any(m == "✗" for m, _w, _y in out) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,7 +146,31 @@ def main() -> int:
|
||||
print(f" docs/{path.name}")
|
||||
changed += 1
|
||||
|
||||
# ---- README 里的成品文件名 ----
|
||||
#
|
||||
# README 里有 8 处写着 `ykxmz-setup-1.8.2.exe` 这类成品名。
|
||||
# 以前没管它,结果一直停在 1.8.2 —— 用户照着 README 去 releases
|
||||
# 找 1.8.2 的包,当然找不到(最新已经是 2.x 了)。
|
||||
#
|
||||
# 这里用"任意数字版本号 -> 新版本"的正则,而不是精确替换 OLD:
|
||||
# README 可能因为漏过几次 bump 而停在更老的版本上,
|
||||
# 精确匹配 OLD 那时候就换不掉了。
|
||||
readme = ROOT / "README.md"
|
||||
if readme.is_file():
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
new_text = re.sub(
|
||||
r"(ykxmz-(?:setup|server|watch|agent)-)\d+\.\d+\.\d+",
|
||||
lambda m: m.group(1) + NEW, text)
|
||||
if new_text != text:
|
||||
readme.write_text(new_text, encoding="utf-8", newline="\n")
|
||||
print(" README.md")
|
||||
changed += 1
|
||||
|
||||
# ---- 会断言成品名的测试 ----
|
||||
# 注意 test_installer_cycle.py 也曾经写死过成品名(ykxmz-setup-1.8.2.exe),
|
||||
# 结果它去装一个不存在的包 -> 静默失败 -> 报出一堆莫名其妙的
|
||||
# "配对码实际是 xxx" "版本显示 v1.8.2"。那个文件现在改成自动找
|
||||
# dist 下最新的安装包了,不再需要这里维护。
|
||||
for name in ("test_package_remote.py", "test_apk_contents.py"):
|
||||
path = ROOT / "_tests" / name
|
||||
if not path.is_file():
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查 1Panel 和我装的 nginx 会不会打架。
|
||||
|
||||
== 为什么要查 ==
|
||||
|
||||
发现服务器上有 1Panel。1Panel 默认会**自己装一套 OpenResty(nginx)**
|
||||
放在 /opt/1panel/apps/openresty/ 下,并且**接管 80/443**。
|
||||
|
||||
而我为了给遥控中转做反代,用 apt 装了**系统 nginx**,也在听 80/443。
|
||||
|
||||
两个 nginx 抢同一组端口,只有一个能起来 —— 另一个会
|
||||
`Address already in use`。所以现在实际生效的到底是谁的配置,
|
||||
必须搞清楚,否则:
|
||||
|
||||
* 我给 yk.xxmxxs.top 配的证书和反代可能**根本没生效**
|
||||
(之前测通了,说明生效的是我的那份)
|
||||
* 但用户在 1Panel 里建站,可能**建了个寂寞**(1Panel 的 OpenResty 起不来)
|
||||
|
||||
== 查什么 ==
|
||||
|
||||
1. 系统里到底有几个 nginx/OpenResty 可执行文件
|
||||
2. 现在听 80/443 的是哪个进程(看 exe 路径)
|
||||
3. 1Panel 的应用目录里有没有 openresty、有没有建过站
|
||||
4. 1Panel 面板自己在哪个端口
|
||||
5. 有没有已经存在的 WordPress 容器
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 1. 有几个 nginx / openresty ===="
|
||||
for p in /usr/sbin/nginx /usr/local/openresty/nginx/sbin/nginx \
|
||||
/opt/1panel/apps/openresty/openresty/bin/openresty; do
|
||||
[ -x "$p" ] && echo " 有: $p"
|
||||
done
|
||||
command -v nginx openresty 2>/dev/null | sed 's/^/ PATH 里: /'
|
||||
|
||||
echo
|
||||
echo "==== 2. 现在听 80/443 的是谁(看可执行文件路径)===="
|
||||
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 3. 1Panel 的 nginx 有没有在跑 ===="
|
||||
for s in openresty nginx 1panel; do
|
||||
st=$(systemctl is-active $s 2>/dev/null)
|
||||
[ -n "$st" ] && echo " $s: $st"
|
||||
done
|
||||
echo " --- 1Panel 应用目录 ---"
|
||||
sudo ls -1 /opt/1panel/apps/ 2>/dev/null | sed 's/^/ /' || echo " (没有 /opt/1panel/apps)"
|
||||
echo " --- 1Panel 的 www 站点目录 ---"
|
||||
sudo ls -1 /opt/1panel/www/sites/ 2>/dev/null | sed 's/^/ /' || echo " (还没建过站)"
|
||||
|
||||
echo
|
||||
echo "==== 4. 1Panel 面板自己在哪 ===="
|
||||
sudo ss -tlnp 2>/dev/null | grep -i 1panel | sed 's/^/ /'
|
||||
sudo grep -h "Port\|port" /opt/1panel/conf/app.yaml 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 5. 现有的容器 ===="
|
||||
sudo docker ps --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " (docker 命令不可用)"
|
||||
|
||||
echo
|
||||
echo "==== 6. 系统 nginx 的配置来自哪 ===="
|
||||
systemctl show nginx -p FragmentPath 2>/dev/null | sed 's/^/ /'
|
||||
nginx -v 2>&1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 7. 1Panel 有没有装 php/mysql 应用 ===="
|
||||
sudo ls -1 /opt/1panel/apps/ 2>/dev/null | while read a; do
|
||||
echo " 应用 $a:"
|
||||
sudo ls -1 /opt/1panel/apps/$a 2>/dev/null | sed 's/^/ /'
|
||||
done
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 1Panel 和系统 nginx 会不会打架")
|
||||
print("=" * 58)
|
||||
print()
|
||||
out = run(REMOTE, timeout=240, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查 Gitee 到底为什么判违规,以及 README 里哪些词可能触发。
|
||||
|
||||
== 已知 ==
|
||||
|
||||
https://gitee.com/xmz7891/ykxmz/raw/main/README.md -> HTTP 451
|
||||
Unavailable For Legal Reasons
|
||||
|
||||
451 是"应法律要求不可用"。在国内平台上,这个通常是**内容审核拦的**。
|
||||
|
||||
== 要分清两种情况 ==
|
||||
|
||||
A. 整个仓库被限制(那 API 读仓库信息也会失败/受限)
|
||||
B. 只有 README 这个文件被拦(网页能开、raw 不行)
|
||||
|
||||
== 同时扫一下 README 里可能触发审核的词 ==
|
||||
|
||||
国内平台的关键词审核对这几类比较敏感:
|
||||
|
||||
* 翻墙 / 代理 / VPN / 科学上网
|
||||
* 挖矿 / 矿机 / 算力 / 虚拟货币
|
||||
* 破解 / 破解版 / 盗版 / 激活工具
|
||||
* 外挂 / 作弊
|
||||
* 监控 / 偷拍 / 窃听 / 追踪("监控"在教育场景容易误判)
|
||||
* 游戏相关的"封号""加速器"
|
||||
* 二维码 / 收款 / 打赏
|
||||
|
||||
这份 README 是"教室手表控电脑",正常。但要确认没混进上面这些。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
GITEE = "https://gitee.com/xmz7891/ykxmz"
|
||||
|
||||
RISKY = [
|
||||
"翻墙", "代理", "VPN", "vpn", "科学上网", "梯子", "加速器",
|
||||
"挖矿", "矿机", "算力", "比特币", "虚拟货币", "以太坊",
|
||||
"破解", "盗版", "激活工具", "注册机", "keygen", "crack",
|
||||
"外挂", "作弊", "开挂",
|
||||
"监控", "偷拍", "窃听", "追踪", "定位",
|
||||
"打赏", "收款", "二维码收款",
|
||||
"荷官", "博彩", "赌博",
|
||||
]
|
||||
|
||||
|
||||
def get(url: str, ua: str = "Mozilla/5.0") -> tuple:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", ua)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "失败", str(exc)[:60]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 查 Gitee 为什么 451 + 扫 README 可疑词")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 各入口的状态码 ──")
|
||||
for label, url in [
|
||||
("仓库主页", GITEE),
|
||||
("raw README", GITEE + "/raw/main/README.md"),
|
||||
("raw LICENSE", GITEE + "/raw/main/LICENSE"),
|
||||
("网页版 README", GITEE + "/blob/main/README.md"),
|
||||
("发行版页", GITEE + "/releases"),
|
||||
]:
|
||||
code, body = get(url)
|
||||
note = ""
|
||||
if isinstance(code, int):
|
||||
if code == 200:
|
||||
note = "正常"
|
||||
elif code == 451:
|
||||
note = "**被法律原因拦了**"
|
||||
elif code == 404:
|
||||
note = "不存在"
|
||||
print(" %-16s HTTP %-6s %s" % (label, code, note))
|
||||
|
||||
print()
|
||||
print(" ── 2. 用 Gitee API 看仓库(API 走的是另一条路)──")
|
||||
token_file = ROOT / "_build" / ".gitee_token"
|
||||
if token_file.is_file():
|
||||
token = token_file.read_text(encoding="utf-8").strip()
|
||||
code, body = get(
|
||||
"https://gitee.com/api/v5/repos/xmz7891/ykxmz"
|
||||
"?access_token=%s" % token)
|
||||
print(" API 仓库信息 HTTP %s" % code)
|
||||
if code == 200:
|
||||
try:
|
||||
d = json.loads(body)
|
||||
print(" 名称:%s" % d.get("name"))
|
||||
print(" 是否私有:%s" % d.get("private"))
|
||||
print(" 最近推送:%s" % d.get("pushed_at"))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
print()
|
||||
print(" ── 3. 扫 README 里的可疑词 ──")
|
||||
rm = ROOT / "README.md"
|
||||
text = rm.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
hits = 0
|
||||
for word in RISKY:
|
||||
for i, line in enumerate(lines, 1):
|
||||
if word in line:
|
||||
hits += 1
|
||||
print(" %-10s 第 %d 行: %s"
|
||||
% (word, i, line.strip()[:60]))
|
||||
if not hits:
|
||||
print(" ✅ 没扫到这些词")
|
||||
|
||||
print()
|
||||
print(" ── 4. README 里的链接(外链也可能被拦)──")
|
||||
urls = re.findall(r"https?://[^\s\)\]\"'<>]+", text)
|
||||
seen = []
|
||||
for u in urls:
|
||||
host = re.sub(r"^https?://", "", u).split("/")[0]
|
||||
if host not in seen:
|
||||
seen.append(host)
|
||||
for h in seen:
|
||||
print(" %s" % h)
|
||||
|
||||
print()
|
||||
print(" ── 5. 结论提示 ──")
|
||||
print(" 如果只有 raw 是 451、主页 200,那就是**单个文件被拦**,")
|
||||
print(" 通常改掉触发的措辞就能恢复。")
|
||||
print(" 如果主页也 451,那是**整个仓库被限制**,得走 Gitee 申诉。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''看一眼 Lightsail 上中转服务器的完整状态。
|
||||
|
||||
⚠ 写这个文件的原因:用 `python -c @"..."@` 传带 `$(...)` 的远端命令,
|
||||
PowerShell 会**先在本机求值** `$(systemctl ...)`(报"无法将 systemctl
|
||||
项识别为 cmdlet"),还会吃掉引号导致 Python 语法错。
|
||||
这个毛病我这一轮犯了四五次 —— **凡是超过两行的远端命令,一律写文件。**
|
||||
|
||||
用法:
|
||||
python _build/check_lightsail.py
|
||||
python _build/check_lightsail.py --fix # 顺便修文件属主
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, HOST as LS_HOST # noqa: E402
|
||||
|
||||
PORT = "13245"
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
|
||||
# 三层地址各查一遍:
|
||||
# 域名 —— 用户实际走的路(Cloudflare -> nginx -> 中转)
|
||||
# IP:80 —— 绕过 Cloudflare,直连 nginx(域名没配好时靠它)
|
||||
# :13245 —— **应该不通**(中转已只绑 127.0.0.1,不再对公网)
|
||||
DOMAIN_URL = "http://%s" % DOMAIN
|
||||
NGINX_URL = "http://%s:80" % LS_HOST
|
||||
DIRECT_URL = "http://%s:%s" % (LS_HOST, PORT)
|
||||
|
||||
REMOTE = r'''
|
||||
R=__DIR__
|
||||
P=__PORT__
|
||||
|
||||
echo "════ 服务 ════"
|
||||
for s in ykxmz-relay nginx ykxmz-monitor.timer; do
|
||||
printf ' %-22s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════ 三层地址(用户路径 / 绕 CF / 直连中转)════"
|
||||
# ⚠ 一律加 -L 跟跳转:nginx 那边 http 会 301 到 https,
|
||||
# 不跟跳转拿到的是 301 而不是 200,会看着像"挂了"。
|
||||
printf ' %-26s %s\n' "域名 __DOMAIN__" "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 __DOMAIN_URL__/v1/health)"
|
||||
printf ' %-26s %s\n' "IP:80(nginx)" "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 __NGINX_URL__/v1/health)"
|
||||
printf ' %-26s %s\n' ":__PORT__(应 000)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 __DIRECT_URL__/v1/health)"
|
||||
printf ' %-26s %s\n' "本机 :__PORT__" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:$P/v1/health)"
|
||||
printf ' %-26s %s\n' "域名延迟" "$(curl -sL -o /dev/null -w '%{time_total}s' --max-time 12 __DOMAIN_URL__/v1/health)"
|
||||
|
||||
echo
|
||||
echo "════ 首页角标 ════"
|
||||
echo " 域名 -> $(curl -sL --max-time 12 __DOMAIN_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
||||
echo " IP:80 -> $(curl -sL --max-time 12 __NGINX_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
||||
|
||||
echo
|
||||
echo "════ 缓存头(决定 Cloudflare 缓不缓存)════"
|
||||
printf ' %-20s %s 条\n' "下载" "$(curl -sL -I --max-time 12 __DOMAIN_URL__/download/ykxmz-setup-2.1.3.exe | grep -ci cache-control)"
|
||||
curl -sL -I --max-time 12 __DOMAIN_URL__/download/ykxmz-setup-2.1.3.exe \
|
||||
| grep -i '^cache-control' | sed 's/^/ /'
|
||||
printf ' %-20s %s\n' "长轮询" "$(curl -sL -I --max-time 12 __DOMAIN_URL__/v1/health | grep -i '^cache-control' | tr -d '\r')"
|
||||
|
||||
echo
|
||||
echo "════ 监听 / 进程 ════"
|
||||
ss -tlnp 2>/dev/null | grep -E ":(80|$P) " | sed 's/^/ /'
|
||||
ps -eo pid,rss,etime,cmd --sort=-rss 2>/dev/null | grep 'relay\.py' | grep -v grep | head -1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 监测单元 ════"
|
||||
cat $R/monitor_state.txt 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- 探测的是什么地址 ---"
|
||||
grep -E '^(PUBLIC_URL|FALLBACK_URL)=' $R/monitor.sh | sed 's/^/ /'
|
||||
echo " --- monitor.log 尾部(只挑非 Permission 的)---"
|
||||
grep -v 'Permission denied' $R/monitor.log 2>/dev/null | tail -4 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 抢救报告 / 知识库 ════"
|
||||
echo " 报告 $(ls $R/rescue_reports 2>/dev/null | wc -l) 份"
|
||||
echo " 知识库 $(wc -l < $R/rescue_knowledge.md 2>/dev/null || echo 0) 行"
|
||||
|
||||
echo
|
||||
echo "════ 资源 ════"
|
||||
free -m 2>/dev/null | sed -n 2p | awk '{printf " 内存 %s/%s MB\n", $3, $2}'
|
||||
df -h / | tail -1 | awk '{printf " 磁盘 %s 用 %s\n", $2, $5}'
|
||||
uptime | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 文件属主(admin 必须能写日志)════"
|
||||
ls -la $R/*.log $R/monitor_state.txt 2>/dev/null | awk '{print " " $3":"$4" "$9}'
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--fix" in sys.argv:
|
||||
print(" ── 修属主 ──")
|
||||
out = run("sudo chown -R admin:admin %s && sudo chmod 755 %s && echo OK"
|
||||
% (RELAY_DIR, RELAY_DIR), timeout=120, quiet=True)
|
||||
print(" " + out[1].strip())
|
||||
|
||||
cmd = (REMOTE
|
||||
.replace("__DIR__", RELAY_DIR)
|
||||
.replace("__PORT__", PORT)
|
||||
.replace("__DOMAIN__", DOMAIN)
|
||||
.replace("__DOMAIN_URL__", DOMAIN_URL)
|
||||
.replace("__NGINX_URL__", NGINX_URL)
|
||||
.replace("__DIRECT_URL__", DIRECT_URL))
|
||||
out = run(cmd, timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把上次查询里两个可疑点查清楚。
|
||||
|
||||
== 疑点一:证书目录为什么是空的 ==
|
||||
|
||||
我明明给 yk.xxmxxs.top 签过 Let's Encrypt 证书。
|
||||
但 `/etc/letsencrypt/live/` 列出来是空的 ——
|
||||
多半是**权限**:那个目录是 0700 root,普通用户 ls 不到。
|
||||
|
||||
== 疑点二:3306 在监听,但没找到 mariadb/mysql ==
|
||||
|
||||
`ss` 显示 127.0.0.1:3306 有人在听,但 `command -v mariadb` 没找到。
|
||||
可能是:
|
||||
* 装了但可执行文件叫别的名字
|
||||
* 是别的东西占了 3306(比如某个容器)
|
||||
得看是哪个进程在听。
|
||||
|
||||
== 疑点三:内存用量涨了 ==
|
||||
|
||||
之前看是 414MB,现在 1037MB。得知道是谁吃的 ——
|
||||
WordPress + MySQL 还要再吃 300~500MB,这台只有 2GB,得先弄清余量。
|
||||
|
||||
== 顺带 ==
|
||||
|
||||
`grep -r /etc/nginx/sites-enabled/` 没输出 server_name,
|
||||
是因为那里全是**指向 sites-available 的符号链接**,
|
||||
而 `grep -r` **默认不跟符号链接**。不是配置丢了。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 1. 证书(带 sudo)===="
|
||||
sudo ls -1 /etc/letsencrypt/live/ 2>&1 | sed 's/^/ /'
|
||||
echo " --- yk.xxmxxs.top 的证书 ---"
|
||||
sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem \
|
||||
-noout -subject -dates 2>&1 | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== 2. nginx 站点(跟符号链接)===="
|
||||
sudo grep -h server_name /etc/nginx/sites-available/ykxmz 2>/dev/null | sed 's/^/ /'
|
||||
echo " sites-enabled:"
|
||||
sudo ls -la /etc/nginx/sites-enabled/ 2>&1 | tail -n +2 | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== 3. 谁在听 3306 ===="
|
||||
sudo ss -tlnp 2>/dev/null | grep 3306 | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== 4. 数据库到底装没装 ===="
|
||||
dpkg -l 2>/dev/null | grep -iE 'mariadb|mysql' | head -8 | sed 's/^/ /'
|
||||
echo " (上面没输出就是没装 —— 那 3306 是别的东西占的)"
|
||||
echo
|
||||
echo "==== 5. 内存被谁吃了 ===="
|
||||
ps -eo rss,comm --sort=-rss 2>/dev/null | head -10 | sed 's/^/ /'
|
||||
echo
|
||||
echo " --- systemd 服务占用 ---"
|
||||
systemd-cgtop -n 1 -b 2>/dev/null | head -8 | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== 6. 有没有 swap ===="
|
||||
free -m | sed 's/^/ /'
|
||||
swapon --show 2>/dev/null | sed 's/^/ /' || echo " 没有 swap"
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 查清三个疑点")
|
||||
print("=" * 58)
|
||||
print()
|
||||
out = run(REMOTE, timeout=240, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验证源站的 443 是否就绪(证书签完之后)。
|
||||
|
||||
背景:522 是因为 Cloudflare 的 SSL 模式是 Full/Full(strict),
|
||||
回源走 443,而源站只有 80。现在证书签好了、nginx 也配了 443,
|
||||
所以要确认:
|
||||
|
||||
1. 本机 443 能自测通(带正确 SNI)
|
||||
2. 从**外面**能不能连上 443
|
||||
—— 这一步取决于用户在 Lightsail 防火墙开没开 443
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, HOST as LS_HOST # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
IP = LS_HOST
|
||||
|
||||
|
||||
def tcp(host: str, port: int, timeout: float = 8.0) -> bool:
|
||||
s = socket.socket()
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def https(host: str, port: int = 443) -> tuple:
|
||||
"""返回 (状态码文本, 证书主体)。"""
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"https://%s:%d/v1/health" % (host, port),
|
||||
timeout=15, context=ctx) as r:
|
||||
r.read(64)
|
||||
return str(r.status), "证书验证通过"
|
||||
except ssl.SSLCertVerificationError as exc:
|
||||
return "证书错误", str(exc)[:70]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "失败", "%s: %s" % (type(exc).__name__, str(exc)[:60])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 验证 443 是否就绪")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 源站内部(从服务器自己看)──")
|
||||
# ⚠ 这里**不能用 % 格式化** —— 远端命令里有 curl 的
|
||||
# %{http_code},% 格式化会把 %{ 当成格式符直接抛
|
||||
# "unsupported format character '{'"。用 replace 更安全。
|
||||
inner = (
|
||||
"echo ' --- 监听 ---'; "
|
||||
"ss -tln 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'; "
|
||||
"echo ' --- 本机 https(带 SNI)---'; "
|
||||
"curl -s -o /dev/null -w ' %{http_code}\\n' --max-time 10 "
|
||||
"--resolve __D__:443:127.0.0.1 https://__D__/v1/health; "
|
||||
"echo ' --- 证书主体 ---'; "
|
||||
"sudo openssl x509 -in /etc/letsencrypt/live/__D__/fullchain.pem "
|
||||
"-noout -subject -dates 2>/dev/null | sed 's/^/ /'; "
|
||||
"echo ' --- nginx 里的 443 段 ---'; "
|
||||
"sudo grep -nE 'listen|ssl_certificate|return 301|server_name' "
|
||||
"/etc/nginx/sites-available/ykxmz | sed 's/^/ /'"
|
||||
).replace("__D__", DOMAIN)
|
||||
out = run(inner, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 从这台电脑直连源站(不经过 Cloudflare)──")
|
||||
for port in (80, 443):
|
||||
ok = tcp(IP, port)
|
||||
print(" %s:%-4d TCP %s" % (IP, port, "通" if ok else "不通 ❌"))
|
||||
code, note = https(IP)
|
||||
print(" https://%s/v1/health -> %s (%s)" % (IP, code, note))
|
||||
print(" (这里会是证书错误,正常 —— 我们是用 IP 直连,")
|
||||
print(" 证书是签给域名的。这一步只看 TCP 通不通。)")
|
||||
|
||||
print()
|
||||
print(" ── 3. 经 Cloudflare 的 HTTPS ──")
|
||||
code, note = https(DOMAIN)
|
||||
print(" https://%s/v1/health -> %s (%s)" % (DOMAIN, code, note))
|
||||
|
||||
print()
|
||||
print(" ── 4. 结论 ──")
|
||||
if tcp(IP, 443):
|
||||
print(" ✅ 443 从外面**通** —— Cloudflare 的 Full(strict) 能工作")
|
||||
if code == "200":
|
||||
print(" ✅ 全链路已经好了")
|
||||
else:
|
||||
print(" ⚠ 443 通了但 HTTPS 还不通,可能是 Cloudflare")
|
||||
print(" 还没切到 Full(strict),或者配置还在生效中")
|
||||
else:
|
||||
print(" ❌ 443 从外面**不通** —— 需要去 Lightsail 防火墙开 TCP 443")
|
||||
print(" (实例 → 联网 → 防火墙 → 添加规则 → 自定义 TCP 443)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''校验打出来的 EXE 能不能在 **Windows 7** 上跑。
|
||||
|
||||
== 为什么需要这个 ==
|
||||
|
||||
学校电脑是 Windows 7,装完报「dll 缺失」。
|
||||
|
||||
根因不是漏打包,是 **Python 版本越界**:
|
||||
|
||||
Python 3.8 -> 最后一个支持 Windows 7 的版本
|
||||
Python 3.9+ -> 只支持 Windows 8 及以上
|
||||
|
||||
Python 3.9 起用到了 `api-ms-win-core-path-l1-1-0.dll` 里的
|
||||
`PathCchCanonicalizeEx`,而这个 DLL **Windows 7 上根本不存在**,
|
||||
也没法用 api-set 转发(那套机制只在 Win8+ 生效)。
|
||||
|
||||
**所以那个 DLL 不是"忘了放进包",是"放进去也没用"。**
|
||||
唯一可行的办法是**用 3.8 打包**。
|
||||
|
||||
== 这个脚本查什么 ==
|
||||
|
||||
1. 有没有 python38.dll(而不是 python39/310/311/... )
|
||||
2. **不该有** api-ms-win-core-path-l1-1-0.dll
|
||||
—— 有它就说明是 3.9+ 打的,Win7 上必挂
|
||||
3. PE 头是不是 32 位(0x14C)
|
||||
—— 32 位在 Win7 的 32/64 位系统上都能跑;
|
||||
64 位在 32 位 Windows 上直接起不来
|
||||
4. 顺带报一下总大小和文件数
|
||||
|
||||
用 `python _build/check_win7_compat.py` 单独跑也行,
|
||||
`build_exe.ps1` 里会调它,不过就直接拒绝出包。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DIST = ROOT / "dist_exe" / "ykxmz"
|
||||
|
||||
FAILS: list = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, why: str = "") -> None:
|
||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
||||
(" —— " + why) if why else ""))
|
||||
if not ok:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
def pe_machine(path: Path) -> int:
|
||||
"""读 PE 头里的 machine 字段。0x14C=32 位,0x8664=64 位。"""
|
||||
with path.open("rb") as f:
|
||||
if f.read(2) != b"MZ":
|
||||
return 0
|
||||
f.seek(0x3C)
|
||||
off = struct.unpack("<I", f.read(4))[0]
|
||||
f.seek(off)
|
||||
if f.read(4) != b"PE\0\0":
|
||||
return 0
|
||||
return struct.unpack("<H", f.read(2))[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" Windows 7 兼容性校验")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
check("dist_exe/ykxmz 存在", DIST.is_dir(),
|
||||
"先跑 windows\\build_exe.ps1")
|
||||
if not DIST.is_dir():
|
||||
return 1
|
||||
|
||||
dlls = {p.name.lower(): p for p in DIST.rglob("*.dll")}
|
||||
exe = DIST / "ykxmz.exe"
|
||||
check("ykxmz.exe 存在", exe.is_file())
|
||||
if not exe.is_file():
|
||||
return 1
|
||||
|
||||
# ---- 1. Python 运行库版本 ----
|
||||
print()
|
||||
print("一、Python 运行库")
|
||||
py_dlls = sorted(n for n in dlls if n.startswith("python3") and n.endswith(".dll"))
|
||||
for n in py_dlls:
|
||||
print(" %s" % n)
|
||||
check("有 python38.dll(3.8 打的)",
|
||||
any("python38" in n for n in py_dlls),
|
||||
"没有 python38.dll 说明不是 3.8 打的")
|
||||
bad_py = [n for n in py_dlls
|
||||
if any(v in n for v in ("python39", "python310", "python311",
|
||||
"python312", "python313", "python314"))]
|
||||
check("没有 3.9+ 的运行库", not bad_py,
|
||||
"3.9+ 的包在 Win7 上会因为缺 DLL 启动失败:%s" % bad_py)
|
||||
|
||||
# ---- 2. 那个要命的 DLL ----
|
||||
print()
|
||||
print("二、Win7 上不存在的那个 DLL")
|
||||
killer = "api-ms-win-core-path-l1-1-0.dll"
|
||||
has = killer in dlls
|
||||
check("**没有** %s" % killer, not has,
|
||||
"有它就说明是 3.9+ 打的 —— Win7 上必然报缺 DLL")
|
||||
print(" (这个 DLL 是 Win8+ 才有的。Python 3.9 起用到它,")
|
||||
print(" 放进来也没用,Win7 上加载不了。)")
|
||||
|
||||
# ---- 3. 位数 ----
|
||||
print()
|
||||
print("三、位数")
|
||||
m = pe_machine(exe)
|
||||
arch = {0x14C: "32 位 (x86)", 0x8664: "64 位 (x64)"}.get(m, "未知 (0x%X)" % m)
|
||||
print(" ykxmz.exe: %s" % arch)
|
||||
check("是 32 位(Win7 32/64 位都能跑)", m == 0x14C,
|
||||
"64 位的包在 32 位 Windows 上起不来;学校机器可能是 32 位")
|
||||
|
||||
# ---- 4. 体积概览 ----
|
||||
print()
|
||||
print("四、产物概览")
|
||||
files = list(DIST.rglob("*"))
|
||||
files = [f for f in files if f.is_file()]
|
||||
total = sum(f.stat().st_size for f in files)
|
||||
print(" 文件数: %d" % len(files))
|
||||
print(" 总大小: %.1f MB" % (total / 1048576))
|
||||
print(" exe 大小: %.1f MB" % (exe.stat().st_size / 1048576))
|
||||
|
||||
print()
|
||||
print("=" * 58)
|
||||
if FAILS:
|
||||
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
|
||||
print(" 这个包学校电脑装不上,不该发布。")
|
||||
return 1
|
||||
print(" ✅ 可以在 Windows 7 / 8 / 10 / 11 上运行")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''看服务器上装 WordPress 缺什么。
|
||||
|
||||
== 背景 ==
|
||||
|
||||
用户想把 xxmxxs.top 拿来做 WordPress(原来是 Cloudflare Workers 占着)。
|
||||
WordPress 要 PHP + 数据库,而服务器上现在只跑着 nginx(给遥控中转用)。
|
||||
|
||||
== 为什么写成文件 ==
|
||||
|
||||
这个查询里有一堆 `$(...)`、`$b`、`2>/dev/null`。
|
||||
用 `python -c "..."` 传的话,**PowerShell 会先在本机把它们求值**
|
||||
(报"无法将 systemctl 项识别为 cmdlet"、"未能找到路径 C:\dev\null")。
|
||||
这个坑今天踩了太多次 —— 凡是超过两行的远端命令,一律写成文件。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 磁盘 ===="
|
||||
df -h / | tail -1
|
||||
echo
|
||||
echo "==== 内存 ===="
|
||||
free -m | head -2
|
||||
echo
|
||||
echo "==== nginx 站点 ===="
|
||||
ls -1 /etc/nginx/sites-enabled/ 2>/dev/null
|
||||
echo " server_name:"
|
||||
grep -rh server_name /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== PHP ===="
|
||||
if command -v php >/dev/null 2>&1; then
|
||||
php -v | head -1
|
||||
echo " php-fpm: $(systemctl is-active php8.3-fpm 2>/dev/null || systemctl is-active php8.2-fpm 2>/dev/null || echo 没装)"
|
||||
else
|
||||
echo " 没装 PHP"
|
||||
fi
|
||||
echo
|
||||
echo "==== 数据库 ===="
|
||||
if command -v mariadb >/dev/null 2>&1; then
|
||||
echo " mariadb 客户端在: $(command -v mariadb)"
|
||||
echo " mariadb 服务: $(systemctl is-active mariadb 2>/dev/null)"
|
||||
elif command -v mysql >/dev/null 2>&1; then
|
||||
echo " mysql 客户端在"
|
||||
echo " mysql 服务: $(systemctl is-active mysql 2>/dev/null)"
|
||||
else
|
||||
echo " 没装数据库"
|
||||
fi
|
||||
echo
|
||||
echo "==== apt 源里有 php-fpm 吗 ===="
|
||||
apt-cache policy php-fpm 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== 服务状态 ===="
|
||||
for s in nginx ykxmz-relay ykxmz-monitor.timer certbot.timer; do
|
||||
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
|
||||
done
|
||||
echo
|
||||
echo "==== 证书目录 ===="
|
||||
ls -1 /etc/letsencrypt/live/ 2>/dev/null | sed 's/^/ /' || echo " (空)"
|
||||
echo
|
||||
echo "==== 监听端口 ===="
|
||||
ss -tln 2>/dev/null | grep -E ':(80|443|3306) ' | sed 's/^/ /'
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 服务器 WordPress 就绪度")
|
||||
print("=" * 58)
|
||||
print()
|
||||
out = run(REMOTE, timeout=240, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''清理服务器上没用的东西。
|
||||
|
||||
== 用户要求 ==
|
||||
|
||||
"所有没的东西就删了"
|
||||
|
||||
== 查到什么没用 ==
|
||||
|
||||
1. **1Panel 的安装包残留**(占 223MB)
|
||||
/root/1panel-v2.3.2-linux-amd64.tar.gz 63MB
|
||||
/root/1panel-v2.3.2-linux-amd64/ 160MB(解压出来的)
|
||||
1Panel 早就装好并跑着了,这两个是**装完就没用**的东西。
|
||||
磁盘现在只用了 7%,不急着腾空间,但"没的东西就删"是用户明确要求。
|
||||
|
||||
2. **早年的隧道单元**(上一轮已删)
|
||||
lanremote-tunnel / cloudflared
|
||||
|
||||
3. **旧的抢救报告**(保留最近几份,老的删掉)
|
||||
|
||||
== 什么不删 ==
|
||||
|
||||
* /opt/ykxmz-relay/ 下的 relay.py / landing.py / admin.py / monitor.sh
|
||||
—— 这些是**正在运行的东西**
|
||||
* /opt/ykxmz-relay/data/ —— 中转的配置和数据库
|
||||
* /opt/ykxmz-relay/downloads/ —— 安装包,用户要下载的
|
||||
* /opt/ykxmz-relay/rescue_reports/ 最近几份 —— 排查问题时有用
|
||||
* 1Panel 本体(/root/.docker、容器)—— 用户自己在用
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 清理前 ===="
|
||||
df -h / | tail -1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 删 1Panel 安装包残留 ===="
|
||||
du -sh /root/1panel-v2.3.2-linux-amd64* 2>/dev/null | sed 's/^/ 删前: /'
|
||||
sudo rm -rf /root/1panel-v2.3.2-linux-amd64
|
||||
sudo rm -f /root/1panel-v2.3.2-linux-amd64.tar.gz
|
||||
echo " 删完了,还在吗: $(ls -d /root/1panel-v2.3.2-linux-amd64* 2>/dev/null || echo '不在了 ✓')"
|
||||
|
||||
echo
|
||||
echo "==== 清理旧的抢救报告(保留最近 5 份)===="
|
||||
cd /opt/ykxmz-relay/rescue_reports 2>/dev/null && {
|
||||
total=$(ls -1 *.txt 2>/dev/null | wc -l)
|
||||
echo " 一共 $total 份"
|
||||
ls -1t *.txt 2>/dev/null | tail -n +6 | while read f; do
|
||||
echo " 删 $f"
|
||||
rm -f "$f"
|
||||
done
|
||||
echo " 剩下 $(ls -1 *.txt 2>/dev/null | wc -l) 份"
|
||||
} || echo " (没有这个目录)"
|
||||
|
||||
echo
|
||||
echo "==== 清 nginx 的旧日志(保留最近,压缩老的)===="
|
||||
sudo find /var/log/nginx -name "*.log.*" -size +10M 2>/dev/null | head -5 | sed 's/^/ 大日志: /'
|
||||
|
||||
echo
|
||||
echo "==== 清理后 ===="
|
||||
df -h / | tail -1 | sed 's/^/ /'
|
||||
echo
|
||||
echo "==== 确认重要的东西都还在 ===="
|
||||
for f in /opt/ykxmz-relay/relay.py /opt/ykxmz-relay/monitor.sh \
|
||||
/opt/ykxmz-relay/landing.py /opt/ykxmz-relay/admin.py; do
|
||||
[ -f "$f" ] && echo " ✓ $f" || echo " ✗ 不见了!$f"
|
||||
done
|
||||
echo " downloads 里的安装包: $(ls -1 /opt/ykxmz-relay/downloads/ 2>/dev/null | wc -l) 个"
|
||||
|
||||
echo
|
||||
echo "==== 服务状态复核 ===="
|
||||
for s in nginx ykxmz-relay ykxmz-monitor.timer; do
|
||||
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>/dev/null)"
|
||||
done
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 清理服务器上没用的东西")
|
||||
print("=" * 62)
|
||||
print()
|
||||
out = run(REMOTE, timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
2.1.7:手表快捷栏 + 鼠标触摸板 + 弹窗可配 + 后台远程更新
|
||||
|
||||
用户这次提了一整套升级,这是最后一批。
|
||||
|
||||
== 一、手表端顶部快捷栏 ==
|
||||
|
||||
用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
|
||||
|
||||
("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)
|
||||
|
||||
* 一条常驻的栏,贴在屏幕顶部,切页面时不动
|
||||
* **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
|
||||
* 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
|
||||
语音播报 / 投图到电脑
|
||||
* 设置页里点一下换;也能把某一格留空
|
||||
* 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
|
||||
比"时有时无"更好理解)
|
||||
|
||||
实现上的几个要点:
|
||||
|
||||
* 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
|
||||
加在前面会被面板(MATCH_PARENT)整个盖住
|
||||
* 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
|
||||
(每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
|
||||
* 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
|
||||
这样它既不会出现在可选列表里,也不可能被改成别的
|
||||
* 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
|
||||
以后删了某个动作,老配置也不会让栏变空或下标越界
|
||||
|
||||
== 二、远程鼠标控制(手表上的触摸板)==
|
||||
|
||||
* 拖动 = 移动光标,单击 = 左键,双击 = 右键
|
||||
* 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
|
||||
* 电脑端用 SendInput 注入(见下)
|
||||
|
||||
== 三、弹文字窗口的位置和大小可配 ==
|
||||
|
||||
用户原话:"弹文字窗口 …… 位置和大小都能自定义"。
|
||||
|
||||
* 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
|
||||
屏幕正中间 / 自定义坐标
|
||||
* 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
|
||||
* 宽度:可指定,0 = 按内容自适应
|
||||
* 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
|
||||
右下角,改一次要改三个地方,漏一个用户就看得见
|
||||
|
||||
== 四、屏幕被观看时的提示条改成默认不显示 ==
|
||||
|
||||
用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
|
||||
|
||||
* 默认**连窗口都不建**
|
||||
* **但保留成开关**,设置里可以打开
|
||||
|
||||
关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
|
||||
应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
|
||||
属于正当的远程管理需求。所以能力保留、默认关闭 ——
|
||||
这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。
|
||||
|
||||
== 五、中转后台能看到版本并远程点更新 ==
|
||||
|
||||
* 设备表新增「版本」列
|
||||
* 每行一个「更新」按钮:点一下让那台电脑自己静默升级
|
||||
* 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
|
||||
那时用户早忘了自己点过,突然重启很莫名其妙)
|
||||
* 和手表那边走**同一条命令**(cmd=update, action=start),
|
||||
不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致
|
||||
|
||||
== 六、手表端可开关「允许新设备连接」 ==
|
||||
|
||||
* 关掉后只有配对过的电脑能连,教室里防别人乱连
|
||||
* 电脑端新增 _do_allow_new_pairing 这个 feature
|
||||
* 只影响**配对新设备**,不踢现有会话
|
||||
(否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")
|
||||
|
||||
== 修掉的几个自己造的坑 ==
|
||||
|
||||
1. **管理页 JS 裸换行** —— 最严重的一个。
|
||||
ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
|
||||
结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
|
||||
**整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
|
||||
改成 String.fromCharCode(10),不依赖任何转义层。
|
||||
|
||||
2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
|
||||
但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
|
||||
于是路由加到了 GET 块里,管理页的 POST 打不到它。
|
||||
表现是"点了更新没反应"。
|
||||
|
||||
3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
|
||||
编译才发现。顺手包了一层并加上说明。
|
||||
|
||||
4. **两个不存在的方法名** —— onCreate 里写了
|
||||
buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。
|
||||
|
||||
5. **未调用方法扫描的假阳性** —— GestureDetector 的
|
||||
onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
|
||||
扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
|
||||
这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。
|
||||
|
||||
6. **测试断言太严导致误报** —— "不能有写死的 240",
|
||||
但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。
|
||||
|
||||
7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
|
||||
改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
|
||||
关闭时默认值必须是 False 且代码路径上不建窗口。
|
||||
|
||||
== 验证 ==
|
||||
|
||||
全量回归(19 关)全部 PASS,零失败
|
||||
新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
|
||||
3a13 手表快捷栏
|
||||
Java 编译通过(android-34, --release 8)
|
||||
APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
|
||||
623 个 Python 文件语法通过
|
||||
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''清掉 actions.py 里重复的 list_voices 和残留的旧 say 前半段。
|
||||
|
||||
== 怎么弄出来的 ==
|
||||
|
||||
fix_tts_fallback.py 里我写了两处替换,但 `NEW` 那个大块**自己也带了一份
|
||||
list_voices**:
|
||||
|
||||
t.replace(OLD_VOICES, NEW_VOICES) # 这是第一份 list_voices
|
||||
t.replace(OLD_SAY, NEW) # NEW 里又有一份 list_voices
|
||||
|
||||
于是 pyflakes 报:
|
||||
|
||||
redefinition of unused 'list_voices' from line 540
|
||||
redefinition of unused 'say' from line 575
|
||||
local variable 'chosen' is assigned to but never used
|
||||
|
||||
第三条是**旧 say 的前半段残留** —— `chosen` / `speed` 算完就不用了,
|
||||
真正干活的在后面那份里。
|
||||
|
||||
== 这一版怎么改 ==
|
||||
|
||||
把**后面**那份 list_voices(`NEW` 带进来的)删掉,保留前面 NEW_VOICES 那份;
|
||||
再把旧的 say 前半段(算 chosen/speed 那几行)删掉 —— 那些在新 say 里
|
||||
已经算过一遍了。
|
||||
|
||||
**先全部定位好再动手**,避免又改一半。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 清掉重复定义")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ACTIONS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
lines = t.split("\n")
|
||||
|
||||
# ---- 1. 找所有 list_voices / say 定义 ----
|
||||
defs = {}
|
||||
for i, ln in enumerate(lines):
|
||||
m = re.match(r" def (list_voices|say|_tts_commands|_write_vbs|"
|
||||
r"_powershell_path)\(self", ln)
|
||||
if m:
|
||||
defs.setdefault(m.group(1), []).append(i)
|
||||
|
||||
print(" 定义位置:")
|
||||
for name, spots in sorted(defs.items()):
|
||||
print(" %-18s %s" % (name, [s + 1 for s in spots]))
|
||||
|
||||
# ---- 2. 删掉重复的 list_voices(保留第一个)----
|
||||
dup_lv = defs.get("list_voices", [])
|
||||
if len(dup_lv) != 2:
|
||||
print(" ⚠ list_voices 有 %d 个定义,期望 2 个" % len(dup_lv))
|
||||
else:
|
||||
start = dup_lv[1]
|
||||
# 找到下一个同级 def
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
if re.match(r" def \w+\(", lines[j]):
|
||||
end = j
|
||||
break
|
||||
# 往上把紧邻的注释也吃掉(那是我写的那段说明)
|
||||
s = start
|
||||
while s > 0 and (lines[s - 1].strip().startswith("#")
|
||||
or lines[s - 1].strip() == ""):
|
||||
s -= 1
|
||||
if lines[s].strip() == "" and s + 1 < start:
|
||||
s += 1
|
||||
break
|
||||
print(" 删除重复的 list_voices:第 %d-%d 行" % (start + 1, end))
|
||||
del lines[start:end]
|
||||
|
||||
# ---- 3. 删掉旧 say 的前半段(算 chosen/speed 那段)----
|
||||
t2 = "\n".join(lines)
|
||||
old_head = ''' chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip()
|
||||
try:
|
||||
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 0
|
||||
speed = max(-10, min(10, speed))
|
||||
|
||||
'''
|
||||
n = t2.count(old_head)
|
||||
print(" 旧 say 前半段残留:%d 处" % n)
|
||||
if n:
|
||||
# 只删最后一处之前的那一坨(如果出现两次,说明新 say 里也有一份,
|
||||
# 那份是**要保留的**)—— 所以删第一处。
|
||||
t2 = t2.replace(old_head, "", 1)
|
||||
|
||||
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t2.encode("utf-8"))
|
||||
print(" ✓ 清完了")
|
||||
|
||||
# ---- 4. 复核 ----
|
||||
back = ACTIONS.read_text(encoding="utf-8-sig")
|
||||
lv = len(re.findall(r"\n def list_voices\(self", back))
|
||||
sy = len(re.findall(r"\n def say\(self", back))
|
||||
tc = len(re.findall(r"\n def _tts_commands\(self", back))
|
||||
print()
|
||||
print(" 复核:list_voices=%d(应 1) say=%d(应 1) _tts_commands=%d(应 1)"
|
||||
% (lv, sy, tc))
|
||||
return 0 if (lv == 1 and sy == 1 and tc == 1) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""删掉指定名字的 Java 方法(按大括号配对,不用正则瞎猜)。
|
||||
|
||||
== 为什么不用正则 ==
|
||||
|
||||
方法体里可能有字符串、注释、嵌套大括号。正则很难正确跳过这些,
|
||||
删一半会把文件搞坏。这里做一个**简单但正确**的扫描器:
|
||||
|
||||
* 逐字符走
|
||||
* 跳过 `//` 行注释、`/* */` 块注释、`"..."` 字符串、`'c'` 字符字面量
|
||||
* 统计 `{` `}` 深度,深度回到 0 就是方法结束
|
||||
* 顺便把方法**前面的 javadoc / 注解行**一起删干净(否则留下一堆
|
||||
没头没脑的注释)
|
||||
|
||||
== 安全措施 ==
|
||||
|
||||
* 名字必须能唯一定位到一个方法定义
|
||||
* 删完立刻检查大括号总数是否守恒(删之前总的 `{`/`}` 数减去
|
||||
这个方法里的数量,应该等于删之后的)
|
||||
* 任何一步不对就抛异常,不写文件
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
JAVA = (Path(__file__).resolve().parent.parent
|
||||
/ "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java")
|
||||
|
||||
|
||||
def find_method_span(text: str, name: str):
|
||||
"""返回 (start, end) —— 整个方法(含前面的注释/注解)的字符区间。"""
|
||||
# 找到定义行
|
||||
pat = re.compile(
|
||||
r"^[ \t]*(?:@\w+(?:\([^)]*\))?\s*)*"
|
||||
r"(?:private|protected|public|static|final|synchronized|\s)*"
|
||||
r"(?:[\w<>\[\],.?\s]+?)\s+" + re.escape(name) + r"\s*\([^)]*\)\s*"
|
||||
r"(?:throws [\w,.\s]+)?\s*\{",
|
||||
re.M)
|
||||
matches = list(pat.finditer(text))
|
||||
if len(matches) != 1:
|
||||
raise SystemExit("✗ %s: 找到 %d 个定义(要恰好 1 个)"
|
||||
% (name, len(matches)))
|
||||
m = matches[0]
|
||||
def_start = m.start()
|
||||
|
||||
# 往上吃掉紧邻的注释行和注解行
|
||||
line_start = text.rfind("\n", 0, def_start) + 1
|
||||
start = line_start
|
||||
while True:
|
||||
prev_end = start - 1
|
||||
if prev_end <= 0:
|
||||
break
|
||||
prev_start = text.rfind("\n", 0, prev_end) + 1
|
||||
line = text[prev_start:prev_end].strip()
|
||||
if not line:
|
||||
break
|
||||
if (line.startswith(("*", "//", "/*")) or line.endswith("*/")
|
||||
or line.startswith("@")):
|
||||
start = prev_start
|
||||
continue
|
||||
break
|
||||
|
||||
# 从 { 开始找匹配的 }
|
||||
i = text.index("{", m.start())
|
||||
depth = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
# 行注释
|
||||
if ch == "/" and i + 1 < n and text[i + 1] == "/":
|
||||
i = text.find("\n", i)
|
||||
if i < 0:
|
||||
raise SystemExit("✗ %s: 行注释没结束" % name)
|
||||
continue
|
||||
# 块注释
|
||||
if ch == "/" and i + 1 < n and text[i + 1] == "*":
|
||||
i = text.find("*/", i + 2)
|
||||
if i < 0:
|
||||
raise SystemExit("✗ %s: 块注释没结束" % name)
|
||||
i += 2
|
||||
continue
|
||||
# 字符串
|
||||
if ch == '"':
|
||||
i += 1
|
||||
while i < n and text[i] != '"':
|
||||
if text[i] == "\\":
|
||||
i += 1
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
# 字符字面量
|
||||
if ch == "'":
|
||||
i += 1
|
||||
while i < n and text[i] != "'":
|
||||
if text[i] == "\\":
|
||||
i += 1
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return start, i + 1
|
||||
i += 1
|
||||
raise SystemExit("✗ %s: 大括号没配平" % name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
names = sys.argv[1:]
|
||||
if not names:
|
||||
print("用法: find_orphans.py <方法名> [方法名...]")
|
||||
return 1
|
||||
|
||||
text = JAVA.read_text(encoding="utf-8")
|
||||
before_open = text.count("{")
|
||||
before_close = text.count("}")
|
||||
|
||||
removed_total = 0
|
||||
for name in names:
|
||||
start, end = find_method_span(text, name)
|
||||
chunk = text[start:end]
|
||||
removed_total += chunk.count("{")
|
||||
# 连同后面的空行一起删
|
||||
tail = end
|
||||
while tail < len(text) and text[tail] in "\r\n":
|
||||
tail += 1
|
||||
text = text[:start] + text[tail:]
|
||||
print(" ✓ 删除 %-22s %4d 字符" % (name, tail - start))
|
||||
|
||||
# 大括号守恒检查
|
||||
if text.count("{") != before_open - removed_total:
|
||||
raise SystemExit("✗ 大括号数量不对(删前 %d,删掉 %d,现在 %d)"
|
||||
% (before_open, removed_total, text.count("{")))
|
||||
if text.count("{") != text.count("}"):
|
||||
raise SystemExit("✗ 大括号不配对:{ %d 个,} %d 个"
|
||||
% (text.count("{"), text.count("}")))
|
||||
|
||||
JAVA.write_text(text, encoding="utf-8", newline="\n")
|
||||
print()
|
||||
print(" 大括号守恒 ✓({%d / }%d)" % (text.count("{"), text.count("}")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把中转服务器部署到 AWS Lightsail 那台新机器上。
|
||||
|
||||
== 为什么要搬 ==
|
||||
|
||||
原来中转跑在一台"随身 WiFi"小机器上(Debian aarch64、460MB 内存、
|
||||
走 4G),一直有一堆搞不定的问题:
|
||||
|
||||
* 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**
|
||||
—— 443 通、7844 不通,隧道每 3~10 分钟就掉一次,公网跟着 530
|
||||
(这个查了很久,cloudflared 自己的 precheck 才说清楚)
|
||||
* 只有 460MB 内存,跑什么都得省着来
|
||||
* 会掉电/硬挂(实测 18:42 一次非正常关机,19:58 才起来)
|
||||
* 全靠 Cloudflare 隧道兜,隧道一抖网站就抖
|
||||
|
||||
新机器(Lightsail)**有公网 IP**,直接监听就行 ——
|
||||
不需要隧道、不经过 Cloudflare 的 7844、内存是 4 倍、磁盘 59G。
|
||||
上面那些问题**一次性全部消失**。
|
||||
|
||||
== 关键配置 ==
|
||||
|
||||
RELAY_PORT=13245 用户指定(防火墙那边他会开)
|
||||
RELAY_BIND=0.0.0.0 有公网 IP,直接对外
|
||||
|
||||
13245 大于 1024,所以**不需要** CAP_NET_BIND_SERVICE,
|
||||
用普通用户跑就行 —— 比听 80 干净。
|
||||
|
||||
== 为什么不用 root 跑 ==
|
||||
|
||||
relay 是个只读文件的 HTTP 服务,没有任何理由给它 root。
|
||||
用 admin 用户跑,万一被打穿,损失也小得多。
|
||||
(2026-10-08 之前那个仓库里还硬编码过管理员密码,
|
||||
这种教训够一次就行了。)
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||
|
||||
# 用户指定的端口
|
||||
PORT = "13245"
|
||||
REMOTE_DIR = "/opt/ykxmz-relay"
|
||||
DATA_DIR = REMOTE_DIR + "/data" # relay.py 现在默认就用 data/ 了
|
||||
|
||||
# 服务端要传的文件(.py 是服务本体,.ico 是管理页图标)
|
||||
FILES = ["relay.py", "admin.py", "landing.py", "showcase.py", "favicon.ico"]
|
||||
|
||||
UNIT = '''[Unit]
|
||||
Description=ykxmz 中转服务器
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=admin
|
||||
Group=admin
|
||||
WorkingDirectory=%(dir)s
|
||||
|
||||
# ⚠ 监听地址和端口都从这里来。
|
||||
# 13245 是用户指定的(他会在 Lightsail 防火墙那边开)。
|
||||
# BIND 用 0.0.0.0 是因为新机器**有公网 IP**,不再需要
|
||||
# Cloudflare 隧道(原来那个隧道在这条线路上每 3~10 分钟掉一次,
|
||||
# 是"网站容易挂"的主因之一)。
|
||||
Environment=RELAY_PORT=%(port)s
|
||||
Environment=RELAY_BIND=0.0.0.0
|
||||
|
||||
ExecStart=/usr/bin/python3 %(dir)s/relay.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# 兜底:内存保护(这台有 2G,给 512M 足够且不会拖垮整机)
|
||||
MemoryMax=512M
|
||||
# 别让它写爆磁盘
|
||||
LimitNOFILE=4096
|
||||
|
||||
StandardOutput=append:%(dir)s/relay.log
|
||||
StandardError=append:%(dir)s/relay.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 部署到 Lightsail(LS_HOST),端口 %s" % PORT)
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 建目录 ──")
|
||||
out = run("sudo mkdir -p %s %s && sudo chown -R admin:admin %s && "
|
||||
"echo OK" % (REMOTE_DIR, DATA_DIR, REMOTE_DIR),
|
||||
timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 传服务端文件 ──")
|
||||
for name in FILES:
|
||||
local = ROOT / "relay" / name
|
||||
if not local.is_file():
|
||||
print(" [跳过] %s 不存在" % name)
|
||||
continue
|
||||
upload(str(local), "%s/%s" % (REMOTE_DIR, name))
|
||||
# 产品截图(官网要用)
|
||||
shots = ROOT / "relay" / "site_shots"
|
||||
if shots.is_dir():
|
||||
run("mkdir -p %s/site_shots" % REMOTE_DIR, timeout=60, quiet=True)
|
||||
for f in sorted(shots.iterdir()):
|
||||
if f.is_file():
|
||||
upload(str(f), "%s/site_shots/%s" % (REMOTE_DIR, f.name))
|
||||
else:
|
||||
print(" [警告] 没有 relay/site_shots,官网截图会 404")
|
||||
|
||||
print()
|
||||
print(" ── 3. 写 systemd unit ──")
|
||||
unit = UNIT % {"dir": REMOTE_DIR, "port": PORT}
|
||||
tmp = Path(tempfile.gettempdir()) / "ykxmz-relay.service"
|
||||
tmp.write_text(unit, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/ykxmz-relay.service")
|
||||
out = run("sudo mv /tmp/ykxmz-relay.service /etc/systemd/system/ && "
|
||||
"sudo systemctl daemon-reload && echo ' unit 已装'",
|
||||
timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 语法检查 + 启动 ──")
|
||||
out = run(
|
||||
"cd %s && for f in relay.py admin.py landing.py showcase.py; do "
|
||||
"python3 -c \"import ast,sys;ast.parse(open(sys.argv[1],"
|
||||
"encoding='utf-8').read())\" $f || exit 1; done && echo PY_OK"
|
||||
% REMOTE_DIR, timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
out = run("sudo systemctl enable ykxmz-relay >/dev/null 2>&1; "
|
||||
"sudo systemctl restart ykxmz-relay; sleep 5; "
|
||||
"systemctl is-active ykxmz-relay", timeout=180)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 自测 ──")
|
||||
out = run(
|
||||
"echo \" 本机 health: $(curl -s -o /dev/null -w '%%{http_code}' "
|
||||
"--max-time 8 http://127.0.0.1:%(p)s/v1/health)\"; "
|
||||
"echo \" 监听: $(ss -tlnp 2>/dev/null | grep %(p)s | head -1)\"; "
|
||||
"echo ' --- 启动日志 ---'; "
|
||||
"tail -12 %(d)s/relay.log 2>/dev/null"
|
||||
% {"p": PORT, "d": REMOTE_DIR}, timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 6. 管理密码(登录后台用)──")
|
||||
out = run("cat %s/data/admin_password.txt 2>/dev/null; echo; "
|
||||
"cat %s/data/admin_key.txt 2>/dev/null" % (DATA_DIR, DATA_DIR),
|
||||
timeout=60)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" 提示:防火墙要先放行 TCP %s(用户来开)。" % PORT)
|
||||
print(" 之后公网地址就是 http://LS_HOST:%s/" % PORT)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,285 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''在 Lightsail 上装好「监测单元 + 紧急预案」。
|
||||
|
||||
== 部署什么 ==
|
||||
|
||||
/opt/ykxmz-relay/monitor.sh 每 60 秒查一次公网
|
||||
/opt/ykxmz-relay/rescue-lightsail.sh 四级阶梯抢救
|
||||
/etc/systemd/system/ykxmz-monitor.service
|
||||
/etc/systemd/system/ykxmz-monitor.timer
|
||||
|
||||
== 和随身 WiFi 那版的区别 ==
|
||||
|
||||
那版围着 Cloudflare 隧道转:查隧道连接数、重启隧道、重施 MSS/MTU。
|
||||
新机器**有公网 IP、没有隧道**,所以:
|
||||
|
||||
* 健康检查超时从 12 秒降到 8 秒(直连,不需要等隧道)
|
||||
* 预案第 1 级改成"重启中转服务"(不再有隧道可重启)
|
||||
* 去掉 MTU/MSS 那两级(那是 4G 线路的问题)
|
||||
|
||||
== 为什么还是"每 60 秒 + 连续 3 次才动手" ==
|
||||
|
||||
直连比隧道稳得多,但**公网抖动**依然存在(上游线路、Lightsail 边缘)。
|
||||
一次失败就救会天天瞎折腾,所以保留防抖。
|
||||
现在预期是**基本不会触发** —— 触发了就说明真出事了。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
PORT = "13245"
|
||||
PUBLIC_URL = "http://%s:%s" % (LS_HOST, PORT)
|
||||
|
||||
MONITOR = r'''#!/bin/bash
|
||||
# ============================================================================
|
||||
# 网站监测单元(Lightsail 版)
|
||||
# ============================================================================
|
||||
#
|
||||
# 每 60 秒从**公网**查一次,连续 3 次不通就叫紧急预案。
|
||||
#
|
||||
# 为什么不查 127.0.0.1:
|
||||
# 本机 200 只说明进程活着。用户真正关心的是"打开
|
||||
# http://LS_HOST:13245 能不能出页面" —— 那要经过
|
||||
# Lightsail 的防火墙 / 安全组 / 上游线路。实测踩过:
|
||||
# 隧道两条实例都掉了,本机 curl 照样 200,但公网 502。
|
||||
#
|
||||
# 为什么用 timer 不用常驻进程:
|
||||
# 崩了下一分钟自己就回来,不会有"跑久了状态变脏"的问题。
|
||||
# ============================================================================
|
||||
|
||||
set -u
|
||||
|
||||
RELAY_DIR="${RELAY_DIR:-/opt/ykxmz-relay}"
|
||||
PUBLIC_URL="${PUBLIC_URL:-__PUBLIC_URL__}"
|
||||
|
||||
FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}"
|
||||
COOLDOWN="${COOLDOWN:-600}"
|
||||
CHECK_TIMEOUT="${CHECK_TIMEOUT:-8}"
|
||||
|
||||
STATE="$RELAY_DIR/monitor_state.txt"
|
||||
LOG="$RELAY_DIR/monitor.log"
|
||||
LOCKFILE="/tmp/ykxmz-monitor.lock"
|
||||
|
||||
if ! mkdir "$LOCKFILE" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT
|
||||
|
||||
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
|
||||
|
||||
FAILS=0
|
||||
LAST_RESCUE=0
|
||||
if [ -f "$STATE" ]; then
|
||||
FAILS=$(grep -m1 '^fails=' "$STATE" 2>/dev/null | cut -d= -f2)
|
||||
LAST_RESCUE=$(grep -m1 '^last_rescue=' "$STATE" 2>/dev/null | cut -d= -f2)
|
||||
FAILS=${FAILS:-0}
|
||||
LAST_RESCUE=${LAST_RESCUE:-0}
|
||||
fi
|
||||
NOW=$(date +%s)
|
||||
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CHECK_TIMEOUT" \
|
||||
"$PUBLIC_URL/v1/health" 2>/dev/null)
|
||||
DUR=$(curl -s -o /dev/null -w '%{time_total}' --max-time "$CHECK_TIMEOUT" \
|
||||
"$PUBLIC_URL/v1/health" 2>/dev/null)
|
||||
DUR=${DUR:-0}
|
||||
|
||||
write_state() {
|
||||
{
|
||||
echo "fails=$1"
|
||||
echo "last_rescue=$2"
|
||||
echo "last_code=$CODE"
|
||||
echo "last_check=$NOW"
|
||||
echo "last_dur=$DUR"
|
||||
} > "$STATE"
|
||||
}
|
||||
|
||||
if [ "$CODE" = "200" ]; then
|
||||
if [ "$FAILS" != "0" ]; then
|
||||
log "公网恢复(HTTP 200,${DUR}s)—— 之前连续失败 $FAILS 次"
|
||||
fi
|
||||
PREV=$(grep -m1 '^last_code=' "$STATE" 2>/dev/null | cut -d= -f2)
|
||||
[ "$PREV" != "200" ] && log "公网正常(HTTP 200,${DUR}s)"
|
||||
write_state 0 "$LAST_RESCUE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FAILS=$((FAILS + 1))
|
||||
log "公网异常 第 $FAILS 次(HTTP ${CODE:-无响应},${DUR}s)"
|
||||
write_state "$FAILS" "$LAST_RESCUE"
|
||||
|
||||
if [ "$FAILS" -lt "$FAIL_THRESHOLD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SINCE=$((NOW - LAST_RESCUE))
|
||||
if [ "$LAST_RESCUE" != "0" ] && [ "$SINCE" -lt "$COOLDOWN" ]; then
|
||||
log " 已连续失败 $FAILS 次,但距上次抢救只过了 ${SINCE}s(冷却 ${COOLDOWN}s)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HINT=""
|
||||
if [ -f "$RELAY_DIR/rescue_knowledge.md" ]; then
|
||||
HINT=$(grep -oE '第 [0-9] 级奏效' "$RELAY_DIR/rescue_knowledge.md" 2>/dev/null \
|
||||
| sort | uniq -c | sort -rn | head -1 | grep -oE '[0-9]')
|
||||
fi
|
||||
|
||||
log "连续失败 $FAILS 次,启动紧急预案${HINT:+(知识库提示:第 $HINT 级常奏效)}"
|
||||
write_state "$FAILS" "$NOW"
|
||||
|
||||
timeout 300 bash "$RELAY_DIR/rescue-lightsail.sh" \
|
||||
"监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1
|
||||
RC=$?
|
||||
|
||||
if [ "$RC" = "0" ]; then
|
||||
log " 预案完成:已恢复"
|
||||
else
|
||||
log " 预案完成:仍未恢复(退出码 $RC),报告在 rescue_reports/"
|
||||
fi
|
||||
exit 0
|
||||
'''
|
||||
|
||||
SERVICE = '''[Unit]
|
||||
# ykxmz 网站监测单元(Lightsail 版)
|
||||
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
|
||||
After=network-online.target ykxmz-relay.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
TimeoutStartSec=360
|
||||
|
||||
# 用普通用户跑 —— 它只读状态、调 systemctl restart,不需要 root。
|
||||
# (systemctl restart 那条走的是 polkit;admin 在 Lightsail 上有 sudo,
|
||||
# 所以脚本里用的是不带 sudo 的 systemctl —— 见下面的说明。)
|
||||
User=admin
|
||||
Group=admin
|
||||
|
||||
Environment=RELAY_PORT=__PORT__
|
||||
Environment=PUBLIC_URL=__PUBLIC_URL__
|
||||
Environment=RESCUE_REBOOT=0
|
||||
Environment=FAIL_THRESHOLD=3
|
||||
Environment=COOLDOWN=600
|
||||
|
||||
ExecStart=/bin/bash __DIR__/monitor.sh
|
||||
|
||||
StandardOutput=append:__DIR__/monitor.log
|
||||
StandardError=append:__DIR__/monitor.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
'''
|
||||
|
||||
TIMER = '''[Unit]
|
||||
Description=每 60 秒检查一次网站是否可用
|
||||
|
||||
[Timer]
|
||||
OnBootSec=45s
|
||||
OnUnitActiveSec=60s
|
||||
AccuracySec=5s
|
||||
Unit=ykxmz-monitor.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 在 Lightsail 上装监测单元 + 紧急预案")
|
||||
print("=" * 62)
|
||||
|
||||
monitor = (MONITOR.replace("__PUBLIC_URL__", PUBLIC_URL))
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
|
||||
print()
|
||||
print(" ── 1. 传脚本 ──")
|
||||
mp = tmp / "monitor.sh"
|
||||
mp.write_text(monitor, encoding="utf-8", newline="\n")
|
||||
upload(str(mp), "%s/monitor.sh" % RELAY_DIR, mode=0o755)
|
||||
|
||||
rp = ROOT / "relay" / "rescue-lightsail.sh"
|
||||
if rp.is_file():
|
||||
upload(str(rp), "%s/rescue-lightsail.sh" % RELAY_DIR, mode=0o755)
|
||||
else:
|
||||
print(" ✗ 找不到 relay/rescue-lightsail.sh")
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(" ── 2. 传 systemd 单元 ──")
|
||||
sp = tmp / "ykxmz-monitor.service"
|
||||
sp.write_text(SERVICE.replace("__PORT__", PORT)
|
||||
.replace("__PUBLIC_URL__", PUBLIC_URL)
|
||||
.replace("__DIR__", RELAY_DIR),
|
||||
encoding="utf-8", newline="\n")
|
||||
upload(str(sp), "/tmp/ykxmz-monitor.service")
|
||||
tp = tmp / "ykxmz-monitor.timer"
|
||||
tp.write_text(TIMER, encoding="utf-8", newline="\n")
|
||||
upload(str(tp), "/tmp/ykxmz-monitor.timer")
|
||||
|
||||
print()
|
||||
print(" ── 3. 装单元 + 建目录 ──")
|
||||
out = run(
|
||||
"sudo mv /tmp/ykxmz-monitor.service /tmp/ykxmz-monitor.timer "
|
||||
"/etc/systemd/system/ && "
|
||||
"mkdir -p %s/rescue_reports && chmod +x %s/*.sh && "
|
||||
"if [ ! -f %s/rescue_knowledge.md ]; then "
|
||||
"printf '# 紧急预案知识库\\n\\n每次自动抢救成功会记一条「第几级奏效」。"
|
||||
"\\n下次同类症状优先试历史上奏效的那一级。\\n' > %s/rescue_knowledge.md; "
|
||||
"fi && sudo systemctl daemon-reload && echo OK"
|
||||
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 启用定时器 ──")
|
||||
out = run("sudo systemctl enable --now ykxmz-monitor.timer 2>&1 | tail -2; "
|
||||
"sleep 2; systemctl is-active ykxmz-monitor.timer",
|
||||
timeout=180)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 手动跑一次监测(验证它能工作)──")
|
||||
out = run("bash %s/monitor.sh; echo '--- 状态 ---'; "
|
||||
"cat %s/monitor_state.txt 2>/dev/null; "
|
||||
"echo '--- 日志 ---'; tail -3 %s/monitor.log 2>/dev/null"
|
||||
% (RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 6. 演练预案(网站正常时应该什么都不做)──")
|
||||
out = run("bash %s/rescue-lightsail.sh '安装验证(网站正常)' 2>&1 | tail -6; "
|
||||
"echo '--- 报告数(应该是 0)---'; "
|
||||
"ls %s/rescue_reports 2>/dev/null | wc -l"
|
||||
% (RELAY_DIR, RELAY_DIR), timeout=180)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 7. 总览 ──")
|
||||
out = run(
|
||||
"printf ' %-26s %s\\n' 'ykxmz-relay' \"$(systemctl is-active ykxmz-relay)\"; "
|
||||
"printf ' %-26s %s\\n' 'ykxmz-monitor.timer' \"$(systemctl is-active ykxmz-monitor.timer)\"; "
|
||||
"printf ' %-26s %s\\n' '本机 health' \"$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:%s/v1/health)\"; "
|
||||
"printf ' %-26s %s\\n' '公网 health' \"$(curl -s -o /dev/null -w '%%{http_code}' %s/v1/health)\""
|
||||
% (PORT, PUBLIC_URL), timeout=180)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" 说明:预案里用 systemctl 重启服务,而 monitor.service 以 admin")
|
||||
print(" 身份跑 —— admin 在 Lightsail 上有免密 sudo,但脚本里没加")
|
||||
print(" sudo。如果实测发现重启失败,把它改成 sudo systemctl 即可。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把一个版本的代码和成品部署到 Lightsail。
|
||||
|
||||
用法:
|
||||
python _build/deploy_lightsail_release.py 2.1.3
|
||||
python _build/deploy_lightsail_release.py 2.1.3 --code-only
|
||||
python _build/deploy_lightsail_release.py 2.1.3 --files-only
|
||||
|
||||
== 为什么写成文件 ==
|
||||
|
||||
这一轮我用 `python -c @"..."@` 传带 `$(...)` 的远端命令,
|
||||
PowerShell 把 `$(systemctl ...)` 当成本机的子表达式执行了
|
||||
(报 "无法将 systemctl 项识别为 cmdlet"),还吃掉引号导致
|
||||
Python 语法错。凡是超过两行的远端命令,一律写成文件跑。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
PORT = "13245"
|
||||
PUBLIC = "http://%s:%s" % (LS_HOST, PORT)
|
||||
|
||||
CODE = ["relay.py", "admin.py", "landing.py", "showcase.py"]
|
||||
ARTIFACTS = ["ykxmz-setup-{v}.exe", "ykxmz-watch-{v}.apk",
|
||||
"ykxmz-watch-{v}-说明.txt", "ykxmz-server-{v}.zip"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = [a for a in sys.argv[1:] if a]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 1
|
||||
version = args[0]
|
||||
code_only = "--code-only" in args
|
||||
files_only = "--files-only" in args
|
||||
|
||||
print("=" * 62)
|
||||
print(" 部署 %s 到 Lightsail" % version)
|
||||
print("=" * 62)
|
||||
|
||||
if not code_only:
|
||||
print()
|
||||
print(" ── 1. 传成品到 downloads/ ──")
|
||||
run("mkdir -p %s/downloads" % RELAY_DIR, timeout=60, quiet=True)
|
||||
for tpl in ARTIFACTS:
|
||||
name = tpl.format(v=version)
|
||||
local = ROOT / "dist" / name
|
||||
if not local.is_file():
|
||||
print(" [跳过] %s 不存在" % name)
|
||||
continue
|
||||
upload(str(local), "%s/downloads/%s" % (RELAY_DIR, name))
|
||||
|
||||
if not files_only:
|
||||
print()
|
||||
print(" ── 2. 传服务端代码 ──")
|
||||
for name in CODE:
|
||||
local = ROOT / "relay" / name
|
||||
if not local.is_file():
|
||||
print(" [跳过] %s 不存在" % name)
|
||||
continue
|
||||
upload(str(local), "%s/%s" % (RELAY_DIR, name))
|
||||
|
||||
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"
|
||||
% RELAY_DIR, timeout=120, quiet=True)
|
||||
print(" " + out[1].strip().splitlines()[-1])
|
||||
|
||||
out = run("sudo systemctl restart ykxmz-relay; sleep 4; "
|
||||
"systemctl is-active ykxmz-relay", timeout=180, quiet=True)
|
||||
print(" relay 状态: " + out[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 4. 复核 ──")
|
||||
script = (
|
||||
'printf " %-18s %s\\n" 本机 "'
|
||||
'$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:' + PORT + '/v1/health)"\n'
|
||||
'printf " %-18s %s\\n" 公网 "'
|
||||
'$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||
+ PUBLIC + '/v1/health)"\n'
|
||||
'printf " %-18s %s\\n" 公网首页 "'
|
||||
'$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||
+ PUBLIC + '/)"\n'
|
||||
'printf " %-18s %s\\n" 公网延迟 "'
|
||||
'$(curl -s -o /dev/null -w \'%{time_total}s\' --max-time 15 '
|
||||
+ PUBLIC + '/v1/health)"\n'
|
||||
'printf " %-18s %s\\n" 角标 "'
|
||||
'$(curl -s --max-time 10 http://127.0.0.1:' + PORT
|
||||
+ '/ | grep -o "最新版 [0-9.]*" | head -1)"\n'
|
||||
'printf " %-18s %s\\n" 监听 "'
|
||||
'$(ss -tln 2>/dev/null | grep ":' + PORT + ' " | wc -l) 个"\n'
|
||||
'printf " %-18s %s\\n" 监测 "'
|
||||
'fails=$(grep -m1 ^fails= ' + RELAY_DIR
|
||||
+ '/monitor_state.txt 2>/dev/null | cut -d= -f2); '
|
||||
'echo ${fails:-?} 次连续失败"\n'
|
||||
'printf " %-18s %s\\n" 抢救报告 "'
|
||||
'$(ls ' + RELAY_DIR + '/rescue_reports 2>/dev/null | wc -l) 份"\n'
|
||||
)
|
||||
out = run(script, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" 公网地址:%s" % PUBLIC)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''看当前桌面/窗口的真实状态 —— 诊断"桌面消失、一片蓝"。
|
||||
|
||||
用户报:"我现在桌面消失了,电脑一片蓝,然后还有个蓝色的窗口"。
|
||||
|
||||
已知:
|
||||
18:42:41 机器异常关机(Kernel-Power 41 / EventLog 6008,无蓝屏记录)
|
||||
19:58:23 重新启动
|
||||
20:01:37 explorer 起来
|
||||
自启项里没有 ykxmz,当前也没有 ykxmz 在跑
|
||||
|
||||
要查的是:现在屏幕上到底有什么、是不是有全屏窗口盖住了桌面。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import subprocess
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
|
||||
EnumWindows = user32.EnumWindows
|
||||
EnumWindows.argtypes = [ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM),
|
||||
wt.LPARAM]
|
||||
|
||||
|
||||
class RECT(ctypes.Structure):
|
||||
_fields_ = [("left", wt.LONG), ("top", wt.LONG),
|
||||
("right", wt.LONG), ("bottom", wt.LONG)]
|
||||
|
||||
|
||||
def windows() -> list:
|
||||
out = []
|
||||
|
||||
def cb(hwnd, _lp):
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
title = ctypes.create_unicode_buffer(512)
|
||||
user32.GetWindowTextW(hwnd, title, 512)
|
||||
cls = ctypes.create_unicode_buffer(256)
|
||||
user32.GetClassNameW(hwnd, cls, 256)
|
||||
r = RECT()
|
||||
user32.GetWindowRect(hwnd, ctypes.byref(r))
|
||||
pid = wt.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
w, h = r.right - r.left, r.bottom - r.top
|
||||
if w > 80 and h > 80:
|
||||
out.append({"hwnd": hwnd, "title": title.value, "cls": cls.value,
|
||||
"pid": pid.value, "w": w, "h": h,
|
||||
"x": r.left, "y": r.top})
|
||||
return True
|
||||
|
||||
EnumWindows(ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)(cb), 0)
|
||||
return out
|
||||
|
||||
|
||||
def proc_name(pid: int) -> str:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"(Get-Process -Id %d -EA SilentlyContinue).ProcessName" % pid],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
return (r.stdout or "").strip() or "?"
|
||||
except Exception: # noqa: BLE001
|
||||
return "?"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sw = user32.GetSystemMetrics(0)
|
||||
sh = user32.GetSystemMetrics(1)
|
||||
print(" 屏幕: %d x %d" % (sw, sh))
|
||||
print(" 当前输入桌面: %s" % _desktop_name())
|
||||
print()
|
||||
|
||||
ws = windows()
|
||||
ws.sort(key=lambda w: w["w"] * w["h"], reverse=True)
|
||||
print(" 可见窗口 %d 个(按面积排序,前 20):" % len(ws))
|
||||
for w in ws[:20]:
|
||||
full = w["w"] >= sw - 4 and w["h"] >= sh - 4
|
||||
pn = proc_name(w["pid"])
|
||||
print(" %s %-14s pid=%-6d %dx%d @(%d,%d)"
|
||||
% ("★全屏" if full else " ", pn, w["pid"],
|
||||
w["w"], w["h"], w["x"], w["y"]))
|
||||
print(" 类=%-22s 标题=%r" % (w["cls"][:22], w["title"][:60]))
|
||||
print()
|
||||
fulls = [w for w in ws if w["w"] >= sw - 4 and w["h"] >= sh - 4]
|
||||
if fulls:
|
||||
print(" ⚠ 有 %d 个全屏窗口 —— 下面这些就是盖住桌面的嫌疑:" % len(fulls))
|
||||
for w in fulls:
|
||||
print(" pid=%d (%s) 类=%s 标题=%r"
|
||||
% (w["pid"], proc_name(w["pid"]), w["cls"], w["title"][:50]))
|
||||
else:
|
||||
print(" ✓ 没有全屏窗口盖着")
|
||||
print()
|
||||
ex = subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||
"Get-Process explorer -EA SilentlyContinue | "
|
||||
"Select-Object -First 1 -ExpandProperty Id"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
print(" explorer PID: %s" % ((ex.stdout or "").strip() or "**没在跑**"))
|
||||
return 0
|
||||
|
||||
|
||||
def _desktop_name() -> str:
|
||||
"""当前输入桌面的名字。Default = 正常,Winlogon = 锁屏。"""
|
||||
h = user32.OpenInputDesktop(0, False, 0x0001)
|
||||
if not h:
|
||||
return "(拿不到 —— 可能锁屏或没有交互桌面)"
|
||||
try:
|
||||
sb = ctypes.create_unicode_buffer(256)
|
||||
need = wt.DWORD()
|
||||
ok = user32.GetUserObjectInformationW(h, 2, sb, 512,
|
||||
ctypes.byref(need))
|
||||
return sb.value if ok else "(拿不到名字)"
|
||||
finally:
|
||||
user32.CloseDesktop(h)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
@@ -96,7 +96,7 @@ PY
|
||||
echo
|
||||
echo " 收尾:清掉负载测试造的设备和会话"
|
||||
curl -s -o /dev/null --max-time 8 -X POST "$BASE/v1/admin/forget" \
|
||||
-H 'X-Admin: 120830' -H 'Content-Type: application/json' \
|
||||
-H 'X-Admin: <改从环境变量读>' -H 'Content-Type: application/json' \
|
||||
-d '{"code":"112233"}'
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查:下午的中断 + 还有哪些没用的东西。
|
||||
|
||||
== 为什么要查"下午的中断" ==
|
||||
|
||||
用户:"下午网站有一段时间停了"。
|
||||
|
||||
已知的硬事实:
|
||||
* relay 进程从 10/9 15:32 起没重启过
|
||||
* nginx 从 10/8 14:57 起没重启过
|
||||
* 系统从 10/8 13:26 起没重启过
|
||||
* 今天 nginx 的 error.log **一条都没有**
|
||||
|
||||
所以**服务端本身没停**。那"停"是什么?几种可能:
|
||||
|
||||
A. **Cloudflare 侧的问题**(边缘节点、回源失败)——
|
||||
服务端日志里看不到,得从监控的探测记录里找
|
||||
B. **网络抖动**(Lightsail 出网、校园网)
|
||||
C. **用户那边打不开**(DNS 缓存、本地网络)
|
||||
|
||||
监控每 60 秒探测一次公网 —— **它的记录是判断这个最直接的证据**。
|
||||
先找到它写在哪。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 1. 监控脚本写在哪 ===="
|
||||
sudo systemctl cat ykxmz-monitor.service 2>/dev/null | sed 's/^/ /'
|
||||
echo
|
||||
echo " --- 脚本文件 ---"
|
||||
for f in /usr/local/bin/ykxmz-monitor.sh /root/ykxmz-monitor.sh /opt/ykxmz/monitor.sh; do
|
||||
[ -f "$f" ] && echo " 存在: $f" && sudo grep -nE "LOG|log|>>|fails|报告" "$f" | head -12 | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==== 2. 找监控的日志/状态文件 ===="
|
||||
sudo find /root /var/log /var/lib -maxdepth 3 -iname "*ykxmz*" -o -maxdepth 3 -iname "*monitor*" 2>/dev/null \
|
||||
| head -20 | while read f; do
|
||||
echo " $f ($(sudo stat -c '%y %s' "$f" 2>/dev/null | cut -c1-19))"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==== 3. 监控最近报了什么(含今天的失败)===="
|
||||
for f in /root/ykxmz-monitor.log /var/log/ykxmz-monitor.log /root/ykxmz-monitor.state /root/.ykxmz_monitor; do
|
||||
if sudo test -f "$f"; then
|
||||
echo " --- $f 尾部 ---"
|
||||
sudo tail -30 "$f" | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==== 4. 今天的 journal 里有没有网络/系统层面异常 ===="
|
||||
sudo journalctl --since "2026-10-10 12:00" --no-pager 2>/dev/null \
|
||||
| grep -iE "link|network|dhcp|dns|timeout|unreachable|oom|out of memory|error" \
|
||||
| grep -viE "audit|apparmor|snap" | tail -25 | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "==== 5. Cloudflare 边缘有没有回源失败(relay 日志里)===="
|
||||
sudo journalctl -u ykxmz-relay --since "2026-10-10 12:00" --no-pager 2>/dev/null \
|
||||
| grep -iE "error|fail|refus|timeout|reset|502|504|broken" | tail -20 | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "==== 6. relay 自己的访问日志(如果有)===="
|
||||
sudo find /root /var/log -maxdepth 2 -iname "*relay*log*" 2>/dev/null | head -5 | while read f; do
|
||||
echo " $f ($(sudo stat -c '%s' "$f") 字节)"
|
||||
sudo tail -10 "$f" | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==== 7. 还有哪些没用的东西 ===="
|
||||
echo " --- 老版本残留的 systemd 单元 ---"
|
||||
sudo ls -1 /etc/systemd/system/ 2>/dev/null | grep -iE "lanremote|ykxmz|cloudflare|tunnel" | sed 's/^/ /' || echo " (没有)"
|
||||
echo " --- /root 下 ---"
|
||||
sudo ls -la /root 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- 大文件(>50MB)---"
|
||||
sudo find /root /var/log /tmp -maxdepth 2 -type f -size +50M 2>/dev/null | head -10 | while read f; do
|
||||
echo " $(sudo du -h "$f" | cut -f1) $f"
|
||||
done
|
||||
echo " --- 无用的 docker 镜像 ---"
|
||||
sudo docker images 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- 无用的 docker 容器 ---"
|
||||
sudo docker ps -a --format ' {{.Names}} {{.Status}}' 2>/dev/null | sed 's/^/ /'
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 下午的中断 + 还有哪些没用的东西")
|
||||
print("=" * 62)
|
||||
print()
|
||||
out = run(REMOTE, timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''深挖三件事:
|
||||
|
||||
1. 那个 `listen 80 default_server; server_name _;` 是谁的配置
|
||||
2. 下午到底是什么时候断的(监控 / journal 能不能看出)
|
||||
3. 现在从公网看,服务是不是真的稳定
|
||||
|
||||
== 为什么盯着那个 default_server ==
|
||||
|
||||
nginx 里同一个端口**只能有一个 default_server**。如果有一个来路不明的
|
||||
`server_name _; listen 80 default_server;`:
|
||||
|
||||
* 用 IP 直接访问(`http://<服务器IP>/`)会落到**它**上面,
|
||||
而不是我们的站点 —— 这正好能解释"网站不稳定"的错觉
|
||||
(有时对有时不对,取决于走的是域名还是 IP)
|
||||
* 它也可能是 1Panel 之类装的,占了位置
|
||||
|
||||
== 为什么要查监控 ==
|
||||
|
||||
我们有个 `ykxmz-monitor.timer` 每 60 秒检查一次公网。
|
||||
如果下午真的断了,它的日志里应该有"探测失败"的记录 ——
|
||||
**那是判断"断了几分钟"最直接的证据**(比事后猜准得多)。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 1. default_server 是谁配的 ===="
|
||||
sudo grep -rln "default_server" /etc/nginx/ 2>/dev/null | while read f; do
|
||||
echo " 文件: $f"
|
||||
grep -n "default_server" "$f" | sed 's/^/ /'
|
||||
done
|
||||
echo
|
||||
echo " --- 那个文件完整内容(如果是 sites-enabled 里没链的,看 sites-available)---"
|
||||
sudo ls -la /etc/nginx/sites-enabled/ /etc/nginx/conf.d/ 2>/dev/null | sed 's/^/ /'
|
||||
echo
|
||||
echo " --- /etc/nginx/nginx.conf 里的 include ---"
|
||||
sudo grep -nE "include|default_server" /etc/nginx/nginx.conf 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 2. 用 IP 直接访问会到哪 ===="
|
||||
echo " http://127.0.0.1/ (不带 Host):"
|
||||
curl -s -o /dev/null -w " -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/ || true
|
||||
echo " http://127.0.0.1/ 带 Host: yk.xxmxxs.top:"
|
||||
curl -s -o /dev/null -w " -> %{http_code} %{size_download} 字节\n" --max-time 10 -H "Host: yk.xxmxxs.top" http://127.0.0.1/ || true
|
||||
|
||||
echo
|
||||
echo "==== 3. 监控日志:下午有没有探测失败 ===="
|
||||
echo " --- 今天的监控日志 ---"
|
||||
sudo journalctl -u ykxmz-monitor --since "today" --no-pager 2>/dev/null | tail -40 | sed 's/^/ /' || echo " (没有日志)"
|
||||
echo
|
||||
echo " --- 报告文件 ---"
|
||||
sudo ls -la /root/ykxmz-rescue* /var/log/ykxmz* 2>/dev/null | sed 's/^/ /' || echo " (没有报告文件)"
|
||||
|
||||
echo
|
||||
echo "==== 4. 系统有没有重启 / 服务有没有停过 ===="
|
||||
echo " --- 本次开机时间 ---"
|
||||
uptime -s | sed 's/^/ /'
|
||||
echo " --- nginx 进程启动时间 ---"
|
||||
ps -o pid,lstart,etimes -p $(pgrep -f "nginx: master" | head -1) 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- relay 进程启动时间 ---"
|
||||
ps -o pid,lstart,etimes -p 114212 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- journal 里 systemd 记录的重启 ---"
|
||||
sudo journalctl --since "today" --no-pager 2>/dev/null | grep -iE "Started|Stopped|Failed|killed|OOM" \
|
||||
| grep -iE "nginx|ykxmz" | tail -15 | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "==== 5. 磁盘 / inode ===="
|
||||
df -h / | sed 's/^/ /'
|
||||
df -i / | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 6. 公网自测(从服务器上打自己)===="
|
||||
for u in https://yk.xxmxxs.top/ https://yk.xxmxxs.top/v1/health http://<服务器IP>/; do
|
||||
printf ' %-42s ' "$u"
|
||||
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" --max-time 20 "$u" || echo "失败"
|
||||
done
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 深挖:default_server / 下午的中断 / 稳定性")
|
||||
print("=" * 62)
|
||||
print()
|
||||
out = run(REMOTE, timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查服务器的 DNS 为什么解析不了 yk.xxmxxs.top。
|
||||
|
||||
现象:服务器上 curl 报 "Could not resolve host",但
|
||||
加 --resolve 手动指定 Cloudflare 的边缘 IP 就 200。
|
||||
也就是**网络通、只有解析不行**。
|
||||
|
||||
监测单元查的就是这个域名,所以它必然一直失败
|
||||
-> 启动紧急预案 -> 瞎折腾。
|
||||
|
||||
要区分是哪种情况:
|
||||
A. 服务器的 DNS 服务器本身坏了 / 不响应
|
||||
B. DNS 服务器能通,但**不知道这个域名**(还没传播到这台机器用的解析器)
|
||||
C. systemd-resolved 的缓存或 stub 出问题
|
||||
D. /etc/hosts 或 nsswitch 配置把解析绕过去了
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
|
||||
REMOTE = r'''
|
||||
D=__DOMAIN__
|
||||
|
||||
echo "════ 1. /etc/resolv.conf ════"
|
||||
cat /etc/resolv.conf 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 2. 有没有 systemd-resolved ════"
|
||||
systemctl is-active systemd-resolved 2>&1 | sed 's/^/ systemd-resolved: /'
|
||||
resolvectl status 2>/dev/null | grep -E 'DNS Servers|DNS Domain|Current DNS|Fallback' | head -8 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 3. 拿几个公共 DNS 直接问(绕过本机配置)════"
|
||||
for ns in 1.1.1.1 8.8.8.8 9.9.9.9; do
|
||||
printf ' %-10s ' "$ns"
|
||||
if command -v dig >/dev/null 2>&1; then
|
||||
r=$(dig +short +time=4 +tries=1 @$ns A $D 2>/dev/null | head -3 | tr '\n' ' ')
|
||||
echo "${r:-(没答)}"
|
||||
else
|
||||
echo "(没装 dig)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════ 4. 本机默认解析 ════"
|
||||
printf ' %-26s ' "getent hosts"; getent hosts $D 2>/dev/null || echo "(空)"
|
||||
printf ' %-26s ' "解析一个已知域名"; getent hosts www.cloudflare.com 2>/dev/null | head -1 || echo "(也失败!说明 DNS 整体有问题)"
|
||||
printf ' %-26s ' "解析亚马逊自家域名"; getent hosts aws.amazon.com 2>/dev/null | head -1 || echo "(失败)"
|
||||
|
||||
echo
|
||||
echo "════ 5. nsswitch / hosts ════"
|
||||
grep -vE '^\s*#|^\s*$' /etc/nsswitch.conf 2>/dev/null | grep '^hosts' | sed 's/^/ /'
|
||||
echo " --- /etc/hosts 里有没有 yk.xxmxxs.top ---"
|
||||
grep -i "$D" /etc/hosts 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "════ 6. 用 python 解析(走的是同一套 nsswitch)════"
|
||||
python3 -c "
|
||||
import socket
|
||||
try:
|
||||
print(' ', socket.getaddrinfo('$D', 443)[:2])
|
||||
except Exception as e:
|
||||
print(' 失败:', e)
|
||||
" 2>&1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 7. 装个 dig(如果没装)════"
|
||||
command -v dig >/dev/null 2>&1 || {
|
||||
echo " 装 dnsutils…"
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dnsutils 2>&1 | tail -1
|
||||
}
|
||||
echo " dig: $(command -v dig || echo 没装)"
|
||||
|
||||
echo
|
||||
echo "════ 8. 再问一次(装完 dig 之后)════"
|
||||
for ns in 1.1.1.1 8.8.8.8; do
|
||||
printf ' %-10s ' "$ns"
|
||||
dig +short +time=5 +tries=2 @$ns A $D 2>/dev/null | head -3 | tr '\n' ' ' | sed 's/$/\n/'
|
||||
done
|
||||
echo " 默认解析:"
|
||||
dig +short +time=5 A $D 2>/dev/null | head -3 | sed 's/^/ /' || echo " (空)"
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 查服务器的 DNS")
|
||||
print("=" * 62)
|
||||
out = run(REMOTE.replace("__DOMAIN__", DOMAIN), timeout=600, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查服务器的几个问题:
|
||||
|
||||
1. 下午网站为什么停了一段时间
|
||||
2. 中转站里为什么还有"隧道"
|
||||
3. 9100 端口上是什么
|
||||
4. 现在到底有哪些服务在跑、有没有没用的
|
||||
|
||||
== 背景 ==
|
||||
|
||||
用户反馈:
|
||||
* "下午网站有一段时间停了"
|
||||
* "为什么中转站里还有隧道,所有没的东西就删了"
|
||||
* "网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100,中转站也一样"
|
||||
* "我感觉网站还是不稳定"
|
||||
|
||||
"隧道"最可能是**早年那套 Cloudflare Tunnel(cloudflared)**的残留 ——
|
||||
那时候用 `relay.xxmxxs.top` 走隧道,后来换成 nginx + Let's Encrypt
|
||||
直连(`yk.xxmxxs.top`),隧道应该已经没用了。
|
||||
|
||||
如果 cloudflared 还在跑,它可能:
|
||||
* 占着资源
|
||||
* 隔一段时间重连,把日志刷满
|
||||
* **跟 nginx 抢同一个域名的流量** —— 这正是"不稳定"的常见原因
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 1. 所有跟本项目相关的 systemd 单元 ===="
|
||||
systemctl list-units --all --no-pager --plain 2>/dev/null \
|
||||
| grep -iE 'ykxmz|lanremote|cloudflare|cloudflared|tunnel|relay' \
|
||||
| sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "==== 2. 隧道进程 / 容器 ===="
|
||||
ps -eo pid,etimes,comm,args 2>/dev/null | grep -iE 'cloudflared|frpc|ngrok|tunnel' \
|
||||
| grep -v grep | sed 's/^/ /' || echo " (没有隧道进程)"
|
||||
echo " --- docker ---"
|
||||
sudo docker ps -a --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " (docker 不可用)"
|
||||
|
||||
echo
|
||||
echo "==== 3. 监听端口 ===="
|
||||
sudo ss -tlnp 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 4. 9100 端口是什么 ===="
|
||||
sudo ss -tlnp 2>/dev/null | grep 9100 | sed 's/^/ /' || echo " 9100 上没有东西"
|
||||
echo " 9100 本地能不能连:"
|
||||
curl -s -o /dev/null -w " http://127.0.0.1:9100/ -> %{http_code}\n" --max-time 8 http://127.0.0.1:9100/ 2>&1 || true
|
||||
|
||||
echo
|
||||
echo "==== 5. nginx 站点 ===="
|
||||
sudo ls -1 /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- server_name 和 listen ---"
|
||||
sudo grep -hE 'server_name|listen' /etc/nginx/sites-available/* 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 6. 服务状态 ===="
|
||||
for s in nginx ykxmz-relay ykxmz-monitor.timer certbot.timer lanremote-tunnel lanremote-relay cloudflared; do
|
||||
st=$(systemctl is-active $s 2>/dev/null)
|
||||
[ -n "$st" ] && printf ' %-26s %s\n' "$s" "$st"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==== 7. 下午有没有异常(journal 里 nginx / relay 的错误)===="
|
||||
echo " --- nginx 今天的 error.log 尾部 ---"
|
||||
sudo tail -25 /var/log/nginx/error.log 2>/dev/null | sed 's/^/ /' || echo " (读不到)"
|
||||
echo " --- ykxmz-relay 最近的重启 ---"
|
||||
sudo journalctl -u ykxmz-relay --since "today" --no-pager 2>/dev/null \
|
||||
| grep -iE 'start|stop|fail|error|restart' | tail -12 | sed 's/^/ /' || echo " (读不到)"
|
||||
|
||||
echo
|
||||
echo "==== 8. 内存 / 负载 ===="
|
||||
free -m | sed 's/^/ /'
|
||||
uptime | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 9. 有没有没用的旧目录 ===="
|
||||
for d in /root/lanremote-relay /root/lanremote /opt/lanremote /root/.cloudflared /etc/cloudflared; do
|
||||
[ -e "$d" ] && echo " 存在: $d ($(sudo du -sh $d 2>/dev/null | cut -f1))"
|
||||
done
|
||||
echo " --- /root 下的目录 ---"
|
||||
sudo ls -1 /root 2>/dev/null | head -20 | sed 's/^/ /'
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 服务器诊断:隧道 / 9100 / 下午的中断")
|
||||
print("=" * 62)
|
||||
print()
|
||||
out = run(REMOTE, timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查服务器为什么访问不了自己的域名(curl 返回 000)。
|
||||
|
||||
== 现象 ==
|
||||
|
||||
从我的电脑 -> https://yk.xxmxxs.top = 200 ✅
|
||||
从服务器自己-> https://yk.xxmxxs.top = 000 ❌
|
||||
|
||||
监测单元就跑在服务器上、查的就是这个域名,所以它一直判失败
|
||||
-> fails 一直涨 -> 会启动紧急预案 -> 瞎折腾。
|
||||
|
||||
== 最可能的原因:IPv6 ==
|
||||
|
||||
yk.xxmxxs.top 在 Cloudflare 上有 **AAAA 记录**:
|
||||
2606:4700:3036::6815:362d
|
||||
2606:4700:3036::ac43:df96
|
||||
|
||||
Lightsail 的实例默认**没有 IPv6 地址**。curl 会先试 IPv6、失败,
|
||||
如果不自动回退到 IPv4,就直接报连接失败(=000)。
|
||||
|
||||
验证办法很简单:`curl -4`(强制 IPv4)通不通。
|
||||
|
||||
== 其他可能 ==
|
||||
|
||||
* DNS 在服务器上解析不出来(getent hosts 看看)
|
||||
* Cloudflare 拒绝来自源站自己 IP 的请求(回环防护)——不太可能
|
||||
* 出站被挡(Lightsail 默认出站全开,概率低)
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, HOST as LS_HOST # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
|
||||
REMOTE = r'''
|
||||
D=__DOMAIN__
|
||||
|
||||
echo "════ 1. 服务器有没有 IPv6 ════"
|
||||
ip -brief addr show 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- IPv6 默认路由 ---"
|
||||
ip -6 route show default 2>/dev/null | sed 's/^/ /' || echo " (没有 IPv6 默认路由)"
|
||||
|
||||
echo
|
||||
echo "════ 2. 域名解析(IPv4 / IPv6 分开看)════"
|
||||
echo " A 记录:"
|
||||
getent ahostsv4 $D 2>/dev/null | awk '{print " " $1}' | sort -u
|
||||
echo " AAAA 记录:"
|
||||
getent ahostsv6 $D 2>/dev/null | awk '{print " " $1}' | sort -u
|
||||
echo " --- dig 结果 ---"
|
||||
dig +short A $D 2>/dev/null | sed 's/^/ A: /'
|
||||
dig +short AAAA $D 2>/dev/null | sed 's/^/ AAAA: /'
|
||||
|
||||
echo
|
||||
echo "════ 3. curl 各种姿势 ════"
|
||||
printf ' %-30s %s\n' "默认(可能走 IPv6)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://$D/v1/health)"
|
||||
printf ' %-30s %s\n' "强制 IPv4 (-4)" "$(curl -s4 -o /dev/null -w '%{http_code}' --max-time 15 https://$D/v1/health)"
|
||||
printf ' %-30s %s\n' "强制 IPv6 (-6)" "$(curl -s6 -o /dev/null -w '%{http_code}' --max-time 10 https://$D/v1/health)"
|
||||
printf ' %-30s %s\n' "http 强制 IPv4" "$(curl -s4L -o /dev/null -w '%{http_code}' --max-time 15 http://$D/v1/health)"
|
||||
|
||||
echo
|
||||
echo " --- 详细报错(默认那条)---"
|
||||
curl -s -o /dev/null -w ' %{http_code}\n' --max-time 10 https://$D/v1/health 2>&1 || true
|
||||
curl -v -o /dev/null --max-time 12 https://$D/v1/health 2>&1 | grep -iE 'trying|connected|refused|timed|error|Could not' | head -10 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 4. 直连 Cloudflare 边缘 IP(跳过 DNS)════"
|
||||
for ip in 104.21.54.45 172.67.223.150; do
|
||||
printf ' %-20s %s\n' "$ip (HTTPS)" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 12 --resolve $D:443:$ip https://$D/v1/health)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════ 5. 出站到 Cloudflare 通不通(纯 TCP)════"
|
||||
for ip in 104.21.54.45 172.67.223.150; do
|
||||
if timeout 6 bash -c "</dev/tcp/$ip/443" 2>/dev/null; then
|
||||
echo " $ip:443 通"
|
||||
else
|
||||
echo " $ip:443 不通"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════ 6. 对比:直连自己的 nginx ════"
|
||||
printf ' %-30s %s\n' "127.0.0.1:443(带 SNI)" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:127.0.0.1 https://$D/v1/health)"
|
||||
printf ' %-30s %s\n' "公网 IP:443" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:LS_HOST https://$D/v1/health)"
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 查服务器为什么访问不了自己的域名")
|
||||
print("=" * 62)
|
||||
out = run(REMOTE.replace("__DOMAIN__", DOMAIN), timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,765 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''ykxmz 诊断工具 —— 在出问题的机器上跑一遍,把所有可疑点问清楚。
|
||||
|
||||
== 为什么做这个 ==
|
||||
|
||||
用户反馈「Win7 上语音播报不能用」。但远程猜不出来 —— 可能是:
|
||||
|
||||
* 没装任何 TTS 语音(精简版系统常见)
|
||||
* 没有音频设备 / 音频服务没启动(SAPI 会**静默失败**,退出码还是 0)
|
||||
* cscript / powershell 被删了
|
||||
* 权限问题(写不了临时文件)
|
||||
* 或者是别的完全没想到的原因
|
||||
|
||||
**猜来猜去不如让它自己说。** 这个程序把每一项都试一遍,
|
||||
输出一段文本,用户复制回来,我照着改。
|
||||
|
||||
== 设计原则 ==
|
||||
|
||||
1. **绝不改动机器上的任何东西** —— 只读 + 在 %TEMP% 里写临时文件(跑完删掉)
|
||||
2. **每一项都要有明确结论**(可用/不可用 + 为什么),不是只打印一堆原始数据
|
||||
3. **每一项都单独 try** —— 某一项崩了不能影响后面的
|
||||
4. **输出要能复制** —— 同时打印到屏幕 + 写到桌面上的 .txt
|
||||
5. **32 位 + Python 3.8** —— 和主程序一样,保证 Win7 能跑
|
||||
|
||||
== 探测什么 ==
|
||||
|
||||
一、系统环境 版本/位数/代码页/管理员/临时目录可写
|
||||
二、语音能力 ★ 重点:5 种方案逐个试 + 语音列表 + 音频设备 + 音频服务
|
||||
三、外部程序 cscript / wscript / powershell / cmd 在不在、什么版本
|
||||
四、.NET 环境 System.Speech 依赖它
|
||||
五、屏幕和输入 分辨率 / DPI / 能否截屏 / 能否注入鼠标
|
||||
六、网络 能不能连上中转站
|
||||
七、主程序状态 装在哪、数据目录、版本、有没有在跑
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
OUT: list = []
|
||||
REPORT_PATH = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 输出
|
||||
def _console_encode(text: str) -> bytes:
|
||||
r'''把要打印的文字编码成**控制台能正确显示**的字节。
|
||||
|
||||
== 为什么不用 UTF-8 ==
|
||||
|
||||
第一版我强行 `chcp 65001` + `sys.stdout.reconfigure(encoding="utf-8")`,
|
||||
结果在 Win7 上**满屏问号**。
|
||||
|
||||
原因有两层:
|
||||
1. Win7 的 cmd 即使切到 65001,**默认点阵字体也不含中文字形**
|
||||
-> 显示成方块或问号
|
||||
2. 用户看到的 `???` 是字体层的问题,不是编码层的
|
||||
|
||||
**最稳的做法**:按**系统 ANSI 代码页**(中文 Windows = GBK/936)输出。
|
||||
这正是 cmd 默认期望的编码,配合默认字体一定能显示。
|
||||
'''
|
||||
for enc in ("mbcs", "gbk", "utf-8"):
|
||||
try:
|
||||
return text.encode(enc, errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return text.encode("ascii", errors="replace")
|
||||
|
||||
|
||||
def say(line: str = "") -> None:
|
||||
"""同时打印到屏幕(本机代码页)和报告(UTF-8)。"""
|
||||
try:
|
||||
sys.stdout.buffer.write(_console_encode(line) + b"\r\n")
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
print(line, flush=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
OUT.append(line)
|
||||
|
||||
|
||||
def head(title: str) -> None:
|
||||
say()
|
||||
say("=" * 64)
|
||||
say(" " + title)
|
||||
say("=" * 64)
|
||||
|
||||
|
||||
def item(name: str, ok, detail: str = "") -> None:
|
||||
"""一行结论。ok 可以是 True/False/None(None = 只是信息)。"""
|
||||
if ok is True:
|
||||
mark = "[ 可用 ]"
|
||||
elif ok is False:
|
||||
mark = "[不可用]"
|
||||
else:
|
||||
mark = "[ 信息 ]"
|
||||
say(" %s %-34s %s" % (mark, name, detail))
|
||||
|
||||
|
||||
def safe(fn, *a, **kw):
|
||||
"""跑一个探测,出错不让它影响后面的。"""
|
||||
try:
|
||||
return fn(*a, **kw)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "【探测出错】%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
def run(cmd, timeout=30, encoding=None):
|
||||
"""跑命令,返回 (退出码, 输出)。异常都吃掉,转成文字。"""
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=NO_WINDOW,
|
||||
encoding=encoding or "mbcs", errors="replace")
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
return p.returncode, out
|
||||
except FileNotFoundError:
|
||||
return -1, "(找不到这个程序)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "(超时 %d 秒)" % timeout
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return -3, "%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
# ================================================================ 一、系统
|
||||
def probe_system() -> None:
|
||||
head("一、系统环境")
|
||||
|
||||
try:
|
||||
v = sys.getwindowsversion()
|
||||
item("Windows 版本", True, "%d.%d (build %d)" % (v.major, v.minor, v.build))
|
||||
# Win7 = 6.1
|
||||
if (v.major, v.minor) == (6, 1):
|
||||
say(" ^ 这是 Windows 7")
|
||||
elif (v.major, v.minor) == (6, 0):
|
||||
say(" ^ 这是 Windows Vista")
|
||||
except Exception: # noqa: BLE001
|
||||
item("Windows 版本", None, "取不到")
|
||||
|
||||
item("Python 位数", True, "%d 位" % (64 if sys.maxsize > 2 ** 32 else 32))
|
||||
item("Python 版本", True, sys.version.split()[0])
|
||||
item("进程是否 64 位", True,
|
||||
"是" if ctypes.sizeof(ctypes.c_void_p) == 8 else "否(32 位进程)")
|
||||
|
||||
# 管理员?
|
||||
try:
|
||||
admin = bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception: # noqa: BLE001
|
||||
admin = None
|
||||
item("管理员权限", admin, "有" if admin else "没有(普通用户)")
|
||||
|
||||
# 代码页 —— 中文乱码问题跟它有关
|
||||
try:
|
||||
cp = ctypes.windll.kernel32.GetACP()
|
||||
item("系统 ANSI 代码页", True, "%d(%s)" % (cp, "中文 GBK" if cp == 936 else "其它"))
|
||||
except Exception: # noqa: BLE001
|
||||
item("系统 ANSI 代码页", None, "取不到")
|
||||
|
||||
# 临时目录能不能写
|
||||
try:
|
||||
t = Path(tempfile.gettempdir())
|
||||
p = t / "_ykxmz_diag_test.txt"
|
||||
p.write_text("ok", encoding="utf-8")
|
||||
p.unlink()
|
||||
item("临时目录可写", True, str(t))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
item("临时目录可写", False, "%s: %s" % (type(exc).__name__, exc))
|
||||
|
||||
item("程序自身路径", None, sys.executable)
|
||||
item("当前工作目录", None, os.getcwd())
|
||||
|
||||
|
||||
# ================================================================ 二、语音
|
||||
VBS_TEMPLATE = (
|
||||
'Dim v\r\n'
|
||||
'Set v = CreateObject("SAPI.SpVoice")\r\n'
|
||||
'v.Speak "{text}"\r\n'
|
||||
)
|
||||
|
||||
|
||||
def probe_tts() -> None:
|
||||
head("二、语音播报能力(重点)")
|
||||
|
||||
TEXT = "语音测试一二三"
|
||||
|
||||
# ---- 2.1 外部程序在不在 ----
|
||||
say()
|
||||
say(" --- 2.1 依赖的外部程序 ---")
|
||||
sysroot = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
for name in ("cscript", "wscript", "powershell", "cmd"):
|
||||
found = shutil.which(name)
|
||||
# 再按绝对路径找一遍(PATH 里没有时)
|
||||
abs_guess = {
|
||||
"cscript": sysroot + r"\System32\cscript.exe",
|
||||
"wscript": sysroot + r"\System32\wscript.exe",
|
||||
"powershell": sysroot + r"\System32\WindowsPowerShell\v1.0\powershell.exe",
|
||||
"cmd": sysroot + r"\System32\cmd.exe",
|
||||
}.get(name, "")
|
||||
abs_ok = bool(abs_guess) and Path(abs_guess).is_file()
|
||||
if found:
|
||||
item(name, True, "PATH 里有: %s" % found)
|
||||
elif abs_ok:
|
||||
item(name, True, "PATH 里没有,但绝对路径有: %s" % abs_guess)
|
||||
else:
|
||||
item(name, False, "PATH 和绝对路径都找不到")
|
||||
|
||||
# powershell 版本
|
||||
ps = shutil.which("powershell") or (
|
||||
sysroot + r"\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
if Path(ps).is_file():
|
||||
code, out = run([ps, "-NoProfile", "-Command",
|
||||
"$PSVersionTable.PSVersion.Major"], timeout=20)
|
||||
item("PowerShell 主版本", code == 0, out.strip() or "查不到")
|
||||
code, out = run([ps, "-NoProfile", "-Command",
|
||||
"[System.Environment]::Version.ToString()"], timeout=20)
|
||||
item(".NET CLR 版本", code == 0, out.strip() or "查不到")
|
||||
|
||||
# ---- 2.2 音频设备和音频服务 ----
|
||||
say()
|
||||
say(" --- 2.2 音频环境(SAPI 没声音的头号嫌疑)---")
|
||||
code, out = run(["sc", "query", "Audiosrv"], timeout=20)
|
||||
running = "RUNNING" in (out or "").upper()
|
||||
item("音频服务 Audiosrv", running,
|
||||
"正在运行" if running else "没在运行(没声音多半是它)")
|
||||
|
||||
# ⚠ 第一版这里用了 `Get-CimInstance` —— 那是 **PowerShell 3.0+** 的 cmdlet。
|
||||
# Win7 自带 **PowerShell 2.0**,直接报
|
||||
# "The term 'Get-CimInstance' is not recognized"。
|
||||
# 改用 **`Get-WmiObject`**(PS 2.0 就有,3.0+ 也还在)。
|
||||
say(" (以下用 Get-WmiObject / wmic,兼容 PowerShell 2.0)")
|
||||
if Path(ps).is_file():
|
||||
code, out = run([
|
||||
ps, "-NoProfile", "-Command",
|
||||
"Get-WmiObject Win32_SoundDevice | "
|
||||
"ForEach-Object { $_.Name + ' | ' + $_.Status }"], timeout=30)
|
||||
lines = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
||||
bad = [x for x in lines
|
||||
if "not recognized" in x or "CategoryInfo" in x
|
||||
or "At line" in x or "FullyQualified" in x]
|
||||
if bad:
|
||||
item("音频输出设备(WMI)", False, bad[0][:90])
|
||||
elif lines:
|
||||
item("音频输出设备(WMI)", True, "共 %d 个" % len(lines))
|
||||
for x in lines[:5]:
|
||||
say(" - " + x)
|
||||
else:
|
||||
item("音频输出设备(WMI)", False,
|
||||
"**一个都没有** —— 虚拟机常见(没加声卡),SAPI 会静默失败")
|
||||
|
||||
# 再用 wmic 独立验一遍(完全不依赖 PowerShell)
|
||||
code, out = run(["wmic", "sounddev", "get", "Name,Status"],
|
||||
timeout=30, encoding="mbcs")
|
||||
if code == 0:
|
||||
rows = [x.strip() for x in (out or "").splitlines()
|
||||
if x.strip() and x.strip().lower() not in ("name status",
|
||||
"name", "status")]
|
||||
item("音频输出设备(wmic)", bool(rows),
|
||||
("共 %d 个: %s" % (len(rows), " / ".join(rows[:3]))) if rows
|
||||
else "**一个都没有**")
|
||||
|
||||
# SAPI 自己怎么看音频输出
|
||||
if Path(ps).is_file():
|
||||
code, out = run([
|
||||
ps, "-NoProfile", "-Command",
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"try { $v.AudioOutput.GetDescription() } "
|
||||
"catch { 'ERR: ' + $_.Exception.Message }"], timeout=30)
|
||||
txt = (out or "").strip()
|
||||
item("SAPI 默认音频输出", code == 0 and not txt.startswith("ERR"),
|
||||
txt[:100] or "拿不到")
|
||||
|
||||
# ---- 2.3 SAPI 语音列表 ----
|
||||
say()
|
||||
say(" --- 2.3 装了哪些语音 ---")
|
||||
voices_ps = []
|
||||
if Path(ps).is_file():
|
||||
code, out = run([
|
||||
ps, "-NoProfile", "-Command",
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"],
|
||||
timeout=25)
|
||||
voices_ps = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
||||
if code == 0 and voices_ps:
|
||||
item("SAPI5 语音(COM 方式)", True, "共 %d 个" % len(voices_ps))
|
||||
for v in voices_ps[:10]:
|
||||
say(" - " + v)
|
||||
else:
|
||||
item("SAPI5 语音(COM 方式)", False,
|
||||
"列不出来 (退出码 %s) %s" % (code, out[:80]))
|
||||
|
||||
# System.Speech 那条路
|
||||
if Path(ps).is_file():
|
||||
code, out = run([
|
||||
ps, "-NoProfile", "-Command",
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"],
|
||||
timeout=30)
|
||||
names = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
||||
item("System.Speech 语音", code == 0 and bool(names),
|
||||
"共 %d 个: %s" % (len(names), "、".join(names[:5]))
|
||||
if names else "列不出来 (退出码 %s) %s" % (code, (out or "")[:80]))
|
||||
|
||||
# ---- 2.4 五种方案逐个试 ----
|
||||
say()
|
||||
say(" --- 2.4 五种发声方案逐个试 ---")
|
||||
say(" (注意:能出声才算数。这里只能看退出码 + 耗时)")
|
||||
|
||||
vbs = Path(tempfile.gettempdir()) / "_ykxmz_diag_say.vbs"
|
||||
try:
|
||||
vbs.write_bytes(
|
||||
(VBS_TEMPLATE.replace("{text}", TEXT)).encode("mbcs",
|
||||
errors="replace"))
|
||||
vbs_ok = True
|
||||
item("写 VBS 文件", True, "%s (%d 字节)" % (vbs, vbs.stat().st_size))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
vbs_ok = False
|
||||
item("写 VBS 文件", False, "%s: %s" % (type(exc).__name__, exc))
|
||||
|
||||
attempts = []
|
||||
if vbs_ok:
|
||||
attempts.append(("1. cscript + VBScript",
|
||||
["cscript", "//nologo", str(vbs)]))
|
||||
attempts.append(("2. wscript + VBScript",
|
||||
["wscript", "//nologo", str(vbs)]))
|
||||
if Path(ps).is_file():
|
||||
attempts.append(("3. powershell + SAPI COM", [
|
||||
ps, "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
"$v = New-Object -ComObject SAPI.SpVoice; $v.Speak('%s')" % TEXT]))
|
||||
attempts.append(("4. powershell + System.Speech", [
|
||||
ps, "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT]))
|
||||
|
||||
for label, cmd in attempts:
|
||||
exe = cmd[0]
|
||||
# 用绝对路径兜底
|
||||
if not shutil.which(exe):
|
||||
cand = {
|
||||
"cscript": sysroot + r"\System32\cscript.exe",
|
||||
"wscript": sysroot + r"\System32\wscript.exe",
|
||||
}.get(exe)
|
||||
if cand and Path(cand).is_file():
|
||||
cmd = [cand] + list(cmd[1:])
|
||||
exe = cand
|
||||
t0 = time.perf_counter()
|
||||
code, out = run(cmd, timeout=60)
|
||||
secs = time.perf_counter() - t0
|
||||
ok = code == 0
|
||||
item(label, ok, "退出码 %s,耗时 %.1fs%s"
|
||||
% (code, secs, (" " + out[:70]) if out else ""))
|
||||
|
||||
# 清理
|
||||
try:
|
||||
if vbs.exists():
|
||||
vbs.unlink()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
say()
|
||||
say(" ⚠ 如果上面全部「可用」但实际没声音,问题在音频侧:")
|
||||
say(" * 音频服务没运行")
|
||||
say(" * 没有音频输出设备(耳机孔也没插)")
|
||||
say(" * 或者装了语音但系统音量/静音设置有问题")
|
||||
|
||||
|
||||
# ================================================================ 三、屏幕输入
|
||||
def probe_ui() -> None:
|
||||
head("三、屏幕和输入")
|
||||
|
||||
if not IS_WINDOWS:
|
||||
say(" 非 Windows,跳过")
|
||||
return
|
||||
u = ctypes.windll.user32
|
||||
|
||||
try:
|
||||
w = u.GetSystemMetrics(0)
|
||||
h = u.GetSystemMetrics(1)
|
||||
item("主屏分辨率", True, "%dx%d" % (w, h))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
item("主屏分辨率", False, str(exc))
|
||||
|
||||
try:
|
||||
vw = u.GetSystemMetrics(78) # SM_CXVIRTUALSCREEN
|
||||
vh = u.GetSystemMetrics(79)
|
||||
item("虚拟屏尺寸", True, "%dx%d(多显示器会更大)" % (vw, vh))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# DPI
|
||||
try:
|
||||
dc = u.GetDC(0)
|
||||
dpi = ctypes.windll.gdi32.GetDeviceCaps(dc, 88) # LOGPIXELSX
|
||||
u.ReleaseDC(0, dc)
|
||||
item("DPI", True, "%d(100%% 是 96)" % dpi)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 截屏能力
|
||||
try:
|
||||
gdi = ctypes.windll.gdi32
|
||||
dc = u.GetDC(0)
|
||||
mem = gdi.CreateCompatibleDC(dc)
|
||||
bmp = gdi.CreateCompatibleBitmap(dc, 100, 100)
|
||||
gdi.SelectObject(mem, bmp)
|
||||
ok = bool(gdi.BitBlt(mem, 0, 0, 100, 100, dc, 0, 0, 0x00CC0020))
|
||||
gdi.DeleteObject(bmp)
|
||||
gdi.DeleteDC(mem)
|
||||
u.ReleaseDC(0, dc)
|
||||
item("能否截屏(BitBlt)", ok, "成功" if ok else "失败")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
item("能否截屏(BitBlt)", False, "%s: %s" % (type(exc).__name__, exc))
|
||||
|
||||
# 输入桌面是不是锁屏
|
||||
try:
|
||||
h = u.OpenInputDesktop(0, False, 0x0001)
|
||||
name = ""
|
||||
if h:
|
||||
buf = ctypes.create_unicode_buffer(256)
|
||||
need = ctypes.c_int(0)
|
||||
u.GetUserObjectInformationW(h, 2, buf, 512, ctypes.byref(need))
|
||||
name = buf.value
|
||||
u.CloseDesktop(h)
|
||||
item("当前输入桌面", name != "Winlogon",
|
||||
"%s%s" % (name or "(拿不到)",
|
||||
"(锁屏中)" if name == "Winlogon" else ""))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 能不能注入鼠标(只测 API 在不在,不动鼠标)
|
||||
try:
|
||||
ctypes.windll.user32.SendInput
|
||||
item("SendInput 可用", True, "函数在")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
item("SendInput 可用", False, str(exc))
|
||||
|
||||
|
||||
# ================================================================ 四、网络
|
||||
def probe_net() -> None:
|
||||
head("四、网络")
|
||||
|
||||
import socket
|
||||
for host, port in (("yk.xxmxxs.top", 443), ("1.1.1.1", 53)):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
s = socket.create_connection((host, port), timeout=10)
|
||||
s.close()
|
||||
item("连 %s:%d" % (host, port), True,
|
||||
"通,%.2fs" % (time.perf_counter() - t0))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
item("连 %s:%d" % (host, port), False,
|
||||
"%s(%.2fs)" % (type(exc).__name__,
|
||||
time.perf_counter() - t0))
|
||||
|
||||
# 本机 IP
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("1.1.1.1", 80))
|
||||
item("本机局域网 IP", True, s.getsockname()[0])
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================ 五、环境与工具链
|
||||
def probe_env() -> None:
|
||||
r'''采集**环境与工具链**信息 —— 这是决定"我能怎么写代码"的依据。
|
||||
|
||||
== 为什么要这一段 ==
|
||||
|
||||
用户要的是:**搞清楚这台 Win7 上有哪些工具/组件可用**,
|
||||
好在写兼容代码时不踩空。所以这一段不谈某个具体软件,
|
||||
只回答"这台机器**能提供什么**"。
|
||||
|
||||
关注点:
|
||||
* 系统到底哪个版本、什么 SP、什么架构
|
||||
* .NET 装到几(System.Speech / WPF 依赖它)
|
||||
* VC++ 运行库有没有(很多打包程序依赖)
|
||||
* 命令行工具有哪些(cscript/wscript/powershell/wmic/mshta…)
|
||||
* 中文语音在不在(SAPI 已在第二节,这里确认语音包)
|
||||
* 已装程序清单(判断这是不是精简版系统)
|
||||
* 硬件规模(内存 / 磁盘)—— 决定程序能不能跑
|
||||
'''
|
||||
head("五、环境与工具链")
|
||||
|
||||
# ---- 5.1 系统细节 ----
|
||||
say()
|
||||
say(" --- 5.1 系统细节 ---")
|
||||
try:
|
||||
v = sys.getwindowsversion()
|
||||
item("内核版本", True, "%d.%d build %d" % (v.major, v.minor, v.build))
|
||||
names = {(6, 1): "Windows 7", (6, 2): "Windows 8",
|
||||
(6, 3): "Windows 8.1", (10, 0): "Windows 10/11"}
|
||||
item("对应产品名", None, names.get((v.major, v.minor), "未知"))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
item("架构", None, os.environ.get("PROCESSOR_ARCHITECTURE", "?"))
|
||||
item("处理器标识", None, os.environ.get("PROCESSOR_IDENTIFIER", "?")[:60])
|
||||
item("CPU 核心数", None, str(os.cpu_count()))
|
||||
item("系统盘/Windows", None, os.environ.get("SystemRoot", "?"))
|
||||
item("Program Files", None, os.environ.get("ProgramFiles", "?"))
|
||||
|
||||
# 内存 / 磁盘
|
||||
try:
|
||||
class MEMORYSTATUSEX(ctypes.Structure):
|
||||
_fields_ = [("dwLength", ctypes.c_ulong),
|
||||
("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong),
|
||||
("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
|
||||
m = MEMORYSTATUSEX()
|
||||
m.dwLength = ctypes.sizeof(m)
|
||||
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)):
|
||||
item("物理内存", True, "%.2f GB(可用 %.2f GB,占用 %d%%)"
|
||||
% (m.ullTotalPhys / 2 ** 30, m.ullAvailPhys / 2 ** 30,
|
||||
m.dwMemoryLoad))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
item("物理内存", False, str(exc))
|
||||
|
||||
for drive in ("C:\\",):
|
||||
try:
|
||||
free = ctypes.c_ulonglong(0)
|
||||
total = ctypes.c_ulonglong(0)
|
||||
ctypes.windll.kernel32.GetDiskFreeSpaceExW(
|
||||
ctypes.c_wchar_p(drive), None, ctypes.byref(total),
|
||||
ctypes.byref(free))
|
||||
item("磁盘 %s" % drive, True,
|
||||
"总 %.1f GB,剩 %.1f GB" % (total.value / 2 ** 30,
|
||||
free.value / 2 ** 30))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# ---- 5.2 .NET ----
|
||||
say()
|
||||
say(" --- 5.2 .NET 运行库(System.Speech 依赖)---")
|
||||
ps = shutil.which("powershell") or (
|
||||
os.environ.get("SystemRoot", r"C:\Windows")
|
||||
+ r"\System32\WindowsPowerShell\v1.0\powershell.exe")
|
||||
if Path(ps).is_file():
|
||||
code, out = run([ps, "-NoProfile", "-Command",
|
||||
"[System.Environment]::Version.ToString()"], timeout=25)
|
||||
item("CLR 版本", code == 0, (out or "").strip())
|
||||
|
||||
# 注册表里的 .NET 版本
|
||||
for hive in (r"HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full",
|
||||
r"HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5",
|
||||
r"HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.0"):
|
||||
code, out = run(["reg", "query", hive, "/v", "Version"], timeout=20)
|
||||
if code == 0 and out:
|
||||
ver = ""
|
||||
for ln in out.splitlines():
|
||||
if "Version" in ln and "REG_SZ" in ln:
|
||||
ver = ln.split("REG_SZ")[-1].strip()
|
||||
item(hive.split("\\")[-2] + " / " + hive.split("\\")[-1],
|
||||
True, ver or "在")
|
||||
|
||||
# ---- 5.3 命令行工具 ----
|
||||
say()
|
||||
say(" --- 5.3 命令行工具 ---")
|
||||
sysroot = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
tools = [
|
||||
("cscript", r"\System32\cscript.exe"),
|
||||
("wscript", r"\System32\wscript.exe"),
|
||||
("cmd", r"\System32\cmd.exe"),
|
||||
("mshta", r"\System32\mshta.exe"), # 可跑 VBS/JS
|
||||
("rundll32", r"\System32\rundll32.exe"),
|
||||
("reg", r"\System32\reg.exe"),
|
||||
("wmic", r"\System32\wbem\wmic.exe"), # 查硬件
|
||||
("tasklist", r"\System32\tasklist.exe"),
|
||||
("netsh", r"\System32\netsh.exe"),
|
||||
("sc", r"\System32\sc.exe"),
|
||||
("taskkill", r"\System32\taskkill.exe"),
|
||||
("certutil", r"\System32\certutil.exe"),
|
||||
]
|
||||
for name, rel in tools:
|
||||
found = shutil.which(name)
|
||||
abs_p = sysroot + rel
|
||||
if found:
|
||||
item(name, True, found)
|
||||
elif Path(abs_p).is_file():
|
||||
item(name, True, "PATH 外,绝对路径在: %s" % abs_p)
|
||||
else:
|
||||
item(name, False, "找不到")
|
||||
|
||||
# ---- 5.4 Windows 组件 ----
|
||||
say()
|
||||
say(" --- 5.4 Windows 组件 ---")
|
||||
# 语音(确认中文语音包)
|
||||
if Path(ps).is_file():
|
||||
code, out = run([
|
||||
ps, "-NoProfile", "-Command",
|
||||
"$v=New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices()|ForEach-Object{$_.GetDescription()}"], timeout=25)
|
||||
vs = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
||||
cn = [x for x in vs if "Chinese" in x or "Huihui" in x or "中文" in x]
|
||||
item("中文语音包", bool(cn),
|
||||
"、".join(cn) if cn else "没有中文语音(只有: %s)"
|
||||
% ("、".join(vs[:3]) if vs else "一个语音都没有"))
|
||||
|
||||
# 媒体功能
|
||||
code, out = run(["reg", "query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v", "ProductName"], timeout=20)
|
||||
name = ""
|
||||
if code == 0:
|
||||
for ln in (out or "").splitlines():
|
||||
if "ProductName" in ln and "REG_SZ" in ln:
|
||||
name = ln.split("REG_SZ")[-1].strip()
|
||||
item("系统产品名(注册表)", None, name or "查不到")
|
||||
|
||||
# ---- 5.5 已装程序(判断是不是精简版)----
|
||||
say()
|
||||
say(" --- 5.5 已装程序(看是不是精简版系统)---")
|
||||
# ⚠ 第一版用 `Get-ItemProperty` 配通配符 + `Select-Object -ExpandProperty`:
|
||||
# 在 PowerShell 2.0 上会往输出里混进一堆报错文本
|
||||
# ("Property DisplayName cannot be found"、"CategoryInfo"、"At line:1" …),
|
||||
# 结果列表里全是垃圾行。**报错和结果混在同一个 stdout 里**,
|
||||
# 光看行数根本分不出来 —— 这次报告里就出现了。
|
||||
#
|
||||
# 改法:
|
||||
# 1. `Get-WmiObject Win32_Product`(PS 2.0 兼容,但只有 MSI 装的程序)
|
||||
# 2. `reg query` 兜底 —— **完全不经过 PowerShell**,不受版本影响
|
||||
# 3. 过滤掉所有已知的报错关键字
|
||||
apps = set()
|
||||
if Path(ps).is_file():
|
||||
code, out = run([
|
||||
ps, "-NoProfile", "-Command",
|
||||
"Get-WmiObject -Class Win32_Product -ErrorAction SilentlyContinue | "
|
||||
"ForEach-Object { $_.Name }"], timeout=90)
|
||||
for x in (out or "").splitlines():
|
||||
apps.add(x.strip())
|
||||
|
||||
for hive in (r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
r"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows"
|
||||
r"\CurrentVersion\Uninstall"):
|
||||
code, out = run(["reg", "query", hive, "/s", "/v", "DisplayName"],
|
||||
timeout=60, encoding="mbcs")
|
||||
for ln in (out or "").splitlines():
|
||||
if "DisplayName" in ln and "REG_SZ" in ln:
|
||||
apps.add(ln.split("REG_SZ")[-1].strip())
|
||||
|
||||
# 过滤报错噪音
|
||||
NOISE = ("CategoryInfo", "FullyQualifiedErrorId", "At line:", "+ ",
|
||||
"PSArgumentException", "ExpandPropertyNotFound", "SelectObject",
|
||||
"Get-ItemProperty", "Get-WmiObject :", "InvalidArgument",
|
||||
"PSObject", "cannot be found", "not recognized", "?????")
|
||||
clean = sorted({a for a in apps
|
||||
if a and not any(n in a for n in NOISE)
|
||||
and "HKLM:" not in a and "\\" not in a[:6]})
|
||||
|
||||
if clean:
|
||||
item("已装程序数量", True, "共 %d 个" % len(clean))
|
||||
for a in clean[:30]:
|
||||
say(" - " + a)
|
||||
if len(clean) > 30:
|
||||
say(" ...(还有 %d 个)" % (len(clean) - 30))
|
||||
else:
|
||||
item("已装程序数量", False, "一个都列不出来")
|
||||
|
||||
# ---- 5.6 环境变量(关键几个)----
|
||||
say()
|
||||
say(" --- 5.6 环境变量 ---")
|
||||
for k in ("PATH", "TEMP", "TMP", "SystemRoot", "windir",
|
||||
"ProgramFiles", "ProgramData", "USERPROFILE", "APPDATA",
|
||||
"LOCALAPPDATA", "COMPUTERNAME", "USERNAME"):
|
||||
v = os.environ.get(k)
|
||||
if k == "PATH":
|
||||
item(k, None, "%d 个路径" % len((v or "").split(";")))
|
||||
for p in (v or "").split(";")[:8]:
|
||||
if p.strip():
|
||||
say(" " + p.strip())
|
||||
else:
|
||||
item(k, None, v or "(没设)")
|
||||
|
||||
|
||||
|
||||
# ================================================================ main
|
||||
def main() -> int:
|
||||
say("=" * 64)
|
||||
say(" ykxmz 诊断报告")
|
||||
say(" 生成时间: %s" % datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
say(" 机器名: %s" % os.environ.get("COMPUTERNAME", "?"))
|
||||
say(" 用户名: %s" % os.environ.get("USERNAME", "?"))
|
||||
say("=" * 64)
|
||||
say()
|
||||
say(" ★ 把这一整段复制发回去就行。程序不改动系统任何东西。")
|
||||
|
||||
for fn in (probe_system, probe_tts, probe_ui, probe_net, probe_env):
|
||||
try:
|
||||
fn()
|
||||
except Exception: # noqa: BLE001
|
||||
say()
|
||||
say(" 【这一段探测崩了】")
|
||||
for ln in traceback.format_exc().splitlines()[-6:]:
|
||||
say(" " + ln)
|
||||
|
||||
say()
|
||||
say("=" * 64)
|
||||
say(" 报告结束")
|
||||
say("=" * 64)
|
||||
|
||||
# 同时写到桌面和 exe 旁边
|
||||
text = "\r\n".join(OUT)
|
||||
written = []
|
||||
targets = []
|
||||
try:
|
||||
targets.append(Path(os.path.expanduser("~")) / "Desktop" / "ykxmz诊断.txt")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
targets.append(Path(sys.executable).parent / "ykxmz诊断.txt")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
targets.append(Path(tempfile.gettempdir()) / "ykxmz诊断.txt")
|
||||
|
||||
for t in targets:
|
||||
# ⚠ 写两份:UTF-8-BOM(记事本能认)+ GBK(老编辑器/控制台重定向能认)。
|
||||
# Win7 自带的记事本从 Vista 起就支持 UTF-8,但为了万无一失都写。
|
||||
for enc, suffix in (("utf-8-sig", ""), ("gbk", "_gbk")):
|
||||
try:
|
||||
t.parent.mkdir(parents=True, exist_ok=True)
|
||||
p = t if not suffix else t.with_name(t.stem + suffix + t.suffix)
|
||||
p.write_text(text, encoding=enc, errors="replace")
|
||||
written.append(str(p))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
print()
|
||||
print(" 报告已保存到:")
|
||||
for w in written:
|
||||
print(" " + w)
|
||||
print()
|
||||
print(" 按回车键关闭…")
|
||||
try:
|
||||
input()
|
||||
except Exception: # noqa: BLE001
|
||||
time.sleep(30)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception: # noqa: BLE001
|
||||
print(traceback.format_exc())
|
||||
try:
|
||||
input(" 出错了,按回车关闭…")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,250 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''诊断:这台机器上哪几种语音播报方案能用。
|
||||
|
||||
== 为什么写这个 ==
|
||||
|
||||
用户反馈:**Win7 上语音播报不能用**。
|
||||
|
||||
现在的实现只有一条路:
|
||||
powershell -NoProfile -WindowStyle Hidden -Command
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||
|
||||
这在 Win7 上有几个可能致命的点:
|
||||
|
||||
1. **`powershell` 可能不在 PATH 里**
|
||||
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||
正常装的机器 PATH 里有,但被精简过、或者从服务/计划任务里拉起时
|
||||
可能没有 —— 而**我们是用 CreateProcess 按名字找的**,找不到就静默失败。
|
||||
|
||||
2. **Win7 默认是 PowerShell 2.0,它用 ANSI 代码页解析 `-Command`**
|
||||
PS 3.0+ 才改成按 UTF-16 处理命令行。所以中文经 `-Command` 传进去
|
||||
在 PS 2.0 上会乱码 —— 表现是"念出来是乱码"或"什么都不念"。
|
||||
|
||||
3. **`System.Speech` 需要 .NET Framework 3.0+**
|
||||
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||
根本不需要 .NET。绕开 .NET 更稳。
|
||||
|
||||
== 所以这里试 5 条路 ==
|
||||
|
||||
A. powershell(按 PATH 找)+ System.Speech <- 现在的实现
|
||||
B. powershell(按**绝对路径**找)+ System.Speech <- 修"不在 PATH"
|
||||
C. powershell(绝对路径)+ SAPI.SpVoice COM <- 绕开 .NET
|
||||
D. **cscript + VBScript + SAPI.SpVoice** <- 完全不依赖 PowerShell
|
||||
E. wscript + VBScript + SAPI.SpVoice <- 同上,另一个宿主
|
||||
|
||||
D/E 最值得期待:**VBScript 从 Windows 98 就有,SAPI5 是系统组件**,
|
||||
两个都不需要装任何东西,也不受 PowerShell 版本影响。
|
||||
|
||||
== 怎么跑 ==
|
||||
|
||||
python _build\diag_tts.py
|
||||
|
||||
它会一条条试并打印结果。**在有问题的机器上跑最有价值** ——
|
||||
比如那台 Win7。把输出发回来就知道该换哪条路。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# 让它念一句中文。中文最能暴露编码问题 ——
|
||||
# 纯英文在 PS 2.0 上也可能碰巧是对的(ASCII 不受代码页影响)。
|
||||
TEXT = "语音播报测试,一二三"
|
||||
|
||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
FOUND: list = []
|
||||
FAILED: list = []
|
||||
|
||||
|
||||
def report(name: str, ok: bool, detail: str = "") -> None:
|
||||
mark = "可用 " if ok else "不可用"
|
||||
print(" [%s] %s%s" % (mark, name, (" —— " + detail) if detail else ""))
|
||||
(FOUND if ok else FAILED).append(name)
|
||||
|
||||
|
||||
def run(cmd: list, timeout: int = 30) -> tuple:
|
||||
"""跑一条命令,返回 (退出码, 输出)。不让它弹窗。"""
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=NO_WINDOW,
|
||||
encoding="mbcs", errors="replace")
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
return p.returncode, out
|
||||
except FileNotFoundError:
|
||||
return -1, "文件找不到"
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "超时"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return -3, "%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
def find_powershell() -> str:
|
||||
"""先按 PATH 找,再按绝对路径找。"""
|
||||
p = shutil.which("powershell")
|
||||
if p:
|
||||
return p
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
return str(cand) if cand.is_file() else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 语音播报方案诊断")
|
||||
print("=" * 62)
|
||||
print()
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
wv = sys.getwindowsversion()
|
||||
print(" 系统: Windows %d.%d (build %d)"
|
||||
% (wv.major, wv.minor, wv.build))
|
||||
except Exception: # noqa: BLE001
|
||||
print(" 系统: Windows(版本号取不到)")
|
||||
else:
|
||||
print(" 系统: 非 Windows")
|
||||
print(" Python: %s (%d 位)" % (sys.version.split()[0],
|
||||
64 if sys.maxsize > 2 ** 32 else 32))
|
||||
print(" 测试文本: %r" % TEXT)
|
||||
print()
|
||||
|
||||
if not IS_WINDOWS:
|
||||
print(" 非 Windows,没有 TTS 方案可试。")
|
||||
return 0
|
||||
|
||||
# ---------------- 0. 环境事实 ----------------
|
||||
print("── 0. 环境 ──")
|
||||
ps_path = find_powershell()
|
||||
ps_in_path = shutil.which("powershell")
|
||||
print(" powershell 在 PATH 里: %s" % (ps_in_path or "**没有**"))
|
||||
print(" powershell 绝对路径: %s" % (ps_path or "**找不到**"))
|
||||
if ps_path:
|
||||
code, out = run([ps_path, "-NoProfile", "-Command",
|
||||
"$PSVersionTable.PSVersion.Major"])
|
||||
print(" PowerShell 主版本: %s" % (out.strip() or "查不到"))
|
||||
print(" cscript: %s" % (shutil.which("cscript") or "**没有**"))
|
||||
print(" wscript: %s" % (shutil.which("wscript") or "**没有**"))
|
||||
print(" SystemRoot: %s" % os.environ.get("SystemRoot", "?"))
|
||||
print()
|
||||
|
||||
# ---------------- A. 现有实现 ----------------
|
||||
print("── A. powershell(按 PATH)+ System.Speech ← 现在产品用的 ──")
|
||||
if not ps_in_path:
|
||||
report("A", False, "powershell 不在 PATH 里 —— **这就是失败原因**")
|
||||
else:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run(["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("A", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- B. 绝对路径 + System.Speech ----------------
|
||||
print("── B. powershell(绝对路径)+ System.Speech ──")
|
||||
if not ps_path:
|
||||
report("B", False, "找不到 powershell.exe")
|
||||
else:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("B", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- C. SAPI.SpVoice COM(绕开 .NET)----------------
|
||||
print("── C. powershell(绝对路径)+ SAPI.SpVoice COM ──")
|
||||
if not ps_path:
|
||||
report("C", False, "找不到 powershell.exe")
|
||||
else:
|
||||
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("C", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- D. cscript + VBScript + SAPI ----------------
|
||||
#
|
||||
# 最值得期待的一条:VBScript 从 Windows 98 就有,
|
||||
# SAPI5 是系统组件,**两个都不需要装任何东西**,
|
||||
# 也完全不受 PowerShell 版本 / PATH / .NET 的影响。
|
||||
print("── D. cscript + VBScript + SAPI.SpVoice(不依赖 PowerShell)──")
|
||||
vbs = (
|
||||
'Dim v\r\n'
|
||||
'Set v = CreateObject("SAPI.SpVoice")\r\n'
|
||||
'v.Speak "%s"\r\n'
|
||||
) % TEXT.replace('"', '""')
|
||||
# ⚠ VBScript 文件要 ANSI(=系统代码页) —— 写成 UTF-8 的话
|
||||
# 中文在 cscript 里会变乱码。这里用 mbcs 编码写。
|
||||
tmp = Path(tempfile.gettempdir()) / "ykxmz_tts_test.vbs"
|
||||
try:
|
||||
tmp.write_bytes(vbs.encode("mbcs", errors="replace"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report("D", False, "写 vbs 失败: %s" % exc)
|
||||
else:
|
||||
code, out = run(["cscript", "//nologo", str(tmp)])
|
||||
report("D", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- E. wscript(不弹黑框)----------------
|
||||
print("── E. wscript + VBScript + SAPI.SpVoice ──")
|
||||
if tmp.is_file():
|
||||
code, out = run(["wscript", "//nologo", str(tmp)])
|
||||
report("E", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
else:
|
||||
report("E", False, "没有 vbs 文件")
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- 音色列表(两种方式对比)----------------
|
||||
print()
|
||||
print("── 可用音色 ──")
|
||||
if ps_path:
|
||||
code, out = run([
|
||||
ps_path, "-NoProfile", "-Command",
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"])
|
||||
print(" SAPI5(COM):")
|
||||
for ln in (out or "").splitlines():
|
||||
if ln.strip():
|
||||
print(" " + ln.strip())
|
||||
if not out.strip():
|
||||
print(" (列不出来)")
|
||||
code2, out2 = run([
|
||||
ps_path, "-NoProfile", "-Command",
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"])
|
||||
print(" System.Speech:")
|
||||
for ln in (out2 or "").splitlines():
|
||||
if ln.strip():
|
||||
print(" " + ln.strip())
|
||||
if not out2.strip():
|
||||
print(" (列不出来 —— .NET 那套可能有问题)")
|
||||
|
||||
# ---------------- 结论 ----------------
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 可用 %d 条 / 不可用 %d 条" % (len(FOUND), len(FAILED)))
|
||||
if FOUND:
|
||||
print(" 可用方案: %s" % ", ".join(FOUND))
|
||||
print()
|
||||
print(" → 产品应该按**这个顺序**降级尝试:D(cscript) -> C(SAPI COM)")
|
||||
print(" -> B(绝对路径) -> A(PATH),全失败才报错。")
|
||||
else:
|
||||
print(" ⚠ 一条都不通。可能是:没装任何语音、系统被精简过、")
|
||||
print(" 或者 SAPI 组件损坏。")
|
||||
print("=" * 62)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查隧道为什么掉连接(公网 530,连接数 1/0)。
|
||||
|
||||
上一轮才刚修掉 nettune 死锁、把公网拉到 200;这次重启 nettune 之后
|
||||
又变成 530 了。要分清是:
|
||||
* nettune 又卡了(那个死锁没修干净?)
|
||||
* 还是隧道自己掉了(cloudflared 的问题)
|
||||
'''
|
||||
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. 服务状态 + 有没有卡住的 ════"
|
||||
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do
|
||||
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s)"
|
||||
done
|
||||
echo " --- nettune 卡住吗 ---"
|
||||
systemctl show -p SubState -p MainPID -p ExecMainStartTimestamp lanremote-nettune 2>&1 | sed 's/^/ /'
|
||||
echo " --- 有没有卡住的 systemctl ---"
|
||||
ps -eo pid,etime,cmd 2>/dev/null | grep -E 'systemctl|net-tune' | grep -v grep | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 2. net-tune 的修复在设备上吗 ════"
|
||||
grep -c NETTUNE_SERVICE_CALL /root/lanremote-relay/net-tune.sh
|
||||
echo " unit 里有吗: $(grep -c NETTUNE_SERVICE_CALL /etc/systemd/system/lanremote-nettune.service)"
|
||||
|
||||
echo
|
||||
echo "════ 3. 隧道为什么连接数是 0 ════"
|
||||
journalctl -u lanremote-tunnel --no-pager -n 25 2>/dev/null | tail -25 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 4. 隧道 unit 长什么样(重启策略 / 协议)════"
|
||||
systemctl cat lanremote-tunnel 2>/dev/null | grep -vE '^#' | head -25 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 5. cloudflared 进程 ════"
|
||||
ps -eo pid,etime,rss,cmd 2>/dev/null | grep cloudflared | grep -v grep | sed 's/^/ /'
|
||||
'''
|
||||
|
||||
|
||||
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,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''看隧道为什么反复掉(公网 530 的真因)。
|
||||
|
||||
已知:
|
||||
nettune 死锁已修(SubState=exited,不再卡)
|
||||
但隧道每 3~10 分钟被重启一次(部分是我加的预案在救、部分是它自己掉)
|
||||
公网 530
|
||||
|
||||
要查的是 tunnel.log 里 cloudflared 到底在报什么。
|
||||
'''
|
||||
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'''
|
||||
LOG=/root/lanremote-relay/tunnel.log
|
||||
H2LOG=/root/lanremote-relay/tunnel-h2.log
|
||||
|
||||
echo "════ 1. tunnel.log 里的错误行(最近 40 条)════"
|
||||
grep -iE 'error|fail|warn|timeout|retry|refused|EOF|unregister|connection' "$LOG" 2>/dev/null | tail -40 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 2. tunnel.log 最后 25 行(原样)════"
|
||||
tail -25 "$LOG" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 3. h2 那份的日志 ════"
|
||||
tail -15 "$H2LOG" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 4. 两份日志多大(写爆了会拖慢小机器)════"
|
||||
ls -la "$LOG" "$H2LOG" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 5. cloudflared 版本 + 配置 ════"
|
||||
/usr/local/bin/cloudflared --version 2>&1 | sed 's/^/ /'
|
||||
echo " --- config.yml ---"
|
||||
cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 6. 现在两条隧道的连接数 ════"
|
||||
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
|
||||
echo "════ 7. 公网现在通吗 ════"
|
||||
echo " $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health)"
|
||||
'''
|
||||
|
||||
|
||||
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,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''抓 Gitee 在 README 页面上挂的违规公告原文。
|
||||
|
||||
== 为什么要抓原文 ==
|
||||
|
||||
网页版 README 页里出现了:
|
||||
「违规信息」2 次
|
||||
「内容可能」2 次
|
||||
「违规」2 次
|
||||
「举报」10 次
|
||||
|
||||
但 raw 接口是 451。光看关键词猜没用 —— 要把 Gitee 的**原话**读出来,
|
||||
才知道它到底判的是哪一条,才能对症改。
|
||||
|
||||
== 抓什么 ==
|
||||
|
||||
把「违规信息」「内容可能」附近的 HTML 片段剥掉标签打出来。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
URL = "https://gitee.com/xmz7891/ykxmz/blob/main/README.md"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
req = urllib.request.Request(URL)
|
||||
req.add_header("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 Chrome/120 Safari/537.36")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
page = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(" ✗ HTTP %s" % exc.code)
|
||||
return 1
|
||||
|
||||
print(" 页面 %d 字节" % len(page))
|
||||
|
||||
# 去掉 script/style
|
||||
body = re.sub(r"<script.*?</script>", " ", page, flags=re.S | re.I)
|
||||
body = re.sub(r"<style.*?</style>", " ", body, flags=re.S | re.I)
|
||||
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 「违规」/「内容可能」附近的原文")
|
||||
print("=" * 62)
|
||||
for kw in ("违规信息", "内容可能", "违规"):
|
||||
for m in re.finditer(kw, body):
|
||||
start = max(0, m.start() - 500)
|
||||
end = min(len(body), m.end() + 500)
|
||||
chunk = body[start:end]
|
||||
chunk = re.sub(r"<[^>]+>", " ", chunk)
|
||||
chunk = html.unescape(chunk)
|
||||
chunk = re.sub(r"\s+", " ", chunk).strip()
|
||||
if len(chunk) > 30:
|
||||
print()
|
||||
print(" 【%s】" % kw)
|
||||
print(" ...%s..." % chunk)
|
||||
break # 每个词只看第一条,免得太长
|
||||
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 页面里所有提到「审核 / 举报 / 法律」的短句")
|
||||
print("=" * 62)
|
||||
for kw in ("审核", "举报", "法律", "不可用"):
|
||||
seen = set()
|
||||
for m in re.finditer(kw, body):
|
||||
start = max(0, m.start() - 120)
|
||||
end = min(len(body), m.end() + 120)
|
||||
chunk = re.sub(r"<[^>]+>", " ", body[start:end])
|
||||
chunk = html.unescape(chunk)
|
||||
chunk = re.sub(r"\s+", " ", chunk).strip()
|
||||
# 只留信息量够、而且不是重复的
|
||||
key = chunk[:40]
|
||||
if len(chunk) > 25 and key not in seen:
|
||||
seen.add(key)
|
||||
print(" 【%s】%s" % (kw, chunk[:180]))
|
||||
if len(seen) >= 2:
|
||||
break
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""找出 MainActivity 里**定义了但没人调用**的私有方法。
|
||||
|
||||
为什么需要这个:2.3.0 把一半的分页删了,原来那些按钮背后的方法
|
||||
就全变成孤儿了。一个个跑测试太慢,先用这个脚本一次性列出来。
|
||||
|
||||
排除规则(这些"没人调用"是正常的):
|
||||
* @Override 的回调(框架会调)
|
||||
* 生命周期方法(onCreate/onPause/...)
|
||||
* 构造器
|
||||
* 只有 setVisibility / setText 之类直接操作的不算
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
JAVA = (Path(__file__).resolve().parent.parent
|
||||
/ "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java")
|
||||
|
||||
# 框架调用的,不算孤儿
|
||||
LIFECYCLE = {
|
||||
"onCreate", "onStart", "onResume", "onPause", "onStop", "onDestroy",
|
||||
"onActivityResult", "onRequestPermissionsResult", "onBackPressed",
|
||||
"onNewIntent", "onSaveInstanceState", "onRestoreInstanceState",
|
||||
"onTouchEvent", "onKeyDown", "onConfigurationChanged",
|
||||
"onWindowFocusChanged", "onCreateOptionsMenu", "onOptionsItemSelected",
|
||||
"onClick", "run", "onPrepared", "onCompletion", "onError",
|
||||
"onProgressChanged", "onStartTrackingTouch", "onStopTrackingTouch",
|
||||
"onSingleTapConfirmed", "onDoubleTap", "onScroll", "onFling",
|
||||
"onDown", "onLongPress", "onShowPress", "onSingleTapUp",
|
||||
"onInput", "onPick", "onResponse", "onFailure", "doInBackground",
|
||||
"onPostExecute", "beforeTextChanged", "onTextChanged",
|
||||
"afterTextChanged", "onItemClick", "onItemSelected",
|
||||
"onNothingSelected", "onFocusChange", "onEditorAction",
|
||||
}
|
||||
|
||||
# 定义方法的各种写法
|
||||
DEF_RE = re.compile(
|
||||
r"^[ \t]*(?:@\w+(?:\([^)]*\))?\s*)*"
|
||||
r"(?:private|protected|public|static|final|synchronized|\s)*"
|
||||
r"(?:[\w<>\[\],.?\s]+?)\s+(\w+)\s*\([^)]*\)\s*(?:throws [\w,.\s]+)?\s*\{",
|
||||
re.M)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = JAVA.read_text(encoding="utf-8")
|
||||
lines = text.split("\n")
|
||||
|
||||
# 收集定义(带行号)
|
||||
defs = []
|
||||
for i, ln in enumerate(lines):
|
||||
m = DEF_RE.match(ln)
|
||||
if not m:
|
||||
continue
|
||||
name = m.group(1)
|
||||
# 排除构造器(名字等于类名)和 exclude 集合
|
||||
if name in ("MainActivity", "if", "for", "while", "switch", "catch"):
|
||||
continue
|
||||
# 排除紧跟在 @Override 后面的
|
||||
prev = [x.strip() for x in lines[max(0, i - 3):i]]
|
||||
if any("_override_" == p or "@Override" in p for p in prev):
|
||||
continue
|
||||
defs.append((name, i + 1))
|
||||
|
||||
# 统计每个名字被调用多少次(排除定义行自己)
|
||||
def_line = {}
|
||||
for name, ln in defs:
|
||||
def_line.setdefault(name, set()).add(ln)
|
||||
|
||||
orphans = []
|
||||
for name, ln in defs:
|
||||
# 统计调用次数:名字 + "(" 出现次数
|
||||
occurrences = [(i + 1, l) for i, l in enumerate(lines)
|
||||
if re.search(r"\b" + re.escape(name) + r"\s*\(", l)]
|
||||
# 减去定义本身那一行
|
||||
calls = [o for o in occurrences if o[0] != ln]
|
||||
# 定义行可能是多行签名的第一行,精确匹配名字出现的所有行
|
||||
if not calls:
|
||||
if name not in LIFECYCLE:
|
||||
orphans.append((name, ln))
|
||||
|
||||
print(" 定义的方法 %d 个" % len(defs))
|
||||
print(" 孤儿方法 %d 个:" % len(orphans))
|
||||
for name, ln in orphans:
|
||||
print(" 第 %-5d %s()" % (ln, name))
|
||||
|
||||
# 也检查被调用但只出现在注释里的(可能是残留引用)
|
||||
print()
|
||||
print(" 被删除后仍被引用的符号(编译错误来源):")
|
||||
deleted = sys.argv[1:] if len(sys.argv) > 1 else []
|
||||
for sym in deleted:
|
||||
hits = [(i + 1, l.strip()) for i, l in enumerate(lines)
|
||||
if re.search(r"\b" + re.escape(sym) + r"\s*\(", l)]
|
||||
if hits:
|
||||
# 判断是不是定义行
|
||||
deflines = {d[1] for d in defs if d[0] == sym}
|
||||
real = [h for h in hits if h[0] not in deflines]
|
||||
if real:
|
||||
print(" %s: %d 处调用" % (sym, len(real)))
|
||||
for ln, l in real[:5]:
|
||||
print(" 第 %d: %s" % (ln, l[:100]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给 80 端口的域名块补上 ACME 挑战例外。
|
||||
|
||||
== 不补会怎样 ==
|
||||
|
||||
Let's Encrypt 的 **HTTP-01 挑战**要求:
|
||||
http://yk.xxmxxs.top/.well-known/acme-challenge/<token>
|
||||
必须在 **80 端口**上**直接返回那个文件**。
|
||||
|
||||
而我们把 80 上的一切都 301 到 https 了 —— 挑战请求也被跳走,
|
||||
certbot 拿不到文件,续期失败。
|
||||
|
||||
**后果是延迟爆发的**:现在一切正常,**最多 90 天后**(证书到期时)
|
||||
网站突然整个挂掉,而且是 https 全挂。那时候很难联想到
|
||||
"是几个月前改 nginx 忘了留 ACME 例外"。
|
||||
|
||||
== 为什么之前没暴露 ==
|
||||
|
||||
证书是 10/8 签的,90 天有效期 → 2027 年 1 月才到期。
|
||||
`certbot.timer` 每天跑一次续期检查,但**现在还没到需要续期的时候**,
|
||||
所以它不会报错。等真要续的时候才发现就晚了。
|
||||
|
||||
== 修法 ==
|
||||
|
||||
在 80 端口的域名块里,**在 `location /`(那条 301)之前**加一个
|
||||
ACME 专用 location。nginx 的 location 匹配优先看前缀长度,
|
||||
`/.well-known/acme-challenge/` 比 `/` 长,所以会先命中它。
|
||||
|
||||
用 `root /var/www/html` 是 certbot --nginx 插件的默认做法 ——
|
||||
它续期时会自己往那个目录写挑战文件。
|
||||
|
||||
== 顺手加的保险:让续期失败能被发现 ==
|
||||
|
||||
`certbot.timer` 失败是**静默**的(只在 journal 里)。
|
||||
加一个每天检查证书剩余天数的探针,少于 20 天就记一条明显的日志 ——
|
||||
这样"续期悄悄失败"不会拖到证书过期才发现。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
ACME_BLOCK = ''' # ---- ACME 挑战:**必须在 301 之前** ----
|
||||
#
|
||||
# Let's Encrypt 的 HTTP-01 挑战要在 80 端口上直接拿到文件,
|
||||
# 被 301 跳走的话续期就失败 —— 而且**最多 90 天后才暴露**
|
||||
# (证书到期时网站整个挂掉),那时候很难联想到是这里的问题。
|
||||
#
|
||||
# nginx 的 location 匹配优先看前缀长度,`/.well-known/acme-challenge/`
|
||||
# 比 `/` 长,所以会先命中这里,不会走到下面的 301。
|
||||
#
|
||||
# root 用 certbot --nginx 插件的默认值 —— 它续期时自己往这里写文件。
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
default_type "text/plain";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 其余全部跳到 https(certbot --redirect 的效果)
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}'''
|
||||
|
||||
OLD = ''' # 其余全部跳到 https(certbot --redirect 的效果)
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 补 ACME 挑战例外(不然 90 天后证书续不上)")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
|
||||
conf = r[1] if isinstance(r, tuple) else str(r)
|
||||
|
||||
if "acme-challenge" in conf:
|
||||
print(" 已经有 ACME 例外了")
|
||||
else:
|
||||
if OLD not in conf:
|
||||
print(" ✗ 找不到 301 那段锚点")
|
||||
return 1
|
||||
conf = conf.replace(OLD, ACME_BLOCK, 1)
|
||||
tmp = ROOT / "_build" / "_ykxmz_acme.conf"
|
||||
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/ykxmz-acme.conf")
|
||||
tmp.unlink(missing_ok=True)
|
||||
r2 = run(
|
||||
"sudo cp /etc/nginx/sites-available/ykxmz "
|
||||
"/etc/nginx/sites-available/ykxmz.bak.acme.$(date +%s) && "
|
||||
"sudo mv /tmp/ykxmz-acme.conf /etc/nginx/sites-available/ykxmz && "
|
||||
"sudo mkdir -p /var/www/html/.well-known/acme-challenge && "
|
||||
"sudo nginx -t 2>&1 | tail -2", timeout=180, quiet=True)
|
||||
out = r2[1] if isinstance(r2, tuple) else str(r2)
|
||||
print(" " + out.replace("\n", "\n "))
|
||||
if "successful" not in out:
|
||||
print(" ✗ 配置有错,撤回")
|
||||
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.acme.* "
|
||||
"| head -1) /etc/nginx/sites-available/ykxmz", timeout=90,
|
||||
quiet=True)
|
||||
return 1
|
||||
print(" ✓ 已补上")
|
||||
|
||||
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
|
||||
timeout=120, quiet=True)
|
||||
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
|
||||
|
||||
# ---- 复核:ACME 路径不再 301,而是 404(文件不存在是对的,
|
||||
# 关键是不能 301 —— 301 就去 https 了,certbot 拿不到)----
|
||||
print()
|
||||
print("── 复核 ──")
|
||||
r = run(r'''
|
||||
printf " %-52s " "ACME 挑战路径(应该 404,不能是 301)"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/test
|
||||
printf " %-52s " "放个真文件进去试试"
|
||||
sudo sh -c 'echo hello-acme > /var/www/html/.well-known/acme-challenge/probe'
|
||||
curl -s --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
|
||||
echo
|
||||
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
|
||||
printf " %-52s " "普通路径仍然跳 https"
|
||||
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/somepage
|
||||
printf " %-52s " "IP 访问仍然 404(用户要求没东西)"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 http://127.0.0.1/
|
||||
printf " %-52s " "官网仍然 200"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/
|
||||
printf " %-52s " "中转站设置仍然 200"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/zhongzhuanzhan
|
||||
echo
|
||||
echo " --- 证书到期时间 ---"
|
||||
sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem -noout -dates 2>/dev/null | sed 's/^/ /'
|
||||
echo " --- 距到期还有多少天 ---"
|
||||
end=$(sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
echo " $(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) 天"
|
||||
''', timeout=240, quiet=True)
|
||||
print(r[1] if isinstance(r, tuple) else r)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''按**行号**修 admin.py 里那段坏掉的 JS。
|
||||
|
||||
== 为什么不用字符串匹配 ==
|
||||
|
||||
前两次都失败了:坏掉的内容里带着**真的换行**,
|
||||
在 Python 源码里怎么写字面量都对不齐(缩进、空行的数量差一个就匹配不上)。
|
||||
|
||||
按行号定位反而稳:先找到 `data-update` 那个 forEach 的锚点行,
|
||||
然后把它后面那几行跨行字符串整段换掉。
|
||||
|
||||
== 坏掉的是什么 ==
|
||||
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?\n\n'
|
||||
+ '它会自己下载……'))
|
||||
|
||||
Python 三引号里 `\n` 变成真换行 -> JS 字符串字面量跨行 -> 语法非法
|
||||
-> **整个 script 块解析失败 -> 管理页所有按钮全死**。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ADMIN = ROOT / "relay" / "admin.py"
|
||||
|
||||
NEW_LINES = [
|
||||
" const name = btn.dataset.name || '这台电脑';",
|
||||
" // ⚠ 这里**不能用反斜杠 n** 拼多行文案。",
|
||||
" // ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成",
|
||||
" // **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,",
|
||||
" // 整个 script 块解析失败,管理页所有按钮全死。",
|
||||
" // (回归里的「管理页 JS 合法性」就是专门抓这个的。)",
|
||||
" //",
|
||||
" // 用 String.fromCharCode(10) 完全不依赖转义层,",
|
||||
" // 以后谁改这段都不会再踩。",
|
||||
" const NL = String.fromCharCode(10);",
|
||||
" const msg = ['让「' + name + '」现在更新到最新版?', '',",
|
||||
" '它会自己下载、静默安装并重启,全程不弹窗口,',",
|
||||
" '中转地址、配对码和各项设置都不变。'].join(NL);",
|
||||
" if (!confirm(msg)) {",
|
||||
" return;",
|
||||
" }",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 按行号修 admin.py 的 JS")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ADMIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "String.fromCharCode(10)" in text:
|
||||
print(" 已经修好了")
|
||||
return 0
|
||||
|
||||
lines = text.split("\n")
|
||||
|
||||
# 找 forEach 的锚点
|
||||
start = None
|
||||
for i, ln in enumerate(lines):
|
||||
if "querySelectorAll('button[data-update]')" in ln:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
print(" ✗ 找不到 data-update 的 forEach")
|
||||
return 1
|
||||
print(" 锚点在第 %d 行" % (start + 1))
|
||||
|
||||
# 锚点后第一行应该是 btn.onclick = async () => {
|
||||
j = start + 1
|
||||
while j < len(lines) and "btn.onclick" not in lines[j]:
|
||||
j += 1
|
||||
if j >= len(lines):
|
||||
print(" ✗ 找不到 btn.onclick")
|
||||
return 1
|
||||
# 下一个非空行是 const name = ...
|
||||
k = j + 1
|
||||
while k < len(lines) and not lines[k].strip():
|
||||
k += 1
|
||||
if "const name" not in lines[k]:
|
||||
print(" ✗ const name 那行不在预期位置(第 %d 行):%r"
|
||||
% (k + 1, lines[k][:70]))
|
||||
return 1
|
||||
|
||||
# k 之后到底几个坏行?找到 "if (!confirm(" 开始,到那个跨行字符串结束
|
||||
# (下一个以 ' return;' 结尾的块之前)
|
||||
end = k + 1
|
||||
found_confirm = False
|
||||
while end < len(lines):
|
||||
s = lines[end].strip()
|
||||
if s.startswith("if (!confirm("):
|
||||
found_confirm = True
|
||||
if found_confirm and s.startswith("return;"):
|
||||
break
|
||||
end += 1
|
||||
if not found_confirm:
|
||||
print(" ✗ 找不到 if (!confirm(")
|
||||
return 1
|
||||
|
||||
print(" 要替换第 %d 行 ~ 第 %d 行(%d 行)"
|
||||
% (k + 1, end, end - k + 1))
|
||||
for n in range(k, min(end + 1, k + 8)):
|
||||
print(" 坏行 %d: %r" % (n + 1, lines[n][:64]))
|
||||
|
||||
lines[k:end] = NEW_LINES
|
||||
|
||||
ADMIN.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ "\n".join(lines).encode("utf-8"))
|
||||
print(" ✓ 替换完成")
|
||||
|
||||
sys.path.insert(0, str(ROOT / "relay"))
|
||||
import admin # noqa: E402
|
||||
js = admin.ADMIN_JS
|
||||
bad = []
|
||||
for n, ln in enumerate(js.splitlines(), 1):
|
||||
s = ln.strip()
|
||||
if s.startswith("//"):
|
||||
continue
|
||||
if s.count("'") % 2:
|
||||
bad.append((n, ln[:70]))
|
||||
print(" 复核:单引号落单的行 = %d 个" % len(bad))
|
||||
for n, ln in bad[:5]:
|
||||
print(" %4d %s" % (n, ln))
|
||||
return 0 if not bad else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修 manager 页「更新」按钮那段 JS 的裸换行。
|
||||
|
||||
== 出了什么问题 ==
|
||||
|
||||
`ADMIN_JS` 是 Python 的**三引号字符串**。我在里面写:
|
||||
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?\n\n'
|
||||
+ '它会自己下载……\n'
|
||||
|
||||
本意是让 JS 收到 `\n`。但 Python 三引号里 `\n` 就是**真的换行**,
|
||||
于是插进 JS 变成:
|
||||
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?
|
||||
(空行)
|
||||
'
|
||||
|
||||
**字符串字面量跨行,JS 语法非法**。
|
||||
|
||||
后果不是"这一句坏了",而是**整个 <script> 块解析失败** ——
|
||||
管理页上所有按钮(改码/删除/踢下线/全选)全部失灵。回归里的
|
||||
「管理页 JS 合法性」这一关就是专门抓这个的(当初加它就是因为
|
||||
踩过一次同样的坑)。
|
||||
|
||||
== 怎么修 ==
|
||||
|
||||
**不用 `\n`**。改成把文案放进数组,用 `String.fromCharCode(10)` 连起来:
|
||||
|
||||
const NL = String.fromCharCode(10);
|
||||
const msg = ['第一行', '', '第二行'].join(NL);
|
||||
|
||||
这样**完全不依赖转义层** —— 不管以后谁编辑这段、用哪种
|
||||
引号、经不经过别的脚本生成,都不会再变成裸换行。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ADMIN = ROOT / "relay" / "admin.py"
|
||||
|
||||
# ⚠ 这些字符串里必须保留**真的换行**(就是要匹配源文件里那个坏掉的样子)
|
||||
OLD = """document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?
|
||||
|
||||
'
|
||||
+ '它会自己下载、静默安装并重启,全程不弹窗口,
|
||||
'
|
||||
+ '中转地址、配对码和各项设置都不变。')) {
|
||||
return;
|
||||
}"""
|
||||
|
||||
NEW = """document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
// ⚠ 这里**不能用反斜杠 n** 拼多行文案。
|
||||
// ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成
|
||||
// **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,
|
||||
// 整个 script 块解析失败,管理页所有按钮全死。
|
||||
// (回归里的「管理页 JS 合法性」就是抓这个的。)
|
||||
//
|
||||
// 用 String.fromCharCode(10) 完全不依赖转义层,
|
||||
// 以后谁改这段都不会再踩。
|
||||
const NL = String.fromCharCode(10);
|
||||
const msg = ['让「' + name + '」现在更新到最新版?', '',
|
||||
'它会自己下载、静默安装并重启,全程不弹窗口,',
|
||||
'中转地址、配对码和各项设置都不变。'].join(NL);
|
||||
if (!confirm(msg)) {
|
||||
return;
|
||||
}"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 修管理页 JS 的裸换行")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ADMIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "String.fromCharCode(10)" in text and OLD not in text:
|
||||
print(" 已经修好了")
|
||||
return 0
|
||||
|
||||
if OLD not in text:
|
||||
print(" ✗ 找不到坏掉的那段")
|
||||
i = text.find("data-update")
|
||||
print(text[max(0, i - 100):i + 700])
|
||||
return 1
|
||||
|
||||
text = text.replace(OLD, NEW, 1)
|
||||
ADMIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 替换完成")
|
||||
|
||||
# 复核:ADMIN_JS 里不能再有跨行的字符串字面量
|
||||
sys.path.insert(0, str(ROOT / "relay"))
|
||||
for name in list(sys.modules):
|
||||
if name in ("admin", "relay"):
|
||||
del sys.modules[name]
|
||||
import admin # noqa: E402
|
||||
js = admin.ADMIN_JS
|
||||
bad = []
|
||||
for n, ln in enumerate(js.splitlines(), 1):
|
||||
s = ln.strip()
|
||||
if s.startswith("//"):
|
||||
continue
|
||||
if s.count("'") % 2:
|
||||
bad.append((n, ln[:70]))
|
||||
print(" 复核:单引号落单的行 = %d 个" % len(bad))
|
||||
for n, ln in bad[:5]:
|
||||
print(" %4d %s" % (n, ln))
|
||||
return 0 if not bad else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -2,10 +2,10 @@
|
||||
r'''修上一步弄坏的登录表单。
|
||||
|
||||
上一步的正则插账号时把字典插坏了:
|
||||
urlencode({"username": "xmz7891", {"password": "120830"})
|
||||
urlencode({"username": "xmz7891", {"password": "" # 见下方注释:不再写死})
|
||||
^ 多了个左括号
|
||||
应该是:
|
||||
urlencode({"username": "xmz7891", "password": "120830"})
|
||||
urlencode({"username": "xmz7891", "password": "" # 见下方注释:不再写死})
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修掉下载响应的**重复 Cache-Control 头**。
|
||||
|
||||
== 问题 ==
|
||||
|
||||
实测 curl -I /download/ykxmz-setup-2.1.3.exe 返回:
|
||||
|
||||
Cache-Control: public, max-age=604800, immutable
|
||||
Cache-Control: public, max-age=604800, immutable <- 两条!
|
||||
|
||||
一条是 relay 自己发的(它 /download/ 那段本来就带了正确的头),
|
||||
另一条是 nginx 配置里那句 add_header 加的。
|
||||
|
||||
**为什么必须修**:HTTP 规范里 Cache-Control 是**可合并**的头,
|
||||
但重复出现时很多 CDN 会保守处理 —— Cloudflare 遇到重复的
|
||||
Cache-Control 有可能判定"缓存指令有歧义"而**跳过缓存**。
|
||||
那就等于我们白配了这一层。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
**只留 relay 发的那条**,nginx 不再自己加。
|
||||
|
||||
为什么留 relay 的而不是 nginx 的:
|
||||
* relay 知道每个文件的情况,是"单一事实来源"
|
||||
* 万一以后某个文件要不同的缓存策略,改 relay 就行,
|
||||
不用在 nginx 里再维护一套按路径匹配的规则
|
||||
* 少一处配置就少一处不一致
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(" ── 1. 从 nginx 配置里去掉重复的 add_header ──")
|
||||
# 用 sed 精确删掉那一行(它前面有 8 个空格缩进)
|
||||
out = run(
|
||||
"sudo sed -i '/add_header Cache-Control \"public, max-age=604800, "
|
||||
"immutable\" always;/d' /etc/nginx/sites-available/ykxmz && "
|
||||
"echo ' 已删'; "
|
||||
"echo ' --- 现在 /download/ 那段 ---'; "
|
||||
"sudo sed -n '/location \\/download\\//,/^ }/p' "
|
||||
"/etc/nginx/sites-available/ykxmz | sed 's/^/ /'",
|
||||
timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 加一句注释说明为什么这里不设 Cache-Control ──")
|
||||
out = run(
|
||||
"sudo sed -i 's| proxy_buffering on;|"
|
||||
" proxy_buffering on;\\n\\n"
|
||||
" # 这里**故意不设** Cache-Control —— relay 自己已经发了\\n"
|
||||
" # 正确的(public, max-age=604800, immutable)。\\n"
|
||||
" # 两边都设的话响应里会出现**两条** Cache-Control,\\n"
|
||||
" # Cloudflare 遇到重复的缓存指令有可能直接跳过缓存,\\n"
|
||||
" # 那就白配这一层了。|' "
|
||||
"/etc/nginx/sites-available/ykxmz && echo ' 已加注释'",
|
||||
timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 语法检查 + 重载 ──")
|
||||
out = run("sudo nginx -t 2>&1 | tail -2 && sudo systemctl reload nginx && "
|
||||
"sleep 2 && systemctl is-active nginx", timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 复查:现在应该只有一条 Cache-Control ──")
|
||||
out = run(
|
||||
'echo "--- 安装包 ---"; '
|
||||
'curl -sI --max-time 10 http://127.0.0.1:80/download/'
|
||||
'ykxmz-setup-2.1.3.exe | grep -ci "cache-control" | '
|
||||
'sed "s/^/ Cache-Control 条数: /"; '
|
||||
'curl -sI --max-time 10 http://127.0.0.1:80/download/'
|
||||
'ykxmz-setup-2.1.3.exe | grep -i "cache-control" | sed "s/^/ /"; '
|
||||
'echo "--- 长轮询(应该 no-store)---"; '
|
||||
'curl -sI --max-time 10 http://127.0.0.1:80/v1/health | '
|
||||
'grep -i "cache-control" | sed "s/^/ /"',
|
||||
timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -44,7 +44,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OLD = "REVA" + "edu120830" # 拼出来,本脚本自己不含明文
|
||||
OLD = "REVA" + "edu<改从环境变量读>" # 拼出来,本脚本自己不含明文
|
||||
|
||||
TARGETS = [
|
||||
ROOT / "_tests" / "test_relay_admin_login.py",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''监测单元适配"80 会 301 到 https"之后的新情况。
|
||||
|
||||
== 问题 ==
|
||||
|
||||
certbot 签证书时加了 `--redirect`,于是 nginx 的 80 端口现在这样:
|
||||
|
||||
listen 80; server_name yk.xxmxxs.top;
|
||||
return 301 https://$host$request_uri;
|
||||
|
||||
而监测单元原来的判断是"curl 拿到 200 才算健康"。curl **默认不跟跳转**,
|
||||
于是它拿到 301 -> 判定失败 -> 连续 3 次就启动紧急预案
|
||||
-> 预案重启中转服务 -> 再查还是 301 -> **又一个无限循环**。
|
||||
|
||||
这个坑和刚才那个"改监听地址忘了改探测地址"是同一类:
|
||||
**改了入口的行为,必须回头看所有探测入口的地方。**
|
||||
|
||||
== 改法 ==
|
||||
|
||||
把探测地址和判断都改成"看用户真实路径":
|
||||
|
||||
PRIMARY https://yk.xxmxxs.top/v1/health (经 Cloudflare,用户实际走的)
|
||||
FALLBACK http://127.0.0.1:13245/v1/health (中转本身,绕开 CF 和 nginx)
|
||||
|
||||
判断规则也放宽一点:
|
||||
* curl 加 -L 跟跳转(301 本身不算故障)
|
||||
* 200 才算健康
|
||||
* **如果 PRIMARY 挂着但 FALLBACK 好**,说明问题在
|
||||
Cloudflare / DNS / nginx 那一层 —— 这时需要的是
|
||||
"重启 nginx",而不是重启中转。所以预案里补一级。
|
||||
|
||||
== 为什么 PRIMARY 用 https 而不是 http ==
|
||||
|
||||
因为 http 现在只是个跳转,真正的服务在 https 上。
|
||||
用 https 探测,用户遇到什么我们就测到什么。
|
||||
(443 端口还没在 Lightsail 防火墙开之前,PRIMARY 会失败、
|
||||
FALLBACK 成功 —— 这时不会误判成"中转挂了",见上面的规则。)
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
|
||||
NEW_HEAD = '''# ============================================================================
|
||||
# 探测地址
|
||||
# ============================================================================
|
||||
#
|
||||
# PRIMARY:用户实际走的路(经 Cloudflare -> nginx -> 中转,全程 https)
|
||||
# FALLBACK:中转本身,绕开 Cloudflare 和 nginx
|
||||
#
|
||||
# ⚠ 两个都查是有意的:
|
||||
# * PRIMARY 挂 + FALLBACK 好 -> 问题在 CF / DNS / nginx 层
|
||||
# * 两个都挂 -> 中转服务本身出问题了
|
||||
# 分开判才能选对抢救动作(重启 nginx 还是重启中转)。
|
||||
PRIMARY_URL="${PRIMARY_URL:-https://yk.xxmxxs.top}"
|
||||
FALLBACK_URL="${FALLBACK_URL:-http://127.0.0.1:13245}"
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 监测单元适配 https + 301")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 改探测地址那几行 ──")
|
||||
script = (
|
||||
"python3 - <<'PYEOF'\n"
|
||||
"from pathlib import Path\n"
|
||||
"p = Path('%s/monitor.sh')\n"
|
||||
"lines = p.read_text(encoding='utf-8').splitlines()\n"
|
||||
"out = []\n"
|
||||
"done = False\n"
|
||||
"for l in lines:\n"
|
||||
" if l.startswith('PUBLIC_URL=') or l.startswith('FALLBACK_URL='):\n"
|
||||
" continue\n"
|
||||
" if l.strip() == '# 探测地址:域名优先(用户实际走的路),IP:80 兜底' and not done:\n"
|
||||
" out.append(%r)\n"
|
||||
" done = True\n"
|
||||
" continue\n"
|
||||
" out.append(l)\n"
|
||||
"p.write_text('\\n'.join(out) + '\\n', encoding='utf-8', newline='\\n')\n"
|
||||
"print(' 替换完成: %s' % done)\n"
|
||||
"PYEOF" % (RELAY_DIR, NEW_HEAD))
|
||||
out = run(script, timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 健康检查改成 -L + 分清两层 ──")
|
||||
script2 = (
|
||||
"python3 - <<'PYEOF'\n"
|
||||
"from pathlib import Path\n"
|
||||
"p = Path('%s/monitor.sh')\n"
|
||||
"t = p.read_text(encoding='utf-8')\n"
|
||||
"\n"
|
||||
"old = '''PROBE=\"$PUBLIC_URL\"\n"
|
||||
"CODE=$(curl -s -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\n"
|
||||
"if [ \"$CODE\" != \"200\" ] && [ -n \"$FALLBACK_URL\" ]; then\n"
|
||||
" PROBE=\"$FALLBACK_URL\"\n"
|
||||
" CODE=$(curl -s -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\n"
|
||||
"fi'''\n"
|
||||
"\n"
|
||||
"new = '''# -L 跟跳转:nginx 那边 http 会 301 到 https,\n"
|
||||
"# 不跟跳转的话拿到 301 会被误判成故障(这坑踩过一次)。\n"
|
||||
"PUB_CODE=$(curl -sL -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$PRIMARY_URL/v1/health\" 2>/dev/null)\n"
|
||||
"LOC_CODE=$(curl -s -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$FALLBACK_URL/v1/health\" 2>/dev/null)\n"
|
||||
"CODE=\"$PUB_CODE\"\n"
|
||||
"LAYER=\"\"\n"
|
||||
"if [ \"$PUB_CODE\" != \"200\" ] && [ \"$LOC_CODE\" = \"200\" ]; then\n"
|
||||
" LAYER=\"edge\" # 中转是好的,问题在 CF / DNS / nginx 层\n"
|
||||
"elif [ \"$PUB_CODE\" != \"200\" ] && [ \"$LOC_CODE\" != \"200\" ]; then\n"
|
||||
" LAYER=\"core\" # 中转本身也不行\n"
|
||||
"fi'''\n"
|
||||
"\n"
|
||||
"if old in t:\n"
|
||||
" t = t.replace(old, new, 1)\n"
|
||||
" print(' ok 健康检查替换完成')\n"
|
||||
"else:\n"
|
||||
" print(' x 没匹配上,实际内容:')\n"
|
||||
" i = t.find('CODE=$(curl')\n"
|
||||
" print(t[max(0,i-200):i+400])\n"
|
||||
"p.write_text(t, encoding='utf-8', newline='\\n')\n"
|
||||
"PYEOF" % RELAY_DIR)
|
||||
out = run(script2, timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 语法检查 + 跑一次 ──")
|
||||
out = run("bash -n %s/monitor.sh && echo ' 语法 OK'; "
|
||||
"printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||
"%s/monitor_state.txt; "
|
||||
"bash %s/monitor.sh; echo ' --- 状态 ---'; "
|
||||
"cat %s/monitor_state.txt; echo ' --- 日志尾 ---'; "
|
||||
"grep -v 'Permission denied' %s/monitor.log | tail -4"
|
||||
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR),
|
||||
timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 现在探测的各层 ──")
|
||||
out = run(
|
||||
'printf " %%-34s %%s\\n" "PRIMARY https://域名" '
|
||||
'"$(curl -sL -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||
'https://yk.xxmxxs.top/v1/health)"; '
|
||||
'printf " %%-34s %%s\\n" "FALLBACK 127.0.0.1:13245" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:13245/v1/health)"; '
|
||||
'printf " %%-34s %%s\\n" "nginx 本机:80(会 301)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:80/v1/health)"; '
|
||||
'printf " %%-34s %%s\\n" "nginx 本机:443" '
|
||||
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'--resolve yk.xxmxxs.top:443:127.0.0.1 '
|
||||
'https://yk.xxmxxs.top/v1/health)"',
|
||||
timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修监测单元:它现在查错了地址,在**误报网站挂了**。
|
||||
|
||||
== 怎么发现的 ==
|
||||
|
||||
把中转改成只监听 127.0.0.1(公网只能走 nginx)之后,跑状态检查看到:
|
||||
|
||||
公网 health 000 <- 因为 :13245 已经不对公网开了
|
||||
fails=2 <- 监测单元连续两次"失败"
|
||||
|
||||
也就是说监测单元**误以为网站挂了**,再失败一次就会启动紧急预案 ——
|
||||
而它抢救的方式是"重启中转服务",重启完它再查还是 000,再救……
|
||||
**会陷入无限重启循环**。
|
||||
|
||||
这是我自己改监听地址时没想到的连带影响。教训:改一个服务的
|
||||
暴露方式时,必须把所有"探测它"的地方一起看一遍。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
监测的地址应该以"**用户实际怎么访问**"为准,现在应该是:
|
||||
|
||||
http://yk.xxmxxs.top/v1/health (经过 Cloudflare + nginx)
|
||||
|
||||
但域名还没配好(DNS 还没指过来),所以加一个**回退**:
|
||||
域名不通就用 IP:80 试,只要有一个通就算健康。
|
||||
|
||||
这样:
|
||||
* 域名配好之前 -> 走 IP:80,正常监控
|
||||
* 域名配好之后 -> 走域名,监控的是用户真实路径
|
||||
* 两个都不通 -> 才是真挂了
|
||||
|
||||
== 顺带 ==
|
||||
|
||||
monitor.log 的属主问题:monitor.service 以 admin 跑,
|
||||
而 relay.log 之类的之前是 root 建的。已经 chown 过一次,
|
||||
但 monitor.log 是后来新生成的(仍是 root)—— 这次一并修掉,
|
||||
并且在 unit 里加一行让 systemd 自己保证目录属主。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, HOST as LS_HOST # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
FALLBACK = "http://%s:80" % LS_HOST
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 修监测单元:改探测地址 + 修日志属主")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 改 monitor.sh 的健康检查(域名优先,IP 兜底)──")
|
||||
# 把 PUBLIC_URL 的默认值换掉,并把 is_healthy 改成"两个都试"
|
||||
old_url = 'PUBLIC_URL="${PUBLIC_URL:-http://LS_HOST:13245}"'
|
||||
new_url = (
|
||||
'# 探测地址:域名优先(用户实际走的路),IP:80 兜底\n'
|
||||
'# (域名 DNS 还没指过来时用它,否则监测会一直误报)\n'
|
||||
'PUBLIC_URL="${PUBLIC_URL:-http://%s}"\n'
|
||||
'FALLBACK_URL="${FALLBACK_URL:-%s}"' % (DOMAIN, FALLBACK))
|
||||
|
||||
out = run(
|
||||
"grep -n 'PUBLIC_URL=' %s/monitor.sh | head -3" % RELAY_DIR,
|
||||
timeout=60, quiet=True)
|
||||
print(" 改前:")
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
script = (
|
||||
"python3 - <<'PYEOF'\n"
|
||||
"from pathlib import Path\n"
|
||||
"p = Path('%s/monitor.sh')\n"
|
||||
"t = p.read_text(encoding='utf-8')\n"
|
||||
"old = [l for l in t.splitlines() if l.startswith('PUBLIC_URL=')]\n"
|
||||
"if not old:\n"
|
||||
" print(' x 找不到 PUBLIC_URL 行')\n"
|
||||
"else:\n"
|
||||
" t = t.replace(old[0], %r, 1)\n"
|
||||
" # 健康检查改成两个都试\n"
|
||||
" a = 'CODE=$(curl -s -o /dev/null -w \\'%%{http_code}\\' --max-time "
|
||||
"\"$CHECK_TIMEOUT\" \\\\\\n \"$PUBLIC_URL/v1/health\" 2>/dev/null)'\n"
|
||||
" b = ('PROBE=\"$PUBLIC_URL\"\\n'\n"
|
||||
" 'CODE=$(curl -s -o /dev/null -w \\'%%{http_code}\\' "
|
||||
"--max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\\n'\n"
|
||||
" 'if [ \"$CODE\" != \"200\" ] && [ -n \"$FALLBACK_URL\" ]; then\\n'\n"
|
||||
" ' PROBE=\"$FALLBACK_URL\"\\n'\n"
|
||||
" ' CODE=$(curl -s -o /dev/null -w \\'%%{http_code}\\' "
|
||||
"--max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\\n'\n"
|
||||
" 'fi')\n"
|
||||
" if a in t:\n"
|
||||
" t = t.replace(a, b, 1)\n"
|
||||
" print(' ok 健康检查改成域名优先 + IP 兜底')\n"
|
||||
" else:\n"
|
||||
" print(' ! 健康检查那段没匹配上,手工看')\n"
|
||||
" p.write_text(t, encoding='utf-8', newline='\\n')\n"
|
||||
"PYEOF" % (RELAY_DIR, new_url))
|
||||
|
||||
out = run(script, timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
out = run("grep -n 'PUBLIC_URL=\\|FALLBACK_URL=\\|PROBE=' %s/monitor.sh "
|
||||
"| head -8" % RELAY_DIR, timeout=60, quiet=True)
|
||||
print(" 改后:")
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 修日志属主 + 让 systemd 保证它 ──")
|
||||
out = run(
|
||||
"sudo chown -R admin:admin %s && "
|
||||
"sudo sed -i '/^User=admin$/i "
|
||||
"# 日志文件和状态文件都要 admin 能写(服务以 admin 跑)\\n"
|
||||
"# 之前 relay.log / monitor.log 是 root 建的,导致 "
|
||||
"monitor.sh 报 Permission denied\\n"
|
||||
"PermissionsStartOnly=true' "
|
||||
"/etc/systemd/system/ykxmz-monitor.service 2>/dev/null; "
|
||||
"sudo touch %s/monitor.log %s/monitor_state.txt && "
|
||||
"sudo chown admin:admin %s/*.log %s/*.txt %s/*.md 2>/dev/null; "
|
||||
"ls -la %s/*.log | awk '{print \" \" $3\":\"$4\" \" $9}'"
|
||||
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR,
|
||||
RELAY_DIR, RELAY_DIR), timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 重置监测状态 + 跑一次 ──")
|
||||
out = run(
|
||||
"sudo systemctl daemon-reload; "
|
||||
"printf 'fails=0\\nlast_rescue=0\\nlast_code=200\\n' > "
|
||||
"%s/monitor_state.txt; "
|
||||
"bash %s/monitor.sh; echo '--- 状态 ---'; "
|
||||
"cat %s/monitor_state.txt; echo '--- 日志尾 ---'; "
|
||||
"tail -3 %s/monitor.log" % (RELAY_DIR, RELAY_DIR, RELAY_DIR,
|
||||
RELAY_DIR),
|
||||
timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 验证探测链路 ──")
|
||||
out = run(
|
||||
'printf " %-28s %s\\n" "域名(还没配)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://%s/v1/health)"; '
|
||||
'printf " %-28s %s\\n" "IP:80 (兜底)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://LS_HOST:80/v1/health)"; '
|
||||
'printf " %-28s %s\\n" "IP:13245 (已关闭)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://LS_HOST:13245/v1/health)"' % DOMAIN,
|
||||
timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OLD = "REVA" + "edu120830"
|
||||
OLD = "REVA" + "edu<改从环境变量读>"
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''撤掉 Gitee 发行版介绍里泄漏的公网 IP。
|
||||
|
||||
== 用户报的 ==
|
||||
|
||||
"那个发行版的介绍里,服务器公网 IP 地址暴露出去了"
|
||||
|
||||
来源:发布脚本(gitee_release.py / release_all.py)把
|
||||
_build/CHANGELOG.md 当"更新说明"传给 Gitee 建发行版,
|
||||
而 CHANGELOG 的 2.1.3 那节里写了 <源站IP>。
|
||||
|
||||
工作区那边已经清了(_build/scrub_server_ip.py),
|
||||
但**已经发出去的发行版介绍还在 Gitee 上**,得用 API 改回去。
|
||||
|
||||
== 接口 ==
|
||||
|
||||
PATCH /api/v5/repos/{owner}/{repo}/releases/{id}
|
||||
参数:access_token + body(新的发行版介绍)
|
||||
|
||||
== 顺带检查所有发行版 ==
|
||||
|
||||
不只 v2.1.3 —— 从 2.1.0 起我就在"把 CHANGELOG 当更新说明",
|
||||
而 2.1.0/2.1.1 那几节也提到过服务器。全部扫一遍、有 IP 的都改掉。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ⚠ 这个脚本要"在所有发行版介绍里找这个 IP",所以它必须知道 IP。
|
||||
# 但**不能在源码里写死** —— 写了就等于把 IP 又发到公开仓库里,
|
||||
# 正是这个脚本要防的事。
|
||||
# 从 lssh 取(那里会读环境变量 YKXMZ_LS_HOST 或
|
||||
# _build/.lightsail_host,后者已 gitignore)。
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from lssh import HOST as IP, HOST as LS_HOST # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
OWNER_REPO = "xmz7891/ykxmz"
|
||||
|
||||
|
||||
def api(url: str, data: bytes = None, method: str = None) -> tuple:
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("User-Agent", "ykxmz-release-fix")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return r.status, json.loads(r.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read().decode("utf-8", "replace")[:300]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
token_file = ROOT / "_build" / ".gitee_token"
|
||||
if not token_file.is_file():
|
||||
print(" ✗ 找不到 _build/.gitee_token")
|
||||
return 1
|
||||
token = token_file.read_text(encoding="utf-8").strip()
|
||||
|
||||
print("=" * 62)
|
||||
print(" 撤掉 Gitee 发行版介绍里的公网 IP")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 列出所有发行版 ──")
|
||||
code, data = api(
|
||||
"https://gitee.com/api/v5/repos/%s/releases"
|
||||
"?access_token=%s&per_page=60" % (OWNER_REPO, token))
|
||||
if code != 200:
|
||||
print(" ✗ 列不出来: %s" % data)
|
||||
return 1
|
||||
print(" 共 %d 个发行版" % len(data))
|
||||
|
||||
print()
|
||||
print(" ── 2. 逐个检查 body,有 IP 的改掉 ──")
|
||||
fixed = 0
|
||||
for rel in data:
|
||||
tag = rel.get("tag_name", "?")
|
||||
rid = rel.get("id")
|
||||
body = rel.get("body") or ""
|
||||
if IP not in body:
|
||||
print(" %-10s 干净" % tag)
|
||||
continue
|
||||
|
||||
n = body.count(IP)
|
||||
new_body = body.replace(IP, DOMAIN)
|
||||
print(" %-10s ⚠ 有 %d 处 -> 改掉" % (tag, n))
|
||||
|
||||
# ⚠ 两个坑:
|
||||
#
|
||||
# 1. 必须用 **JSON**。第一版 urlencode 拼了 data,但 api()
|
||||
# 见 data 非空就声明 Content-Type: application/json ——
|
||||
# 内容和声明不一致,Gitee 回 400(一页 HTML 错误页)。
|
||||
#
|
||||
# 2. **tag_name 和 name 必须一起传**,即使不改它们。
|
||||
# 漏了会报 {"messages":["tag_name is missing",
|
||||
# "name is missing"]}。
|
||||
# (PATCH 语义上应该允许只改 body,但 Gitee 的实现要求
|
||||
# 把标识字段带上 —— 那就带上。)
|
||||
payload = json.dumps({
|
||||
"access_token": token,
|
||||
"tag_name": tag,
|
||||
"name": rel.get("name") or tag,
|
||||
"body": new_body,
|
||||
}).encode("utf-8")
|
||||
code2, resp = api(
|
||||
"https://gitee.com/api/v5/repos/%s/releases/%s"
|
||||
% (OWNER_REPO, rid), data=payload, method="PATCH")
|
||||
if code2 == 200:
|
||||
print(" ✓ 已更新")
|
||||
fixed += 1
|
||||
else:
|
||||
print(" ✗ 更新失败 (%s): %s" % (code2, resp))
|
||||
|
||||
print()
|
||||
print(" ── 3. 复查 ──")
|
||||
code, data = api(
|
||||
"https://gitee.com/api/v5/repos/%s/releases"
|
||||
"?access_token=%s&per_page=60" % (OWNER_REPO, token))
|
||||
still = [r.get("tag_name") for r in data
|
||||
if IP in (r.get("body") or "")]
|
||||
if still:
|
||||
print(" ⚠ 还有: %s" % ", ".join(still))
|
||||
else:
|
||||
print(" ✅ 所有发行版介绍里都没有这个 IP 了")
|
||||
|
||||
print()
|
||||
print(" 改了 %d 个发行版" % fixed)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修我自己 scrub 时弄坏的 3 个文件。
|
||||
|
||||
== 怎么坏的 ==
|
||||
|
||||
scrub_old_password.py 里对这个模式:
|
||||
|
||||
{"password": "<六位数字>"}
|
||||
|
||||
做了个"聪明"的替换:
|
||||
|
||||
{"password": "" # 见下方注释:不再写死}
|
||||
|
||||
—— 把 `"<六位数字>"` 换成了带注释的东西,**塞进了字典字面量中间**,
|
||||
于是 `{` 永远不闭合,三个文件全语法错。
|
||||
|
||||
教训:**批量替换不能往表达式中间塞注释**。
|
||||
这次改成把整行替换掉,并在文件顶部加一个读密码的辅助函数。
|
||||
|
||||
(同一批被改的其它文件只是字符串内容变了,语法没坏,
|
||||
这里只修真正语法出错的三个。)
|
||||
|
||||
== 顺带 ==
|
||||
|
||||
这个脚本本身也演示了"怎么在运维脚本里拿密码而不写死":
|
||||
从环境变量或 _build/.admin_password 读(后者已 gitignore)。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
BROKEN_LINE = re.compile(
|
||||
r'^(\s*)form = urllib\.parse\.urlencode\(\{"password": ""'
|
||||
r'\s*# 见下方注释:不再写死\}\)\.encode\(\)\s*$')
|
||||
|
||||
# ⚠ 不要手写文件列表。
|
||||
#
|
||||
# 第一版我列了三个文件名(照语法检查的输出抄的),
|
||||
# 结果一跑发现还有两个同样的坏文件没修 —— 列表是**观察到的**,
|
||||
# 不是**穷尽的**。改成全仓库扫这个模式,漏不掉。
|
||||
SCAN_DIRS = ("_build", "_tests", "windows", "relay", "installer")
|
||||
|
||||
REPLACEMENT = (
|
||||
'{indent}form = urllib.parse.urlencode('
|
||||
'{{"password": _admin_password()}}).encode()')
|
||||
|
||||
HELPER = '''
|
||||
|
||||
# ⚠ 不再把密码写死在脚本里。
|
||||
#
|
||||
# 这个仓库是公开的,密码写进来就等于公开(2026-10-08 踩过:
|
||||
# 旧密码曾被写进安装包的许可页,每个装软件的人都能看到)。
|
||||
# 现在从环境变量或 _build/.admin_password(已 gitignore)读 ——
|
||||
# 读不到就直接报错,别偷偷用一个默认值。
|
||||
def _admin_password() -> str:
|
||||
import os
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,"
|
||||
"或把密码写进 _build/.admin_password")
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 修被 scrub 弄坏的 3 个文件")
|
||||
print("=" * 58)
|
||||
|
||||
fixed = 0
|
||||
# 全仓库找坏模式,而不是照抄一份文件名列表
|
||||
targets = []
|
||||
for d in SCAN_DIRS:
|
||||
for p in sorted((ROOT / d).rglob("*.py")):
|
||||
try:
|
||||
if BROKEN_LINE.search(p.read_text(encoding="utf-8-sig")):
|
||||
targets.append(p)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
print(" 扫到 %d 个待修文件" % len(targets))
|
||||
print()
|
||||
for path in targets:
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
raw = path.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
lines = text.splitlines()
|
||||
hit = False
|
||||
for i, line in enumerate(lines):
|
||||
m = BROKEN_LINE.match(line)
|
||||
if m:
|
||||
lines[i] = REPLACEMENT.format(indent=m.group(1))
|
||||
hit = True
|
||||
|
||||
if not hit:
|
||||
continue
|
||||
|
||||
text = "\n".join(lines) + "\n"
|
||||
|
||||
# 顶部加辅助函数(放在 `import` 全部结束之后)
|
||||
if "_admin_password" not in text.split("def _admin_password")[0]:
|
||||
m2 = None
|
||||
for m in re.finditer(r"^(?:import|from)\s+\S+.*$", text, re.M):
|
||||
m2 = m
|
||||
if m2:
|
||||
text = text[:m2.end()] + "\n" + HELPER + text[m2.end():]
|
||||
else:
|
||||
text = HELPER + text
|
||||
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
print(" ✓ %s" % rel)
|
||||
fixed += 1
|
||||
|
||||
print()
|
||||
print(" 修了 %d 个" % fixed)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给服务器配上公共 DNS,让它能解析自己的域名。
|
||||
|
||||
== 问题 ==
|
||||
|
||||
Lightsail 默认用内网解析器 172.26.0.2:
|
||||
|
||||
Current DNS Server: 172.26.0.2
|
||||
dig @172.26.0.2 yk.xxmxxs.top -> 空
|
||||
dig @1.1.1.1 yk.xxmxxs.top -> 104.21.54.45 ✅
|
||||
dig @8.8.8.8 yk.xxmxxs.top -> 104.21.54.45 ✅
|
||||
|
||||
用户刚加完 DNS 记录,公共解析器已经更新,但 Amazon 那个内网解析器
|
||||
**还缓存着"这个域名不存在"**(负缓存,通常几分钟到一小时)。
|
||||
|
||||
后果不只是"监测误报"——任何在服务器上解析该域名的东西都会失败。
|
||||
所以配上公共 DNS,既有 IPv4 也有 IPv6,并且让内网那个当后备
|
||||
(内网解析器对 AWS 自家服务更快,留着有用)。
|
||||
|
||||
== 怎么配(Debian 13 + systemd-resolved)==
|
||||
|
||||
/etc/systemd/resolved.conf.d/ 下放一个 drop-in:
|
||||
|
||||
[Resolve]
|
||||
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2606:4700:4700::1001
|
||||
FallbackDNS=172.26.0.2
|
||||
Domains=~.
|
||||
|
||||
`Domains=~.` 表示"所有域名都用上面这些",把内网那个降为后备。
|
||||
|
||||
改完 `systemctl restart systemd-resolved` 生效。
|
||||
|
||||
== 为什么不直接改 /etc/resolv.conf ==
|
||||
|
||||
它是指向 systemd-resolved stub(127.0.0.53)的软链接,
|
||||
手改会被覆盖。要改就改 resolved 的配置。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
|
||||
CONF = '''# ykxmz:给这台服务器配上公共 DNS。
|
||||
#
|
||||
# 起因:Lightsail 默认用内网解析器 172.26.0.2,而用户刚加的
|
||||
# yk.xxmxxs.top 记录它**还缓存着"不存在"**(负缓存),
|
||||
# 导致服务器自己解析不了这个域名 —— 监测单元因此一直误报网站挂了。
|
||||
#
|
||||
# 公共解析器(1.1.1.1 / 8.8.8.8)已经能正确解析,所以配上来。
|
||||
# 内网解析器降为后备:它对 AWS 自家服务(比如实例元数据、
|
||||
# S3 内网端点)响应更快,留着有用。
|
||||
[Resolve]
|
||||
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2606:4700:4700::1001
|
||||
FallbackDNS=172.26.0.2
|
||||
# 所有域名都用上面这些(内网那个只在全部失败时才用)
|
||||
Domains=~.
|
||||
# 顺便开一下 DNSSEC 校验
|
||||
DNSSEC=allow-downgrade
|
||||
DNSOverTLS=no
|
||||
Cache=yes
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 给服务器配公共 DNS")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 写 drop-in 配置 ──")
|
||||
run("sudo mkdir -p /etc/systemd/resolved.conf.d", timeout=120, quiet=True)
|
||||
tmp = Path(tempfile.gettempdir()) / "ykxmz-dns.conf"
|
||||
tmp.write_text(CONF, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/ykxmz-dns.conf")
|
||||
out = run("sudo mv /tmp/ykxmz-dns.conf /etc/systemd/resolved.conf.d/ykxmz.conf "
|
||||
"&& echo ' 已装'", timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 重启 systemd-resolved ──")
|
||||
out = run("sudo systemctl restart systemd-resolved; sleep 3; "
|
||||
"systemctl is-active systemd-resolved; "
|
||||
"echo ' --- 现在用的 DNS ---'; "
|
||||
"resolvectl status 2>/dev/null | grep -E 'DNS Servers|Fallback|Current DNS' "
|
||||
"| head -6", timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 刷新缓存 + 验证解析 ──")
|
||||
out = run("sudo resolvectl flush-caches 2>/dev/null; "
|
||||
"echo ' getent:'; getent hosts %s | sed 's/^/ /'; "
|
||||
"echo ' dig 默认:'; dig +short +time=5 %s | sed 's/^/ /'; "
|
||||
"echo ' python:'; python3 -c \"import socket;"
|
||||
"print(' ', [a[4][0] for a in socket.getaddrinfo('%s',443)])\""
|
||||
% (DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. curl 域名(监测单元就是这么查的)──")
|
||||
out = run(
|
||||
"printf ' %-30s %s\\n' 'https 域名' "
|
||||
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||
"https://%s/v1/health)\"; "
|
||||
"printf ' %-30s %s\\n' 'http 域名(会 301)' "
|
||||
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||
"http://%s/v1/health)\"; "
|
||||
"printf ' %-30s %s\\n' 'https 域名 -L(跟跳转)' "
|
||||
"\"$(curl -sL -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||
"https://%s/v1/health)\"; "
|
||||
"printf ' %-30s %s\\n' '中转本机' "
|
||||
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "
|
||||
"http://127.0.0.1:13245/v1/health)\""
|
||||
% (DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 重跑监测 ──")
|
||||
run("printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||
"/opt/ykxmz-relay/monitor_state.txt", timeout=60, quiet=True)
|
||||
run("bash /opt/ykxmz-relay/monitor.sh", timeout=180, quiet=True)
|
||||
out = run("cat /opt/ykxmz-relay/monitor_state.txt", timeout=60, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,384 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把语音播报改成**多条方案降级**,修 Win7 上用不了的问题。
|
||||
|
||||
== 用户反馈 ==
|
||||
|
||||
"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"
|
||||
|
||||
== 原来只有一条路,而且有几个 Win7 特有的坑 ==
|
||||
|
||||
powershell -NoProfile -WindowStyle Hidden -Command
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||
|
||||
坑一:**`powershell` 只按名字找**
|
||||
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||
精简版系统、或者从服务/计划任务拉起时 PATH 里可能没有 ——
|
||||
找不到就**静默失败**(Popen 不报错,因为进程起来了,只是在里面失败)。
|
||||
|
||||
坑二:**Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
|
||||
PS 3.0 起才按 UTF-16 处理。所以中文经 `-Command` 传进去在 PS 2.0 上
|
||||
很容易乱码 —— 表现是"念出来是乱码"或者干脆不念。
|
||||
|
||||
坑三:**`System.Speech` 需要 .NET Framework 3.0+**
|
||||
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||
**根本不需要 .NET**。绕开 .NET 更稳。
|
||||
|
||||
== 现在的做法:降级链 ==
|
||||
|
||||
按"依赖越少越优先"排:
|
||||
|
||||
1. **cscript + VBScript + SAPI.SpVoice**
|
||||
VBScript 从 Windows 98 就有,SAPI5 是系统组件 ——
|
||||
两个都不需要装东西,也不受 PowerShell 版本 / PATH / .NET 影响。
|
||||
**这是最可能在 Win7 上通的一条。**
|
||||
|
||||
2. **wscript + VBScript + SAPI.SpVoice**
|
||||
同上,换宿主(cscript 万一被安全软件拦了)。
|
||||
|
||||
3. **powershell(绝对路径)+ SAPI.SpVoice COM**
|
||||
绕开 .NET,但仍要 PowerShell。
|
||||
|
||||
4. **powershell(绝对路径)+ System.Speech**
|
||||
原来的做法,只是改用绝对路径。
|
||||
|
||||
5. **powershell(按 PATH)+ System.Speech**
|
||||
最原始的做法,留作最后兜底。
|
||||
|
||||
**一条通了就记住它**(模块级缓存),后面都直接走那条 ——
|
||||
不然每播一次都要把上面几条全试一遍,慢且难看。
|
||||
|
||||
== 顺手改进 ==
|
||||
|
||||
* `say()` 原来用 `Popen` 不等待 —— **失败了也返回"已播报"**。
|
||||
现在**等它结束并看退出码**,真失败会明确报错,不再骗人。
|
||||
代价是播报变成了同步的(要等念完)—— 但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
* VBScript 文件用 **mbcs(ANSI)** 编码写。写成 UTF-8 的话
|
||||
cscript 读出来是乱码 —— 这是 VBScript 的老规矩。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||
|
||||
# ---------------------------------------------------------------- 要替换的旧代码
|
||||
OLD_VOICES = ''' def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
script = (
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]'''
|
||||
|
||||
OLD_SAY = ''' safe = text.replace("'", "''")
|
||||
parts = [
|
||||
"Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
|
||||
]
|
||||
if chosen:
|
||||
parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} "
|
||||
"catch { };")
|
||||
if speed:
|
||||
parts.append(f"$s.Rate = {speed};")
|
||||
parts.append(f"$s.Speak('{safe}')")
|
||||
script = "".join(parts)
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
except Exception as exc:
|
||||
raise ActionError(f"TTS 启动失败: {exc}") from exc
|
||||
return f"已播报({chosen or '默认音色'})"'''
|
||||
|
||||
NEW = ''' # ------------------------------------------------------------------
|
||||
# 语音播报(系统自带 TTS)
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明,
|
||||
# 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题,
|
||||
# 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。
|
||||
#
|
||||
# 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM
|
||||
# -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech
|
||||
|
||||
def _powershell_path(self) -> str:
|
||||
"""找 powershell.exe。**先按绝对路径**,PATH 只作兜底。
|
||||
|
||||
为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时
|
||||
是**静默失败**的(进程根本没起来,但 Popen 不报错)。
|
||||
"""
|
||||
import shutil as _sh
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / \\
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
if os.name == "nt" and cand.is_file():
|
||||
return str(cand)
|
||||
return _sh.which("powershell") or ""
|
||||
|
||||
def _write_vbs(self, text: str, voice: str, rate: int) -> Path:
|
||||
"""生成一个播报用的 .vbs。
|
||||
|
||||
⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 ——
|
||||
cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。
|
||||
"""
|
||||
import tempfile
|
||||
lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")']
|
||||
if voice:
|
||||
# 按描述匹配音色。找不到就 silently 用默认 ——
|
||||
# 音色选不上不该让整条播报失败。
|
||||
lines.append('Dim i')
|
||||
lines.append('For i = 0 To v.GetVoices().Count - 1')
|
||||
lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then'
|
||||
% voice.replace('"', '""'))
|
||||
lines.append(' Set v.Voice = v.GetVoices().Item(i)')
|
||||
lines.append(' End If')
|
||||
lines.append('Next')
|
||||
if rate:
|
||||
# SAPI 的 Rate 范围是 -10..10,跟我们的设置一致
|
||||
lines.append("v.Rate = %d" % max(-10, min(10, rate)))
|
||||
# 长文本 VBScript 单行有长度限制,所以**分句拼接**
|
||||
chunk = text.replace('"', '""').replace("\\r", " ").replace("\\n", " ")
|
||||
while chunk:
|
||||
piece, chunk = chunk[:400], chunk[400:]
|
||||
lines.append('v.Speak "%s"' % piece)
|
||||
path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs"
|
||||
path.write_bytes(("\\r\\n".join(lines) + "\\r\\n").encode("mbcs",
|
||||
errors="replace"))
|
||||
return path
|
||||
|
||||
def _tts_commands(self, text: str, voice: str, rate: int) -> list:
|
||||
"""按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。"""
|
||||
cmds = []
|
||||
vbs = None
|
||||
try:
|
||||
vbs = self._write_vbs(text, voice, rate)
|
||||
except Exception: # noqa: BLE001
|
||||
vbs = None
|
||||
if vbs is not None:
|
||||
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||
|
||||
ps = self._powershell_path()
|
||||
esc = text.replace("'", "''")
|
||||
if ps:
|
||||
# 3:绕开 .NET,只用系统原生 SAPI5
|
||||
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
|
||||
if voice:
|
||||
sapi.append("foreach ($o in $v.GetVoices()) { "
|
||||
"if ($o.GetDescription() -like '*%s*') { "
|
||||
"$v.Voice = $o } };" % voice.replace("'", "''"))
|
||||
if rate:
|
||||
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sapi.append("$v.Speak('%s')" % esc)
|
||||
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sapi)]))
|
||||
|
||||
# 4:原来的做法,但用绝对路径
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
|
||||
"Hidden", "-Command", "".join(sp)]))
|
||||
|
||||
# 5:最原始的(PATH + System.Speech),兜底
|
||||
import shutil as _sh
|
||||
if _sh.which("powershell"):
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-path", ["powershell", "-NoProfile",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sp)]))
|
||||
return cmds
|
||||
|
||||
def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没有 .NET 3.0 的话
|
||||
System.Speech 那条路是列不出来的,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
out = []
|
||||
ps = self._powershell_path()
|
||||
if ps:
|
||||
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if not out:
|
||||
# 退到 System.Speech 再列一次
|
||||
if ps:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis"
|
||||
".SpeechSynthesizer).GetInstalledVoices()"
|
||||
" | ForEach-Object { $_.VoiceInfo.Name }")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
return out
|
||||
|
||||
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||
|
||||
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
"""
|
||||
if not text.strip():
|
||||
raise ActionError("播报内容为空")
|
||||
if not IS_WINDOWS:
|
||||
raise ActionError("语音播报目前仅支持 Windows")
|
||||
|
||||
chosen = (voice or "").strip() or str(
|
||||
self.store.get("tts_voice") or "").strip()
|
||||
try:
|
||||
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 0
|
||||
speed = max(-10, min(10, speed))
|
||||
|
||||
cmds = self._tts_commands(text, chosen, speed)
|
||||
if not cmds:
|
||||
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||
"(没有 cscript 也没有 powershell?)")
|
||||
|
||||
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
|
||||
cached = globals().get("_TTS_LAST_OK")
|
||||
if cached:
|
||||
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
|
||||
|
||||
tried = []
|
||||
for name, cmd in cmds:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, timeout=180,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
except FileNotFoundError:
|
||||
tried.append("%s(没有这个程序)" % name)
|
||||
continue
|
||||
except subprocess.TimeoutExpired:
|
||||
tried.append("%s(超时)" % name)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
tried.append("%s(%s)" % (name, type(exc).__name__))
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
globals()["_TTS_LAST_OK"] = name
|
||||
return "已播报(%s,用 %s)" % (chosen or "默认音色", name)
|
||||
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
|
||||
tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40]))
|
||||
|
||||
raise ActionError("语音播报失败,试过:" + ";".join(tried))'''
|
||||
|
||||
NEW_VOICES = ''' def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
ps = self._powershell_path()
|
||||
if not ps:
|
||||
return []
|
||||
out = []
|
||||
for script in (
|
||||
# 优先 SAPI5 COM
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
|
||||
# 退到 System.Speech
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
|
||||
):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if out:
|
||||
return out
|
||||
return []'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 语音播报:改成多条方案降级")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ACTIONS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "_tts_commands" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
missing = [k for k, v in {"list_voices": OLD_VOICES, "say": OLD_SAY}.items()
|
||||
if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点就位")
|
||||
|
||||
t = t.replace(OLD_VOICES, NEW_VOICES, 1)
|
||||
t = t.replace(OLD_SAY, NEW, 1)
|
||||
|
||||
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ list_voices 改用 SAPI5 COM(+ System.Speech 兜底)")
|
||||
print(" ✓ say 改成降级链 + 等待退出码 + 记住可用的那条")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修 verify_all.ps1 里被转义弄坏的那段。
|
||||
|
||||
== 弄坏了什么 ==
|
||||
|
||||
我用 `python -c "..."` 传的脚本里带了 `\$stageOut`,
|
||||
PowerShell 先解析了一遍,把 `$stageOut` 变成了 `\`,
|
||||
结果插进去的是:
|
||||
|
||||
\ = & python (Join-Path \ "_tests\test_admin_update.py") 2>&1
|
||||
Show-Fails Record "后台更新按钮" (\ -eq 0)
|
||||
|
||||
`Show-Fails` 和 `Record` 挤在一行,变量名也没了。
|
||||
|
||||
== 教训 ==
|
||||
|
||||
凡是**要写进文件的内容里带 `$`**,一律写成 .py 文件再跑,
|
||||
不要用 `python -c "..."` 穿过 PowerShell。这个坑今天踩了太多次。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
# 被弄坏的那两段
|
||||
BROKEN = '''\\ = & python (Join-Path \\ "_tests\\test_admin_update.py") 2>&1
|
||||
Show-Fails Record "后台更新按钮" (\\ -eq 0)
|
||||
|
||||
\\ = & python (Join-Path \\ "_tests\\test_notice_options.py") 2>&1
|
||||
Show-Fails Record "弹窗位置和大小" (\\ -eq 0)'''
|
||||
|
||||
FIXED = '''$stageOut = & python (Join-Path $root "_tests\\test_admin_update.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "后台更新按钮" ($LASTEXITCODE -eq 0)
|
||||
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_notice_options.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "弹窗位置和大小" ($LASTEXITCODE -eq 0)'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if FIXED in text:
|
||||
print(" 已经修好了")
|
||||
return 0
|
||||
|
||||
if BROKEN not in text:
|
||||
print(" ✗ 找不到被弄坏的那段。当前 3a12 附近的内容:")
|
||||
i = text.find("3a12")
|
||||
print(text[max(0, i - 50):i + 700])
|
||||
return 1
|
||||
|
||||
text = text.replace(BROKEN, FIXED, 1)
|
||||
|
||||
# 顺手把 "3a12/7" 改成 "3a12/9"(关卡数变了)
|
||||
text = text.replace('Banner "3a12/7', 'Banner "3a12/9')
|
||||
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 修好了")
|
||||
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_admin_update.py")',
|
||||
'$stageOut = & python (Join-Path $root "_tests\\test_notice_options.py")',
|
||||
'Record "后台更新按钮"',
|
||||
'Record "弹窗位置和大小"'):
|
||||
ok = token in back
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:58]))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给 xxmxxs.top 签 Let's Encrypt 证书,并加回 443 块。
|
||||
|
||||
== 现在的状态 ==
|
||||
|
||||
* `xxmxxs.top` 的 A 记录已经指过来(走 Cloudflare 代理)✓
|
||||
* `www.xxmxxs.top` **还没解析** —— 先不带它签
|
||||
* 80 端口上博客已经能用(`http://<服务器IP>/console` 可以进控制台)
|
||||
|
||||
== 为什么先不签 www ==
|
||||
|
||||
`certbot -d xxmxxs.top -d www.xxmxxs.top` 会**同时校验两个域名**。
|
||||
www 没解析的话整张证书都签不下来(Let's Encrypt 要求所有域名都通过校验)。
|
||||
所以先签主域名,等 www 记录加了再 `--expand` 补进去。
|
||||
|
||||
== 走 Cloudflare 代理时的一个风险 ==
|
||||
|
||||
ACME 的 HTTP-01 挑战要求:
|
||||
http://xxmxxs.top/.well-known/acme-challenge/<token>
|
||||
能直接返回文件。
|
||||
|
||||
但 Cloudflare 如果开了 **"Always Use HTTPS"**,会把 80 端口的请求
|
||||
301 到 https —— **certbot 就跟不上那个跳转,签不下来**。
|
||||
|
||||
不过 Cloudflare **默认不代理 `.well-known/acme-challenge/`**,
|
||||
而且我们是 `--nginx` 插件(它会临时插一个 location)。
|
||||
真出问题的话,报错信息会明确说"challenge 拿不到",
|
||||
那时候再去 Cloudflare 加一条"绕过规则"。
|
||||
|
||||
== 443 块怎么加回来 ==
|
||||
|
||||
之前在配置文件里**故意没写** 443 段(没证书时 `nginx -t` 会失败:
|
||||
"no ssl_certificate is defined")。
|
||||
现在证书有了,用 `certbot --nginx` 让它**自己写** —— 比我手写靠谱,
|
||||
因为它会把续期需要的所有行都带上。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 给 xxmxxs.top 签证书 + 开 HTTPS")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
print("── 1. 签证书(certbot 自己改 nginx 配置)──")
|
||||
out = run(r'''
|
||||
sudo certbot --nginx -d xxmxxs.top \
|
||||
--non-interactive --agree-tos --register-unsafely-without-email \
|
||||
--redirect --keep-until-expiring 2>&1 | tail -20
|
||||
''', timeout=900, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
print()
|
||||
print("── 2. 复核 ──")
|
||||
out = run(r'''
|
||||
echo " --- 证书文件 ---"
|
||||
sudo ls -1 /etc/letsencrypt/live/ 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " --- 证书信息 ---"
|
||||
sudo openssl x509 -in /etc/letsencrypt/live/xxmxxs.top/fullchain.pem \
|
||||
-noout -subject -dates 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " --- nginx 配置现在有几个 server 块 ---"
|
||||
sudo grep -cE "^\s*server \{" /etc/nginx/sites-available/halo | sed 's/^/ halo: /'
|
||||
|
||||
echo
|
||||
echo " --- 443 块有了吗 ---"
|
||||
sudo grep -c "listen 443 ssl" /etc/nginx/sites-available/halo | sed 's/^/ listen 443: /'
|
||||
|
||||
echo
|
||||
echo " --- nginx 检查 + 重载 ---"
|
||||
sudo nginx -t 2>&1 | tail -2 | sed 's/^/ /'
|
||||
sudo systemctl reload nginx && echo " 重载完成" || echo " 重载失败"
|
||||
echo " nginx: $(systemctl is-active nginx)"
|
||||
|
||||
echo
|
||||
echo " --- 各入口复核 ---"
|
||||
printf " %-42s " "http://<服务器IP>/console (IP 控制台)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1/console
|
||||
printf " %-42s " "https://xxmxxs.top/ (博客)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 15 https://xxmxxs.top/
|
||||
printf " %-42s " "https://xxmxxs.top/console (控制台)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 https://xxmxxs.top/console
|
||||
printf " %-42s " "http://xxmxxs.top/ (应该 301 到 https)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 http://xxmxxs.top/
|
||||
printf " %-42s " "https://yk.xxmxxs.top/ (中转站没坏)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 https://yk.xxmxxs.top/
|
||||
printf " %-42s " "http://<服务器IP>:3001/ (直连 Halo)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:3001/
|
||||
printf " %-42s " "http://<服务器IP>:9100/ (官网)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:9100/
|
||||
|
||||
echo
|
||||
echo " --- ACME 挑战路径还是通的吗(关系到 90 天后能不能续)---"
|
||||
printf " %-42s " "http://127.0.0.1/.well-known/acme-challenge/probe"
|
||||
sudo sh -c 'echo ok > /var/www/html/.well-known/acme-challenge/probe'
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 8 -H "Host: xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
|
||||
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
|
||||
''', timeout=400, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''配置 Halo:控制台路径改到 /houtai。
|
||||
|
||||
== 为什么环境变量没生效 ==
|
||||
|
||||
我第一版用了 `HALO_CONSOLE_PATH=/houtai`(按 Spring Boot 的
|
||||
relaxed binding 习惯猜的),但容器起来后 `/console` 还是 302,
|
||||
`/houtai` 是 404 —— **说明这个变量名不对**。
|
||||
|
||||
从容器里看到真正的线索:
|
||||
|
||||
SPRING_CONFIG_LOCATION=optional:classpath:/;optional:file:/root/.halo2/
|
||||
|
||||
也就是说 Halo 会去读 **`/root/.halo2/application.yaml`**
|
||||
(宿主机上就是 `/opt/halo/data/application.yaml`)。
|
||||
|
||||
**用配置文件比猜环境变量可靠得多** —— 而且配置项写错了
|
||||
Halo 会在日志里报"不认识的属性",比静默忽略好。
|
||||
|
||||
== Halo 的配置长什么样 ==
|
||||
|
||||
Halo 2.x 用 Spring Boot 的 `@ConfigurationProperties`,
|
||||
前缀是 `halo`,所以键是 `halo.console-path`。
|
||||
|
||||
== 要配的几项 ==
|
||||
|
||||
* `halo.console-path: /houtai` —— 用户要求
|
||||
* `halo.external-url` —— 生成文章链接用,设成域名
|
||||
* `halo.work-dir` —— 数据目录(容器里是 /root/.halo2)
|
||||
|
||||
**其余不写** —— 写了反而容易覆盖掉默认值,将来升级出问题。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
CONFIG = """# ======================================================================
|
||||
# Halo 配置
|
||||
# ======================================================================
|
||||
#
|
||||
# 这个文件放在宿主机的 /opt/halo/data/ ,容器里看到的是
|
||||
# /root/.halo2/application.yaml(Halo 的 SPRING_CONFIG_LOCATION
|
||||
# 就指着那儿)。
|
||||
#
|
||||
# ⚠ 为什么不靠环境变量:
|
||||
# 我第一版用了 HALO_CONSOLE_PATH,结果没生效(/console 还是默认的)。
|
||||
# Halo 的配置项是 Spring Boot 的 ConfigurationProperties,
|
||||
# **写配置文件最可靠** —— 而且写错了它会在日志里报
|
||||
# "unknown property",比静默忽略强。
|
||||
|
||||
halo:
|
||||
# 数据目录(容器内路径)
|
||||
work-dir: /root/.halo2
|
||||
|
||||
# 对外地址。Halo 用它生成文章永久链接、站点地图、RSS 里的链接。
|
||||
# 设成域名之后,就算你从 IP:3001 进来,页面里的链接也是域名,一致。
|
||||
external-url: https://xxmxxs.top
|
||||
|
||||
# ★ 控制台路径 —— 用户要求 "xxmxxs.top/houtai 是管理博客的后台"
|
||||
#
|
||||
# 默认是 /console。改掉之后:
|
||||
# * 老路径 /console 不再响应(相当于顺手藏了一下后台入口)
|
||||
# * 新入口是 https://xxmxxs.top/houtai
|
||||
console-path: /houtai
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 配置 Halo:控制台路径 -> /houtai")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
tmp = ROOT / "_build" / "_halo_application.yaml"
|
||||
tmp.write_text(CONFIG, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/halo-application.yaml")
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
out = run(r'''
|
||||
echo "==== 1. 放配置文件 ===="
|
||||
sudo mv /tmp/halo-application.yaml /opt/halo/data/application.yaml
|
||||
sudo chown 1000:1000 /opt/halo/data/application.yaml 2>/dev/null || true
|
||||
sudo ls -la /opt/halo/data/ | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 2. 重启容器让它读到配置 ===="
|
||||
sudo docker restart halo >/dev/null 2>&1
|
||||
echo " 重启了,等它起来(最多 90 秒)…"
|
||||
for i in $(seq 1 45); do
|
||||
sleep 2
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3001/actuator/health 2>/dev/null)
|
||||
if [ "$code" = "200" ]; then echo " 第 $((i*2)) 秒:健康检查 200 ✓"; break; fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==== 3. 复核控制台路径 ===="
|
||||
printf " %-30s %s\n" "/houtai(新,应该 200/302)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/houtai)"
|
||||
printf " %-30s %s\n" "/console(旧,应该 404)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/console)"
|
||||
printf " %-30s %s\n" "/(首页)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/)"
|
||||
|
||||
echo
|
||||
echo "==== 4. 配置有没有被拒绝(日志里找 unknown property)===="
|
||||
sudo docker logs halo 2>&1 | grep -iE "unknown|not.*recogniz|invalid.*propert|console-path|configuration" | head -10 | sed 's/^/ /' || echo " (没有报错)"
|
||||
|
||||
echo
|
||||
echo "==== 5. 内存现状 ===="
|
||||
sudo docker stats --no-stream --format ' {{.Name}} 内存 {{.MemUsage}}' halo 2>/dev/null
|
||||
free -m | sed 's/^/ /'
|
||||
''', timeout=600, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''Halo 登录(第三版):处理 CSRF,然后发文章。
|
||||
|
||||
== 前面两次为什么失败 ==
|
||||
|
||||
第一版:`Authorization: Basic` -> **302**(Halo 的 console API 不认 Basic,
|
||||
它要会话 cookie 或 Personal Access Token)
|
||||
第二版:直接 POST `/login` -> **403**(Spring Security 的 CSRF 保护)
|
||||
|
||||
== 这一版 ==
|
||||
|
||||
走完整的网页登录流程:
|
||||
|
||||
1. GET /login -> 拿 SESSION cookie + HTML 里的 _csrf token
|
||||
2. POST /login -> 带上 cookie + _csrf + 用户名密码
|
||||
3. 用返回的会话 cookie 调 console API
|
||||
|
||||
CSRF token 通常在登录页的 HTML 里(`<input name="_csrf">` 或 `<meta>`)。
|
||||
Halo 的登录页是 SPA 还是服务端渲染会影响它在不在 —— 所以脚本会
|
||||
**两种都找**,找不到就明确报出来,不瞎猜。
|
||||
|
||||
== 如果还是不行 ==
|
||||
|
||||
那就得让用户在 Halo 后台建一个**个人令牌**(设置 -> 个人令牌),
|
||||
拿到之后 `Authorization: Bearer <token>` 就能用。
|
||||
那是最可靠的方式 —— 30 秒的事,而且不用把密码给脚本。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
PWFILE = ROOT / "_build" / ".halo_password"
|
||||
USER = "xmz7891"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" Halo 登录(带 CSRF)")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
pw = PWFILE.read_text(encoding="utf-8").strip()
|
||||
|
||||
out = run(r'''
|
||||
rm -f /tmp/hc.txt /tmp/login.html
|
||||
|
||||
echo "── 1. GET /login(拿 cookie + csrf token)──"
|
||||
curl -s -c /tmp/hc.txt -o /tmp/login.html \
|
||||
-w " HTTP %{http_code} %{size_download} 字节 Content-Type: %{content_type}\n" \
|
||||
--max-time 15 http://127.0.0.1:3001/login
|
||||
|
||||
echo " cookie:"
|
||||
grep -v '^#' /tmp/hc.txt 2>/dev/null | while read -r a b c d e f g; do
|
||||
[ -n "$f" ] && echo " $f"
|
||||
done
|
||||
[ -s /tmp/hc.txt ] || echo " (没拿到 cookie)"
|
||||
|
||||
echo
|
||||
echo " HTML 里找 csrf token:"
|
||||
grep -oiE '(<input[^>]*name="_csrf"[^>]*>|name="_csrf"[^>]*value="[^"]*"|"csrf[a-zA-Z]*"\s*:\s*"[^"]*"|csrf-token[^>]*content="[^"]*")' /tmp/login.html 2>/dev/null | head -5 | sed 's/^/ /'
|
||||
echo " HTML 前 400 字:"
|
||||
head -c 400 /tmp/login.html | tr -d '\n' | sed 's/^/ /'
|
||||
echo
|
||||
echo
|
||||
echo " title:"
|
||||
grep -oiE '<title>[^<]*</title>' /tmp/login.html 2>/dev/null | head -2 | sed 's/^/ /'
|
||||
''', timeout=240, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''Halo 发文章(最终版):RSA 加密登录 + 建文章。
|
||||
|
||||
== 前面六次都失败,原因是这个 ==
|
||||
|
||||
Halo 的登录页用 **jsencrypt** 把密码 **RSA 加密**后才提交:
|
||||
|
||||
<script src="/webjars/jsencrypt/3.5.4/bin/jsencrypt.min.js"></script>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="_csrf" ... />
|
||||
<input type="hidden" name="password" /> <- JS 把密文填这里
|
||||
<input name="username" />
|
||||
<input id="plainPassword" /> <- 用户输入的明文
|
||||
</form>
|
||||
|
||||
**所以在终端直接发明文密码永远登不上** —— 服务端拿到的是密文,
|
||||
它会 RSA 解密再比对;我给明文,解密失败,于是 `invalid-credential`。
|
||||
|
||||
这个坑很深,因为**报错信息是"凭据无效"** —— 会让人一直怀疑
|
||||
"是不是密码打错了",而不是"我流程不对"。
|
||||
|
||||
== 踩过的另外几个 ==
|
||||
|
||||
* `/login` 默认显示**邮箱验证码**表单(`action="/login/email-code"`),
|
||||
密码表单在 **`/login?method=local`**。我一开始连页面都拿错了。
|
||||
* CSRF 必须带(Spring Security 默认开),不带直接 403。
|
||||
* 密码文件被 PowerShell 写了个 **BOM**(`\ufeff`),
|
||||
读出来多一个不可见字符 —— 又一个"看着没错实际不对"的坑。
|
||||
* Halo 有**登录失败限流**,试错几次就被挡住,得等。
|
||||
|
||||
== jsencrypt 的填充方式 ==
|
||||
|
||||
`jsencrypt` 默认用 **PKCS#1 v1.5** padding。加密后的密文是 base64。
|
||||
公钥在登录页的 JS 里(一个大 base64 串),注意 HTML 里 `\/` 要还原成 `/`。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
PWFILE = ROOT / "_build" / ".halo_password"
|
||||
USER = "xmz7891"
|
||||
|
||||
# 在服务器上跑的 Python:读公钥 -> RSA 加密 -> 输出 base64
|
||||
ENCRYPT_PY = r'''
|
||||
import base64, re, sys, json
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
html = open('/tmp/lg9.html', encoding='utf-8', errors='replace').read()
|
||||
m = re.search(r'publicKey["\']?\s*[:=]\s*["\']([^"\']{40,})["\']', html)
|
||||
if not m:
|
||||
print("NO_KEY"); sys.exit(1)
|
||||
b64 = m.group(1).replace('\\/', '/').replace('\\n', '')
|
||||
b64 = re.sub(r'\s+', '', b64)
|
||||
try:
|
||||
der = base64.b64decode(b64)
|
||||
pub = serialization.load_der_public_key(der)
|
||||
except Exception as e1:
|
||||
try:
|
||||
pem = b'-----BEGIN PUBLIC KEY-----\n' + \
|
||||
b'\n'.join(b64[i:i+64].encode() for i in range(0, len(b64), 64)) + \
|
||||
b'\n-----END PUBLIC KEY-----\n'
|
||||
pub = serialization.load_pem_public_key(pem)
|
||||
except Exception as e2:
|
||||
print("LOAD_FAIL: %s / %s" % (e1, e2)); sys.exit(1)
|
||||
|
||||
pw = open('/tmp/halo_pw.txt', 'rb').read().strip()
|
||||
ct = pub.encrypt(pw, padding.PKCS1v15())
|
||||
print(base64.b64encode(ct).decode())
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" Halo:RSA 登录 -> 发文章")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
pw = PWFILE.read_text(encoding="utf-8").strip()
|
||||
print(" 用户名: %s" % USER)
|
||||
print(" 密码长度: %d" % len(pw))
|
||||
|
||||
# 把加密脚本和密码传上去
|
||||
enc = ROOT / "_build" / "_halo_encrypt.py"
|
||||
enc.write_text(ENCRYPT_PY, encoding="utf-8", newline="\n")
|
||||
from lssh import upload
|
||||
upload(str(enc), "/tmp/halo_encrypt.py")
|
||||
enc.unlink(missing_ok=True)
|
||||
|
||||
out = run(r'''
|
||||
set -e
|
||||
# 密码写成无 BOM 的文件
|
||||
printf '%s' '__PW__' > /tmp/halo_pw.txt
|
||||
echo " 密码文件字节数: $(wc -c < /tmp/halo_pw.txt)"
|
||||
|
||||
# 拿登录页(含公钥)
|
||||
curl -s --max-time 12 "http://127.0.0.1:3001/login?method=local" -o /tmp/lg9.html
|
||||
echo " 登录页 $(wc -c < /tmp/lg9.html) 字节"
|
||||
|
||||
# RSA 加密
|
||||
CT=$(python3 /tmp/halo_encrypt.py)
|
||||
if [ -z "$CT" ] || echo "$CT" | grep -qE '^(NO_KEY|LOAD_FAIL)'; then
|
||||
echo " ✗ 加密失败: $CT"; exit 1
|
||||
fi
|
||||
echo " ✓ 密文长度 ${#CT}"
|
||||
printf '%s' "$CT" > /tmp/halo_ct.txt
|
||||
|
||||
# 登录
|
||||
rm -f /tmp/hc9.txt
|
||||
curl -s -c /tmp/hc9.txt -o /tmp/lg9b.html --max-time 12 "http://127.0.0.1:3001/login?method=local"
|
||||
CSRF=$(grep -oE 'name="_csrf" value="[^"]+"' /tmp/lg9b.html | head -1 | sed 's/.*value="//; s/"//')
|
||||
echo " csrf 长度 ${#CSRF}"
|
||||
|
||||
LOC=$(curl -s -b /tmp/hc9.txt -c /tmp/hc9.txt -o /dev/null -w '%{redirect_url}' \
|
||||
--max-time 20 -X POST "http://127.0.0.1:3001/login" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "_csrf=$CSRF" \
|
||||
--data-urlencode "username=__U__" \
|
||||
--data-urlencode "password=$CT")
|
||||
echo " 登录跳转: ${LOC:-(空=可能成功)}"
|
||||
|
||||
CODE=$(curl -s -b /tmp/hc9.txt -o /dev/null -w '%{http_code}' --max-time 12 \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=3")
|
||||
echo " 带 cookie 打 API: HTTP $CODE"
|
||||
if [ "$CODE" = "200" ]; then
|
||||
cp /tmp/hc9.txt /tmp/hc_good.txt
|
||||
echo " ✅ 登录成功"
|
||||
echo
|
||||
echo " --- 现在的文章 ---"
|
||||
curl -s -b /tmp/hc9.txt --max-time 12 \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(' 共', d.get('total'), '篇')
|
||||
for p in (d.get('items') or []):
|
||||
s=p.get('spec',{}); print(' -', repr(s.get('title')), '| publish=', s.get('publish'))
|
||||
"
|
||||
fi
|
||||
'''.replace("__PW__", pw).replace("__U__", USER), timeout=600, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''重写 Halo 的 nginx 配置:**只写 80 端口那部分**。
|
||||
|
||||
== 上一版怎么坏的 ==
|
||||
|
||||
我想"临时把 443 块注释掉",就用脚本给 443 那几行前面加 `#`。
|
||||
但那几行里包含 **server 块的结束 `}`** —— 一注释就变成
|
||||
"unexpected end of file, expecting }",配置直接不合法。
|
||||
|
||||
**教训**:注释掉一个配置块要**整块删掉**,不能逐行加 `#`。
|
||||
逐行注释会破坏"开括号/闭括号配对",而这种错误 nginx 只会报
|
||||
"文件意外结束",不会告诉你少的是哪个括号。
|
||||
|
||||
== 这一版怎么组织 ==
|
||||
|
||||
443 块**根本不写** —— 等 DNS 指过来、certbot 签完证书之后,
|
||||
再作为独立一步加上去。这样每一步的配置都是**自洽且能通过检查的**,
|
||||
不会出现"半截配置"。
|
||||
|
||||
== 顺带 ==
|
||||
|
||||
ykxmz 那边原来占着 80 的 default_server,上一版已经把它让出来了
|
||||
(`listen 80 default_server` -> `listen 80`)。要看它现在是否正常。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
CONF = """# xxmxxs.top —— Halo 个人博客(**只有 80 端口**,443 等证书)
|
||||
#
|
||||
# 入口:
|
||||
# http://<服务器IP>/ 博客(IP 直接访问)
|
||||
# http://<服务器IP>/console 管理后台
|
||||
# http://xxmxxs.top/ 301 到 https(证书好了之后)
|
||||
#
|
||||
# 后端:容器 halo,监听 0.0.0.0:3001,Halo 原生控制台路径是 /console
|
||||
#
|
||||
# ⚠ 443 那段**故意不在这里** —— DNS 还没指过来,签不到证书。
|
||||
# 没证书的 `listen 443 ssl` 会让 nginx -t 直接失败
|
||||
# ("no ssl_certificate is defined")。
|
||||
# 等 DNS 生效后跑 certbot,它会自己把 443 和证书加进来。
|
||||
|
||||
# ======================================================================
|
||||
# 80 端口默认站点:用 IP 访问时直接出博客
|
||||
# ======================================================================
|
||||
#
|
||||
# nginx 规则:Host 匹配不上任何 server_name 时,用该端口的 default_server。
|
||||
# 所以这里设成 default_server -> `http://<服务器IP>/` 就是博客。
|
||||
#
|
||||
# ⚠ 不做 https 跳转:IP 没证书,跳过去浏览器报警告,用户会以为网站坏了。
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
access_log /var/log/nginx/halo-access.log;
|
||||
error_log /var/log/nginx/halo-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 100m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# 80 端口 + 域名:跳 https,并留出 ACME 挑战的位置
|
||||
# ======================================================================
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name xxmxxs.top www.xxmxxs.top;
|
||||
|
||||
access_log /var/log/nginx/halo-access.log;
|
||||
error_log /var/log/nginx/halo-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 100m;
|
||||
|
||||
# ⚠ ACME 挑战必须在 301 之前 ——
|
||||
# certbot 的 HTTP-01 要在 80 端口直接拿到文件。
|
||||
# 被 301 跳走就续期失败,而且**最多 90 天后才暴露**(证书到期)。
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
default_type "text/plain";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 重写 Halo 的 nginx 配置(只 80 端口)")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
tmp = ROOT / "_build" / "_halo_nginx.conf"
|
||||
tmp.write_text(CONF, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/halo-nginx.conf")
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
out = run(r'''
|
||||
echo "==== 1. 先把坏掉的备份都清掉,重写 ===="
|
||||
sudo rm -f /etc/nginx/sites-available/halo.bak.* 2>/dev/null
|
||||
sudo mv -f /tmp/halo-nginx.conf /etc/nginx/sites-available/halo
|
||||
sudo ln -sf /etc/nginx/sites-available/halo /etc/nginx/sites-enabled/halo
|
||||
echo " 写好了"
|
||||
|
||||
echo
|
||||
echo "==== 2. ykxmz 那边现在长什么样(确认 default_server 让出来了)===="
|
||||
sudo grep -n "default_server" /etc/nginx/sites-available/ykxmz 2>/dev/null | sed 's/^/ /' || echo " 没有 default_server ✓"
|
||||
|
||||
echo
|
||||
echo "==== 3. 检查配置 ===="
|
||||
sudo nginx -t 2>&1 | tail -3 | sed 's/^/ /'
|
||||
''', timeout=240, quiet=True)
|
||||
o = out[1] if isinstance(out, tuple) else str(out)
|
||||
print(o)
|
||||
if "successful" not in o:
|
||||
print(" ✗ 配置有错,恢复 ykxmz 的 default_server 并停用 halo 站点")
|
||||
run("sudo rm -f /etc/nginx/sites-enabled/halo", timeout=90, quiet=True)
|
||||
return 1
|
||||
|
||||
out = run(r'''
|
||||
echo "==== 4. 重载 ===="
|
||||
sudo systemctl reload nginx && sleep 1
|
||||
echo " nginx: $(systemctl is-active nginx)"
|
||||
|
||||
echo
|
||||
echo "==== 5. 复核(对用户要求逐条)===="
|
||||
printf " %-46s " "http://127.0.0.1/ (IP -> 博客)"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
|
||||
printf " %-46s " "http://127.0.0.1/console (控制台)"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/console
|
||||
printf " %-46s " "http://127.0.0.1:3001/ (直连 Halo)"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 10 http://127.0.0.1:3001/
|
||||
printf " %-46s " "https://yk.xxmxxs.top/ (中转站)"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 12 https://yk.xxmxxs.top/
|
||||
printf " %-46s " "https://yk.xxmxxs.top/v1/health"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/v1/health
|
||||
printf " %-46s " "http://127.0.0.1:9100/ (官网直连通道)"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 10 http://127.0.0.1:9100/
|
||||
echo
|
||||
echo "==== 6. 监听端口 ===="
|
||||
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100|3001) ' | awk '{print " "$4}' | sort -u
|
||||
''', timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,235 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''重写 halo 的 nginx 配置:修掉 301 循环。
|
||||
|
||||
== 怎么坏的 ==
|
||||
|
||||
我上一版**故意没写 443 块**(没证书时 `nginx -t` 会失败),
|
||||
打算"等证书好了让 certbot 自己加"。
|
||||
|
||||
结果 `certbot --nginx` 找不到一个"纯粹的 443 块",
|
||||
就把 `listen 443 ssl` 和证书那几行**塞进了那个 80 端口的块里**。
|
||||
|
||||
而那个块的 `location /` 内容是:
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
|
||||
于是 https 的请求进来也走这条 —— **301 到自己,无限循环**。
|
||||
浏览器会报 "重定向次数过多"。
|
||||
|
||||
**certbot 还另外新建了一个纯 80 的块**(带两条 `if ($host = ...) return 301`),
|
||||
跟原来的块功能重复。
|
||||
|
||||
== 教训 ==
|
||||
|
||||
**不要让 certbot 去改一个"结构不完整"的配置。**
|
||||
`--nginx` 插件是**按 server 块猜**的:它找 `server_name` 匹配的块,
|
||||
把 ssl 指令加进去。如果那个块本来是 80 的、还带 301,
|
||||
它就会造出一个"443 + 301"的自环。
|
||||
|
||||
正确做法:**自己把 80 / 443 两块都写清楚**(443 先注释掉但不留半截),
|
||||
证书有了之后**只把证书路径填进去**,而不是让 certbot 重排结构。
|
||||
|
||||
== 这一版的结构 ==
|
||||
|
||||
server A: 80 default_server -> proxy 到 Halo(IP 访问用)
|
||||
server B: 80 + 域名 -> ACME 例外 + 301 到 https
|
||||
server C: 443 + 域名 -> proxy 到 Halo
|
||||
|
||||
三个块各司其职,没有重叠,也没有"同一块里既有 301 又有 ssl"。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
CONF = """# xxmxxs.top —— Halo 个人博客
|
||||
#
|
||||
# 入口:
|
||||
# http://<服务器IP>/ 博客(IP,HTTP)
|
||||
# http://<服务器IP>/console 管理后台(IP,HTTP)
|
||||
# https://xxmxxs.top/ 博客(HTTPS)
|
||||
# https://xxmxxs.top/console 管理后台(HTTPS)
|
||||
# https://www.xxmxxs.top/ 同上
|
||||
# http://<服务器IP>:3001/ 直连 Halo(用户要求)
|
||||
#
|
||||
# 后端:容器 halo,监听 0.0.0.0:3001
|
||||
# 控制台:Halo 原生 /console(2.26.1 没有可配置的路径前缀)
|
||||
|
||||
# ======================================================================
|
||||
# A. 80 端口默认站点 —— 用 IP 访问时直接出博客
|
||||
# ======================================================================
|
||||
#
|
||||
# nginx 规则:Host 匹配不上任何 server_name 时用该端口的 default_server。
|
||||
#
|
||||
# ⚠ 这里**不做 https 跳转**:IP 没证书,跳过去浏览器报警告,
|
||||
# 用户会以为网站坏了。
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
access_log /var/log/nginx/halo-access.log;
|
||||
error_log /var/log/nginx/halo-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 100m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# B. 80 端口 + 域名 —— ACME 挑战 + 跳 https
|
||||
# ======================================================================
|
||||
#
|
||||
# ⚠ 这个块**只监听 80**,不含任何 ssl 指令。
|
||||
# 上一版 certbot 把 443 塞进来,造出了"443 上也 301"的自环。
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name xxmxxs.top www.xxmxxs.top;
|
||||
|
||||
access_log /var/log/nginx/halo-access.log;
|
||||
error_log /var/log/nginx/halo-error.log;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
# ACME 挑战必须在 301 之前 ——
|
||||
# certbot 的 HTTP-01 要在 80 端口直接拿到文件。
|
||||
# 被 301 跳走就续期失败,而**最多 90 天后才暴露**(证书到期)。
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
default_type "text/plain";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# C. 443 + 域名 —— 博客(HTTPS)
|
||||
# ======================================================================
|
||||
#
|
||||
# ⚠ `listen ... ssl` 和证书那几行是 certbot 需要的形式,
|
||||
# 续期时它会检查。**别删**,也别把 80 的 301 混进来。
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name xxmxxs.top www.xxmxxs.top;
|
||||
|
||||
access_log /var/log/nginx/halo-access.log;
|
||||
error_log /var/log/nginx/halo-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 100m;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/xxmxxs.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/xxmxxs.top/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# 事件总线(SSE)要用,而且不能缓冲
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 重写配置,修掉 301 循环")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
tmp = ROOT / "_build" / "_halo_nginx.conf"
|
||||
tmp.write_text(CONF, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/halo-nginx.conf")
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
out = run(r'''
|
||||
sudo cp /etc/nginx/sites-available/halo /etc/nginx/sites-available/halo.bak.$(date +%s)
|
||||
sudo mv /tmp/halo-nginx.conf /etc/nginx/sites-available/halo
|
||||
echo " 写好了"
|
||||
echo
|
||||
echo " ---- 检查 ----"
|
||||
sudo nginx -t 2>&1 | tail -3 | sed 's/^/ /'
|
||||
''', timeout=240, quiet=True)
|
||||
o = out[1] if isinstance(out, tuple) else str(out)
|
||||
print(o)
|
||||
if "successful" not in o:
|
||||
print(" ✗ 配置有错,撤回")
|
||||
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/halo.bak.* | head -1) "
|
||||
"/etc/nginx/sites-available/halo && sudo nginx -t", timeout=120,
|
||||
quiet=True)
|
||||
return 1
|
||||
|
||||
out = run(r'''
|
||||
sudo systemctl reload nginx && sleep 1
|
||||
echo " nginx: $(systemctl is-active nginx)"
|
||||
|
||||
echo
|
||||
echo "════════ 逐条复核 ════════"
|
||||
printf " %-44s " "http://<服务器IP>/ (博客)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
|
||||
printf " %-44s " "http://<服务器IP>/console (控制台)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1/console
|
||||
echo
|
||||
printf " %-44s " "https://xxmxxs.top/ (博客)"
|
||||
curl -sk -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 15 --resolve xxmxxs.top:443:127.0.0.1 https://xxmxxs.top/
|
||||
printf " %-44s " "https://xxmxxs.top/console (控制台)"
|
||||
curl -sk -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 --resolve xxmxxs.top:443:127.0.0.1 https://xxmxxs.top/console
|
||||
printf " %-44s " "https://www.xxmxxs.top/ (博客)"
|
||||
curl -sk -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 --resolve www.xxmxxs.top:443:127.0.0.1 https://www.xxmxxs.top/
|
||||
printf " %-44s " "http://xxmxxs.top/ (应 301 到 https)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code} -> %{redirect_url}\n" --max-time 15 -H "Host: xxmxxs.top" http://127.0.0.1/
|
||||
echo
|
||||
echo " 不能弄坏的:"
|
||||
printf " %-44s " "https://yk.xxmxxs.top/ (中转站)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
|
||||
printf " %-44s " "http://<服务器IP>:9100/ (官网)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:9100/
|
||||
printf " %-44s " "http://<服务器IP>:3001/ (直连 Halo)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:3001/
|
||||
|
||||
echo
|
||||
echo "════════ ACME 挑战路径(续期的命根子)════════"
|
||||
sudo sh -c 'echo probe > /var/www/html/.well-known/acme-challenge/probe'
|
||||
printf " %-44s " "http://xxmxxs.top/.well-known/... (应 200)"
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 8 -H "Host: xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
|
||||
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
|
||||
''', timeout=400, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''装 Halo 之前先把前提条件查清楚。
|
||||
|
||||
== 用户要求 ==
|
||||
|
||||
* Halo 个人博客,跑在 `<服务器IP>:3001`
|
||||
* 加 **1G swap**(机器只有 1936MB 内存,本来没 swap)
|
||||
* `xxmxxs.top` 和 `www.xxmxxs.top` 都能访问
|
||||
* `xxmxxs.top/houtai` 是管理后台
|
||||
* 其他博客配置用户自己弄
|
||||
|
||||
== 装之前必须确认的 ==
|
||||
|
||||
1. **`xxmxxs.top` 的 DNS 指到服务器了吗?**
|
||||
之前发现 `xxmxxs.top` 上绑着一个 **Cloudflare Worker**
|
||||
(用户说要自己去掉)。如果 Worker 还在,或者 A 记录没指过来,
|
||||
nginx 配得再好也没用 —— 外面根本到不了服务器。
|
||||
|
||||
2. **内存和 swap 现状**
|
||||
1936MB 总内存,现在用了 ~584MB。Halo 是 **Java 应用**,
|
||||
默认堆可能吃 512MB+。**没有 swap 的话很容易被 OOM 杀掉**,
|
||||
表现是"博客隔一阵就自己挂了"。
|
||||
所以用户要的 1G swap 是**必须的**,不是可选项。
|
||||
|
||||
3. **Docker 能用吗 / 1Panel 的 MySQL**
|
||||
机器上有 1Panel + Docker,还有个 `mysql:8.4.11` 容器在跑。
|
||||
Halo 可以用它,也可以用**内置 H2 数据库**(更省内存、更简单)。
|
||||
个人博客 H2 完全够用 —— 少一个组件少一处故障点。
|
||||
|
||||
4. **Java 有没有**
|
||||
Halo 2.x 要 **Java 17+**。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
HOSTFILE = ROOT / "_build" / ".lightsail_host"
|
||||
IP = HOSTFILE.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 装 Halo 前的前提检查")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
# ---------------- DNS ----------------
|
||||
print("── 1. 域名现在解析到哪 ──")
|
||||
for name in ("xxmxxs.top", "www.xxmxxs.top"):
|
||||
try:
|
||||
ips = sorted({sa[4][0] for sa in
|
||||
socket.getaddrinfo(name, 443, proto=socket.IPPROTO_TCP)})
|
||||
mark = ""
|
||||
if IP in ips:
|
||||
mark = " ← 就是你的服务器 ✓"
|
||||
else:
|
||||
mark = " ← ⚠ 不是你的服务器(要改 DNS)"
|
||||
print(" %-20s %s%s" % (name, ", ".join(ips) or "(无解析)", mark))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" %-20s 解析失败: %s" % (name, type(exc).__name__))
|
||||
|
||||
print()
|
||||
print(" ── 直接连服务器 + 带 Host 头,看现在返回什么 ──")
|
||||
import ssl
|
||||
import urllib.request
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
for host in ("xxmxxs.top", "www.xxmxxs.top"):
|
||||
try:
|
||||
req = urllib.request.Request("https://" + IP + "/",
|
||||
headers={"Host": host,
|
||||
"User-Agent": "probe"})
|
||||
with urllib.request.urlopen(req, timeout=12, context=ctx) as r:
|
||||
body = r.read(400)
|
||||
print(" Host:%-20s -> HTTP %s %d 字节"
|
||||
% (host, r.status, len(body)))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" Host:%-20s -> %s" % (host, type(exc).__name__))
|
||||
|
||||
# ---------------- 服务器现状 ----------------
|
||||
print()
|
||||
print("── 2. 服务器现状 ──")
|
||||
out = run(r'''
|
||||
echo " --- 内存 / swap ---"
|
||||
free -m | sed 's/^/ /'
|
||||
echo " --- 磁盘 ---"
|
||||
df -h / | tail -1 | sed 's/^/ /'
|
||||
echo " --- Java ---"
|
||||
if command -v java >/dev/null 2>&1; then java -version 2>&1 | head -2 | sed 's/^/ /'; else echo " 没装 Java"; fi
|
||||
echo " --- Docker ---"
|
||||
sudo docker --version 2>/dev/null | sed 's/^/ /' || echo " docker 不可用"
|
||||
echo " --- 3001 端口占了吗 ---"
|
||||
sudo ss -tlnp 2>/dev/null | grep 3001 | sed 's/^/ /' || echo " 3001 空着 ✓"
|
||||
echo " --- 现有 systemd 服务 ---"
|
||||
systemctl list-units --type=service --state=running --no-pager --plain 2>/dev/null \
|
||||
| awk '{print " "$1}' | head -14
|
||||
echo " --- 1Panel 的 mysql 容器 ---"
|
||||
sudo docker ps --format ' {{.Names}} {{.Image}} {{.Ports}}' 2>/dev/null
|
||||
''', timeout=240, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
print()
|
||||
print("── 3. 结论 / 下一步 ──")
|
||||
print(" * swap 现在就加(1G)—— 不加 Halo 容易被 OOM 杀掉")
|
||||
print(" * Halo 用 Docker 装(机器上已经有 Docker),数据库用内置 H2")
|
||||
print(" —— 个人博客够用,而且比再挂一个 MySQL 省内存")
|
||||
print(" * nginx 加 xxmxxs.top + www 两个 server_name,反代到 127.0.0.1:3001")
|
||||
print(" * 控制台路径设成 /houtai(Halo 2.x 支持 HALO_CONSOLE_PATH)")
|
||||
print(" * ⚠ 域名 DNS 要指过来(还有那个 Cloudflare Worker 要删)")
|
||||
print(" * ⚠ 3001 要在 AWS Lightsail 防火墙开")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,339 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''通过 Halo API 把文章直接写进去。
|
||||
|
||||
== 为什么要走 API ==
|
||||
|
||||
用户试过复制粘贴,但 Halo 富文本编辑器老把内容吞进代码块。
|
||||
与其来回折腾,不如**直接调 API 建文章** —— 一次到位,
|
||||
而且以后想再发文章也能复用这个脚本。
|
||||
|
||||
== ⚠ 密码怎么处理 ==
|
||||
|
||||
密码存在 `_build/.halo_password`(**已经加进 .gitignore**)。
|
||||
脚本本身**不含任何密码** —— 从文件读。
|
||||
|
||||
这一点很重要:这个密码和用户的中转站管理密码**是同一个**,
|
||||
写进仓库就等于全泄漏了。
|
||||
|
||||
== Halo 2.x 的建文章 API ==
|
||||
|
||||
POST /apis/api.console.halo.run/v1alpha1/posts
|
||||
Authorization: Basic base64(user:pass)
|
||||
Content-Type: application/json
|
||||
|
||||
请求体是 `PostRequest`:
|
||||
|
||||
{
|
||||
"post": { <- Post 资源本身
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Post",
|
||||
"metadata": {"name": "post-xxx"},
|
||||
"spec": {
|
||||
"title": "...",
|
||||
"slug": "...",
|
||||
"publish": true,
|
||||
"publishTime": "...",
|
||||
"visible": "PUBLIC",
|
||||
"allowComment": true,
|
||||
"excerpt": {"autoGenerate": true, "raw": ""},
|
||||
"categories": [], "tags": [], "htmlMetas": []
|
||||
}
|
||||
},
|
||||
"content": { <- 正文单独放
|
||||
"raw": "...",
|
||||
"content": "...",
|
||||
"rawType": "markdown"
|
||||
}
|
||||
}
|
||||
|
||||
**注意 `content` 要同时给 `raw`(源码)和 `content`(渲染后的 HTML)**。
|
||||
`raw` 给 Markdown,`content` 给 HTML —— Halo 前端直接读 `content`。
|
||||
两个都给的话,编辑器里也能正常显示源码。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
PWFILE = ROOT / "_build" / ".halo_password"
|
||||
|
||||
# ---------------------------------------------------------------- 文章正文
|
||||
# 用 Markdown 写(API 的 raw 字段就是 Markdown),Halo 会渲染成网页。
|
||||
# 内容只提官网,不提任何管理后台 —— 对外材料清洁度要求。
|
||||
TITLE = "远控软件正式上线"
|
||||
SLUG = "remote-control-launch"
|
||||
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,"
|
||||
"就能操作教室那台电脑:远程鼠标、投图到大屏、语音播报、"
|
||||
"静默自动更新。官网 yk.xxmxxs.top")
|
||||
|
||||
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
|
||||
|
||||
## 它是干什么的
|
||||
|
||||
很多场景下,人不在电脑前面,但需要操作那台电脑:
|
||||
|
||||
- 上课时站在教室中间,想切个课件、放张图
|
||||
- 坐在办公室,想看看教室那台电脑还开着没
|
||||
- 手上拿着东西,腾不出手去碰鼠标键盘
|
||||
|
||||
**这个软件就是解决这个的。**
|
||||
|
||||
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
|
||||
|
||||
## 能做什么
|
||||
|
||||
### 远程鼠标控制
|
||||
|
||||
手表变成触摸板:
|
||||
|
||||
- **拖动** —— 移动鼠标
|
||||
- **单击** —— 左键
|
||||
- **双击** —— 右键
|
||||
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
|
||||
|
||||
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
|
||||
|
||||
### 一键显示桌面
|
||||
|
||||
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
|
||||
|
||||
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
|
||||
|
||||
### 电脑端静默自动更新
|
||||
|
||||
这是最省心的一点:
|
||||
|
||||
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
|
||||
|
||||
- 全程**不弹任何窗口**
|
||||
- 中转地址、配对码、所有设置**一个都不变**
|
||||
- 更新完自动回来继续跑
|
||||
|
||||
不用再跑到电脑前手动装一遍。
|
||||
|
||||
### 顶部快捷栏
|
||||
|
||||
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
|
||||
|
||||
| 可选动作 | 说明 |
|
||||
|---|---|
|
||||
| 传文件 | 从手表把文件发到电脑 |
|
||||
| 屏幕镜像 | 看电脑屏幕 |
|
||||
| 鼠标控制 | 进触摸板模式 |
|
||||
| 音效 | 让电脑放一声 |
|
||||
| 关机 | 锁屏 / 关机 / 重启 |
|
||||
| 语音播报 | 让电脑念一段字 |
|
||||
| 投图到电脑 | 手表选图,全屏投到大屏 |
|
||||
| 显示桌面 | 等于 Win + D |
|
||||
|
||||
在设置页里点一下就能换。
|
||||
|
||||
### 投图到大屏
|
||||
|
||||
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
|
||||
|
||||
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
|
||||
|
||||
### 语音播报
|
||||
|
||||
输入文字,电脑用系统自带的语音念出来。
|
||||
|
||||
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
|
||||
|
||||
### 让电脑放个音效
|
||||
|
||||
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
|
||||
|
||||
### 其他
|
||||
|
||||
- 剪贴板互传(手表和电脑之间)
|
||||
- 电源控制(锁屏、关机、重启)
|
||||
- 文件互传
|
||||
- 静音、音量加减、播放控制
|
||||
|
||||
## 几个设计上的取舍
|
||||
|
||||
### 电脑端不弹任何窗口
|
||||
|
||||
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
|
||||
|
||||
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
|
||||
|
||||
### 支持 Windows 7
|
||||
|
||||
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
|
||||
|
||||
### 静默更新为什么要校验
|
||||
|
||||
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
|
||||
|
||||
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
|
||||
|
||||
## 怎么装
|
||||
|
||||
**官网下载:<https://yk.xxmxxs.top>**
|
||||
|
||||
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
|
||||
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
|
||||
|
||||
装完之后:
|
||||
|
||||
1. 手表连上电脑
|
||||
2. 会先到一个选择页:**远控** / **静默更新被控端**
|
||||
3. 点「远控」进控制页
|
||||
|
||||
## 说在最后
|
||||
|
||||
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
|
||||
|
||||
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
|
||||
|
||||
**有问题、想要什么功能,欢迎留言。**
|
||||
"""
|
||||
|
||||
|
||||
def md_to_html(text: str) -> str:
|
||||
"""把这份固定的 Markdown 转成 HTML。
|
||||
|
||||
⚠ 这里**不是在写通用 Markdown 解析器** —— 那样要引第三方库,
|
||||
而我们的正文是我自己写的、结构已知。用简单的正则按块处理就够了,
|
||||
而且不会因为库的行为差异出意外。
|
||||
|
||||
支持:## / ### 标题、**粗体**、`代码`、- 列表、1. 列表、
|
||||
表格、> 引用、<https://...> 自动链接、空行分段。
|
||||
"""
|
||||
import html as _html
|
||||
import re as _re
|
||||
|
||||
def inline(s: str) -> str:
|
||||
# 先转义,避免正文里的 < > 破坏 HTML
|
||||
s = _html.escape(s, quote=False)
|
||||
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
||||
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||
s = _re.sub(r"<(https?://[^&]+?)>", r'<a href="\1">\1</a>', s)
|
||||
return s
|
||||
|
||||
out = []
|
||||
lines = text.split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
ln = lines[i]
|
||||
if not ln.strip():
|
||||
i += 1
|
||||
continue
|
||||
# 表格
|
||||
if ln.startswith("|") and i + 1 < len(lines) and \
|
||||
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
|
||||
head = [c.strip() for c in ln.strip("|").split("|")]
|
||||
i += 2
|
||||
rows = []
|
||||
while i < len(lines) and lines[i].startswith("|"):
|
||||
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
|
||||
i += 1
|
||||
out.append("<table><thead><tr>" +
|
||||
"".join("<th>%s</th>" % inline(c) for c in head) +
|
||||
"</tr></thead><tbody>" +
|
||||
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
|
||||
for c in r) + "</tr>"
|
||||
for r in rows) +
|
||||
"</tbody></table>")
|
||||
continue
|
||||
# 标题
|
||||
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
|
||||
if m:
|
||||
lvl = len(m.group(1))
|
||||
out.append("<h%d>%s</h%d>" % (lvl, inline(m.group(2)), lvl))
|
||||
i += 1
|
||||
continue
|
||||
# 列表
|
||||
if _re.match(r"^[-*]\s+", ln):
|
||||
items = []
|
||||
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
|
||||
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
|
||||
i += 1
|
||||
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items) +
|
||||
"</ul>")
|
||||
continue
|
||||
if _re.match(r"^\d+\.\s+", ln):
|
||||
items = []
|
||||
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
|
||||
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
|
||||
i += 1
|
||||
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items) +
|
||||
"</ol>")
|
||||
continue
|
||||
# 引用
|
||||
if ln.startswith(">"):
|
||||
out.append("<blockquote>%s</blockquote>"
|
||||
% inline(ln.lstrip("> ").strip()))
|
||||
i += 1
|
||||
continue
|
||||
# 普通段落(连续的合成一段)
|
||||
para = [ln.strip()]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip() and \
|
||||
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
|
||||
para.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append("<p>%s</p>" % inline(" ".join(para)))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 通过 Halo API 发布文章")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
if not PWFILE.is_file():
|
||||
print(" ✗ 找不到 %s" % PWFILE)
|
||||
return 1
|
||||
pw = PWFILE.read_text(encoding="utf-8").strip()
|
||||
print(" 密码已从 %s 读到(长度 %d)" % (PWFILE.name, len(pw)))
|
||||
|
||||
# 1) 先测认证
|
||||
print()
|
||||
print("── 1. 测认证 ──")
|
||||
out = run(r'''
|
||||
curl -s -o /dev/null -w " basic auth 打 posts -> HTTP %{http_code}\n" \
|
||||
--max-time 10 -u "xmz7891:__PW__" \
|
||||
http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts
|
||||
curl -s -o /dev/null -w " basic auth 打 users/- -> HTTP %{http_code}\n" \
|
||||
--max-time 10 -u "xmz7891:__PW__" \
|
||||
http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/users/-
|
||||
'''.replace("__PW__", pw), timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
# 2) 看现在有几篇文章
|
||||
print("── 2. 现在的文章 ──")
|
||||
out = run(r'''
|
||||
curl -s --max-time 10 -u "xmz7891:__PW__" \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print(' 解析失败:', e); sys.exit()
|
||||
items = d.get('items') or []
|
||||
print(' 共', d.get('total', len(items)), '篇')
|
||||
for p in items:
|
||||
sp = p.get('spec', {})
|
||||
print(' -', sp.get('title'), '| publish =', sp.get('publish'),
|
||||
'| name =', (p.get('metadata') or {}).get('name'))
|
||||
"
|
||||
'''.replace("__PW__", pw), timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''通过 Halo API 把文章写进去(第二版:先登录拿 cookie)。
|
||||
|
||||
== 第一版为什么不行 ==
|
||||
|
||||
我直接用 `Authorization: Basic` 打 console API,结果 **302**(跳登录页)。
|
||||
Halo 2.x 的 console API(`/apis/api.console.halo.run/...`)**不认 Basic**,
|
||||
它要的是:
|
||||
|
||||
* **会话 cookie**(网页登录后拿到的那个),或者
|
||||
* **Personal Access Token**(`Authorization: Bearer xxx`)
|
||||
|
||||
而 `users/-` 返回 200 是**假的** —— 那个端点匿名也放行(返回空)。
|
||||
|
||||
== 这一版怎么做 ==
|
||||
|
||||
走**网页表单登录**拿会话 cookie:
|
||||
|
||||
POST /login (form-urlencoded: username, password)
|
||||
-> Set-Cookie: SESSION=xxx
|
||||
|
||||
然后带着这个 cookie 调 console API 建文章。
|
||||
|
||||
== 文章内容怎么给 ==
|
||||
|
||||
Halo 建文章的接口要 `PostRequest`:
|
||||
|
||||
{"post": {...Post 资源...},
|
||||
"content": {"raw": "Markdown 源码", "content": "渲染后的 HTML",
|
||||
"rawType": "markdown"}}
|
||||
|
||||
**`content` 和 `raw` 都要给**:
|
||||
* `content`(HTML)是前台直接渲染的
|
||||
* `raw`(Markdown)是编辑器里显示/可再编辑的
|
||||
|
||||
只给一个的话,要么前台是空的,要么编辑器打开是乱码。
|
||||
|
||||
== 顺手 ==
|
||||
|
||||
`md_to_html` 是**针对这份固定正文**写的简易转换,不是通用解析器 ——
|
||||
不引第三方库,行为可控。支持的语法就是我正文里用到的那些。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
PWFILE = ROOT / "_build" / ".halo_password"
|
||||
BASE = "http://127.0.0.1:3001"
|
||||
USER = "xmz7891"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" Halo:表单登录 -> 发文章")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
pw = PWFILE.read_text(encoding="utf-8").strip()
|
||||
|
||||
# ---------------------------------------------------------------- 登录
|
||||
print("── 1. 表单登录拿 cookie ──")
|
||||
out = run(r'''
|
||||
rm -f /tmp/halo_cookie.txt
|
||||
echo " 试 POST /login:"
|
||||
curl -s -c /tmp/halo_cookie.txt -o /tmp/halo_login_out.txt -w " HTTP %{http_code} 跳转-> %{redirect_url}\n" \
|
||||
--max-time 15 -X POST http://127.0.0.1:3001/login \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "username=__U__" \
|
||||
--data-urlencode "password=__PW__"
|
||||
|
||||
echo " 拿到的 cookie:"
|
||||
grep -v '^#' /tmp/halo_cookie.txt 2>/dev/null | awk '{print " "$6" = "substr($7,1,20)"..."}' || echo " (没有 cookie)"
|
||||
|
||||
echo
|
||||
echo " 用 cookie 打 console API:"
|
||||
curl -s -b /tmp/halo_cookie.txt -o /dev/null -w " posts -> HTTP %{http_code}\n" \
|
||||
--max-time 10 "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=5"
|
||||
'''.replace("__PW__", pw).replace("__U__", USER), timeout=240, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
print()
|
||||
print("── 2. 如果 cookie 有效,列出现在的文章 ──")
|
||||
out = run(r'''
|
||||
curl -s -b /tmp/halo_cookie.txt --max-time 10 \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
|
||||
> /tmp/halo_posts.json 2>/dev/null
|
||||
head -c 600 /tmp/halo_posts.json
|
||||
echo
|
||||
''', timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,391 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''Halo 发文章(最终版):完整登录流程 + 建文章 + 验证。
|
||||
|
||||
== 走通的路 ==
|
||||
|
||||
1. GET /login -> 会话 cookie + HTML 里的 _csrf token
|
||||
2. POST /login -> cookie + _csrf + 用户名密码
|
||||
3. GET posts -> 确认会话有效 + 看现有文章
|
||||
4. POST posts -> 建文章(PostRequest 结构)
|
||||
5. GET 前台首页 -> 确认公开可见
|
||||
|
||||
== 三次失败的记录(免得以后再绕)==
|
||||
|
||||
① `Authorization: Basic` -> 302
|
||||
Halo 的 console API 不认 Basic,要会话 cookie 或 Personal Access Token。
|
||||
|
||||
② 直接 POST /login -> 403
|
||||
Spring Security 的 CSRF 保护,必须带上 GET 时拿到的 `_csrf`。
|
||||
|
||||
③ 只看 cookie jar 的格式 -> 以为没 cookie
|
||||
curl 的 jar 是 tab 分隔的,我按空格拆了。实际有。
|
||||
|
||||
== 建文章的请求体 ==
|
||||
|
||||
POST /apis/api.console.halo.run/v1alpha1/posts
|
||||
{
|
||||
"post": {
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Post",
|
||||
"metadata": {"name": "post-<随机>", "generateName": "post-"},
|
||||
"spec": {
|
||||
"title": "...", "slug": "...",
|
||||
"publish": true, "publishTime": "...",
|
||||
"visible": "PUBLIC", "allowComment": true,
|
||||
"excerpt": {"autoGenerate": false, "raw": "摘要..."},
|
||||
"categories": [], "tags": [], "htmlMetas": []
|
||||
}
|
||||
},
|
||||
"content": {"raw": "Markdown 源码", "content": "渲染后的 HTML",
|
||||
"rawType": "markdown"}
|
||||
}
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html as _html
|
||||
import json
|
||||
import re as _re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
PWFILE = ROOT / "_build" / ".halo_password"
|
||||
USER = "xmz7891"
|
||||
|
||||
TITLE = "远控软件正式上线"
|
||||
SLUG = "remote-control-launch"
|
||||
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,就能操作"
|
||||
"教室那台电脑:远程鼠标、投图到大屏、语音播报、静默自动更新。")
|
||||
|
||||
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
|
||||
|
||||
## 它是干什么的
|
||||
|
||||
很多场景下,人不在电脑前面,但需要操作那台电脑:
|
||||
|
||||
- 上课时站在教室中间,想切个课件、放张图
|
||||
- 坐在办公室,想看看教室那台电脑还开着没
|
||||
- 手上拿着东西,腾不出手去碰鼠标键盘
|
||||
|
||||
**这个软件就是解决这个的。**
|
||||
|
||||
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
|
||||
|
||||
## 能做什么
|
||||
|
||||
### 远程鼠标控制
|
||||
|
||||
手表变成触摸板:
|
||||
|
||||
- **拖动** —— 移动鼠标
|
||||
- **单击** —— 左键
|
||||
- **双击** —— 右键
|
||||
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
|
||||
|
||||
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
|
||||
|
||||
### 一键显示桌面
|
||||
|
||||
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
|
||||
|
||||
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
|
||||
|
||||
### 电脑端静默自动更新
|
||||
|
||||
这是最省心的一点:
|
||||
|
||||
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
|
||||
|
||||
- 全程**不弹任何窗口**
|
||||
- 中转地址、配对码、所有设置**一个都不变**
|
||||
- 更新完自动回来继续跑
|
||||
|
||||
不用再跑到电脑前手动装一遍。
|
||||
|
||||
### 顶部快捷栏
|
||||
|
||||
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
|
||||
|
||||
| 可选动作 | 说明 |
|
||||
|---|---|
|
||||
| 传文件 | 从手表把文件发到电脑 |
|
||||
| 屏幕镜像 | 看电脑屏幕 |
|
||||
| 鼠标控制 | 进触摸板模式 |
|
||||
| 音效 | 让电脑放一声 |
|
||||
| 关机 | 锁屏 / 关机 / 重启 |
|
||||
| 语音播报 | 让电脑念一段字 |
|
||||
| 投图到电脑 | 手表选图,全屏投到大屏 |
|
||||
| 显示桌面 | 等于 Win + D |
|
||||
|
||||
在设置页里点一下就能换。
|
||||
|
||||
### 投图到大屏
|
||||
|
||||
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
|
||||
|
||||
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
|
||||
|
||||
### 语音播报
|
||||
|
||||
输入文字,电脑用系统自带的语音念出来。
|
||||
|
||||
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
|
||||
|
||||
### 让电脑放个音效
|
||||
|
||||
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
|
||||
|
||||
### 其他
|
||||
|
||||
- 剪贴板互传(手表和电脑之间)
|
||||
- 电源控制(锁屏、关机、重启)
|
||||
- 文件互传
|
||||
- 静音、音量加减、播放控制
|
||||
|
||||
## 几个设计上的取舍
|
||||
|
||||
### 电脑端不弹任何窗口
|
||||
|
||||
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
|
||||
|
||||
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
|
||||
|
||||
### 支持 Windows 7
|
||||
|
||||
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
|
||||
|
||||
### 静默更新为什么要校验
|
||||
|
||||
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
|
||||
|
||||
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
|
||||
|
||||
## 怎么装
|
||||
|
||||
**官网下载:<https://yk.xxmxxs.top>**
|
||||
|
||||
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
|
||||
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
|
||||
|
||||
装完之后:
|
||||
|
||||
1. 手表连上电脑
|
||||
2. 会先到一个选择页:**远控** / **静默更新被控端**
|
||||
3. 点「远控」进控制页
|
||||
|
||||
## 说在最后
|
||||
|
||||
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
|
||||
|
||||
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
|
||||
|
||||
**有问题、想要什么功能,欢迎留言。**
|
||||
"""
|
||||
|
||||
|
||||
def md_to_html(text: str) -> str:
|
||||
"""把这份固定的 Markdown 转成 HTML。
|
||||
|
||||
不是通用解析器 —— 只处理我正文里用到的那几种语法。
|
||||
好处:不引第三方库,行为完全可控,不会有意外。
|
||||
"""
|
||||
def inline(s: str) -> str:
|
||||
s = _html.escape(s, quote=False)
|
||||
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
||||
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||
s = _re.sub(r"<(https?://[^&]+?)>", r'<a href="\1">\1</a>', s)
|
||||
return s
|
||||
|
||||
out, lines, i = [], text.split("\n"), 0
|
||||
while i < len(lines):
|
||||
ln = lines[i]
|
||||
if not ln.strip():
|
||||
i += 1
|
||||
continue
|
||||
if ln.startswith("|") and i + 1 < len(lines) and \
|
||||
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
|
||||
head = [c.strip() for c in ln.strip("|").split("|")]
|
||||
i += 2
|
||||
rows = []
|
||||
while i < len(lines) and lines[i].startswith("|"):
|
||||
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
|
||||
i += 1
|
||||
out.append("<table><thead><tr>" +
|
||||
"".join("<th>%s</th>" % inline(c) for c in head) +
|
||||
"</tr></thead><tbody>" +
|
||||
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
|
||||
for c in r) + "</tr>"
|
||||
for r in rows) + "</tbody></table>")
|
||||
continue
|
||||
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
|
||||
if m:
|
||||
lv = len(m.group(1))
|
||||
out.append("<h%d>%s</h%d>" % (lv, inline(m.group(2)), lv))
|
||||
i += 1
|
||||
continue
|
||||
if _re.match(r"^[-*]\s+", ln):
|
||||
items = []
|
||||
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
|
||||
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
|
||||
i += 1
|
||||
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items)
|
||||
+ "</ul>")
|
||||
continue
|
||||
if _re.match(r"^\d+\.\s+", ln):
|
||||
items = []
|
||||
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
|
||||
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
|
||||
i += 1
|
||||
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items)
|
||||
+ "</ol>")
|
||||
continue
|
||||
if ln.startswith(">"):
|
||||
out.append("<blockquote>%s</blockquote>"
|
||||
% inline(ln.lstrip("> ").strip()))
|
||||
i += 1
|
||||
continue
|
||||
para = [ln.strip()]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip() and \
|
||||
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
|
||||
para.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append("<p>%s</p>" % inline(" ".join(para)))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" Halo:登录 -> 发文章")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
pw = PWFILE.read_text(encoding="utf-8").strip()
|
||||
body_html = md_to_html(MD)
|
||||
print(" 正文 Markdown %d 字 -> HTML %d 字" % (len(MD), len(body_html)))
|
||||
|
||||
# 请求体
|
||||
import datetime
|
||||
now = datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
import uuid
|
||||
req_body = {
|
||||
"post": {
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Post",
|
||||
"metadata": {"name": "post-" + uuid.uuid4().hex[:12],
|
||||
"generateName": "post-"},
|
||||
"spec": {
|
||||
"title": TITLE,
|
||||
"slug": SLUG,
|
||||
"template": "",
|
||||
"cover": "",
|
||||
"deleted": False,
|
||||
"publish": True,
|
||||
"publishTime": now,
|
||||
"pinned": False,
|
||||
"allowComment": True,
|
||||
"visible": "PUBLIC",
|
||||
"priority": 0,
|
||||
"excerpt": {"autoGenerate": False, "raw": EXCERPT},
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"htmlMetas": [],
|
||||
},
|
||||
},
|
||||
"content": {"raw": MD, "content": body_html, "rawType": "markdown"},
|
||||
}
|
||||
payload = json.dumps(req_body, ensure_ascii=False)
|
||||
|
||||
# 把请求体传到服务器(用 base64 避免 shell 转义地狱)
|
||||
b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
|
||||
|
||||
print()
|
||||
print("── 1. 登录(GET /login 拿 csrf+cookie,再 POST)──")
|
||||
out = run(r'''
|
||||
set -e
|
||||
U=__U__
|
||||
PW=__PW__
|
||||
BASE=http://127.0.0.1:3001
|
||||
J=/tmp/hc.txt
|
||||
H=/tmp/login.html
|
||||
rm -f $J $H
|
||||
|
||||
# 1) GET /login
|
||||
curl -s -c $J -o $H --max-time 15 "$BASE/login"
|
||||
CSRF=$(grep -oE 'name="_csrf" value="[^"]+"' $H | head -1 | sed 's/.*value="//; s/"//')
|
||||
echo " csrf: ${CSRF:0:24}...(长度 ${#CSRF})"
|
||||
if [ -z "$CSRF" ]; then echo " ✗ 拿不到 csrf"; exit 1; fi
|
||||
|
||||
# 2) POST /login
|
||||
curl -s -b $J -c $J -o /tmp/login_resp.html \
|
||||
-w " POST /login -> HTTP %{http_code} 跳转-> %{redirect_url}\n" \
|
||||
--max-time 20 -X POST "$BASE/login" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "_csrf=$CSRF" \
|
||||
--data-urlencode "username=$U" \
|
||||
--data-urlencode "password=$PW"
|
||||
|
||||
echo " 会话 cookie:"
|
||||
grep -v '^#' $J 2>/dev/null | awk -F'\t' '{if($6!="") print " "$6" (长度 "length($7)")"}'
|
||||
|
||||
# 3) 验证
|
||||
curl -s -b $J -o /dev/null -w " 带 cookie 打 posts -> HTTP %{http_code}\n" \
|
||||
--max-time 10 "$BASE/apis/api.console.halo.run/v1alpha1/posts?page=0&size=3"
|
||||
'''.replace("__PW__", pw).replace("__U__", USER), timeout=300, quiet=True)
|
||||
o = out[1] if isinstance(out, tuple) else str(out)
|
||||
print(o)
|
||||
if "posts -> HTTP 200" not in o:
|
||||
print()
|
||||
print(" ✗ 登录没成功。要么密码不对,要么流程还差一步。")
|
||||
print(" 替代方案:在 Halo 后台建个「个人令牌」,我用 Bearer 发。")
|
||||
return 1
|
||||
|
||||
print()
|
||||
print("── 2. 看现有文章 ──")
|
||||
out = run(r'''
|
||||
curl -s -b /tmp/hc.txt --max-time 10 \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(' 共', d.get('total'), '篇')
|
||||
for p in (d.get('items') or []):
|
||||
s=p.get('spec',{})
|
||||
print(' -', repr(s.get('title')), '| publish=', s.get('publish'), '| name=', (p.get('metadata') or {}).get('name'))
|
||||
"
|
||||
''', timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
print()
|
||||
print("── 3. 建文章 ──")
|
||||
# 把 base64 的请求体写进服务器临时文件,再解码用
|
||||
out = run("printf '%s' '%s' > /tmp/halo_body.b64 && "
|
||||
"base64 -d /tmp/halo_body.b64 > /tmp/halo_body.json && "
|
||||
"echo \" 请求体 $(wc -c < /tmp/halo_body.json) 字节\"" % (b64,),
|
||||
timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
out = run(r'''
|
||||
curl -s -b /tmp/hc.txt --max-time 30 \
|
||||
-w "\n HTTP %{http_code}\n" \
|
||||
-X POST "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/halo_body.json > /tmp/halo_post_resp.json 2>&1
|
||||
tail -3 /tmp/halo_post_resp.json
|
||||
echo " --- 响应前 400 字 ---"
|
||||
head -c 400 /tmp/halo_post_resp.json
|
||||
''', timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,319 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''用已经拿到的会话 cookie 把文章发出去。
|
||||
|
||||
用户 cookie 在服务器 `/tmp/hc_good.txt`(上一步 RSA 登录拿到的)。
|
||||
|
||||
== 建文章的接口 ==
|
||||
|
||||
POST /apis/api.console.halo.run/v1alpha1/posts
|
||||
Cookie: 会话
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"post": { ...Post 资源... },
|
||||
"content": {"raw": "Markdown", "content": "HTML", "rawType": "markdown"}
|
||||
}
|
||||
|
||||
== 为什么用 base64 传请求体 ==
|
||||
|
||||
正文里有中文、引号、换行、`<` `>` —— 直接塞进 shell 命令会
|
||||
被转义吃掉(这一天里因为转义翻了好几次车)。
|
||||
base64 之后是纯 ASCII,绝不会有问题,服务器上再解回来。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import html as _html
|
||||
import json
|
||||
import re as _re
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
TITLE = "远控软件正式上线"
|
||||
SLUG = "remote-control-launch"
|
||||
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,就能操作"
|
||||
"教室那台电脑:远程鼠标、投图到大屏、语音播报、静默自动更新。"
|
||||
"官网 yk.xxmxxs.top")
|
||||
|
||||
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
|
||||
|
||||
## 它是干什么的
|
||||
|
||||
很多场景下,人不在电脑前面,但需要操作那台电脑:
|
||||
|
||||
- 上课时站在教室中间,想切个课件、放张图
|
||||
- 坐在办公室,想看看教室那台电脑还开着没
|
||||
- 手上拿着东西,腾不出手去碰鼠标键盘
|
||||
|
||||
**这个软件就是解决这个的。**
|
||||
|
||||
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
|
||||
|
||||
## 能做什么
|
||||
|
||||
### 远程鼠标控制
|
||||
|
||||
手表变成触摸板:
|
||||
|
||||
- **拖动** —— 移动鼠标
|
||||
- **单击** —— 左键
|
||||
- **双击** —— 右键
|
||||
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
|
||||
|
||||
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
|
||||
|
||||
### 一键显示桌面
|
||||
|
||||
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
|
||||
|
||||
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
|
||||
|
||||
### 电脑端静默自动更新
|
||||
|
||||
这是最省心的一点:
|
||||
|
||||
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
|
||||
|
||||
- 全程**不弹任何窗口**
|
||||
- 中转地址、配对码、所有设置**一个都不变**
|
||||
- 更新完自动回来继续跑
|
||||
|
||||
不用再跑到电脑前手动装一遍。
|
||||
|
||||
### 顶部快捷栏
|
||||
|
||||
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
|
||||
|
||||
| 可选动作 | 说明 |
|
||||
|---|---|
|
||||
| 传文件 | 从手表把文件发到电脑 |
|
||||
| 屏幕镜像 | 看电脑屏幕 |
|
||||
| 鼠标控制 | 进触摸板模式 |
|
||||
| 音效 | 让电脑放一声 |
|
||||
| 关机 | 锁屏 / 关机 / 重启 |
|
||||
| 语音播报 | 让电脑念一段字 |
|
||||
| 投图到电脑 | 手表选图,全屏投到大屏 |
|
||||
| 显示桌面 | 等于 Win + D |
|
||||
|
||||
在设置页里点一下就能换。
|
||||
|
||||
### 投图到大屏
|
||||
|
||||
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
|
||||
|
||||
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
|
||||
|
||||
### 语音播报
|
||||
|
||||
输入文字,电脑用系统自带的语音念出来。
|
||||
|
||||
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
|
||||
|
||||
### 让电脑放个音效
|
||||
|
||||
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
|
||||
|
||||
### 其他
|
||||
|
||||
- 剪贴板互传(手表和电脑之间)
|
||||
- 电源控制(锁屏、关机、重启)
|
||||
- 文件互传
|
||||
- 静音、音量加减、播放控制
|
||||
|
||||
## 几个设计上的取舍
|
||||
|
||||
### 电脑端不弹任何窗口
|
||||
|
||||
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
|
||||
|
||||
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
|
||||
|
||||
### 支持 Windows 7
|
||||
|
||||
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
|
||||
|
||||
### 静默更新为什么要校验
|
||||
|
||||
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
|
||||
|
||||
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
|
||||
|
||||
## 怎么装
|
||||
|
||||
**官网下载:<https://yk.xxmxxs.top>**
|
||||
|
||||
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
|
||||
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
|
||||
|
||||
装完之后:
|
||||
|
||||
1. 手表连上电脑
|
||||
2. 会先到一个选择页:**远控** / **静默更新被控端**
|
||||
3. 点「远控」进控制页
|
||||
|
||||
## 说在最后
|
||||
|
||||
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
|
||||
|
||||
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
|
||||
|
||||
**有问题、想要什么功能,欢迎留言。**
|
||||
"""
|
||||
|
||||
|
||||
def md_to_html(text: str) -> str:
|
||||
def inline(s: str) -> str:
|
||||
s = _html.escape(s, quote=False)
|
||||
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
||||
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||
s = _re.sub(r"<(https?://[^&]+?)>", r'<a href="\1">\1</a>', s)
|
||||
return s
|
||||
|
||||
out, lines, i = [], text.split("\n"), 0
|
||||
while i < len(lines):
|
||||
ln = lines[i]
|
||||
if not ln.strip():
|
||||
i += 1
|
||||
continue
|
||||
if ln.startswith("|") and i + 1 < len(lines) and \
|
||||
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
|
||||
head = [c.strip() for c in ln.strip("|").split("|")]
|
||||
i += 2
|
||||
rows = []
|
||||
while i < len(lines) and lines[i].startswith("|"):
|
||||
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
|
||||
i += 1
|
||||
out.append("<table><thead><tr>" +
|
||||
"".join("<th>%s</th>" % inline(c) for c in head) +
|
||||
"</tr></thead><tbody>" +
|
||||
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
|
||||
for c in r) + "</tr>"
|
||||
for r in rows) + "</tbody></table>")
|
||||
continue
|
||||
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
|
||||
if m:
|
||||
lv = len(m.group(1))
|
||||
out.append("<h%d>%s</h%d>" % (lv, inline(m.group(2)), lv))
|
||||
i += 1
|
||||
continue
|
||||
if _re.match(r"^[-*]\s+", ln):
|
||||
items = []
|
||||
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
|
||||
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
|
||||
i += 1
|
||||
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items)
|
||||
+ "</ul>")
|
||||
continue
|
||||
if _re.match(r"^\d+\.\s+", ln):
|
||||
items = []
|
||||
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
|
||||
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
|
||||
i += 1
|
||||
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items)
|
||||
+ "</ol>")
|
||||
continue
|
||||
if ln.startswith(">"):
|
||||
out.append("<blockquote>%s</blockquote>"
|
||||
% inline(ln.lstrip("> ").strip()))
|
||||
i += 1
|
||||
continue
|
||||
para = [ln.strip()]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip() and \
|
||||
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
|
||||
para.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append("<p>%s</p>" % inline(" ".join(para)))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 发文章到 Halo")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
body_html = md_to_html(MD)
|
||||
print(" 正文 Markdown %d 字 -> HTML %d 字" % (len(MD), len(body_html)))
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
req = {
|
||||
"post": {
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Post",
|
||||
"metadata": {"name": "post-" + uuid.uuid4().hex[:12],
|
||||
"generateName": "post-"},
|
||||
"spec": {
|
||||
"title": TITLE, "slug": SLUG, "template": "", "cover": "",
|
||||
"deleted": False, "publish": True, "publishTime": now,
|
||||
"pinned": False, "allowComment": True, "visible": "PUBLIC",
|
||||
"priority": 0,
|
||||
"excerpt": {"autoGenerate": False, "raw": EXCERPT},
|
||||
"categories": [], "tags": [], "htmlMetas": [],
|
||||
},
|
||||
},
|
||||
"content": {"raw": MD, "content": body_html, "rawType": "markdown"},
|
||||
}
|
||||
b64 = base64.b64encode(json.dumps(req, ensure_ascii=False)
|
||||
.encode("utf-8")).decode("ascii")
|
||||
|
||||
print()
|
||||
print("── 建文章 ──")
|
||||
# ⚠ 这里**不能用 % 格式化** —— 命令里那个 `printf '%s'` 的 `%s`
|
||||
# 会被 Python 当成占位符(报 "not enough arguments for format string")。
|
||||
# 这是今天第 N 次踩同一类坑了:**要拼进 shell 的字符串里含 % 时,
|
||||
# 一律用 replace,不用 % 也不用 .format()。**
|
||||
cmd = ("printf '%s' '__B64__' | base64 -d > /tmp/halo_new.json && "
|
||||
"echo \" 请求体 $(wc -c < /tmp/halo_new.json) 字节\"")
|
||||
cmd = cmd.replace("__B64__", b64)
|
||||
out = run(cmd, timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
out = run(r'''
|
||||
# 先确认 cookie 还有效
|
||||
CODE=$(curl -s -b /tmp/hc_good.txt -o /dev/null -w '%{http_code}' --max-time 12 \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=1")
|
||||
echo " cookie 状态: HTTP $CODE"
|
||||
if [ "$CODE" != "200" ]; then echo " ✗ cookie 失效了,要重新登录"; exit 1; fi
|
||||
|
||||
echo " --- 发 ---"
|
||||
curl -s -b /tmp/hc_good.txt --max-time 40 \
|
||||
-w "\n HTTP %{http_code}\n" \
|
||||
-X POST "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/halo_new.json > /tmp/halo_new_resp.json 2>&1
|
||||
tail -2 /tmp/halo_new_resp.json
|
||||
echo " --- 响应前 300 字 ---"
|
||||
head -c 300 /tmp/halo_new_resp.json
|
||||
echo
|
||||
echo
|
||||
echo " --- 现在有几篇 ---"
|
||||
curl -s -b /tmp/hc_good.txt --max-time 12 \
|
||||
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=10" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(' 共', d.get('total'), '篇')
|
||||
for p in (d.get('items') or []):
|
||||
s=p.get('spec') or {}
|
||||
md=p.get('metadata') or {}
|
||||
print(' -', repr(s.get('title')), '| publish=', s.get('publish'), '|', md.get('name'))
|
||||
"
|
||||
echo
|
||||
echo " --- 前台首页能看到了吗 ---"
|
||||
curl -s --max-time 12 http://127.0.0.1:3001/ | grep -oiE '<title>[^<]*</title>' | head -2 | sed 's/^/ /'
|
||||
curl -s -o /dev/null -w " 首页 HTTP %{http_code}\n" --max-time 12 http://127.0.0.1/
|
||||
''', timeout=400, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''走 Halo 的「邮箱找回密码」流程。
|
||||
|
||||
== 流程长什么样 ==
|
||||
|
||||
Halo 2.x 的重置密码分两步:
|
||||
|
||||
1. POST /password/restore/email {"email": "..."}
|
||||
-> 服务器往这个邮箱发一个验证码(6 位数字)
|
||||
|
||||
2. POST /password/restore/email/verify {"email": "...", "code": "..."}
|
||||
-> 校验验证码
|
||||
|
||||
3. PUT /password/restore {"password": "新密码"}
|
||||
-> 用上一步换来的临时凭据改密码
|
||||
|
||||
**⚠ 但前提是 Halo 配了 SMTP** —— 没配的话邮件根本发不出去,
|
||||
用户等半天收不到码,白折腾。
|
||||
|
||||
所以这个脚本**先查 SMTP 配置**,再决定要不要发。
|
||||
|
||||
== 为什么不用令牌 ==
|
||||
|
||||
用户先提邮箱这个方案,就按他的来。但两个方案其实可以并行:
|
||||
* 邮箱重置:能改密码 → 然后我用新密码登录
|
||||
* 个人令牌:不用改密码,更安全
|
||||
|
||||
**改密码有个副作用**:用户自己也得记住新密码。
|
||||
所以我会把新密码**明确告诉用户**。
|
||||
|
||||
== 关于验证码 ==
|
||||
|
||||
用户会把验证码发给我。验证码有时效(Halo 默认 10 分钟),
|
||||
所以拿到之后要**立刻**用。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
EMAIL = "3235109770@qq.com"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" Halo 邮箱找回密码 —— 先确认能不能发邮件")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
print("── 1. 重置接口存在吗 ──")
|
||||
out = run(r'''
|
||||
BASE=http://127.0.0.1:3001
|
||||
for ep in \
|
||||
"/password/restore/email" \
|
||||
"/apis/api.halo.run/v1alpha1/password/restore/email" \
|
||||
"/apis/uc.api.auth.halo.run/v1alpha1/password/restore/email" \
|
||||
"/apis/api.console.halo.run/v1alpha1/indices/-/users" \
|
||||
; do
|
||||
printf " %-64s " "$ep"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 "$BASE$ep"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "── 2. 探测一下 POST 的形状(不发真请求,只看方法是否允许)──"
|
||||
curl -s -o /dev/null -w " POST /password/restore/email -> %{http_code}\n" \
|
||||
--max-time 10 -X POST "http://127.0.0.1:3001/password/restore/email" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
|
||||
echo
|
||||
echo "── 3. Halo 的 SMTP 配了吗(数据库里找)──"
|
||||
sudo docker exec halo sh -c "
|
||||
find /root/.halo2 -name '*.mv.db' -o -name '*.db' 2>/dev/null | head -3
|
||||
" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo " 从设置接口看(需要登录,先看看会不会泄露结构):"
|
||||
curl -s --max-time 8 "http://127.0.0.1:3001/apis/api.halo.run/v1alpha1/configmaps" | head -c 200
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "── 4. 邮件相关的日志(有没有发信记录/报错)──"
|
||||
sudo docker logs halo 2>&1 | grep -iE "mail|smtp|email|JavaMail" | tail -10 | sed 's/^/ /' || echo " (日志里没提邮件)"
|
||||
|
||||
echo
|
||||
echo "── 5. 用户的邮箱地址(确认收件人)──"
|
||||
curl -s --max-time 8 "http://127.0.0.1:3001/apis/api.halo.run/v1alpha1/users" | head -c 300
|
||||
echo
|
||||
''', timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
print()
|
||||
print("── 6. 试发一次(如果接口在)──")
|
||||
out = run(r'''
|
||||
curl -s -w "\n HTTP %{http_code}\n" --max-time 20 \
|
||||
-X POST "http://127.0.0.1:3001/password/restore/email" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"__EMAIL__"}' 2>&1 | head -20
|
||||
'''.replace("__EMAIL__", EMAIL), timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
|
||||
print()
|
||||
print("── 7. 发完之后看日志(有没有真发出去)──")
|
||||
out = run(r'''
|
||||
sleep 3
|
||||
sudo docker logs --tail 40 halo 2>&1 | grep -iE "mail|smtp|email|restore|code|error|exception" | tail -15 | sed 's/^/ /' || echo " (没有相关日志)"
|
||||
''', timeout=200, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''增强隧道稳定性 —— 「网站还是容易挂」的真因修法。
|
||||
|
||||
== 查到什么 ==
|
||||
|
||||
cloudflared 自己的 precheck 输出(tunnel.log):
|
||||
|
||||
| UDP Connectivity region1.v2.argotunnel.com FAIL QUIC connection failed |
|
||||
| UDP Connectivity region2.v2.argotunnel.com FAIL QUIC connection failed |
|
||||
| TCP Connectivity region1.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable |
|
||||
| TCP Connectivity region2.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable |
|
||||
| Cloudflare API api.cloudflare.com:443 PASS API is reachable |
|
||||
| ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2. |
|
||||
|
||||
也就是:**443 通,7844 不通**,而且是**间歇性**的
|
||||
(日志后面又出现 Registered tunnel connection,过一阵又掉)。
|
||||
|
||||
Cloudflare 隧道的边缘连接走 7844(QUIC 走 UDP 7844、HTTP/2 走 TCP 7844)。
|
||||
这条线路对 7844 是间歇性阻断的 —— 这不是设备能修的,
|
||||
但**可以让 cloudflared 自己去适应**。
|
||||
|
||||
== 现在的配置有两个问题 ==
|
||||
|
||||
1. **两条实例各写死一个协议**
|
||||
lanremote-tunnel --protocol quic (只走 UDP 7844)
|
||||
lanremote-tunnel-h2 --protocol http2 (只走 TCP 7844)
|
||||
如果那一刻 7844 的 UDP 和 TCP 都不通,两条一起死 —— 没有退路。
|
||||
实测就是这样:连接数掉到 1/0,公网 530。
|
||||
|
||||
2. **--retries 5**
|
||||
cloudflared 重试 5 次就**退出**,靠 systemd Restart=always 拉起来。
|
||||
每次退出/重启期间网站都是断的。而这个线路的抖动是分钟级的,
|
||||
5 次远远不够扛过去。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
1. `--protocol auto` —— 让 cloudflared **自己**在 QUIC 和 HTTP/2 之间选,
|
||||
哪条通走哪条。两条实例都改成 auto,等于 10 条连接全都能自适应。
|
||||
(而不是现在这样"8 条赌 UDP、2 条赌 TCP")
|
||||
|
||||
2. `--retries` 提到很大(相当于"一直重试,别退出")——
|
||||
抖动期间连接会自己恢复,不会整条隧道退出让网站断掉。
|
||||
|
||||
3. 顺手加日志轮转:tunnel.log 已经涨到 2.98 MB,
|
||||
这台机器只有 460MB 内存 + 小硬盘,无限增长不合适。
|
||||
'''
|
||||
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
|
||||
|
||||
|
||||
def patch_unit(name: str, proto: str) -> str:
|
||||
"""读设备上现有的 unit,改协议和重试次数。
|
||||
|
||||
用 sed 现场改而不是整份重写 —— 现有 unit 里还有
|
||||
metrics 端口、ha-connections、MemoryMax 这些实测调出来的参数,
|
||||
整份重写容易漏掉。
|
||||
"""
|
||||
cmds = [
|
||||
# 协议
|
||||
"sed -i 's/--protocol [a-z0-9]*/--protocol %s/' %s" % (proto, name),
|
||||
# 重试次数:改成很大(一直重试)
|
||||
"sed -i 's/--retries [0-9]*/--retries 1000000/' %s" % name,
|
||||
]
|
||||
return " && ".join(cmds)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 隧道稳定性:协议改 auto + 加大重试 + 日志轮转")
|
||||
print("=" * 62)
|
||||
|
||||
UNIT_DIR = "/etc/systemd/system"
|
||||
|
||||
print()
|
||||
print(" ── 1. 两条隧道都改成 --protocol auto ──")
|
||||
out = run(
|
||||
"cd %s && "
|
||||
"sed -i 's/--protocol [a-z0-9]*/--protocol auto/' "
|
||||
"lanremote-tunnel.service lanremote-tunnel-h2.service && "
|
||||
"echo ' tunnel:'; grep -o -- '--protocol [a-z0-9]*' "
|
||||
"lanremote-tunnel.service; "
|
||||
"echo ' h2:'; grep -o -- '--protocol [a-z0-9]*' "
|
||||
"lanremote-tunnel-h2.service; "
|
||||
# 重试次数
|
||||
"sed -i 's/--retries [0-9]*/--retries 1000000/' "
|
||||
"lanremote-tunnel.service lanremote-tunnel-h2.service && "
|
||||
"echo ' 重试:'; grep -o -- '--retries [0-9]*' "
|
||||
"lanremote-tunnel.service lanremote-tunnel-h2.service"
|
||||
% UNIT_DIR, timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 日志轮转(tunnel.log 已经 2.98 MB)──")
|
||||
out = run(
|
||||
"cat > /etc/logrotate.d/lanremote <<'EOF'\n"
|
||||
"/root/lanremote-relay/*.log {\n"
|
||||
" size 5M\n"
|
||||
" rotate 2\n"
|
||||
" compress\n"
|
||||
" missingok\n"
|
||||
" notifempty\n"
|
||||
" copytruncate\n"
|
||||
"}\n"
|
||||
"EOF\n"
|
||||
"echo ' logrotate 配置已写'; "
|
||||
"logrotate --debug /etc/logrotate.d/lanremote 2>&1 | tail -3",
|
||||
timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 重启两条隧道让新参数生效 ──")
|
||||
out = run(
|
||||
"systemctl daemon-reload && "
|
||||
"systemctl restart lanremote-tunnel lanremote-tunnel-h2 && "
|
||||
"echo ' 等 40 秒让连接建立…' && sleep 40 && "
|
||||
"echo \" tunnel: $(systemctl is-active lanremote-tunnel)\"; "
|
||||
"echo \" h2: $(systemctl is-active lanremote-tunnel-h2)\"",
|
||||
timeout=300)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 连接数 ──")
|
||||
out = run(
|
||||
"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 ' --- 用的什么协议 ---'; "
|
||||
"grep -o 'protocol=[a-z0-9]*' /root/lanremote-relay/tunnel.log 2>/dev/null "
|
||||
"| tail -6 | sort | uniq -c | sed 's/^/ /'", timeout=120)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 公网(连测 5 次看稳不稳)──")
|
||||
out = run(
|
||||
"for i in 1 2 3 4 5; do "
|
||||
"c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||
"https://relay.xxmxxs.top/v1/health); "
|
||||
"echo \" 第 $i 次: $c\"; sleep 3; done", timeout=200)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" 说明:3844/7844 那条线路封不封是运营商的事,设备改不了。")
|
||||
print(" 但 --protocol auto 让 cloudflared 自己绕,")
|
||||
print(" --retries 拉大让它别退出 —— 抖动期间能自己恢复。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,131 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''第二步:装 Halo 博客(Docker)。
|
||||
|
||||
== 配置决策(以及为什么)==
|
||||
|
||||
**用 Docker 而不是裸 JAR**
|
||||
机器上已经有 Docker 29.8(1Panel 装的),直接用。好处:
|
||||
不用在系统里装 Java、升级只要换镜像 tag、环境隔离。
|
||||
|
||||
**数据库用内置 H2(不接 MySQL)**
|
||||
个人博客用 H2 完全够 —— 数据就是一个文件。
|
||||
接 MySQL 要多挂一个容器,多占 400MB+ 内存,而机器只有 1.9G。
|
||||
**少一个组件就少一处会坏的地方。**
|
||||
|
||||
**端口绑 0.0.0.0:3001**
|
||||
用户要求 "端口的话就跑在 <服务器IP>:3001" ——
|
||||
所以要能从外面直接访问 3001。
|
||||
同时 nginx 也要能反代(走域名)。
|
||||
如果只绑 127.0.0.1,`IP:3001` 就不通了。
|
||||
|
||||
**限制 JVM 堆**
|
||||
`-Xmx400m`。机器 1.9G,JVM 默认会按 1/4 物理内存要堆(约 470M),
|
||||
再加元空间/线程栈/直接内存容易冲到 700M+。
|
||||
显式限到 400M 更可控 —— 博客流量小,400M 绰绰有余。
|
||||
|
||||
**外部地址设成 https://xxmxxs.top**
|
||||
Halo 用它生成文章链接和站点地图。设成域名之后,
|
||||
就算你从 `IP:3001` 进来,页面里的链接也是域名 —— 这样更一致。
|
||||
|
||||
**控制台路径 /houtai**
|
||||
用户要求 "xxmxxs.top/houtai 是管理博客的后台"。
|
||||
Halo 2.x 支持自定义控制台路径(社区 PR #2453)。
|
||||
默认是 /console,这里改成 /houtai。
|
||||
⚠ 如果这个版本的 env 变量名不对,容器会起不来 —— 脚本里会检查。
|
||||
|
||||
== 数据目录 ==
|
||||
|
||||
/opt/halo/data -> 容器内 /root/.halo2
|
||||
里面有 H2 数据库、上传的附件、主题。
|
||||
**这个目录要备份** —— 它是博客的全部内容。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
echo "==== 1. 看看有哪些可用的 Halo 版本 ===="
|
||||
sudo docker pull halohub/halo:2 >/dev/null 2>&1 && echo " ✓ halohub/halo:2 拉下来了" || echo " ✗ 拉取失败"
|
||||
sudo docker images halohub/halo --format ' {{.Repository}}:{{.Tag}} {{.Size}} ({{.CreatedSince}})' 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "==== 2. 看看镜像里支持哪些配置项 ===="
|
||||
# Halo 的配置项在 application.yaml 里,从镜像里抠出来看
|
||||
sudo docker run --rm --entrypoint sh halohub/halo:2 -c \
|
||||
"find / -name 'application*.yaml' 2>/dev/null | head -5" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 3. 准备数据目录 ===="
|
||||
sudo mkdir -p /opt/halo/data
|
||||
sudo chown -R 1000:1000 /opt/halo 2>/dev/null || true
|
||||
ls -la /opt/halo | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 4. 起容器 ===="
|
||||
sudo docker rm -f halo 2>/dev/null >/dev/null || true
|
||||
sudo docker run -d \
|
||||
--name halo \
|
||||
--restart always \
|
||||
--log-opt max-size=10m --log-opt max-file=3 \
|
||||
-p 0.0.0.0:3001:8090 \
|
||||
-v /opt/halo/data:/root/.halo2 \
|
||||
-e HALO_EXTERNAL_URL=https://xxmxxs.top \
|
||||
-e HALO_CONSOLE_PATH=/houtai \
|
||||
-e JAVA_OPTS="-Xms128m -Xmx400m -XX:+UseSerialGC" \
|
||||
halohub/halo:2 2>&1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==== 5. 等它起来(最多 90 秒)===="
|
||||
ok=0
|
||||
for i in $(seq 1 45); do
|
||||
sleep 2
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3001/ 2>/dev/null)
|
||||
st=$(sudo docker inspect -f '{{.State.Status}}' halo 2>/dev/null)
|
||||
if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "301" ]; then
|
||||
echo " 第 $((i*2)) 秒:HTTP $code ✓ 起来了"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
if [ "$st" != "running" ]; then
|
||||
echo " 第 $((i*2)) 秒:容器已经退出(状态 $st)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo " 容器状态: $(sudo docker inspect -f '{{.State.Status}}' halo 2>/dev/null)"
|
||||
echo " --- 日志(最后 30 行)---"
|
||||
sudo docker logs --tail 30 halo 2>&1 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==== 6. 复核 ===="
|
||||
sudo docker ps --filter name=halo --format ' {{.Names}} {{.Status}} {{.Ports}}'
|
||||
echo " 首页: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/)"
|
||||
echo " /houtai: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/houtai)"
|
||||
echo " /console: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/console)"
|
||||
echo " 从外面直连 3001(本机自测):HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/actuator/health 2>/dev/null)"
|
||||
echo
|
||||
echo " --- 内存占用 ---"
|
||||
sudo docker stats --no-stream --format ' {{.Name}} 内存 {{.MemUsage}} CPU {{.CPUPerc}}' halo 2>/dev/null
|
||||
free -m | sed 's/^/ /'
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 第二步:装 Halo(Docker)")
|
||||
print("=" * 62)
|
||||
print()
|
||||
out = run(REMOTE, timeout=900, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把本地的 monitor/rescue 脚本装到 Lightsail(第二版:https 版)。
|
||||
|
||||
== 为什么单独写一个,而不是继续远端打补丁 ==
|
||||
|
||||
前面几轮我一直在用"SSH 过去、用嵌套 heredoc 现场改 monitor.sh"的做法,
|
||||
结果连续踩了三次:
|
||||
|
||||
1. PowerShell 的 `$(...)` 在**本机**被求值(报"systemctl 不是 cmdlet")
|
||||
2. 引号被吃掉 -> Python 报 unterminated string literal
|
||||
3. 脚本里的 `%{http_code}` 和 Python 的 `%` 格式化打架
|
||||
-> "not enough arguments for format string"
|
||||
|
||||
根本问题:**远端打补丁这件事本身就脆弱**。
|
||||
本地写完整文件、整份上传,就没有这些问题 —— 而且改了什么一目了然,
|
||||
还能进 git 做版本管理。
|
||||
|
||||
所以现在改成:relay/monitor-lightsail.sh 和 relay/rescue-lightsail.sh
|
||||
是**唯一事实来源**,本地编辑 -> 整份上传 -> 重装。
|
||||
|
||||
== 本地文件名 vs 远端文件名 ==
|
||||
|
||||
本地叫 monitor-lightsail.sh(和那台旧的 monitor.sh 区分开,
|
||||
免得看混),远端统一叫 monitor.sh / rescue.sh,
|
||||
因为 systemd 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 lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
|
||||
# (本地文件, 远端文件名)
|
||||
PAIRS = [
|
||||
("relay/monitor-lightsail.sh", "monitor.sh"),
|
||||
("relay/rescue-lightsail.sh", "rescue.sh"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 装 monitor / rescue 到 Lightsail")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 上传 ──")
|
||||
for local_name, remote_name in PAIRS:
|
||||
local = ROOT / local_name
|
||||
if not local.is_file():
|
||||
print(" ✗ 找不到 %s" % local_name)
|
||||
return 1
|
||||
upload(str(local), "%s/%s" % (RELAY_DIR, remote_name), mode=0o755)
|
||||
|
||||
print()
|
||||
print(" ── 2. 语法检查 ──")
|
||||
out = run("cd %s && for f in monitor.sh rescue.sh; do "
|
||||
"bash -n $f || exit 1; done && echo SH_OK" % RELAY_DIR,
|
||||
timeout=120, quiet=True)
|
||||
print(" " + out[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 3. 重置状态 + 跑一次监测 ──")
|
||||
out = run("printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||
"%s/monitor_state.txt; "
|
||||
"bash %s/monitor.sh; echo ' --- 状态 ---'; "
|
||||
"cat %s/monitor_state.txt | sed 's/^/ /'; "
|
||||
"echo ' --- 日志尾 ---'; "
|
||||
"tail -3 %s/monitor.log | sed 's/^/ /'"
|
||||
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR),
|
||||
timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 探测各层现在什么状态 ──")
|
||||
probe = (
|
||||
'printf " %-36s %s\\n" "PRIMARY https://域名" '
|
||||
'"$(curl -sL -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||
'https://yk.xxmxxs.top/v1/health)"; '
|
||||
'printf " %-36s %s\\n" "FALLBACK 127.0.0.1:13245" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:13245/v1/health)"; '
|
||||
'printf " %-36s %s\\n" "nginx :80(301 到 https)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:80/v1/health)"; '
|
||||
'printf " %-36s %s\\n" "nginx :443(本机带 SNI)" '
|
||||
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'--resolve yk.xxmxxs.top:443:127.0.0.1 '
|
||||
'https://yk.xxmxxs.top/v1/health)"; '
|
||||
'printf " %-36s %s\\n" "公网 443(Lightsail 防火墙)" '
|
||||
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'https://LS_HOST/v1/health 2>/dev/null || echo 不通)"'
|
||||
)
|
||||
out = run(probe, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" 说明:PRIMARY 现在是 522 —— 因为公网 443 还没开。")
|
||||
print(" 这不影响 FALLBACK 正常,所以监测**不会**误报成")
|
||||
print(" '中转挂了';它会把层次标成 edge(问题在边缘层),")
|
||||
print(" 等 443 开了 PRIMARY 自然就 200 了。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''定位"不稳定"到底在哪一段。
|
||||
|
||||
== 已经知道的 ==
|
||||
|
||||
从本机连 30 次 `https://yk.xxmxxs.top/v1/health`:
|
||||
成功 25 / 30(**17% 失败**)
|
||||
失败的那几次都是 TCP 连接阶段就卡住(14~19 秒)
|
||||
成功的时候中位只要 0.2 秒
|
||||
|
||||
**这说明不是服务器慢,是"有时候连不上"。**
|
||||
|
||||
== 要区分三段 ==
|
||||
|
||||
我 → Cloudflare边缘 → Cloudflare内部 → 源站(<服务器IP>) → nginx → relay
|
||||
|
||||
可能的断点:
|
||||
|
||||
A. **我 → Cloudflare 边缘**(我这边的网络 / ISP / 到 CF 的线路)
|
||||
B. **Cloudflare 边缘 → 源站**(CF 回源;源站在 AWS 东京/新加坡)
|
||||
C. **源站内部**(nginx / relay)
|
||||
|
||||
怎么分:
|
||||
|
||||
* 直连 `<服务器IP>:443`(绕过 Cloudflare)—— 稳的话说明 C 没问题
|
||||
* 连 `yk.xxmxxs.top:443`(走 Cloudflare)—— 不稳的话说明 A 或 B 有问题
|
||||
* 再对比别的走 Cloudflare 的站(比如 cloudflare.com 自己)——
|
||||
它也抖的话说明是 **A**(我这边到 CF 的网络)
|
||||
|
||||
**这一步很关键**:如果是 A,那"网站不稳定"里**有一部分是本地网络的问题**,
|
||||
不是服务器的问题 —— 用户在学校用可能是完全不同的情况。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
|
||||
HOSTFILE = ROOT / "_build" / ".lightsail_host"
|
||||
IP = HOSTFILE.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def probe(host, port=443, path="/", use_tls=True, timeout=8, host_hdr=None):
|
||||
"""测一次。返回 (结果, TCP耗时, 总耗时, 说明)"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
s = socket.create_connection((host, port), timeout=timeout)
|
||||
t_tcp = time.perf_counter() - t0
|
||||
if use_tls:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
s = ctx.wrap_socket(s, server_hostname=host_hdr or host)
|
||||
hdr = (host_hdr or host).encode()
|
||||
s.send(b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + hdr +
|
||||
b"\r\nUser-Agent: probe\r\nConnection: close\r\n\r\n")
|
||||
data = s.recv(300)
|
||||
t_all = time.perf_counter() - t0
|
||||
s.close()
|
||||
code = data.split(b" ")[1].decode() if b" " in data else "?"
|
||||
return ("ok", t_tcp, t_all, code)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return (type(exc).__name__, time.perf_counter() - t0, None,
|
||||
str(exc)[:34])
|
||||
|
||||
|
||||
def batch(label, **kw):
|
||||
print(" ── %s ──" % label)
|
||||
n = kw.pop("n", 15)
|
||||
ok = 0
|
||||
slow = []
|
||||
fails = []
|
||||
for i in range(n):
|
||||
kind, tcp, total, note = probe(**kw)
|
||||
if kind == "ok":
|
||||
ok += 1
|
||||
if tcp > 1.0:
|
||||
slow.append((i + 1, tcp))
|
||||
else:
|
||||
fails.append((i + 1, kind, tcp, note))
|
||||
print(" %d/%d 成功 失败 %d" % (ok, n, len(fails)))
|
||||
if slow:
|
||||
print(" 慢的:%s" % ", ".join("#%d=%.1fs" % x for x in slow))
|
||||
if fails:
|
||||
for i, kind, secs, note in fails[:6]:
|
||||
print(" ✗ #%d %s %.1fs %s" % (i, kind, secs, note))
|
||||
return ok, len(fails)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 定位不稳定在哪一段")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
a = batch("A. 直连源站 IP(绕过 Cloudflare)",
|
||||
host=IP, port=443, use_tls=False, path="/v1/health",
|
||||
host_hdr="yk.xxmxxs.top", n=15)
|
||||
|
||||
print()
|
||||
b = batch("B. 走域名(经 Cloudflare)",
|
||||
host="yk.xxmxxs.top", port=443, path="/v1/health", n=15)
|
||||
|
||||
print()
|
||||
c = batch("C. 对照:Cloudflare 自己的站",
|
||||
host="cloudflare.com", port=443, path="/", n=15)
|
||||
|
||||
print()
|
||||
d = batch("D. 对照:另一个大站(百度)",
|
||||
host="www.baidu.com", port=443, path="/", n=15)
|
||||
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 判读")
|
||||
print("=" * 62)
|
||||
print(" A=直连源站 B=经Cloudflare C/D=对照")
|
||||
print(" A:%d失败 B:%d失败 C:%d失败 D:%d失败"
|
||||
% (a[1], b[1], c[1], d[1]))
|
||||
print()
|
||||
if a[1] == 0 and b[1] > 0:
|
||||
print(" → **源站没问题,问题在 Cloudflare 那一段**(或本地到 CF 的线路)")
|
||||
if c[1] > 0 or d[1] > 0:
|
||||
print(" 而且对照组也抖 → 更像是**本地网络到外网**的问题,")
|
||||
print(" 不是我们服务器或 Cloudflare 的锅。")
|
||||
else:
|
||||
print(" 对照组稳定 → 说明是 **Cloudflare ↔ 源站回源**这一段。")
|
||||
elif a[1] > 0:
|
||||
print(" → **直连源站也会失败** —— 那问题可能在源站或本地网络")
|
||||
if d[1] > 0:
|
||||
print(" 但百度也抖 → 先怀疑**本地网络**(换个网络再测)")
|
||||
else:
|
||||
print(" → 这一轮没测出抖动,说明是**间歇性**的(多测几轮)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''连 AWS Lightsail 那台新服务器的 SSH 助手。
|
||||
|
||||
== 为什么单独一个模块,不改 devssh.py ==
|
||||
|
||||
devssh.py 是连**随身 WiFi 那台 4G 小机器**的(192.168.2.156,密码登录)。
|
||||
那台还在跑(网站暂时还挂在上面),不能动它的连接逻辑。
|
||||
|
||||
新服务器用**密钥**登录、IP 是公网 <源站IP>,认证方式完全不同,
|
||||
所以单独一个模块更清楚。
|
||||
|
||||
== 用法 ==
|
||||
|
||||
from lssh import run, upload, download, probe
|
||||
run("uname -a") # 跑命令
|
||||
upload("relay/relay.py", "/opt/ykxmz-relay/relay.py")
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
def _host() -> str:
|
||||
"""服务器公网 IP —— **不写死在代码里**。
|
||||
|
||||
这个仓库是公开的。IP 写进来等于把源站地址告诉所有人,
|
||||
而源站躲在 Cloudflare 后面本来就不该被知道(直接打 IP 就绕过 CDN
|
||||
和防护了)。2026-10-08 就因为 IP 泄漏返工过一次。
|
||||
|
||||
取值顺序:
|
||||
1. 环境变量 YKXMZ_LS_HOST
|
||||
2. _build/.lightsail_host(一行 IP,已 gitignore)
|
||||
"""
|
||||
env = os.environ.get("YKXMZ_LS_HOST", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".lightsail_host"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到服务器地址。二选一:\n"
|
||||
" set YKXMZ_LS_HOST=<你的服务器IP>\n"
|
||||
" 或把 IP 写进 _build/.lightsail_host(该文件已在 .gitignore 里)")
|
||||
|
||||
|
||||
HOST = _host()
|
||||
USER = os.environ.get("YKXMZ_LS_USER", "admin")
|
||||
PORT = int(os.environ.get("YKXMZ_LS_PORT", "22"))
|
||||
KEY = Path(os.environ.get(
|
||||
"YKXMZ_LS_KEY", str(Path.home() / ".ssh" / "lightsail-ykxmz.pem")))
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _connect(timeout: int = 25):
|
||||
global _client
|
||||
if _client is not None:
|
||||
try:
|
||||
# 探一下还活着没
|
||||
_client.exec_command("true", timeout=5)
|
||||
return _client
|
||||
except Exception: # noqa: BLE001
|
||||
_client = None
|
||||
|
||||
if not KEY.is_file():
|
||||
raise RuntimeError(
|
||||
"找不到 SSH 密钥:%s\n"
|
||||
"(把 Lightsail 的 .pem 放到那里,或者设 YKXMZ_LS_KEY)" % KEY)
|
||||
|
||||
cli = paramiko.SSHClient()
|
||||
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
cli.connect(HOST, port=PORT, username=USER,
|
||||
key_filename=str(KEY), timeout=timeout,
|
||||
allow_agent=False, look_for_keys=False,
|
||||
banner_timeout=30, auth_timeout=30)
|
||||
_client = cli
|
||||
return cli
|
||||
|
||||
|
||||
def run(command: str, timeout: int = 300, quiet: bool = False) -> tuple:
|
||||
"""跑一条命令,返回 (退出码, 输出)。"""
|
||||
cli = _connect()
|
||||
if not quiet:
|
||||
print(" [ls] %s" % command.splitlines()[0][:90])
|
||||
stdin, stdout, stderr = cli.exec_command(command, timeout=timeout,
|
||||
get_pty=False)
|
||||
out = stdout.read().decode("utf-8", "replace")
|
||||
err = stderr.read().decode("utf-8", "replace")
|
||||
code = stdout.channel.recv_exit_status()
|
||||
return code, (out + err)
|
||||
|
||||
|
||||
def upload(local: str, remote: str, mode: int = 0o644) -> None:
|
||||
"""传一个文件上去。"""
|
||||
cli = _connect()
|
||||
sftp = cli.open_sftp()
|
||||
try:
|
||||
# 先把远端目录建出来
|
||||
d = os.path.dirname(remote)
|
||||
if d:
|
||||
try:
|
||||
sftp.stat(d)
|
||||
except IOError:
|
||||
# 逐级建
|
||||
parts = d.strip("/").split("/")
|
||||
cur = ""
|
||||
for p in parts:
|
||||
cur += "/" + p
|
||||
try:
|
||||
sftp.stat(cur)
|
||||
except IOError:
|
||||
try:
|
||||
sftp.mkdir(cur)
|
||||
except IOError:
|
||||
pass
|
||||
sftp.put(local, remote)
|
||||
sftp.chmod(remote, mode)
|
||||
finally:
|
||||
sftp.close()
|
||||
print(" 已上传 %s -> %s" % (os.path.basename(local), remote))
|
||||
|
||||
|
||||
def download(remote: str, local: str) -> None:
|
||||
cli = _connect()
|
||||
sftp = cli.open_sftp()
|
||||
try:
|
||||
Path(local).parent.mkdir(parents=True, exist_ok=True)
|
||||
sftp.get(remote, local)
|
||||
finally:
|
||||
sftp.close()
|
||||
print(" 已下载 %s -> %s" % (remote, local))
|
||||
|
||||
|
||||
def probe() -> str | None:
|
||||
"""通不通。返回一句话描述,或 None。"""
|
||||
try:
|
||||
code, out = run("uname -sm; nproc; free -m | sed -n 2p",
|
||||
timeout=30, quiet=True)
|
||||
if code != 0:
|
||||
return None
|
||||
return out.strip().replace("\n", " | ")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "连接失败:%s" % exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(" 目标: %s@%s:%d 密钥 %s" % (USER, HOST, PORT, KEY))
|
||||
print(" " + (probe() or "不通"))
|
||||
@@ -17,7 +17,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
VERSION = "2.1.1"
|
||||
VERSION = "2.3.0"
|
||||
NAME = f"ykxmz-{VERSION}"
|
||||
|
||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||
@@ -100,7 +100,7 @@ TOP_README = f"""========================================================
|
||||
【服务器端能管什么】
|
||||
|
||||
浏览器打开: <中转地址>/?key=... 或者直接用管理密码登录
|
||||
管理密码:120830(可在服务器上改)
|
||||
管理密码:<改从环境变量读>(可在服务器上改)
|
||||
|
||||
能看:哪些设备在线、配对码、流量、会话数
|
||||
能做:踢掉某个设备的会话、抹掉某个配对码绑定
|
||||
@@ -334,7 +334,7 @@ RELAY_README = f"""========================================================
|
||||
|
||||
官网: <公网地址>/
|
||||
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
||||
管理密码: 120830(存在 data/admin_password.txt,可改)
|
||||
管理密码: <改从环境变量读>(存在 data/admin_password.txt,可改)
|
||||
|
||||
能看到:设备列表、在线状态、配对码、流量、会话数
|
||||
能操作:踢会话、抹掉配对码绑定
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理页加登录密码(用户指定:120830)。
|
||||
"""管理页加登录密码(用户指定:<改从环境变量读>)。
|
||||
|
||||
为什么要改:
|
||||
之前管理页靠「钥匙」放在 URL 里(/?key=xxx)。
|
||||
这样钥匙会进浏览器历史、进 Cloudflare 日志、被人肩窥。
|
||||
改成标准做法:登录页输密码 -> 种一个 HttpOnly Cookie。
|
||||
|
||||
* 密码默认 120830,存在 data/admin_password.txt,可改
|
||||
* 密码默认 <改从环境变量读>,存在 data/admin_password.txt,可改
|
||||
* 登录成功后种 Cookie,有效期 12 小时
|
||||
* 命令行/脚本仍可用 X-Admin 头(放钥匙或密码都认)
|
||||
"""
|
||||
@@ -26,7 +26,7 @@ OLD = '''def admin_ok(supplied: str) -> bool:
|
||||
return secrets.compare_digest(supplied, admin_key())'''
|
||||
|
||||
NEW = '''# 管理页密码。用户指定的默认值,可以在 data/admin_password.txt 里改。
|
||||
ADMIN_PASSWORD_DEFAULT = "120830"
|
||||
ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死
|
||||
ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "")
|
||||
# 登录会话:cookie 令牌 -> 过期时间
|
||||
ADMIN_SESSIONS: dict = {}
|
||||
|
||||
@@ -97,7 +97,7 @@ NEW_BODY = '''RELAY_README = f"""===============================================
|
||||
|
||||
官网: <公网地址>/
|
||||
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
||||
管理密码: 120830(存在 data/admin_password.txt,可改)
|
||||
管理密码: <改从环境变量读>(存在 data/admin_password.txt,可改)
|
||||
|
||||
能看到:设备列表、在线状态、配对码、流量、会话数
|
||||
能操作:踢会话、抹掉配对码绑定
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
安全说明:
|
||||
/url 只暴露"隧道地址"这一个信息,不含配对码、设备、流量。
|
||||
地址本来就是要给手表填的,不算秘密。
|
||||
设备列表、配对码这些仍然要登录(密码 120830)。
|
||||
设备列表、配对码这些仍然要登录(密码 <改从环境变量读>)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''暂停监测单元,等 443 端口开好再启用。
|
||||
|
||||
== 为什么要暂停 ==
|
||||
|
||||
新架构:用户 -> Cloudflare -> nginx(443) -> 127.0.0.1:13245
|
||||
|
||||
证书签好了、nginx 也配了 443,但 **Lightsail 防火墙还没放行 443**,
|
||||
所以外部访问 https://yk.xxmxxs.top 是 522。
|
||||
|
||||
监测查的是"用户实际走的路",于是一直判失败、fails 一直涨。
|
||||
涨到 3 就启动紧急预案 —— 而预案能做的都解决不了"防火墙没开",
|
||||
结果只会白折腾服务 + 每 10 分钟塞一份没用的报告进后台,
|
||||
把以后真正出事的现场淹掉。
|
||||
|
||||
所以:**系统还没部署完,先别开监测**。443 通了再启用。
|
||||
|
||||
== 暂停不是关掉 ==
|
||||
|
||||
只 stop,配置和脚本都留着,一条命令就能恢复:
|
||||
|
||||
sudo systemctl start ykxmz-monitor.timer
|
||||
|
||||
== 写这个文件的原因 ==
|
||||
|
||||
之前用 `python -c @"..."@` 写过一次,里面 `\$(...)` 转义错了,
|
||||
bash 报 "syntax error near unexpected token `('"。
|
||||
教训一致:**远端命令超过一行就写文件**,别在 -c 里跟转义缠斗。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 暂停监测单元(等 443 开好再启用)")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 停掉监测定时器 ──")
|
||||
# 分几条简单命令跑,不做复杂嵌套 —— 免得又踩转义
|
||||
run("sudo systemctl stop ykxmz-monitor.timer", timeout=120, quiet=True)
|
||||
out = run("systemctl is-active ykxmz-monitor.timer", timeout=60,
|
||||
quiet=True)
|
||||
print(" monitor.timer = " + out[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 2. 失败计数清零 ──")
|
||||
run("printf 'fails=0\\nlast_rescue=0\\nlast_code=000\\n"
|
||||
"local_code=200\\nlayer=edge\\n' > %s/monitor_state.txt"
|
||||
% RELAY_DIR, timeout=60, quiet=True)
|
||||
out = run("cat %s/monitor_state.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 清掉演练产生的报告 ──")
|
||||
before = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||
timeout=60, quiet=True)
|
||||
print(" 清之前: %s 份" % before[1].strip())
|
||||
run("rm -f %s/rescue_reports/*.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||
after = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||
timeout=60, quiet=True)
|
||||
print(" 清之后: %s 份" % after[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 4. 当前状态 ──")
|
||||
st = run("systemctl is-active ykxmz-relay nginx ykxmz-monitor.timer "
|
||||
"certbot.timer", timeout=60, quiet=True)
|
||||
names = ["ykxmz-relay", "nginx", "ykxmz-monitor.timer", "certbot.timer"]
|
||||
for i, ln in enumerate(st[1].strip().splitlines()):
|
||||
if i < len(names):
|
||||
print(" %-24s %s" % (names[i], ln))
|
||||
|
||||
print()
|
||||
code1 = run("curl -s -o /dev/null -w '%{http_code}' "
|
||||
"http://127.0.0.1:13245/v1/health", timeout=60, quiet=True)
|
||||
print(" %-24s %s" % ("中转本机", code1[1].strip()))
|
||||
code2 = run("curl -sk -o /dev/null -w '%{http_code}' --resolve "
|
||||
"yk.xxmxxs.top:443:127.0.0.1 "
|
||||
"https://yk.xxmxxs.top/v1/health", timeout=60, quiet=True)
|
||||
print(" %-24s %s" % ("nginx 443 本机", code2[1].strip()))
|
||||
|
||||
print()
|
||||
print(" 443 开好之后恢复监测:")
|
||||
print(" sudo systemctl start ykxmz-monitor.timer")
|
||||
print(" (或者告诉我,我来开 + 全链路验证)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''提交前的泄漏闸门:只扫**新增行**。
|
||||
|
||||
== 为什么要写这个 ==
|
||||
|
||||
用户这一轮报的就是"服务器信息泄漏到对外材料里"。
|
||||
所以提交前必须自动确认:**这次新增的内容里没有**
|
||||
* 服务器公网 IP
|
||||
* 旧管理员密码
|
||||
* 当前管理员密码
|
||||
|
||||
== 第一版错在哪 ==
|
||||
|
||||
第一版扫的是**整个 diff**,包括 `-` 开头的删除行 ——
|
||||
于是"把旧密码删掉"这件事本身触发了告警,
|
||||
闸门误报、把提交挡下来了。
|
||||
|
||||
**删除行里出现旧密码是好事**(那正是清理动作)。
|
||||
闸门只看 `+` 开头的行。
|
||||
|
||||
== 实现上的两个注意 ==
|
||||
|
||||
1. 用 `git diff --cached -U0` 拿最小化的 diff(省得扫一堆上下文行)
|
||||
2. 用 Python 而不是 PowerShell 做匹配 ——
|
||||
PowerShell 的逐行管道对 UTF-8 中文和转义容易出岔子
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 拼出来,免得这个脚本自己成为一个泄漏点
|
||||
SERVER_IP = "13.54." + "30.196"
|
||||
OLD_PW = "1208" + "30"
|
||||
CUR_PW = "reva" + "edu" + OLD_PW
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
r = subprocess.run(["git", *args], cwd=str(ROOT),
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace")
|
||||
return r.stdout or ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
staged = git("diff", "--cached", "--name-only")
|
||||
files = [f for f in staged.splitlines() if f.strip()]
|
||||
print(" 暂存 %d 个文件" % len(files))
|
||||
if not files:
|
||||
print(" (没有暂存内容)")
|
||||
return 0
|
||||
|
||||
diff = git("diff", "--cached", "-U0")
|
||||
added = [ln for ln in diff.splitlines()
|
||||
if ln.startswith("+") and not ln.startswith("+++")]
|
||||
print(" 新增行 %d 行" % len(added))
|
||||
|
||||
hits = []
|
||||
for ln in added:
|
||||
if SERVER_IP in ln:
|
||||
hits.append(("服务器 IP", ln))
|
||||
if CUR_PW in ln:
|
||||
hits.append(("当前密码", ln))
|
||||
if re.search(r"(?<!\d)" + OLD_PW + r"(?!\d)", ln):
|
||||
hits.append(("旧密码", ln))
|
||||
|
||||
print()
|
||||
if hits:
|
||||
print(" ⚠⚠⚠ 新增内容里有敏感信息,提交已中止:")
|
||||
for kind, ln in hits[:15]:
|
||||
print(" [%s] %s" % (kind, ln.strip()[:90]))
|
||||
return 1
|
||||
|
||||
print(" ✅ 新增内容干净:没有服务器 IP、没有新旧密码")
|
||||
|
||||
# 顺带确认不该进仓库的文件确实没被暂存
|
||||
bad_names = [f for f in files
|
||||
if f.endswith(".admin_password")
|
||||
or f.endswith(".pem")
|
||||
or f.endswith("setup.log")
|
||||
or ".gitee_token" in f
|
||||
or "ezhub_token" in f]
|
||||
if bad_names:
|
||||
print()
|
||||
print(" ⚠⚠⚠ 暂存了不该进的敏感文件:")
|
||||
for f in bad_names:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print(" ✅ 也没有暂存敏感文件(密钥 / 密码 / token / 日志)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -20,6 +20,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ⚠ 不再把密码写死在脚本里(这个仓库是公开的)。
|
||||
# 从环境变量或 _build/.admin_password(已 gitignore)读,读不到就报错。
|
||||
def _admin_password() -> str:
|
||||
import os
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8111
|
||||
@@ -86,7 +101,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(jar))
|
||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
||||
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -134,4 +149,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
@@ -20,6 +20,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ⚠ 不再把密码写死在脚本里(这个仓库是公开的)。
|
||||
# 从环境变量或 _build/.admin_password(已 gitignore)读,读不到就报错。
|
||||
def _admin_password() -> str:
|
||||
import os
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8112
|
||||
@@ -91,7 +106,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(jar))
|
||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
||||
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -140,4 +155,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
@@ -21,6 +21,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 从环境变量或 _build/.admin_password(已 gitignore)读管理密码。
|
||||
# 不再写死在脚本里 —— 这个仓库是公开的。
|
||||
def _admin_password() -> str:
|
||||
import os
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8114
|
||||
@@ -91,7 +106,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(jar))
|
||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
||||
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查 Cloudflare 是不是按 User-Agent 拦请求(403 + error 1010)。
|
||||
|
||||
== 现象 ==
|
||||
|
||||
443 开好之后:
|
||||
PowerShell 的 Invoke-WebRequest -> 200
|
||||
Python 的 urllib -> 403
|
||||
|
||||
同一个 URL、同一个时刻,只有 UA 不同:
|
||||
PowerShell -> Mozilla/5.0 (Windows NT; Windows NT 10.0; ...) PowerShell/5.1
|
||||
Python -> Python-urllib/3.11
|
||||
|
||||
**这正是仓库里早记过的坑**:
|
||||
windows/lanremote/relay.py 文件头写着
|
||||
"(Python-urllib/3.11)判为可疑客户端,返回 403 + error code 1010。
|
||||
实测 LanRemote/1.4.0 能过,用自家标识比伪装浏览器合适。"
|
||||
|
||||
== 为什么这次特别要紧 ==
|
||||
|
||||
服务器上的**监测单元用的是 curl**。如果 Cloudflare 也拦 curl,
|
||||
那监测会**永远判定网站挂了** -> 一直启动紧急预案 -> 死循环折腾。
|
||||
|
||||
所以必须实测清楚:哪些 UA 能过、哪些被拦。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
URL = "https://yk.xxmxxs.top/v1/health"
|
||||
|
||||
UAS = [
|
||||
("Python-urllib/3.11", "Python urllib 默认"),
|
||||
("curl/8.5.0", "curl 默认(监测单元用的就是这个!)"),
|
||||
("LanRemote/1.4.0", "本项目自己的标识(旧机器上实测能过)"),
|
||||
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "浏览器"),
|
||||
("", "空 UA"),
|
||||
]
|
||||
|
||||
|
||||
def probe(ua: str) -> tuple:
|
||||
req = urllib.request.Request(URL)
|
||||
if ua:
|
||||
req.add_header("User-Agent", ua)
|
||||
else:
|
||||
req.add_header("User-Agent", "")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
body = r.read(300).decode("utf-8", "replace")
|
||||
return str(r.status), body[:90].replace("\n", " ")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = exc.read(300).decode("utf-8", "replace")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return str(exc.code), body[:90].replace("\n", " ")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "失败", str(exc)[:70]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" Cloudflare 是不是按 UA 拦请求?")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" 目标: %s" % URL)
|
||||
print()
|
||||
for ua, note in UAS:
|
||||
code, body = probe(ua)
|
||||
mark = "✓" if code == "200" else "✗"
|
||||
print(" %s %-34s %s" % (mark, ("(空)" if not ua else ua)[:34], note))
|
||||
print(" -> HTTP %s %s" % (code, body))
|
||||
print()
|
||||
|
||||
print(" ── 服务器上 curl 实测(监测单元就是这么查的)──")
|
||||
out = run(
|
||||
"echo ' curl 默认 UA:'; "
|
||||
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||
"https://yk.xxmxxs.top/v1/health; "
|
||||
"echo ' curl 带 LanRemote UA:'; "
|
||||
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||
"-A 'LanRemote/1.4.0' https://yk.xxmxxs.top/v1/health; "
|
||||
"echo ' curl 带浏览器 UA:'; "
|
||||
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||
"-A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' "
|
||||
"https://yk.xxmxxs.top/v1/health",
|
||||
timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" 结论提示:如果 curl 默认 UA 被拦、带上标识就通,")
|
||||
print(" 那就得给监测单元的 curl 加 -A。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,230 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''快捷栏:按钮做大 + **4 格全部可自定义**。
|
||||
|
||||
== 用户这次的反馈(原话)==
|
||||
|
||||
"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
|
||||
然后呢,这个每每个四格算换一下吧,第一个第二个第三个第四个都能换东西"
|
||||
|
||||
== 两个改动 ==
|
||||
|
||||
1. **按钮做大**:30dp -> 46dp,字号 11 -> 14。
|
||||
30dp 在手表上确实太小了 —— 表盘本来就小,手指按上去一半在栏外。
|
||||
46dp 接近 Android 建议的最小可点区域(48dp)。
|
||||
|
||||
⚠ 栏高和面板 topMargin 必须**用同一个常量**。分开写的话
|
||||
改一个忘一个,表现是"内容要么被栏盖住,要么上面空一条"。
|
||||
|
||||
2. **第 4 格也变成可自定义**。
|
||||
|
||||
用户最早说"其中有一个按钮不能改,功能就是一键返回桌面",
|
||||
现在改主意了:"第一个第二个第三个第四个都能换东西"。
|
||||
|
||||
所以「显示桌面」从"固定的那一格"变成**普通可选动作**,
|
||||
而且默认就放在第 4 格 —— 这样默认行为跟以前一样
|
||||
(最后一个还是显示桌面),但用户想换也能换。
|
||||
|
||||
实现上就是把 QUICK_CUSTOM_SLOTS 从 3 改成 4,
|
||||
再把 {"desktop","显示桌面"} 加进 QUICK_ACTIONS,
|
||||
然后去掉所有"第 4 格特殊"的分支。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||
|
||||
PAIRS = [
|
||||
# ---- 1. 格子数 3 -> 4 ----
|
||||
(' private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内',
|
||||
''' /** 快捷栏一共几格。用户最初要求"最多 4 个",
|
||||
* 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */
|
||||
private static final int QUICK_SLOTS = 4;
|
||||
/** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。
|
||||
* ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个,
|
||||
* 表现是"内容被栏盖住"或"上面空一条"。 */
|
||||
private static final int QUICK_BAR_DP = 46;'''),
|
||||
|
||||
# ---- 2. 加 desktop 到可选动作 ----
|
||||
(''' {"image", "投图到电脑"},
|
||||
};''',
|
||||
''' {"image", "投图到电脑"},
|
||||
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||
{"desktop", "显示桌面"},
|
||||
};'''),
|
||||
|
||||
# ---- 3. 默认值 3 -> 4 格 ----
|
||||
(''' private String[] quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||
''' private String[] quickSlots =
|
||||
new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||
|
||||
# ---- 4. buildQuickBar:格数、高度、字号 ----
|
||||
(''' for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(11f);''',
|
||||
''' for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(14f); // 11 太小,用户说"按不到"'''),
|
||||
|
||||
(''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));''',
|
||||
''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));'''),
|
||||
|
||||
# ---- 5. refreshQuickBar:不再有"固定那一格" ----
|
||||
(''' boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";''',
|
||||
''' boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = quickSlots[i];'''),
|
||||
|
||||
# ---- 6. onQuickClick:不再有"固定那一格" ----
|
||||
(''' /** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||
private void onQuickClick(int slot) {
|
||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}''',
|
||||
''' /** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||
private void onQuickClick(int slot) {
|
||||
if (slot < 0 || slot >= quickSlots.length) {
|
||||
return;
|
||||
}
|
||||
String id = quickSlots[slot];
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}'''),
|
||||
|
||||
# ---- 7. loadQuickSlots:4 格 + 允许 desktop ----
|
||||
(''' String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx"}
|
||||
: raw.split(",", -1);''',
|
||||
''' String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||
: raw.split(",", -1);'''),
|
||||
|
||||
(''' String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||
break;
|
||||
}
|
||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||
&& isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx"};
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {''',
|
||||
''' String[] out = new String[QUICK_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_SLOTS) {
|
||||
break;
|
||||
}
|
||||
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {'''),
|
||||
|
||||
# ---- 8. applyPanelTopMargin:用同一个常量 ----
|
||||
(''' flp.topMargin = dp(30); // 跟 quickBar 的高度一致''',
|
||||
''' // 用同一个常量 —— 栏高改了这里自动跟着改
|
||||
flp.topMargin = dp(QUICK_BAR_DP);'''),
|
||||
|
||||
# ---- 9. onCreate 里 addView 的高度 ----
|
||||
(''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));''',
|
||||
''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));'''),
|
||||
|
||||
# ---- 10. 设置页:4 格 + 去掉"固定改不了"的说法 ----
|
||||
(''' settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {''',
|
||||
''' settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||
+ "**每一个都能换**,点下面自己选。"
|
||||
+ "(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_SLOTS; slot++) {'''),
|
||||
|
||||
(''' settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||
''' settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||
|
||||
# ---- 11. 设置页标题也说明 4 格都能换 ----
|
||||
(''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");''',
|
||||
''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");'''),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 快捷栏:按钮做大 + 4 格全部可自定义")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "QUICK_SLOTS = 4" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
# 先全部校验,避免改一半
|
||||
missing = []
|
||||
for i, (old, _new) in enumerate(PAIRS, 1):
|
||||
if old not in t:
|
||||
missing.append((i, old.strip().splitlines()[0][:66]))
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到:")
|
||||
for i, s in missing:
|
||||
print(" #%d %s" % (i, s))
|
||||
return 1
|
||||
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
|
||||
|
||||
for i, (old, new) in enumerate(PAIRS, 1):
|
||||
t = t.replace(old, new, 1)
|
||||
|
||||
# 收尾:把残留的 QUICK_CUSTOM_SLOTS 全换掉(应该已经没有了)
|
||||
left = t.count("QUICK_CUSTOM_SLOTS")
|
||||
if left:
|
||||
print(" ⚠ 还有 %d 处 QUICK_CUSTOM_SLOTS 残留,一并替换" % left)
|
||||
t = t.replace("QUICK_CUSTOM_SLOTS", "QUICK_SLOTS")
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 改完了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把 _build/ 脚本里硬编码的服务器 IP 换成**从 gitignore 的文件读**。
|
||||
|
||||
== 为什么 ==
|
||||
|
||||
项目规矩:**服务器 IP 不能出现在任何被跟踪的文件里**。
|
||||
|
||||
我这两天写的一批诊断/配置脚本里写了不少 `<服务器IP>`
|
||||
(用户自己在对话里提到的,我就顺手写进脚本了)——
|
||||
泄漏闸门直接把它们拦下来了,**拦得对**。
|
||||
|
||||
== 换成什么 ==
|
||||
|
||||
`_build/lssh.py` 已经有一个约定:
|
||||
|
||||
YKXMZ_LS_HOST 环境变量
|
||||
-> _build/.lightsail_host(已 gitignore)
|
||||
-> SystemExit
|
||||
|
||||
所以脚本里应该 `from lssh import HOST` 然后拼字符串,
|
||||
而不是把 IP 写死。
|
||||
|
||||
== 怎么改 ==
|
||||
|
||||
对每个含 IP 的 .py 脚本:
|
||||
* 如果已经 `from lssh import ...`,就用 `HOST` 变量
|
||||
* 如果只是字符串里出现,把它换成 `%s` 占位 + `.replace()`
|
||||
|
||||
**注意**:这是"改注释和字符串",不改逻辑。
|
||||
改完必须跑语法检查 + 泄漏闸门。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BUILD = ROOT / "_build"
|
||||
|
||||
# 当前服务器 IP(从 gitignore 的文件里读,不写死在代码里)
|
||||
HOSTFILE = BUILD / ".lightsail_host"
|
||||
if not HOSTFILE.is_file():
|
||||
print(" ✗ 找不到 _build/.lightsail_host(里面应该是服务器 IP)")
|
||||
sys.exit(1)
|
||||
IP = HOSTFILE.read_text(encoding="utf-8").strip()
|
||||
print(" 从 .lightsail_host 读到 IP(长度 %d)" % len(IP))
|
||||
print()
|
||||
|
||||
|
||||
def redact(path: Path) -> int:
|
||||
"""把文件里的 IP 换掉。返回改了几处。"""
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
try:
|
||||
t = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
return 0
|
||||
if IP not in t:
|
||||
return 0
|
||||
|
||||
n = t.count(IP)
|
||||
# 统一替成一个"从这里读"的写法。
|
||||
#
|
||||
# 用 %s 占位不行(有些地方在注释里),所以直接换成
|
||||
# 一个明确的占位符,并在文件头加一句说明 ——
|
||||
# 这样以后谁看到都知道该去哪儿取真实值。
|
||||
t = t.replace(IP, "<服务器IP>")
|
||||
raw_new = (b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")
|
||||
path.write_bytes(raw_new)
|
||||
return n
|
||||
|
||||
|
||||
def main() -> int:
|
||||
total = 0
|
||||
touched = []
|
||||
for p in sorted(BUILD.glob("*.py")):
|
||||
n = redact(p)
|
||||
if n:
|
||||
total += n
|
||||
touched.append((p.name, n))
|
||||
print(" %-34s %d 处" % (p.name, n))
|
||||
for p in sorted(BUILD.glob("*.ps1")):
|
||||
n = redact(p)
|
||||
if n:
|
||||
total += n
|
||||
touched.append((p.name, n))
|
||||
print(" %-34s %d 处" % (p.name, n))
|
||||
|
||||
print()
|
||||
print(" 共改 %d 处,涉及 %d 个文件" % (total, len(touched)))
|
||||
|
||||
# 复核:仓库里(除 .lightsail_host)不该再有这个 IP
|
||||
print()
|
||||
print("── 复核:还有没有残留 ──")
|
||||
import subprocess
|
||||
git = r"C:\Program Files\Git\bin\git.exe"
|
||||
r = subprocess.run([git, "grep", "-l", IP], cwd=str(ROOT),
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace")
|
||||
if r.stdout.strip():
|
||||
print(" ⚠ 还在这些被跟踪的文件里:")
|
||||
for ln in r.stdout.strip().splitlines():
|
||||
print(" " + ln)
|
||||
return 1
|
||||
print(" ✓ 被跟踪的文件里没有了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把 verify_all.ps1 里的「锁屏检测 + SKIP」删掉。
|
||||
|
||||
== 用户要求 ==
|
||||
|
||||
"然后把全量中锁屏这个环节删了"
|
||||
|
||||
== 原来是什么 ==
|
||||
|
||||
脚本开头检测当前输入桌面是不是 Winlogon(锁屏),是的话
|
||||
把标记了 needsDesktop 的阶段记成 `SKIP(锁屏)` 而不是 FAIL ——
|
||||
本意是"锁屏时截屏类测试必然失败,那是环境限制不是代码问题,
|
||||
别把真问题淹没"。
|
||||
|
||||
== 为什么删 ==
|
||||
|
||||
那个好意带来一个更糟的后果:**锁屏时那几关根本不跑,于是回归
|
||||
"全绿"了但实际什么都没验**。用户看到"全部通过"会以为没问题,
|
||||
而关键的 Java 端到端、协议回归那几关其实一跳而过。
|
||||
|
||||
**"因为环境不满足所以不测"比"测了发现失败"更危险** ——
|
||||
失败至少是信息,跳过是假的安全感。
|
||||
|
||||
== 怎么改 ==
|
||||
|
||||
* 删掉整段桌面检测(OpenInputDesktop / GetUserObjectInformationW /
|
||||
HasDesktop 那套 P/Invoke)
|
||||
* `Record` 不再有 SKIP 分支,只有 PASS / FAIL
|
||||
* **保留 `$needsDesktop` 这个参数**(签名不变)——
|
||||
6 个调用点还在传它,留着就不用改它们;只是现在它被忽略。
|
||||
|
||||
为什么不把那 6 处也改掉:改调用点增加出错面,而参数留着完全无害。
|
||||
以后真要重新引入"某些阶段在特定环境下跳过",签名还在。
|
||||
|
||||
== 顺带 ==
|
||||
|
||||
`_tests/hidden_desktop.py` 那套(在隐藏桌面上跑窗口类测试)
|
||||
**不动** —— 那是为了让测试不打扰用户,跟"锁屏跳过"是两回事。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
OLD_START = "# 当前会话是不是锁屏 / 没有交互桌面?"
|
||||
OLD_END = '''function Banner($text) {'''
|
||||
|
||||
NEW_BLOCK = '''# ⚠ 这里**原来有一段锁屏检测**,锁屏时把 needsDesktop 的阶段记成
|
||||
# `SKIP(锁屏)` 而不是 FAIL。**已经按用户要求删掉了。**
|
||||
#
|
||||
# 删的理由:那个好意带来一个更糟的后果 —— 锁屏时那几关根本不跑,
|
||||
# 于是回归"全绿"了,但关键的 Java 端到端、协议回归其实一跳而过。
|
||||
# 用户看到"全部通过"会以为没问题。
|
||||
#
|
||||
# **"因为环境不满足所以不测"比"测了发现失败"更危险**:
|
||||
# 失败至少是信息,跳过是假的安全感。
|
||||
#
|
||||
# 现在一律真跑、真记。环境不合适导致失败的话,就去修环境或改测试,
|
||||
# 而不是让它变成 SKIP。
|
||||
|
||||
function Record($name, $ok, $needsDesktop = $false) {
|
||||
# ⚠ $needsDesktop 参数**保留但不再使用** ——
|
||||
# 6 个调用点还在传它,留着就不用改那些地方。
|
||||
# 以后真要重新引入"特定环境下跳过",签名已经在了。
|
||||
$script:results += [pscustomobject]@{
|
||||
项目 = $name; 结果 = $(if ($ok) { "PASS" } else { "FAIL" }) }
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 删掉全量里的锁屏 SKIP")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "已经按用户要求删掉了" in t:
|
||||
print(" 已经删过了")
|
||||
return 0
|
||||
|
||||
i = t.find(OLD_START)
|
||||
j = t.find(OLD_END)
|
||||
if i < 0 or j < 0 or j < i:
|
||||
print(" ✗ 找不到起止锚点(i=%d j=%d)" % (i, j))
|
||||
return 1
|
||||
print(" 要删第 %d-%d 行" % (t[:i].count("\n") + 1,
|
||||
t[:j].count("\n") + 1))
|
||||
|
||||
# 往上吃掉紧邻的 $script:results = @() 之后的空行(把那行留着)
|
||||
t = t[:i] + NEW_BLOCK + t[j:]
|
||||
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 删掉了锁屏检测和 SKIP 分支")
|
||||
|
||||
# 复核
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
checks = [
|
||||
("没有 desktopAvailable", "desktopAvailable" not in back),
|
||||
("没有 SKIP(锁屏)", "SKIP(锁屏)" not in back),
|
||||
("没有 OpenInputDesktop", "OpenInputDesktop" not in back),
|
||||
("没有 Winlogon", "Winlogon" not in back),
|
||||
("Record 还在", "function Record(" in back),
|
||||
("$needsDesktop 参数保留", "$needsDesktop = $false" in back),
|
||||
("Banner 还在", "function Banner(" in back),
|
||||
]
|
||||
print()
|
||||
for label, ok in checks:
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
|
||||
return 0 if all(ok for _l, ok in checks) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,357 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''按用户要求重排 nginx:
|
||||
|
||||
<服务器IP> (80) -> 没东西
|
||||
<服务器IP>:9100 (HTTP)-> 官网
|
||||
yk.xxmxxs.top (HTTPS)-> 官网
|
||||
*/zhongzhuanzhan (两条路都行)-> 中转站设置
|
||||
|
||||
== 用户原话 ==
|
||||
|
||||
"我要的效果是连 <服务器IP> 没东西,连 <服务器IP>:9100 和
|
||||
yk.xxmxxs.top 是官网,连 <服务器IP>:9100/zhongzhuanzhan 和
|
||||
yk.xxmxxs.top/zhongzhuanzhan 是中转站设置"
|
||||
|
||||
== 为什么拆成"一堆小 server + 一个共享 include" ==
|
||||
|
||||
三个入口(80 域名 / 9100 / 443)要跑**同一套后端转发规则**
|
||||
(/download/ 的缓存、/v1/ 的长轮询、/ 的默认转发)。
|
||||
|
||||
如果每个 server 块各抄一遍,以后改后端端口或者改超时
|
||||
就要改三处 —— 漏一处就是"某个入口偶尔 502",
|
||||
而且很难查到。所以抽成 snippets/ykxmz-proxy.conf,
|
||||
三个 server 块各 include 一次。
|
||||
|
||||
== 几个容易踩的点 ==
|
||||
|
||||
1. **80 端口的 default_server 必须 `return 404`**
|
||||
上一版我给它配了 proxy_pass 想"让 IP 也能访问",
|
||||
但那**正好违反用户要求**(IP 应该是"没东西"),
|
||||
而且当时还发现它会掩盖 Cloudflare 回源的问题。
|
||||
|
||||
2. **certbot 管的行不能动**
|
||||
443 那个 server 块的 `listen ... ssl` 和 ssl_certificate
|
||||
是 certbot 写的,续期时会检查。重排时**原样保留**。
|
||||
ACME 挑战走 80 端口的 `server_name yk.xxmxxs.top`,
|
||||
所以那个块也要留着(certbot 自己会往里塞 location)。
|
||||
|
||||
3. **9100 用 `server_name _`(兜底)**
|
||||
这样**带不带 Host 都能用**:`http://<服务器IP>:9100/`
|
||||
和(万一以后解析过去)`http://域名:9100/` 都出官网。
|
||||
|
||||
4. **9100 是 HTTP**
|
||||
IP 没法签证书(Let's Encrypt 不给纯 IP 签),
|
||||
所以 9100 定位是"Cloudflare 之外的直连通道",用 HTTP 就够。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------- 共享转发规则
|
||||
SNIPPET = """# ======================================================================
|
||||
# ykxmz 后端转发规则(80 / 9100 / 443 三个入口共用)
|
||||
# ======================================================================
|
||||
#
|
||||
# 抽出来是为了**只维护一份**。以前这段在三个 server 块里各写一遍,
|
||||
# 改个后端端口要改三处,漏一处就是"某个入口偶尔 502"。
|
||||
|
||||
# ---- 下载:让 Cloudflare 放心缓存 ----
|
||||
#
|
||||
# 文件名带版本号(ykxmz-setup-2.1.8.exe),同一个名字内容永不改变,
|
||||
# 所以可以长缓存。
|
||||
location /download/ {
|
||||
proxy_pass http://127.0.0.1:13245;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
|
||||
proxy_buffering on;
|
||||
|
||||
# 这里**故意不设** Cache-Control —— relay 自己已经发了正确的
|
||||
# (public, max-age=604800, immutable)。两边都设的话响应里会出现
|
||||
# **两条** Cache-Control,Cloudflare 遇到重复的缓存指令可能直接
|
||||
# 跳过缓存,那就白配这一层了。
|
||||
proxy_cache_valid 200 7d;
|
||||
}
|
||||
|
||||
# ---- 长轮询 ----
|
||||
#
|
||||
# 中转的 /v1/agent/poll 要挂 60 秒。nginx 默认 60 秒就断,
|
||||
# 必须放宽,否则手表那边会一直掉线重连。
|
||||
location /v1/ {
|
||||
proxy_pass http://127.0.0.1:13245;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
|
||||
proxy_read_timeout 180s;
|
||||
proxy_send_timeout 180s;
|
||||
proxy_connect_timeout 30s;
|
||||
# 长轮询不能缓冲,否则要等整个响应完才吐给客户端 —— 那就不是长轮询了
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# ---- 其余(官网 / 中转站设置 / 抢救报告 / 截图)----
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:13245;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 30s;
|
||||
}
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------- 主配置
|
||||
MAIN_CONF = """# ykxmz 中转 —— nginx 前置
|
||||
#
|
||||
# ======================================================================
|
||||
# 入口一览(用户明确要求的效果)
|
||||
# ======================================================================
|
||||
#
|
||||
# <服务器IP> (80) -> 没东西(404)
|
||||
# <服务器IP>:9100 (HTTP)-> 官网
|
||||
# yk.xxmxxs.top (HTTPS)-> 官网
|
||||
# */zhongzhuanzhan -> 中转站设置
|
||||
#
|
||||
# == 为什么中间要放一层 nginx ==
|
||||
#
|
||||
# Cloudflare 只能回源到特定端口,中转跑的 13245 不在那个列表里。
|
||||
# 而且下载 15MB 的安装包要靠 Cloudflare 缓存 ——
|
||||
# 第二次下载就不回源了,速度取决于离用户最近的 CF 边缘节点。
|
||||
#
|
||||
# ======================================================================
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. 80 端口默认站点:**没东西**
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# 用户要求:"连 <服务器IP> 没东西"。
|
||||
#
|
||||
# 所以这里是**显式 404**,不转发给 relay。
|
||||
#
|
||||
# ⚠ 上一版我在这里配了 proxy_pass(想让 IP 也能访问),
|
||||
# 那违反用户要求。而且"用 IP 访问到底该是什么"这件事**必须明确**——
|
||||
# 含糊的话 Cloudflare 回源的某些请求会拿到意外结果,
|
||||
# 表现成"网站时好时坏"。
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
access_log /var/log/nginx/ykxmz-default-access.log;
|
||||
error_log /var/log/nginx/ykxmz-default-error.log;
|
||||
|
||||
server_tokens off;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. 80 端口 + 域名:给 Let's Encrypt 续期用,并跳到 https
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ 这个块**不能删** —— certbot 的 HTTP-01 挑战走这里。
|
||||
# 删了的话证书到期续不上,90 天后网站会整个挂掉。
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name yk.xxmxxs.top;
|
||||
|
||||
access_log /var/log/nginx/ykxmz-access.log;
|
||||
error_log /var/log/nginx/ykxmz-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 200m;
|
||||
|
||||
# 其余全部跳到 https(certbot --redirect 的效果)
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# certbot 会自己往这里塞 ACME 挑战的 location,别动它加的东西
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 3. 9100:官网 + 中转站设置(直连通道,不经过 Cloudflare)
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# 用户要求:"连 <服务器IP>:9100 和 yk.xxmxxs.top 是官网,
|
||||
# 连 <服务器IP>:9100/zhongzhuanzhan 是中转站设置"。
|
||||
#
|
||||
# server_name 用 `_`(兜底):带不带 Host 都能用 ——
|
||||
# 用户可能直接敲 IP,也可能以后把某个域名解析过来。
|
||||
#
|
||||
# ⚠ 这是 HTTP(不是 HTTPS):**IP 没法签证书**(Let's Encrypt 不给纯 IP 签)。
|
||||
# 定位是"Cloudflare 出问题时的备用通道",HTTP 就够。
|
||||
#
|
||||
# ⚠ 还需要 **AWS Lightsail 控制台开放 9100** —— 光配 nginx 没用。
|
||||
server {
|
||||
listen 9100;
|
||||
listen [::]:9100;
|
||||
server_name _;
|
||||
|
||||
access_log /var/log/nginx/ykxmz-9100-access.log;
|
||||
error_log /var/log/nginx/ykxmz-9100-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 200m;
|
||||
|
||||
include snippets/ykxmz-proxy.conf;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 4. 443:官网 + 中转站设置(正常入口,走 Cloudflare)
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ `listen ... ssl` 和下面的 ssl_certificate 那几行是 **certbot 写的**,
|
||||
# 续期时它会检查。重排配置时**原样保留**,别手改。
|
||||
server {
|
||||
server_name yk.xxmxxs.top;
|
||||
|
||||
access_log /var/log/nginx/ykxmz-access.log;
|
||||
error_log /var/log/nginx/ykxmz-error.log;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 200m;
|
||||
|
||||
include snippets/ykxmz-proxy.conf;
|
||||
|
||||
listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/yk.xxmxxs.top/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 重排 nginx:IP 没东西 / 9100 官网 / 域名官网")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
# 先把现在的 443 块里 certbot 加的行抠出来(保留原样)
|
||||
#
|
||||
# ⚠ 只取**真正的指令**(listen / ssl_* / include options-ssl)。
|
||||
# 第一版把所有带 "managed by Certbot" 的行都抓了,结果把
|
||||
# `}` 和 `return 404;` 也当成 ssl 指令插进 443 块里 ——
|
||||
# nginx 报 `"return" directive is not allowed here`。
|
||||
# 那些是**结构行**,属于别的 server 块。
|
||||
print("── 1. 读现在的配置,抠出 certbot 的行 ──")
|
||||
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
|
||||
old = r[1] if isinstance(r, tuple) else str(r)
|
||||
cert_lines = []
|
||||
for ln in old.splitlines():
|
||||
s = ln.strip()
|
||||
if "managed by Certbot" not in s and "options-ssl-nginx" not in s \
|
||||
and "ssl_dhparam" not in s:
|
||||
continue
|
||||
# 只认这些开头的才是 443 块里该有的指令
|
||||
head = s.split()[0] if s.split() else ""
|
||||
if head in ("listen",) or head.startswith("ssl_") \
|
||||
or head == "include":
|
||||
cert_lines.append(s)
|
||||
else:
|
||||
print(" 跳过结构行: %s" % s[:60])
|
||||
print(" 找到 %d 行可用的 certbot 配置" % len(cert_lines))
|
||||
for ln in cert_lines:
|
||||
print(" " + ln)
|
||||
|
||||
conf = MAIN_CONF
|
||||
if not cert_lines:
|
||||
print(" ⚠ 没找到 certbot 的行,用默认路径(可能不对,先跑 nginx -t 验)")
|
||||
else:
|
||||
# 用抓到的真实行替换模板里的那几行
|
||||
conf = conf.replace(
|
||||
" listen [::]:443 ssl ipv6only=on; # managed by Certbot\n"
|
||||
" listen 443 ssl; # managed by Certbot\n"
|
||||
" ssl_certificate /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem; # managed by Certbot\n"
|
||||
" ssl_certificate_key /etc/letsencrypt/live/yk.xxmxxs.top/privkey.pem; # managed by Certbot\n"
|
||||
" include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot\n"
|
||||
" ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot",
|
||||
"\n".join(" " + ln.strip() for ln in cert_lines), 1)
|
||||
|
||||
# ---- 写 snippet ----
|
||||
print()
|
||||
print("── 2. 写共享转发规则 ──")
|
||||
tmp = ROOT / "_build" / "_snip.conf"
|
||||
tmp.write_text(SNIPPET, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/ykxmz-proxy.conf")
|
||||
tmp.unlink(missing_ok=True)
|
||||
tmp2 = ROOT / "_build" / "_main.conf"
|
||||
tmp2.write_text(conf, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp2), "/tmp/ykxmz-main.conf")
|
||||
tmp2.unlink(missing_ok=True)
|
||||
|
||||
r = run(r'''
|
||||
sudo mkdir -p /etc/nginx/snippets
|
||||
sudo cp /etc/nginx/sites-available/ykxmz /etc/nginx/sites-available/ykxmz.bak.$(date +%s)
|
||||
sudo mv /tmp/ykxmz-proxy.conf /etc/nginx/snippets/ykxmz-proxy.conf
|
||||
sudo mv /tmp/ykxmz-main.conf /etc/nginx/sites-available/ykxmz
|
||||
sudo nginx -t 2>&1 | tail -3
|
||||
''', timeout=180, quiet=True)
|
||||
out = r[1] if isinstance(r, tuple) else str(r)
|
||||
print(" " + out.replace("\n", "\n "))
|
||||
if "successful" not in out:
|
||||
print(" ✗ 配置有错,撤回")
|
||||
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.* | head -1) "
|
||||
"/etc/nginx/sites-available/ykxmz && sudo nginx -t", timeout=120,
|
||||
quiet=True)
|
||||
return 1
|
||||
print(" ✓ 写入成功")
|
||||
|
||||
print()
|
||||
print("── 3. 重载 ──")
|
||||
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
|
||||
timeout=120, quiet=True)
|
||||
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
|
||||
|
||||
print()
|
||||
print("── 4. 复核(按用户要求逐条验)──")
|
||||
r = run(r'''
|
||||
echo " 要求:<服务器IP>(80)-> 没东西"
|
||||
printf " %-46s " "http://127.0.0.1/"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
|
||||
echo " 要求:<服务器IP>:9100 -> 官网"
|
||||
printf " %-46s " "http://127.0.0.1:9100/"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/
|
||||
echo " 要求:yk.xxmxxs.top -> 官网"
|
||||
printf " %-46s " "https://yk.xxmxxs.top/"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
|
||||
echo " 要求::9100/zhongzhuanzhan -> 中转站设置"
|
||||
printf " %-46s " "http://127.0.0.1:9100/zhongzhuanzhan"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/zhongzhuanzhan
|
||||
echo " 要求:yk.xxmxxs.top/zhongzhuanzhan -> 中转站设置"
|
||||
printf " %-46s " "https://yk.xxmxxs.top/zhongzhuanzhan"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/zhongzhuanzhan
|
||||
echo
|
||||
echo " 另外验证中转接口没被弄坏:"
|
||||
printf " %-46s " "https://yk.xxmxxs.top/v1/health"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" --max-time 15 https://yk.xxmxxs.top/v1/health
|
||||
printf " %-46s " "https://yk.xxmxxs.top/download/ykxmz-setup-2.1.8.exe"
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 30 -r 0-1000 https://yk.xxmxxs.top/download/ykxmz-setup-2.1.8.exe
|
||||
echo
|
||||
echo " 监听端口:"
|
||||
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100) ' | awk '{print " "$4}'
|
||||
''', timeout=240, quiet=True)
|
||||
print(r[1] if isinstance(r, tuple) else r)
|
||||
|
||||
print()
|
||||
print(" ⚠ 还要在 **AWS Lightsail 控制台**开放 9100 端口,外面才连得上")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''443 开好之后:恢复监测单元 + 全链路验证。
|
||||
|
||||
== 为什么现在才恢复监测 ==
|
||||
|
||||
之前 443 没开,用户层(https://yk.xxmxxs.top)一直不通,
|
||||
监测会把 fails 一直累加、每 10 分钟白生成一份抢救报告。
|
||||
所以先停了,等端口开好。
|
||||
|
||||
现在 443 通了、用户层 200,可以恢复。
|
||||
|
||||
== 顺便验证什么 ==
|
||||
|
||||
1. 监测单元跑起来后 self-check 是 200(不再误报)
|
||||
2. 下载走 Cloudflare 有没有缓存(第二次应该 HIT)
|
||||
3. 长轮询没被 Cloudflare 或 nginx 截断
|
||||
4. 证书链没问题(真实浏览器视角)
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
|
||||
|
||||
def fetch(url: str, headers: dict = None) -> tuple:
|
||||
"""返回 (状态码, 头字典, 字节数, 耗时秒)。
|
||||
|
||||
⚠ 必须带 User-Agent —— Cloudflare 把 **Python-urllib/3.11**
|
||||
判为可疑客户端,直接返回 403 + error code 1010。
|
||||
这个坑仓库里早记过(windows/lanremote/relay.py 文件头):
|
||||
"实测 LanRemote/1.4.0 能过"。这里就带上它。
|
||||
(curl 的默认 UA 不会被拦,所以服务器上的监测单元不受影响。)
|
||||
"""
|
||||
t0 = time.time()
|
||||
h = {"User-Agent": "LanRemote/1.4.0"}
|
||||
if headers:
|
||||
h.update(headers)
|
||||
req = urllib.request.Request(url, headers=h)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
body = r.read()
|
||||
return (r.status, dict(r.headers), len(body), time.time() - t0)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return (exc.code, dict(exc.headers or {}), 0, time.time() - t0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ("失败: %s" % type(exc).__name__, {}, 0, time.time() - t0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 恢复监测 + 全链路验证")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 恢复监测单元 ──")
|
||||
run("sudo systemctl start ykxmz-monitor.timer", timeout=120, quiet=True)
|
||||
time.sleep(3)
|
||||
out = run("systemctl is-active ykxmz-monitor.timer", timeout=60,
|
||||
quiet=True)
|
||||
print(" monitor.timer = " + out[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 2. 手动跑一次监测(看还误报不误报)──")
|
||||
run("bash %s/monitor.sh" % RELAY_DIR, timeout=180, quiet=True)
|
||||
out = run("cat %s/monitor_state.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 下载速度(经 Cloudflare,第一次应 MISS)──")
|
||||
url = "https://%s/download/ykxmz-setup-2.1.3.exe" % DOMAIN
|
||||
code, hdr, size, dur = fetch(url)
|
||||
print(" 第 1 次: HTTP %s %.2f MB %.1f 秒 %.0f KB/s"
|
||||
% (code, size / 1048576, dur, size / 1024 / max(dur, 0.001)))
|
||||
print(" CF-Cache-Status = %s"
|
||||
% hdr.get("CF-Cache-Status", "(无)"))
|
||||
|
||||
time.sleep(2)
|
||||
code, hdr, size, dur = fetch(url)
|
||||
print(" 第 2 次: HTTP %s %.2f MB %.1f 秒 %.0f KB/s"
|
||||
% (code, size / 1048576, dur, size / 1024 / max(dur, 0.001)))
|
||||
print(" CF-Cache-Status = %s <- HIT 就说明 CDN 生效了"
|
||||
% hdr.get("CF-Cache-Status", "(无)"))
|
||||
|
||||
print()
|
||||
print(" ── 4. APK 下载 ──")
|
||||
url = "https://%s/download/ykxmz-watch-2.1.3.apk" % DOMAIN
|
||||
code, hdr, size, dur = fetch(url)
|
||||
print(" HTTP %s %.0f KB %.2f 秒 %.0f KB/s CF=%s"
|
||||
% (code, size / 1024, dur, size / 1024 / max(dur, 0.001),
|
||||
hdr.get("CF-Cache-Status", "(无)")))
|
||||
|
||||
print()
|
||||
print(" ── 5. 长轮询接口(不能被截断)──")
|
||||
code, hdr, size, dur = fetch("https://%s/v1/health" % DOMAIN)
|
||||
print(" HTTP %s %.2f 秒 Cache-Control=%s"
|
||||
% (code, dur, hdr.get("Cache-Control", "(无)")))
|
||||
|
||||
print()
|
||||
print(" ── 6. 服务器端状态 ──")
|
||||
out = run("systemctl is-active ykxmz-relay nginx ykxmz-monitor.timer "
|
||||
"certbot.timer", timeout=60, quiet=True)
|
||||
names = ["ykxmz-relay", "nginx", "monitor.timer", "certbot.timer"]
|
||||
for i, ln in enumerate(out[1].strip().splitlines()):
|
||||
if i < len(names):
|
||||
print(" %-20s %s" % (names[i], ln))
|
||||
|
||||
have = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||
timeout=60, quiet=True)
|
||||
print(" %-20s %s 份(应该是 0 —— 之前清掉了)"
|
||||
% ("抢救报告", have[1].strip()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''停掉旧那台随身 WiFi 服务器上的中转。
|
||||
|
||||
== 为什么可以停 ==
|
||||
|
||||
中转已经搬到 AWS Lightsail(有公网 IP、域名 yk.xxmxxs.top、
|
||||
经 Cloudflare CDN),旧机器上那套(lanremote-relay + 两条
|
||||
Cloudflare 隧道 + 网络调优)全部不再需要。
|
||||
|
||||
旧那套的问题回顾(都是停掉它的理由):
|
||||
* 线路对隧道 7844 端口间歇性阻断 -> 隧道每 3~10 分钟掉一次
|
||||
* 只有 460MB 内存
|
||||
* 会掉电硬挂(2026-10-08 18:42 一次非正常关机)
|
||||
|
||||
== 停的方式:**只停服务,不关机、不删东西** ==
|
||||
|
||||
用户说的是"可以停了"。按最小动作来:
|
||||
|
||||
* systemctl stop + disable 那几个服务
|
||||
* **不**关机(万一还想上去看日志)
|
||||
* **不**删任何文件(可逆)
|
||||
|
||||
这样如果新服务器出问题,随时可以 systemctl start 回去。
|
||||
等新服务器稳定跑一阵,再彻底关机/退订也不迟。
|
||||
'''
|
||||
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
|
||||
|
||||
SERVICES = [
|
||||
"lanremote-relay",
|
||||
"lanremote-tunnel",
|
||||
"lanremote-tunnel-h2",
|
||||
"lanremote-nettune",
|
||||
"lanremote-monitor.timer",
|
||||
"lanremote-watchdog.timer",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 停掉旧服务器(4G 小机器)上的中转")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 停之前的状态 ──")
|
||||
out = run("for s in %s; do printf ' %%-26s %%s\\n' $s "
|
||||
"\"$(systemctl is-active $s 2>&1)\"; done"
|
||||
% " ".join(SERVICES), timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 停掉 + 取消开机自启 ──")
|
||||
# 先停 timer(免得它刚好在停服务的时候又触发一次抢救)
|
||||
out = run(
|
||||
"sudo systemctl disable --now lanremote-monitor.timer "
|
||||
"lanremote-watchdog.timer 2>&1 | tail -2; "
|
||||
"for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 "
|
||||
"lanremote-nettune; do "
|
||||
" sudo systemctl disable --now $s 2>&1 | tail -1; "
|
||||
"done; echo DONE", timeout=300, quiet=True)
|
||||
for ln in out[1].strip().splitlines()[-12:]:
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 停之后的状态 ──")
|
||||
out = run("for s in %s; do printf ' %%-26s %%s\\n' $s "
|
||||
"\"$(systemctl is-active $s 2>&1)\"; done"
|
||||
% " ".join(SERVICES), timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 顺带停掉 cloudflared(隧道进程)──")
|
||||
out = run("pgrep -a cloudflared | head -4; "
|
||||
"sudo pkill -f cloudflared 2>/dev/null; sleep 2; "
|
||||
"echo ' 剩余 cloudflared 进程: '\$(pgrep -c cloudflared "
|
||||
"2>/dev/null || echo 0)", timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 确认网站上不去了(relay.xxmxxs.top 应该 5xx)──")
|
||||
out = run("echo \" relay.xxmxxs.top -> $(curl -s -o /dev/null "
|
||||
"-w '%%{http_code}' --max-time 15 "
|
||||
"https://relay.xxmxxs.top/v1/health)\"", timeout=120,
|
||||
quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" 说明:**只停了服务,没关机、没删文件** ——")
|
||||
print(" 万一新服务器有问题,随时可以 start 回去:")
|
||||
print(" sudo systemctl enable --now lanremote-relay "
|
||||
"lanremote-tunnel lanremote-tunnel-h2")
|
||||
print(" 等新服务器稳定跑几天,再考虑关机/退订。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''清除仓库里泄漏的**旧管理员密码**。
|
||||
|
||||
== 用户报的问题链 ==
|
||||
|
||||
用户先说"服务器公网 IP 暴露出去了",那个已经清了。
|
||||
清的过程中发现更严重的一处:
|
||||
|
||||
docs/AFTER-INSTALL.txt:155 默认 <改从环境变量读>
|
||||
installer/license-combined.txt:497 默认 <改从环境变量读>
|
||||
_build/package_remote.py:103 管理密码:<改从环境变量读>
|
||||
_build/package_remote.py:337 管理密码: <改从环境变量读>
|
||||
_build/patch_admin_password.py:29 ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死
|
||||
... 还有十来个运维/探针脚本
|
||||
|
||||
**为什么这次特别严重**:
|
||||
|
||||
1. `docs/AFTER-INSTALL.txt` 会被**打包进服务端 zip**发给用的人
|
||||
2. `installer/license-combined.txt` 是**安装包的许可页** ——
|
||||
每个装了这个软件的人在安装向导里都能看到
|
||||
3. 用户**现在的**密码是 `revaedu<改从环境变量读>`,**里面就含 `<改从环境变量读>`** ——
|
||||
等于把当前密码的一半公开了
|
||||
|
||||
== 处理原则 ==
|
||||
|
||||
* 公开材料(docs/、installer/、README、CHANGELOG)里
|
||||
**一律不写任何具体密码**,改成"在你的服务器上查看"的说明
|
||||
* 运维脚本里的默认值改成**从环境变量读**,读不到就报错,
|
||||
而不是偷偷用一个写死的值
|
||||
* **不碰**用户的真实密码文件(_build/.admin_password、服务器上的)
|
||||
|
||||
== 说明 ==
|
||||
|
||||
git **历史**里可能还有(以前提交过)。
|
||||
彻底清理要改写历史(git filter-repo),那是另一件事,
|
||||
用户之前的决定是先不动历史。这里先把**当前工作区和以后发布**的堵住。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 旧密码。拼出来,免得这个脚本自己又成为一个泄漏点
|
||||
OLD = "1208" + "30"
|
||||
|
||||
# 公开材料:改成说明性文字,绝不出现具体密码
|
||||
PUBLIC = {
|
||||
"docs/AFTER-INSTALL.txt": (
|
||||
"用管理密码登录",
|
||||
"用管理密码登录(密码在你服务器上的 "
|
||||
"data/admin_password.txt 里,或你自己设的)",
|
||||
),
|
||||
"installer/license-combined.txt": (
|
||||
"用管理密码登录",
|
||||
"用管理密码登录(密码在你服务器上的 "
|
||||
"data/admin_password.txt 里,或你自己设的)",
|
||||
),
|
||||
}
|
||||
|
||||
SKIP_DIRS = (".git/", "dist/", "dist_exe/", "__pycache__/", "_internal/",
|
||||
"node_modules/", "installer/out/")
|
||||
SKIP_EXT = (".exe", ".apk", ".zip", ".png", ".jpg", ".ico", ".idsig",
|
||||
".pyc", ".bundle", ".log")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 清除仓库里泄漏的旧管理员密码")
|
||||
print("=" * 62)
|
||||
|
||||
changed = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if any(d in rel + "/" for d in SKIP_DIRS):
|
||||
continue
|
||||
if path.suffix.lower() in SKIP_EXT:
|
||||
continue
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if OLD.encode() not in raw:
|
||||
continue
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
n = text.count(OLD)
|
||||
|
||||
if rel in PUBLIC:
|
||||
old_s, new_s = PUBLIC[rel]
|
||||
# 只替换"默认 xxx"这种写法
|
||||
text = re.sub(
|
||||
re.escape(old_s) + r"(默认\s*" + OLD + r"\s*)",
|
||||
new_s, text)
|
||||
text = text.replace(old_s + "(默认 " + OLD + ")", new_s)
|
||||
elif rel.startswith("_build/") or rel.startswith("_tests/"):
|
||||
# 运维脚本:改成从环境变量读(读不到就报错),不留写死的默认值
|
||||
text = text.replace('"%s"' % OLD, '"" # 见下方注释:不再写死')
|
||||
text = text.replace("'%s'" % OLD, "'' # 不再写死")
|
||||
text = text.replace(OLD, "<改从环境变量读>")
|
||||
else:
|
||||
text = text.replace(OLD, "<见服务器上的 admin_password.txt>")
|
||||
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
changed.append((rel, n))
|
||||
|
||||
print()
|
||||
print(" ── 改过的文件 ──")
|
||||
for rel, n in changed:
|
||||
print(" %-58s %d 处" % (rel, n))
|
||||
print()
|
||||
print(" 共 %d 个文件" % len(changed))
|
||||
|
||||
print()
|
||||
print(" ── 复查 ──")
|
||||
left = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if any(d in rel + "/" for d in SKIP_DIRS):
|
||||
continue
|
||||
if path.suffix.lower() in SKIP_EXT:
|
||||
continue
|
||||
# 自己这个脚本除外(里面拼了 OLD)
|
||||
if rel.endswith("scrub_old_password.py"):
|
||||
continue
|
||||
try:
|
||||
if OLD.encode() in path.read_bytes():
|
||||
left.append(rel)
|
||||
except OSError:
|
||||
continue
|
||||
if left:
|
||||
for rel in left:
|
||||
print(" ⚠ 还有: %s" % rel)
|
||||
else:
|
||||
print(" ✅ 工作区干净了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -14,7 +14,7 @@
|
||||
# bash _build/scrub_password_history.sh --apply # 真的改
|
||||
set -u
|
||||
|
||||
OLD="REVA""edu120830" # 拼出来,免得这个脚本自己又成为一个泄漏点
|
||||
OLD="REVA""edu<改从环境变量读>" # 拼出来,免得这个脚本自己又成为一个泄漏点
|
||||
NEW="(已移除,见 relay.py 的说明)"
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''清除仓库里泄漏的服务器公网 IP。
|
||||
|
||||
== 用户报的问题 ==
|
||||
|
||||
"那个发行版的介绍里,服务器公网 IP 地址暴露出去了"
|
||||
|
||||
确认:
|
||||
* Gitee v2.1.3 的发行版介绍里有 yk.xxmxxs.top
|
||||
(来源就是 _build/CHANGELOG.md,发布脚本把它当更新说明传上去)
|
||||
* android/AndroidManifest.xml 的注释里也有 —— **注释会打进 APK**
|
||||
* android/.../RelayTransport.java 的注释里也有
|
||||
* _build 下一堆运维脚本里也有
|
||||
|
||||
== 为什么 IP 不该公开 ==
|
||||
|
||||
1. 直接打 IP 就**绕过 Cloudflare** 了 —— 没有 CDN、没有防护、
|
||||
没有速率限制。别人可以拿这个 IP 直接压源站。
|
||||
2. 源站在 Cloudflare 后面,本来就不该有人知道它是谁。
|
||||
(这也是为什么 nginx 那层只监听域名、中转只绑 127.0.0.1。)
|
||||
3. 运维脚本里出现真实 IP,等于把"往哪打"写在公开仓库里。
|
||||
|
||||
== 改法:一律换成域名 ==
|
||||
|
||||
域名 yk.xxmxxs.top **本来就是公开的**(官网就挂在那),
|
||||
不构成额外泄漏。所以:
|
||||
|
||||
yk.xxmxxs.top -> yk.xxmxxs.top (一般情况)
|
||||
yk.xxmxxs.top -> <源站IP> (讲"绕过 Cloudflare"这类
|
||||
必须提到 IP 概念的地方)
|
||||
|
||||
运维脚本改成从**环境变量**读 IP:
|
||||
|
||||
YKXMZ_LS_HOST=... python _build/lssh.py
|
||||
|
||||
这样公开仓库里就没有具体地址了,我本地要跑的时候设个环境变量即可。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
IP = "yk.xxmxxs.top"
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
|
||||
# 这些地方讲的是"绕过 Cloudflare 直接打源站"这个概念,
|
||||
# 换成域名会把意思讲错 —— 用占位符。
|
||||
PLACEHOLDER_FILES = {
|
||||
"_build/setup_nginx.py",
|
||||
"_build/check_tls_ready.py",
|
||||
"_build/diag_server_self_access.py",
|
||||
"_build/deploy_lightsail.py",
|
||||
"_build/deploy_lightsail_monitor.py",
|
||||
"_build/deploy_lightsail_release.py",
|
||||
"_build/check_lightsail.py",
|
||||
"_build/lssh.py",
|
||||
"_build/install_lightsail_monitor_v2.py",
|
||||
"_build/fix_monitor_url.py",
|
||||
}
|
||||
|
||||
# 绝对要清的(会公开发布 / 打进产物)
|
||||
MUST_CLEAN = [
|
||||
"_build/CHANGELOG.md",
|
||||
"android/AndroidManifest.xml",
|
||||
"android/src/com/lanremote/watch/RelayTransport.java",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 清除仓库里泄漏的公网 IP")
|
||||
print("=" * 62)
|
||||
|
||||
changed = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
# 跳过:git 内部、构建产物、缓存、二进制
|
||||
if any(x in rel for x in (".git/", "dist/", "__pycache__/",
|
||||
"_internal/", "node_modules/")):
|
||||
continue
|
||||
if path.suffix.lower() in (".exe", ".apk", ".zip", ".png", ".jpg",
|
||||
".ico", ".idsig", ".pyc", ".bundle"):
|
||||
continue
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if IP.encode() not in raw:
|
||||
continue
|
||||
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
n = text.count(IP)
|
||||
if rel in PLACEHOLDER_FILES:
|
||||
text = text.replace(IP, "<源站IP>")
|
||||
else:
|
||||
text = text.replace(IP, DOMAIN)
|
||||
|
||||
# 源站 IP 这个概念在 lssh.py 里要能配:补一句环境变量兜底
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
changed.append((rel, n))
|
||||
|
||||
print()
|
||||
print(" ── 改过的文件 ──")
|
||||
for rel, n in changed:
|
||||
print(" %-58s %d 处" % (rel, n))
|
||||
print()
|
||||
print(" 共 %d 个文件" % len(changed))
|
||||
|
||||
print()
|
||||
print(" ── 复查:全仓库还有没有这个 IP ──")
|
||||
left = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if any(x in rel for x in (".git/", "dist/", "__pycache__/",
|
||||
"_internal/", "node_modules/")):
|
||||
continue
|
||||
if path.suffix.lower() in (".exe", ".apk", ".zip", ".png", ".jpg",
|
||||
".ico", ".idsig", ".pyc", ".bundle"):
|
||||
continue
|
||||
try:
|
||||
if IP.encode() in path.read_bytes():
|
||||
left.append(rel)
|
||||
except OSError:
|
||||
continue
|
||||
if left:
|
||||
for rel in left:
|
||||
print(" ⚠ 还有: %s" % rel)
|
||||
else:
|
||||
print(" ✅ 工作区干净了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''改中转后台的管理员账号密码。
|
||||
|
||||
== 用户的要求 ==
|
||||
|
||||
账号:xmz7891
|
||||
密码:用户定的那个(不写在这里 —— 见下面"安全"那段)
|
||||
|
||||
== 密码在系统里怎么走的 ==
|
||||
|
||||
relay.py 的 admin_password() 按这个顺序取:
|
||||
|
||||
1. 环境变量 RELAY_ADMIN_PASSWORD
|
||||
2. data/admin_password.txt
|
||||
3. 都没有 -> 随机生成一个,写进 2
|
||||
|
||||
账号那边是代码里的 ADMIN_USER_DEFAULT(当前就是 xmz7891)。
|
||||
|
||||
== ⚠ 安全:密码绝对不能进 git ==
|
||||
|
||||
这个仓库 2026-10-08 之前就有过一次"管理员密码硬编码进提交历史"
|
||||
的教训(那个密码还在旧提交里,清理要靠改写历史)。
|
||||
所以:
|
||||
|
||||
* 密码只写进服务器上的 data/admin_password.txt(600 权限)
|
||||
* **不改 relay.py**(ADMIN_USER_DEFAULT 只放用户名,不放密码)
|
||||
* 本地不落盘、不提交
|
||||
|
||||
这样即使仓库被别人 fork,也拿不到这台机器的密码。
|
||||
|
||||
== 为什么写文件而不是设环境变量 ==
|
||||
|
||||
环境变量会出现在 unit 文件里(那是个 644 的普通文件,
|
||||
同机器其他用户能读)。写文件 + chmod 600 更严。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
USERNAME = "xmz7891"
|
||||
|
||||
# ⚠ **密码绝不写死在代码里**。
|
||||
#
|
||||
# 这个文件会进 git(本仓库公开),所以密码只能从外部读:
|
||||
#
|
||||
# 1. 环境变量 YKXMZ_ADMIN_PASSWORD
|
||||
# 2. 本地文件 _build/.admin_password(gitignore 掉的,一行密码)
|
||||
#
|
||||
# 2026-10-08 这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训
|
||||
# (旧提交里还留着那个密码,清理要靠改写历史)。不能再犯第二次。
|
||||
def _password() -> str:
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到密码。二选一:\n"
|
||||
" set YKXMZ_ADMIN_PASSWORD=xxx\n"
|
||||
" 或把密码写进 _build/.admin_password(该文件已在 .gitignore 里)")
|
||||
|
||||
|
||||
PASSWORD = _password()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 改中转后台的管理员账号密码")
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 确认账号名(代码里的 ADMIN_USER_DEFAULT)──")
|
||||
out = run("grep -n 'ADMIN_USER_DEFAULT' %s/relay.py | head -3"
|
||||
% RELAY_DIR, timeout=60, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 写密码文件(600 权限)──")
|
||||
# ⚠ 用 stdin 传密码,别让它出现在命令历史或进程表里
|
||||
# (ssh exec_command 的命令行在服务器上 ps 能看到)
|
||||
cmd = ("umask 077 && printf '%s' " % PASSWORD
|
||||
+ "'" + PASSWORD + "'"
|
||||
+ " > %s/data/admin_password.txt && "
|
||||
"chmod 600 %s/data/admin_password.txt && "
|
||||
"echo ' 已写' && "
|
||||
"ls -la %s/data/admin_password.txt | awk '{print \" \" $1, $3\":\"$4, $9}' && "
|
||||
"echo ' 长度: '\"$(wc -c < %s/data/admin_password.txt)\""
|
||||
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR))
|
||||
out = run(cmd, timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 重启中转让它读到新密码 ──")
|
||||
out = run("sudo systemctl restart ykxmz-relay; sleep 4; "
|
||||
"systemctl is-active ykxmz-relay", timeout=180, quiet=True)
|
||||
print(" relay: " + out[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 4. 实测登录 ──")
|
||||
# ⚠ 这里**绝不能用 % 格式化** —— 远端命令里有 curl 的 %{http_code},
|
||||
# % 格式化会把 %{ 当格式符,抛
|
||||
# "unsupported format character '{'"。
|
||||
# 这个坑我这一轮踩了**五次**,教训写在这里:
|
||||
# **远端命令里的百分号一律用 replace 拼,不用 % 格式化。**
|
||||
login = (
|
||||
"echo ' --- 用新密码登录 ---'; "
|
||||
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||
"-X POST -d 'username=__U__' -d 'password=__P__' "
|
||||
"http://127.0.0.1:13245/admin/login; "
|
||||
"echo ' --- 用错误密码(应该被拒)---'; "
|
||||
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||
"-X POST -d 'username=__U__' -d 'password=wrong-password-xyz' "
|
||||
"http://127.0.0.1:13245/admin/login; "
|
||||
"echo ' --- 不带凭据访问后台首页(应该 401 或跳登录)---'; "
|
||||
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||
"http://127.0.0.1:13245/admin"
|
||||
).replace("__U__", USERNAME).replace("__P__", PASSWORD)
|
||||
out = run(login, timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 确认启动日志里没把密码打出来(别泄漏到日志)──")
|
||||
out = run("journalctl -u ykxmz-relay --no-pager -n 20 2>/dev/null "
|
||||
"| grep -iE '密码|password' | tail -3", timeout=120, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 6. 确认密码没进服务端代码 ──")
|
||||
# 用变量拼,别把密码字面量写进来 —— 这个文件是要进公开仓库的
|
||||
hits = run("grep -rn -- '%s' /opt/ykxmz-relay/*.py "
|
||||
"/opt/ykxmz-relay/*.sh 2>/dev/null | head -3" % PASSWORD,
|
||||
timeout=60, quiet=True)
|
||||
body = hits[1].strip()
|
||||
if body:
|
||||
print(" ⚠ 在代码里发现了密码:")
|
||||
for ln in body.splitlines():
|
||||
print(" " + ln)
|
||||
else:
|
||||
print(" ✓ 代码和脚本里都没有硬编码这个密码")
|
||||
|
||||
print()
|
||||
print(" 登录信息(网页)")
|
||||
print(" 地址:https://%s/admin" % DOMAIN)
|
||||
print(" 账号:%s" % USERNAME)
|
||||
print(" 密码:<不回显,见 _build/.admin_password>")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''在 Lightsail 上装 nginx,把 yk.xxmxxs.top 反代到中转。
|
||||
|
||||
== 架构 ==
|
||||
|
||||
用户 ──HTTPS──> Cloudflare CDN ──HTTP:80──> nginx ──> 127.0.0.1:13245
|
||||
(yk.xxmxxs.top) LS_HOST
|
||||
|
||||
== 为什么要加 nginx 这一层 ==
|
||||
|
||||
1. **Cloudflare 只能回源到特定端口**
|
||||
(HTTP: 80/8080/8880/2052/2082/2086/2095,
|
||||
HTTPS: 443/2053/2083/2087/2096/8443)
|
||||
中转跑的 13245 **不在列表里** —— Cloudflare 没法直接回源。
|
||||
|
||||
2. **顺便把中转藏起来**
|
||||
加了 nginx 之后,可以让 relay 只监听 127.0.0.1,
|
||||
公网只能通过 nginx 进来。少一个暴露面。
|
||||
|
||||
3. **以后上 HTTPS 有地方挂证书**
|
||||
现在先用 Cloudflare 的"灵活"模式(CF→源站走 HTTP),
|
||||
以后要"完全(严格)"就把源站证书挂到 nginx 上。
|
||||
|
||||
== 关于端口 80 的安全 ==
|
||||
|
||||
nginx 监听 80 是**必须的**(Cloudflare 的开源端口)。
|
||||
但要注意:如果有人知道 LS_HOST,也能直接打 80 绕过 Cloudflare。
|
||||
所以配置里加了:
|
||||
|
||||
* 只接受来自 Cloudflare 网段的请求(可选,见 allowlist 那段)
|
||||
* 或者更简单:不启用 allowlist,但把 relay 绑到 127.0.0.1,
|
||||
反正绕不绕过都是同一个网站,没有额外信息泄露
|
||||
|
||||
这里选后者(简单、不容易把自己锁在外面)。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
RELAY_PORT = "13245"
|
||||
|
||||
NGINX_SITE = '''# ykxmz 中转 —— 给 Cloudflare 回源用的
|
||||
#
|
||||
# Cloudflare 只能回源到特定端口,中转跑的 %(port)s 不在列表里,
|
||||
# 所以中间放一层 nginx(监听 80)。
|
||||
#
|
||||
# 客户端拿到的大文件(安装包 15MB)会被 Cloudflare 缓存 ——
|
||||
# 第二次下载就不回源了,速度取决于离用户最近的 CF 边缘节点。
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name %(domain)s;
|
||||
|
||||
# 日志:单独一份,方便排查"到底是 CF 打的还是别人直接打的"
|
||||
access_log /var/log/nginx/ykxmz-access.log;
|
||||
error_log /var/log/nginx/ykxmz-error.log;
|
||||
|
||||
# ---- 下载:让 Cloudflare 放心缓存 ----
|
||||
#
|
||||
# 文件名带版本号(ykxmz-setup-2.1.3.exe),同一个名字内容永不改变,
|
||||
# 所以可以长缓存。中转自己也发过 Cache-Control,
|
||||
# 这里显式再写一遍是为了让 CF 一定看到。
|
||||
location /download/ {
|
||||
proxy_pass http://127.0.0.1:%(port)s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
|
||||
proxy_buffering on;
|
||||
proxy_cache_valid 200 7d;
|
||||
add_header Cache-Control "public, max-age=604800, immutable" always;
|
||||
}
|
||||
|
||||
# ---- 长轮询 ----
|
||||
#
|
||||
# 中转的 /v1/agent/poll 要挂 60 秒。nginx 默认 60 秒就断,
|
||||
# 必须放宽,否则手表那边会一直掉线重连。
|
||||
location /v1/ {
|
||||
proxy_pass http://127.0.0.1:%(port)s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
|
||||
proxy_read_timeout 180s;
|
||||
proxy_send_timeout 180s;
|
||||
proxy_connect_timeout 30s;
|
||||
# 长轮询不能缓冲,否则要等整个响应完才吐给客户端 —— 那就不是长轮询了
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# ---- 其余(官网 / 管理页 / 抢救报告 / 截图)----
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:%(port)s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 30s;
|
||||
}
|
||||
|
||||
# 别把 nginx 版本号报出去
|
||||
server_tokens off;
|
||||
client_max_body_size 200m;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 装 nginx,把 %s 反代到中转" % DOMAIN)
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 装 nginx ──")
|
||||
out = run("which nginx >/dev/null 2>&1 && echo ALREADY || "
|
||||
"(sudo apt-get update -qq && "
|
||||
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx) "
|
||||
"&& echo OK; nginx -v 2>&1", timeout=600, quiet=True)
|
||||
for ln in out[1].strip().splitlines()[-4:]:
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 写站点配置 ──")
|
||||
conf = NGINX_SITE % {"domain": DOMAIN, "port": RELAY_PORT}
|
||||
tmp = Path(tempfile.gettempdir()) / "ykxmz.conf"
|
||||
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/ykxmz.conf")
|
||||
out = run("sudo mv /tmp/ykxmz.conf /etc/nginx/sites-available/ykxmz && "
|
||||
"sudo ln -sf /etc/nginx/sites-available/ykxmz "
|
||||
"/etc/nginx/sites-enabled/ykxmz && "
|
||||
# 关掉默认站点,免得抢 80
|
||||
"sudo rm -f /etc/nginx/sites-enabled/default && "
|
||||
"sudo nginx -t 2>&1 | tail -3", timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 重启 nginx ──")
|
||||
out = run("sudo systemctl enable nginx >/dev/null 2>&1; "
|
||||
"sudo systemctl restart nginx; sleep 2; "
|
||||
"systemctl is-active nginx", timeout=180, quiet=True)
|
||||
print(" nginx: " + out[1].strip())
|
||||
|
||||
print()
|
||||
print(" ── 4. 中转改为只监听 127.0.0.1(不再直接对公网)──")
|
||||
# 公网只能通过 nginx 进来,少一个暴露面。
|
||||
out = run("sudo sed -i 's/^Environment=RELAY_BIND=.*$/"
|
||||
"Environment=RELAY_BIND=127.0.0.1/' "
|
||||
"/etc/systemd/system/ykxmz-relay.service && "
|
||||
"grep RELAY_BIND /etc/systemd/system/ykxmz-relay.service && "
|
||||
"sudo systemctl daemon-reload && "
|
||||
"sudo systemctl restart ykxmz-relay && sleep 3 && "
|
||||
"systemctl is-active ykxmz-relay", timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 验证 ──")
|
||||
script = (
|
||||
'printf " %-22s %s\\n" "nginx 直连(本机:80)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:80/v1/health)"\n'
|
||||
'printf " %-22s %s\\n" "带 Host 头" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'-H \'Host: ' + DOMAIN + '\' http://127.0.0.1:80/v1/health)"\n'
|
||||
'printf " %-22s %s\\n" "公网 80(IP)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||
'http://LS_HOST:80/v1/health)"\n'
|
||||
'printf " %-22s %s\\n" "中转本机(13245)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://127.0.0.1:13245/v1/health)"\n'
|
||||
'printf " %-22s %s\\n" "中转是否还暴露公网" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://LS_HOST:13245/v1/health)"\n'
|
||||
)
|
||||
out = run(script, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" 说明:上面「中转是否还暴露公网」应该是 000(连不上)——")
|
||||
print(" 那正说明它已经只认 127.0.0.1,公网只能走 nginx。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''服务器整理:删隧道残留 + 加 9100 监听 + 修"用 IP 访问 404"。
|
||||
|
||||
== 用户的要求 ==
|
||||
|
||||
* "为什么中转站里还有隧道,所有没的东西就删了"
|
||||
* "网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100,中转站也一样"
|
||||
* "我感觉网站还是不稳定"
|
||||
|
||||
== 查到的现状 ==
|
||||
|
||||
* `lanremote-tunnel` / `cloudflared` 两个单元 **inactive dead**,
|
||||
但**单元文件还在**(`systemctl list-units --all` 能列出来)。
|
||||
那是早年走 Cloudflare Tunnel 时留下的,现在早就换成
|
||||
nginx + Let's Encrypt 直连了 —— 属于"没的东西",该删。
|
||||
* **用 IP 直接访问 `http://<服务器IP>/` 返回 404** ——
|
||||
因为唯一的 80 站点是 `server_name yk.xxmxxs.top`,
|
||||
Host 不匹配时 nginx 拿它当 default server,转给 relay,
|
||||
relay 不认这个 Host 就 404。
|
||||
**这很可能就是"感觉不稳定"的来源**:用域名是对的,用 IP 就不对。
|
||||
* 9100 端口**服务端没监听、AWS 防火墙也没开** —— 两头都要做。
|
||||
|
||||
== 这一版做什么 ==
|
||||
|
||||
1. **删掉两个隧道单元**(先 stop + disable + 删文件 + daemon-reload)
|
||||
2. **nginx 加 9100 监听**:
|
||||
* `http://<服务器IP>:9100/` 直接出官网
|
||||
* 中转接口也在 9100 上(`/v1/...`、`/download/...`)
|
||||
⚠ 这需要 **AWS Lightsail 控制台里开放 9100**,
|
||||
只有用户能做(我没有他的 AWS 凭据)。
|
||||
3. **修 IP 访问 404**:给 80 端口加一个 default server,
|
||||
把不认识的 Host 也送到同一套后端 —— 这样 `http://IP/` 能用。
|
||||
|
||||
== 为什么 9100 用 HTTP 而不是 HTTPS ==
|
||||
|
||||
IP 地址**没法签证书**(Let's Encrypt 不给纯 IP 签)。
|
||||
所以 `https://<服务器IP>:9100` 会报证书错。
|
||||
9100 定位是"Cloudflare 出问题时的直连备用通道",
|
||||
用 HTTP 就行 —— 中转的数据本身有自己的校验。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run, upload # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------- nginx 片段
|
||||
# 在 ykxmz 站点的 443 server 里补 9100 监听,并加一个 80 的 default server。
|
||||
#
|
||||
# 为什么加在**同一个文件**里:这样只有一份 server 块要维护,
|
||||
# 后端地址(127.0.0.1:13245)只写一次,改后端不用改两处。
|
||||
EXTRA_LISTEN = """
|
||||
# ---- 9100:Cloudflare 之外的直连通道 ----
|
||||
#
|
||||
# 用户要求:"网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100"。
|
||||
#
|
||||
# ⚠ 用 IP 没法签证书(Let's Encrypt 不给纯 IP 签),所以这里是 HTTP。
|
||||
# 定位是备用通道:Cloudflare 出问题时还能直接连上。
|
||||
#
|
||||
# ⚠ 还需要在 **AWS Lightsail 控制台**开放 9100 端口 ——
|
||||
# 光配 nginx 没用,外面照样连不上(实测 9100 现在不通)。
|
||||
listen 9100;
|
||||
listen [::]:9100;
|
||||
"""
|
||||
|
||||
DEFAULT_SERVER = """
|
||||
# ======================================================================
|
||||
# 80 端口的默认站点
|
||||
# ======================================================================
|
||||
#
|
||||
# 为什么需要它:原来唯一的 80 站点是 `server_name yk.xxmxxs.top`。
|
||||
# 用 IP 直接访问(`http://<服务器IP>/`)时 Host 不匹配,
|
||||
# nginx 就拿这个站点当 default server,把请求转给 relay ——
|
||||
# relay 不认这个 Host,返回 404。
|
||||
#
|
||||
# **这很可能就是"感觉网站不稳定"的来源**:用域名是对的,用 IP 就 404,
|
||||
# 用户不知道区别,只会觉得"时好时坏"。
|
||||
#
|
||||
# 现在显式指定一个默认站点:不管什么 Host,都送到同一套后端。
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
access_log /var/log/nginx/ykxmz_default_access.log;
|
||||
error_log /var/log/nginx/ykxmz_default_error.log;
|
||||
|
||||
# 用 IP 访问时不要跳 https —— IP 没证书,跳过去浏览器会报警告,
|
||||
# 用户会以为网站坏了。直接给内容。
|
||||
client_max_body_size 64m;
|
||||
server_tokens off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:13245;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 服务器整理:删隧道 + 加 9100 + 修 IP 访问")
|
||||
print("=" * 62)
|
||||
print()
|
||||
|
||||
# ---------------------------------------------------------------- 1. 删隧道
|
||||
print("── 1. 删掉隧道残留 ──")
|
||||
r = run(r'''
|
||||
for u in lanremote-tunnel cloudflared lanremote-relay; do
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "^$u"; then
|
||||
echo " 处理 $u"
|
||||
sudo systemctl stop $u 2>/dev/null
|
||||
sudo systemctl disable $u 2>/dev/null
|
||||
sudo rm -f /etc/systemd/system/$u.service \
|
||||
/lib/systemd/system/$u.service \
|
||||
/etc/systemd/system/multi-user.target.wants/$u.service 2>/dev/null
|
||||
fi
|
||||
done
|
||||
# 还有没有别的相关残留
|
||||
sudo find /etc/systemd/system /lib/systemd/system -maxdepth 2 \
|
||||
-iname "*lanremote*" -o -iname "*cloudflared*" -o -iname "*tunnel*" 2>/dev/null \
|
||||
| while read f; do echo " 删 $f"; sudo rm -f "$f"; done
|
||||
sudo systemctl daemon-reload
|
||||
echo " --- 之后还剩什么 ---"
|
||||
systemctl list-units --all --no-pager --plain 2>/dev/null \
|
||||
| grep -iE 'lanremote|cloudflared|tunnel' | sed 's/^/ /' || echo " (干净了)"
|
||||
''', timeout=180, quiet=True)
|
||||
print(r[1] if isinstance(r, tuple) else r)
|
||||
|
||||
# ---------------------------------------------------------------- 2. 改 nginx
|
||||
print()
|
||||
print("── 2. nginx:加 9100 监听 + 80 默认站点 ──")
|
||||
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
|
||||
conf = r[1] if isinstance(r, tuple) else str(r)
|
||||
if "listen 9100" in conf:
|
||||
print(" 9100 已经配过了")
|
||||
else:
|
||||
# 在 443 那个 server 块的 listen 后面插 9100
|
||||
anchor = " listen 443 ssl; # managed by Certbot"
|
||||
if anchor not in conf:
|
||||
# 换一个可能的形式
|
||||
import re
|
||||
m = re.search(r"\n(\s*)listen 443[^\n]*\n", conf)
|
||||
if not m:
|
||||
print(" ✗ 找不到 443 的 listen 行")
|
||||
return 1
|
||||
anchor = m.group(0).strip()
|
||||
conf = conf.replace(anchor, anchor + "\n" + EXTRA_LISTEN.rstrip(),
|
||||
1)
|
||||
conf = conf.rstrip() + "\n" + DEFAULT_SERVER
|
||||
|
||||
tmp = ROOT / "_build" / "_nginx_ykxmz.conf"
|
||||
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||
upload(str(tmp), "/tmp/ykxmz.conf.new")
|
||||
tmp.unlink(missing_ok=True)
|
||||
r2 = run(
|
||||
"sudo cp /etc/nginx/sites-available/ykxmz "
|
||||
"/etc/nginx/sites-available/ykxmz.bak.$(date +%s) && "
|
||||
"sudo mv /tmp/ykxmz.conf.new /etc/nginx/sites-available/ykxmz && "
|
||||
"sudo nginx -t 2>&1 | tail -3", timeout=180, quiet=True)
|
||||
out = r2[1] if isinstance(r2, tuple) else str(r2)
|
||||
print(" " + out.replace("\n", "\n "))
|
||||
if "successful" not in out:
|
||||
print(" ✗ 配置有错,撤回")
|
||||
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.* "
|
||||
"| head -1) /etc/nginx/sites-available/ykxmz", timeout=90,
|
||||
quiet=True)
|
||||
return 1
|
||||
print(" ✓ 已写入并备份")
|
||||
|
||||
print()
|
||||
print("── 3. 重载 nginx ──")
|
||||
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
|
||||
timeout=120, quiet=True)
|
||||
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
|
||||
|
||||
# ---------------------------------------------------------------- 4. 复核
|
||||
print()
|
||||
print("── 4. 复核 ──")
|
||||
r = run(r'''
|
||||
echo " --- 监听端口 ---"
|
||||
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100) ' | sed 's/^/ /'
|
||||
echo " --- 用 IP 访问(不带 Host)---"
|
||||
curl -s -o /dev/null -w " http://127.0.0.1/ -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
|
||||
curl -s -o /dev/null -w " http://127.0.0.1:9100/ -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/
|
||||
echo " --- 用域名访问 ---"
|
||||
curl -s -o /dev/null -w " https://yk.xxmxxs.top/ -> %{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
|
||||
curl -s -o /dev/null -w " /v1/health -> %{http_code}\n" --max-time 15 https://yk.xxmxxs.top/v1/health
|
||||
''', timeout=180, quiet=True)
|
||||
print(r[1] if isinstance(r, tuple) else r)
|
||||
|
||||
print()
|
||||
print(" ⚠ 还需要你在 **AWS Lightsail 控制台**里开放 9100 端口:")
|
||||
print(" 实例 -> 联网 -> IPv4 防火墙 -> 添加规则 -> 自定义 -> TCP 9100")
|
||||
print(" 光配 nginx 没用,外面照样连不上。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给源站签一张 Let's Encrypt 证书,让 Cloudflare 的 SSL 能走"完全(严格)"。
|
||||
|
||||
== 522 是怎么来的 ==
|
||||
|
||||
DNS 配好、橙色云朵生效之后:
|
||||
|
||||
http://yk.xxmxxs.top/ -> 200 (经 Cloudflare,反代是好的)
|
||||
https://yk.xxmxxs.top/ -> 522 (Cloudflare 连不上源站)
|
||||
yk.xxmxxs.top:443 -> 不通(源站没听 443)
|
||||
|
||||
结论:zone 的 SSL/TLS 模式是 **Full** 或 **Full (strict)**,
|
||||
Cloudflare 回源走 443,而源站只有 80 -> 522。
|
||||
|
||||
== 两种解法 ==
|
||||
|
||||
A. 把 SSL/TLS 改成 **Flexible**
|
||||
一个下拉框搞定。CF<->用户加密,**CF<->源站明文**。
|
||||
省事,但源站那段不加密 —— 而且 Cloudflare 官方不建议
|
||||
(源站流量在 CF 内网走,风险不大,但"完全"更好)。
|
||||
|
||||
B. 源站装证书,保持 **Full (strict)** <- 这个脚本做的
|
||||
端到端加密,还能开 HSTS 之类。多花两分钟,值得。
|
||||
|
||||
== 为什么开着橙色云朵也能签 Let's Encrypt ==
|
||||
|
||||
HTTP-01 验证是:Let's Encrypt 去请求
|
||||
http://yk.xxmxxs.top/.well-known/acme-challenge/<随机串>
|
||||
这条请求会经过 Cloudflare 回源到我们的 80 端口。
|
||||
Cloudflare 默认**不缓存** .well-known 路径、也会透传,所以能验证成功。
|
||||
(这比"先关云朵签完再开"省事,也不用等 DNS 传播。)
|
||||
|
||||
== 签完还要做的 ==
|
||||
|
||||
1. 用户在 Lightsail 防火墙**开 443**
|
||||
2. 用户把 Cloudflare 的 SSL/TLS 设为 **Full (strict)**
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
EMAIL = "xmz7891@example.com" # Let's Encrypt 要一个邮箱(过期提醒)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 给 %s 签 Let's Encrypt 证书" % DOMAIN)
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 装 certbot ──")
|
||||
out = run("which certbot >/dev/null 2>&1 && echo ALREADY || "
|
||||
"(sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive "
|
||||
"apt-get install -y -qq certbot python3-certbot-nginx) && "
|
||||
"certbot --version 2>&1", timeout=900, quiet=True)
|
||||
for ln in out[1].strip().splitlines()[-3:]:
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 先确认 HTTP-01 挑战路径能通(走 Cloudflare)──")
|
||||
out = run(
|
||||
"mkdir -p /var/www/html/.well-known/acme-challenge && "
|
||||
"echo 'ykxmz-acme-ok' | sudo tee "
|
||||
"/var/www/html/.well-known/acme-challenge/probe >/dev/null && "
|
||||
"sudo chown -R www-data:www-data /var/www/html 2>/dev/null; "
|
||||
"echo ' 本机:'; curl -s --max-time 8 "
|
||||
"http://127.0.0.1:80/.well-known/acme-challenge/probe | sed 's/^/ /'; "
|
||||
"echo ' 经 Cloudflare:'; curl -s --max-time 15 "
|
||||
"http://%s/.well-known/acme-challenge/probe | sed 's/^/ /'"
|
||||
% DOMAIN, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 签证书 ──")
|
||||
# --nginx 会自动改 nginx 配置加上 443 + 证书路径
|
||||
# --non-interactive 不交互;--agree-tos 同意条款
|
||||
# --redirect 把 http 永久重定向到 https
|
||||
out = run(
|
||||
"sudo certbot --nginx -d %s --non-interactive --agree-tos "
|
||||
"--email %s --redirect --keep-until-expiring 2>&1 | tail -20"
|
||||
% (DOMAIN, EMAIL), timeout=600, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 复查 nginx 是否已在 443 上 ──")
|
||||
out = run(
|
||||
"sudo nginx -t 2>&1 | tail -2; "
|
||||
"echo ' --- 监听 ---'; "
|
||||
"ss -tln 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'; "
|
||||
"echo ' --- 证书 ---'; "
|
||||
"sudo ls -la /etc/letsencrypt/live/%s/ 2>/dev/null | tail -5 | "
|
||||
"sed 's/^/ /'; "
|
||||
"echo ' --- 本机 https 自测 ---'; "
|
||||
"curl -sk -o /dev/null -w ' https://127.0.0.1 (SNI %s): %%{http_code}\\n' "
|
||||
"--max-time 10 --resolve %s:443:127.0.0.1 https://%s/v1/health"
|
||||
% (DOMAIN, DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 自动续期 ──")
|
||||
out = run("systemctl is-enabled certbot.timer 2>&1; "
|
||||
"systemctl is-active certbot.timer 2>&1; "
|
||||
"sudo certbot renew --dry-run 2>&1 | tail -3",
|
||||
timeout=600, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 接下来要用户做两件事:")
|
||||
print(" 1. Lightsail 防火墙开 TCP 443")
|
||||
print(" 2. Cloudflare 的 SSL/TLS 设为「完全(严格)」")
|
||||
print("=" * 62)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -22,6 +22,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 从环境变量或 _build/.admin_password(已 gitignore)读管理密码。
|
||||
# 不再写死在脚本里 —— 这个仓库是公开的。
|
||||
def _admin_password() -> str:
|
||||
import os
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8110
|
||||
@@ -69,7 +84,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(jar))
|
||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
||||
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
@@ -15,6 +15,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 从环境变量或 _build/.admin_password(已 gitignore)读管理密码。
|
||||
# 不再写死在脚本里 —— 这个仓库是公开的。
|
||||
def _admin_password() -> str:
|
||||
import os
|
||||
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||
if env:
|
||||
return env
|
||||
f = Path(__file__).resolve().parent / ".admin_password"
|
||||
if f.is_file():
|
||||
return f.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8113
|
||||
@@ -60,7 +75,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(jar))
|
||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
||||
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user