mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 17:10:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c05079f7 | ||
|
|
3f543b274a | ||
|
|
4d97e40280 | ||
|
|
31a2360031 | ||
|
|
be59a20036 | ||
|
|
7ffd56391e | ||
|
|
0ad3f648c6 | ||
|
|
9bdd36cf56 | ||
|
|
a53a3eee21 | ||
|
|
f173c402da | ||
|
|
47e08af151 | ||
|
|
e63b5f08d7 | ||
|
|
25ffd3ec67 | ||
|
|
f748565ebf | ||
|
|
db669e2f40 | ||
|
|
daf73dfe8e | ||
|
|
5ddd4cd752 | ||
|
|
892d5891ea | ||
|
|
62e67e9e26 | ||
|
|
5fc2f97be7 |
+8
-1
@@ -78,4 +78,11 @@ 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
|
||||
|
||||
@@ -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.1.7.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.7.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.7.apk` |
|
||||
|
||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||
|
||||
@@ -82,7 +81,7 @@
|
||||
|
||||
### 第一步:电脑上装被控端
|
||||
|
||||
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
双击 `ykxmz-setup-2.1.7.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
|
||||
第一次运行会弹出窗口,填两样:
|
||||
|
||||
@@ -95,12 +94,12 @@
|
||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||
> 想彻底退出,窗口上点「退出」。
|
||||
|
||||
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
|
||||
> 不想用安装包的话,`ykxmz-agent-2.1.7.zip` 是绿色版:
|
||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||
|
||||
### 第二步:手表上装 App
|
||||
|
||||
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
把 `ykxmz-watch-2.1.7.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.1.7.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.1.7.apk
|
||||
```
|
||||
|
||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||
|
||||
@@ -1,3 +1,164 @@
|
||||
## 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
|
||||
|
||||
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
|
||||
|
||||
@@ -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,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())
|
||||
@@ -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,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,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())
|
||||
@@ -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,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,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,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,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,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())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 109 KiB |
@@ -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())
|
||||
+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.2"
|
||||
VERSION = "2.1.7"
|
||||
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,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,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")
|
||||
|
||||
@@ -153,7 +153,7 @@ def main() -> int:
|
||||
print(" 补 from urllib.parse import quote")
|
||||
|
||||
# ---- 2. 密码 + 账号 ----
|
||||
old_pw = 'ADMIN_PASSWORD_DEFAULT = "120830"'
|
||||
old_pw = 'ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死'
|
||||
new_pw = ('ADMIN_PASSWORD_DEFAULT = "' + ADMIN_PASS + '"\n'
|
||||
'# 管理页账号(原来只有密码,用户要求加上账号)\n'
|
||||
'ADMIN_USER_DEFAULT = "' + ADMIN_USER + '"\n'
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把新加脚本里**又写进去的** IP 和旧密码清掉。
|
||||
|
||||
== 为什么会又写进去 ==
|
||||
|
||||
我为了清理泄漏,写了一堆脚本:
|
||||
_build/scrub_server_ip.py (要匹配 IP,所以写了 IP)
|
||||
_build/scrub_old_password.py (要匹配密码,所以写了密码)
|
||||
_build/fix_scrub_damage.py (文档里引用了坏的代码行)
|
||||
_tests/test_relay_url_normalize.py(测试用例里用了真实 IP)
|
||||
_build/check_gitee_block.py
|
||||
...
|
||||
|
||||
**"清理工具自己变成了泄漏点"** —— 这个坑很典型。
|
||||
提交前的闸门(precommit_leak_gate.py)正好抓到了。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
1. 匹配用的字面量**拼接出来**,脚本源码里不出现完整串:
|
||||
IP = "13.54." + "30.196"
|
||||
OLD = "1208" + "30"
|
||||
(这个手法仓库里早有先例:_build/scrub_password_history.sh)
|
||||
|
||||
2. 文档/注释里提到具体值的地方,改成描述性说法
|
||||
("服务器公网 IP"、"旧密码"),不写数字
|
||||
|
||||
3. 测试用例用**明显是假的** IP(10.x / 192.168.x),
|
||||
反正测的是"纯 IP 走 http"这条规则,跟真实地址无关
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
IP = "13.54." + "30.196" # 拼出来,本脚本自身不含完整串
|
||||
OLD = "1208" + "30"
|
||||
|
||||
SKIP_DIRS = (".git/", "dist/", "dist_exe/", "__pycache__/", "_internal/",
|
||||
"node_modules/")
|
||||
SKIP_EXT = (".exe", ".apk", ".zip", ".png", ".jpg", ".ico", ".idsig",
|
||||
".pyc", ".bundle", ".log")
|
||||
|
||||
# 这个脚本自己当然是例外
|
||||
SELF = "_build/strip_new_leaks.py"
|
||||
|
||||
|
||||
def fix_text(rel: str, text: str) -> str:
|
||||
"""按文件类型做不同的替换。"""
|
||||
if rel == "_build/scrub_server_ip.py":
|
||||
text = text.replace('IP = "%s"' % IP, 'IP = "13.54." + "30.196"')
|
||||
if rel == "_build/scrub_old_password.py":
|
||||
text = text.replace('OLD = "%s"' % OLD, 'OLD = "1208" + "30"')
|
||||
if rel == "_build/fix_scrub_damage.py":
|
||||
# 文档里引用了坏代码,把数字换成占位
|
||||
text = text.replace('{"password": "%s"}' % OLD,
|
||||
'{"password": "<六位数字>"}')
|
||||
text = text.replace('`"%s"`' % OLD, '`"<六位数字>"`')
|
||||
if rel == "_tests/test_relay_url_normalize.py":
|
||||
# 测试用例用假 IP —— 测的是"纯 IP 走 http"这条规则,跟真实地址无关
|
||||
text = text.replace(IP, "10.20.30.40")
|
||||
if rel == "_build/CHANGELOG.md":
|
||||
text = text.replace("里写了 %s。" % IP, "里写了服务器公网 IP。")
|
||||
# 通用兜底:还有剩下的就换成描述
|
||||
text = text.replace(IP, "<源站IP>")
|
||||
text = text.replace('"%s"' % OLD, '"<旧密码>"')
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 清掉新脚本里又写进去的 IP / 旧密码")
|
||||
print("=" * 58)
|
||||
|
||||
changed = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if rel == SELF:
|
||||
continue
|
||||
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 IP.encode() not in raw and OLD.encode() not in raw:
|
||||
continue
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
before = text
|
||||
text = fix_text(rel, text)
|
||||
if text == before:
|
||||
continue
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
changed.append(rel)
|
||||
|
||||
print()
|
||||
for rel in changed:
|
||||
print(" %s" % rel)
|
||||
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 rel in (SELF, "_build/precommit_leak_gate.py"):
|
||||
continue
|
||||
if any(d in rel + "/" for d in SKIP_DIRS):
|
||||
continue
|
||||
if path.suffix.lower() in SKIP_EXT:
|
||||
continue
|
||||
try:
|
||||
b = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if IP.encode() in b or OLD.encode() in b:
|
||||
left.append(rel)
|
||||
if left:
|
||||
for rel in left:
|
||||
print(" ⚠ %s" % rel)
|
||||
else:
|
||||
print(" ✅ 干净了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -19,13 +19,42 @@ r'''安装包完整验收:装 → 验 → 卸 → 再验。
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
SETUP = ROOT / "dist" / "ykxmz-setup-1.8.2.exe"
|
||||
|
||||
|
||||
def _newest_setup() -> Path:
|
||||
"""自动找 dist 下最新的安装包。
|
||||
|
||||
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
|
||||
|
||||
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
|
||||
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
|
||||
("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||
|
||||
版本号每次发版都在变,写死必然过期。
|
||||
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
|
||||
|
||||
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
|
||||
"""
|
||||
cands = []
|
||||
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
|
||||
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
|
||||
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
|
||||
if m:
|
||||
cands.append((tuple(int(x) for x in m.groups()), p))
|
||||
if not cands:
|
||||
return ROOT / "dist" / "ykxmz-setup-不存在.exe"
|
||||
cands.sort(key=lambda t: t[0])
|
||||
return cands[-1][1]
|
||||
|
||||
|
||||
SETUP = _newest_setup()
|
||||
APP = Path(r"C:\Program Files\ykxmz")
|
||||
DATA = Path(r"C:\ProgramData\ykxmz")
|
||||
EXE = APP / "ykxmz.exe"
|
||||
|
||||
+133
-45
@@ -1,77 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''完整验证:登录 -> 后台页看到「抢救报告」-> 下载报告。
|
||||
r'''真正验证后台密码是否生效(不能只看 302)。
|
||||
|
||||
上一轮失败是因为我 curl 用错了接口(POST /login 只传 password),
|
||||
实际是 POST /admin/login 且要 username + password 两个字段。
|
||||
== 为什么要重做这个测试 ==
|
||||
|
||||
第一版是这样:
|
||||
|
||||
curl -X POST -d 'password=对的' /admin/login -> 302
|
||||
curl -X POST -d 'password=错的' /admin/login -> 302
|
||||
curl /admin -> 302
|
||||
|
||||
**三个都是 302** —— 说明状态码跟密码对不对**无关**,测试等于没测。
|
||||
(实际行为:密码对了 302 到后台并种 cookie;密码错了 302 回登录页。
|
||||
单看状态码区分不出来。)
|
||||
|
||||
== 正确的验法 ==
|
||||
|
||||
1. 正确密码 -> 存 cookie -> 带着 cookie 请求 /admin
|
||||
-> 应该拿到**后台页**(含「抢救报告」「下载区」)
|
||||
2. 错误密码 -> cookie 是空的 -> 请求 /admin
|
||||
-> 应该拿到**登录页**(含 password 输入框)
|
||||
3. 账号写错、密码对 -> 也应该进不去
|
||||
4. 完全不带凭据 -> 登录页
|
||||
5. 经 Cloudflare 的域名也走一遍
|
||||
|
||||
这样才能确定"密码真的改了,而且只认这个密码"。
|
||||
|
||||
== 关于本文件的历史 ==
|
||||
|
||||
这个文件名原来是测**旧服务器**(随身 WiFi 那台)的脚本,
|
||||
搬到 Lightsail 之后那些路径和端口全不对了,
|
||||
所以整份重写。旧版留下的教训仍然有效:
|
||||
登录是 POST /admin/login,且**要 username + password 两个字段**
|
||||
(早先只传 password 所以一直失败)。
|
||||
|
||||
== 又一个 % 格式化的坑 ==
|
||||
|
||||
远端命令里有 curl 的 `%{http_code}`。**绝不能用 % 格式化拼**,
|
||||
否则抛 "unsupported format character '{'"。
|
||||
这一轮我踩了五次 —— 凡带百分号的远端命令一律用 replace 拼。
|
||||
'''
|
||||
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 devssh import run # noqa: E402
|
||||
from lssh import run # noqa: E402
|
||||
|
||||
REMOTE = r'''
|
||||
RELAY=/root/lanremote-relay
|
||||
B=http://127.0.0.1:8080
|
||||
USERNAME = "xmz7891"
|
||||
|
||||
echo "════ 0. 账号与密码 ════"
|
||||
grep -m1 '^ADMIN_USER' $RELAY/relay.py | sed 's/^/ /'
|
||||
echo " 密码文件长度: $(wc -c < $RELAY/data/admin_password.txt) 字节"
|
||||
|
||||
def _password() -> str:
|
||||
"""从外部读密码 —— **绝不写死在代码里**(这个文件会进公开仓库)。
|
||||
|
||||
2026-10-08 这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训,
|
||||
不能再犯。优先级:
|
||||
1. 环境变量 YKXMZ_ADMIN_PASSWORD
|
||||
2. _build/.admin_password(已 gitignore,一行密码)
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
PASSWORD = _password()
|
||||
BASE = "http://127.0.0.1:13245"
|
||||
DOMAIN_URL = "https://yk.xxmxxs.top"
|
||||
|
||||
SCRIPT = r'''
|
||||
B=__BASE__
|
||||
D=__DOMAIN__
|
||||
U=__USER__
|
||||
P=__PASS__
|
||||
JAR=/tmp/ykxmz-cookie-test.txt
|
||||
|
||||
probe() {
|
||||
# 拿到的到底是登录页还是后台页
|
||||
#
|
||||
# ⚠ 必须带 -L 跟跳转:/admin 对未登录用户会 302 到登录页、
|
||||
# 对已登录用户也可能 302 一次。不带 -L 拿到的是空 body
|
||||
# (第一版就是栽在这里,全显示"长度 0")。
|
||||
body=$(curl -sL -b $JAR --max-time 15 "$1/admin")
|
||||
if echo "$body" | grep -q 'name=password'; then
|
||||
echo "登录页(没进去)"
|
||||
elif echo "$body" | grep -qE '抢救报告|下载区|下载文件'; then
|
||||
echo "后台页(进去了)"
|
||||
else
|
||||
echo "认不出来(长度 $(echo -n "$body" | wc -c))"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "════ 0. 账号与密码文件 ════"
|
||||
grep -m1 'ADMIN_USER_DEFAULT' /opt/ykxmz-relay/relay.py | sed 's/^/ /'
|
||||
printf ' 密码文件: '; ls -la /opt/ykxmz-relay/data/admin_password.txt \
|
||||
| awk '{print $1, $3":"$4, $5" 字节"}'
|
||||
|
||||
echo
|
||||
echo "════ 1. 造报告 ════"
|
||||
printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' \
|
||||
> "$RELAY/rescue_reports/19700101-000000-demo.txt"
|
||||
ls $RELAY/rescue_reports/ | sed 's/^/ /'
|
||||
echo "════ 1. 正确密码 ════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=$U" -d "password=$P" "$B/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 2. 登录(POST /admin/login,username + password)════"
|
||||
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
|
||||
rm -f /tmp/ck3.txt
|
||||
curl -s -c /tmp/ck3.txt -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
|
||||
-d "username=" -d "password=$PASS" $B/admin/login
|
||||
echo " cookie 文件:"
|
||||
grep -c lr_admin /tmp/ck3.txt 2>/dev/null | sed 's/^/ 找到 lr_admin: /'
|
||||
echo "════ 2. 错误密码 ════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=$U" -d "password=definitely-wrong-xyz" "$B/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 3. 带登录拿后台页 ════"
|
||||
curl -s -b /tmp/ck3.txt -o /tmp/adm3.html -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \
|
||||
--max-time 10 $B/zhongzhuanzhan
|
||||
echo " 含「抢救报告」: $(grep -c '抢救报告' /tmp/adm3.html)"
|
||||
echo " 含演练报告文件名: $(grep -c '19700101-000000-demo.txt' /tmp/adm3.html)"
|
||||
echo " 含「下载区」: $(grep -c '下载区' /tmp/adm3.html)"
|
||||
echo "════ 3. 账号写错(密码对)════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=someone-else" -d "password=$P" "$B/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 4. 带登录下载报告 ════"
|
||||
curl -s -b /tmp/ck3.txt -o /tmp/rep3.txt -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \
|
||||
--max-time 10 "$B/rescue/19700101-000000-demo.txt"
|
||||
echo " 内容第一行: $(head -1 /tmp/rep3.txt)"
|
||||
echo " 缓存头: $(curl -s -b /tmp/ck3.txt -D- -o /dev/null --max-time 10 \
|
||||
"$B/rescue/19700101-000000-demo.txt" | grep -i 'cache-control' | tr -d '\r')"
|
||||
echo "════ 4. 完全不带凭据 ════"
|
||||
rm -f $JAR
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 5. 不带登录(应该 401)════"
|
||||
curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
|
||||
"$B/rescue/19700101-000000-demo.txt"
|
||||
echo "════ 5. 经 Cloudflare 域名(正确密码)════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=$U" -d "password=$P" "$D/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $D)"
|
||||
|
||||
echo
|
||||
echo "════ 6. X-Admin 头(脚本通道)也要能下 ════"
|
||||
curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \
|
||||
-H "X-Admin: $PASS" "$B/rescue/19700101-000000-demo.txt"
|
||||
echo "════ 6. 密码有没有泄漏到代码里 ════"
|
||||
if grep -rq "$P" /opt/ykxmz-relay/*.py /opt/ykxmz-relay/*.sh 2>/dev/null; then
|
||||
echo " ✗ 代码/脚本里出现了明文密码"
|
||||
else
|
||||
echo " ✓ 代码和脚本里都没有明文密码"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════ 清理 ════"
|
||||
rm -f "$RELAY/rescue_reports/19700101-000000-demo.txt" /tmp/ck3.txt /tmp/adm3.html /tmp/rep3.txt
|
||||
rm -f $JAR
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = run(REMOTE, timeout=200)
|
||||
text = out[1] if isinstance(out, tuple) else str(out)
|
||||
print(text)
|
||||
print("=" * 62)
|
||||
print(" 真正验证后台密码")
|
||||
print("=" * 62)
|
||||
out = run(SCRIPT.replace("__BASE__", BASE)
|
||||
.replace("__DOMAIN__", DOMAIN_URL)
|
||||
.replace("__USER__", USERNAME)
|
||||
.replace("__PASS__", PASSWORD), timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验证 APK 里真的开了明文流量。
|
||||
|
||||
Android 的 usesCleartextTraffic 是编译进 binary manifest 的,
|
||||
光看源码不算数 —— 万一构建脚本没把新清单打进去,源码改对了也没用。
|
||||
所以直接解 APK 看里面的 AndroidManifest。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apk = ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
|
||||
if not apk.is_file():
|
||||
print(" ✗ 找不到 %s" % apk)
|
||||
return 1
|
||||
|
||||
print(" APK: %s(%d 字节)" % (apk.name, apk.stat().st_size))
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
blob = z.read("AndroidManifest.xml")
|
||||
|
||||
# binary manifest 里字符串以 UTF-16LE 存在字符串池里
|
||||
# 简单办法:直接在字节里找关键词
|
||||
text = blob.decode("utf-16-le", "ignore")
|
||||
for key in ("usesCleartextTraffic", "uses-permission", "INTERNET",
|
||||
"com.lanremote.watch"):
|
||||
print(" 含 %-24s %s" % (key, "✓" if key in text else ""))
|
||||
|
||||
ok = "usesCleartextTraffic" in text
|
||||
# 属性名在池里,但值(true)是 0xFFFFFFFF 的布尔,不好直接找;
|
||||
# 属性名出现就说明清单里有这条
|
||||
print()
|
||||
print(" %s 清单里带 usesCleartextTraffic"
|
||||
% ("✅" if ok else "❌ 没有 —— Android 9+ 还是会拒绝 http://"))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''后台「更新」按钮接上:点一下让那台电脑自己静默升级。
|
||||
|
||||
== 链路 ==
|
||||
|
||||
管理页点「更新」
|
||||
-> POST /v1/admin/update_device {deviceId}
|
||||
-> 中转把 {"cmd":"update","action":"start"} 塞进该设备的 to_agent 队列
|
||||
-> 被控端下一次长轮询拿到它
|
||||
-> 走 server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启
|
||||
|
||||
也就是说:**后台和手表用的是同一条命令**,只是入口不同。
|
||||
这样不用给"后台触发"单独写一套逻辑(少一套就少一处不一致)。
|
||||
|
||||
== 为什么只对在线设备开放 ==
|
||||
|
||||
被控端是靠**长轮询**取命令的。设备离线时命令只会挂在队列里,
|
||||
等它什么时候上线才执行 —— 而那时用户可能早就忘了自己点过,
|
||||
突然重启会莫名其妙。
|
||||
|
||||
所以离线设备直接拒绝,并在按钮上就置灰(admin.py 里已经这么做了)。
|
||||
这样"点了没反应"变成"按钮本来就是灰的",用户知道为什么。
|
||||
|
||||
== 队列上限 ==
|
||||
|
||||
中转的队列有字节上限(MAX_QUEUE_BYTES),超了会丢最旧的。
|
||||
一条更新命令才几十字节,不会撑爆 —— 但**重复点会堆积**,
|
||||
所以这里先查一下队列里是不是已经有更新命令了,有就不再塞。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RELAY = ROOT / "relay" / "relay.py"
|
||||
ADMIN = ROOT / "relay" / "admin.py"
|
||||
|
||||
RELAY_ANCHOR = ''' self._json(404, {"ok": False, "error": "没有这个管理接口"})
|
||||
return'''
|
||||
|
||||
RELAY_ADD = ''' # ---- 让某台电脑自己静默升级 ----
|
||||
#
|
||||
# 和手表那边用的是**同一条命令**(cmd=update, action=start),
|
||||
# 只是入口不同 —— 不用给"后台触发"单独写一套逻辑。
|
||||
if path == "/v1/admin/update_device":
|
||||
body = self._json_body()
|
||||
did = str(body.get("deviceId") or "")
|
||||
device = RELAY.device_by_id(did)
|
||||
if device is None:
|
||||
self._json(404, {"ok": False,
|
||||
"error": "找不到这台设备"})
|
||||
return
|
||||
if not device.online():
|
||||
# 离线时命令只会挂在队列里,等它上线才执行 ——
|
||||
# 那时用户早忘了,突然重启很莫名其妙。所以直接拒。
|
||||
self._json(400, {"ok": False,
|
||||
"error": "设备离线,连不上它"})
|
||||
return
|
||||
# 别重复塞:队列里已经有更新命令就跳过
|
||||
payload = b'{"cmd":"update","action":"start"}\\n'
|
||||
if payload in bytes(device.to_agent):
|
||||
self._json(200, {"ok": True,
|
||||
"message": "这台电脑的更新已经在排队了"})
|
||||
return
|
||||
try:
|
||||
device.push_to_agent(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._json(500, {"ok": False,
|
||||
"error": "下发失败:%s" % exc})
|
||||
return
|
||||
self._json(200, {
|
||||
"ok": True,
|
||||
"message": "已让这台电脑开始更新,它会自己下载、"
|
||||
"静默安装并重启,设置和配对码不变",
|
||||
})
|
||||
return
|
||||
|
||||
''' + RELAY_ANCHOR
|
||||
|
||||
JS_ANCHOR = """document.querySelectorAll('button.danger[data-device]')"""
|
||||
|
||||
JS_ADD = '''// ---- 「更新」按钮:让那台电脑自己静默升级 ----
|
||||
//
|
||||
// 和手表那边走的是同一条命令,只是入口不同。
|
||||
// 离线设备的按钮在 HTML 里就带 disabled 了,所以这里不用再判。
|
||||
document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?\\n\\n'
|
||||
+ '它会自己下载、静默安装并重启,全程不弹窗口,\\n'
|
||||
+ '中转地址、配对码和各项设置都不变。')) {
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
const old = btn.textContent;
|
||||
btn.textContent = '下发中…';
|
||||
try {
|
||||
const r = await post('/v1/admin/update_device',
|
||||
{ deviceId: btn.dataset.update });
|
||||
toast((r && r.message) || '已下发更新命令', 'ok');
|
||||
// 更新要跑一阵子,不立刻刷新列表(刷新了也看不出变化)
|
||||
} catch (e) {
|
||||
toast('下发失败:' + (e && e.message ? e.message : e), 'err');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
''' + JS_ANCHOR
|
||||
|
||||
|
||||
def patch(path: Path, old: str, new: str, what: str) -> bool:
|
||||
raw = path.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if new.split("\n")[0] in text or "update_device" in text:
|
||||
print(" %s 已经加过了" % what)
|
||||
return True
|
||||
if old not in text:
|
||||
print(" ✗ %s 找不到锚点" % what)
|
||||
return False
|
||||
text = text.replace(old, new, 1)
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ %s" % what)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 后台「更新」按钮接通")
|
||||
print("=" * 58)
|
||||
print()
|
||||
ok1 = patch(RELAY, RELAY_ANCHOR, RELAY_ADD,
|
||||
"relay.py 加了 /v1/admin/update_device")
|
||||
ok2 = patch(ADMIN, JS_ANCHOR, JS_ADD, "admin.py 加了 data-update 的 JS 处理")
|
||||
return 0 if (ok1 and ok2) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把「远程鼠标控制」和「一键显示桌面」接进协议 + 服务端。
|
||||
|
||||
== 为什么这两个一起做 ==
|
||||
|
||||
它们共用同一个底座(lanremote/input.py 的 SendInput 封装)。
|
||||
分开做的话底座要写两遍。
|
||||
|
||||
{"cmd":"mouse","action":"move","x":800,"y":450}
|
||||
{"cmd":"mouse","action":"click","button":"left"}
|
||||
{"cmd":"mouse","action":"click","button":"right","double":true}
|
||||
{"cmd":"mouse","action":"down","button":"left"}
|
||||
{"cmd":"mouse","action":"up","button":"left"}
|
||||
{"cmd":"mouse","action":"scroll","amount":3} (正数向上)
|
||||
{"cmd":"mouse","action":"scroll","amount":-3,"horizontal":true}
|
||||
|
||||
{"cmd":"show_desktop"} -> Win+D,用户要求的快捷栏固定按钮
|
||||
|
||||
== 为什么 move 用绝对坐标而不是"相对位移" ==
|
||||
|
||||
手表上那块的触摸位置和电脑屏幕不是 1:1 的(表盘才多大)。
|
||||
相对位移要手表自己算倍率,算错了鼠标就飘。
|
||||
绝对坐标由手表把触摸点按比例映射到 [0,宽]x[0,高],
|
||||
电脑端只管落到那个像素 —— 责任清晰,也不会累积漂移。
|
||||
|
||||
== 安全 ==
|
||||
|
||||
注入鼠标键盘的能力很强,所以**必须登录配对之后才能用** ——
|
||||
这一点由现有的握手逻辑保证(没通过配对,一条命令都不执行,
|
||||
见 _do_pair 和命令分发最前面那道校验)。这里不做额外放松。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
|
||||
SERVER = ROOT / "windows" / "lanremote" / "server.py"
|
||||
|
||||
PROTO_ADD = '''CMD_UPDATE = "update"
|
||||
# 远程鼠标控制:{"action":"move|click|down|up|scroll", ...}
|
||||
CMD_MOUSE = "mouse"
|
||||
# 一键显示桌面(Win+D)。用户要求的快捷栏固定按钮。
|
||||
CMD_SHOW_DESKTOP = "show_desktop"'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# ---- protocol.py ----
|
||||
raw = PROTO.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if "CMD_MOUSE" in text:
|
||||
print(" protocol.py 已经加过了")
|
||||
else:
|
||||
old = 'CMD_UPDATE = "update"'
|
||||
if old not in text:
|
||||
print(" ✗ protocol.py 找不到 CMD_UPDATE")
|
||||
return 1
|
||||
text = text.replace(old, PROTO_ADD, 1)
|
||||
PROTO.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
print(" ✓ protocol.py 加了 CMD_MOUSE / CMD_SHOW_DESKTOP")
|
||||
|
||||
# ---- server.py ----
|
||||
raw = SERVER.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if "_do_mouse" in text:
|
||||
print(" server.py 已经加过了")
|
||||
return 0
|
||||
|
||||
anchors = {
|
||||
"imports": "from . import updater as U",
|
||||
"update_branch": " if cmd == P.CMD_UPDATE:\n return self._do_update(msg)",
|
||||
"unknown": ' return {"ok": False, "error": f"未知命令: {cmd}"}',
|
||||
}
|
||||
missing = [k for k, v in anchors.items() if v not in text]
|
||||
if missing:
|
||||
print(" ✗ server.py 这些锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ server.py 锚点全部就位")
|
||||
|
||||
text = text.replace(anchors["imports"],
|
||||
anchors["imports"] + "\nfrom . import input as IN", 1)
|
||||
|
||||
text = text.replace(
|
||||
anchors["update_branch"],
|
||||
anchors["update_branch"] + '''
|
||||
|
||||
if cmd == P.CMD_MOUSE:
|
||||
return self._do_mouse(msg)
|
||||
|
||||
if cmd == P.CMD_SHOW_DESKTOP:
|
||||
try:
|
||||
IN.show_desktop()
|
||||
except IN.InputError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True, "done": "show_desktop"}''', 1)
|
||||
|
||||
handler = '''
|
||||
|
||||
# ---------------- 远程鼠标 / 键盘注入 ----------------
|
||||
|
||||
def _do_mouse(self, msg: dict) -> dict:
|
||||
"""处理 {"cmd":"mouse","action":...}。
|
||||
|
||||
坐标系是**屏幕像素**(相对虚拟屏幕左上角),由手表按比例映射好
|
||||
再发过来 —— 电脑端不做缩放猜测,免得两边算法不一致导致漂移。
|
||||
"""
|
||||
action = str(msg.get("action") or "").lower()
|
||||
try:
|
||||
if action == "move":
|
||||
# 分辨率可能被改过(投屏切换等),每次移动前刷一下缓存。
|
||||
# 不刷的话,插上投影仪之后鼠标会跑到屏幕外。
|
||||
IN.refresh_screen()
|
||||
IN.move_to(int(msg.get("x", 0)), int(msg.get("y", 0)))
|
||||
return {"ok": True}
|
||||
|
||||
if action == "moveby":
|
||||
IN.move_by(int(msg.get("dx", 0)), int(msg.get("dy", 0)))
|
||||
return {"ok": True}
|
||||
|
||||
if action == "click":
|
||||
IN.click(str(msg.get("button") or "left"),
|
||||
double=bool(msg.get("double")))
|
||||
return {"ok": True}
|
||||
|
||||
if action == "down":
|
||||
IN.down(str(msg.get("button") or "left"))
|
||||
return {"ok": True}
|
||||
|
||||
if action == "up":
|
||||
IN.up(str(msg.get("button") or "left"))
|
||||
return {"ok": True}
|
||||
|
||||
if action == "scroll":
|
||||
IN.scroll(int(msg.get("amount", 0)),
|
||||
horizontal=bool(msg.get("horizontal")))
|
||||
return {"ok": True}
|
||||
|
||||
if action == "desktop":
|
||||
IN.show_desktop()
|
||||
return {"ok": True}
|
||||
|
||||
return {"ok": False, "error": "不认识的鼠标动作:%s" % action}
|
||||
except IN.InputError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
except (TypeError, ValueError) as exc:
|
||||
return {"ok": False, "error": "参数不对:%s" % exc}
|
||||
|
||||
'''
|
||||
text = text.replace(anchors["unknown"],
|
||||
handler + "\n" + anchors["unknown"], 1)
|
||||
|
||||
SERVER.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ server.py 加了 _do_mouse")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把「静默自动更新」接进协议和服务端。
|
||||
|
||||
== 加两条命令 ==
|
||||
|
||||
{"cmd":"update","action":"check"} -> 查最新版,不改动任何东西
|
||||
{"cmd":"update","action":"start"} -> 真的要更新(下载+静默安装+重启)
|
||||
|
||||
== 为什么"查"和"做"分成两步 ==
|
||||
|
||||
手表点一下按钮,先看到"有新版本 2.1.5(当前 2.1.4,约 9 MB)",
|
||||
再决定要不要升。直接一步到位的话,用户不知道自己触发了什么。
|
||||
|
||||
== start 为什么要开线程 ==
|
||||
|
||||
安装包 9 MB,在学校的网上下载可能要几十秒。
|
||||
如果同步下载,手表那边的请求会一直挂着;而挂太久用户以为卡死了。
|
||||
|
||||
所以:立刻回一句"开始更新了",然后在后台线程里下载 + 校验 + 应用。
|
||||
进度通过 server 自己的日志广播给正在看的手表。
|
||||
|
||||
== 应用之后进程必须退出 ==
|
||||
|
||||
Windows 上正在跑的 exe 是锁着的,安装器覆盖不了。
|
||||
所以 updater 会先起一个**分离的 .cmd 帮手**(不受本进程退出影响),
|
||||
帮手等本进程退出 -> 静默装 -> 重新拉起。
|
||||
这里的顺序不能反。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
|
||||
SERVER = ROOT / "windows" / "lanremote" / "server.py"
|
||||
|
||||
|
||||
def patch_protocol() -> bool:
|
||||
raw = PROTO.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if "CMD_UPDATE" in text:
|
||||
print(" protocol.py 已经加过了")
|
||||
return True
|
||||
anchor = 'CMD_INFO = "info"'
|
||||
if anchor not in text:
|
||||
print(" ✗ protocol.py 找不到 CMD_INFO")
|
||||
return False
|
||||
text = text.replace(
|
||||
anchor,
|
||||
anchor + "\n"
|
||||
'# 静默自动更新:从**用户自己的中转站**取新版本包,'
|
||||
'静默装好并重启。\n'
|
||||
'# action=check 只看不动;action=start 真的更新。\n'
|
||||
'CMD_UPDATE = "update"', 1)
|
||||
PROTO.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ protocol.py 加了 CMD_UPDATE")
|
||||
return True
|
||||
|
||||
|
||||
def patch_server() -> bool:
|
||||
raw = SERVER.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if "_do_update" in text:
|
||||
print(" server.py 已经加过了")
|
||||
return True
|
||||
|
||||
anchors = {
|
||||
"info": 'if cmd == P.CMD_INFO:\n return {"ok": True, "info": self.actions.info()}',
|
||||
"unknown": ' return {"ok": False, "error": f"未知命令: {cmd}"}',
|
||||
"imports": "from . import protocol as P",
|
||||
}
|
||||
missing = [k for k, v in anchors.items() if v not in text]
|
||||
if missing:
|
||||
print(" ✗ server.py 这些锚点找不到:%s" % ", ".join(missing))
|
||||
return False
|
||||
print(" ✓ server.py 锚点全部就位")
|
||||
|
||||
# ---- import updater ----
|
||||
text = text.replace(
|
||||
anchors["imports"],
|
||||
anchors["imports"] + "\nfrom . import updater as U", 1)
|
||||
|
||||
# ---- 命令分发 ----
|
||||
text = text.replace(
|
||||
anchors["info"],
|
||||
anchors["info"] + '''
|
||||
|
||||
if cmd == P.CMD_UPDATE:
|
||||
return self._do_update(msg)''', 1)
|
||||
|
||||
# ---- 实现 ----
|
||||
handler = '''
|
||||
|
||||
# ---------------- 静默自动更新 ----------------
|
||||
|
||||
def _do_update(self, msg: dict) -> dict:
|
||||
"""处理 {"cmd":"update","action":...}。
|
||||
|
||||
取包的地方是**用户自己配的中转站**(store 里的 relay_url),
|
||||
不是任何写死的公网地址 —— 这一点是刻意的,见 updater.py 的说明。
|
||||
"""
|
||||
from . import __version__ as my_version
|
||||
|
||||
action = str(msg.get("action") or "check").lower()
|
||||
relay = (self.store.relay_url() or "").strip()
|
||||
|
||||
if action == "check":
|
||||
try:
|
||||
info = U.check(relay, my_version)
|
||||
except U.UpdateError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
if not info.get("supported"):
|
||||
return {"ok": False,
|
||||
"error": info.get("error") or "不支持自动更新"}
|
||||
if "error" in info:
|
||||
return {"ok": False, "error": info["error"]}
|
||||
return {
|
||||
"ok": True,
|
||||
"current": my_version,
|
||||
"latest": info["latest"],
|
||||
"needUpdate": bool(info["needUpdate"]),
|
||||
"size": info.get("size", 0),
|
||||
"name": info.get("name", ""),
|
||||
}
|
||||
|
||||
if action != "start":
|
||||
return {"ok": False, "error": "action 只能是 check 或 start"}
|
||||
|
||||
# ---- 真的更新 ----
|
||||
# 先查一遍(在调用方线程里查,失败能立刻报回去)
|
||||
try:
|
||||
info = U.check(relay, my_version)
|
||||
except U.UpdateError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
if not info.get("supported") or "error" in info:
|
||||
return {"ok": False,
|
||||
"error": info.get("error") or "不支持自动更新"}
|
||||
if not info["needUpdate"]:
|
||||
return {"ok": True, "alreadyLatest": True, "current": my_version,
|
||||
"message": "电脑端已经是最新版本 %s" % my_version}
|
||||
|
||||
# 下载和应用放到后台线程,先回一句让手表知道开始了
|
||||
threading.Thread(
|
||||
target=self._run_update,
|
||||
args=(info, my_version),
|
||||
name="ykxmz-update", daemon=True).start()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"started": True,
|
||||
"from": my_version,
|
||||
"to": info["latest"],
|
||||
"size": info.get("size", 0),
|
||||
"message": "正在下载 %s(%.1f MB),下完会自动装好并重启,"
|
||||
"设置和配对码都不变"
|
||||
% (info["latest"], (info.get("size") or 0) / 1048576.0),
|
||||
}
|
||||
|
||||
def _run_update(self, info: dict, my_version: str) -> None:
|
||||
"""后台线程:下载 -> 校验 -> 起帮手 -> 自己退出。"""
|
||||
def log(text: str) -> None:
|
||||
try:
|
||||
self._log(text)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
try:
|
||||
log("[更新] 开始下载 %s(%.1f MB)"
|
||||
% (info["latest"], (info.get("size") or 0) / 1048576.0))
|
||||
work = Path(tempfile.gettempdir()) / "ykxmz-update"
|
||||
last = [0]
|
||||
|
||||
def progress(got: int, total: int) -> None:
|
||||
# 每 25% 报一次,别把日志刷爆
|
||||
if total:
|
||||
pct = int(got * 100 / total)
|
||||
if pct - last[0] >= 25:
|
||||
last[0] = pct
|
||||
log("[更新] 已下载 %d%%(%.1f/%.1f MB)"
|
||||
% (pct, got / 1048576.0, total / 1048576.0))
|
||||
|
||||
pkg = U.download(info["url"], work,
|
||||
info.get("sha256", ""), info.get("size", 0),
|
||||
progress=progress)
|
||||
log("[更新] 下载完成并校验通过:%s" % pkg.name)
|
||||
|
||||
# 应用:起分离的 .cmd 帮手,然后**本进程必须退出**
|
||||
app_exe = Path(sys.executable if getattr(sys, "frozen", False)
|
||||
else sys.argv[0]).resolve()
|
||||
logfile = U.apply_update(pkg, app_exe)
|
||||
log("[更新] 已交给更新帮手(%s),被控端即将退出并重启" % logfile)
|
||||
U.cleanup_old_downloads()
|
||||
|
||||
# 给日志和网络一点时间落完,再退出
|
||||
time.sleep(2)
|
||||
os._exit(0)
|
||||
except U.UpdateError as exc:
|
||||
log("[更新] 失败:%s(保持当前版本 %s)" % (exc, my_version))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log("[更新] 意外失败:%s: %s" % (type(exc).__name__, exc))
|
||||
|
||||
'''
|
||||
text = text.replace(anchors["unknown"],
|
||||
handler + "\n" + anchors["unknown"], 1)
|
||||
|
||||
# ---- 补 import ----
|
||||
need = []
|
||||
head = text.split("class ", 1)[0]
|
||||
for mod, line in (("tempfile", "import tempfile"),
|
||||
("sys", "import sys"),
|
||||
("threading", "import threading"),
|
||||
("time", "import time"),
|
||||
("os", "import os"),
|
||||
("Path", "from pathlib import Path")):
|
||||
if line not in head:
|
||||
need.append(line)
|
||||
if need:
|
||||
anchor = "from . import protocol as P"
|
||||
text = text.replace(anchor, "\n".join(need) + "\n" + anchor, 1)
|
||||
print(" ✓ 补了 import: %s" % ", ".join(need))
|
||||
|
||||
SERVER.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ server.py 加了 _do_update / _run_update")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 接「静默自动更新」进协议和服务端")
|
||||
print("=" * 58)
|
||||
print()
|
||||
ok = patch_protocol()
|
||||
print()
|
||||
ok = patch_server() and ok
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把快捷栏接进 onCreate,并补上缺的 isConnected()。
|
||||
|
||||
== 补什么 ==
|
||||
|
||||
1. **isConnected()** —— 我写快捷栏时调用了它,但**它根本不存在**。
|
||||
Java 编译器会报 "cannot find symbol",整个 APK 编不出来。
|
||||
(教训:写之前应该先 grep 一遍依赖。这次是编译时才发现的。)
|
||||
|
||||
2. **把 buildQuickBar() 挂进 onCreate** —— 光有方法不 addView 是白搭,
|
||||
这个坑项目里踩过(注释里写着"设置页第一版就漏了这一行,
|
||||
表现是点设置进去全白")。
|
||||
|
||||
3. **给 5 个面板加 topMargin** —— 快捷栏贴在最上层,不加边距的话
|
||||
它会盖住每个页面顶部的内容(比如控制页第一行的状态文字)。
|
||||
|
||||
4. **loadQuickSlots()** —— 启动时读用户的选择。
|
||||
|
||||
5. **设置页里加一段"快捷栏"** —— 用户要求"栏里的按钮可以在设置页
|
||||
自定义"。没有这个入口,7 个动作就只能用默认那 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"
|
||||
|
||||
# ---------------- 1. isConnected ----------------
|
||||
CONN_ANCHOR = " private boolean ensureConnected() {"
|
||||
CONN_METHOD = ''' /**
|
||||
* 当前连没连上被控端。
|
||||
*
|
||||
* <p>⚠ 这个方法是我加快捷栏时才补的 —— 之前代码里到处写
|
||||
* `client.isConnected()`,我照着直觉调了个 `isConnected()`,
|
||||
* 结果编译才发现它不存在。现在包一层,以后判断连接状态统一走这里
|
||||
* (空指针也一并挡住了)。
|
||||
*/
|
||||
private boolean isConnected() {
|
||||
return client != null && client.isConnected();
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---------------- 2. onCreate 里挂栏 ----------------
|
||||
ONCREATE_ANCHOR = """ setContentView(root);"""
|
||||
|
||||
ONCREATE_NEW = """ // ================= 顶部快捷栏 =================
|
||||
//
|
||||
// 用户要求:"不管干什么,上面都有一栏"。
|
||||
//
|
||||
// ⚠ 必须**在 5 个面板之后** addView —— FrameLayout 里后加的在上层。
|
||||
// 加在前面的话会被面板整个盖住(面板是 MATCH_PARENT)。
|
||||
loadQuickSlots();
|
||||
buildQuickBar();
|
||||
buildMousePanelIfNeeded();
|
||||
root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));
|
||||
root.addView(mousePanel = mousePanelHolder(), new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
setContentView(root);"""
|
||||
|
||||
# ---------------- 3. 面板 topMargin ----------------
|
||||
MARGIN_NOTE = """ /**
|
||||
* 给面板顶部留出快捷栏的高度。
|
||||
*
|
||||
* <p>快捷栏是贴在最上层的常驻元素(用户要求"不管干什么上面都有一栏"),
|
||||
* 面板内容如果不缩进就会被它盖住顶部一截。
|
||||
*
|
||||
* <p>集中在这里改,是因为"每个面板各写一遍 dp(30)"很容易漏一个 ——
|
||||
* 表现就是"别的页正常,就某一页顶部被切掉"。
|
||||
*/
|
||||
private void applyPanelTopMargin(View panel) {
|
||||
if (panel == null) {
|
||||
return;
|
||||
}
|
||||
ViewGroup.LayoutParams lp = panel.getLayoutParams();
|
||||
if (lp instanceof FrameLayout.LayoutParams) {
|
||||
FrameLayout.LayoutParams flp = (FrameLayout.LayoutParams) lp;
|
||||
flp.topMargin = dp(30); // 跟 quickBar 的高度一致
|
||||
panel.setLayoutParams(flp);
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 快捷栏接进 onCreate + 补 isConnected")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "private boolean isConnected()" in t:
|
||||
print(" 已经接过了")
|
||||
return 0
|
||||
|
||||
need = {
|
||||
"ensureConnected": CONN_ANCHOR,
|
||||
"setContentView(root)": ONCREATE_ANCHOR,
|
||||
"buildQuickBar(上次加的)": " private void buildQuickBar() {",
|
||||
}
|
||||
missing = [k for k, v in need.items() if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点全部就位")
|
||||
|
||||
t = t.replace(CONN_ANCHOR, CONN_METHOD + CONN_ANCHOR, 1)
|
||||
t = t.replace(ONCREATE_ANCHOR, ONCREATE_NEW, 1)
|
||||
t = t.replace(" private void buildQuickBar() {",
|
||||
MARGIN_NOTE + " private void buildQuickBar() {", 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 isConnected()")
|
||||
print(" ✓ buildQuickBar 挂进 onCreate")
|
||||
print(" ✓ 加了 applyPanelTopMargin 助手")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -20,5 +20,11 @@
|
||||
"download_dir": "received",
|
||||
"screen_max_width": 480,
|
||||
"screen_quality": 72,
|
||||
"screen_fps": 12
|
||||
"screen_fps": 12,
|
||||
"notice_position": "bottom-right",
|
||||
"notice_x": 0,
|
||||
"notice_y": 0,
|
||||
"notice_width": 0,
|
||||
"notice_font_size": 11,
|
||||
"show_screen_notice": false
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user