Files
ykxmz/_build/split_site_admin2.py
T
xmz7891 430923dd84 🔐 修一个严重安全问题:管理密码被硬编码进公开仓库
== 问题 ==

relay/relay.py 第 138 行:

    ADMIN_PASSWORD_DEFAULT = "@REVAedu120830"

而且 _tests/ 和 _build/ 下 11 个文件里也写着明文密码。
这个仓库是要推到**公开的 Gitee / EzHub** 的,等于:

    git 仓库 = 公开的
    管理页路径 /zhongzhuanzhan = 公开的(就写在代码里)
    公网地址 = 公开的
    密码 = 也在仓库里

**= 把管理台的门钥匙挂在门上。**

而且 git 历史删不掉 —— 只是在最新提交里删掉没用,
读过仓库的人已经从历史里拿到了。

== 修法 ==

### 1. 代码里不再有任何固定密码

relay.py 的 admin_password() 改成和 admin_key 一样的做法:

    环境变量 RELAY_ADMIN_PASSWORD
      -> data/admin_password.txt(600 权限,不进仓库)
      -> 都没有就**首次启动随机生成**,写进那个文件并打印一次

这样全新部署的密码天然是随机的、每台不同。

### 2. 11 个文件里的明文密码清掉

    _tests/  acceptance.py, test_relay_admin_login.py,
             test_admin_batch_delete.py, test_admin_button.py,
             test_admin_device_control.py, test_admin_js.py,
             test_admin_netcheck.py
    _build/  check_web.sh, verify_site_admin.sh,
             split_site_admin2.py, test_response_headers.py

统一改成从 RELAY_ADMIN_PASSWORD 环境变量读,
拿不到就从部署的 data/admin_password.txt 读,
再拿不到就**跳过需要登录的用例**(而不是编个假密码硬试)。

### 3. 真机上的密码已经轮换

设备上的 data/admin_password.txt 已换成用户指定的新密码,
并实测:
    ✓ 新密码能登录(HTTP 302 + lr_admin cookie)
    ✓ 老密码登不上了
    ✓ 新密码无换行(printf 不带 \n,免得读出来多一个字符)

顺带:重启中断了 relay-stability 的 A/B 对照 A 组(69 个样本),
分析数据时会标注这一段。

== 新增测试 ==

_tests/test_admin_password_gen.py 验证六件事:
    1. 全新部署会生成随机密码(不是那个老密码)
    2. 密码长度 >= 12、纯字母数字
    3. 用生成的密码能登录管理页
    4. **老密码登不进去**
    5. 重启后密码不变(从文件读回来)
    6. **两次全新部署的密码不一样**(真的是随机的)

== 还没做的(等用户决定)==

git **历史**里还有那个密码。要彻底清掉得重写历史 + 强制推送,
这是个危险操作,按用户的规矩必须他确认之后才做。
而且**光删历史不够** —— 密码已经公开过,必须换(已换)。
2026-10-07 20:49:33 +08:00

303 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 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 = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)"
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 = "120830"'
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())