mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 21:50:44 +00:00
按用户定的规矩,这一轮有新功能,升第二位: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 个阶段全部通过。
2089 lines
88 KiB
Python
2089 lines
88 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""局域网遥控 · 中转服务器
|
||
|
||
作用:让**不在同一网络**的被控端和手表连上。
|
||
|
||
手表 ──HTTPS──┐
|
||
├──> Cloudflare 边缘 ──隧道──> cloudflared ──> 本程序 :8080
|
||
被控端 ─HTTPS─┘
|
||
|
||
设计要点
|
||
--------
|
||
1. **盲转发**:本程序不认识上层协议,只按「配对码」把两条连接的字节流对上。
|
||
配对、令牌校验、权限判断全部由被控端自己做 —— 所以就算这台中转机
|
||
被攻破,也进不了用户的电脑。
|
||
|
||
2. **长轮询**(不是 WebSocket):HTTP 长轮询在任何代理/4G 网络下都最稳,
|
||
而且两端用标准库就能实现(被控端是 Python 标准库,手表是纯 Java 无依赖)。
|
||
|
||
3. **省内存**(这台机器只有 460MB):
|
||
* 队列用 deque(maxlen) 封顶,客户端不取就丢最旧的,绝不无限增长
|
||
* 单条消息和整队都有字节上限
|
||
* 线程按需创建,空闲就退出
|
||
* 不引任何第三方库
|
||
|
||
接口
|
||
----
|
||
被控端:
|
||
POST /v1/agent/register {"deviceId","code","name","version"} -> {"ok","token","code"}
|
||
POST /v1/agent/poll 头 X-Token -> 原始字节(长轮询,最多 25 秒)
|
||
POST /v1/agent/send 头 X-Token,体=原始字节 -> {"ok"}
|
||
POST /v1/agent/bye 头 X-Token -> {"ok"}
|
||
|
||
控制端:
|
||
POST /v1/client/attach {"code","name"} -> {"ok","session","deviceName","online"}
|
||
POST /v1/client/poll 头 X-Session -> 原始字节
|
||
POST /v1/client/send 头 X-Session,体=原始字节 -> {"ok"}
|
||
POST /v1/client/detach 头 X-Session -> {"ok"}
|
||
|
||
公共:
|
||
GET /v1/health -> {"ok","devices","sessions",...}
|
||
GET / 管理页(要 key)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import socket
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
from urllib.parse import quote
|
||
import urllib.request
|
||
from collections import deque
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
|
||
VERSION = "2.1.0"
|
||
|
||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||
|
||
PORT = int(os.environ.get("RELAY_PORT", "8080"))
|
||
BIND = os.environ.get("RELAY_BIND", "127.0.0.1")
|
||
DATA_DIR = Path(os.environ.get("RELAY_DATA", "/root/lanremote-relay"))
|
||
|
||
# 长轮询最多挂多久。太长会占着 Cloudflare 的连接,太短则空转请求多。
|
||
# 长轮询挂多久。
|
||
# 25 -> 60:隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源),
|
||
# 而这个开销是按"次数"收的。60 秒一循环把请求数降到 1/4,
|
||
# 暴露在丢包/重连窗口里的概率也降到 1/4。
|
||
# **不会让命令变慢**:有数据时立刻返回(靠 device.event 唤醒),
|
||
# 拉长只是减少空转重开。
|
||
# Cloudflare 单请求上限 100 秒,60 留了 40 秒余量(实测 4/4 通过)。
|
||
POLL_SECONDS = float(os.environ.get("RELAY_POLL", "60"))
|
||
|
||
# 单个设备最多缓多少字节没发出去。手表半天不取就丢最旧的,保住内存。
|
||
MAX_QUEUE_BYTES = int(os.environ.get("RELAY_QUEUE_BYTES",
|
||
str(2 * 1024 * 1024)))
|
||
# 单次请求体上限(一个分块 256KB + JSON 外壳,给到 4MB 很宽松)
|
||
MAX_BODY = int(os.environ.get("RELAY_MAX_BODY", str(4 * 1024 * 1024)))
|
||
|
||
# 配对码:6 位数字(和手表上输的一致)
|
||
CODE_RE = re.compile(r"^\d{6}$")
|
||
|
||
# 设备多久没心跳算掉线
|
||
DEVICE_TIMEOUT = 90.0
|
||
# 设备多久没动静就**从列表里彻底删掉**。
|
||
#
|
||
# 和 DEVICE_TIMEOUT(90 秒)不一样:那个只是标记"离线",
|
||
# 记录还留着,方便手表看到"我以前连过这台"。
|
||
# 但记录只增不减会出问题 —— 被控端每次换端口/重装都用新 deviceId
|
||
# 注册一次,老的永远留着。实测一天攒了 75 条僵尸记录,
|
||
# /v1/health 从几 KB 涨到 20KB,手表和网页每次白下载一大坨。
|
||
# 6 小时足够覆盖"上课中途断一下",真回来会重新注册。
|
||
DEVICE_TTL = float(os.environ.get("RELAY_DEVICE_TTL", str(6 * 3600)))
|
||
# 一个设备最多同时挂几个手表会话
|
||
MAX_SESSIONS_PER_DEVICE = 3
|
||
|
||
# 防爆破:同一个 IP 每小时最多试几次配对码
|
||
ATTACH_LIMIT = int(os.environ.get("RELAY_ATTACH_LIMIT", "30"))
|
||
ATTACH_WINDOW = 3600.0
|
||
|
||
ADMIN_KEY = os.environ.get("RELAY_ADMIN_KEY", "")
|
||
|
||
|
||
def admin_key() -> str:
|
||
"""管理页 / 管理接口的钥匙。
|
||
|
||
没配置就自动生成一个并记到 data/admin_key.txt。
|
||
以前默认是空字符串,等于管理页谁都能打开 ——
|
||
那上面能看到配对码,太不合适了。
|
||
"""
|
||
global ADMIN_KEY
|
||
if ADMIN_KEY:
|
||
return ADMIN_KEY
|
||
path = DATA_DIR / "admin_key.txt"
|
||
try:
|
||
if path.is_file():
|
||
ADMIN_KEY = path.read_text(encoding="utf-8").strip()
|
||
if not ADMIN_KEY:
|
||
ADMIN_KEY = secrets.token_urlsafe(18)
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(ADMIN_KEY, encoding="utf-8")
|
||
try:
|
||
os.chmod(path, 0o600)
|
||
except OSError:
|
||
pass
|
||
except Exception: # noqa: BLE001
|
||
ADMIN_KEY = ADMIN_KEY or secrets.token_urlsafe(18)
|
||
return ADMIN_KEY
|
||
|
||
|
||
# 管理页密码。
|
||
#
|
||
# ⚠ 这里**不放默认密码**(以前放了一个写死的,是个严重问题):
|
||
# 代码一旦进公开仓库,那个密码就等于公开了 ——
|
||
# 而管理页路径和公网地址也是公开的,等于把管理台的门钥匙挂在门上。
|
||
# 就算是私人自用,也不该把密码提交进 git:git 历史删不掉。
|
||
#
|
||
# 正确做法和 admin_key 一样:首次启动**随机生成**,写进
|
||
# data/admin_password.txt(权限 600),只有本机 root 能看。
|
||
# 想指定就用环境变量 RELAY_ADMIN_PASSWORD。
|
||
ADMIN_PASSWORD_DEFAULT = ""
|
||
# 管理页账号。账号本身不是秘密(还得配密码),但也允许覆盖。
|
||
ADMIN_USER_DEFAULT = "xmz7891"
|
||
ADMIN_USER = (os.environ.get("RELAY_ADMIN_USER") or
|
||
ADMIN_USER_DEFAULT)
|
||
ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "")
|
||
# 登录会话:cookie 令牌 -> 过期时间
|
||
# 成品下载目录(管理页上给人下载 APK / 整包用)。
|
||
# 只放白名单后缀的文件,文件名做严格校验防目录穿越。
|
||
DOWNLOADS_DIR = Path(__file__).resolve().parent / "downloads"
|
||
|
||
# 抢救报告目录。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]
|
||
|
||
|
||
DOWNLOAD_SUFFIXES = (".apk", ".zip", ".txt", ".exe", ".idsig")
|
||
|
||
|
||
def _safe_download_name(name: str) -> str:
|
||
"""校验下载文件名。不合法返回空串。
|
||
|
||
防目录穿越:只允许字母数字、点、横线、下划线、中文,
|
||
且必须是白名单后缀。
|
||
"""
|
||
name = (name or "").strip()
|
||
if not name or len(name) > 120:
|
||
return ""
|
||
if "/" in name or "\\" in name or ".." in name:
|
||
return ""
|
||
if not name.lower().endswith(DOWNLOAD_SUFFIXES):
|
||
return ""
|
||
for ch in name:
|
||
if ch.isalnum() or ch in "._-()() ":
|
||
continue
|
||
return ""
|
||
return name
|
||
|
||
|
||
def _list_downloads() -> list:
|
||
"""列出可下载的文件(按修改时间倒序)。"""
|
||
items = []
|
||
try:
|
||
if not DOWNLOADS_DIR.is_dir():
|
||
return items
|
||
for entry in DOWNLOADS_DIR.iterdir():
|
||
if not entry.is_file():
|
||
continue
|
||
if not _safe_download_name(entry.name):
|
||
continue
|
||
stat = entry.stat()
|
||
items.append({
|
||
"name": entry.name,
|
||
"size": stat.st_size,
|
||
"mtime": stat.st_mtime,
|
||
})
|
||
except OSError:
|
||
return items
|
||
items.sort(key=lambda item: item["mtime"], reverse=True)
|
||
return items
|
||
|
||
ADMIN_SESSIONS: dict = {}
|
||
ADMIN_SESSION_SECONDS = 12 * 3600
|
||
|
||
# 管理后台的路径。
|
||
# 用户要求官网放 /(公开),管理后台另开一个不那么好猜的路径。
|
||
#
|
||
# 之前这个常量其实**从来没定义过** —— 路由里写的是字面量
|
||
# "/zhongzhuanzhan",而新写的 _admin_page 引用了 ADMIN_PATH,
|
||
# 结果一访问管理页就 NameError(页面 000)。
|
||
# 现在统一定义,路由和页面都引用它,避免再次不一致。
|
||
ADMIN_PATH = "/zhongzhuanzhan"
|
||
|
||
|
||
def admin_password() -> str:
|
||
"""管理页密码。优先级:环境变量 -> data/admin_password.txt -> 随机生成。
|
||
|
||
⚠ 为什么是"随机生成"而不是"写死一个默认值":
|
||
写死的默认值一旦进了公开仓库,就等于把管理台钥匙公开 ——
|
||
而且 git 历史删不掉,改代码也补不回来。
|
||
随机生成 + 写进 600 权限的文件,只有本机能看。
|
||
"""
|
||
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:
|
||
if ADMIN_PASSWORD_DEFAULT:
|
||
# 兼容:确实想指定一个固定值(不推荐,别提交进仓库)
|
||
ADMIN_PASSWORD = ADMIN_PASSWORD_DEFAULT
|
||
else:
|
||
# 生成一个好念但够强的密码:4 段,每段 4 个字符
|
||
# 用 token_urlsafe 保证随机性,去掉易混字符
|
||
raw = secrets.token_urlsafe(12)
|
||
ADMIN_PASSWORD = "".join(
|
||
ch for ch in raw if ch.isalnum())[:16]
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(ADMIN_PASSWORD, encoding="utf-8")
|
||
try:
|
||
os.chmod(path, 0o600)
|
||
except OSError:
|
||
pass
|
||
log("=" * 56)
|
||
log(" 已生成管理页密码(只显示这一次,也写在下面这个文件里)")
|
||
log(" 账号:%s" % ADMIN_USER)
|
||
log(" 密码:%s" % ADMIN_PASSWORD)
|
||
log(" 文件:%s" % path)
|
||
log(" 想改就直接改那个文件,或者设 RELAY_ADMIN_PASSWORD。")
|
||
log(" ⚠ 不要把密码提交进 git —— 历史删不掉。")
|
||
log("=" * 56)
|
||
except Exception: # noqa: BLE001
|
||
# 落盘失败也不能让管理页打不开:退回一个进程内随机密码
|
||
ADMIN_PASSWORD = ADMIN_PASSWORD or "".join(
|
||
ch for ch in secrets.token_urlsafe(12) if ch.isalnum())[:16]
|
||
return ADMIN_PASSWORD
|
||
|
||
|
||
def admin_login(username: str, password: str) -> str:
|
||
"""验密码,对了就发一个会话令牌。"""
|
||
# 账号也要对(用户要求用账号+密码登录)
|
||
if username and username.strip() != ADMIN_USER:
|
||
return ""
|
||
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())
|
||
|
||
|
||
def public_url() -> str:
|
||
"""当前隧道的公网地址。
|
||
|
||
隧道换地址时会写进 public_url.txt(tunnel-run.sh 负责维护)。
|
||
这里几个可能的位置都找一下,找到第一个像地址的就返回。
|
||
"""
|
||
candidates = [
|
||
DATA_DIR.parent / "public_url.txt",
|
||
DATA_DIR / "public_url.txt",
|
||
Path("/root/lanremote-relay/public_url.txt"),
|
||
]
|
||
for path in candidates:
|
||
try:
|
||
if not path.is_file():
|
||
continue
|
||
text = path.read_text(encoding="utf-8").strip()
|
||
if text.startswith("http"):
|
||
return text
|
||
except OSError:
|
||
continue
|
||
return ""
|
||
|
||
|
||
def log(message: str) -> None:
|
||
stamp = time.strftime("%m-%d %H:%M:%S")
|
||
print(f"[{stamp}] {message}", flush=True)
|
||
|
||
|
||
# ======================================================================
|
||
# 数据
|
||
# ======================================================================
|
||
|
||
class Device:
|
||
"""一台被控端。"""
|
||
|
||
__slots__ = ("device_id", "code", "name", "version", "token",
|
||
"last_seen", "connected_at", "to_client", "to_agent",
|
||
"event", "bytes_in", "bytes_out", "current_session",
|
||
"active_session", "lan_ips", "port")
|
||
|
||
def __init__(self, device_id: str, code: str, name: str,
|
||
version: str = "") -> None:
|
||
self.device_id = device_id
|
||
self.code = code
|
||
self.name = name
|
||
self.version = version
|
||
self.token = secrets.token_hex(24)
|
||
# 这台电脑的局域网 IP 和监听端口。
|
||
# 手表拿它判断"要不要切成局域网直连"。
|
||
self.lan_ips: list = []
|
||
self.port: int = 0
|
||
self.last_seen = time.time()
|
||
self.connected_at = time.time()
|
||
self.to_client = bytearray() # 被控端 -> 手表
|
||
self.to_agent = bytearray() # 手表 -> 被控端
|
||
self.event = threading.Event() # 有新数据时唤醒长轮询
|
||
self.bytes_in = 0
|
||
self.bytes_out = 0
|
||
# 当前挂在这台设备上的手表会话令牌。变了就说明手表换了一台/重连了,
|
||
# 被控端据此重开一条会话,避免把上一条的状态带过来。
|
||
self.current_session = ""
|
||
# 当前挂在这台设备上的 Session 对象。数据来了只唤醒它,
|
||
# 不要让已经被顶掉的旧会话把数据截胡。
|
||
self.active_session = None
|
||
|
||
def online(self) -> bool:
|
||
return (time.time() - self.last_seen) < DEVICE_TIMEOUT
|
||
|
||
def push_to_agent(self, data: bytes) -> None:
|
||
if not data:
|
||
return
|
||
self.bytes_in += len(data)
|
||
self._append(self.to_agent, data)
|
||
self.event.set()
|
||
|
||
def push_to_client(self, data: bytes) -> None:
|
||
if not data:
|
||
return
|
||
self.bytes_out += len(data)
|
||
self._append(self.to_client, data)
|
||
self.event.set()
|
||
# 同时叫醒当前那个会话的长轮询(旧会话已经被替换,不该再拿数据)
|
||
session = self.active_session
|
||
if session is not None:
|
||
session.event.set()
|
||
|
||
@staticmethod
|
||
def _append(buffer: bytearray, data: bytes) -> None:
|
||
"""往队列里塞数据,超上限就丢最旧的 —— 内存优先。"""
|
||
buffer.extend(data)
|
||
overflow = len(buffer) - MAX_QUEUE_BYTES
|
||
if overflow > 0:
|
||
del buffer[:overflow]
|
||
|
||
|
||
class Session:
|
||
"""一个手表连接。"""
|
||
|
||
__slots__ = ("token", "device_id", "code", "name", "ip",
|
||
"created", "last_seen", "event", "replaced")
|
||
|
||
def __init__(self, device_id: str, code: str, name: str, ip: str) -> None:
|
||
self.token = secrets.token_hex(24)
|
||
self.device_id = device_id
|
||
self.code = code
|
||
self.name = name
|
||
self.ip = ip
|
||
self.created = time.time()
|
||
self.last_seen = time.time()
|
||
# 自己的唤醒事件。每个会话独立等待,避免被顶掉的旧会话截胡数据。
|
||
self.event = threading.Event()
|
||
self.replaced = False
|
||
|
||
|
||
class SessionReplaced(Exception):
|
||
"""这个会话已经被新的手表顶替了 —— 它必须让位,不能继续取数据。"""
|
||
|
||
|
||
class Relay:
|
||
def __init__(self) -> None:
|
||
self.lock = threading.RLock()
|
||
self.devices: dict = {} # device_id -> Device
|
||
self.codes: dict = {} # code -> device_id
|
||
self.sessions: dict = {} # session_token -> Session
|
||
self.attach_log: dict = {} # ip -> [时间戳]
|
||
# 被新连接顶替掉的会话:token -> 原因。
|
||
# 留着是为了能明确告诉旧客户端"你是被顶掉的",
|
||
# 而不是含糊的 401 —— 否则它会以为自己只是过期了,然后抢回来。
|
||
self.retired: dict = {}
|
||
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
|
||
self.allow_new = True
|
||
# 设备历史名单(只有 deviceId,不含配对码)。
|
||
# **超时清理绝不动它** —— 它是"这台设备曾经被允许过"的唯一凭证。
|
||
# 详见 register() 里 known_device 的注释。
|
||
self.known_devices: set = set()
|
||
self.started = time.time()
|
||
self.total_requests = 0
|
||
self.state_file = DATA_DIR / "relay_state.json"
|
||
self._load()
|
||
|
||
# ---------------- 持久化(只存配对码,恢复单设备单配对码制)--------
|
||
|
||
def _load(self) -> None:
|
||
try:
|
||
raw = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||
except Exception: # noqa: BLE001
|
||
return
|
||
# 是否允许新设备接入(重启不丢)
|
||
self.allow_new = bool(raw.get("allowNew", True))
|
||
# 恢复设备历史名单(重启不丢)
|
||
for did in raw.get("knownDevices", []):
|
||
did = str(did or "").strip()
|
||
if did:
|
||
self.known_devices.add(did)
|
||
for item in raw.get("codes", []):
|
||
device_id = str(item.get("deviceId", ""))
|
||
code = str(item.get("code", ""))
|
||
if device_id and CODE_RE.match(code):
|
||
self.codes[code] = device_id
|
||
log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")
|
||
|
||
def _save(self) -> None:
|
||
try:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
payload = {
|
||
"allowNew": bool(self.allow_new),
|
||
# 设备历史:超时清理不会删它,所以关掉接入开关后
|
||
# 老设备第二天开机仍然能上线
|
||
"knownDevices": sorted(self.known_devices),
|
||
"codes": [
|
||
{"deviceId": did, "code": code}
|
||
for code, did in sorted(self.codes.items())
|
||
],
|
||
}
|
||
tmp = self.state_file.with_suffix(".tmp")
|
||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
tmp.replace(self.state_file)
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"保存状态失败:{exc}")
|
||
|
||
# ---------------- 被控端 ----------------
|
||
|
||
def register(self, body: dict) -> dict:
|
||
"""被控端上线登记。
|
||
|
||
**单设备单配对码制**:
|
||
* 一台设备第一次来 -> 记下它报的码
|
||
* 同一台设备(deviceId 一样)以后再来 -> 一律用它**原来那个码**,
|
||
它要是报了别的码,告诉它用旧的
|
||
* 一个码不能同时属于两台设备 -> 拒绝
|
||
"""
|
||
device_id = str(body.get("deviceId") or "").strip()
|
||
code = str(body.get("code") or "").strip()
|
||
name = str(body.get("name") or "").strip()[:40] or "未命名设备"
|
||
version = str(body.get("version") or "").strip()[:20]
|
||
# 这台电脑的局域网 IP + 端口(手表判断要不要走局域网直连)
|
||
raw_ips = body.get("lanIps")
|
||
lan_ips = []
|
||
if isinstance(raw_ips, list):
|
||
for item in raw_ips[:8]:
|
||
text = str(item).strip()
|
||
# 只收看起来像 IPv4 的,别把乱七八糟的东西转给手表
|
||
parts = text.split(".")
|
||
if len(parts) == 4 and all(
|
||
p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
||
lan_ips.append(text)
|
||
try:
|
||
listen_port = int(body.get("port") or 0)
|
||
except (TypeError, ValueError):
|
||
listen_port = 0
|
||
if not (0 < listen_port < 65536):
|
||
listen_port = 0
|
||
|
||
if not device_id or len(device_id) > 80:
|
||
return {"ok": False, "error": "deviceId 不合法"}
|
||
# deviceId 走白名单:字母数字 + - _ . :
|
||
#
|
||
# 为什么要卡这么死:
|
||
# * 控制字符(\x00、\r\n)能进来 —— 管理页转义做得对所以
|
||
# 不是 XSS,但 deviceId 会写进日志,`\r\n` 能伪造日志行
|
||
# * 路径分隔符(/ \)能进来 —— 会进状态文件和页面,不该有
|
||
#
|
||
# 真实 deviceId 的形状是「机器名-12位hex」(见 _ensure_device_id),
|
||
# 字符集本来就很窄,卡严不会有任何副作用。
|
||
if not all(ch.isalnum() or ch in "-_.:" for ch in device_id):
|
||
log(f"deviceId 含非法字符,拒绝:{device_id!r}")
|
||
return {"ok": False, "error": "deviceId 含非法字符"}
|
||
if not CODE_RE.match(code):
|
||
return {"ok": False, "error": "配对码必须是 6 位数字"}
|
||
|
||
with self.lock:
|
||
# 管理员关掉了"允许新设备接入"时,
|
||
# 只放行**已经登记过**的设备。
|
||
#
|
||
# 注意判断依据是 deviceId(装机时生成、重装也不变),
|
||
# **不是配对码** —— 否则管理员给老设备改了码,
|
||
# 那台设备就被自己的开关挡在外面了。
|
||
# ⚠ 判断"老设备"必须查 known_devices(设备历史),
|
||
# **不能查 self.devices** —— devices 里超过 6 小时没上报的
|
||
# 会被 snapshot() 清掉(电脑关机过夜必然超时),
|
||
# 那时老设备会被自己的开关拒之门外,而且管理员在页面上
|
||
# 已经看不到它、不知道该放开谁,重启也救不回来。
|
||
known_device = device_id in self.known_devices
|
||
if not known_device and not self.allow_new:
|
||
log(f"新设备接入已被关闭,拒绝 {name} ({device_id[:12]}…)")
|
||
return {"ok": False,
|
||
"blocked": True,
|
||
"error": "中转站当前不接受新设备接入"}
|
||
|
||
known = self.codes.get(code)
|
||
if known and known != device_id:
|
||
# codeTaken 让客户端能精确识别这种冲突,
|
||
# 不用去匹配中文错误文案(文案一改就坏)。
|
||
log(f"配对码 {code} 已被 {known} 占用,拒绝 {device_id}")
|
||
return {"ok": False,
|
||
"codeTaken": True,
|
||
"error": "该配对码已被使用,请换一个配对码"}
|
||
|
||
# 这台设备以前配过码吗?
|
||
old_code = None
|
||
for existing_code, existing_device in self.codes.items():
|
||
if existing_device == device_id:
|
||
old_code = existing_code
|
||
break
|
||
|
||
if old_code and old_code != code:
|
||
log(f"设备 {name} 报了新码 {code},按单设备单码制改回 {old_code}")
|
||
code = old_code
|
||
|
||
self.codes[code] = device_id
|
||
device = self.devices.get(device_id)
|
||
if device is None:
|
||
device = Device(device_id, code, name, version)
|
||
self.devices[device_id] = device
|
||
else:
|
||
device.code = code
|
||
device.name = name
|
||
device.version = version
|
||
device.token = secrets.token_hex(24)
|
||
device.last_seen = time.time()
|
||
# 记进设备历史名单(这是"曾经被允许过"的凭证,只增不减;
|
||
# 只有管理员显式删除设备时才会被移除)
|
||
self.known_devices.add(device_id)
|
||
# 每次登记都刷新局域网信息(IP 可能变,端口也可能改)
|
||
device.lan_ips = lan_ips
|
||
device.port = listen_port
|
||
self._save()
|
||
log(f"被控端上线:{name} 码 {code} ({device_id[:12]}…)")
|
||
|
||
note = ""
|
||
if old_code and old_code != str(body.get("code") or "").strip():
|
||
note = (f"这台设备之前设过配对码 {old_code},"
|
||
f"已按「单设备单配对码」沿用它")
|
||
return {"ok": True, "token": device.token, "code": code,
|
||
"note": note, "pollSeconds": POLL_SECONDS}
|
||
|
||
# ---------------- 管理操作 ----------------
|
||
|
||
def set_allow_new(self, allow: bool) -> dict:
|
||
"""开关"允许新设备接入"。"""
|
||
with self.lock:
|
||
self.allow_new = bool(allow)
|
||
self._save()
|
||
log(f"管理员把「允许新设备接入」设为 {self.allow_new}")
|
||
return {"ok": True, "allowNew": self.allow_new}
|
||
|
||
def set_device_code(self, device_id: str, new_code: str) -> dict:
|
||
"""给一台设备换配对码。
|
||
|
||
要点:
|
||
* 新码必须是 6 位数字
|
||
* 不能已经被**别的**设备占用
|
||
* 旧码的映射必须删掉,否则会变成孤儿条目,
|
||
管理页上"配对码占用"的数字会虚高
|
||
* 该设备挂着的会话要踢掉 —— 会话是绑在码上的,
|
||
不踢的话手表还拿着旧会话以为连着
|
||
"""
|
||
device_id = str(device_id or "").strip()
|
||
new_code = str(new_code or "").strip()
|
||
if not CODE_RE.match(new_code):
|
||
return {"ok": False, "error": "配对码必须是 6 位数字"}
|
||
with self.lock:
|
||
device = self.devices.get(device_id)
|
||
if device is None:
|
||
return {"ok": False, "error": "没有这台设备(可能已下线)"}
|
||
|
||
owner = self.codes.get(new_code)
|
||
if owner and owner != device_id:
|
||
return {"ok": False,
|
||
"error": "这个配对码已经被别的设备占用了"}
|
||
|
||
old_code = device.code
|
||
if old_code == new_code:
|
||
return {"ok": True, "code": new_code, "unchanged": True}
|
||
|
||
# 删掉旧码映射(连同可能存在的别名)
|
||
for code, owner_id in list(self.codes.items()):
|
||
if owner_id == device_id:
|
||
self.codes.pop(code, None)
|
||
self.codes[new_code] = device_id
|
||
device.code = new_code
|
||
|
||
# 会话绑在码上,旧会话作废,让手表重新配对。
|
||
#
|
||
# ⚠ 会话是以 token 为 key 存在 self.sessions 里的,
|
||
# Session 用了 __slots__,**根本没有 session_id 属性**。
|
||
# 必须遍历 items() 拿 key;写 s.session_id 会 AttributeError,
|
||
# 而且会被上层兜底成"服务器内部错误"(500),很难定位。
|
||
kicked = 0
|
||
for sid in [t for t, s in self.sessions.items()
|
||
if s.device_id == device_id]:
|
||
self.sessions.pop(sid, None)
|
||
kicked += 1
|
||
|
||
self._save()
|
||
log(f"管理员把设备 {device.name} 的配对码 "
|
||
f"{old_code} 改成 {new_code}(踢掉 {kicked} 个会话)")
|
||
return {"ok": True, "code": new_code,
|
||
"oldCode": old_code, "kicked": kicked}
|
||
|
||
def device_by_token(self, token: str) -> Device | None:
|
||
if not token:
|
||
return None
|
||
with self.lock:
|
||
for device in self.devices.values():
|
||
if secrets.compare_digest(device.token, token):
|
||
return device
|
||
return None
|
||
|
||
# ---------------- 控制端 ----------------
|
||
|
||
def attach(self, body: dict, ip: str) -> dict:
|
||
code = str(body.get("code") or "").strip()
|
||
name = str(body.get("name") or "").strip()[:40] or "手表"
|
||
|
||
if not CODE_RE.match(code):
|
||
return {"ok": False, "error": "配对码要是 6 位数字"}
|
||
|
||
with self.lock:
|
||
now = time.time()
|
||
hits = [t for t in self.attach_log.get(ip, [])
|
||
if now - t < ATTACH_WINDOW]
|
||
if len(hits) >= ATTACH_LIMIT:
|
||
wait = int(ATTACH_WINDOW - (now - hits[0]))
|
||
log(f"配对尝试过多:{ip}")
|
||
return {"ok": False,
|
||
"error": f"尝试次数过多,请等 {wait} 秒"}
|
||
hits.append(now)
|
||
self.attach_log[ip] = hits
|
||
|
||
device_id = self.codes.get(code)
|
||
if not device_id:
|
||
return {"ok": False, "error": "没有设备在用这个配对码",
|
||
"needPair": True}
|
||
device = self.devices.get(device_id)
|
||
if device is None or not device.online():
|
||
return {"ok": False, "error": "设备不在线", "offline": True,
|
||
"deviceName": device.name if device else ""}
|
||
|
||
# 一台设备同时只留一个手表会话。
|
||
# (之前留 3 个,但被控端只有一条到中转的流,分不清哪段字节
|
||
# 属于哪个手表 —— 所以这里改成新的来了就踢旧的。)
|
||
for stale_token, stale in list(self.sessions.items()):
|
||
if stale.device_id == device_id:
|
||
self.sessions.pop(stale_token, None)
|
||
self.retired[stale_token] = "replaced"
|
||
stale.replaced = True
|
||
# 立刻叫醒它挂着的长轮询 —— 否则它会一直等下去,
|
||
# 等新数据来时把本该给新手表的数据抢走。
|
||
stale.event.set()
|
||
log(f"踢掉旧会话 {stale_token[:8]}…(同一设备只允许一个)")
|
||
# 清掉太老的顶替记录,别让字典涨
|
||
if len(self.retired) > 200:
|
||
for old_token in list(self.retired)[:100]:
|
||
self.retired.pop(old_token, None)
|
||
|
||
session = Session(device_id, code, name, ip)
|
||
self.sessions[session.token] = session
|
||
device.current_session = session.token
|
||
device.active_session = session
|
||
# 换会话了:把上一条留在队列里的半截数据清掉,
|
||
# 不然新手表会收到上一个手表没取走的残留字节。
|
||
device.to_client.clear()
|
||
log(f"手表接入:{name} ({ip}) -> {device.name}")
|
||
return {"ok": True, "session": session.token,
|
||
"deviceName": device.name, "online": True,
|
||
# 手表拿这两个判断"是不是和电脑在同一个网络",
|
||
# 是的话就切成局域网直连(快得多)。
|
||
"lanIps": list(device.lan_ips),
|
||
"port": device.port,
|
||
"pollSeconds": POLL_SECONDS}
|
||
|
||
def session_by_token(self, token: str) -> Session | None:
|
||
if not token:
|
||
return None
|
||
with self.lock:
|
||
session = self.sessions.get(token)
|
||
if session is None:
|
||
return None
|
||
session.last_seen = time.time()
|
||
return session
|
||
|
||
def session_replaced(self, token: str) -> bool:
|
||
"""这个令牌是不是「被新连接顶掉」的(而不是过期/不存在)。"""
|
||
if not token:
|
||
return False
|
||
with self.lock:
|
||
return self.retired.get(token) == "replaced"
|
||
|
||
# ---------------- 收发 ----------------
|
||
|
||
def agent_take(self, device: Device, wait: float) -> bytes:
|
||
"""被控端取走「手表发来的」数据;没有就挂着等。"""
|
||
return self._take(device, device.to_agent, wait, "agent")
|
||
|
||
def agent_put(self, device: Device, data: bytes) -> None:
|
||
"""被控端把「要发给手表」的数据放进来。"""
|
||
device.push_to_client(data)
|
||
|
||
def client_take(self, session: Session, wait: float) -> bytes:
|
||
"""手表取走「被控端发来的」数据。
|
||
|
||
注意这里**不能**复用 _take:那个是按设备的事件等的,
|
||
会把被顶掉的旧会话和新会话混在一起等,导致数据被旧会话截胡。
|
||
每个会话等自己的 event,并且每轮先看自己是不是已经被顶替了。
|
||
"""
|
||
deadline = time.time() + wait
|
||
while True:
|
||
if session.replaced:
|
||
raise SessionReplaced()
|
||
with self.lock:
|
||
device = self.devices.get(session.device_id)
|
||
if device is None:
|
||
return b""
|
||
if device.to_client:
|
||
data = bytes(device.to_client)
|
||
device.to_client.clear()
|
||
device.event.clear()
|
||
session.event.clear()
|
||
return data
|
||
remaining = deadline - time.time()
|
||
if remaining <= 0:
|
||
return b""
|
||
session.event.wait(min(remaining, 0.5))
|
||
session.event.clear()
|
||
|
||
def client_put(self, session: Session, data: bytes) -> None:
|
||
"""手表把「要发给被控端」的数据放进来。"""
|
||
with self.lock:
|
||
device = self.devices.get(session.device_id)
|
||
if device is not None:
|
||
device.push_to_agent(data)
|
||
|
||
def _take(self, device: Device, buffer: bytearray, wait: float,
|
||
who: str) -> bytes:
|
||
"""从队列里取数据。空了就等,最多 wait 秒。
|
||
|
||
用 device.event 做唤醒:另一端 push 时会 set(),这里立刻醒来,
|
||
不用轮询 —— 省 CPU,这台机器只有 4 个核还要跑 4G 模块。
|
||
"""
|
||
deadline = time.time() + wait
|
||
while True:
|
||
if who == "agent":
|
||
device.last_seen = time.time()
|
||
with self.lock:
|
||
if buffer:
|
||
data = bytes(buffer)
|
||
buffer.clear()
|
||
device.event.clear()
|
||
return data
|
||
remaining = deadline - time.time()
|
||
if remaining <= 0:
|
||
if who == "agent":
|
||
device.last_seen = time.time()
|
||
return b""
|
||
device.event.wait(min(remaining, 1.0))
|
||
device.event.clear()
|
||
|
||
# ---------------- 统计 / 管理 ----------------
|
||
|
||
def snapshot(self) -> dict:
|
||
with self.lock:
|
||
now = time.time()
|
||
|
||
# 先把长时间没动静的设备清掉。
|
||
#
|
||
# 为什么必须清:这个 dict 只增不减。被控端每次启动/换端口/
|
||
# 重装都会用新的 deviceId 注册一次,老的永远留着 ——
|
||
# 实测跑一天就攒了 75 条僵尸记录,/v1/health 从几 KB
|
||
# 涨到 20KB,手表和网页每次都要白下载一大坨没用的东西。
|
||
# 设备超过 6 小时没上报就当它下线了(真回来会重新注册)。
|
||
stale = [did for did, dev in self.devices.items()
|
||
if now - getattr(dev, "last_seen", now) > DEVICE_TTL]
|
||
for did in stale:
|
||
self.devices.pop(did, None)
|
||
# 它的会话也一起收掉,免得挂着一堆死会话。
|
||
# ⚠ 同样要用 items() 拿 token 当 key ——
|
||
# Session 没有 session_id 属性(见 set_device_code 里的说明)。
|
||
# 这段以前写的是 s.session_id,只是因为平时 stale 是空的
|
||
# 才一直没炸;一旦真有设备超时就会 500。
|
||
for sid in [t for t, s in self.sessions.items()
|
||
if s.device_id == did]:
|
||
self.sessions.pop(sid, None)
|
||
|
||
# 配对码映射也要一起清。
|
||
# 它和 devices 是一对孪生 dict,只清一个的话,
|
||
# codes 里会攒下一堆指向已删设备的孤儿条目 ——
|
||
# 实测管理页显示"配对码占用 131",实际一台设备都没有。
|
||
# 指向已不存在设备的码 = 废码,直接扔。
|
||
orphan = [code for code, owner in self.codes.items()
|
||
if owner not in self.devices]
|
||
for code in orphan:
|
||
self.codes.pop(code, None)
|
||
# ⚠ 清完孤儿码要落盘。不然内存里没了、磁盘上还在,
|
||
# 重启后 _load() 又恢复回来,下一次 snapshot() 再清一遍 ——
|
||
# 看上去能用,但每次重启都会"回滚"一次状态,很难排查。
|
||
# (超时清理 devices 也有同样问题,一并落盘。)
|
||
if stale or orphan:
|
||
self._save()
|
||
|
||
devices = []
|
||
for device in self.devices.values():
|
||
sessions = [s for s in self.sessions.values()
|
||
if s.device_id == device.device_id]
|
||
devices.append({
|
||
"deviceId": device.device_id,
|
||
"code": device.code,
|
||
"name": device.name,
|
||
# 手表用这两个判断能不能走局域网直连
|
||
"lanIps": list(device.lan_ips),
|
||
"port": device.port,
|
||
"version": device.version,
|
||
"online": device.online(),
|
||
"idleSeconds": int(now - device.last_seen),
|
||
"sessions": len(sessions),
|
||
"queuedToClient": len(device.to_client),
|
||
"queuedToAgent": len(device.to_agent),
|
||
"bytesIn": device.bytes_in,
|
||
"bytesOut": device.bytes_out,
|
||
})
|
||
devices.sort(key=lambda d: d["name"])
|
||
return {
|
||
"ok": True,
|
||
"version": VERSION,
|
||
"uptimeSeconds": int(now - self.started),
|
||
"devices": devices,
|
||
"sessions": len(self.sessions),
|
||
"totalRequests": self.total_requests,
|
||
"memoryKB": _rss_kb(),
|
||
# 管理页上的"允许新设备接入"开关就显示这个值
|
||
"allowNew": bool(self.allow_new),
|
||
"codeCount": len(self.codes),
|
||
}
|
||
|
||
def kick(self, device_id: str) -> int:
|
||
with self.lock:
|
||
gone = [t for t, s in self.sessions.items()
|
||
if s.device_id == device_id]
|
||
for token in gone:
|
||
self.sessions.pop(token, None)
|
||
device = self.devices.get(device_id)
|
||
if device is not None:
|
||
device.to_agent.clear()
|
||
device.to_client.clear()
|
||
device.last_seen = 0 # 标成掉线,逼它重连
|
||
return len(gone)
|
||
|
||
def delete_device(self, *, device_id: str = "", code: str = "") -> dict:
|
||
"""删掉一台设备,**连同它的配对码绑定和所有会话**。
|
||
|
||
这就是管理页上「删除设备」按钮做的事。
|
||
和 kick/forget 的区别:
|
||
kick 只踢会话,设备还在,码还占着
|
||
forget 只抹码绑定,设备还挂在列表上
|
||
delete 三样一起清 —— 那台设备下次可以重新设任意配对码
|
||
|
||
返回做了什么,便于界面显示。
|
||
"""
|
||
target = None
|
||
with self.lock:
|
||
if device_id:
|
||
target = self.devices.get(device_id)
|
||
if target is None and code:
|
||
owner = self.codes.get(code)
|
||
if owner:
|
||
target = self.devices.get(owner)
|
||
device_id = owner
|
||
if target is None:
|
||
return {"ok": False, "error": "找不到这台设备"}
|
||
|
||
removed_sessions = self.kick(target.device_id)
|
||
|
||
with self.lock:
|
||
self.devices.pop(target.device_id, None)
|
||
# "删除设备"是**真正忘记这台设备** —— 连历史名单一起删。
|
||
# 不删的话,管理员点了删除、开关又关着,
|
||
# 那台设备下次来仍然被当成老设备放行,开关就形同虚设。
|
||
self.known_devices.discard(target.device_id)
|
||
# 把它的码绑定一起删掉
|
||
gone_codes = [c for c, owner in list(self.codes.items())
|
||
if owner == target.device_id]
|
||
for item in gone_codes:
|
||
self.codes.pop(item, None)
|
||
# 清掉可能残留的会话记录
|
||
for token in list(getattr(self, "sessions", {}).keys()):
|
||
self.sessions.pop(token, None)
|
||
self._save()
|
||
|
||
log(f"管理操作:删除设备 {target.name}(码 {target.code}),"
|
||
f"踢掉 {removed_sessions} 个会话,清掉 {len(gone_codes)} 条码绑定")
|
||
return {
|
||
"ok": True,
|
||
"deleted": target.device_id,
|
||
"name": target.name,
|
||
"code": target.code,
|
||
"kicked": removed_sessions,
|
||
"codesCleared": gone_codes,
|
||
}
|
||
|
||
def forget(self, code: str) -> bool:
|
||
"""抹掉一个配对码绑定(让那台设备下次可以设新码)。
|
||
|
||
注意:**只抹码,不抹设备历史**。
|
||
抹码的语义是"这个码我不占了",不是"我不认识这台设备了"。
|
||
"""
|
||
with self.lock:
|
||
device_id = self.codes.pop(code, None)
|
||
if not device_id:
|
||
return False
|
||
device = self.devices.get(device_id)
|
||
if device is not None:
|
||
self.devices.pop(device_id, None)
|
||
self._save()
|
||
return True
|
||
|
||
|
||
def _rss_kb() -> int:
|
||
"""自己占了多少内存(KB)。省内存的项目得能看见自己吃多少。"""
|
||
try:
|
||
with open("/proc/self/status", encoding="utf-8") as handle:
|
||
for line in handle:
|
||
if line.startswith("VmRSS:"):
|
||
return int(line.split()[1])
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return 0
|
||
|
||
|
||
RELAY = Relay()
|
||
|
||
|
||
# ======================================================================
|
||
# HTTP 层
|
||
# ======================================================================
|
||
|
||
def _query(url: str, name: str) -> str:
|
||
"""从 URL 里取一个查询参数(不引 urllib,省内存)。"""
|
||
if "?" not in url:
|
||
return ""
|
||
try:
|
||
from urllib.parse import parse_qs
|
||
values = parse_qs(url.split("?", 1)[1]).get(name) or []
|
||
return values[0] if values else ""
|
||
except Exception: # noqa: BLE001
|
||
return ""
|
||
|
||
|
||
def _site_page() -> str:
|
||
"""官网首页(公开,不用登录)。
|
||
|
||
实际渲染在 relay/site.py —— relay.py 已经两千多行,
|
||
再放几百行 HTML 就没法看了。网站属于展示层,
|
||
跟中转逻辑分开更清楚。
|
||
"""
|
||
# 注意别把模块叫 site.py —— 那是 Python 标准库的名字,
|
||
# import site 会拿到标准库那个,页面直接崩。
|
||
# (这个坑是本地渲染测试抓出来的,部署前就修了。)
|
||
from landing import render_site
|
||
return render_site(_list_downloads())
|
||
|
||
def _login_page(message: str = "") -> bytes:
|
||
"""管理页登录页(输密码,不再是 URL 里带钥匙)。"""
|
||
notice = ('<p class="bad">' + message + '</p>') if message else ''
|
||
html = """<!doctype html><html lang=zh><meta charset=utf-8>
|
||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||
<link rel="apple-touch-icon" href="/favicon.ico">
|
||
<title>中转服务器 · 登录</title>
|
||
<style>
|
||
body{font:14px/1.6 -apple-system,'Microsoft YaHei',sans-serif;
|
||
background:#0f1117;color:#1f2328;display:flex;align-items:center;
|
||
justify-content:center;height:100vh;margin:0}
|
||
.box{background:#ffffff;border:1px solid #d0d7de;border-radius:12px;
|
||
padding:28px 32px;width:320px}
|
||
h1{font-size:17px;margin:0 0 4px}
|
||
.sub{color:#656d76;font-size:12px;margin-bottom:18px}
|
||
input{width:100%;box-sizing:border-box;background:#ffffff;color:#1f2328;
|
||
border:1px solid #d0d7de;border-radius:7px;padding:9px 11px;font-size:15px}
|
||
input:focus{outline:none;border-color:#1f6feb}
|
||
button{width:100%;margin-top:14px;background:#1f6feb;color:#fff;border:0;
|
||
border-radius:7px;padding:10px;font-size:14px;cursor:pointer}
|
||
button:hover{background:#388bfd}
|
||
.bad{color:#f85149;font-size:12px;margin:10px 0 0}
|
||
.foot{color:#8b949e;font-size:11px;margin-top:16px;text-align:center}
|
||
</style>
|
||
<div class=box>
|
||
<h1>中转服务器</h1>
|
||
<div class=sub>局域网遥控 · 管理后台</div>
|
||
<form method=post action=/admin/login><input name=username placeholder=账号 autocomplete=username required>
|
||
<input type=password name=password placeholder=管理密码 autofocus
|
||
autocomplete=current-password>
|
||
<button type=submit>登 录</button>
|
||
</form>
|
||
""" + notice + """
|
||
<div class=foot>只显示设备与流量,看不到任何传输内容</div>
|
||
</div></html>"""
|
||
return html.encode("utf-8")
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
protocol_version = "HTTP/1.1"
|
||
server_version = f"LanRemoteRelay/{VERSION}"
|
||
|
||
# 关掉逐条访问日志 —— 4G 机器上写日志很费
|
||
def log_message(self, fmt, *args): # noqa: A003
|
||
pass
|
||
|
||
# ---------------- 小工具 ----------------
|
||
|
||
def _send(self, code: int, body: bytes = b"",
|
||
content_type: str = "application/json",
|
||
extra: dict | None = None) -> None:
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
# ⚠ 默认 no-store,但**如果调用方在 extra 里给了 Cache-Control,
|
||
# 就不要再发默认的了** —— 否则会有两个 Cache-Control 头,
|
||
# 客户端/Cloudflare 只看第一个,调用方的设置就被吃掉了。
|
||
# (这个坑真踩了:首页明明传了 public,max-age=60,
|
||
# 响应里却还是 no-store,Cloudflare 一直 DYNAMIC 不缓存。)
|
||
extra = extra or {}
|
||
if not any(name.lower() == "cache-control" for name in extra):
|
||
self.send_header("Cache-Control", "no-store")
|
||
for name, value in extra.items():
|
||
self.send_header(name, value)
|
||
self.end_headers()
|
||
if body:
|
||
try:
|
||
self.wfile.write(body)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
|
||
def _json(self, code: int, payload: dict) -> None:
|
||
self._send(code, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
||
|
||
def _body(self) -> bytes:
|
||
"""读请求体。支持 Content-Length 和 chunked 两种。
|
||
|
||
为什么必须支持 chunked:
|
||
Cloudflare 转发时如果拿不到原始 Content-Length,
|
||
就会用 Transfer-Encoding: chunked。
|
||
而 Python 的 BaseHTTPRequestHandler **不会**自动解码 chunked。
|
||
不读走的话,残留的 "0\r\n\r\n" 会被当成下一个请求行,
|
||
报 "Bad request syntax ('0')",整条连接错位 ——
|
||
表现就是隧道那边不停 502/530,可用率上不去。
|
||
"""
|
||
transfer = (self.headers.get("Transfer-Encoding") or "").lower()
|
||
if "chunked" in transfer:
|
||
return self._body_chunked()
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
return b""
|
||
if length <= 0:
|
||
return b""
|
||
if length > MAX_BODY:
|
||
# 超限也要读掉,否则残留数据同样会污染下一条请求
|
||
self._drain(length)
|
||
return b""
|
||
return self._read_exact(length)
|
||
|
||
def _read_exact(self, length: int) -> bytes:
|
||
"""保证读满 length 字节(rfile.read 在慢连接上可能读不满)。"""
|
||
chunks = []
|
||
remaining = length
|
||
while remaining > 0:
|
||
piece = self.rfile.read(remaining)
|
||
if not piece:
|
||
break
|
||
chunks.append(piece)
|
||
remaining -= len(piece)
|
||
return b"".join(chunks)
|
||
|
||
def _drain(self, length: int) -> None:
|
||
"""把多余的请求体读掉丢弃,避免污染下一个请求。"""
|
||
remaining = min(length, MAX_BODY * 2)
|
||
while remaining > 0:
|
||
piece = self.rfile.read(min(remaining, 65536))
|
||
if not piece:
|
||
break
|
||
remaining -= len(piece)
|
||
|
||
def _body_chunked(self) -> bytes:
|
||
"""按 RFC 7230 解码 chunked 请求体。
|
||
|
||
注意最后那个 0 长度的 chunk 后面还有一段 trailer(通常为空),
|
||
一定要读掉 —— 残留就是 "Bad request syntax ('0')" 的来源。
|
||
"""
|
||
data = bytearray()
|
||
while True:
|
||
line = self.rfile.readline(256)
|
||
if not line:
|
||
break
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
size_text = line.split(b";")[0].strip()
|
||
try:
|
||
size = int(size_text, 16)
|
||
except ValueError:
|
||
break
|
||
if size <= 0:
|
||
# 末尾 trailer:读到空行为止
|
||
while True:
|
||
trailer = self.rfile.readline(256)
|
||
if not trailer or trailer.strip() == b"":
|
||
break
|
||
break
|
||
if len(data) + size > MAX_BODY:
|
||
self._drain(size + 2)
|
||
break
|
||
data += self._read_exact(size)
|
||
self.rfile.readline(8) # 每个 chunk 结尾的 CRLF
|
||
return bytes(data)
|
||
|
||
def _json_body(self) -> dict:
|
||
raw = self._body()
|
||
if not raw:
|
||
return {}
|
||
try:
|
||
value = json.loads(raw.decode("utf-8", "replace"))
|
||
return value if isinstance(value, dict) else {}
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
def _redirect(self, location: str) -> None:
|
||
self.send_response(302)
|
||
self.send_header("Location", location)
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
|
||
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:
|
||
# 走 Cloudflare,真实 IP 在 CF-Connecting-IP 里
|
||
return (self.headers.get("CF-Connecting-IP")
|
||
or self.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||
or self.client_address[0])
|
||
|
||
# ---------------- 路由 ----------------
|
||
|
||
def do_HEAD(self): # noqa: N802
|
||
"""HEAD 请求只回头,不回体。
|
||
|
||
之前没实现,HEAD 一律 501 Unsupported method。
|
||
浏览器用 GET 所以页面照开,但:
|
||
* Cloudflare 的健康检查、很多监控/代理探测会用 HEAD
|
||
* curl -I 也是 HEAD,排查时看不出响应头很别扭
|
||
|
||
⚠ 第一版实现有个 bug:它把响应体整个算出来再取长度。
|
||
对下载路由(15 MB 的安装包)等于要 read 一遍整个文件 ——
|
||
既浪费内存又慢。更糟的是当时**没给 /download/ 写分支**,
|
||
于是 HEAD 一个 APK 会返回 `Content-Length: 0` + `text/plain`,
|
||
下载器(IDM、迅雷、部分浏览器)据此会认为文件是空的。
|
||
|
||
现在改成只算**元数据**(状态码 / 类型 / 长度 / 附加头),
|
||
一个字节都不读。
|
||
"""
|
||
RELAY.total_requests += 1
|
||
path = self.path.split("?")[0]
|
||
try:
|
||
info = self._head_info(path)
|
||
except Exception: # noqa: BLE001
|
||
info = {"status": 500, "ctype": "text/plain; charset=utf-8",
|
||
"length": 0, "extra": {}}
|
||
|
||
self.send_response(info["status"])
|
||
self.send_header("Content-Type", info["ctype"])
|
||
self.send_header("Content-Length", str(info["length"]))
|
||
extra = info.get("extra") or {}
|
||
# ⚠ HEAD 的响应头必须和 GET 一致。
|
||
# 否则下载器/代理用 HEAD 探测时会拿到 no-store 和错误的长度,
|
||
# 进而误判文件不可缓存、甚至误判成空文件。
|
||
# (这个不一致坑过一次:我拿 curl -I 验证 GET 的缓存改动,
|
||
# 看到的全是 no-store,白查了半天 —— 因为 -I 发的是 HEAD。)
|
||
if not any(name.lower() == "cache-control" for name in extra):
|
||
self.send_header("Cache-Control", "no-store")
|
||
for name, value in extra.items():
|
||
self.send_header(name, value)
|
||
self.end_headers()
|
||
|
||
def _as_bytes(self, value) -> bytes:
|
||
"""把页面函数的结果统一转成 bytes。
|
||
|
||
为什么需要:`_site_page()` 返回 str,`_login_page()` 返回 bytes,
|
||
两个同类的函数返回类型不一致。直接对 bytes 调 .encode()
|
||
会 AttributeError,而且会被上层兜底悄悄吃掉,
|
||
表现为"页面类型莫名变成 text/plain",很难查。
|
||
这个函数把两种情况都兜住。
|
||
"""
|
||
if isinstance(value, bytes):
|
||
return value
|
||
if isinstance(value, str):
|
||
return value.encode("utf-8")
|
||
return b""
|
||
|
||
def _head_info(self, path: str) -> dict:
|
||
"""给 HEAD 用:算这个路径的响应元数据,不读任何大文件。
|
||
|
||
注意:**不能标 @staticmethod**。
|
||
登录页要走 self._login_page(),静态方法里没有 self,
|
||
一调用就抛异常,被 do_HEAD 的兜底吃掉,
|
||
结果管理页的 HEAD 报告成 text/plain(今天真踩了这次)。
|
||
"""
|
||
|
||
def ok(ctype: str, length: int, extra: dict | None = None) -> dict:
|
||
return {"status": 200, "ctype": ctype, "length": length,
|
||
"extra": extra or {}}
|
||
|
||
def no(status: int = 404) -> dict:
|
||
return {"status": status, "ctype": "text/plain; charset=utf-8",
|
||
"length": 0, "extra": {}}
|
||
|
||
# 各路由必须带上**和 GET 完全一样**的缓存头,否则用 HEAD 探测的
|
||
# 下载器/代理会拿到 no-store,误判成不可缓存。
|
||
CACHE_SITE = {"Cache-Control": "public, max-age=60"}
|
||
CACHE_ICON = {"Cache-Control": "public, max-age=86400"}
|
||
CACHE_SHOT = {"Cache-Control": "public, max-age=86400"}
|
||
CACHE_DL = {"Cache-Control": "public, max-age=604800, immutable"}
|
||
|
||
if path == "/":
|
||
body = self._as_bytes(_site_page())
|
||
return ok("text/html; charset=utf-8", len(body), CACHE_SITE)
|
||
if path in ("/v1/health", "/v1/downloads"):
|
||
return ok("application/json", 2)
|
||
if path in ("/url", "/v1/url"):
|
||
return ok("text/plain; charset=utf-8", len(public_url() or ""))
|
||
if path in ("/favicon.ico", "/shot/favicon.ico"):
|
||
icon = Path(__file__).resolve().parent / "favicon.ico"
|
||
if icon.is_file():
|
||
return ok("image/x-icon", icon.stat().st_size, CACHE_ICON)
|
||
return no()
|
||
if path == ADMIN_PATH or path == "/admin" or path == "/login":
|
||
# 注意:_login_page() 返回的**已经是 bytes**(它的签名是 -> bytes),
|
||
# 而 _site_page() 返回的是 str(要自己 encode)。
|
||
# 两个函数返回值类型不一致,这里踩过一次:
|
||
# 对着 bytes 调 .encode() 直接 AttributeError,
|
||
# 被 do_HEAD 的兜底吃掉,于是管理页 HEAD 报成 text/plain。
|
||
# 用 _as_bytes() 兜住,以后谁改了返回类型都不至于炸。
|
||
return ok("text/html; charset=utf-8",
|
||
len(self._as_bytes(_login_page())))
|
||
|
||
# ---- 产品截图 ----
|
||
if path.startswith("/shot/"):
|
||
name = path[len("/shot/"):]
|
||
if ".." in name or "/" in name or "\\" in name:
|
||
return no()
|
||
target = Path(__file__).resolve().parent / "site_shots" / name
|
||
if target.is_file() and name.lower().endswith(
|
||
(".png", ".jpg", ".jpeg", ".webp")):
|
||
ctype = ("image/png" if name.lower().endswith(".png")
|
||
else "image/jpeg")
|
||
return ok(ctype, target.stat().st_size, CACHE_SHOT)
|
||
return no()
|
||
|
||
# ---- 下载区 ----
|
||
# 这一块原来漏了,导致 HEAD 一个 APK 报 Content-Length: 0。
|
||
# 类型判断必须和 do_GET 里那份保持一致。
|
||
if path.startswith("/rescue/"):
|
||
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
|
||
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
|
||
# 同样要登录(见 do_GET 那段注释)。
|
||
# 同上:必须自己发响应,而且状态码要是 401 不是 404
|
||
# (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。
|
||
if not self._admin_gate():
|
||
return no(401)
|
||
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)
|
||
|
||
if path.startswith("/download/"):
|
||
wanted = urllib.parse.unquote(path[len("/download/"):])
|
||
safe = _safe_download_name(wanted)
|
||
if not safe:
|
||
return no()
|
||
target = DOWNLOADS_DIR / safe
|
||
if not target.is_file():
|
||
return no()
|
||
lower = safe.lower()
|
||
if lower.endswith(".apk"):
|
||
ctype = "application/vnd.android.package-archive"
|
||
elif lower.endswith(".zip"):
|
||
ctype = "application/zip"
|
||
elif lower.endswith(".txt"):
|
||
ctype = "text/plain; charset=utf-8"
|
||
else:
|
||
ctype = "application/octet-stream"
|
||
quoted = urllib.parse.quote(safe)
|
||
headers = dict(CACHE_DL)
|
||
headers["Content-Disposition"] = (
|
||
"attachment; filename*=UTF-8''%s" % quoted)
|
||
return ok(ctype, target.stat().st_size, headers)
|
||
|
||
return no()
|
||
|
||
def do_GET(self): # noqa: N802
|
||
RELAY.total_requests += 1
|
||
path = self.path.split("?")[0]
|
||
|
||
# 当前公网地址。故意不需要登录 ——
|
||
# 地址本来就要填到手表里,不是秘密;而且隧道换地址时
|
||
# 用户就是靠这个页面知道新地址的。
|
||
if path in ("/url", "/v1/url"):
|
||
url = public_url()
|
||
body = (url or "(还没有地址,隧道可能没起来)") + "\n"
|
||
self._send(200, body.encode("utf-8"),
|
||
"text/plain; charset=utf-8")
|
||
return
|
||
|
||
# 只读查询:这个配对码有没有被别的电脑占?
|
||
# 安装时要"先检查再决定要不要改防火墙",而检查不能真去注册
|
||
# (一注册就等于自己占了它)。所以单独开一个只读接口。
|
||
if path in ("/v1/code/check", "/v1/check"):
|
||
query = urllib.parse.urlparse(self.path).query
|
||
wanted = (urllib.parse.parse_qs(query).get("code") or [""])[0]
|
||
wanted = wanted.strip()
|
||
if not CODE_RE.match(wanted):
|
||
self._json(400, {"ok": False, "error": "配对码要是 6 位数字"})
|
||
return
|
||
with RELAY.lock:
|
||
owner = RELAY.codes.get(wanted)
|
||
device = RELAY.devices.get(owner) if owner else None
|
||
self._json(200, {
|
||
"ok": True,
|
||
"code": wanted,
|
||
"taken": bool(owner),
|
||
"deviceName": device.name if device else "",
|
||
"online": bool(device and device.online()),
|
||
})
|
||
return
|
||
|
||
# ---- 网站图标 ----
|
||
if path in ("/favicon.ico", "/apple-touch-icon.png"):
|
||
icon = Path(__file__).resolve().parent / "favicon.ico"
|
||
if icon.is_file():
|
||
try:
|
||
blob = icon.read_bytes()
|
||
except OSError:
|
||
blob = b""
|
||
if blob:
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "image/x-icon")
|
||
self.send_header("Content-Length", str(len(blob)))
|
||
# 图标不常变,让浏览器缓存一天
|
||
self.send_header("Cache-Control", "public, max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(blob)
|
||
return
|
||
# 没有图标文件就 404,别让请求打到后面的逻辑
|
||
self.send_response(404)
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
return
|
||
|
||
# ---- 成品下载(挂在管理页上给人下 APK / 整包)----
|
||
if path == "/v1/downloads":
|
||
self._json(200, {"ok": True, "files": _list_downloads()})
|
||
return
|
||
|
||
# 官网用的产品截图。
|
||
# 单独一条路由(不放进下载区),只允许 png/jpg/webp。
|
||
if path.startswith("/shot/"):
|
||
name = path[len("/shot/"):]
|
||
if (not name or ".." in name or "/" in name or "\\" in name
|
||
or not name.lower().endswith((".png", ".jpg", ".jpeg",
|
||
".webp"))):
|
||
self._json(404, {"ok": False, "error": "没有这张图"})
|
||
return
|
||
target = Path(__file__).resolve().parent / "site_shots" / name
|
||
if not target.is_file():
|
||
self._json(404, {"ok": False, "error": "没有这张图"})
|
||
return
|
||
body = target.read_bytes()
|
||
ctype = ("image/png" if name.lower().endswith(".png")
|
||
else "image/jpeg")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "public, max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
return
|
||
|
||
if path.startswith("/rescue/"):
|
||
# 抢救报告下载。**单独一条路由,不发缓存头** ——
|
||
# 内容会变,而且不该让 Cloudflare 边缘留着。
|
||
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
|
||
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
|
||
#
|
||
# ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、
|
||
# MTU/路由/iptables —— 是运维信息,不是给所有人看的。
|
||
# 第一版把这条路由插在 _admin_gate() **之前**,
|
||
# 等于匿名可下,实测踩到。
|
||
# ⚠ _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/"):])
|
||
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
|
||
|
||
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
|
||
target = DOWNLOADS_DIR / safe
|
||
if not target.is_file():
|
||
self._json(404, {"ok": False, "error": "没有这个文件"})
|
||
return
|
||
try:
|
||
size = target.stat().st_size
|
||
except OSError as exc:
|
||
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
|
||
return
|
||
if safe.lower().endswith(".apk"):
|
||
content_type = "application/vnd.android.package-archive"
|
||
elif safe.lower().endswith(".zip"):
|
||
content_type = "application/zip"
|
||
elif safe.lower().endswith(".txt"):
|
||
content_type = "text/plain; charset=utf-8"
|
||
else:
|
||
content_type = "application/octet-stream"
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Content-Length", str(size))
|
||
# 文件名可能含中文,用 RFC 5987 编码
|
||
quoted = urllib.parse.quote(safe)
|
||
self.send_header(
|
||
"Content-Disposition",
|
||
f"attachment; filename*=UTF-8''{quoted}")
|
||
# ★ 让 Cloudflare 在边缘缓存这个文件。
|
||
# 原来这里是 no-store,于是每次下载都要穿隧道回 4G 源站 ——
|
||
# 实测吞吐只有 3~67 KB/s(15 MB 的安装包根本下不动)。
|
||
# 文件名带版本号(ykxmz-setup-1.8.2.exe),同一名字内容
|
||
# 永不改变,所以可以放心长缓存。
|
||
self.send_header("Cache-Control",
|
||
"public, max-age=604800, immutable")
|
||
self.end_headers()
|
||
|
||
# 分块流式发送,不要一次性 read_bytes 再 write ——
|
||
# 大文件(19MB)那样会先攒满内存、数据也迟迟不开始流动;
|
||
# 而 Cloudflare 有 100 秒的源站响应上限,越晚开始发越容易被掐。
|
||
sent = 0
|
||
try:
|
||
with target.open("rb") as stream:
|
||
while True:
|
||
chunk = stream.read(65536)
|
||
if not chunk:
|
||
break
|
||
self.wfile.write(chunk)
|
||
sent += len(chunk)
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
# 客户端/边缘主动断开(比如大文件超时),正常现象
|
||
log(f"下载中断:{safe}(已发 {sent}/{size} 字节)")
|
||
return
|
||
except OSError as exc:
|
||
log(f"下载出错:{safe}(已发 {sent}/{size} 字节){exc}")
|
||
return
|
||
log(f"下载:{safe}({sent} 字节)")
|
||
return
|
||
|
||
if path in ("/v1/health", "/health"):
|
||
snap = RELAY.snapshot()
|
||
snap["service"] = "lanremote-relay"
|
||
self._json(200, snap)
|
||
return
|
||
|
||
# ---- 退出登录 ----
|
||
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 == "/v1/admin/netcheck":
|
||
# 网络体检:中转和 cloudflared 在同一台机器上,
|
||
# 所以能直接读它的指标,不用用户去 SSH 敲命令。
|
||
if not self._admin_gate():
|
||
self._json(401, {"ok": False, "error": "请先登录"})
|
||
return
|
||
info = {"ok": True}
|
||
# --- cloudflared 的连接数等 ---
|
||
try:
|
||
request = urllib.request.Request(
|
||
"http://127.0.0.1:20241/metrics",
|
||
headers={"User-Agent": "LanRemoteRelay/1.0"})
|
||
with urllib.request.urlopen(request, timeout=4) as response:
|
||
text = response.read(65536).decode("utf-8", "replace")
|
||
wanted = {
|
||
"cloudflared_tunnel_ha_connections": "tunnelConnections",
|
||
"cloudflared_tunnel_total_requests": "tunnelRequests",
|
||
"cloudflared_tunnel_tcp_active_sessions":
|
||
"tunnelTcpSessions",
|
||
}
|
||
for line in text.splitlines():
|
||
if line.startswith("#"):
|
||
continue
|
||
parts = line.split()
|
||
if len(parts) < 2:
|
||
continue
|
||
key = wanted.get(parts[0])
|
||
if key:
|
||
try:
|
||
info[key] = int(float(parts[1]))
|
||
except ValueError:
|
||
pass
|
||
info["tunnelMetrics"] = True
|
||
except Exception as exc: # noqa: BLE001
|
||
info["tunnelMetrics"] = False
|
||
info["tunnelError"] = f"{type(exc).__name__}"
|
||
# --- 自己这边的概况 ---
|
||
snap = RELAY.snapshot()
|
||
info["devices"] = len(snap["devices"])
|
||
info["online"] = sum(1 for d in snap["devices"] if d["online"])
|
||
info["sessions"] = snap["sessions"]
|
||
info["memoryKB"] = snap["memoryKB"]
|
||
info["uptimeSeconds"] = snap["uptimeSeconds"]
|
||
info["publicUrl"] = public_url() or ""
|
||
# --- 每条设备是不是从局域网来的 ---
|
||
lan_devices = []
|
||
for device in snap["devices"]:
|
||
for ip in (device.get("lanIps") or []):
|
||
if ip:
|
||
lan_devices.append(ip)
|
||
break
|
||
info["lanDevices"] = len(lan_devices)
|
||
self._json(200, info)
|
||
return
|
||
|
||
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 == "/":
|
||
# 缓存 60 秒:挡住"刷新就重新回源"的浪费,
|
||
# 又不会让页面上的当前版本号和下载按钮过期太久。
|
||
self._send(200, _site_page().encode("utf-8"),
|
||
"text/html; charset=utf-8",
|
||
{"Cache-Control": "public, max-age=60"})
|
||
return
|
||
|
||
# ---- 管理后台 ----
|
||
# 挪到 /zhongzhuanzhan(用户要求)。
|
||
# /admin 保留成跳转,免得存过书签的人打不开。
|
||
if path == "/admin":
|
||
self._redirect(ADMIN_PATH)
|
||
return
|
||
|
||
if path == ADMIN_PATH:
|
||
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
|
||
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
|
||
|
||
self._json(404, {"ok": False, "error": "没有这个接口"})
|
||
|
||
def do_POST(self): # noqa: N802
|
||
RELAY.total_requests += 1
|
||
path = self.path.split("?")[0]
|
||
|
||
try:
|
||
self._route_post(path)
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"处理 {path} 出错:{exc}")
|
||
self._json(500, {"ok": False, "error": "服务器内部错误"})
|
||
|
||
def _route_post(self, path: str) -> None:
|
||
# ---- 被控端 ----
|
||
if path == "/v1/agent/register":
|
||
result = RELAY.register(self._json_body())
|
||
log(f"注册请求 -> ok={result.get('ok')}")
|
||
self._json(200 if result.get("ok") else 400, result)
|
||
return
|
||
|
||
if path == "/v1/agent/poll":
|
||
device = RELAY.device_by_token(self.headers.get("X-Token", ""))
|
||
if device is None:
|
||
self._json(401, {"ok": False, "error": "令牌无效"})
|
||
return
|
||
session_at_start = device.current_session
|
||
data = RELAY.agent_take(device, POLL_SECONDS)
|
||
self._send(200, data, "application/octet-stream",
|
||
extra={"X-Relay-Session": device.current_session or "-",
|
||
"X-Relay-Session-AtStart": session_at_start or "-"})
|
||
return
|
||
|
||
if path == "/v1/agent/send":
|
||
device = RELAY.device_by_token(self.headers.get("X-Token", ""))
|
||
if device is None:
|
||
self._json(401, {"ok": False, "error": "令牌无效"})
|
||
return
|
||
data = self._body()
|
||
RELAY.agent_put(device, data)
|
||
self._json(200, {"ok": True, "queued": len(data)})
|
||
return
|
||
|
||
if path == "/v1/agent/bye":
|
||
device = RELAY.device_by_token(self.headers.get("X-Token", ""))
|
||
if device is not None:
|
||
device.last_seen = 0
|
||
device.current_session = ""
|
||
log(f"被控端下线:{device.name}")
|
||
self._json(200, {"ok": True})
|
||
return
|
||
|
||
# ---- 控制端 ----
|
||
if path == "/v1/client/attach":
|
||
result = RELAY.attach(self._json_body(), self._client_ip())
|
||
self._json(200 if result.get("ok") else 403, result)
|
||
return
|
||
|
||
if path == "/v1/client/poll":
|
||
token = self.headers.get("X-Session", "")
|
||
session = RELAY.session_by_token(token)
|
||
if session is None:
|
||
if RELAY.session_replaced(token):
|
||
# 明确告诉它"你是被顶掉的",客户端就不会抢回来了
|
||
self._json(409, {"ok": False, "replaced": True,
|
||
"error": "已被另一台手表接管"})
|
||
return
|
||
self._json(401, {"ok": False, "error": "会话无效"})
|
||
return
|
||
try:
|
||
data = RELAY.client_take(session, POLL_SECONDS)
|
||
except SessionReplaced:
|
||
self._json(409, {"ok": False, "replaced": True,
|
||
"error": "已被另一台手表接管"})
|
||
return
|
||
self._send(200, data, "application/octet-stream")
|
||
return
|
||
|
||
if path == "/v1/client/send":
|
||
token = self.headers.get("X-Session", "")
|
||
session = RELAY.session_by_token(token)
|
||
if session is None:
|
||
if RELAY.session_replaced(token):
|
||
self._json(409, {"ok": False, "replaced": True,
|
||
"error": "已被另一台手表接管"})
|
||
return
|
||
self._json(401, {"ok": False, "error": "会话无效"})
|
||
return
|
||
data = self._body()
|
||
RELAY.client_put(session, data)
|
||
self._json(200, {"ok": True, "queued": len(data)})
|
||
return
|
||
|
||
# ---- 登录 ----
|
||
if path == "/admin/login":
|
||
form = self._body().decode("utf-8", "replace")
|
||
username = ""
|
||
password = ""
|
||
from urllib.parse import unquote_plus
|
||
for piece in form.split("&"):
|
||
name, _, value = piece.partition("=")
|
||
if name == "password":
|
||
password = unquote_plus(value)
|
||
elif name == "username":
|
||
username = unquote_plus(value)
|
||
token = admin_login(username, password)
|
||
if not token:
|
||
log("管理页登录失败(%s),来自 %s"
|
||
% (username or "未填账号", self._client_ip()))
|
||
self._redirect(ADMIN_PATH + "?bad=1")
|
||
return
|
||
log("管理页登录成功(%s),来自 %s"
|
||
% (username, self._client_ip()))
|
||
self.send_response(302)
|
||
self.send_header("Location", ADMIN_PATH)
|
||
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
|
||
|
||
if path == "/v1/admin/devices":
|
||
self._json(200, RELAY.snapshot())
|
||
return
|
||
|
||
if path == "/v1/admin/kick":
|
||
device_id = str(self._json_body().get("deviceId") or "")
|
||
if not device_id:
|
||
self._json(400, {"ok": False, "error": "要给 deviceId"})
|
||
return
|
||
gone = RELAY.kick(device_id)
|
||
log(f"管理操作:踢掉 {device_id[:12]}… 的 {gone} 个会话")
|
||
self._json(200, {"ok": True, "kicked": gone})
|
||
return
|
||
|
||
if path == "/v1/admin/delete_many":
|
||
# 批量删除:勾几台删几台。
|
||
# 和"删除全部"的区别是不动没勾的,也不清孤儿绑定。
|
||
body = self._json_body()
|
||
wanted = body.get("deviceIds") or []
|
||
if not isinstance(wanted, list):
|
||
self._json(400, {"ok": False,
|
||
"error": "deviceIds 要是数组"})
|
||
return
|
||
wanted = [str(item).strip() for item in wanted if str(item).strip()]
|
||
if not wanted:
|
||
self._json(400, {"ok": False, "error": "没有勾选任何设备"})
|
||
return
|
||
results = [RELAY.delete_device(device_id=item)
|
||
for item in wanted]
|
||
done = [r for r in results if r.get("ok")]
|
||
failed = [r for r in results if not r.get("ok")]
|
||
log(f"管理操作:批量删除 {len(done)}/{len(wanted)} 台设备")
|
||
self._json(200, {
|
||
"ok": True,
|
||
"requested": len(wanted),
|
||
"deleted": len(done),
|
||
"failed": len(failed),
|
||
"devices": done,
|
||
"errors": failed[:10],
|
||
})
|
||
return
|
||
|
||
if path in ("/v1/admin/delete", "/v1/admin/delete_all"):
|
||
body = self._json_body()
|
||
if path == "/v1/admin/delete_all":
|
||
# 一次删光(清场用)
|
||
with RELAY.lock:
|
||
all_ids = list(RELAY.devices.keys())
|
||
results = [RELAY.delete_device(device_id=item)
|
||
for item in all_ids]
|
||
done = [r for r in results if r.get("ok")]
|
||
# 还要清掉**孤儿绑定** —— 设备早就下线、但配对码
|
||
# 还占着的记录。不清的话,用户想用的码可能一直
|
||
# 被一台不存在的机器占着("我的码怎么被占了")。
|
||
with RELAY.lock:
|
||
orphans = list(RELAY.codes.items())
|
||
RELAY.codes.clear()
|
||
RELAY._save()
|
||
log(f"管理操作:清场 —— 删除 {len(done)} 台设备,"
|
||
f"清掉 {len(orphans)} 条配对码绑定(含孤儿)")
|
||
self._json(200, {"ok": True, "deleted": len(done),
|
||
"orphansCleared": len(orphans),
|
||
"devices": done})
|
||
return
|
||
|
||
device = str(body.get("deviceId") or "").strip()
|
||
code = str(body.get("code") or "").strip()
|
||
if not device and not code:
|
||
self._json(400, {"ok": False,
|
||
"error": "要给 deviceId 或 code"})
|
||
return
|
||
result = RELAY.delete_device(device_id=device, code=code)
|
||
self._json(200 if result.get("ok") else 404, result)
|
||
return
|
||
|
||
if path == "/v1/admin/forget":
|
||
code = str(self._json_body().get("code") or "").strip()
|
||
if not CODE_RE.match(code):
|
||
self._json(400, {"ok": False, "error": "配对码要是 6 位数字"})
|
||
return
|
||
done = RELAY.forget(code)
|
||
log(f"管理操作:抹掉配对码 {code} -> {done}")
|
||
self._json(200, {"ok": True, "forgotten": done})
|
||
return
|
||
|
||
# ---- 允许/禁止新设备接入 ----
|
||
if path == "/v1/admin/allow_new":
|
||
body = self._json_body()
|
||
allow = bool(body.get("allow", True))
|
||
result = RELAY.set_allow_new(allow)
|
||
self._json(200, result)
|
||
return
|
||
|
||
# ---- 改某台设备的配对码 ----
|
||
if path == "/v1/admin/set_code":
|
||
body = self._json_body()
|
||
result = RELAY.set_device_code(
|
||
str(body.get("deviceId") or ""),
|
||
str(body.get("code") or ""))
|
||
self._json(200 if result.get("ok") else 400, result)
|
||
return
|
||
|
||
self._json(404, {"ok": False, "error": "没有这个管理接口"})
|
||
return
|
||
|
||
if path == "/v1/client/detach":
|
||
session = RELAY.session_by_token(self.headers.get("X-Session", ""))
|
||
if session is not None:
|
||
relay_sessions = getattr(RELAY, "sessions", {})
|
||
relay_sessions.pop(session.token, None)
|
||
RELAY.retired.pop(session.token, None)
|
||
self._json(200, {"ok": True})
|
||
return
|
||
|
||
self._json(404, {"ok": False, "error": "没有这个接口"})
|
||
|
||
# ---------------- 管理页 ----------------
|
||
|
||
def _admin_page(self) -> None:
|
||
"""管理后台。
|
||
|
||
实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS
|
||
内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻,
|
||
没法维护。拆出去之后这里只负责把数据递过去。
|
||
|
||
⚠ Content-Type 必须显式写 text/html。
|
||
_send() 默认是 application/json;不传的话浏览器会把整页
|
||
HTML 当纯文本显示出来 —— 用户看到的是一屏源码。
|
||
(这个坑真踩了:改完只测了 HTTP 200 和字节数,
|
||
没看响应头,结果页面在浏览器里是白的。)
|
||
"""
|
||
from admin import render_admin
|
||
snap = RELAY.snapshot()
|
||
self._send(200, render_admin(
|
||
snap=snap,
|
||
public=public_url() or "",
|
||
admin_path=ADMIN_PATH,
|
||
rescue_fn=_list_rescue_reports,
|
||
codes=len(RELAY.codes),
|
||
downloads_fn=_list_downloads,
|
||
).encode("utf-8"), "text/html; charset=utf-8")
|
||
|
||
|
||
|
||
# ======================================================================
|
||
# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复)
|
||
# ======================================================================
|
||
|
||
def _esc(text) -> str:
|
||
return (str(text).replace("&", "&").replace("<", "<")
|
||
.replace(">", ">").replace('"', """))
|
||
|
||
def _kb(value: int) -> str:
|
||
if value < 1024:
|
||
return f"{value} B"
|
||
if value < 1024 * 1024:
|
||
return f"{value / 1024:.1f} KB"
|
||
return f"{value / 1024 / 1024:.1f} MB"
|
||
|
||
def _uptime(seconds: int) -> str:
|
||
days, rest = divmod(int(seconds), 86400)
|
||
hours, rest = divmod(rest, 3600)
|
||
minutes = rest // 60
|
||
if days:
|
||
return f"{days} 天 {hours} 小时"
|
||
if hours:
|
||
return f"{hours} 小时 {minutes} 分"
|
||
return f"{minutes} 分"
|
||
|
||
|
||
# ======================================================================
|
||
# 启动
|
||
# ======================================================================
|
||
|
||
class Server(ThreadingHTTPServer):
|
||
daemon_threads = True
|
||
allow_reuse_address = True
|
||
request_queue_size = 64
|
||
|
||
def main() -> int:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
server = Server((BIND, PORT), Handler)
|
||
except OSError as exc:
|
||
print(f"监听 {BIND}:{PORT} 失败:{exc}")
|
||
return 1
|
||
|
||
log(f"中转服务器 v{VERSION} 启动,监听 {BIND}:{PORT}")
|
||
log(f"长轮询 {POLL_SECONDS:.0f} 秒 · 队列上限 "
|
||
f"{MAX_QUEUE_BYTES // 1024} KB · 数据目录 {DATA_DIR}")
|
||
key = admin_key()
|
||
# 同样要**在这里**把密码也生成出来:
|
||
# admin_password() 原来只在有人访问登录页时才调用,
|
||
# 于是全新部署启动后 data/admin_password.txt 一直不存在,
|
||
# 日志里也看不到密码 —— 管理员不知道该用什么登录。
|
||
# 启动时就调一次,密码会随启动日志打印出来。
|
||
admin_password()
|
||
log(f"管理页:http://<本机>:{PORT}/?key={key}")
|
||
log(f"(钥匙也在 {DATA_DIR / 'admin_key.txt'})")
|
||
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
log("收到中断,退出")
|
||
finally:
|
||
server.server_close()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|