27 Commits
Author SHA1 Message Date
xmz7891 31a2360031 删掉临时提交说明 2026-10-09 13:16:31 +08:00
xmz7891 be59a20036 2.1.5:修「Windows 7 装完报 dll 缺失」
用户报:学校的电脑是 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+ 生效)。

所以之前用 3.11 打出来的包,在 Win7 上必然报缺 DLL ——
**那个 DLL 不是"忘了放进包",是"放进去也没用"。**

顺带说明:包里那 47 个 api-ms-win-core-*.dll 转发库正是
Python 为兼容 Win7 才带上的 —— 上游本来就考虑过 Win7,
只是 3.11 这个版本号越界了。

== 改法 ==

1. **改用 Python 3.8 打包,而且选 32 位**
   32 位在 Win7 的 32 位和 64 位系统上都能跑,学校不用分两种发。
   (64 位的包在 32 位 Windows 上直接起不来。)

   依赖固定到 3.8 能用的版本:
       Pillow==9.5.0(最后一个支持 3.8 的)
       pyinstaller 6.x(保持 _internal 布局,和安装包/测试/卸载逻辑一致;
                       5.x 会把依赖摊在根目录,导致"_internal 在"那一项失败)

2. **打包脚本主动拦**
   windows\build_exe.ps1 打包前先校验 Python:
     * 找不到 3.8 -> 报错并给出安装命令
     * 版本不是 3.8 -> 直接 throw,而不是默默打一个别人装不上的包
     * 不是 32 位 -> 警告
   Python 从 YKXMZ_PY 环境变量或常见路径里找,不写死。

3. **新增 Win7 兼容校验** _build\check_win7_compat.py
   盯三件事:内嵌是不是 3.8 的运行库、**有没有**那个要命的 DLL、
   exe 是不是 32 位。打包时和回归套件(3a9 关)里都会跑,
   不过就拒绝出包。

== 顺手修的 ==

_build/test_installer_cycle.py 里写死了 ykxmz-setup-1.8.2.exe,
于是一直在**装一个不存在的旧包** -> 静默失败 -> 报出一堆
莫名其妙的失败("配对码实际是 xxx"、"版本显示 v1.8.2")。
改成自动找 dist 下版本号最新的包(按数字段排序,不能按字符串,
否则 1.9.0 会排到 2.1.4 前面)。

== 验证(用户要求:自己测一遍,没问题再上传)==

    全量回归 42 个阶段全 PASS(含新的 "Windows 7 兼容" 关)
    安装包完整周期 20/20
    Win7 校验:python38.dll / 无 api-ms-win-core-path / 32 位
    实际启动打包的 EXE:进程活着、TCP 监听、UDP 自动发现在听、
                        连接能建立(日志里有"设备接入")
    产物扫描:三件全部干净(无旧密码、无服务器 IP)
    提交前泄漏闸门通过

安装包体积 14.4 MB -> 9.0 MB。
2026-10-09 13:16:31 +08:00
xmz7891 7ffd56391e 删掉临时提交说明 2026-10-09 00:02:32 +08:00
xmz7891 0ad3f648c6 修上一轮清理留下的副作用:占位符把运维脚本弄连不上了
上一个提交把仓库里的服务器 IP 换成了占位符,但**那些脚本本身要用这个 IP
去连服务器** —— 换掉之后它们就跑不起来了。这个提交把取值方式理清楚。

== 取值统一走 _build/lssh.py ==

    YKXMZ_LS_HOST 环境变量
    或 _build/.lightsail_host(一行 IP,已 gitignore)
    都没有就报错,并告诉你怎么设

十个运维脚本改成 `from lssh import ... HOST as LS_HOST`,
公开仓库里一个具体地址都不留。

== 顺带修的两个自己造的 bug ==

  * 用正则往 `from lssh import ...` 后面追加东西时,
    追加到了行尾的 `# noqa: E402` **注释里面** ——
    十处导入全是废的,脚本一跑就 NameError。
    改成先剥掉注释、拼好导入、再把 noqa 加回去。

  * 同一个正则还把 `from lssh import HOST as LS_HOST` 加进了
    **lssh.py 自己**,造成自我导入。

教训:**正则批量改导入语句很危险**(注释、行尾、顺序都能坑到),
这种地方宁可逐个 edit。

== 为什么要重建产物 ==

docs/AFTER-INSTALL.txt 会被打进服务端 zip,而它上一轮改过
(去掉了写死的旧管理员密码)—— 不重打的话发出去的还是旧的。

重打后确认:三个产物里都没有旧密码、没有服务器 IP。

== 验证 ==

    全量回归退出码 0,41 个阶段全 PASS
    603 个 Python 文件语法通过
    ykxmz-agent / server / watch 三个产物扫描干净
    提交前泄漏闸门通过
2026-10-09 00:02:32 +08:00
xmz7891 9bdd36cf56 2.1.4:修 URL 协议头 bug + 清理对外材料里泄漏的服务器信息
用户报了三件事,一起处理。

== 一、发行版介绍里泄漏了服务器公网 IP ==

_build/CHANGELOG.md 的 2.1.3 那节写了服务器公网 IP,而发布脚本
把这个文件当"更新说明"传给 Gitee,于是发行版介绍里就带出去了。

为什么 IP 不该公开:直接打 IP 就绕过 Cloudflare 了 ——
没有 CDN、没有防护、没有速率限制。源站本来就不该被人知道是谁
(这也是为什么 nginx 那层只认域名、中转只绑 127.0.0.1)。

处理:
  * 已发的 v2.1.3 发行版介绍用 API 改掉(扫了全部 11 个发行版,只它一个)
  * 仓库里 16 个文件里的 IP 全部换成域名或占位符
  * 运维脚本改成从环境变量读,公开仓库里不留具体地址

顺手发现**更严重的一处**:旧管理员密码硬编码在
docs/AFTER-INSTALL.txt(会打进服务端 zip)和
installer/license-combined.txt(**安装包的许可页,每个装的人都能看到**)里。
而用户当前密码就含这个串,等于公开了一半。一并清掉,
公开材料里改成"密码在你服务器上"的说明。

== 二、手表端填了地址却连不上 ==

用户截图:填域名(没写协议头)-> 点「检查服务器」报

    MalformedURLException: no protocol: <域名>/v1/health

根因:补协议头的逻辑只写在连接那条路(RelayTransport 的构造函数)上,
checkServer() 直接拿原始字符串拼 URL —— 同一个地址,连接能用、检查却崩。

修法:把规范化收到 relayUrl() 这**唯一一个入口**,所有调用方都从那里取。
规则和电脑端一致:IP / localhost / *.local -> http,域名 -> https。

顺带补了 _tests/test_relay_url_normalize.py 盯住它(含 9 个用例实算)。

== 三、Gitee 的 README 被判"可能含有违规信息" ==

Gitee 挂的是软性公告(可自行修改或申诉),但确实挂在那。
逐项看了 README,改掉几处**不准确**且容易误判的表述:

  * 原文写"看看学生机是不是在打游戏" —— 这是**错的**:
    被控端只装在一台电脑上(讲台那台),根本看不到别的机器。
    既不准确又像监控软件,改成"作用于被控端那一台"
  * 课堂那几项功能如实说明:改的是本机 hosts、结束的是本机进程,
    都可逆,下课点「一键恢复全部」就还原
  * "免杀对抗""伪装成系统进程"这类词(虽然是在说"我们不做这些")
    本身就在审核词库里,换成正面的说法
  * 成品文件名从 1.8.2 更新到当前版本,并且以后发版自动跟着改
    (把 README 加进了 bump 脚本;以前一直停在 1.8.2,
     用户照着 README 去 releases 根本找不到那个文件)

== 四、做这件事时自己踩的坑(都写进注释了)==

  * 批量替换时**没排除 gitignore 的文件**,把本地密码文件也改了
  * 替换时往字典字面量中间塞注释,**弄坏了 5 个文件的语法**
  * 修的时候手写"坏文件列表",漏了 2 个 ——
    改成全仓库扫模式(列表是观察到的,不是穷尽的)
  * 清理工具自己又成了泄漏点(脚本里写了要匹配的 IP/密码),
    加了 _build/precommit_leak_gate.py 当闸门才抓到

教训:批量替换要先排除不该碰的文件,而且不能往表达式中间塞东西。

== 按用户要求:测完再提交 ==

    全量回归 41 个阶段全 PASS(2 项因锁屏 SKIP)
    601 个 Python 文件语法通过
    pyflakes 干净
    安装包完整周期 20/20
    对外材料清洁度 15/15
    提交前泄漏闸门通过

(这条信息是补写的:上一个提交因为 heredoc 把提交说明打到屏幕而不是
 文件,信息变成空的 "remove temp" 了,内容本身是对的。)
2026-10-08 23:51:17 +08:00
xmz7891 a53a3eee21 搬到 Lightsail:nginx 反代 + TLS + 公共 DNS + 后台口令改由服务器持有
== 架构(最终形态)==

    用户 --HTTPS--> Cloudflare CDN --HTTP/HTTPS--> nginx --+--> 127.0.0.1:13245
                    yk.xxmxxs.top                  13.54.30.196

中转只绑 127.0.0.1,公网只能经 nginx 进来 —— 少一个暴露面
(实测公网直连 :13245 返回 000,符合预期)。

== 这一轮加的 ==

1. **nginx 反代**(_build/setup_nginx.py)
   Cloudflare 只能回源到特定端口(80/8080/443/2053…),
   中转跑的 13245 不在列表里,所以中间必须有一层。
   配置要点:
     /download/  proxy_buffering on
     /v1/        proxy_read_timeout 180s + **proxy_buffering off**
                 (长轮询要挂 60 秒;关缓冲,否则要等整个响应完
                   才吐给客户端,那就不是长轮询了)

2. **源站 TLS 证书**(_build/setup_tls_origin.py)
   Cloudflare 的 SSL 模式是 Full(strict),回源走 443,源站只有 80
   -> 522。用 certbot 的 nginx 插件签了 Let's Encrypt 证书。
   开着橙色云朵也能签:HTTP-01 挑战经 Cloudflare 回源到 80 能验证。
   踩的坑:第一次用 example.com 当邮箱,Let's Encrypt 拒收保留域名。

3. **公共 DNS**(_build/fix_server_dns.py)
   换域名之后 **服务器自己解析不了** yk.xxmxxs.top:
   Lightsail 默认的内网解析器 172.26.0.2 还缓存着"不存在"(负缓存),
   而 1.1.1.1 / 8.8.8.8 已经能解析。
   后果不只是监测误报 —— 服务器上任何解析该域名的东西都会失败。
   配上公共 DNS,内网那个降为后备(它对 AWS 自家服务更快)。

4. **监测与预案重写**(relay/monitor-lightsail.sh、rescue-lightsail.sh)
   分两层探测,才能分清是哪一层坏的:
     PRIMARY   https://域名          用户实际走的
     FALLBACK  127.0.0.1:13245       中转本身
     PRIMARY 挂 + FALLBACK 好 -> edge 层 -> 动 nginx
     两个都挂               -> core 层 -> 动中转
   不区分的话只能瞎重启,而 edge 层的问题重启中转一万次也没用。

   做法上也改了:不再"SSH 过去用嵌套 heredoc 现场打补丁"
   (这一轮因此踩了五次转义/求值/百分号格式化的坑),
   改成**本地写完整文件、整份上传**,能进 git 做版本管理。

5. **后台口令改由服务器持有**(_build/set_admin_password.py)
   密码只写进 /opt/ykxmz-relay/data/admin_password.txt(600),
   **不进代码、不进仓库**。本地脚本从环境变量或
   _build/.admin_password(已 gitignore)读取。
   这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训了。

6. **验证脚本**(_build/verify_admin_login.py)
   第一版只看登录请求的状态码,结果"对的密码/错的密码/不带凭据"
   **全是 302** —— 等于没测。改成看 cookie 再访问 /admin,
   区分拿到的是登录页还是后台页。实测:
     正确密码 -> cookie 1 条 -> 后台页
     错误密码 -> cookie 0 条 -> 登录页
     账号写错 -> cookie 0 条 -> 登录页
     经 Cloudflare 域名 -> 后台页

== 其他修复 ==

  * 下载响应有**两条** Cache-Control(nginx 和 relay 各发一条),
    Cloudflare 遇到重复的缓存指令可能直接跳过缓存 -> 去掉 nginx 那条
  * 监测探测地址跟着监听地址改(改入口必须回头看所有探测入口)
  * curl 加 -L 跟跳转(certbot 加了 http->https 301)
  * 旧服务器(随身 WiFi 那台)的服务全部停掉并取消自启,
    但**没关机、没删文件** —— 保持可逆

全量回归 46 个阶段通过;594 个 Python 文件语法通过。
2026-10-08 23:07:45 +08:00
xmz7891 f173c402da remove temp 2026-10-08 22:53:44 +08:00
xmz7891 47e08af151 监测单元和预案重写成"分层"版(来源改为本地整份文件)
== 为什么重写 ==

certbot 签证书时加了 http -> https 的 301,而监测单元原来判断
"curl 拿到 200 才算健康"、且 curl **默认不跟跳转** -> 拿到 301
-> 判成"网站挂了" -> 又要启动预案 -> 重启中转 -> 再查还是 301。
这是**第二次**踩同一类坑了(上一次是改监听地址忘了改探测地址)。

共同教训:**改了入口的行为,必须回头看所有探测入口的地方。**

== 新设计:分两层探测 ==

    PRIMARY   https://yk.xxmxxs.top/v1/health     用户实际走的路
    FALLBACK  http://127.0.0.1:13245/v1/health    中转本身

两个都查才能分清哪一层坏了,进而选对抢救动作:

    PRIMARY 挂 + FALLBACK 好  -> edge 层(CF / DNS / nginx)
   两个都挂                  -> core 层(中转服务本身)

预案据此走不同的阶梯:

    edge: 重载 nginx -> 重启 nginx -> 查 443 与证书
    core: 重启中转 -> 强杀重拉 -> 中转+nginx 都重启

不区分层次的话只能瞎重启,而 edge 层的问题重启中转一万次也没用。

== 做法上的改进:不再远端打补丁 ==

前几轮一直是"SSH 过去用嵌套 heredoc 现场改 monitor.sh",
连续踩三次:PowerShell 在本机求值 \、引号被吃、
脚本里的 %{http_code} 和 Python 的 %% 格式化打架。

现在改成:relay/monitor-lightsail.sh 和 relay/rescue-lightsail.sh
是**唯一事实来源**,本地编辑 -> 整份上传 -> 重装,
改了什么一目了然,也能进 git。

== 顺带 ==

因为 Lightsail 防火墙还没放行 443,PRIMARY 一直是 000,
监测会把 fails 一直加上去、白生成抢救报告。
所以暂时把 monitor.timer 停掉,等 443 开了再启用
(脚本 _build/pause_monitor.py,恢复是一条 systemctl start)。
2026-10-08 22:53:44 +08:00
xmz7891 e63b5f08d7 remove temp 2026-10-08 22:49:36 +08:00
xmz7891 25ffd3ec67 源站装 TLS 证书 + 停掉旧服务器
== 522 的根因 ==

DNS 配好、橙色云朵生效之后:
    http://yk.xxmxxs.top/     -> 200(经 Cloudflare,反代正常)
    https://yk.xxmxxs.top/    -> 522
    13.54.30.196:443          -> 不通

zone 的 SSL/TLS 模式是 **Full / Full(strict)**,Cloudflare 回源走 443,
而源站只听了 80 —— 因此 522。

== 解决:源站签 Let's Encrypt 证书 ==

用 certbot 的 nginx 插件签(**不需要 webroot**,插件自己处理挑战):

    Successfully received certificate
    /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem
    notAfter = Jan 6 2027(自动续期已启用 certbot.timer)
    nginx 已加 listen 443 + 证书 + http->https 跳转

**开着橙色云朵也能签**:HTTP-01 挑战请求
http://yk.xxmxxs.top/.well-known/acme-challenge/... 会经 Cloudflare
回源到 80,CF 不缓存该路径、会透传,所以验证能过。
比"先关云朵签完再开"省事,也不用等 DNS 传播。

踩的坑:第一次用了 xmz7891@example.com,Let's Encrypt 拒收
example.com 这类保留域名,报"invalid email"。
改用 --register-unsafely-without-email 即可
(续期由 timer 负责,不依赖邮件提醒)。

== 停掉旧服务器 ==

中转已在 Lightsail 跑通,旧那台(随身 WiFi + 两条 CF 隧道 + 网络调优)
全部停掉并取消自启:

    lanremote-relay / lanremote-tunnel / lanremote-tunnel-h2 /
    lanremote-nettune / monitor.timer / watchdog.timer  -> 全 inactive

**只停服务,没关机、没删文件** —— 保持可逆,新服务器稳几天再说。

== 待用户操作 ==

Lightsail 防火墙开 TCP 443(Cloudflare 的 Full(strict) 回源要用)。
2026-10-08 22:49:36 +08:00
xmz7891 f748565ebf remove temp 2026-10-08 22:41:33 +08:00
xmz7891 db669e2f40 修监测单元查错地址(改监听后它开始误报网站挂了)
把中转改成只监听 127.0.0.1(公网走 nginx)之后,监测单元还在查
http://13.54.30.196:13245/v1/health —— 那个端口已经不对公网开了,
于是它连续报"公网异常",fails 涨到 2。

