mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 19:30:42 +00:00
用户报了三件事,一起处理。
== 一、发行版介绍里泄漏了服务器公网 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" 了,内容本身是对的。)
304 lines
13 KiB
Python
304 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
r"""中转站改造成「官网 + 管理页分家」(修正版)。
|
|
|
|
上一版脚本自己踩了 f-string 花括号没转义,在写文件前就崩了 ——
|
|
所以 relay.py 没被改动。这版全部改成普通字符串拼接。
|
|
|
|
用户要求:
|
|
relay.xxmxxs.top/ -> 官网(简洁介绍 + 下载)
|
|
relay.xxmxxs.top/zhingzhuanzhan -> 中转站管理(要登录)
|
|
管理账号 xmz7891 密码 @(从 RELAY_ADMIN_PASSWORD 环境变量读)
|
|
"""
|
|
from __future__ import annotations
|
|
import os
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py")
|
|
|
|
ADMIN_PATH = "/zhingzhuanzhan"
|
|
ADMIN_USER = "xmz7891"
|
|
ADMIN_PASS = (os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip()
|
|
|
|
|
|
def site_page_source() -> str:
|
|
"""生成 _site_page() 的源码。用拼接,避免 f-string 花括号地狱。"""
|
|
head = '''def _site_page() -> str:
|
|
"""官网首页:简洁介绍 + 下载。**不需要登录**。
|
|
|
|
用户要求把 relay.xxmxxs.top 当官网用,
|
|
管理后台挪到 ADMIN_PATH(见 _login_page / _admin_page)。
|
|
"""
|
|
files = _list_downloads()
|
|
|
|
def pick(suffix, keyword=""):
|
|
for item in files:
|
|
name = item["name"].lower()
|
|
if name.endswith(suffix) and (not keyword or keyword in name):
|
|
return item
|
|
return None
|
|
|
|
setup = pick(".exe")
|
|
server = pick(".zip", "server")
|
|
watch = pick(".apk")
|
|
|
|
def card(title, desc, item, tag):
|
|
if not item:
|
|
return ('<div class=card><div class=t>' + title + '</div>'
|
|
'<div class=d>' + desc + '</div>'
|
|
'<div class=soon>暂未上传</div></div>')
|
|
mb = item["size"] / 1024 / 1024
|
|
size = ("%.1f MB" % mb) if mb >= 0.1 else ("%.0f KB" % (item["size"] / 1024))
|
|
href = "/download/" + quote(item["name"])
|
|
return ('<div class=card><div class=t>' + title + '</div>'
|
|
'<div class=d>' + desc + '</div>'
|
|
'<a class=btn href="' + href + '">下载 ' + tag + '</a>'
|
|
'<div class=s>' + item["name"] + ' · ' + size + '</div></div>')
|
|
|
|
ver = ""
|
|
for item in files:
|
|
found = re.search(r"(\\d+\\.\\d+\\.\\d+)", item["name"])
|
|
if found:
|
|
ver = found.group(1)
|
|
break
|
|
|
|
cards = (card("电脑端", "教室那台 Windows 电脑装这个", setup, "安装包")
|
|
+ card("服务器端", "要跨网络才需要,装在一台小机器上", server, "压缩包")
|
|
+ card("手表端", "安卓手表装这个 APK", watch, "APK"))
|
|
|
|
badge = ("最新版 " + ver) if ver else ""
|
|
|
|
return """<!DOCTYPE html>
|
|
<html lang=zh-CN><head>
|
|
<meta charset=utf-8>
|
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
<title>ykxmz · 教室电脑手表遥控</title>
|
|
<link rel="icon" href="/favicon.ico">
|
|
<style>
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
|
|
background:#0d1117;color:#e6edf3;line-height:1.7}
|
|
.wrap{max-width:780px;margin:0 auto;padding:56px 20px 80px}
|
|
h1{font-size:34px;margin:0 0 10px;letter-spacing:-.5px}
|
|
.sub{color:#8b949e;font-size:17px;margin-bottom:10px}
|
|
.ver{display:inline-block;background:#1f6feb22;color:#79c0ff;
|
|
border:1px solid #1f6feb55;border-radius:20px;
|
|
padding:2px 12px;font-size:13px;margin-bottom:34px}
|
|
.lead{font-size:16px;color:#c9d1d9;margin:0 0 14px}
|
|
ul{color:#8b949e;padding-left:22px;margin:0 0 40px}
|
|
li{margin:4px 0}
|
|
.cards{display:grid;gap:14px;grid-template-columns:1fr}
|
|
@media(min-width:640px){.cards{grid-template-columns:1fr 1fr 1fr}}
|
|
.card{background:#161b22;border:1px solid #30363d;border-radius:12px;
|
|
padding:18px 16px;display:flex;flex-direction:column}
|
|
.card .t{font-weight:600;font-size:15px;margin-bottom:6px}
|
|
.card .d{color:#8b949e;font-size:13px;flex:1;margin-bottom:14px}
|
|
.btn{display:block;text-align:center;background:#238636;color:#fff;
|
|
text-decoration:none;padding:9px 14px;border-radius:8px;
|
|
font-weight:600;font-size:14px}
|
|
.btn:hover{background:#2ea043}
|
|
.soon{color:#6e7681;font-size:13px;text-align:center;padding:9px 0}
|
|
.s{color:#6e7681;font-size:11px;margin-top:8px;
|
|
word-break:break-all;text-align:center}
|
|
.foot{margin-top:46px;padding-top:20px;border-top:1px solid #21262d;
|
|
color:#6e7681;font-size:13px}
|
|
.foot a{color:#79c0ff;text-decoration:none}
|
|
</style></head><body><div class=wrap>
|
|
|
|
<h1>ykxmz</h1>
|
|
<div class=sub>老师抬一下手腕,就能管教室里那台电脑。</div>
|
|
<div class=ver>""" + badge + """</div>
|
|
|
|
<p class=lead>放课件、放音频、投图到大屏、看看谁在打游戏、下课关机 ——
|
|
不用走回讲台去点鼠标。</p>
|
|
<ul>
|
|
<li>手表和电脑在同一个 WiFi:直连,几十毫秒</li>
|
|
<li>不在同一个网络:自动走中转,办公室也能管教室</li>
|
|
<li>屏幕被看时电脑上一定有提示条,关不掉</li>
|
|
</ul>
|
|
|
|
<div class=cards>""" + cards + """</div>
|
|
|
|
<div class=foot>
|
|
源码在 <a href="https://gitee.com/xmz7891/ykxmz" target=_blank rel=noopener>Gitee</a>
|
|
· 历史版本在 <a href="https://gitee.com/xmz7891/ykxmz/releases" target=_blank rel=noopener>发行版</a>
|
|
· <a href=\"""" + ADMIN_PATH + """\">管理入口</a>
|
|
</div>
|
|
|
|
</div></body></html>"""
|
|
|
|
|
|
'''
|
|
return head
|
|
|
|
|
|
def main() -> int:
|
|
text = RELAY.read_text(encoding="utf-8")
|
|
if "_site_page" in text:
|
|
print(" 已经改造过")
|
|
return 0
|
|
|
|
ok = True
|
|
|
|
# ---- 1. 补导入 ----
|
|
if "\nimport re" not in text:
|
|
text = text.replace("import os\n", "import os\nimport re\n", 1)
|
|
print(" 补 import re")
|
|
if "from urllib.parse import quote" not in text:
|
|
text = text.replace("import urllib.parse\n",
|
|
"import urllib.parse\n"
|
|
"from urllib.parse import quote\n", 1)
|
|
print(" 补 from urllib.parse import quote")
|
|
|
|
# ---- 2. 密码 + 账号 ----
|
|
old_pw = 'ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死'
|
|
new_pw = ('ADMIN_PASSWORD_DEFAULT = "' + ADMIN_PASS + '"\n'
|
|
'# 管理页账号(原来只有密码,用户要求加上账号)\n'
|
|
'ADMIN_USER_DEFAULT = "' + ADMIN_USER + '"\n'
|
|
'ADMIN_USER = (os.environ.get("RELAY_ADMIN_USER") or\n'
|
|
' ADMIN_USER_DEFAULT)')
|
|
if old_pw in text:
|
|
text = text.replace(old_pw, new_pw, 1)
|
|
print(" 默认管理密码 -> " + ADMIN_PASS + ",账号 " + ADMIN_USER)
|
|
else:
|
|
print(" [提示] 默认密码锚点没找到")
|
|
|
|
# ---- 3. admin_login 加账号参数 ----
|
|
if "def admin_login(password: str) -> str:" in text:
|
|
text = text.replace("def admin_login(password: str) -> str:",
|
|
"def admin_login(username: str, password: str) -> str:", 1)
|
|
print(" admin_login 签名加了账号")
|
|
|
|
old_body = (' if not password or not secrets.compare_digest(password, admin_password()):\n'
|
|
' return ""')
|
|
new_body = (' # 账号也要对(用户要求用账号+密码登录)\n'
|
|
' if username and username.strip() != ADMIN_USER:\n'
|
|
' return ""\n'
|
|
' if not password or not secrets.compare_digest(password, admin_password()):\n'
|
|
' return ""')
|
|
if old_body in text:
|
|
text = text.replace(old_body, new_body, 1)
|
|
print(" admin_login 加密码校验")
|
|
else:
|
|
print(" [提示] admin_login 函数体锚点没找到")
|
|
|
|
# ---- 4. 路由 ----
|
|
old_route = (' if path in ("/", "/admin"):\n'
|
|
' # 管理页要登录 —— 上面会显示配对码,不能谁都能看\n'
|
|
' if not self._admin_gate():\n'
|
|
' self._send(200, _login_page(\n'
|
|
' "密码不对,再试一次" if _query(self.path, "bad") else ""\n'
|
|
' ), "text/html; charset=utf-8")\n'
|
|
' return\n'
|
|
' self._admin_page()\n'
|
|
' return')
|
|
new_route = (' # ---- 官网首页(公开,不用登录)----\n'
|
|
' if path == "/":\n'
|
|
' self._send(200, _site_page(), "text/html; charset=utf-8")\n'
|
|
' return\n\n'
|
|
' # ---- 管理后台 ----\n'
|
|
' # 挪到 ' + ADMIN_PATH + '(用户要求)。\n'
|
|
' # /admin 保留成跳转,免得存过书签的人打不开。\n'
|
|
' if path == "/admin":\n'
|
|
' self._redirect("' + ADMIN_PATH + '")\n'
|
|
' return\n\n'
|
|
' if path == "' + ADMIN_PATH + '":\n'
|
|
' # 管理页要登录 —— 上面会显示配对码,不能谁都能看\n'
|
|
' if not self._admin_gate():\n'
|
|
' self._send(200, _login_page(\n'
|
|
' "账号或密码不对,再试一次"\n'
|
|
' if _query(self.path, "bad") else ""\n'
|
|
' ), "text/html; charset=utf-8")\n'
|
|
' return\n'
|
|
' self._admin_page()\n'
|
|
' return')
|
|
if old_route in text:
|
|
text = text.replace(old_route, new_route, 1)
|
|
print(" 路由:/ 官网," + ADMIN_PATH + " 管理页")
|
|
else:
|
|
print(" [失败] 路由锚点没找到")
|
|
ok = False
|
|
|
|
# ---- 5. 登录接口 ----
|
|
old_login = (' if path == "/admin/login":\n'
|
|
' form = self._body().decode("utf-8", "replace")\n'
|
|
' password = ""\n'
|
|
' for piece in form.split("&"):\n'
|
|
' name, _, value = piece.partition("=")\n'
|
|
' if name == "password":\n'
|
|
' from urllib.parse import unquote_plus\n'
|
|
' password = unquote_plus(value)\n'
|
|
' token = admin_login(password)\n'
|
|
' if not token:\n'
|
|
' log(f"管理页登录失败,来自 {self._client_ip()}")\n'
|
|
' self._redirect("/?bad=1")\n'
|
|
' return\n'
|
|
' log(f"管理页登录成功,来自 {self._client_ip()}")\n'
|
|
' self.send_response(302)\n'
|
|
' self.send_header("Location", "/")')
|
|
new_login = (' if path == "/admin/login":\n'
|
|
' form = self._body().decode("utf-8", "replace")\n'
|
|
' username = ""\n'
|
|
' password = ""\n'
|
|
' from urllib.parse import unquote_plus\n'
|
|
' for piece in form.split("&"):\n'
|
|
' name, _, value = piece.partition("=")\n'
|
|
' if name == "password":\n'
|
|
' password = unquote_plus(value)\n'
|
|
' elif name == "username":\n'
|
|
' username = unquote_plus(value)\n'
|
|
' token = admin_login(username, password)\n'
|
|
' if not token:\n'
|
|
' log("管理页登录失败(%s),来自 %s"\n'
|
|
' % (username or "未填账号", self._client_ip()))\n'
|
|
' self._redirect("' + ADMIN_PATH + '?bad=1")\n'
|
|
' return\n'
|
|
' log("管理页登录成功(%s),来自 %s"\n'
|
|
' % (username, self._client_ip()))\n'
|
|
' self.send_response(302)\n'
|
|
' self.send_header("Location", "' + ADMIN_PATH + '")')
|
|
if old_login in text:
|
|
text = text.replace(old_login, new_login, 1)
|
|
print(" 登录接口:接受账号 + 密码")
|
|
else:
|
|
print(" [失败] 登录接口锚点没找到")
|
|
ok = False
|
|
|
|
# ---- 6. 登录表单加账号框 ----
|
|
old_form = '<form method=post action=/admin/login>'
|
|
if old_form in text:
|
|
text = text.replace(
|
|
old_form,
|
|
old_form + '<input name=username placeholder=账号 '
|
|
'autocomplete=username required>', 1)
|
|
print(" 登录表单加了账号框")
|
|
|
|
# ---- 7. 插入官网函数 ----
|
|
anchor = "def _login_page("
|
|
if anchor in text:
|
|
text = text.replace(anchor, site_page_source() + anchor, 1)
|
|
print(" 插入 _site_page()")
|
|
else:
|
|
print(" [失败] 找不到 _login_page")
|
|
ok = False
|
|
|
|
RELAY.write_text(text, encoding="utf-8", newline="\n")
|
|
|
|
try:
|
|
ast.parse(text)
|
|
print(" relay.py 语法 OK")
|
|
except SyntaxError as exc:
|
|
print(" [失败] 语法错误: 行 %s: %s" % (exc.lineno, exc.msg))
|
|
lines = text.splitlines()
|
|
for i in range(max(0, (exc.lineno or 1) - 3),
|
|
min(len(lines), (exc.lineno or 1) + 2)):
|
|
print(" %d: %s" % (i + 1, lines[i][:90]))
|
|
return 1
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|