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" 了,内容本身是对的。)
This commit is contained in:
xmz7891
2026-10-08 23:51:17 +08:00
parent a53a3eee21
commit 9bdd36cf56
190 changed files with 1480 additions and 111 deletions
+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.4.exe` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.4.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.4.apk` |
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
@@ -82,7 +81,7 @@
### 第一步:电脑上装被控端
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
双击 `ykxmz-setup-2.1.4.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
第一次运行会弹出窗口,填两样:
@@ -95,12 +94,12 @@
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
> 想彻底退出,窗口上点「退出」。
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
> 不想用安装包的话,`ykxmz-agent-2.1.4.zip` 是绿色版:
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
### 第二步:手表上装 App
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
把 `ykxmz-watch-2.1.4.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.4.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.4.apk
```
最低支持 Android 7.0(API 24),targetSdk 34。
+30 -1
View File
@@ -1,3 +1,32 @@
## 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
- **支持「自己搭的、没有证书的中转」**(新服务器就是这种)。
@@ -6,7 +35,7 @@
一台有公网 IP 的机器上,客户端填 `IP:端口` 就能连 —— 延迟、稳定性
都比隧道好得多。为此改了两处客户端:
- **手表端**:填 `13.54.30.196:13245` 这种**没写协议头**的地址时,
- **手表端**:填 `yk.xxmxxs.top:13245` 这种**没写协议头**的地址时,
现在会按「IP / localhost / 本地域名 → 明文,域名 → 加密」来猜,
**和电脑端规则一致**。原来一律补成 `https://`,而那台机器没有证书,
结果是**永远连不上**,报的还是 SSL 错误,很容易让人白查半天证书。
+5 -5
View File
@@ -3,7 +3,7 @@ r'''让客户端能用"纯 IP + 明文 HTTP"的中转(新服务器就是这种
== 背景 ==
2026-10-08 换了服务器:AWS Lightsail,**有公网 IP**(13.54.30.196),
2026-10-08 换了服务器:AWS Lightsail,**有公网 IP**(yk.xxmxxs.top),
中转直接监听 13245,**不再走 Cloudflare 隧道**。
好处一大堆(延迟从 1000ms 降到 1ms、不再有 7844 阻断、内存 4 倍),
@@ -11,7 +11,7 @@ r'''让客户端能用"纯 IP + 明文 HTTP"的中转(新服务器就是这种
而两个客户端原来都默认往 https 上走:
1. Android 的 RelayTransport:用户填 `13.54.30.196:13245`
1. Android 的 RelayTransport:用户填 `yk.xxmxxs.top:13245`
(没写协议头)会被强制加上 `https://` -> 连不上(那台机器没证书)
2. Android 9+ **默认禁止明文 HTTP**。没有
@@ -39,7 +39,7 @@ Windows 那边已经是对的:relay.py 的 _PRIVATE_HOST 正则里
明文 HTTP 意味着**同网络的人能看到流量**(虽然配对令牌、内容本来
就是加密的,但 URL、设备名这些会露)。真正的解法是上 HTTPS:
把域名 relay.xxmxxs.top 的 A 记录指到 13.54.30.196,
把域名 relay.xxmxxs.top 的 A 记录指到 yk.xxmxxs.top,
然后 certbot 签一张 Let's Encrypt 证书,confd 挂上。
那一步要用户动 DNS,所以先让明文能用(这个补丁),
@@ -91,7 +91,7 @@ JAVA_NEW = ''' if (!url.startsWith("http://") && !url.startsWith("https:/
// 而且报的是 SSL 错误,会把人带去查证书查半天。
//
// ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
// 之后中转就是 http://13.54.30.196:13245,
// 之后中转就是 http://yk.xxmxxs.top:13245,
// 只把 192.168/10./172.16 当私有的话这里会走错。
String hostOnly = url;
int slash = hostOnly.indexOf('/');
@@ -113,7 +113,7 @@ MAN_OLD = ''' <uses-permission android:name="android.permission.INTERNET" />'
MAN_NEW = ''' <!-- ⚠ 允许明文 HTTP。
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
2026-10-08 之后那台有公网 IP 的服务器(http://13.54.30.196:13245)。
2026-10-08 之后那台有公网 IP 的服务器(http://yk.xxmxxs.top:13245)。
Android 9+ 默认**禁止明文流量**,不加这一条的话请求会被系统
直接掐掉,日志里只有一句含糊的
"Cleartext HTTP traffic to ... not permitted"。
+20
View File
@@ -146,6 +146,26 @@ 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
# ---- 会断言成品名的测试 ----
for name in ("test_package_remote.py", "test_apk_contents.py"):
path = ROOT / "_tests" / name
+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())
+2 -2
View File
@@ -28,8 +28,8 @@ RELAY_DIR = "/opt/ykxmz-relay"
# IP:80 —— 绕过 Cloudflare,直连 nginx(域名没配好时靠它)
# :13245 —— **应该不通**(中转已只绑 127.0.0.1,不再对公网)
DOMAIN_URL = "http://%s" % DOMAIN
NGINX_URL = "http://13.54.30.196:80"
DIRECT_URL = "http://13.54.30.196:%s" % PORT
NGINX_URL = "http://<源站IP>:80"
DIRECT_URL = "http://<源站IP>:%s" % PORT
REMOTE = r'''
R=__DIR__
+1 -1
View File
@@ -22,7 +22,7 @@ sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
DOMAIN = "yk.xxmxxs.top"
IP = "13.54.30.196"
IP = "<源站IP>"
def tcp(host: str, port: int, timeout: float = 8.0) -> bool:
+2 -2
View File
@@ -88,7 +88,7 @@ WantedBy=multi-user.target
def main() -> int:
print("=" * 62)
print(" 部署到 Lightsail(13.54.30.196),端口 %s" % PORT)
print(" 部署到 Lightsail(<源站IP>),端口 %s" % PORT)
print("=" * 62)
print()
@@ -167,7 +167,7 @@ def main() -> int:
print()
print(" 提示:防火墙要先放行 TCP %s(用户来开)。" % PORT)
print(" 之后公网地址就是 http://13.54.30.196:%s/" % PORT)
print(" 之后公网地址就是 http://<源站IP>:%s/" % PORT)
return 0
+2 -2
View File
@@ -35,7 +35,7 @@ from lssh import run, upload # noqa: E402
RELAY_DIR = "/opt/ykxmz-relay"
PORT = "13245"
PUBLIC_URL = "http://13.54.30.196:%s" % PORT
PUBLIC_URL = "http://<源站IP>:%s" % PORT
MONITOR = r'''#!/bin/bash
# ============================================================================
@@ -46,7 +46,7 @@ MONITOR = r'''#!/bin/bash
#
# 为什么不查 127.0.0.1:
# 本机 200 只说明进程活着。用户真正关心的是"打开
# http://13.54.30.196:13245 能不能出页面" —— 那要经过
# http://<源站IP>:13245 能不能出页面" —— 那要经过
# Lightsail 的防火墙 / 安全组 / 上游线路。实测踩过:
# 隧道两条实例都掉了,本机 curl 照样 200,但公网 502。
#
+1 -1
View File
@@ -24,7 +24,7 @@ from lssh import run, upload # noqa: E402
RELAY_DIR = "/opt/ykxmz-relay"
PORT = "13245"
PUBLIC = "http://13.54.30.196:%s" % PORT
PUBLIC = "http://<源站IP>:%s" % PORT
CODE = ["relay.py", "admin.py", "landing.py", "showcase.py"]
ARTIFACTS = ["ykxmz-setup-{v}.exe", "ykxmz-watch-{v}.apk",
+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
+1 -1
View File
@@ -86,7 +86,7 @@ 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:13.54.30.196 https://$D/v1/health)"
printf ' %-30s %s\n' "公网 IP:443" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:<源站IP> https://$D/v1/health)"
'''
+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
+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",
+4 -4
View File
@@ -47,7 +47,7 @@ from lssh import run # noqa: E402
RELAY_DIR = "/opt/ykxmz-relay"
DOMAIN = "yk.xxmxxs.top"
FALLBACK = "http://13.54.30.196:80"
FALLBACK = "http://<源站IP>:80"
def main() -> int:
@@ -58,7 +58,7 @@ def main() -> int:
print()
print(" ── 1. 改 monitor.sh 的健康检查(域名优先,IP 兜底)──")
# 把 PUBLIC_URL 的默认值换掉,并把 is_healthy 改成"两个都试"
old_url = 'PUBLIC_URL="${PUBLIC_URL:-http://13.54.30.196:13245}"'
old_url = 'PUBLIC_URL="${PUBLIC_URL:-http://<源站IP>:13245}"'
new_url = (
'# 探测地址:域名优先(用户实际走的路),IP:80 兜底\n'
'# (域名 DNS 还没指过来时用它,否则监测会一直误报)\n'
@@ -151,10 +151,10 @@ def main() -> int:
'http://%s/v1/health)"; '
'printf " %-28s %s\\n" "IP:80 (兜底)" '
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
'http://13.54.30.196:80/v1/health)"; '
'http://<源站IP>:80/v1/health)"; '
'printf " %-28s %s\\n" "IP:13245 (已关闭)" '
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
'http://13.54.30.196:13245/v1/health)"' % DOMAIN,
'http://<源站IP>:13245/v1/health)"' % DOMAIN,
timeout=120, quiet=True)
for ln in out[1].strip().splitlines():
print(ln)
+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
+132
View File
@@ -0,0 +1,132 @@
# -*- 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>"
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())
+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())
+1 -1
View File
@@ -94,7 +94,7 @@ def main() -> int:
'https://yk.xxmxxs.top/v1/health)"; '
'printf " %-36s %s\\n" "公网 443(Lightsail 防火墙)" '
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
'https://13.54.30.196/v1/health 2>/dev/null || echo 不通)"'
'https://<源站IP>/v1/health 2>/dev/null || echo 不通)"'
)
out = run(probe, timeout=180, quiet=True)
for ln in out[1].strip().splitlines():
+2 -2
View File
@@ -6,7 +6,7 @@ r'''连 AWS Lightsail 那台新服务器的 SSH 助手。
devssh.py 是连**随身 WiFi 那台 4G 小机器**的(192.168.2.156,密码登录)。
那台还在跑(网站暂时还挂在上面),不能动它的连接逻辑。
新服务器用**密钥**登录、IP 是公网 13.54.30.196,认证方式完全不同,
新服务器用**密钥**登录、IP 是公网 <源站IP>,认证方式完全不同,
所以单独一个模块更清楚。
== 用法 ==
@@ -23,7 +23,7 @@ from pathlib import Path
import paramiko
HOST = os.environ.get("YKXMZ_LS_HOST", "13.54.30.196")
HOST = os.environ.get("YKXMZ_LS_HOST", "<源站IP>")
USER = os.environ.get("YKXMZ_LS_USER", "admin")
PORT = int(os.environ.get("YKXMZ_LS_PORT", "22"))
KEY = Path(os.environ.get(
+3 -3
View File
@@ -17,7 +17,7 @@ import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERSION = "2.1.3"
VERSION = "2.1.4"
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,可改)
能看到:设备列表、在线状态、配对码、流量、会话数
能操作:踢会话、抹掉配对码绑定
+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
+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")
+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())
+4 -4
View File
@@ -4,7 +4,7 @@ r'''在 Lightsail 上装 nginx,把 yk.xxmxxs.top 反代到中转。
== 架构 ==
用户 ──HTTPS──> Cloudflare CDN ──HTTP:80──> nginx ──> 127.0.0.1:13245
(yk.xxmxxs.top) 13.54.30.196
(yk.xxmxxs.top) <源站IP>
== 为什么要加 nginx 这一层 ==
@@ -24,7 +24,7 @@ r'''在 Lightsail 上装 nginx,把 yk.xxmxxs.top 反代到中转。
== 关于端口 80 的安全 ==
nginx 监听 80 是**必须的**(Cloudflare 的开源端口)。
但要注意:如果有人知道 13.54.30.196,也能直接打 80 绕过 Cloudflare。
但要注意:如果有人知道 <源站IP>,也能直接打 80 绕过 Cloudflare。
所以配置里加了:
* 只接受来自 Cloudflare 网段的请求(可选,见 allowlist 那段)
@@ -178,13 +178,13 @@ def main() -> int:
'-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://13.54.30.196:80/v1/health)"\n'
'http://<源站IP>: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://13.54.30.196:13245/v1/health)"\n'
'http://<源站IP>:13245/v1/health)"\n'
)
out = run(script, timeout=180, quiet=True)
for ln in out[1].strip().splitlines():
+1 -1
View File
@@ -7,7 +7,7 @@ DNS 配好、橙色云朵生效之后:
http://yk.xxmxxs.top/ -> 200 (经 Cloudflare,反代是好的)
https://yk.xxmxxs.top/ -> 522 (Cloudflare 连不上源站)
13.54.30.196:443 -> 不通(源站没听 443)
yk.xxmxxs.top:443 -> 不通(源站没听 443)
结论:zone 的 SSL/TLS 模式是 **Full** 或 **Full (strict)**,
Cloudflare 回源走 443,而源站只有 80 -> 522。
+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())

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