**差一点就是无限重启循环**:再失败一次它会启动紧急预案,
而预案的动作是"重启中转服务",重启完再查还是 000,再救……

这是改监听地址时没想到的连带影响。教训写在这里:
**改一个服务的暴露方式时,必须把所有"探测它"的地方一起看一遍。**

== 改法 ==

监测地址改成"用户实际怎么访问"为准,域名优先、IP 兜底:

    PUBLIC_URL=http://yk.xxmxxs.top          域名(经 Cloudflare + nginx)
    FALLBACK_URL=http://13.54.30.196:80      绕过 CF 直连 nginx

只要有一个通就算健康。这样域名配好前靠兜底、配好后走真实路径。

== 顺带修的 ==

  * monitor.log 属主还是 root(后来新建的),admin 写不进去
    -> 再 chown 一次
  * _build/check_lightsail.py 里的"公网"地址也要跟着改,
    并且**故意保留**一项查 :13245(预期 000)用来确认
    中转确实不再暴露公网

全量状态:三层地址、缓存头、监测、属主全部正确。
2026-10-08 22:41:33 +08:00
xmz7891 daf73dfe8e 修掉下载响应的重复 Cache-Control 头(会让 Cloudflare 跳过缓存) 2026-10-08 22:39:59 +08:00
xmz7891 5ddd4cd752 remove temp 2026-10-08 22:38:57 +08:00
xmz7891 892d5891ea 搬到 Lightsail 真服务器 + 加 nginx 给 Cloudflare 回源
== 新架构 ==

    用户 --HTTPS--> Cloudflare CDN --HTTP:80--> nginx
                    (yk.xxmxxs.top)              13.54.30.196
                                                     |
                                                     v
                                            127.0.0.1:13245 (中转)

旧架构是"Cloudflare 隧道"(cloudflared 走 7844 到那台 4G 小机器),
那条线路对 7844 是间歇性阻断的(443 通、7844 不通),
隧道每 3~10 分钟掉一次、公网跟着 530 —— 是"网站容易挂"的主因。

新架构**不用隧道**:新机器有公网 IP,Cloudflare 走标准的
反代(回源 80 端口),这是完全不同的东西,没有 7844 问题。

== 为什么要 nginx 这一层 ==

Cloudflare 只能回源到特定端口:
    HTTP:  80 / 8080 / 8880 / 2052 / 2082 / 2086 / 2095
    HTTPS: 443 / 2053 / 2083 / 2087 / 2096 / 8443
中转跑的 13245 不在列表里,所以中间放一层 nginx。

加了 nginx 之后顺便把中转**改成只监听 127.0.0.1** ——
公网只能通过 nginx 进来,少一个暴露面。实测
http://13.54.30.196:13245 现在是连不上的(000),符合预期。

== nginx 配置要点 ==

  /download/   proxy_buffering on + 显式 Cache-Control 604800 immutable
               (文件名带版本号、内容永不改变,放心长缓存;
                 Cloudflare 缓存后第二次下载不回源)
  /v1/         proxy_read_timeout 180s + **proxy_buffering off**
               (长轮询挂 60 秒,nginx 默认 60 秒就断;而且必须关缓冲,
                 否则要等整个响应完才吐给客户端,那就不是长轮询了)
  /            普通反代
  server_tokens off
  client_max_body_size 200m

== 代码修复 ==

relay.py 的 DATA_DIR 默认值原来是写死的 /root/lanremote-relay。
新机器上服务以 admin 普通用户跑(它没理由要 root),而 /root 是 0700,
部署时直接 PermissionError。改成"脚本所在目录下的 data/"。

== 客户端 ==

2.1.3 里已经让手表端支持"没有证书的中转"(裸 IP 走明文 +
usesCleartextTraffic)。等域名和 Cloudflare 接好之后就是全程 HTTPS 了。

== 新增的运维脚本 ==

  _build/lssh.py                     Lightsail 的 SSH 助手(密钥登录)
  _build/deploy_lightsail.py         首次部署中转
  _build/deploy_lightsail_monitor.py 装监测单元 + 预案
  _build/deploy_lightsail_release.py 按版本部署代码和成品
  _build/check_lightsail.py          看状态
  _build/setup_nginx.py              装配 nginx
  relay/rescue-lightsail.sh          紧急预案(针对新架构重写:
                                     没有隧道了,改成重启中转 / 强杀重拉 /
                                     查防火墙 / 重启整机[默认关])
2026-10-08 22:38:57 +08:00
xmz7891 62e67e9e26 remove temp 2026-10-08 22:29:57 +08:00
xmz7891 5fc2f97be7 2.1.3:搬到 Lightsail 真服务器 + 支持"没有证书的中转"
用户买了 AWS Lightsail(2G 内存 / 2 vCPU / 60G SSD / 公网 IP
13.54.30.196),中转从随身 WiFi 那台小机器搬过去。

== 为什么这次搬迁解决了一堆老问题 ==

原机器(Debian aarch64、460MB、走 4G)一直有几件搞不定的:

  * 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**(443 通、7844 不通),
    隧道每 3~10 分钟掉一次、公网跟着 530 —— 查了很久才从
    cloudflared 自己的 precheck 里看清
  * 只有 460MB 内存
  * 会掉电硬挂(实测 2026-10-08 18:42 一次非正常关机)

新机器**有公网 IP**,中转直接监听,不经隧道、不过 7844。
实测公网延迟从 **1000~2000 ms 降到 1.1 ms**。

== 代码改动 ==

1. **relay.py 的 DATA_DIR 默认值不再写死**
   原来是 /root/lanremote-relay。新机器上服务以 admin 普通用户跑
   (它没理由要 root),而 /root 是 0700 —— 部署时直接
   PermissionError: '/root/lanremote-relay'。
   改成"本脚本所在目录下的 data/",搬到哪都跟着走。

2. **客户端支持"没有证书的中转"**
   新服务器是明文 HTTP(没签证书),而两个客户端原来都往 https 走:
     * 手表端:裸 IP 会被强制补 https:// -> 永远连不上,
       报的还是 SSL 错误,很容易让人白查半天证书
     * Android 9+ 默认禁止明文流量,没 usesCleartextTraffic
       请求会被系统直接掐掉
   Windows 端本来就是对的(正则里把任意纯 IP 都当"用明文")。
   现在手表端规则和它一致,并加了 usesCleartextTraffic。

3. 清掉电脑端一段函数内的重复代码(return 之后还有一份拷贝,
   缩进仍在函数体内所以不报错,但读起来很迷惑)。

== 新服务器上的部署 ==

    /opt/ykxmz-relay/            服务端(relay/admin/landing/showcase)
    ykxmz-relay.service          监听 0.0.0.0:13245
    ykxmz-monitor.service/.timer 每 60 秒查公网,连续 3 次不通就叫预案
    rescue-lightsail.sh          四级阶梯抢救(重启中转 / 强杀重拉 /
                                 查防火墙 / 重启整机[默认关])

  预案针对新架构重写过:原来围着隧道转(重启隧道、重施 MSS/MTU),
  现在没有隧道,改成重启中转 + 查端口/防火墙。

全量回归 46 个阶段全部通过。
2026-10-08 22:29:56 +08:00
xmz7891 bc289c5c21 remove temp 2026-10-08 20:08:16 +08:00
xmz7891 d5a1943d1f 加两个运维辅助脚本
_build/watch_stability.py   连续打公网统计可用率(验证隧道加固效果)
  _build/desktop_state.py     列出当前可见窗口 + 输入桌面名
                              (排查"桌面消失/一片蓝"这类问题用)

这两个是查 2.1.1 / 2.1.2 那两个问题时写的,以后还会用到。

顺便记一笔当时的排查结论(2026-10-08):
用户报"桌面消失、一片蓝、有个蓝色窗口",查下来:
    * 输入桌面是 Default(正常),explorer 在跑
    * 那 9 个"全屏窗口"全是 Windows 自己的 UWP 组件
      (开始菜单 / 搜索 / 锁屏 / 输入法),程序枚举时会误报全屏
    * 真正开着的是「设置」App 和任务管理器
    * 自启项里没有 ykxmz,也没有残留的隐藏桌面或测试进程
    * 18:42:41 有一次**非正常关机**(Kernel-Power 41 / EventLog 6008),
      19:58 重启。没有任何蓝屏或硬件错误记录 ——
      最符合"掉电或硬挂",不是软件崩溃。
2026-10-08 20:08:16 +08:00
xmz7891 768309c1f2 remove temp 2026-10-08 20:04:35 +08:00
xmz7891 8dc47d0f48 2.1.2:隧道稳定性加固(继续修「网站还是容易挂」)
2.1.1 修掉了 nettune 死锁之后,公网上到 200,但**过一阵又 530**。
这次抓到的是隧道自己:

cloudflared 的 precheck 输出(tunnel.log):

    | UDP Connectivity  region1.v2.argotunnel.com  FAIL  QUIC connection failed                      |
    | TCP Connectivity  region1.v2.argotunnel.com  FAIL  HTTP/2 connection is blocked or unreachable |
    | Cloudflare API    api.cloudflare.com:443     PASS  API is reachable                            |
    | ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2.                                  |

也就是 **443 通、7844 不通**,而且是**间歇性**的(过几分钟自己又好了,
然后再次断)。Cloudflare 隧道的边缘连接走 7844(QUIC 走 UDP 7844、
HTTP/2 走 TCP 7844)。这条线路对它是间歇性阻断 —— 属于运营商那一侧,
设备改不了,但可以让 cloudflared 自己去适应。

== 两个改动 ==

1. **协议从写死改成 auto**
       lanremote-tunnel      --protocol quic    -> --protocol auto
       lanremote-tunnel-h2   --protocol http2   -> --protocol auto
   原来两条实例各赌一个协议:那一刻 7844 的 UDP 和 TCP 都不通就一起死,
   没有退路(实测连接数掉到 1/0、公网 530)。改成 auto 之后
   cloudflared 自己在 QUIC / HTTP/2 之间挑当前能通的那条,
   10 条连接全都有自适应能力。

2. **--retries 5 -> 1000000**
   原来失败 5 次就退出、靠 systemd Restart=always 拉起来,
   每次退出到重启之间网站都是断的。而这条线路的抖动是分钟级的 ——
   实测隧道每 3~10 分钟就被重启一次(19:37 / 19:40 / 19:43 / 19:50 / 19:52),
   公网跟着一起断。拉大之后抖动期间连接自己恢复,整条隧道不退出。

3. 顺手加日志轮转(tunnel.log 已经 2.98 MB,这台机器硬盘不大)。

== 实测 ==

    改前:连接数 1/0,公网 530
    改后:连接数 7+2(接近满配 10),公网连测 5 次全部 200

== 同步 ==

relay/deploy.sh 里的 MAIN_PROTO / ALT_PROTO 也改成 auto、
TUNNEL_BASE 的 --retries 也拉到 1000000,新机器装出来就是加固过的。

全量回归 46 个阶段全部通过。
2026-10-08 20:04:35 +08:00
xmz7891 4e64bc7920 remove temp 2026-10-08 19:52:09 +08:00
xmz7891 c5fa4eb517 2.1.1:修一个会让网站整个挂掉的死锁(「网站容易挂」的真因)
2.1.0 的服务端包里带着这个死锁,所以要单独发一版让部署的人拿到修好的包。

== 死锁 ==

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

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

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

== 改法 ==

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

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

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

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

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

== 死锁链条 ==

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

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

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

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

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

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

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

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

== 改法 ==

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

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

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

== 另外 ==

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

协议加 sfx_list / sfx_play 两条命令。

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

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

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

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

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

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

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

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

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

