Files
ykxmz/_build/patch_admin_password.py
T
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

252 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""管理页加登录密码(用户指定:<改从环境变量读>)。
为什么要改:
之前管理页靠「钥匙」放在 URL 里(/?key=xxx)。
这样钥匙会进浏览器历史、进 Cloudflare 日志、被人肩窥。
改成标准做法:登录页输密码 -> 种一个 HttpOnly Cookie。
* 密码默认 <改从环境变量读>,存在 data/admin_password.txt,可改
* 登录成功后种 Cookie,有效期 12 小时
* 命令行/脚本仍可用 X-Admin 头(放钥匙或密码都认)
"""
from __future__ import annotations
import sys
from pathlib import Path
RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
# ---------------- 1. 密码 + 会话 ----------------
OLD = '''def admin_ok(supplied: str) -> bool:
"""常数时间比较,避免时序侧信道。"""
if not supplied:
return False
return secrets.compare_digest(supplied, admin_key())'''
NEW = '''# 管理页密码。用户指定的默认值,可以在 data/admin_password.txt 里改。
ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死
ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "")
# 登录会话:cookie 令牌 -> 过期时间
ADMIN_SESSIONS: dict = {}
ADMIN_SESSION_SECONDS = 12 * 3600
def admin_password() -> str:
global ADMIN_PASSWORD
if ADMIN_PASSWORD:
return ADMIN_PASSWORD
path = DATA_DIR / "admin_password.txt"
try:
if path.is_file():
ADMIN_PASSWORD = path.read_text(encoding="utf-8").strip()
if not ADMIN_PASSWORD:
ADMIN_PASSWORD = ADMIN_PASSWORD_DEFAULT
DATA_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(ADMIN_PASSWORD, encoding="utf-8")
try:
os.chmod(path, 0o600)
except OSError:
pass
except Exception: # noqa: BLE001
ADMIN_PASSWORD = ADMIN_PASSWORD or ADMIN_PASSWORD_DEFAULT
return ADMIN_PASSWORD
def admin_login(password: str) -> str:
"""验密码,对了就发一个会话令牌。"""
if not password or not secrets.compare_digest(password, admin_password()):
return ""
token = secrets.token_urlsafe(24)
now = time.time()
# 顺手清掉过期的,别让字典涨
for old, expiry in list(ADMIN_SESSIONS.items()):
if expiry < now:
ADMIN_SESSIONS.pop(old, None)
ADMIN_SESSIONS[token] = now + ADMIN_SESSION_SECONDS
return token
def admin_session_valid(token: str) -> bool:
if not token:
return False
expiry = ADMIN_SESSIONS.get(token)
if expiry is None:
return False
if expiry < time.time():
ADMIN_SESSIONS.pop(token, None)
return False
return True
def admin_ok(supplied: str) -> bool:
"""命令行/脚本用的通道:钥匙或者密码,常数时间比较。"""
if not supplied:
return False
if secrets.compare_digest(supplied, admin_key()):
return True
return secrets.compare_digest(supplied, admin_password())'''
# ---------------- 2. Cookie 解析 ----------------
OLD_IP = ''' def _client_ip(self) -> str:'''
NEW_IP = ''' def _admin_cookie(self) -> str:
"""从 Cookie 里取管理会话令牌。"""
raw = self.headers.get("Cookie") or ""
for piece in raw.split(";"):
name, _, value = piece.strip().partition("=")
if name == "lr_admin":
return value
return ""
def _admin_gate(self) -> bool:
"""管理页/管理接口的统一入口:Cookie 或 X-Admin 头。"""
if admin_session_valid(self._admin_cookie()):
return True
return admin_ok(self.headers.get("X-Admin") or "")
def _client_ip(self) -> str:'''
# ---------------- 3. 登录页 + 路由 ----------------
OLD_GET_ADMIN = ''' # ---- 管理接口(GET,也要钥匙)----
if path.startswith("/v1/admin/"):
supplied = (self.headers.get("X-Admin")
or _query(self.path, "key") or "")
if not admin_ok(supplied):
self._json(403, {"ok": False, "error": "管理钥匙不对"})
return
if path == "/v1/admin/devices":
self._json(200, RELAY.snapshot())
return
self._json(404, {"ok": False, "error": "没有这个管理接口"})
return
if path in ("/", "/admin"):
# 管理页要钥匙 —— 上面会显示配对码,不能谁都能看
supplied = _query(self.path, "key") or ""
if not admin_ok(supplied):
self._send(403, _login_page(), "text/html; charset=utf-8")
return
self._admin_page()
return'''
NEW_GET_ADMIN = ''' # ---- 退出登录 ----
if path == "/admin/logout":
token = self._admin_cookie()
ADMIN_SESSIONS.pop(token, None)
self._send(302, b"", "text/html; charset=utf-8")
# 手动补 Location 和清 Cookie
return
# ---- 管理接口(GET)----
if path.startswith("/v1/admin/"):
if not self._admin_gate():
self._json(401, {"ok": False, "error": "请先登录"})
return
if path == "/v1/admin/devices":
self._json(200, RELAY.snapshot())
return
self._json(404, {"ok": False, "error": "没有这个管理接口"})
return
if path in ("/", "/admin"):
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
if not self._admin_gate():
self._send(200, _login_page(
"密码不对,再试一次" if _query(self.path, "bad") else ""
), "text/html; charset=utf-8")
return
self._admin_page()
return'''
OLD_POST_ADMIN = ''' # ---- 管理接口(要钥匙)----
if path.startswith("/v1/admin/"):
supplied = (self.headers.get("X-Admin")
or _query(self.path, "key") or "")
if not admin_ok(supplied):
self._json(403, {"ok": False, "error": "管理钥匙不对"})
return'''
NEW_POST_ADMIN = ''' # ---- 登录 ----
if path == "/admin/login":
form = self._body().decode("utf-8", "replace")
password = ""
for piece in form.split("&"):
name, _, value = piece.partition("=")
if name == "password":
from urllib.parse import unquote_plus
password = unquote_plus(value)
token = admin_login(password)
if not token:
log(f"管理页登录失败,来自 {self._client_ip()}")
self._redirect("/?bad=1")
return
log(f"管理页登录成功,来自 {self._client_ip()}")
self.send_response(302)
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
f"lr_admin={token}; Path=/; HttpOnly; SameSite=Lax; "
f"Max-Age={ADMIN_SESSION_SECONDS}")
self.send_header("Content-Length", "0")
self.end_headers()
return
# ---- 管理接口(要登录)----
if path.startswith("/v1/admin/"):
if not self._admin_gate():
self._json(401, {"ok": False, "error": "请先登录"})
return'''
# ---------------- 4. 登录页 HTML ----------------
OLD_LOGIN = '''def _login_page() -> bytes:
return """<!doctype html><html lang=zh><meta charset=utf-8>'''
NEW_LOGIN = '''def _redirect(self, location: str) -> None:
pass
def _login_page(message: str = "") -> bytes:
notice = (f'<p style="color:#f85149">{message}</p>' if message else "")
return ("""<!doctype html><html lang=zh><meta charset=utf-8>'''
def main() -> int:
text = RELAY.read_text(encoding="utf-8")
ok = True
for old, new, tag in ((OLD, NEW, "密码 + 登录会话"),
(OLD_IP, NEW_IP, "Cookie 解析 + 统一鉴权"),
(OLD_GET_ADMIN, NEW_GET_ADMIN, "GET 路由改登录"),
(OLD_POST_ADMIN, NEW_POST_ADMIN, "登录接口"),
(OLD_LOGIN, NEW_LOGIN, "登录页带错误提示")):
if old not in text:
print(f" [失败] {tag} —— 锚点没找到")
ok = False
continue
text = text.replace(old, new, 1)
print(f" {tag}")
RELAY.write_text(text, encoding="utf-8")
import ast
tree = ast.parse(text)
defined = {n.name for n in tree.body
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
needed = {"_query", "_login_page", "_esc", "_kb", "_uptime",
"admin_key", "admin_ok", "admin_password", "admin_login",
"admin_session_valid"}
missing = sorted(needed - defined)
print(f" 模块级函数: {sorted(defined)}")
if missing:
print(f" [失败] 还缺: {missing}")
ok = False
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())