mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 16:00:43 +00:00
阶段3 管理页 - 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、 删除按钮 #2d1214 等 11 处),现在 0 处深色 - 接口全部正常:health / downloads / netcheck 阶段4 局域网诊断 - 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的** (lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一), 是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据 - 所以改了手表的提示:不再只说"不在同一网络", 而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi - 端到端验证局域网路径:认证 1 ms,功能列表 207 项 阶段1 结论修正(一天内推翻自己两次,如实记录) - 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP - 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了 —— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址 - 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余 - 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s - **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**, 比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身, 不是设备的隧道。公网侧已无优化空间。 - 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路 阶段5 验收 - 全量回归 29 项全通过 - 设备侧验收 38 项全通过 - 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
2040 lines
82 KiB
Python
2040 lines
82 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 = "1.7.1"
|
||
|
||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||
|
||
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 的连接,太短则空转请求多。
|
||
POLL_SECONDS = float(os.environ.get("RELAY_POLL", "25"))
|
||
|
||
# 单个设备最多缓多少字节没发出去。手表半天不取就丢最旧的,保住内存。
|
||
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
|
||
# 一个设备最多同时挂几个手表会话
|
||
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
|
||
|
||
|
||
# 管理页密码。用户指定的默认值,可以在 data/admin_password.txt 里改。
|
||
ADMIN_PASSWORD_DEFAULT = "@REVAedu120830"
|
||
# 管理页账号(原来只有密码,用户要求加上账号)
|
||
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"
|
||
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
|
||
|
||
|
||
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(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 = {}
|
||
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
|
||
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 = {"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 不合法"}
|
||
if not CODE_RE.match(code):
|
||
return {"ok": False, "error": "配对码必须是 6 位数字"}
|
||
|
||
with self.lock:
|
||
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()
|
||
# 每次登记都刷新局域网信息(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 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()
|
||
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(),
|
||
}
|
||
|
||
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)
|
||
# 把它的码绑定一起删掉
|
||
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)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
for name, value in (extra or {}).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_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
|
||
|
||
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}")
|
||
self.send_header("Cache-Control", "no-store")
|
||
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 == "/":
|
||
self._send(200, _site_page().encode("utf-8"),
|
||
"text/html; charset=utf-8")
|
||
return
|
||
|
||
# ---- 管理后台 ----
|
||
# 挪到 /zhongzhuanzhan(用户要求)。
|
||
# /admin 保留成跳转,免得存过书签的人打不开。
|
||
if path == "/admin":
|
||
self._redirect("/zhongzhuanzhan")
|
||
return
|
||
|
||
if path == "/zhongzhuanzhan":
|
||
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
|
||
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("/zhongzhuanzhan?bad=1")
|
||
return
|
||
log("管理页登录成功(%s),来自 %s"
|
||
% (username, self._client_ip()))
|
||
self.send_response(302)
|
||
self.send_header("Location", "/zhongzhuanzhan")
|
||
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
|
||
|
||
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:
|
||
snap = RELAY.snapshot()
|
||
rows = []
|
||
for device in snap["devices"]:
|
||
online = device["online"]
|
||
badge = ("<span class='badge on'>在线</span>" if online
|
||
else "<span class='badge off'>离线</span>")
|
||
rows.append(
|
||
"<tr>"
|
||
"<td class=pickcell>"
|
||
f'<input type=checkbox class=pick value="{_esc(device["deviceId"])}">'
|
||
"</td>"
|
||
# data-l 是给窄屏用的:表格变成卡片时,
|
||
# 每个值前面用 ::before 显示这个标签(见 CSS 的 @media)
|
||
f'<td class=name data-l="设备">'
|
||
f'<span class="dot {"on" if online else "off"}"></span>'
|
||
f'<span class=v>{_esc(device["name"])}</span>'
|
||
f'<div class=sub2>{_esc(device["deviceId"])}</div></td>'
|
||
f'<td data-l="配对码"><span class=val>'
|
||
f'<code>{_esc(device["code"])}</code></span></td>'
|
||
f'<td data-l="状态"><span class=val>{badge}</span></td>'
|
||
f'<td data-l="空闲"><span class=val>'
|
||
f'{device["idleSeconds"]}s</span></td>'
|
||
f'<td data-l="待发(表/机)"><span class=val>'
|
||
f'{device["queuedToClient"]} / '
|
||
f'{device["queuedToAgent"]}</span></td>'
|
||
f'<td class=num data-l="流量(入/出)"><span class=val>'
|
||
f'{_kb(device["bytesIn"])} / {_kb(device["bytesOut"])}'
|
||
f'</span></td>'
|
||
'<td class=ops><button class="btn del" '
|
||
f'data-device="{_esc(device["deviceId"])}" '
|
||
f'data-name="{_esc(device["name"])}" '
|
||
f'data-code="{_esc(device["code"])}">删除</button></td>'
|
||
"</tr>")
|
||
|
||
online_count = sum(1 for d in snap["devices"] if d["online"])
|
||
url_text = public_url() or ""
|
||
url_display = _esc(url_text) if url_text else "(还没有地址,隧道可能没起来)"
|
||
|
||
empty = ('<tr><td colspan=8 class=empty>'
|
||
'<div class=big>还没有电脑连上来</div>'
|
||
'<div>在被控端双击 ykxmz.exe 设好配对码,'
|
||
'手表上输那 6 位数字就能连上。</div></td></tr>')
|
||
|
||
html = f"""<!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">
|
||
<title>ykxmz 中转服务器</title>
|
||
<style>
|
||
*{{box-sizing:border-box}}
|
||
:root{{
|
||
--bg:#ffffff; --panel:#ffffff; --panel2:#f6f8fa; --border:#d0d7de;
|
||
--fg:#1f2328; --dim:#656d76; --muted:#8b949e;
|
||
--accent:#0969da; --ok:#1a7f37; --danger:#f85149; --warn:#9a6700;
|
||
}}
|
||
html,body{{max-width:100%;overflow-x:hidden}}
|
||
body{{margin:0;padding:0;background:var(--bg);color:var(--fg);
|
||
font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',
|
||
'Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased}}
|
||
.wrap{{width:100%;max-width:1120px;margin:0 auto;padding:22px 20px 40px}}
|
||
|
||
/* ---------- 头部 ---------- */
|
||
header{{display:flex;align-items:center;gap:12px;
|
||
padding-bottom:16px;border-bottom:1px solid var(--border);
|
||
margin-bottom:20px;flex-wrap:wrap}}
|
||
.logo{{width:38px;height:38px;border-radius:10px;flex:none;
|
||
background:linear-gradient(140deg,#0969da,#1f6feb);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-weight:700;font-size:15px;color:#fff;letter-spacing:-.5px}}
|
||
.brand h1{{margin:0;font-size:17px;font-weight:600;letter-spacing:.2px}}
|
||
.brand .sub{{color:var(--dim);font-size:12px}}
|
||
.meta{{margin-left:auto;text-align:right;color:var(--muted);font-size:12px;
|
||
line-height:1.5}}
|
||
.meta b{{color:var(--dim);font-weight:500}}
|
||
.meta a.logout{{color:var(--dim);text-decoration:none;
|
||
border-bottom:1px dashed #d0d7de;transition:.12s}}
|
||
.meta a.logout:hover{{color:var(--accent);border-bottom-color:var(--accent)}}
|
||
|
||
/* ---------- 通用卡片 ---------- */
|
||
.panel{{background:var(--panel);border:1px solid var(--border);
|
||
border-radius:12px;padding:16px 18px;margin-bottom:16px}}
|
||
.panel .label{{color:var(--dim);font-size:12px;margin-bottom:8px;
|
||
display:flex;align-items:center;gap:6px}}
|
||
|
||
/* ---------- 公网地址 ---------- */
|
||
.urlrow{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
|
||
.url{{flex:1;min-width:220px;font:600 15px/1.5 ui-monospace,Consolas,
|
||
'Courier New',monospace;color:#0969da;word-break:break-all;
|
||
background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;
|
||
padding:10px 12px}}
|
||
.url.empty{{color:var(--muted);font-weight:400;font-style:normal}}
|
||
|
||
/* ---------- 统计 ---------- */
|
||
.stats{{display:grid;gap:12px;margin-bottom:16px;
|
||
grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
|
||
.stat{{background:var(--panel);border:1px solid var(--border);
|
||
border-radius:12px;padding:14px 16px;position:relative;overflow:hidden}}
|
||
.stat .n{{font-size:26px;font-weight:650;line-height:1.15;
|
||
letter-spacing:-.5px}}
|
||
.stat .t{{color:var(--dim);font-size:12px;margin-top:2px}}
|
||
.stat::after{{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;
|
||
background:var(--accent);opacity:.65}}
|
||
.stat.ok::after{{background:var(--ok)}}
|
||
.stat.warn::after{{background:var(--warn)}}
|
||
.stat.mute::after{{background:#d0d7de}}
|
||
|
||
/* ---------- 工具条 ---------- */
|
||
.toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
|
||
margin-bottom:12px}}
|
||
.toolbar .spacer{{flex:1}}
|
||
.tip{{color:var(--muted);font-size:12px}}
|
||
|
||
/* ---------- 按钮 ---------- */
|
||
button{{font-family:inherit;font-size:13px;cursor:pointer;
|
||
border-radius:8px;padding:7px 14px;border:1px solid var(--border);
|
||
background:var(--panel2);color:var(--fg);transition:.12s}}
|
||
button:hover{{background:#f3f4f6;border-color:#d0d7de}}
|
||
button:active{{transform:translateY(1px)}}
|
||
button:disabled{{opacity:.4;cursor:not-allowed;transform:none}}
|
||
button.primary{{background:var(--accent);border-color:var(--accent);
|
||
color:#fff;font-weight:600}}
|
||
button.primary:hover{{background:#1d4ed8;border-color:#1d4ed8}}
|
||
button.del{{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}}
|
||
button.del:hover{{background:#ffe3e3;border-color:#a33;color:#a61b1b}}
|
||
button.del:disabled{{opacity:.4}}
|
||
button.small{{padding:5px 11px;font-size:12px}}
|
||
|
||
/* ---------- 表格 ---------- */
|
||
.tablewrap{{background:var(--panel);border:1px solid var(--border);
|
||
border-radius:12px;overflow:hidden;max-width:100%}}
|
||
table{{width:100%;max-width:100%;border-collapse:collapse;
|
||
table-layout:fixed}}
|
||
th,td{{padding:11px 14px;text-align:left;font-size:13px;
|
||
border-bottom:1px solid #eaeef2;vertical-align:middle;
|
||
overflow-wrap:anywhere;min-width:0}}
|
||
th{{background:var(--panel2);color:var(--dim);font-weight:500;
|
||
font-size:12px;white-space:nowrap}}
|
||
tbody tr{{transition:background .1s}}
|
||
tbody tr:hover td{{background:#f6f8fa}}
|
||
tbody tr:last-child td{{border-bottom:none}}
|
||
td.name{{font-weight:500}}
|
||
td.num{{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
|
||
font-size:12px}}
|
||
td.ops{{text-align:right;white-space:nowrap}}
|
||
.sub2{{color:var(--muted);font-size:11px;font-weight:400;
|
||
font-family:ui-monospace,Consolas,monospace;margin-top:1px}}
|
||
.val{{min-width:0;overflow-wrap:anywhere;word-break:break-word}}
|
||
td.empty{{text-align:center;padding:44px 20px;color:var(--dim)}}
|
||
td.empty .big{{font-size:15px;color:var(--fg);margin-bottom:6px;font-weight:500}}
|
||
code{{background:#eaeef2;padding:2px 8px;border-radius:6px;
|
||
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
|
||
font-size:13px;color:#0969da}}
|
||
.dot{{display:inline-block;width:7px;height:7px;border-radius:50%;
|
||
margin-right:7px;vertical-align:middle;background:#d0d7de}}
|
||
.dot.on{{background:var(--ok);box-shadow:0 0 0 3px #1a7f3722}}
|
||
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
|
||
border:1px solid transparent;white-space:nowrap}}
|
||
.badge.on{{color:var(--ok);background:#1a7f371a;border-color:#1a7f3740}}
|
||
.badge.off{{color:var(--muted);background:#8b949e1a;border-color:#8b949e40}}
|
||
.pickcell{{width:38px;text-align:center;padding-right:0}}
|
||
input[type=checkbox]{{width:15px;height:15px;cursor:pointer;
|
||
accent-color:var(--accent);vertical-align:middle}}
|
||
tr.picked td{{background:#eff6ff}}
|
||
tr.picked:hover td{{background:#dbeafe}}
|
||
|
||
/* ---------- 提示条 ---------- */
|
||
#toast{{position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
|
||
background:#1f6feb;color:#fff;padding:11px 20px;border-radius:10px;
|
||
font-size:13px;box-shadow:0 8px 24px #0002;opacity:0;
|
||
transition:.22s;pointer-events:none;max-width:88vw;text-align:center}}
|
||
#toast.show{{opacity:1;transform:translate(-50%,0)}}
|
||
#toast.err{{background:#b62324}}
|
||
#toast.ok{{background:#1a7f37}}
|
||
|
||
footer{{color:var(--muted);font-size:12px;margin-top:22px;
|
||
padding-top:14px;border-top:1px solid var(--border);line-height:1.7}}
|
||
|
||
/* ---------- 窄屏:表格变卡片 ---------- */
|
||
@media (max-width:720px){{
|
||
.wrap{{padding:16px 12px 32px}}
|
||
/* 从根上保证不会有东西超出容器。
|
||
表格在窄屏变成卡片式,这几条必须写死,
|
||
不然 table-layout:fixed 对 display:block 的行不起作用。 */
|
||
table,tbody,tr,td{{display:block;width:100%;max-width:100%;
|
||
box-sizing:border-box}}
|
||
.meta{{margin-left:0;text-align:left;width:100%}}
|
||
.stats{{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}}
|
||
.stat{{padding:11px 13px}}
|
||
.stat .n{{font-size:21px}}
|
||
.urlrow{{flex-direction:column;align-items:stretch}}
|
||
.url{{min-width:0;font-size:13px}}
|
||
thead{{display:none}}
|
||
tbody tr{{display:block;border-bottom:1px solid var(--border);
|
||
padding:12px 14px 14px;position:relative}}
|
||
tbody tr:last-child{{border-bottom:none}}
|
||
/* 每个值一行:左边标签、右边值。
|
||
min-width:0 + overflow-wrap 是关键 ——
|
||
少了它,长配对码/流量会把容器撑破、右边的值被截掉。 */
|
||
tbody td{{display:flex;justify-content:space-between;align-items:baseline;
|
||
gap:14px;border:none;padding:3px 0;text-align:right;
|
||
min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}}
|
||
tbody td .val{{flex:1 1 auto;min-width:0;text-align:right}}
|
||
td.name{{display:block}}
|
||
tbody td::before{{content:attr(data-l);color:var(--dim);font-size:12px;
|
||
text-align:left;flex:none}}
|
||
tbody td>*{{min-width:0}}
|
||
/* 设备名占满整行,不参与"标签/值"那一套 */
|
||
td.name{{display:block;text-align:left;padding:0 32px 8px 0}}
|
||
td.name::before{{content:'';display:none}}
|
||
td.name .v{{font-size:15px}}
|
||
td.pickcell{{position:absolute;right:12px;top:12px;width:auto;
|
||
padding:0;min-width:0}}
|
||
td.pickcell::before{{content:'';display:none}}
|
||
td.ops{{justify-content:flex-end;padding-top:10px;margin-top:4px;
|
||
border-top:1px solid #eaeef2}}
|
||
td.ops::before{{content:'';display:none}}
|
||
td.empty{{display:block;text-align:center}}
|
||
td.empty::before{{content:'';display:none}}
|
||
}}
|
||
</style>
|
||
|
||
<div class=wrap>
|
||
<header>
|
||
<div class=logo>yx</div>
|
||
<div class=brand>
|
||
<h1>ykxmz 中转服务器</h1>
|
||
<div class=sub>远程控制 · 跨网络中转</div>
|
||
</div>
|
||
<div class=meta>
|
||
<div>版本 <b>{_esc(snap['version'])}</b> · 已运行 <b>{_uptime(snap['uptimeSeconds'])}</b></div>
|
||
<div>本进程内存 <b>{snap['memoryKB'] // 1024} MB</b> · 累计请求 <b>{snap['totalRequests']}</b>
|
||
· <a href="/admin/logout" class=logout>退出登录</a></div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class=panel>
|
||
<div class=label>当前公网地址 —— 手表「中转设置」里填这个</div>
|
||
<div class=urlrow>
|
||
<div class="url{' empty' if not url_text else ''}" id=urlBox>{url_display}</div>
|
||
<button class="btn primary" id=copyUrl {'disabled' if not url_text else ''}>复制</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class=panel id=netPanel>
|
||
<div class=label>网络体检 —— 走的是局域网还是隧道?</div>
|
||
<div id=netBody class=tip>点下面按钮测一下</div>
|
||
<div style="margin-top:10px">
|
||
<button class="btn small" id=netBtn>测一下</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class=panel id=dlPanel>
|
||
<div class=label>下载最新版</div>
|
||
<div id=dlBody class=tip>正在读…</div>
|
||
</div>
|
||
|
||
<div class=stats>
|
||
<div class=stat><div class=n>{len(snap['devices'])}</div><div class=t>登记设备</div></div>
|
||
<div class="stat {'ok' if online_count else 'mute'}"><div class=n>{online_count}</div><div class=t>在线</div></div>
|
||
<div class="stat {'warn' if snap['sessions'] else 'mute'}"><div class=n>{snap['sessions']}</div><div class=t>手表会话</div></div>
|
||
<div class=stat><div class=n>{len(RELAY.codes)}</div><div class=t>配对码占用</div></div>
|
||
</div>
|
||
|
||
<div class=toolbar>
|
||
<button class="btn del" id=delPicked>删除选中</button>
|
||
<button class="btn del" id=delAll>删除全部设备</button>
|
||
<div class=spacer></div>
|
||
<label class=tip style="cursor:pointer;display:flex;align-items:center;gap:6px">
|
||
<input type=checkbox id=autoRef checked> 自动刷新(10 秒)
|
||
</label>
|
||
</div>
|
||
<div class=tip style="margin:-4px 0 12px">
|
||
删除会连配对码一起清掉,那台设备下次可以重新设码。
|
||
</div>
|
||
|
||
<div class=tablewrap>
|
||
<table>
|
||
<thead><tr>
|
||
<th class=pickcell><input type=checkbox id=pickAll title="全选"></th>
|
||
<th>设备</th><th>配对码</th><th>状态</th>
|
||
<th>空闲</th><th>待发(表/机)</th><th>流量(入/出)</th><th></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{''.join(rows) or empty}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<footer>
|
||
数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容<br>
|
||
中转只是转发字节,不解析、不存储会话内容。
|
||
</footer>
|
||
</div>
|
||
|
||
<div id=toast></div>
|
||
|
||
<script>
|
||
const toast = (msg, kind) => {{
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.className = kind ? ('show ' + kind) : 'show';
|
||
clearTimeout(el._t);
|
||
el._t = setTimeout(() => {{ el.className = ''; }}, 2600);
|
||
}};
|
||
|
||
const post = async (path, payload) => {{
|
||
const r = await fetch(path, {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify(payload || {{}})
|
||
}});
|
||
return r.json();
|
||
}};
|
||
|
||
// ---------------- 单台删除 ----------------
|
||
document.querySelectorAll('button.del[data-device]').forEach(b => {{
|
||
b.onclick = async () => {{
|
||
const name = b.dataset.name, code = b.dataset.code;
|
||
if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\\n\\n'
|
||
+ '这会:\\n 1. 断开它现在的手表连接\\n'
|
||
+ ' 2. 从服务器删掉这台设备\\n'
|
||
+ ' 3. 清掉它的配对码绑定')) return;
|
||
try {{
|
||
const j = await post('/v1/admin/delete', {{deviceId: b.dataset.device}});
|
||
if (j.ok) {{
|
||
toast('已删除「' + (j.name || name) + '」,清掉配对码 '
|
||
+ (j.codesCleared || []).length + ' 条', 'ok');
|
||
setTimeout(() => location.reload(), 700);
|
||
}} else {{
|
||
toast('删除失败:' + (j.error || '未知错误'), 'err');
|
||
}}
|
||
}} catch (e) {{
|
||
toast('请求出错:' + e, 'err');
|
||
}}
|
||
}};
|
||
}});
|
||
|
||
// ---------------- 批量选择 ----------------
|
||
const picks = () => Array.from(document.querySelectorAll('input.pick'));
|
||
const picked = () => picks().filter(c => c.checked).map(c => c.value);
|
||
|
||
function refreshPickState() {{
|
||
const chosen = picked().length;
|
||
const btn = document.getElementById('delPicked');
|
||
if (btn) {{
|
||
btn.disabled = chosen === 0;
|
||
btn.textContent = chosen ? ('删除选中 (' + chosen + ')') : '删除选中';
|
||
}}
|
||
picks().forEach(c => {{
|
||
const tr = c.closest('tr');
|
||
if (tr) tr.classList.toggle('picked', c.checked);
|
||
}});
|
||
const all = document.getElementById('pickAll');
|
||
if (all) {{
|
||
const total = picks().length;
|
||
all.checked = total > 0 && chosen === total;
|
||
all.indeterminate = chosen > 0 && chosen < total;
|
||
}}
|
||
}}
|
||
|
||
picks().forEach(c => c.addEventListener('change', refreshPickState));
|
||
const pickAll = document.getElementById('pickAll');
|
||
if (pickAll) pickAll.addEventListener('change', () => {{
|
||
picks().forEach(c => {{ c.checked = pickAll.checked; }});
|
||
refreshPickState();
|
||
}});
|
||
refreshPickState();
|
||
|
||
// ---------------- 批量删除 ----------------
|
||
const delPicked = document.getElementById('delPicked');
|
||
if (delPicked) delPicked.onclick = async () => {{
|
||
const ids = picked();
|
||
if (!ids.length) return;
|
||
if (!confirm('删除选中的 ' + ids.length + ' 台设备?\\n\\n'
|
||
+ '会断开它们现在的手表连接,并清掉它们占的配对码。'))
|
||
return;
|
||
delPicked.disabled = true;
|
||
try {{
|
||
const j = await post('/v1/admin/delete_many', {{deviceIds: ids}});
|
||
if (j.ok) {{
|
||
toast('已删除 ' + j.deleted + ' 台设备'
|
||
+ (j.failed ? (',' + j.failed + ' 台失败') : ''), 'ok');
|
||
setTimeout(() => location.reload(), 700);
|
||
}} else {{
|
||
toast('删除失败:' + (j.error || '未知错误'), 'err');
|
||
delPicked.disabled = false;
|
||
}}
|
||
}} catch (e) {{
|
||
toast('请求出错:' + e, 'err');
|
||
delPicked.disabled = false;
|
||
}}
|
||
}};
|
||
|
||
// ---------------- 删除全部 ----------------
|
||
const delAll = document.getElementById('delAll');
|
||
if (delAll) delAll.onclick = async () => {{
|
||
if (!confirm('删除**全部**设备(连同所有配对码)?\\n\\n'
|
||
+ '清场用,删完所有电脑都要重新设码。')) return;
|
||
try {{
|
||
const j = await post('/v1/admin/delete_all');
|
||
toast('已删除 ' + (j.deleted || 0) + ' 台设备', 'ok');
|
||
setTimeout(() => location.reload(), 700);
|
||
}} catch (e) {{
|
||
toast('请求出错:' + e, 'err');
|
||
}}
|
||
}};
|
||
|
||
// ---------------- 复制公网地址 ----------------
|
||
const copyUrl = document.getElementById('copyUrl');
|
||
if (copyUrl) copyUrl.onclick = async () => {{
|
||
const text = document.getElementById('urlBox').textContent.trim();
|
||
try {{
|
||
await navigator.clipboard.writeText(text);
|
||
toast('地址已复制', 'ok');
|
||
}} catch (e) {{
|
||
// 非安全上下文(http 且不是 localhost)没有 clipboard API,
|
||
// 退回到老办法:选中 + execCommand
|
||
const range = document.createRange();
|
||
range.selectNodeContents(document.getElementById('urlBox'));
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
try {{ document.execCommand('copy'); toast('地址已复制', 'ok'); }}
|
||
catch (e2) {{ toast('复制失败,请手动选中', 'err'); }}
|
||
}}
|
||
}};
|
||
|
||
// ---------------- 网络体检 ----------------
|
||
const netBtn = document.getElementById('netBtn');
|
||
if (netBtn) netBtn.onclick = async () => {{
|
||
const body = document.getElementById('netBody');
|
||
body.textContent = '正在测…';
|
||
try {{
|
||
const r = await fetch('/v1/admin/netcheck');
|
||
const j = await r.json();
|
||
if (!j.ok) {{
|
||
body.innerHTML = '<span style="color:#f85149">读不到:'
|
||
+ (j.error || '未知') + '</span>';
|
||
return;
|
||
}}
|
||
const rows = [];
|
||
if (j.tunnelMetrics) {{
|
||
const conns = j.tunnelConnections || 0;
|
||
const color = conns >= 2 ? '#1a7f37'
|
||
: (conns === 1 ? '#9a6700' : '#f85149');
|
||
rows.push('隧道连接数:<b style="color:' + color + '">' + conns
|
||
+ '</b>' + (conns === 0 ? '(断了,看门狗会重启)' : ''));
|
||
rows.push('隧道累计请求:' + (j.tunnelRequests || 0));
|
||
}} else {{
|
||
rows.push('<span style="color:#9a6700">读不到隧道指标'
|
||
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
|
||
}}
|
||
rows.push('登记设备:' + j.devices + '(在线 ' + j.online + ')');
|
||
rows.push('手表会话:' + j.sessions);
|
||
rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB');
|
||
rows.push('已运行:' + j.uptimeSeconds + ' 秒');
|
||
if (j.publicUrl) {{
|
||
rows.push('公网地址:<code style="letter-spacing:0">'
|
||
+ j.publicUrl + '</code>');
|
||
}}
|
||
body.innerHTML = rows.map(t => '<div style="margin:2px 0">' + t
|
||
+ '</div>').join('');
|
||
// 顺便测一下这台机器的公网往返
|
||
const t0 = performance.now();
|
||
try {{
|
||
await fetch('/v1/health?t=' + Date.now(), {{cache: 'no-store'}});
|
||
const ms = Math.round(performance.now() - t0);
|
||
body.innerHTML += '<div style="margin:2px 0">浏览器到中转往返:<b>'
|
||
+ ms + ' ms</b>'
|
||
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
|
||
+ '</div>';
|
||
}} catch (e) {{
|
||
body.innerHTML += '<div style="margin:2px 0;color:#f85149">'
|
||
+ '浏览器到中转:失败(' + e + ')</div>';
|
||
}}
|
||
}} catch (e) {{
|
||
body.textContent = '请求出错:' + e;
|
||
}}
|
||
}};
|
||
|
||
// ---------------- 下载(就一个链接)----------------
|
||
(async () => {{
|
||
const body = document.getElementById('dlBody');
|
||
if (!body) return;
|
||
try {{
|
||
const r = await fetch('/v1/downloads');
|
||
const j = await r.json();
|
||
const files = (j && j.files) || [];
|
||
if (!files.length) {{
|
||
body.innerHTML = '<span style="color:#656d76">服务器上还没有成品。</span>';
|
||
return;
|
||
}}
|
||
// 优先挑被控端整包;没有就挑第一个 zip;再没有就第一个
|
||
const pick = files.find(f => /agent/i.test(f.name))
|
||
|| files.find(f => /\.zip$/i.test(f.name))
|
||
|| files[0];
|
||
const mb = (pick.size / 1024 / 1024).toFixed(2);
|
||
body.innerHTML =
|
||
'<a href="/download/' + encodeURIComponent(pick.name) + '" '
|
||
+ 'style="display:inline-block;padding:10px 20px;border-radius:6px;'
|
||
+ 'background:#238636;color:#fff;text-decoration:none;'
|
||
+ 'font-weight:600;font-size:15px">'
|
||
+ '⬇ 下载 ' + pick.name + '</a>'
|
||
+ '<div style="color:#8b949e;font-size:12px;margin-top:8px">'
|
||
+ mb + ' MB · 也可以去 '
|
||
+ '<a href="https://gitee.com/xmz7891/ykxmz/releases" target="_blank" '
|
||
+ 'rel="noopener" style="color:#0969da">Gitee 历史版本</a>'
|
||
+ '</div>';
|
||
}} catch (e) {{
|
||
body.innerHTML = '<span style="color:#f85149">读不到:' + e + '</span>';
|
||
}}
|
||
}})();
|
||
|
||
// ---------------- 自动刷新 ----------------
|
||
const autoRef = document.getElementById('autoRef');
|
||
let autoTimer = null;
|
||
function setupAuto() {{
|
||
clearInterval(autoTimer);
|
||
if (autoRef && autoRef.checked) {{
|
||
autoTimer = setInterval(() => {{
|
||
// 有勾选/删除操作进行中就先别刷新,免得打断
|
||
if (picked().length === 0 && !document.hidden) location.reload();
|
||
}}, 10000);
|
||
}}
|
||
}}
|
||
if (autoRef) {{
|
||
autoRef.addEventListener('change', () => {{
|
||
try {{ localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0'); }}
|
||
catch (e) {{}}
|
||
setupAuto();
|
||
}});
|
||
try {{
|
||
const saved = localStorage.getItem('lr_autoref');
|
||
if (saved === '0') autoRef.checked = false;
|
||
}} catch (e) {{}}
|
||
setupAuto();
|
||
}}
|
||
</script>
|
||
</html>"""
|
||
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
|
||
|
||
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()
|
||
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())
|