全量回归 46 个阶段全部通过。
2026-10-08 18:36:50 +08:00
278 changed files with 12939 additions and 2076 deletions
+8 -1
View File
@@ -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
+681 -661
View File
File diff suppressed because it is too large Load Diff
+35 -25
View File
@@ -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.5.exe` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.5.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.5.apk` |
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
@@ -82,7 +81,7 @@
### 第一步:电脑上装被控端
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
双击 `ykxmz-setup-2.1.5.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
第一次运行会弹出窗口,填两样:
@@ -95,12 +94,12 @@
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
> 想彻底退出,窗口上点「退出」。
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
> 不想用安装包的话,`ykxmz-agent-2.1.5.zip` 是绿色版:
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
### 第二步:手表上装 App
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
把 `ykxmz-watch-2.1.5.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.5.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.5.apk
```
最低支持 Android 7.0(API 24),targetSdk 34。
+203
View File
@@ -1,3 +1,206 @@
## 2.1.5
- **修「Windows 7 装完报 dll 缺失」** —— 学校电脑装不上。
这一条是用户报的:学校的电脑是 Windows 7,装完提示缺 DLL。
**根因不是漏打包,是 Python 版本越界**:
| Python | 支持的系统 |
|---|---|
| **3.8** | Windows 7 及以上 ← 最后一个支持 Win7 的版本 |
| 3.9 ~ 3.13 | 只支持 Windows 8 及以上 |
Python 3.9 起用到了 `api-ms-win-core-path-l1-1-0.dll` 里的
`PathCchCanonicalizeEx`,而这个 DLL **Windows 7 上根本不存在**,
也没法用 api-set 转发(那套机制只在 Win8+ 生效)。
所以之前用 3.11 打出来的包,在 Win7 上必然报缺 DLL ——
**那个 DLL 不是"忘了放进包",是"放进去也没用"。**
**改法:改用 Python 3.8 打包**,而且选 **32 位** ——
32 位在 Win7 的 32 位和 64 位系统上**都能跑**,学校不用分两种发。
顺带加了两道自动关卡,以后不会再退化:
- 打包脚本 `windows\build_exe.ps1` **先校验 Python 版本**,
不是 3.8 直接报错退出(而不是默默打一个别人装不上的包)
- `_build\check_win7_compat.py` 校验产物:
内嵌是不是 3.8 的运行库、**有没有**那个要命的 DLL、exe 是不是 32 位。
打包时和回归套件里都会跑,不过就拒绝出包
安装包体积也从 14.4 MB 降到 **9.0 MB**(32 位运行库小一圈)。
- **顺手修了安装包测试里一个藏了很久的坑**:
`_build/test_installer_cycle.py` 里写死了 `ykxmz-setup-1.8.2.exe`,
于是它一直在**装一个不存在的旧包** → 静默失败 → 报出一堆
莫名其妙的失败("配对码实际是 xxx"、"版本显示 v1.8.2")。
改成自动找 `dist` 下版本号最新的包,以后不会再过期。
## 2.1.4
- **修一个"填了地址也连不上"的 bug**(手表端)。
中转地址栏里填 `yk.xxmxxs.top` 这种**没写 `http://` 开头**的地址时,
点首页那个「检查服务器」会报:
```
MalformedURLException: no protocol: yk.xxmxxs.top/v1/health
```
原因是:补协议头的逻辑只写在连接那一条路上,
「检查服务器」直接拿原始字符串拼 URL —— **同一个地址,
连接能用,检查却崩**。
现在把补协议头收到**唯一一个入口**(读地址的地方),
所有用到地址的地方都从那里取,以后再加新入口也不会漏。
规则和电脑端保持一致:
- `IP` / `localhost` / `*.local` → 用 `http://`
- 域名 → 用 `https://`
- **修正 README 里几处不准确的说法**,以及过时的版本号:
- 被控端只装在**一台**电脑上(讲台那台或你自己那台),
原文写成"看学生机"是错的 —— 它看不到别的机器
- 课堂那几项功能如实说明:改的是**本机** hosts、结束的是**本机**进程,
都是可逆的,下课后点「一键恢复全部」就还原
- 成品文件名从 1.8.2 更新到当前版本,并且以后发版会自动跟着改
## 2.1.3
- **支持「自己搭的、没有证书的中转」**(新服务器就是这种)。
中转不再是必须挂在 Cloudflare 隧道后面了。现在可以直接跑在
一台有公网 IP 的机器上,客户端填 `IP:端口` 就能连 —— 延迟、稳定性
都比隧道好得多。为此改了两处客户端:
- **手表端**:填 `yk.xxmxxs.top:13245` 这种**没写协议头**的地址时,
现在会按「IP / localhost / 本地域名 → 明文,域名 → 加密」来猜,
**和电脑端规则一致**。原来一律补成 `https://`,而那台机器没有证书,
结果是**永远连不上**,报的还是 SSL 错误,很容易让人白查半天证书。
- **手表端**:允许明文流量。Android 9 以上默认禁止,不加这一条的话
请求会被系统直接掐掉,日志里只有一句含糊的
「Cleartext HTTP traffic to ... not permitted」。
> 说明:中转上跑的内容(配对令牌、画面、文件)本来就是加密过的,
> 明文只暴露地址和设备名。想要全程加密的话,把域名解析到这台
> 服务器再签一张免费证书即可。
- 顺手清掉了电脑端一段函数内的重复代码(`return` 之后还有一份一样的
拷贝,缩进仍在函数体内所以不报错,但读起来很迷惑)。
## 2.1.2
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
```
| UDP Connectivity Cloudflare 边缘 FAIL 连不上 |
| TCP Connectivity Cloudflare 边缘 FAIL 连不上或被挡住 |
| Cloudflare API api.cloudflare.com:443 PASS 通的 |
| ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2. |
```
也就是说:**443 通,7844 不通**,而且是**一阵一阵的**
(过几分钟自己又好了,然后再次断)。Cloudflare 隧道的边缘连接走 7844,
这条线路对它是间歇性阻断 —— 这属于运营商那一侧,设备改不了。
**但可以让它自己去适应**:
- 两条隧道实例原来**各自写死一个协议**(一条只走 QUIC、一条只走 HTTP/2)。
那一刻如果 7844 的 UDP 和 TCP 都不通,两条一起死,没有退路。
现在都改成 **自动选择**,cloudflared 自己在 QUIC / HTTP/2 之间挑
当前能通的那条。等于所有连接都有自适应能力,而不是
"8 条赌 UDP、2 条赌 TCP"。
- 重试次数从 5 提到「一直重试」。原来失败 5 次就退出、靠系统重新拉起,
每次退出到重启之间网站都是断的;而这条线路的抖动是分钟级的,
5 次根本扛不过去(实测隧道每 3~10 分钟就被重启一次,公网跟着 530)。
现在抖动期间连接自己恢复,整条隧道不退出。
**改完实测**:连接数 7+2(接近满配 10),公网连测 5 次全部 200。
- 顺手加了日志轮转(隧道日志已经涨到 3 MB,这台小机器硬盘不大)。
## 2.1.1
- **修一个会让网站整个挂掉的死锁**(这是「网站还是容易挂」的真因)。
在装了中转服务器的那台机器上,网络调优服务和隧道之间会互相等:
```
网络调优服务(nettune): "我必须先于隧道启动完"
网络调优脚本第 4 步: "重启隧道,让新参数生效" ← 卡在这里
```
它在**自己还没结束**的时候去重启一个「必须等自己结束才能启动」的服务,
系统判定成顺序冲突,那一步就永远不返回。结果网络调优一直卡在
「正在启动」,隧道被挡着起不来,**公网就是 530**。
实测卡了 9 分半。而且**日志里一句报错都没有**,
手动跑那个脚本却完全正常(那时没有顺序约束)——
所以特别难查,是靠这次新加的监测单元抓到的
(它记录到「连续 3 次不通」并把现场留了下来)。
**改法**:让脚本知道「这次是服务在调我」,那就跳过重启隧道那一步。
开机时本来也不需要它 —— 顺序就是调优先、隧道后,那时隧道还没起来。
> 这个坑以前一直藏着:设备上那份旧的服务定义里有一个写法错误,
> 导致它在更早的步骤就报错退出了,反而没走到会死锁的那一步。
> 把服务定义改对之后,死锁才露出来。
- 另外这一版的服务端包也带上了上面那个修复。
**2.1.0 的服务端包是有问题的**,请用 2.1.1 重新部署。
## 2.1.0
- **网站挂了会自己救**:中转服务器上常驻一个监测单元,每 60 秒从**公网**
查一次(不是查本机 —— 本机通不代表用户能打开)。连续 3 次不通就按阶梯
自动抢救:
| 级 | 动作 | 代价 |
|---|---|---|
| 1 | 重启隧道 | 不动中转,在线设备不掉线 |
| 2 | 重启中转服务 | 设备要重连,但很快 |
| 3 | 重新施加网络调优 | MSS / MTU 跑偏时用 |
| 4 | 重启无线网卡 | 4G / WiFi 卡死时用 |
| 5 | 重启整机 | 最后的底牌,**默认不开** |
每做完一级立刻复验,好了就停。救完 10 分钟内不再动手(防抖 ——
否则预案刚重启完隧道还没连上,下一次检查又救一遍,
来回重启反而永远连不上)。
- **网站出问题会自动留下诊断报告**:万一五级都没救回来,服务端会把现场
(内存、进程、隧道连接数、路由和 MTU、三份日志尾部、内核异常)
整理成一份 txt 留在服务端,需要排查时直接取来看就行。
只保留最近 30 份,不会把设备塞满。
每次自动抢救成功,也会把「第几级奏效」记进知识库,
下次同类问题优先试历史上管用的那一级。
- **网络热门音效**:手表上新增「音效」按钮,点一下电脑就响。
内置 8 个(冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / 警报 / 鼓点 / 弹簧)。
> 说明一下**版权**:网上那些「冰冰冰」「啊哈哈哈」是有版权的真实录音,
> 不能打包进安装包。所以内置的是**程序合成**的版本(正弦/方波现场算),
> 名字对着感觉来,但不是那些录音本身。
> 想要一模一样的一手素材,把 mp3/wav 丢进电脑的 `data\sfx\` 目录,
> 点「刷新」就出现在列表里,照样能远程播。目录里放了一份说明。
- **修「安卓设置页一片空白」**:设置页建好了但**忘了挂到窗口上**
(少了 `root.addView`),`showOnly()` 让它可见也没用 —— 它根本不在
视图树里。已修,并加了断言盯着「每个面板都必须挂到 root、
并且纳入 showOnly()」—— 这类漏挂编译不报错、只在运行时表现为空白。
- **全量测试改成后台静默**:会开窗口的 6 个阶段(含开**全屏**那个)
现在跑在独立的 Windows 隐藏桌面上 —— 窗口是**真的**(全屏、可见、
几何、Win32 枚举都真实),但屏幕上什么都看不到,也不会锁你的屏。
顺带修了一个一直误判的检测:判断「锁屏」时只看 `OpenInputDesktop`
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
所以截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了。
改成看输入桌面的**名字**。现在锁屏也能跑全量:该 SKIP 的 SKIP,
该跑的照跑。
## 2.0.0
- **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 ——
+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
r'''让 deploy.sh 在装新机器时把「监测单元」也装上。
== 为什么必须做这一步 ==
监测单元是我新加的:monitor.sh 每 60 秒查一次公网,
挂了就叫 rescue.sh 抢救。但如果 deploy.sh 不认识它,
**新装的机器上监测根本不会启动** —— 而且不会报任何错,
只是静默地不跑。等网站真挂了才发现"预案没触发",那就晚了。
部署脚本里 watchdog 已经是个现成的样板(同样是 timer + oneshot),
照着加一份即可。
== 顺便 ==
deploy.sh 里本来就有"包里有才装"的写法(`if [ -f ... ]`),
这里也沿用 —— 老的服务端 zip(没有 monitor.sh)重跑部署时
不会因为找不到文件而报错。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "relay" / "deploy.sh"
ANCHOR = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true'''
NEW = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
fi
# ---- 网站监测单元(如果包里有)----
#
# 和看门狗的区别很重要:
# 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好"
# 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开"
#
# 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200,
# 看门狗也可能觉得没事;只有从公网测才发现 502。
if [ -f "$RELAY_DIR/monitor.sh" ]; then
mkdir -p "$RELAY_DIR/rescue_reports"
chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true
if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then
cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF'
# 紧急预案知识库
每次自动抢救成功,这里会追加一条「第几级奏效」。
以后遇到同类症状,监测单元会优先试历史上奏效的那一级。
分级(从代价最小到最大):
1 重启隧道 不动中转,在线设备不掉线
2 重启中转服务 设备要重连,但很快
3 重新施加网络调优 MSS / MTU 跑偏时用
4 重启无线网卡 4G / WiFi 卡死时用
5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1)
KEOF
fi
# 包里直接带了单元文件就用包里的(内容随版本更新);
# 没有就现场生成一份等价的 —— 兼容老 zip。
if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then
cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true
else
write_unit "$UNIT_DIR/lanremote-monitor.service" <<EOF
[Unit]
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=360
ExecStart=/bin/bash $RELAY_DIR/monitor.sh
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
StandardOutput=append:$RELAY_DIR/monitor.log
StandardError=append:$RELAY_DIR/monitor.log
[Install]
WantedBy=multi-user.target
EOF
fi
if [ -f "$RELAY_DIR/lanremote-monitor.timer" ]; then
cp "$RELAY_DIR/lanremote-monitor.timer" "$UNIT_DIR/" 2>/dev/null || true
else
write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF'
[Unit]
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=lanremote-monitor.service
[Install]
WantedBy=timers.target
EOF
fi
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true
systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true
say " 网站监测单元已启用(每 60 秒查一次公网)"'''
def main() -> int:
raw = DEPLOY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "lanremote-monitor.timer" in text:
print(" · deploy.sh 已经加过了")
return 0
if ANCHOR not in text:
print(" ✗ 找不到锚点")
return 1
if text.count(ANCHOR) != 1:
print(" ! 锚点出现 %d 次" % text.count(ANCHOR))
text = text.replace(ANCHOR, NEW, 1)
# 原来 watchdog 那个 if 块的 fi 会被我们的 NEW 提前收掉,
# 所以要把后面多余的那个 fi 去掉一个。先看看形状。
import re
DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ deploy.sh 加了监测单元的安装")
return 0
if __name__ == "__main__":
sys.exit(main())
+261
View File
@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
r'''后台加一个「抢救报告」分区。
用户要求:
「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区
专门放还有下载这个 txt」
rescue.sh 抢救失败时会往 relay/rescue_reports/ 写一份诊断报告 .txt。
这里让后台能:
* 列出这些报告(时间、大小、一句话结论)
* 点一下下载看内容
== 为什么不复用 /download/ ==
/download/ 是**给用户**的(安装包、APK),带 cf 长缓存
(max-age=604800, immutable)——因为文件名带版本号、内容永不改变。
抢救报告正相反:内容会变、是给管理员看的、不该被边缘缓存。
所以单独开一条 /rescue/<名字>,发 no-store。
'''
from __future__ import annotations
import html
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
ADMIN = ROOT / "relay" / "admin.py"
def patch(path: Path, pairs, label: str):
raw = path.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
results = []
for old, new, what in pairs:
if old not in text:
results.append(("✗", what, "找不到锚点"))
continue
if text.count(old) != 1:
results.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
results.append(("✓", what, ""))
print(" ── %s ──" % label)
for m, w, why in results:
print(" %s %s%s" % (m, w, (" —— " + why) if why else ""))
if any(m == "✗" for m, _w, _y in results):
return False
path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.encode("utf-8"))
return True
# ===========================================================================
# relay.py
# ===========================================================================
RELAY_LIST = '''
# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。
# 和 downloads/ 分开:那个是给用户的安装包(带长缓存),
# 这个是给管理员看的诊断(必须不缓存)。
RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports"
RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满)
def _safe_rescue_name(name: str) -> str:
"""校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。"""
name = (name or "").strip()
if not name or len(name) > 80:
return ""
if "/" in name or "\\\\" in name or ".." in name:
return ""
if not name.endswith(".txt"):
return ""
for ch in name:
if ch.isalnum() or ch in "._-":
continue
return ""
return name
def _list_rescue_reports() -> list:
"""列出抢救报告(按时间倒序)。
顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示,
不用一个个点进去看。
"""
items = []
try:
if not RESCUE_DIR.is_dir():
return items
for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True):
if not entry.is_file():
continue
safe = _safe_rescue_name(entry.name)
if not safe:
continue
stat = entry.stat()
summary = ""
try:
with entry.open("r", encoding="utf-8", errors="replace") as fh:
head = fh.read(1200)
for line in head.splitlines():
s = line.strip()
if s.startswith("判断:"):
summary = s.replace("判断:", "").strip()
break
if not summary:
for line in head.splitlines():
s = line.strip()
if s.startswith("公网 ") and "->" in s:
summary = s
break
except OSError:
pass
items.append({
"name": entry.name,
"size": stat.st_size,
"mtime": stat.st_mtime,
"summary": summary[:120],
})
except OSError:
return items
items.sort(key=lambda i: i["mtime"], reverse=True)
return items[:RESCUE_KEEP]
'''
ADMIN_CALL = ''' admin_path=ADMIN_PATH,'''
ADMIN_CALL_NEW = ''' admin_path=ADMIN_PATH,
rescue_fn=_list_rescue_reports,'''
# 下载路由(GET):插在 /download/ 之前
RESCUE_ROUTE_ANCHOR = ''' if path.startswith("/download/"):
wanted = urllib.parse.unquote(path[len("/download/"):])
safe = _safe_download_name(wanted)'''
RESCUE_ROUTE = ''' if path.startswith("/rescue/"):
# 抢救报告下载。单独一条路由,**不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
'''
# ===========================================================================
# admin.py
# ===========================================================================
ADMIN_SIG = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int,
downloads_fn) -> str:'''
ADMIN_SIG_NEW = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int,
downloads_fn, rescue_fn=None) -> str:'''
ADMIN_CARD_ANCHOR = ''' <div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
</div>'''
ADMIN_CARD = ''' <div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
<div class="tool">
<h4>抢救报告</h4>
<div class="d">网站挂了、自动预案也没救回来时,机器会把现场
打包成一份 txt 放在这里。点开看看是哪一段断了。</div>
<div id="rescueBody">%(rescue)s</div>
</div>
</div>'''
def _rescue_rows(items: list) -> str:
"""抢救报告列表的 HTML。照 dl_rows 的风格(页面里直接渲染)。"""
if not items:
return ('<div class="out">还没有报告 —— 说明网站一直没出过'
'「自动救不回来」的问题。</div>')
import time as _t
import urllib.parse as _up
rows = []
for it in items:
when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"]))
size = it["size"] / 1024
size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"])
summary = html.escape(it.get("summary") or "(没有结论行)")
href = "/rescue/" + _up.quote(it["name"])
rows.append(
'<div class="dlrow"><span class="nm">'
'<a href="%s" style="color:#0a84ff">%s</a>'
'<span style="color:#6b7280;font-size:12px;margin-left:8px">'
'%s</span></span>'
'<span class="sz" style="max-width:46%%;text-align:right">'
'%s</span></div>'
% (href, when, size_s, summary))
return "".join(rows)
def main() -> int:
ok = True
ok &= patch(RELAY, [
("DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"",
"DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"\n"
+ RELAY_LIST, "加 rescue_reports 列表函数"),
(RESCUE_ROUTE_ANCHOR, RESCUE_ROUTE + RESCUE_ROUTE_ANCHOR,
"加 /rescue/ 下载路由(no-store)"),
(ADMIN_CALL, ADMIN_CALL_NEW, "把 rescue_fn 传给后台页"),
], "relay.py")
ok &= patch(ADMIN, [
(ADMIN_SIG, ADMIN_SIG_NEW, "render_admin 加 rescue_fn 参数"),
(ADMIN_CARD_ANCHOR, ADMIN_CARD, "后台加「抢救报告」卡片"),
# 算报告列表(照 dl_rows 的写法)
(''' return """<!doctype html>''',
''' # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉)
try:
rescue_items = rescue_fn() if rescue_fn else []
except Exception: # noqa: BLE001
rescue_items = []
rescue_rows = _rescue_rows(rescue_items)
return """<!doctype html>''',
"算抢救报告列表"),
('"dl": dl_rows,', '"dl": dl_rows,\n "rescue": rescue_rows,',
"把报告列表塞进模板"),
], "admin.py")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+137
View File
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
r'''手表端加「音效」—— 用现成的 showChooser(),不新建面板。
== 为什么改方案 ==
第一版我给音效页写了完整的新面板(FrameLayout + ScrollView + 动态列表),
后来发现 MainActivity 里**已经有 showChooser(title, labels, Chooser)**
这个现成的选择器 —— pickVoice() 就是这么选 TTS 音色的。
用选择器的话:
* 不用新建面板 -> 不用碰 root.addView / showOnly(那两个地方
刚因为"漏挂"出过一次全白的事故)
* 代码量少一半,出错面小得多
* 而且和"选音色"的操作手感一致,用户不用学两套
所以改成:首页加一个「音效」按钮 -> 弹出音效列表 -> 点哪个放哪个。
== 服务端配合 ==
服务端返回的是 {"sfx":[{"name":...},...]} 这种嵌套结构,
而手表的 stringList(reply, key) 只认扁平的字符串数组。
audio_list 早就这么做了(同时返回 files / names / paths),
这里跟着加一个 "names",手表直接用。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
/ "MainActivity.java")
# ---------------------------------------------------------------- 方法
METHOD_ANCHOR = " /** 选择电脑上的 TTS 音色。 */"
METHOD_NEW = ''' /** 让电脑放一个音效(网络热门音效那类)。
*
* 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明:
* pickVoice() 已经是这个套路,复用省事也更不容易出错。
*/
private void pickSfx() {
appendLog("正在获取音效…");
io.execute(new Runnable() {
@Override
public void run() {
try {
final Map<String, Object> reply =
client.call(Json.obj("cmd", "sfx_list"), 15000);
// 服务端同时给了扁平的 names(和 audio_list 一个约定)
final List<String> names = stringList(reply, "names");
final String dir = Json.str(reply, "dir", "");
ui.post(new Runnable() {
@Override
public void run() {
if (names.isEmpty()) {
toast("电脑上还没有音效(或没连上)");
appendLog("音效列表是空的。"
+ (dir.isEmpty() ? "" : "素材目录:" + dir));
return;
}
showChooser("音效(点一下电脑就响)", names,
new Chooser() {
@Override
public void onPick(int index) {
String one = names.get(index);
sendCommand(Json.obj("cmd", "sfx_play",
"name", one), "音效 " + one);
}
});
}
});
} catch (final IOException e) {
postError("获取音效", e);
}
}
});
}
''' + METHOD_ANCHOR
def main() -> int:
text = SRC.read_text(encoding="utf-8")
out: list = []
def patch(old: str, new: str, what: str) -> None:
nonlocal text
if old not in text:
out.append(("✗", what, "找不到锚点"))
return
if text.count(old) != 1:
out.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
out.append(("✓", what, ""))
# 1) 插方法
patch(METHOD_ANCHOR, METHOD_NEW, "插入 pickSfx()")
# 2) 首页加按钮(放在「音效」该在的位置:设置按钮之后)
patch(''' box.addView(sfxButton);''', ''' box.addView(sfxButton);''',
"(占位,见下)") if False else None
patch(''' box.addView(relayButton);''',
''' box.addView(relayButton);
// 音效:点一下电脑就响。用户要求"可以直接远程播放"。
Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14);
LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(52));
sfxParams.setMargins(0, 0, 0, dp(8));
sfxButton.setLayoutParams(sfxParams);
sfxButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
pickSfx();
}
});
box.addView(sfxButton);''',
"首页加「音效」按钮")
print(" ── 补丁结果 ──")
for mark, what, why in out:
print(" %s %s%s" % (mark, what, (" —— " + why) if why else ""))
if any(m == "✗" for m, _w, _y in out):
print()
print(" 有锚点没匹配,没有写回")
return 1
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 已写回 MainActivity.java")
return 0
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -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())
+104
View File
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
r'''给 /rescue/ 加上管理鉴权。
== 问题 ==
我把 /rescue/ 路由插在 do_GET 的第 1531 行,而管理页的登录校验
(self._admin_gate())在第 1723 行 —— **路由在鉴权之前**。
也就是说:任何人只要知道
https://relay.xxmxxs.top/rescue/20261008-173000.txt
就能直接下载诊断报告。那里面有什么:
* 内网 IP(192.168.2.x)、主机名
* 进程列表、内存占用
* 中转和隧道的日志尾部
* MTU / 路由 / iptables 规则
这些是**运维信息**,不该公开。对比一下:/download/ 是给用户的
安装包,公开是对的;/rescue/ 是给自己看的,必须登录。
(同样的问题 /shot/ 也有 —— 那个是屏幕共享截图,本来就该拦。
这次先修 /rescue/,顺手看一眼 /shot/。)
== 改法 ==
在 /rescue/ 分支开头加一句 self._admin_gate(),
和 /v1/admin/* 那些接口一样。do_GET 和 do_HEAD 都要加。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
# do_GET 里的(有 body 的那份)
GET_OLD = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
GET_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
#
# ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、
# MTU/路由/iptables —— 是运维信息,不是给所有人看的。
# 第一版把这条路由插在 _admin_gate() **之前**,
# 等于匿名可下,实测踩到。
if not self._admin_gate():
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
# do_HEAD 里的(只有 headers)
HEAD_OLD = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
# 同样要登录(见 do_GET 那段注释)。
if not self._admin_gate():
return no()
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
def main() -> int:
raw = RELAY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
changed = []
for old, new, what in ((GET_OLD, GET_NEW, "do_GET"),
(HEAD_OLD, HEAD_NEW, "do_HEAD")):
if new.split("\n")[0] in text and "_admin_gate" in text.split(old)[0][-600:]:
changed.append(("·", what, "好像已经加过了"))
continue
if old not in text:
changed.append(("✗", what, "找不到锚点"))
continue
text = text.replace(old, new, 1)
changed.append(("✓", what, "已加鉴权"))
print(" ── 结果 ──")
for m, w, why in changed:
print(" %s %-10s %s" % (m, w, why))
if any(m == "✗" for m, _w, _y in changed):
return 1
RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
print(" 已写回 relay.py")
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
r'''解开"nettune 死锁",然后把根因修掉。
== 死锁是怎么回事 ==
lanremote-nettune.service:
Before=lanremote-tunnel.service <- 关键
ExecStart=/bin/bash net-tune.sh
net-tune.sh 第 4 步:
systemctl restart lanremote-tunnel <- 在这里永远阻塞
为什么阻塞:systemd 正在启动 nettune,而 nettune 声明了"我必须在
隧道之前启动完"。脚本却在**自己还没结束的时候**去 restart 隧道 ——
那要求隧道先停、再等 nettune 结束……systemd 检测到这是顺序冲突,
restart 就一直挂着不返回。
证据:CGroup 里能看到 net-tune.sh(13388)下面挂着
systemctl restart lanremote-tunnel(13428),
etime 09:32 —— 卡了 9 分半,nettune 一直是 activating。
后果:nettune 卡住 -> 隧道被 Before= 挡着起不来 -> 公网 530。
这就是用户说的"网站还是容易挂"的真因之一。
== 为什么以前没暴露 ==
设备上原来那份 unit 的 ExecStart 里有个 `iptables ... || ...`,
systemd 不支持 ||,所以它**在第 2 条 ExecStart 就报错退出了**,
根本没走到"重启隧道"那一步 —— 死锁被那个 bug 遮住了。
我把 unit 换成正确形状之后,死锁才露出来。
== 这一步做什么 ==
1. 杀掉卡住的 systemctl 和 net-tune.sh(解开当前死锁)
2. 拉起隧道(网站先恢复)
3. 把 net-tune.sh 的"重启隧道"改成**只在手动跑时做**
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
echo "════ 1. 解开死锁 ════"
# 先杀卡住的 systemctl(不然它会一直拽着 systemd 的 job)
pkill -f 'systemctl restart lanremote-tunnel' 2>/dev/null && echo " 杀了卡住的 systemctl" || echo " 没有卡住的 systemctl"
sleep 2
# 再让 nettune 的脚本结束(杀它的子进程已经让主脚本继续往下走了)
systemctl stop lanremote-nettune 2>/dev/null
systemctl reset-failed lanremote-nettune 2>/dev/null
sleep 2
echo " nettune 现在: $(systemctl is-active lanremote-nettune)"
echo
echo "════ 2. 拉起隧道(网站先恢复)════"
systemctl reset-failed lanremote-tunnel lanremote-tunnel-h2 2>/dev/null
systemctl start lanremote-tunnel lanremote-tunnel-h2
sleep 15
echo " tunnel: $(systemctl is-active lanremote-tunnel)"
echo " tunnel-h2: $(systemctl is-active lanremote-tunnel-h2)"
echo
echo "════ 3. 验公网 ════"
for i in 1 2 3; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health)
echo " 第 $i 次: $code"
[ "$code" = "200" ] && break
sleep 6
done
echo
echo "════ 4. 隧道连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
echo " $p -> ${n:-拿不到}"
done
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+24
View File
@@ -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():
+96
View File
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
r'''看一眼中转服务器的完整状态。
为什么写成文件:这一轮我用 `python -c @"..."@` 传长命令失败了好几次 ——
PowerShell 的双引号 heredoc 会做变量插值、还会把 `$(...)` 当子表达式
在本机执行(systemctl / iptables 跑到 Windows 上了)。凡是超过两行
的远端命令,一律写成文件跑。
用法:
python _build/check_device.py
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
echo "════ 服务 ════"
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
done
printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer 2>&1)"
printf ' %-24s %s\n' "watchdog.timer" "$(systemctl is-active lanremote-watchdog.timer 2>&1)"
echo
echo "════ 网络 ════"
printf ' %-24s %s\n' "wlan0 MTU" "$(cat /sys/class/net/wlan0/mtu 2>/dev/null)"
printf ' %-24s %s\n' "MSS 规则" "$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条"
printf ' %-24s %s\n' "tcp_mtu_probing" "$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)"
printf ' %-24s %s\n' "默认路由" "$(ip route show default 2>/dev/null | head -1)"
echo
echo "════ 内存 / 运行时长 ════"
printf ' %-24s %s\n' "运行时长" "$(uptime -p 2>/dev/null)"
free -m 2>/dev/null | sed -n '2p' | awk '{printf " %-24s %s/%s MB\n", "内存 used/total", $3, $2}'
echo
echo "════ 隧道连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 "http://127.0.0.1:$p/metrics" 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
printf ' %-24s %s\n' "指标端口 $p" "${n:-拿不到}"
done
echo
echo "════ 公网自测 ════"
printf ' %-24s %s\n' "公网 /v1/health" "$(curl -s -o /dev/null -w '%{http_code} (%{time_total}s)' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)"
printf ' %-24s %s\n' "本机 /v1/health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/v1/health 2>/dev/null)"
printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/ 2>/dev/null)"
printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/rescue/none.txt 2>/dev/null)"
echo
echo "════ 监测单元状态 ════"
cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' || echo " (还没有状态文件)"
echo " ── monitor.log 尾部 ──"
tail -5 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' || echo " (还没有日志)"
echo
echo "════ 抢救报告 ════"
n=$(ls $RELAY/rescue_reports 2>/dev/null | wc -l)
echo " 共 $n 份"
ls -la $RELAY/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{printf " %s %s\n", $6" "$7" "$8, $9}'
echo
echo "════ 知识库 ════"
if [ -f $RELAY/rescue_knowledge.md ]; then
echo " 行数:$(wc -l < $RELAY/rescue_knowledge.md)"
tail -6 $RELAY/rescue_knowledge.md | sed 's/^/ /'
else
echo " (没有)"
fi
echo
echo "════ 定时器下次触发 ════"
systemctl list-timers lanremote-monitor.timer lanremote-watchdog.timer --no-pager 2>/dev/null | head -4 | sed 's/^/ /'
echo
echo "════ 后台页有没有「抢救报告」分区 ════"
printf ' 匹配次数: %s\n' "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告')"
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -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())
+115
View File
@@ -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())
+113
View File
@@ -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())
+137
View File
@@ -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())
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
r'''查 /rescue/ 为什么返回 HTTP 000。
000 是 curl 的"没拿到任何响应"。但同一个端口上 /v1/health 是好的、
目录穿越请求也能拿到 404 —— 说明不是端口不通,是**特定路径**出问题。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 造一份报告 ════"
printf '测试报告\n\n 判断:测试结论\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
ls -la $RELAY/rescue_reports/ | sed 's/^/ /'
echo
echo "════ 对照:几个路径的返回 ════"
for p in /v1/health /rescue/19700101-000000-demo.txt /rescue/nonexistent.txt /rescue/; do
printf ' %-42s -> %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 "$B$p")"
done
echo
echo "════ 详细看 /rescue/ 那次请求 ════"
curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | head -25 | sed 's/^/ /'
echo
echo "════ relay 日志尾部(有没有 Traceback)════"
journalctl -u lanremote-relay --no-pager -n 30 2>/dev/null | tail -30 | sed 's/^/ /'
echo
echo "════ 后台密码文件在不在 ════"
ls -la $RELAY/data/admin_password.txt 2>&1 | sed 's/^/ /'
echo " (长度 $(wc -c < $RELAY/data/admin_password.txt 2>/dev/null || echo 0))"
echo
echo "════ 清理 ════"
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt
'''
def main() -> int:
out = run(REMOTE, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+175
View File
@@ -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())
+285
View File
@@ -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())
+119
View File
@@ -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())
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
r'''把「网站监测 + 紧急预案 + 抢救报告分区」部署到中转服务器。
部署的东西:
relay.py / admin.py 后台多一个「抢救报告」分区 + /rescue/ 下载
monitor.sh / rescue.sh 监测单元 + 紧急预案
lanremote-monitor.service/.timer 每 60 秒拉一次 monitor.sh
lanremote-nettune.service 顺手修掉设备上那份过期的(见下)
== 顺手修的设备问题 ==
设备上那份 lanremote-nettune.service 是**很早以前的版本**,有两个毛病:
1. ExecStart 里写了 `iptables ... || iptables ...`
systemd 的 ExecStart **不支持 `||`** —— 它把 `||` 当成一个参数,
iptables 报 `Bad argument '||'`,服务直接 exit 2 失败。
2. 里面有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己
实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。
**两条 ExecStart 顺序是 net-tune.sh 先、1400 后**,所以最终是 1400 ——
也就是说这份过期配置一直在主动损害稳定性。
本地 relay/deploy.sh 生成的是对的(单入口 -> deploy-tune.sh)。
这里直接按那个正确形状重写设备上的 unit。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import upload, run # noqa: E402
RELAY_DIR = "/root/lanremote-relay"
UNIT_DIR = "/etc/systemd/system"
FILES = [
"relay.py", "admin.py", "landing.py", "showcase.py",
"monitor.sh", "rescue.sh", "net-tune.sh", "watchdog.sh",
"deploy-tune.sh",
]
NETTUNE_UNIT = '''[Unit]
Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测)
After=network-online.target
Wants=network-online.target
Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service
[Service]
Type=oneshot
RemainAfterExit=yes
# ⚠ 只跑 net-tune.sh 这一个入口 —— MSS / 接口 MTU / 路径探测的唯一出处。
#
# 设备上原来那份 unit 有两条毛病:
# * ExecStart 里写了 `iptables ... || iptables ...`
# systemd 的 ExecStart **不支持 `||`**,它把 `||` 当参数传给
# iptables,报 `Bad argument '||'`,服务直接 exit 2 失败。
# * 还有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己
# 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。
# 两条 ExecStart 是 net-tune.sh 先、1400 后,所以最终停在 1400 ——
# 这份过期配置一直在主动损害稳定性。
#
# (仓库里的 deploy.sh 生成的是 `ExecStart=/bin/bash deploy-tune.sh`
# 那种形状,但设备上并没有 deploy-tune.sh,所以这里直接用
# net-tune.sh,和设备的实际情况对齐。
# deploy-tune.sh 也一并传上去了,以后想切回单入口随时可以。)
ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh
[Install]
WantedBy=multi-user.target
'''
def main() -> int:
print("=" * 62)
print(" 部署 监测单元 + 紧急预案 + 抢救报告分区")
print("=" * 62)
print()
print(" ── 1. 上传文件 ──")
for name in FILES:
local = ROOT / "relay" / name
if not local.is_file():
print(" [跳过] %s 不存在" % name)
continue
upload(str(local), "%s/%s" % (RELAY_DIR, name))
print(" ✓ %s" % name)
for unit in ("lanremote-monitor.service", "lanremote-monitor.timer"):
local = ROOT / "relay" / unit
upload(str(local), "%s/%s" % (UNIT_DIR, unit))
print(" ✓ %s -> %s" % (unit, UNIT_DIR))
print()
print(" ── 2. 修掉过期的 nettune 服务(MTU 打架 + ExecStart 里的 ||)──")
import tempfile
tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service"
tmp.write_text(NETTUNE_UNIT, encoding="utf-8", newline="\n")
upload(str(tmp), "%s/lanremote-nettune.service" % UNIT_DIR)
print(" ✓ 已替换")
print()
print(" ── 3. 语法检查 ──")
out = run(
"cd %s && for f in relay.py admin.py landing.py showcase.py; do "
"python3 -c \"import ast,sys; ast.parse(open(sys.argv[1],encoding='utf-8').read())\" $f "
"|| exit 1; done && echo PY_OK; "
"bash -n monitor.sh && bash -n rescue.sh && echo SH_OK; "
"systemd-analyze verify %s/lanremote-monitor.service 2>&1 | head -3 || true"
% (RELAY_DIR, UNIT_DIR), timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 4. 建目录 + 知识库 + 重载并启用 ──")
out = run(
"mkdir -p %s/rescue_reports && chmod +x %s/monitor.sh %s/rescue.sh && "
"[ -f %s/rescue_knowledge.md ] || printf '# 紧急预案知识库\\n\\n"
'每次自动抢救成功,这里会记一条"第几级奏效"。\\n'
'以后遇到同类症状,监测单元会优先试历史上奏效的那一级。\\n" > %s/rescue_knowledge.md; '
"systemctl daemon-reload && "
"systemctl restart lanremote-nettune.service; sleep 3; "
"systemctl enable --now lanremote-monitor.timer 2>&1 | tail -1; "
"sleep 2; systemctl is-active lanremote-monitor.timer"
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 5. 复核 ──")
out = run(
"echo 'nettune: '$(systemctl is-active lanremote-nettune.service); "
"echo 'monitor.timer: '$(systemctl is-active lanremote-monitor.timer); "
"echo 'MTU: '$(cat /sys/class/net/wlan0/mtu); "
"echo 'MSS 规则: '$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss)' 条'; "
"echo '报告目录: '$(ls %s/rescue_reports 2>/dev/null | wc -l)' 份'; "
"echo '后台分区: '$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:8080/rescue/nonexistent.txt)"
% RELAY_DIR, timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" 提示:监测单元默认每 60 秒查一次公网,连续 3 次不通才动手;")
print(" 抢救完 10 分钟内不再动手(防抖,避免来回重启)。")
print(" 日志在 %s/monitor.log 和 %s/rescue.log。" % (RELAY_DIR, RELAY_DIR))
return 0
if __name__ == "__main__":
sys.exit(main())
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
r'''看当前桌面/窗口的真实状态 —— 诊断"桌面消失、一片蓝"。
用户报:"我现在桌面消失了,电脑一片蓝,然后还有个蓝色的窗口"。
已知:
18:42:41 机器异常关机(Kernel-Power 41 / EventLog 6008,无蓝屏记录)
19:58:23 重新启动
20:01:37 explorer 起来
自启项里没有 ykxmz,当前也没有 ykxmz 在跑
要查的是:现在屏幕上到底有什么、是不是有全屏窗口盖住了桌面。
'''
from __future__ import annotations
import ctypes
import ctypes.wintypes as wt
import subprocess
user32 = ctypes.WinDLL("user32", use_last_error=True)
EnumWindows = user32.EnumWindows
EnumWindows.argtypes = [ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM),
wt.LPARAM]
class RECT(ctypes.Structure):
_fields_ = [("left", wt.LONG), ("top", wt.LONG),
("right", wt.LONG), ("bottom", wt.LONG)]
def windows() -> list:
out = []
def cb(hwnd, _lp):
if not user32.IsWindowVisible(hwnd):
return True
title = ctypes.create_unicode_buffer(512)
user32.GetWindowTextW(hwnd, title, 512)
cls = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls, 256)
r = RECT()
user32.GetWindowRect(hwnd, ctypes.byref(r))
pid = wt.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
w, h = r.right - r.left, r.bottom - r.top
if w > 80 and h > 80:
out.append({"hwnd": hwnd, "title": title.value, "cls": cls.value,
"pid": pid.value, "w": w, "h": h,
"x": r.left, "y": r.top})
return True
EnumWindows(ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)(cb), 0)
return out
def proc_name(pid: int) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-Process -Id %d -EA SilentlyContinue).ProcessName" % pid],
capture_output=True, text=True, timeout=8)
return (r.stdout or "").strip() or "?"
except Exception: # noqa: BLE001
return "?"
def main() -> int:
sw = user32.GetSystemMetrics(0)
sh = user32.GetSystemMetrics(1)
print(" 屏幕: %d x %d" % (sw, sh))
print(" 当前输入桌面: %s" % _desktop_name())
print()
ws = windows()
ws.sort(key=lambda w: w["w"] * w["h"], reverse=True)
print(" 可见窗口 %d 个(按面积排序,前 20):" % len(ws))
for w in ws[:20]:
full = w["w"] >= sw - 4 and w["h"] >= sh - 4
pn = proc_name(w["pid"])
print(" %s %-14s pid=%-6d %dx%d @(%d,%d)"
% ("★全屏" if full else " ", pn, w["pid"],
w["w"], w["h"], w["x"], w["y"]))
print(" 类=%-22s 标题=%r" % (w["cls"][:22], w["title"][:60]))
print()
fulls = [w for w in ws if w["w"] >= sw - 4 and w["h"] >= sh - 4]
if fulls:
print(" ⚠ 有 %d 个全屏窗口 —— 下面这些就是盖住桌面的嫌疑:" % len(fulls))
for w in fulls:
print(" pid=%d (%s) 类=%s 标题=%r"
% (w["pid"], proc_name(w["pid"]), w["cls"], w["title"][:50]))
else:
print(" ✓ 没有全屏窗口盖着")
print()
ex = subprocess.run(["powershell", "-NoProfile", "-Command",
"Get-Process explorer -EA SilentlyContinue | "
"Select-Object -First 1 -ExpandProperty Id"],
capture_output=True, text=True, timeout=10)
print(" explorer PID: %s" % ((ex.stdout or "").strip() or "**没在跑**"))
return 0
def _desktop_name() -> str:
"""当前输入桌面的名字。Default = 正常,Winlogon = 锁屏。"""
h = user32.OpenInputDesktop(0, False, 0x0001)
if not h:
return "(拿不到 —— 可能锁屏或没有交互桌面)"
try:
sb = ctypes.create_unicode_buffer(256)
need = wt.DWORD()
ok = user32.GetUserObjectInformationW(h, 2, sb, 512,
ctypes.byref(need))
return sb.value if ok else "(拿不到名字)"
finally:
user32.CloseDesktop(h)
if __name__ == "__main__":
import sys
sys.exit(main())
+1 -1
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
r'''查后台「抢救报告」分区为什么不显示。
可能原因:
1. 后台页要登录,curl 直接拿是登录页(那 0 次是正常的,得带 cookie)
2. relay.py 没真正重新加载(旧进程还在)
3. 模板参数没传进去
一条条排除。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
echo "════ 1. 直接 curl 后台页,看返回的是什么 ════"
curl -s --max-time 8 -D- http://127.0.0.1:8080/zhongzhuanzhan -o /tmp/adm.html 2>&1 | head -8
echo " --- 页面大小: $(wc -c < /tmp/adm.html) 字节 ---"
echo " --- 前 3 行 ---"
head -3 /tmp/adm.html | sed 's/^/ /'
echo " --- 含关键词统计 ---"
for k in 登录 密码 password 抢救报告 下载区 设备; do
printf ' %-12s %s\n' "$k" "$(grep -c "$k" /tmp/adm.html 2>/dev/null)"
done
echo
echo "════ 2. relay.py 里到底有没有 rescue_fn ════"
grep -n 'rescue_fn' $RELAY/relay.py | head -5 | sed 's/^/ /'
echo " admin.py 里 _rescue_rows:"
grep -n 'def _rescue_rows\|def render_admin' $RELAY/admin.py | head -4 | sed 's/^/ /'
echo
echo "════ 3. 进程是什么时候起的(比文件新还是旧)════"
ps -o pid,lstart,cmd -p $(systemctl show -p MainPID --value lanremote-relay) 2>/dev/null | sed 's/^/ /'
echo " relay.py 修改时间: $(stat -c '%y' $RELAY/relay.py)"
echo " admin.py 修改时间: $(stat -c '%y' $RELAY/admin.py)"
echo
echo "════ 4. 无登录直接调 render_admin 看看(绕过 HTTP)════"
cd $RELAY && python3 -c "
import admin
html = admin.render_admin({'version':'2.0.0','uptimeSeconds':1,'devices':[],'allowNew':True},
'https://x','/zhongzhuanzhan',0, lambda: [],
rescue_fn=lambda: [{'name':'20261008-120000.txt','size':1234,
'mtime':1791400000,
'summary':'测试摘要'}] )
print(' 渲染长度:', len(html))
print(' 含抢救报告:', '抢救报告' in html)
print(' 含测试摘要:', '测试摘要' in html)
print(' 含下载链接:', '/rescue/20261008-120000.txt' in html)
" 2>&1 | sed 's/^/ /'
'''
def main() -> int:
out = run(REMOTE, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
r'''查 nettune 为什么卡在 activating(它挡着隧道,隧道一挂网站就 530)。
发现:
lanremote-nettune activating <- 卡住
lanremote-tunnel inactive <- 在等 nettune
lanremote-tunnel-h2 inactive
公网 530
nettune 的 unit 里有 Before=lanremote-tunnel.service,
所以它卡住 = 隧道永远起不来。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
echo "════ 1. nettune 的完整状态 ════"
systemctl status lanremote-nettune --no-pager -n 25 2>&1 | tail -25
echo
echo "════ 2. 有没有卡住的子进程 ════"
systemctl show -p MainPID -p ControlPID -p ExecMainStatus -p StateChangeTimestamp lanremote-nettune 2>&1
echo " --- 所有相关进程 ---"
ps -eo pid,ppid,stat,etime,cmd 2>/dev/null | grep -E 'iptables|net-tune|deploy-tune|ip link' | grep -v grep
echo
echo "════ 3. net-tune.sh 里会不会卡住的那步 ════"
echo " --- 脚本第 4 步(重启隧道)会 wait 吗 ---"
grep -n 'systemctl restart' /root/lanremote-relay/net-tune.sh
echo
echo "════ 4. 手动跑一次 net-tune.sh 看卡在哪(30 秒超时)════"
timeout 30 bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -15
echo " 退出码: $?"
echo
echo "════ 5. 卡住的时候隧道是什么状态 ════"
systemctl is-active lanremote-tunnel lanremote-tunnel-h2
journalctl -u lanremote-tunnel --no-pager -n 12 2>/dev/null | tail -12
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+102
View File
@@ -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())
+103
View File
@@ -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())
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
r'''查隧道为什么掉连接(公网 530,连接数 1/0)。
上一轮才刚修掉 nettune 死锁、把公网拉到 200;这次重启 nettune 之后
又变成 530 了。要分清是:
* nettune 又卡了(那个死锁没修干净?)
* 还是隧道自己掉了(cloudflared 的问题)
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
echo "════ 1. 服务状态 + 有没有卡住的 ════"
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s)"
done
echo " --- nettune 卡住吗 ---"
systemctl show -p SubState -p MainPID -p ExecMainStartTimestamp lanremote-nettune 2>&1 | sed 's/^/ /'
echo " --- 有没有卡住的 systemctl ---"
ps -eo pid,etime,cmd 2>/dev/null | grep -E 'systemctl|net-tune' | grep -v grep | sed 's/^/ /'
echo
echo "════ 2. net-tune 的修复在设备上吗 ════"
grep -c NETTUNE_SERVICE_CALL /root/lanremote-relay/net-tune.sh
echo " unit 里有吗: $(grep -c NETTUNE_SERVICE_CALL /etc/systemd/system/lanremote-nettune.service)"
echo
echo "════ 3. 隧道为什么连接数是 0 ════"
journalctl -u lanremote-tunnel --no-pager -n 25 2>/dev/null | tail -25 | sed 's/^/ /'
echo
echo "════ 4. 隧道 unit 长什么样(重启策略 / 协议)════"
systemctl cat lanremote-tunnel 2>/dev/null | grep -vE '^#' | head -25 | sed 's/^/ /'
echo
echo "════ 5. cloudflared 进程 ════"
ps -eo pid,etime,rss,cmd 2>/dev/null | grep cloudflared | grep -v grep | sed 's/^/ /'
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+66
View File
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
r'''看隧道为什么反复掉(公网 530 的真因)。
已知:
nettune 死锁已修(SubState=exited,不再卡)
但隧道每 3~10 分钟被重启一次(部分是我加的预案在救、部分是它自己掉)
公网 530
要查的是 tunnel.log 里 cloudflared 到底在报什么。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
LOG=/root/lanremote-relay/tunnel.log
H2LOG=/root/lanremote-relay/tunnel-h2.log
echo "════ 1. tunnel.log 里的错误行(最近 40 条)════"
grep -iE 'error|fail|warn|timeout|retry|refused|EOF|unregister|connection' "$LOG" 2>/dev/null | tail -40 | sed 's/^/ /'
echo
echo "════ 2. tunnel.log 最后 25 行(原样)════"
tail -25 "$LOG" 2>/dev/null | sed 's/^/ /'
echo
echo "════ 3. h2 那份的日志 ════"
tail -15 "$H2LOG" 2>/dev/null | sed 's/^/ /'
echo
echo "════ 4. 两份日志多大(写爆了会拖慢小机器)════"
ls -la "$LOG" "$H2LOG" 2>/dev/null | sed 's/^/ /'
echo
echo "════ 5. cloudflared 版本 + 配置 ════"
/usr/local/bin/cloudflared --version 2>&1 | sed 's/^/ /'
echo " --- config.yml ---"
cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /'
echo
echo "════ 6. 现在两条隧道的连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 "http://127.0.0.1:$p/metrics" 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
echo " $p -> ${n:-拿不到}"
done
echo
echo "════ 7. 公网现在通吗 ════"
echo " $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health)"
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+89
View File
@@ -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())
+2 -2
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
r'''修 deploy.sh 里 watchdog 块被我吃掉的两行。
== 出什么事了 ==
我用 heredoc(python -c @"..."@)改 _build/add_monitor_to_deploy.py 的锚点,
PowerShell 把里面的引号和 $ 打散了,脚本没改成;而**上一次**
(锚点写错的那次)已经把 deploy.sh 改了 —— 那次用的锚点是
systemctl enable lanremote-watchdog.timer ... || true
它把后面两行(start timer + say 状态)连带 `fi` 一起卷进了替换,
结果这两行就没了:
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
bash -n 仍然通过(语法没错,只是少了两行执行语句),
所以光靠语法检查发现不了 —— 这就是为什么改完还要**读回来核对**。
这个脚本把这两行补回去。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "relay" / "deploy.sh"
OLD = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
fi
# ---- 网站监测单元(如果包里有)----"""
NEW = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
fi
# ---- 网站监测单元(如果包里有)----"""
def main() -> int:
raw = DEPLOY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "systemctl start lanremote-watchdog.timer" in text:
print(" · 那两行已经在了")
return 0
if OLD not in text:
print(" ✗ 找不到锚点,需要人工看一眼")
return 1
text = text.replace(OLD, NEW, 1)
DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ 补回了 start timer 和 say 那两行")
return 0
if __name__ == "__main__":
sys.exit(main())
+96
View File
@@ -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())
+1 -1
View File
@@ -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",
+171
View File
@@ -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())
+165
View File
@@ -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())
+148
View File
@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
r'''永久修掉 nettune 死锁。
== 死锁回顾 ==
lanremote-nettune.service Before=lanremote-tunnel.service
net-tune.sh 第 4 步 systemctl restart lanremote-tunnel
nettune 在"自己还没结束"的时候去 restart 一个"必须等自己结束才能启动"
的服务 —— systemd 认为顺序冲突,restart 永久阻塞。
实测卡了 9 分半,nettune 一直 activating,隧道被 Before= 挡着起不来,
公网 530。这就是"网站容易挂"的真因之一。
== 修法 ==
第 4 步的本意是"让**已经建好**的隧道用上新参数"(改完 MTU/MSS 之后
手动重跑脚本时用)。**开机时根本不该走到那一步** ——
开机顺序本来就是 nettune 先、隧道后,那时隧道还没起。
所以:unit 里加 Environment=NETTUNE_SERVICE_CALL=1,
脚本看到这个变量就跳过第 4 步。
为什么不干脆删掉第 4 步:
手动改完网络参数后,已经建立的隧道还在用旧参数,
需要重启它才生效。这个功能是有用的,只是不该在服务启动时跑。
为什么不用 `systemctl restart --no-block`:
那只是把阻塞变成异步,nettune 立刻退出、隧道随后重启 ——
开机时仍然是无谓的一次重启(隧道刚起来就被停掉),
而且没解决"不该在开机时重启"这个语义问题。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import upload, run # noqa: E402
TUNE = ROOT / "relay" / "net-tune.sh"
OLD = '''echo " 4) 让已经建立的隧道用上新参数"'''
NEW = '''# ════════════════════════════════════════════════════════════════════
# 4) 让已经建立的隧道用上新参数 —— **只在手动跑的时候做**
# ════════════════════════════════════════════════════════════════════
#
# ⚠ 这一步绝对不能由 lanremote-nettune.service 执行。
#
# 那个 unit 里写着 Before=lanremote-tunnel.service
# (意思是"我必须先于隧道启动完")。如果脚本在自己还没结束的时候
# 去 `systemctl restart lanremote-tunnel`,systemd 会认为存在
# 顺序冲突 —— restart 永久阻塞、nettune 一直卡在 activating、
# 隧道被 Before= 挡着永远起不来。
#
# 实测卡了 9 分半,表现就是"网站又挂了",而日志里什么都看不出来
# (只有 nettune 停在 activating)。手动跑本脚本却完全正常
# (因为那时没有 Before= 约束)—— 所以这个坑特别难发现。
#
# 开机时本来也不需要这一步:顺序就是 nettune 先、隧道后,
# 那时候隧道还没起来。
if [ "${NETTUNE_SERVICE_CALL:-0}" = "1" ]; then
echo " 4) 由 nettune 服务调用,**跳过重启隧道**"
echo " (unit 里有 Before=lanremote-tunnel.service,"
echo " 在这里 restart 会和 systemd 的顺序约束死锁)"
echo " (开机顺序本来就是本脚本先、隧道后,不需要这一步)"
elif [ "${SKIP_TUNNEL_RESTART:-0}" = "1" ]; then
echo " 4) SKIP_TUNNEL_RESTART=1,跳过重启隧道"
else
echo " 4) 让已经建立的隧道用上新参数"'''
def main() -> int:
raw = TUNE.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "NETTUNE_SERVICE_CALL" in text:
print(" · net-tune.sh 已经修过了")
elif OLD not in text:
print(" ✗ 找不到锚点")
return 1
else:
text = text.replace(OLD, NEW, 1)
# 原来的 for 循环 + RESTARTED 逻辑要收进 else 分支
# 找到 "echo \" 网络调优已应用" 之前的部分,补一个 fi
tail_anchor = 'echo " 网络调优已应用'
i = text.find(tail_anchor)
if i < 0:
print(" ✗ 找不到结尾锚点")
return 1
# 在结尾那行之前插入 fi(缩进对齐 for 循环之外)
text = text[:i] + "fi\n\n" + text[i:]
TUNE.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ net-tune.sh 加了 NETTUNE_SERVICE_CALL 判断")
# ---- 推到设备 + 给 unit 加环境变量 ----
upload(str(TUNE), "/root/lanremote-relay/net-tune.sh")
print(" ✓ 已上传 net-tune.sh")
unit = '''[Unit]
Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测)
After=network-online.target
Wants=network-online.target
Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service
[Service]
Type=oneshot
RemainAfterExit=yes
# ⚠ NETTUNE_SERVICE_CALL=1 告诉 net-tune.sh:这次是**服务**在调它,
# 不是人工跑的。脚本据此跳过"重启隧道让新参数生效"那一步。
#
# 为什么必须跳过:本 unit 有 Before=lanremote-tunnel.service,
# 脚本在自己还没结束时去 restart 隧道,systemd 会判定顺序冲突,
# restart 永久阻塞 -> nettune 卡在 activating -> 隧道起不来 -> 公网 530。
# 实测卡过 9 分半。
Environment=NETTUNE_SERVICE_CALL=1
ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh
[Install]
WantedBy=multi-user.target
'''
import tempfile
tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service"
tmp.write_text(unit, encoding="utf-8", newline="\n")
upload(str(tmp), "/etc/systemd/system/lanremote-nettune.service")
print(" ✓ 已更新 unit(加了 NETTUNE_SERVICE_CALL=1)")
out = run("bash -n /root/lanremote-relay/net-tune.sh && echo SH_OK; "
"systemctl daemon-reload && "
"systemctl restart lanremote-nettune && sleep 6 && "
"echo \"nettune: $(systemctl is-active lanremote-nettune)\"; "
"echo \"tunnel: $(systemctl is-active lanremote-tunnel)\"; "
"echo \"h2: $(systemctl is-active lanremote-tunnel-h2)\"; "
"echo \"公网: $(curl -s -o /dev/null -w '%{http_code}' "
"--max-time 20 https://relay.xxmxxs.top/v1/health)\"",
timeout=180)
for ln in out[1].strip().splitlines():
print(" " + ln)
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -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
+139
View File
@@ -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())
+97
View File
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
r'''修 /rescue/ 鉴权失败时"不发响应"导致的挂起。
== 现象 ==
加上鉴权之后,不带登录请求 /rescue/xxx.txt 会**挂到超时**:
> GET /rescue/19700101-000000-demo.txt HTTP/1.1
* Operation timed out after 8001 milliseconds with 0 bytes received
curl 报 HTTP 000。
== 根因 ==
_admin_gate() **只返回布尔值,不负责发响应** —— 它是个判断函数。
所以每个调用点都必须自己在 false 时把响应发出去。
看其它调用点,全都是这么写的:
if not self._admin_gate():
self._json(401, {"ok": False, "error": "请先登录"})
return
而我加的那两处写成了:
if not self._admin_gate():
return <- do_GET:一个字节都没发
...
if not self._admin_gate():
return no() <- do_HEAD:no() 默认 404,状态码也不对
裸 return 在 BaseHTTPRequestHandler 里就是"什么都不做" ——
连接保持着、没有响应头、客户端一直等。表现成"网站卡住",
比报 401 严重得多。
== 改法 ==
do_GET -> self._json(401, ...) 然后 return
do_HEAD -> return no(401) (no() 本来就支持传状态码)
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
GET_OLD = ''' if not self._admin_gate():
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
GET_NEW = ''' # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** ——
# 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler
# 里等于"什么都不做":连接保持着、没有响应头,
# 客户端一直等到超时(curl 报 HTTP 000)。
# 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。
if not self._admin_gate():
self._json(401, {"ok": False, "error": "请先登录"})
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_OLD = ''' if not self._admin_gate():
return no()
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_NEW = ''' # 同上:必须自己发响应,而且状态码要是 401 不是 404
# (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。
if not self._admin_gate():
return no(401)
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
def main() -> int:
raw = RELAY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
ok = True
for old, new, what in ((GET_OLD, GET_NEW, "do_GET"),
(HEAD_OLD, HEAD_NEW, "do_HEAD")):
if old not in text:
print(" ✗ %s: 找不到锚点" % what)
ok = False
continue
text = text.replace(old, new, 1)
print(" ✓ %s: 鉴权失败时现在会发响应" % what)
if not ok:
return 1
RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
print(" 已写回 relay.py")
return 0
if __name__ == "__main__":
sys.exit(main())
+218
View File
@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
r'''补上 add_rescue_section.py 漏掉/插错的三处。
前一个补丁的问题:
1. _rescue_rows() 只在补丁脚本里定义,**没插进 admin.py** ——
后台一渲染就 NameError(页面直接 500)
2. /rescue/ 路由被插进了 **do_HEAD**,而且用了 `self.wfile.write(body)` ——
HEAD 本来就不该有 body。而且 do_GET 那边一条都没加,
等于浏览器点下载根本走不到。
(锚点 "/download/" 出现两次:do_HEAD 和 do_GET 各一份,
replace(...,1) 只命中了前面那个。这就是那个 "! 锚点 2 处" 警告。)
这个脚本按 do_HEAD 自己的 ok()/no() 风格把两处都写对。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
ADMIN = ROOT / "relay" / "admin.py"
def load(p: Path):
raw = p.read_bytes()
return raw, raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig")
def save(p: Path, text: str, bom: bool):
p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
# ===========================================================================
# 1. do_HEAD:换成 ok()/no() 风格(不能有 body)
# ===========================================================================
BAD_HEAD = ''' if path.startswith("/rescue/"):
# 抢救报告下载。单独一条路由,**不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
'''
GOOD_HEAD = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
return no()
target = RESCUE_DIR / safe
if not target.is_file():
return no()
quoted = urllib.parse.quote(safe)
headers = {"Content-Disposition":
"attachment; filename*=UTF-8''%s" % quoted,
"Cache-Control": "no-store"}
return ok("text/plain; charset=utf-8", target.stat().st_size,
headers)
'''
# ===========================================================================
# 2. do_GET:加同一条路由(这里才真正回 body)
# ===========================================================================
GET_ANCHOR = ''' if path.startswith("/download/"):
wanted = urllib.parse.unquote(path[len("/download/"):])
safe = _safe_download_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return'''
GET_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
''' + GET_ANCHOR
# ===========================================================================
# 3. admin.py:把 _rescue_rows 真正定义进去
# ===========================================================================
RESCUE_FN = '''def _rescue_rows(items: list) -> str:
"""抢救报告列表的 HTML。
照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 ——
这台机器只有 460MB 内存,多个接口就多份开销,而且后台页
本来就只是一次性的展示。
没有报告时给一句正面的话("一直没出过问题"),
而不是干巴巴的"暂无数据"—— 没报告是好事。
"""
if not items:
return ('<div class="out">还没有报告 —— 说明网站一直没出过'
'「自动救不回来」的问题。</div>')
import time as _t
import urllib.parse as _up
rows = []
for it in items:
when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"]))
size = it["size"] / 1024
size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"])
summary = _esc(it.get("summary") or "(没有结论行)")
href = "/rescue/" + _up.quote(it["name"])
rows.append(
'<div class="dlrow"><span class="nm">'
'<a href="%s" style="color:#0a84ff">%s</a>'
'<span style="color:#6b7280;font-size:12px;margin-left:8px">'
'%s</span></span>'
'<span class="sz" style="max-width:46%%;text-align:right">'
'%s</span></div>'
% (href, when, size_s, summary))
return "".join(rows)
def render_admin('''
def main() -> int:
ok = True
# ---- relay.py ----
raw, bom, text = load(RELAY)
if BAD_HEAD in text:
text = text.replace(BAD_HEAD, GOOD_HEAD, 1)
print(" ✓ do_HEAD 里的 /rescue/ 换成 ok()/no() 风格(不再写 body)")
elif GOOD_HEAD in text:
print(" · do_HEAD 已经是对的")
else:
print(" ✗ do_HEAD 里找不到那段 /rescue/")
ok = False
if 'if path.startswith("/rescue/")' in text.split("def do_GET")[1]:
print(" · do_GET 里已经有了")
else:
if GET_ANCHOR in text:
# 只替换 do_GET 里那一处(do_HEAD 那处已经在上面被换掉了,
# 所以现在全文只剩一处)
n = text.count(GET_ANCHOR)
if n != 1:
print(" ! do_GET 锚点还有 %d 处" % n)
text = text.replace(GET_ANCHOR, GET_NEW, 1)
print(" ✓ do_GET 加了 /rescue/ 路由(真正回 body 的那条)")
else:
print(" ✗ do_GET 里找不到 /download/ 锚点")
ok = False
save(RELAY, text, bom)
# ---- admin.py ----
raw, bom, text = load(ADMIN)
if "def _rescue_rows(" in text:
print(" · admin.py 里已经定义了 _rescue_rows")
else:
if "def render_admin(" not in text:
print(" ✗ admin.py 里找不到 render_admin")
ok = False
else:
text = text.replace("def render_admin(", RESCUE_FN, 1)
save(ADMIN, text, bom)
print(" ✓ admin.py 里补上了 _rescue_rows 的定义")
ok = ok and True
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+130
View File
@@ -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())
+139
View File
@@ -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())
+162
View File
@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
r'''增强隧道稳定性 —— 「网站还是容易挂」的真因修法。
== 查到什么 ==
cloudflared 自己的 precheck 输出(tunnel.log):
| UDP Connectivity region1.v2.argotunnel.com FAIL QUIC connection failed |
| UDP Connectivity region2.v2.argotunnel.com FAIL QUIC connection failed |
| TCP Connectivity region1.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable |
| TCP Connectivity region2.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable |
| Cloudflare API api.cloudflare.com:443 PASS API is reachable |
| ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2. |
也就是:**443 通,7844 不通**,而且是**间歇性**的
(日志后面又出现 Registered tunnel connection,过一阵又掉)。
Cloudflare 隧道的边缘连接走 7844(QUIC 走 UDP 7844、HTTP/2 走 TCP 7844)。
这条线路对 7844 是间歇性阻断的 —— 这不是设备能修的,
但**可以让 cloudflared 自己去适应**。
== 现在的配置有两个问题 ==
1. **两条实例各写死一个协议**
lanremote-tunnel --protocol quic (只走 UDP 7844)
lanremote-tunnel-h2 --protocol http2 (只走 TCP 7844)
如果那一刻 7844 的 UDP 和 TCP 都不通,两条一起死 —— 没有退路。
实测就是这样:连接数掉到 1/0,公网 530。
2. **--retries 5**
cloudflared 重试 5 次就**退出**,靠 systemd Restart=always 拉起来。
每次退出/重启期间网站都是断的。而这个线路的抖动是分钟级的,
5 次远远不够扛过去。
== 改法 ==
1. `--protocol auto` —— 让 cloudflared **自己**在 QUIC 和 HTTP/2 之间选,
哪条通走哪条。两条实例都改成 auto,等于 10 条连接全都能自适应。
(而不是现在这样"8 条赌 UDP、2 条赌 TCP")
2. `--retries` 提到很大(相当于"一直重试,别退出")——
抖动期间连接会自己恢复,不会整条隧道退出让网站断掉。
3. 顺手加日志轮转:tunnel.log 已经涨到 2.98 MB,
这台机器只有 460MB 内存 + 小硬盘,无限增长不合适。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
def patch_unit(name: str, proto: str) -> str:
"""读设备上现有的 unit,改协议和重试次数。
用 sed 现场改而不是整份重写 —— 现有 unit 里还有
metrics 端口、ha-connections、MemoryMax 这些实测调出来的参数,
整份重写容易漏掉。
"""
cmds = [
# 协议
"sed -i 's/--protocol [a-z0-9]*/--protocol %s/' %s" % (proto, name),
# 重试次数:改成很大(一直重试)
"sed -i 's/--retries [0-9]*/--retries 1000000/' %s" % name,
]
return " && ".join(cmds)
def main() -> int:
print("=" * 62)
print(" 隧道稳定性:协议改 auto + 加大重试 + 日志轮转")
print("=" * 62)
UNIT_DIR = "/etc/systemd/system"
print()
print(" ── 1. 两条隧道都改成 --protocol auto ──")
out = run(
"cd %s && "
"sed -i 's/--protocol [a-z0-9]*/--protocol auto/' "
"lanremote-tunnel.service lanremote-tunnel-h2.service && "
"echo ' tunnel:'; grep -o -- '--protocol [a-z0-9]*' "
"lanremote-tunnel.service; "
"echo ' h2:'; grep -o -- '--protocol [a-z0-9]*' "
"lanremote-tunnel-h2.service; "
# 重试次数
"sed -i 's/--retries [0-9]*/--retries 1000000/' "
"lanremote-tunnel.service lanremote-tunnel-h2.service && "
"echo ' 重试:'; grep -o -- '--retries [0-9]*' "
"lanremote-tunnel.service lanremote-tunnel-h2.service"
% UNIT_DIR, timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 2. 日志轮转(tunnel.log 已经 2.98 MB)──")
out = run(
"cat > /etc/logrotate.d/lanremote <<'EOF'\n"
"/root/lanremote-relay/*.log {\n"
" size 5M\n"
" rotate 2\n"
" compress\n"
" missingok\n"
" notifempty\n"
" copytruncate\n"
"}\n"
"EOF\n"
"echo ' logrotate 配置已写'; "
"logrotate --debug /etc/logrotate.d/lanremote 2>&1 | tail -3",
timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 3. 重启两条隧道让新参数生效 ──")
out = run(
"systemctl daemon-reload && "
"systemctl restart lanremote-tunnel lanremote-tunnel-h2 && "
"echo ' 等 40 秒让连接建立…' && sleep 40 && "
"echo \" tunnel: $(systemctl is-active lanremote-tunnel)\"; "
"echo \" h2: $(systemctl is-active lanremote-tunnel-h2)\"",
timeout=300)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 4. 连接数 ──")
out = run(
"for p in 20241 20242; do "
"n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null "
"| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' "
"| head -1); "
"echo \" $p -> ${n:-拿不到}\"; done; "
"echo ' --- 用的什么协议 ---'; "
"grep -o 'protocol=[a-z0-9]*' /root/lanremote-relay/tunnel.log 2>/dev/null "
"| tail -6 | sort | uniq -c | sed 's/^/ /'", timeout=120)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" ── 5. 公网(连测 5 次看稳不稳)──")
out = run(
"for i in 1 2 3 4 5; do "
"c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
"https://relay.xxmxxs.top/v1/health); "
"echo \" 第 $i 次: $c\"; sleep 3; done", timeout=200)
for ln in out[1].strip().splitlines():
print(" " + ln)
print()
print(" 说明:3844/7844 那条线路封不封是运营商的事,设备改不了。")
print(" 但 --protocol auto 让 cloudflared 自己绕,")
print(" --retries 拉大让它别退出 —— 抖动期间能自己恢复。")
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 109 KiB

+112
View File
@@ -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
View File
@@ -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 "不通"))
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
r'''放根目录的 LICENSE(AGPL-3.0)。
== 为什么要有这个文件 ==
Gitee 对一个公开仓库会检查"有没有公认的开源协议",没有就弹许可证向导,
而且远控类工具**没有协议**在合规视角里比有协议更容易被盯上
(这个仓库已经被判过一次"内容可能含有违规信息")。
原来仓库里只有 docs/LICENSE.txt —— 那是**自定义协议**,
里面写着"复制并分发完整副本(须保持文件完整、**不得修改**)",
这**不符合 OSI 开源定义**(开源必须允许修改),所以 Gitee 不认。
(Gitee 上这个仓库的 license 字段实测就是空的。)
== 为什么选 AGPL-3.0(用户拍板)==
* OSI 认可、Gitee 认,公开仓库不会再被提示
* 强 copyleft:别人改了必须以同样协议开源,**不能拿去做闭源商用** ——
最接近作者原本"免费给人用、但不许别人改名变成自己的"的想法
* 含"网络服务"条款:把它做成在线服务对外提供也必须开源,
正好匹配这个项目"中转服务器"的形态
(GPL 就没有这一条)
== 两份文件的分工(不冲突)==
LICENSE 管**代码**:可以改、可以再发布,但衍生作品必须同样开源
docs/LICENSE.txt 管**使用行为**:禁止偷窥、禁止私自装到别人设备、
禁止伪装隐藏等红线 + 免责声明
一个管"代码怎么用",一个管"行为不能做什么"。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = Path(__import__("os").environ.get("TEMP", "/tmp")) / "agpl.txt"
DST = ROOT / "LICENSE"
HEADER = """ykxmz —— 教室多媒体辅助工具
Copyright (C) 2026 小麦子
本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的
GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。
本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或
特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。
你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本
(见本文件下半部分)。如果没有,见 <https://www.gnu.org/licenses/>。
--------------------------------------------------------------------------
⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》):
本软件是一套远程控制工具,禁止用于未经同意的他人设备、
禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**,
和本文件(约束**代码**的授权)互不冲突。
--------------------------------------------------------------------------
"""
def main() -> int:
if not SRC.is_file():
print(" ✗ 找不到 AGPL 原文:%s" % SRC)
print(" (先跑一遍取原文的那步)")
return 1
body = SRC.read_text(encoding="utf-8")
if "GNU AFFERO GENERAL PUBLIC LICENSE" not in body:
print(" ✗ 原文看着不对,不含许可证标题")
return 1
DST.write_text(HEADER + body, encoding="utf-8", newline="\n")
print(" ✓ 已写 %s(%d 字节)" % (DST.name, DST.stat().st_size))
print(" 含 AGPL 标题: %s" % ("GNU AFFERO GENERAL PUBLIC LICENSE" in
DST.read_text(encoding="utf-8")))
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
r'''写监测单元的两个 systemd 单元文件。
用户要求「在后台设立检测单元」。
为什么用 timer 而不是常驻服务:
* 这台机器只有 460MB 内存,常驻 bash 循环白占一份内存
* 常驻脚本万一自己挂了就永远不检查了;timer 到点拉起来一次,
崩了下一分钟自己就回来
* 而且每次都是全新进程,不会有"跑久了状态变脏"的问题
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "relay"
SERVICE = '''[Unit]
# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。
#
# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量
TimeoutStartSec=360
# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 ——
# 部署时文件权限被改掉是最常见的"脚本不执行"原因。
ExecStart=/bin/bash /root/lanremote-relay/monitor.sh
# 环境变量
# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟)
# COOLDOWN 救完之后多少秒内不再救(默认 600 秒)
# 不设的话:预案刚重启完隧道还没连上,下一次检查
# 又判失败又救一遍,来回重启反而永远连不上
# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。
# 想开就把下面这行的 0 改成 1 —— 但先想清楚:
# 如果问题出在供电或上游线路,重启只会让恢复更慢,
# 而且半夜自动重启一台没人管的机器风险很大。
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
StandardOutput=append:/root/lanremote-relay/monitor.log
StandardError=append:/root/lanremote-relay/monitor.log
[Install]
WantedBy=multi-user.target
'''
TIMER = '''[Unit]
# 每 60 秒检查一次公网。
#
# 为什么是 60 秒不是 5 秒:
# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到
# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里
# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。
#
# OnBootSec=90s:开机后等 90 秒再开始查。
# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=lanremote-monitor.service
[Install]
WantedBy=timers.target
'''
def main() -> int:
(OUT / "lanremote-monitor.service").write_text(
SERVICE, encoding="utf-8", newline="\n")
(OUT / "lanremote-monitor.timer").write_text(
TIMER, encoding="utf-8", newline="\n")
for n in ("lanremote-monitor.service", "lanremote-monitor.timer"):
p = OUT / n
print(" ✓ %s(%d 字节)" % (n, p.stat().st_size))
return 0
if __name__ == "__main__":
sys.exit(main())
+15 -7
View File
@@ -17,7 +17,7 @@ import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERSION = "2.0.0"
VERSION = "2.1.5"
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,可改)
能看到:设备列表、在线状态、配对码、流量、会话数
能操作:踢会话、抹掉配对码绑定
@@ -584,12 +584,20 @@ def main() -> int:
server_dir = work / server_name
server_dir.mkdir(parents=True)
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
# 改成:relay 目录里所有 .py / .sh 都带上。
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
# 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。
# 后缀白名单:
# .py 服务本体
# .sh 部署/运维脚本
# .ico 管理页图标(漏了的话新设备上管理页是默认样式)
# .service /
# .timer systemd 单元。**这两个是后加的** ——
# 加监测单元的时候才发现白名单里没有它们,
# 于是 monitor.sh 进了包、拉它的 timer 却没进,
# 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。
wanted = sorted(
[f for f in RELAY_SRC.iterdir()
if f.suffix in (".py", ".sh", ".ico") and f.is_file()],
if f.suffix in (".py", ".sh", ".ico", ".service", ".timer")
and f.is_file()],
key=lambda f: f.name)
for src in wanted:
shutil.copy2(src, server_dir / src.name)
+3 -3
View File
@@ -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 = {}
+1 -1
View File
@@ -97,7 +97,7 @@ NEW_BODY = '''RELAY_README = f"""===============================================
官网: <公网地址>/
管理页: <公网地址>/?key=<lanremote-key 的输出>
管理密码: 120830(存在 data/admin_password.txt,可改)
管理密码: <改从环境变量读>(存在 data/admin_password.txt,可改)
能看到:设备列表、在线状态、配对码、流量、会话数
能操作:踢会话、抹掉配对码绑定
+1 -1
View File
@@ -17,7 +17,7 @@
安全说明:
/url 只暴露"隧道地址"这一个信息,不含配对码、设备、流量。
地址本来就是要给手表填的,不算秘密。
设备列表、配对码这些仍然要登录(密码 120830)。
设备列表、配对码这些仍然要登录(密码 <改从环境变量读>)。
"""
from __future__ import annotations
+100
View File
@@ -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())
+98
View File
@@ -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())
+17 -2
View File
@@ -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())
+17 -2
View File
@@ -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())
+16 -1
View File
@@ -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")
+108
View File
@@ -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())
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
r'''修设备上的几个卡住的状态。
发现的问题:
1. lanremote-tunnel(主隧道)inactive —— 只剩 h2 副本在跑,
公网返回 530。这是网站"挂了"的直接原因。
2. lanremote-nettune 卡在 activating —— 所以 MTU 还是 1400
(而实测 1400 反而掉到 50%)。
3. monitor.timer 没起来。
按顺序修,每步都验。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
STEPS = [
("1. 看 nettune 为什么卡住(它挡着 MTU)",
"systemctl status lanremote-nettune --no-pager -n 12 2>&1 | tail -12; "
"echo '--- 有没有残留的 iptables 进程 ---'; pgrep -a iptables || echo 无"),
("2. 把卡住的 nettune 停掉、直接跑一次 net-tune.sh",
"systemctl stop lanremote-nettune 2>/dev/null; "
"systemctl reset-failed lanremote-nettune 2>/dev/null; "
"bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -12; "
"echo '--- 结果 ---'; "
"echo \"MTU=$(cat /sys/class/net/wlan0/mtu)\"; "
"echo \"MSS 规则=$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条\""),
("3. 拉起主隧道(网站 530 的直接原因)",
"systemctl reset-failed lanremote-tunnel 2>/dev/null; "
"systemctl restart lanremote-tunnel; sleep 12; "
"systemctl is-active lanremote-tunnel; "
"systemctl is-active lanremote-tunnel-h2"),
("4. 重启中转(让新的 admin.py 生效,后台才有「抢救报告」分区)",
"systemctl restart lanremote-relay; sleep 4; "
"systemctl is-active lanremote-relay"),
("5. 启用监测定时器",
"systemctl enable lanremote-monitor.timer >/dev/null 2>&1; "
"systemctl start lanremote-monitor.timer; sleep 3; "
"systemctl is-active lanremote-monitor.timer"),
("6. 验证:公网通了没 / 隧道连接数 / 后台分区",
"echo \"公网 /v1/health: $(curl -s -o /dev/null -w '%{http_code}' "
"--max-time 20 https://relay.xxmxxs.top/v1/health)\"; "
"echo \"公网首页: $(curl -s -o /dev/null -w '%{http_code}' "
"--max-time 20 https://relay.xxmxxs.top/)\"; "
"for p in 20241 20242; do "
"n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null "
"| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1); "
"echo \" 连接数($p): ${n:-拿不到}\"; done; "
"echo \"后台「抢救报告」出现次数: "
"$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan "
"| grep -c '抢救报告')\""),
]
def main() -> int:
for title, cmd in STEPS:
print()
print("=" * 66)
print(" " + title)
print("=" * 66)
try:
out = run(cmd, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
except Exception as exc: # noqa: BLE001
text = " [执行失败] %s: %s" % (type(exc).__name__, exc)
for ln in text.strip().splitlines():
print(" " + ln)
return 0
if __name__ == "__main__":
sys.exit(main())
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
r'''重启中转并验证 /rescue/,同时查 deploy_release.py 为什么没重启成功。
== 发现的问题 ==
PID=7255 进程启动于 18:04:35
relay.py 文件改于 18:07:41
进程比文件旧 -> 跑的还是老代码 -> /rescue/ 还是挂。
deploy_release.py 的 --code-only 分支里应该有 systemctl restart,
但显然没生效。两个可能:
1. 那一步的代码路径没走到(--code-only 把 restart 也跳过了)
2. 走了但失败被吞掉
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 1. 重启前 ════"
PID=$(systemctl show -p MainPID --value lanremote-relay)
echo " PID=$PID 启动于 $(ps -o lstart= -p $PID 2>/dev/null)"
echo
echo "════ 2. 重启 ════"
systemctl restart lanremote-relay
sleep 5
PID2=$(systemctl show -p MainPID --value lanremote-relay)
echo " 新 PID=$PID2 启动于 $(ps -o lstart= -p $PID2 2>/dev/null)"
echo " relay.py 改于 $(stat -c '%y' $RELAY/relay.py)"
echo " 服务状态: $(systemctl is-active lanremote-relay)"
echo
echo "════ 3. 重启后测 /rescue/(不带登录,应该立刻 401 而不是挂住)════"
printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
echo -n " 不带登录: "
curl -s -o /dev/null -w 'HTTP %{http_code} 用时 %{time_total}s\n' --max-time 8 \
"$B/rescue/19700101-000000-demo.txt"
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
curl -s -c /tmp/ck2.txt -o /dev/null --max-time 8 -d "password=$PASS" $B/login
echo -n " 带登录: "
curl -s -b /tmp/ck2.txt -o /tmp/rep2.txt -w 'HTTP %{http_code} 用时 %{time_total}s\n' \
--max-time 8 "$B/rescue/19700101-000000-demo.txt"
echo " 内容: $(head -1 /tmp/rep2.txt 2>/dev/null)"
echo
echo "════ 4. 后台页里的「抢救报告」分区 ════"
N=$(curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告')
echo " 匹配 $N 次"
curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /'
echo
echo "════ 5. 公网还通吗(重启不能把网站弄挂)════"
echo " 公网 health: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/v1/health)"
echo " 公网首页: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/)"
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt /tmp/ck2.txt /tmp/rep2.txt
'''
def main() -> int:
# ---- 先看 deploy_release.py 的 --code-only 分支到底走不走 restart ----
src = (ROOT / "_build" / "deploy_release.py").read_text(encoding="utf-8")
print(" ── deploy_release.py 里 restart 相关的代码 ──")
for i, ln in enumerate(src.splitlines(), 1):
if "restart" in ln or "files_only" in ln or "code_only" in ln:
print(" %4d: %s" % (i, ln.strip()))
print()
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+125
View File
@@ -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())
+107
View File
@@ -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())
+151
View File
@@ -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())
+1 -1
View File
@@ -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
+147
View File
@@ -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())
+163
View File
@@ -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())
+200
View File
@@ -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())
+127
View File
@@ -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())
+16 -1
View File
@@ -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")
+16 -1
View File
@@ -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")
+1 -1
View File
@@ -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'
+141
View File
@@ -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())
+30 -1
View File
@@ -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"
+167
View File
@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
r'''真正验证后台密码是否生效(不能只看 302)。
== 为什么要重做这个测试 ==
第一版是这样:
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 lssh import run # noqa: E402
USERNAME = "xmz7891"
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. 正确密码 ════"
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. 错误密码 ════"
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. 账号写错(密码对)════"
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. 完全不带凭据 ════"
rm -f $JAR
echo " 访问 /admin -> $(probe $B)"
echo
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. 密码有没有泄漏到代码里 ════"
if grep -rq "$P" /opt/ykxmz-relay/*.py /opt/ykxmz-relay/*.sh 2>/dev/null; then
echo " ✗ 代码/脚本里出现了明文密码"
else
echo " ✓ 代码和脚本里都没有明文密码"
fi
rm -f $JAR
'''
def main() -> int:
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
if __name__ == "__main__":
sys.exit(main())
+45
View File
@@ -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())
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
r'''验证 /rescue/ 鉴权修复是否真的生效。
上一轮部署后还是 HTTP 000。要分清是:
* 代码没传上去 / 进程没重启(改动没生效)
* 还是代码有新问题
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
B=http://127.0.0.1:8080
echo "════ 1. 设备上 relay.py 里那段代码长什么样 ════"
grep -n -A6 'if path.startswith("/rescue/")' $RELAY/relay.py | head -40 | sed 's/^/ /'
echo
echo "════ 2. 进程是什么时候起的 vs relay.py 修改时间 ════"
PID=$(systemctl show -p MainPID --value lanremote-relay)
echo " PID=$PID 启动: $(ps -o lstart= -p $PID 2>/dev/null)"
echo " relay.py 改于: $(stat -c '%y' $RELAY/relay.py)"
echo
echo "════ 3. 再来一次详细请求 ════"
printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | tail -20 | sed 's/^/ /'
echo
echo "════ 4. 对照:一个不存在的普通路径 ════"
curl -s -o /dev/null -w ' /nothing-here -> %{http_code}\n' --max-time 6 "$B/nothing-here"
echo
echo "════ 5. 对照:/download/ 上不存在的文件(那条没加鉴权)════"
curl -s -o /dev/null -w ' /download/none.txt -> %{http_code}\n' --max-time 6 "$B/download/none.txt"
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt
'''
def main() -> int:
out = run(REMOTE, timeout=180)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
r'''端到端验证「监测 + 预案 + 报告分区」。
验这些:
1. /rescue/ 不带登录 -> 401(不能匿名下诊断报告)
2. /rescue/ 带登录 -> 200 且内容正确
3. 报告文件名的目录穿越被挡住
4. monitor.sh 能跑通并写状态
5. rescue.sh 在网站正常时**什么都不做**就退出(不能瞎折腾)
6. 后台页里有「抢救报告」分区
7. 监测定时器在跑
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
B=http://127.0.0.1:8080
echo "════ 准备:造一份演练报告 ════"
DEMO=$RELAY/rescue_reports/19700101-000000-demo.txt
printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' > $DEMO
echo " $(ls $RELAY/rescue_reports | wc -l) 份"
echo
echo "════ 1. 不带登录下载(应该 401)════"
printf ' HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 $B/rescue/19700101-000000-demo.txt)"
echo
echo "════ 2. 带登录下载(应该 200 且有内容)════"
# 先登录拿 cookie
curl -s -c /tmp/ck.txt -o /dev/null --max-time 8 \
-d "password=$PASS" $B/login 2>/dev/null
CODE=$(curl -s -b /tmp/ck.txt -o /tmp/rep.txt -w '%{http_code}' --max-time 8 \
$B/rescue/19700101-000000-demo.txt)
printf ' HTTP %s\n' "$CODE"
echo " 内容:$(head -2 /tmp/rep.txt | tail -1)"
printf ' 缓存头: %s\n' "$(curl -s -b /tmp/ck.txt -D- -o /dev/null --max-time 8 $B/rescue/19700101-000000-demo.txt | grep -i cache-control | tr -d '\r')"
echo
echo "════ 3. 目录穿越(应该 400/404,绝不能读到别的文件)════"
for bad in "../relay.py" "..%2Frelay.py" "x/../../etc/passwd"; do
printf ' %-24s -> HTTP %s\n' "$bad" "$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 8 "$B/rescue/$bad")"
done
echo
echo "════ 4. monitor.sh 跑一次 ════"
rm -f $RELAY/monitor_state.txt
bash $RELAY/monitor.sh
echo " 状态文件:"
cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /'
echo " 日志尾部:"
tail -3 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /'
echo
echo "════ 5. rescue.sh 在网站正常时应该**什么都不做** ════"
BEFORE=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1)
timeout 120 bash $RELAY/rescue.sh "端到端验证(网站正常)" 2>&1 | tail -8 | sed 's/^/ /'
AFTER=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1)
if [ "$BEFORE" = "$AFTER" ]; then
echo " ✅ 网站正常时没有误触发抢救(知识库没变)"
else
echo " ❌ 网站正常却动了抢救"
fi
echo " 报告份数:$(ls $RELAY/rescue_reports | wc -l)(正常时不该新增)"
echo
echo "════ 6. 后台页里的「抢救报告」分区 ════"
N=$(curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告')
printf ' 匹配 %s 次\n' "$N"
curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /'
echo
echo "════ 7. 定时器 ════"
printf ' monitor.timer: %s\n' "$(systemctl is-active lanremote-monitor.timer)"
systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | sed -n '1,2p' | sed 's/^/ /'
echo
echo "════ 收尾:删掉演练报告 ════"
rm -f $DEMO /tmp/ck.txt /tmp/rep.txt
echo " 剩 $(ls $RELAY/rescue_reports | wc -l) 份"
'''
def main() -> int:
out = run(REMOTE, timeout=400)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
r'''稳定性观测:连续打公网,统计可用率。
用途:验证 2.1.2 的隧道加固(协议 auto + 重试拉大)真的有效。
2.1.1 之前是"过一阵就 530",所以只看一次 200 说明不了问题,
要**连续观测一段时间**。
用法:
python _build/watch_stability.py # 默认 20 次、间隔 15 秒
python _build/watch_stability.py 40 20 # 40 次、间隔 20 秒
'''
from __future__ import annotations
import sys
import time
import urllib.error
import urllib.request
URL = "https://relay.xxmxxs.top/v1/health"
def once(timeout: float = 15.0) -> tuple:
"""返回 (状态码文本, 耗时秒)。"""
t0 = time.time()
try:
req = urllib.request.Request(
URL, headers={"Cache-Control": "no-cache",
"User-Agent": "ykxmz-watch"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp.read(64)
return str(resp.status), time.time() - t0
except urllib.error.HTTPError as exc:
try:
exc.read(64)
except Exception: # noqa: BLE001
pass
return str(exc.code), time.time() - t0
except Exception as exc: # noqa: BLE001
return "无响应(%s)" % type(exc).__name__, time.time() - t0
def main() -> int:
times = int(sys.argv[1]) if len(sys.argv) > 1 else 20
gap = float(sys.argv[2]) if len(sys.argv) > 2 else 15.0
print("=" * 58)
print(" 公网稳定性观测:%d 次,间隔 %.0f 秒(约 %.0f 分钟)"
% (times, gap, times * gap / 60))
print("=" * 58)
codes = []
durs = []
for i in range(1, times + 1):
code, dur = once()
codes.append(code)
durs.append(dur)
mark = "✓" if code == "200" else "✗"
print(" %s 第 %2d 次: %-8s %.2fs" % (mark, i, code, dur))
if i < times:
time.sleep(gap)
ok = sum(1 for c in codes if c == "200")
print()
print("=" * 58)
print(" 200 的次数: %d / %d" % (ok, times))
print(" 可用率: %.1f%%" % (100.0 * ok / max(1, times)))
if durs:
sd = sorted(durs)
print(" 延迟 中位: %.2fs P90: %.2fs 最大: %.2fs"
% (sd[len(sd) // 2], sd[min(len(sd) - 1, int(len(sd) * 0.9))],
sd[-1]))
print()
if ok == times:
print(" ✅ 全程 200 —— 加固有效")
elif ok >= times * 0.9:
print(" ⚠ 偶尔失败(%d 次)—— 比加固前好,但还没完全稳"
% (times - ok))
else:
print(" ❌ 可用率偏低(%d 次失败)—— 还要继续查"
% (times - ok))
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
r'''把音效功能接进协议层和服务端。
加两条命令:
sfx_list 列出可用音效
sfx_play 播放指定的音效
复用现成的 play_audio(MCI),不另造一套播放器。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
SERVER = ROOT / "windows" / "lanremote" / "server.py"
def load(p: Path):
raw = p.read_bytes()
return raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig")
def save(p: Path, text: str, bom: bool):
p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
# ---------------------------------------------------------------- protocol
PROTO_ANCHOR = 'CMD_AUDIO_LIST = "audio_list"'
PROTO_NEW = '''CMD_AUDIO_LIST = "audio_list"
CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的)
CMD_SFX_PLAY = "sfx_play" # 播放某个音效'''
# ---------------------------------------------------------------- server
# 挂在 CMD_AUDIO_LIST 那条分支后面
SERVER_ANCHOR = ''' if cmd == P.CMD_AUDIO_LIST:'''
SERVER_NEW = ''' if cmd == P.CMD_SFX_LIST:
items = self.actions.list_sfx()
return {"ok": True, "sfx": items, "count": len(items),
"dir": self.actions.sfx_dir_hint()}
if cmd == P.CMD_SFX_PLAY:
name = str(body.get("name") or "").strip()
if not name:
return {"ok": False, "error": "没说要放哪个音效"}
try:
msg = self.actions.play_sfx(name)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
return {"ok": True, "message": msg, "name": name}
''' + SERVER_ANCHOR
def main() -> int:
ok = True
bom, text = load(PROTO)
if "CMD_SFX_LIST" in text:
print(" · protocol.py 已经加过了")
elif PROTO_ANCHOR not in text:
print(" ✗ protocol.py 找不到锚点")
ok = False
else:
text = text.replace(PROTO_ANCHOR, PROTO_NEW, 1)
save(PROTO, text, bom)
print(" ✓ protocol.py 加了两条命令")
bom, text = load(SERVER)
if "CMD_SFX_LIST" in text:
print(" · server.py 已经加过了")
elif SERVER_ANCHOR not in text:
print(" ✗ server.py 找不到锚点")
ok = False
else:
text = text.replace(SERVER_ANCHOR, SERVER_NEW, 1)
save(SERVER, text, bom)
print(" ✓ server.py 加了两个处理分支")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
r'''把会弹窗的测试阶段包进隐藏桌面。
== 用户要求 ==
「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」
== 现状 ==
有 6 个阶段会真的开窗口,其中 test_image_modes 还会开**全屏**
(直接把用户的屏幕锁住):
1e/7 新增功能 test_new_features.py
1f/7 电脑功能注册表 test_features.py
1g/7 功能模糊测试 fuzz_features.py
1k/7 首次安装设置窗口 test_first_run_setup.py
2b/7 投图显示方式 test_image_modes.py <- 全屏,锁屏
3/7 屏幕共享可见提示 test_indicator_visible.py
套件里原来只有一个 Close-TestWindows,那是**事后收尾**
(杀掉没退干净的进程),窗口在跑的时候照样在屏幕上闪。
== 改法 ==
把这些调用前面套一层 _tests/hidden_desktop.py --,
那个脚本会新建一个 Windows 桌面、把子进程放上去。
子进程里所有窗口都创建在那个桌面上 —— 用户完全看不到,
而窗口本身是**真的**(全屏、可见、几何、Win32 枚举全都真实),
测试有效性一点没损失。
& python hidden_desktop.py -- python test_image_modes.py
== 保留什么 ==
* `*>` 重定向照旧(hidden_desktop 会把子进程输出转写到自己的
stdout,套件照常能抓到 FAIL)
* 退出码照旧(hidden_desktop 用子进程的退出码退出)
* Close-TestWindows 留着 —— 它是兜底,还有用
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SUITE = ROOT / "_tests" / "verify_all.ps1"
# 要把调用包进隐藏桌面的测试
GUIs = [
"test_new_features.py",
"test_features.py",
"fuzz_features.py",
"test_first_run_setup.py",
"test_image_modes.py",
"test_indicator_visible.py",
]
def main() -> int:
raw = SUITE.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "hidden_desktop.py" in text:
print(" 已经接过了")
return 0
changes = []
for name in GUIs:
# 两种形状:
# & python (Join-Path $root "_tests\X") *> ...
# $stageOut = & python (Join-Path $root "_tests\X") 2>&1
pat = re.compile(
r'(& python \(Join-Path \$root "_tests\\%s"\))'
% re.escape(name))
n = len(pat.findall(text))
if n != 1:
changes.append(("!", name, "出现 %d 次" % n))
continue
# ⚠ 替换串里含反斜杠("_tests\hidden_desktop.py"),
# 直接当 repl 字符串会被 re 当成转义序列,
# 报 "bad escape \h"。所以用 lambda 返回字面量。
text = pat.sub(
lambda m: ('& python (Join-Path $root "_tests\\'
'hidden_desktop.py") -- ' + m.group(1)),
text, count=1)
changes.append(("✓", name, ""))
# 在第一个被包的调用之前插一段说明
note = '''# ---------------------------------------------------------------- 隐藏桌面
# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** ——
# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。
#
# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程
# 放上去,所有窗口都创建在那个不显示的桌面上。
# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实),
# 只是用户看不到 —— 测试有效性没有损失。
#
# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。
'''
anchor = 'Banner "1e/7 新增功能'
if anchor in text:
text = text.replace(anchor, note + anchor, 1)
changes.append(("✓", "插入说明注释", ""))
else:
changes.append(("✗", "插入说明注释", "找不到锚点"))
print(" ── 结果 ──")
for mark, what, why in changes:
print(" %s %-28s %s" % (mark, what, why))
if any(m == "✗" for m, _w, _y in changes):
print()
print(" 有没匹配上的,没有写回")
return 1
SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.encode("utf-8"))
print()
print(" 已写回 verify_all.ps1(BOM 保留:%s)" % had_bom)
return 0
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
{
"java-test-device": {
"name": "JVM测试端",
"token": "4ed3ec96a8e2fb0337de88aa9212d7f5",
"paired_at": 1791452868.0075707,
"last_seen": 1791452868.011561
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"port": 8799,
"server_name": "DESKTOP-RN5I625",
"allow_run": true,
"allow_power": true,
"allow_screen": true,
"start_minimized": false,
"allow_new_pairing": true,
"image_timeout": 0,
"image_display": "window",
"image_custom_width": 0,
"image_custom_height": 0,
"image_window_title": "",
"tts_voice": "",
"tts_rate": 0,
"mp3_dir": "",
"file_roots": "",
"extra_apps": "",
"extra_commands": "",
"download_dir": "received",
"screen_max_width": 480,
"screen_quality": 72,
"screen_fps": 12,
"pairing_code": "246813"
}
+256
View File
@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
r'''在**隐藏的 Windows 桌面**上跑命令,用户屏幕不受打扰。
== 为什么需要 ==
用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。
有几个测试必须开**真实的窗口**才测得出东西,不能改成假的:
test_image_modes 投图窗口的三种显示方式(含**全屏**)
test_indicator_visible 屏幕共享提示条 + Win32 窗口枚举
fuzz_features 功能模糊测试
test_first_run_setup 首次设置窗口
问题是它们跑的时候会在用户屏幕上一通闪,**全屏那个还会把屏幕锁住**,
用户根本没法用电脑。
== 解法 ==
Windows 支持多个"桌面"(Desktop)—— 同一个登录会话里可以有若干个,
但**只有一个是当前显示给用户的**(叫 Default)。
我们新建一个桌面,把测试进程放上去:
CreateDesktopW("ykxmz-test", ...) 建一个不显示的桌面
CreateProcessW(..., lpDesktop=那个名字) 把子进程放上去
子进程里所有窗口都创建在那个桌面上 -> 用户完全看不到
跑完 CloseDesktop
这样窗口是**真的**(窗口句柄、几何、可见性、Win32 枚举全都真实),
只是不在用户眼前 —— 测试有效性一点没损失。
== 为什么不用别的方法 ==
* "把窗口设成透明/最小化" —— 那就测不出全屏了,而且还是会闪
* "跳过这些测试" —— 投图和提示条是核心功能,不能因为烦就不测
* "用无头模式" —— 那测的就不是真实窗口了
== 用法 ==
命令行:
python _tests/hidden_desktop.py -- <要跑的命令...>
代码里:
from hidden_desktop import run_hidden
code, out = run_hidden(["python", "test_image_modes.py"], timeout=300)
'''
from __future__ import annotations
import ctypes
import ctypes.wintypes as wt
import os
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
IS_WINDOWS = os.name == "nt"
# ---- Win32 常量和结构 ----
# 桌面权限:给足,否则 CreateProcess 放上去之后子进程建不了窗口
DESKTOP_ALL_ACCESS = 0x000F01FF
GENERIC_ALL = 0x10000000
CREATE_NEW_CONSOLE = 0x00000010
CREATE_UNICODE_ENVIRONMENT = 0x00000400
WAIT_TIMEOUT = 0x00000102
INFINITE = 0xFFFFFFFF
class STARTUPINFOW(ctypes.Structure):
_fields_ = [
("cb", wt.DWORD),
("lpReserved", wt.LPWSTR),
("lpDesktop", wt.LPWSTR),
("lpTitle", wt.LPWSTR),
("dwX", wt.DWORD),
("dwY", wt.DWORD),
("dwXSize", wt.DWORD),
("dwYSize", wt.DWORD),
("dwXCountChars", wt.DWORD),
("dwYCountChars", wt.DWORD),
("dwFillAttribute", wt.DWORD),
("dwFlags", wt.DWORD),
("wShowWindow", wt.WORD),
("cbReserved2", wt.WORD),
("lpReserved2", ctypes.POINTER(ctypes.c_byte)),
("hStdInput", wt.HANDLE),
("hStdOutput", wt.HANDLE),
("hStdError", wt.HANDLE),
]
class PROCESS_INFORMATION(ctypes.Structure):
_fields_ = [
("hProcess", wt.HANDLE),
("hThread", wt.HANDLE),
("dwProcessId", wt.DWORD),
("dwThreadId", wt.DWORD),
]
def _api():
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
user32.CreateDesktopW.argtypes = [
wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD,
wt.DWORD, ctypes.c_void_p]
user32.CreateDesktopW.restype = wt.HANDLE
user32.CloseDesktop.argtypes = [wt.HANDLE]
user32.CloseDesktop.restype = wt.BOOL
kernel32.CreateProcessW.argtypes = [
wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL,
wt.DWORD, ctypes.c_void_p, wt.LPCWSTR,
ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)]
kernel32.CreateProcessW.restype = wt.BOOL
kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
kernel32.WaitForSingleObject.restype = wt.DWORD
kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE,
ctypes.POINTER(wt.DWORD)]
kernel32.GetExitCodeProcess.restype = wt.BOOL
kernel32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT]
kernel32.CloseHandle.argtypes = [wt.HANDLE]
return user32, kernel32
def run_hidden(command, timeout: float = 600.0, cwd: str | None = None,
log_path: Path | None = None) -> tuple:
"""在隐藏桌面上跑命令。
返回 (退出码, 输出文本)。退出码 -1 表示建桌面失败(会自动退回
直接在普通桌面跑,并说明原因 —— 宁可闪一下也不能让测试跑不了)。
"""
if not IS_WINDOWS:
# 非 Windows 直接跑
r = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=timeout)
return r.returncode, (r.stdout or "") + (r.stderr or "")
if log_path is None:
log_path = Path(tempfile.gettempdir()) / (
"ykxmz_hidden_%s.log" % uuid.uuid4().hex[:8])
user32, kernel32 = _api()
name = "ykxmz-test-%s" % uuid.uuid4().hex[:8]
desk = user32.CreateDesktopW(name, None, None, 0,
DESKTOP_ALL_ACCESS | GENERIC_ALL, None)
if not desk:
err = ctypes.get_last_error()
# 建不了桌面(少见,通常是权限)—— 退回普通方式,但要说清楚
r = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=timeout)
msg = (" [警告] 建隐藏桌面失败(Win32 错误 %d),"
"这次只能在普通桌面上跑 —— 屏幕上可能会闪窗口。\n" % err)
return r.returncode, msg + (r.stdout or "") + (r.stderr or "")
try:
# 让子进程自己把输出重定向到文件 —— 走 CreateProcess 时
# 管道不好接管,重定向到文件最简单可靠。
# 用 cmd /c 是为了让 > 生效。
quoted = subprocess.list2cmdline([str(c) for c in command])
full = 'cmd.exe /c %s > "%s" 2>&1' % (quoted, log_path)
# cmd /c 后面整串要作为一个参数传
cmdline = ctypes.create_unicode_buffer(full)
si = STARTUPINFOW()
si.cb = ctypes.sizeof(STARTUPINFOW)
si.lpDesktop = name # <<< 关键:把子进程放到隐藏桌面
pi = PROCESS_INFORMATION()
ok = kernel32.CreateProcessW(
None, cmdline, None, None, False,
CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
None, cwd, ctypes.byref(si), ctypes.byref(pi))
if not ok:
err = ctypes.get_last_error()
r = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, encoding="utf-8",
errors="replace", timeout=timeout)
return r.returncode, (" [警告] 在隐藏桌面上启动失败"
"(Win32 错误 %d)\n" % err
) + (r.stdout or "") + (r.stderr or "")
try:
waited = kernel32.WaitForSingleObject(pi.hProcess,
int(timeout * 1000))
if waited == WAIT_TIMEOUT:
kernel32.TerminateProcess(pi.hProcess, 124)
code = 124
else:
code = wt.DWORD()
kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code))
code = code.value
finally:
kernel32.CloseHandle(pi.hThread)
kernel32.CloseHandle(pi.hProcess)
# 给文件一点时间落盘
for _ in range(40):
if log_path.is_file() and log_path.stat().st_size > 0:
break
time.sleep(0.05)
try:
out = log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
out = ""
return code, out
finally:
user32.CloseDesktop(desk)
try:
log_path.unlink()
except OSError:
pass
def main() -> int:
if not IS_WINDOWS:
print(" 这个工具只在 Windows 上有意义")
return 1
args = sys.argv[1:]
if args and args[0] == "--":
args = args[1:]
if not args:
print(__doc__)
return 1
print(" 在隐藏桌面上运行:%s" % " ".join(args))
started = time.time()
code, out = run_hidden(args, timeout=900)
took = time.time() - started
# 原样把输出打出来(套件靠它判断)
sys.stdout.write(out)
if not out.endswith("\n"):
sys.stdout.write("\n")
# ⚠ 总结这行**只在终端里打**。
# 套件是用 `*> 日志文件` 接输出的,再 `Select-Object -Last 2/3`
# 取摘要 —— 如果这里多打一行,就会把真正的摘要顶掉,
# 汇总表上显示的是"隐藏桌面运行结束"而不是测试结果。
if sys.stdout.isatty():
print(" —— 隐藏桌面运行结束:退出码 %d,用时 %.1f 秒" % (code, took))
return code
if __name__ == "__main__":
sys.exit(main())

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