mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 21:50:44 +00:00
== 现象 ==
用户报:
* 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
* 走中转「已配对」,但点按钮没反应
局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。
== 证据链(插桩复现出来的)==
被控端日志里直接抛了未捕获异常,线程当场死:
Exception in thread client-relay:
server.py:407 session.send(reply)
relay.py:166 self._link.send_to_client(data)
relay.py:357 self._post("/v1/agent/send", ...)
relay.py:766 raise RelayError(f"请求失败: {last_error}")
lanremote.relay.RelayError: 请求失败: Idle
之后的状态:
* client-relay 线程没了 —— 电脑再也不往手表回任何东西
* relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
* **手表看到的就是「连上了,但按一下毫无反应」**
写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。
== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==
_post() 里锁只包住「取连接」那一段:
with self._http_lock:
connection = self._http # 锁到这里就放了
try:
connection.request("POST", ...) # 锁外发请求
于是两个线程会同时用同一条 connection:
* relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
* client-relay 线程同时 POST /v1/agent/send
HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。
_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。
修法:
* **发命令用独立连接**(_post 加 dedicated 参数)。
长轮询那条常年被占着,发命令不该去挤它,各走各的。
* 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
不再只锁取连接那一下。
* 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
* _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
(否则那条连接会被永久标记为忙)。
== 根因 2:回包失败会打死整个会话线程 ==
server.py 的 _client_loop 里:
if reply is not None:
session.send(reply) # <- 不在任何 try 里
...
except OSError: # <- 外层只捕获 OSError
pass
而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。
修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。
== 修复效果 ==
修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常
_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**
这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。
== 同批修掉验证阶段找到的其它问题 ==
1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
register() 用 `device_id in self.devices` 判断老设备,但超时清理
把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
第二天开机就会被自己的开关挡住,而且**重启也救不回来**
(清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
管理员在页面上还看不到这台设备,不知道该放开谁。
修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
register 改查这份历史;清理后补 _save();delete_device 才真正忘记。
2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
`\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
元凶是我加改码功能那次提交。改成 `\\n`。
3. **--relay / --relay-status 崩溃**(严重)
RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
1 required positional argument」(用户以为地址填错了),
一处直接报栈。两处都补上 url。
4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
字母数字 + - _ . :)
5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
返回值反映真实运行状态;不再用「对象建过没有」判断)
6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节
== 局域网全面优化(task-2,另一个智能体完成)==
4 个真问题:
* **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
被判成不同网络,明明能直连却去走中转还费流量。
改成按网卡真实掩码比较。
* **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
* **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
* **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
新增定期回切(先便宜地 TCP ping,通了才动真格,
不破坏正在工作的中转连接)。
实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
现已修复。)
顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。
== 服务端一键部署脚本(task-1,另一个智能体完成)==
relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
* 自动检查并安装 cloudflared
* 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
* **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
不对外开放,绝不能把私人域名烧成默认值)
* 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动
顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。
== 默认中转地址改成空白 ==
用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。
== 文档同步(task-5,另一个智能体完成)==
README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
* 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
* 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
* 删掉「地址已经内置好了」(现在是错的)
* AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
* RELAY_README 原来写「两条隧道同时跑会互相争抢」,
和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
856 lines
35 KiB
Python
856 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""被控端走中转到手表(跨网络)。
|
||
|
||
整体思路
|
||
--------
|
||
现有服务端是"监听 TCP -> accept -> Session(conn) -> 逐条处理命令"。
|
||
跨网络时被控端在 NAT 后面,没法被直连,所以改成**主动连出去**:
|
||
|
||
被控端 --HTTPS 长轮询--> 中转服务器 <--HTTPS 长轮询-- 手表
|
||
|
||
关键技巧:`RelaySocket` 假装成一个 socket。它只实现服务端用到的那几个
|
||
方法(recv / sendall / settimeout / setsockopt / close / shutdown),
|
||
内部把数据压到中转的队列里、从队列里取。
|
||
|
||
于是 `RemoteServer`、`Session`、所有命令处理**一行都不用改** ——
|
||
原来的配对、令牌校验、权限判断全部继续生效。
|
||
中转服务器只是个不认识内容的管道,被攻破也进不了用户的电脑。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import secrets
|
||
import socket
|
||
import ssl
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
# 长轮询:中转那边挂 25 秒,客户端超时给宽一点
|
||
# 长轮询单次请求的超时。
|
||
# 必须显著大于服务端的 hold 时间(现在 60 秒),否则会在服务端
|
||
# 正常返回之前自己先超时 —— 那就变成"每次轮询都失败"了。
|
||
# 180 = 60(hold) + 120(网络余量)。
|
||
# 余量一直在加大,因为实测的长尾一直在刷新:
|
||
# 90 秒 -> 实测 93589ms 不够
|
||
# 120 秒 -> 实测 126604ms 不够(30 次里出现 1 次,其余都是 61~62 秒)
|
||
# 4G 丢包触发的重传退避能拖出一分钟以上的停顿,这是链路特性。
|
||
# 放宽超时只是"别把慢但成功的请求冤枉掉",不增加请求数。
|
||
# 而且客户端有"连续失败 3 次才判离线"的保护,
|
||
# 偶尔一次长等待不会让界面闪"已断开"。
|
||
POLL_TIMEOUT = 180.0
|
||
# 单次请求体上限(一个 256KB 分块 Base64 后约 350KB,给足余量)
|
||
MAX_BODY = 8 * 1024 * 1024
|
||
# 网络断了之后的退避
|
||
RETRY_MIN = 2.0
|
||
RETRY_MAX = 20.0
|
||
# 连续失败几次才判定"离线"。
|
||
# 单次失败就报离线太敏感:4G 链路上有 2~4% 的偶发失败,
|
||
# 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。
|
||
# 连续 3 次(至少 6 秒都不通)才判离线,真的断了仍然如实显示。
|
||
OFFLINE_AFTER = 3
|
||
# 多久没成功通信就算掉线(用于界面上显示状态)
|
||
STALE_SECONDS = 90.0
|
||
|
||
|
||
# 请求头。**必须带 User-Agent**:
|
||
# Cloudflare 的 Browser Integrity Check 会把 Python 默认 UA
|
||
# (Python-urllib/3.11)判为可疑客户端,返回 403 + error code 1010。
|
||
# 实测 LanRemote/1.4.0 能过,用自家标识比伪装浏览器合适。
|
||
USER_AGENT = "LanRemote/1.4.0"
|
||
|
||
|
||
# 私有网段 + 回环 + mDNS 名字 —— 这些肯定是局域网,用明文 HTTP
|
||
_PRIVATE_HOST = re.compile(
|
||
r"^(?:"
|
||
r"localhost"
|
||
r"|127\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||
r"|10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||
r"|192\.168\.\d{1,3}\.\d{1,3}"
|
||
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
|
||
r"|\d{1,3}(?:\.\d{1,3}){3}" # 任意纯 IP(含公网)
|
||
r"|[^./:]+\.local" # mDNS 名字,如 lanremote.local
|
||
r")"
|
||
r"(?::\d{1,5})?$",
|
||
re.IGNORECASE)
|
||
|
||
|
||
def _guess_scheme(address: str) -> str:
|
||
"""用户没写 http/https 时,猜一个。
|
||
|
||
IP 地址 / localhost / *.local -> http://
|
||
局域网里的中转服务是明文 HTTP。猜成 https 会永远连不上,
|
||
而且报的是 SSL 错误,很容易把人带去查证书(查半天查不出)。
|
||
域名 -> https://
|
||
走公网必须加密(Cloudflare 隧道本来就是 https)。
|
||
"""
|
||
host = address.split("/", 1)[0]
|
||
return "http://" if _PRIVATE_HOST.match(host) else "https://"
|
||
|
||
|
||
|
||
"""用户没写 http/https 时,猜一个。
|
||
|
||
IP 地址 / localhost -> http://
|
||
局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
|
||
而且报的是 SSL 错误,很容易误导人去查证书。
|
||
域名 -> https://
|
||
走公网必须加密(Cloudflare 隧道本来就是 https)。
|
||
"""
|
||
host = address.split("/", 1)[0]
|
||
return "http://" if _PRIVATE_HOST.match(host) else "https://"
|
||
|
||
|
||
def _ssl_context() -> ssl.SSLContext:
|
||
"""给 HTTPS 用的 SSL 上下文。
|
||
|
||
不依赖 certifi:Python 在 Windows 上会从系统证书库加载根证书,
|
||
打包成 EXE 之后也能用。
|
||
"""
|
||
context = ssl.create_default_context()
|
||
try:
|
||
context.load_default_certs()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return context
|
||
|
||
|
||
class RelayError(Exception):
|
||
pass
|
||
|
||
|
||
class RelaySocket:
|
||
"""假装成一个 socket,实际数据走中转服务器。
|
||
|
||
服务端只用到这几个方法,所以只实现这几个就够了。
|
||
`recv()` 在没有数据时按 `settimeout()` 的设定阻塞等待。
|
||
"""
|
||
|
||
def __init__(self, link: "RelayLink", session_token: str) -> None:
|
||
self._link = link
|
||
self.session_token = session_token
|
||
self._inbox = bytearray()
|
||
self._lock = threading.Lock()
|
||
self._ready = threading.Event()
|
||
self._closed = False
|
||
self._timeout: float | None = None
|
||
self.peer = ("中转服务器", 0)
|
||
|
||
# ---- 服务端会调用的接口 ----
|
||
|
||
def recv(self, size: int) -> bytes:
|
||
deadline = (time.time() + self._timeout) if self._timeout else None
|
||
while True:
|
||
with self._lock:
|
||
if self._inbox:
|
||
data = bytes(self._inbox[:size])
|
||
del self._inbox[:size]
|
||
if not self._inbox:
|
||
self._ready.clear()
|
||
return data
|
||
if self._closed:
|
||
return b""
|
||
if deadline is not None and time.time() >= deadline:
|
||
raise socket.timeout("等待中转数据超时")
|
||
self._ready.wait(0.5)
|
||
if self._closed:
|
||
return b""
|
||
|
||
def sendall(self, data: bytes) -> None:
|
||
if self._closed:
|
||
raise OSError("中转连接已关闭")
|
||
self._link.send_to_client(data)
|
||
|
||
def settimeout(self, value) -> None:
|
||
self._timeout = value
|
||
|
||
def setsockopt(self, *_args, **_kwargs) -> None:
|
||
"""TCP_NODELAY 之类,这里没有意义,忽略掉就行。"""
|
||
|
||
def getpeername(self):
|
||
return self.peer
|
||
|
||
def shutdown(self, _how=0) -> None:
|
||
self.close()
|
||
|
||
def close(self) -> None:
|
||
self._closed = True
|
||
self._ready.set()
|
||
|
||
def fileno(self) -> int:
|
||
# 有些代码会拿它做 select;这里不支持,返回 -1 明确表示
|
||
return -1
|
||
|
||
# ---- 中转那边把数据喂进来 ----
|
||
|
||
def feed(self, data: bytes) -> None:
|
||
if self._closed or not data:
|
||
return
|
||
with self._lock:
|
||
self._inbox.extend(data)
|
||
self._ready.set()
|
||
|
||
@property
|
||
def is_closed(self) -> bool:
|
||
return self._closed
|
||
|
||
|
||
class RelayLink:
|
||
"""被控端到中转服务器的长连接。
|
||
|
||
一条链路,一个"当前手表会话"。中转那边一台设备同时只允许一个
|
||
手表会话,所以这里也只需要维护一个 RelaySocket。
|
||
"""
|
||
|
||
def __init__(self, store, log=None, on_session=None, on_alert=None) -> None:
|
||
self.store = store
|
||
self._log = log or (lambda _m: None)
|
||
self._on_session = on_session
|
||
# 出需要用户处理的事(比如配对码被占)时调这个,用来弹通知
|
||
self._on_alert = on_alert
|
||
|
||
self.url = ""
|
||
self.candidates: list = []
|
||
self._fail_count = 0
|
||
self.device_id = ""
|
||
self.device_name = str(store.get("server_name") or "")
|
||
self.token = ""
|
||
self.code = ""
|
||
self.online = False
|
||
self.last_ok = 0.0
|
||
self.last_error = ""
|
||
self.sessions_served = 0
|
||
# 配对码冲突的说明。非空表示现在卡在这,等用户换码。
|
||
self.code_conflict = ""
|
||
self._alerted_code = ""
|
||
|
||
self._thread: threading.Thread | None = None
|
||
self._running = False
|
||
self._socket: RelaySocket | None = None
|
||
self._socket_lock = threading.Lock()
|
||
self._retry = RETRY_MIN
|
||
# 复用的 HTTP 连接。
|
||
# 为什么必须复用:这条链路丢包约 40%,每次新建 TLS 连接有约
|
||
# 30% 概率握手失败;连接建好后后续请求就 100% 成功。
|
||
# 实测:每次新建 70% 可用,复用 100%。
|
||
self._http = None
|
||
self._http_lock = threading.RLock()
|
||
# 缓存的那条连接现在有没有在途请求。
|
||
# /v1/agent/poll 是长轮询,一条请求挂 180 秒 ——
|
||
# 那段时间里这条连接**不能**被别的线程再拿去发请求,
|
||
# 否则 HTTP/1.1 状态机直接抛 CannotSendRequest("Idle")。
|
||
self._http_busy = False
|
||
# 发命令专用连接。长轮询那条常年被占着,
|
||
# 发命令不能去挤它 —— 各走各的。
|
||
self._http_send = None
|
||
self._send_lock = threading.RLock()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 对外
|
||
# ------------------------------------------------------------------
|
||
|
||
def start(self, url: str) -> bool:
|
||
"""开始连中转。
|
||
|
||
url 支持用**分号**分隔多个地址,按顺序尝试:
|
||
http://192.168.1.10:8080;https://aaa.bbb.com
|
||
|
||
为什么需要多个:
|
||
* 电脑和中转常在同一局域网 —— 直连局域网中转更快更稳,
|
||
而且地址永远不变(不用管隧道地址变来变去)
|
||
* 电脑在外面时才走隧道
|
||
|
||
注意:**地址由用户自己填**,代码里不内置任何默认中转地址。
|
||
"""
|
||
candidates = []
|
||
for piece in (url or "").replace(",", ";").split(";"):
|
||
piece = piece.strip().rstrip("/")
|
||
if not piece:
|
||
continue
|
||
if not piece.startswith(("http://", "https://")):
|
||
piece = _guess_scheme(piece) + piece
|
||
if piece not in candidates:
|
||
candidates.append(piece)
|
||
|
||
if not candidates:
|
||
self._log("没配置中转地址,跳过")
|
||
return False
|
||
|
||
# 上次连通的地址排到最前面。
|
||
# 设备 IP 会变(DHCP),但大概率还是上次那个;
|
||
# 排前面能省掉"挨个探到超时"的那几秒(实测 2 个候选要 10 秒,
|
||
# 而这段时间局域网发现都还没起来,手表搜不到电脑)。
|
||
try:
|
||
last_good = str(self.store.get("relay_last_good") or "").strip()
|
||
except Exception: # noqa: BLE001
|
||
last_good = ""
|
||
if last_good:
|
||
normalized = last_good.rstrip("/")
|
||
if normalized in candidates:
|
||
candidates.remove(normalized)
|
||
candidates.insert(0, normalized)
|
||
|
||
self.candidates = candidates
|
||
# 先探一遍,挑第一个通的
|
||
chosen = self._pick_candidate(candidates)
|
||
self.url = chosen
|
||
if len(candidates) > 1:
|
||
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")
|
||
|
||
# 记下来,下次优先试它
|
||
if chosen:
|
||
try:
|
||
if str(self.store.get("relay_last_good") or "") != chosen:
|
||
self.store.set("relay_last_good", chosen)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
self.device_id = self._ensure_device_id()
|
||
self.code = str(self.store.pairing_code() or "")
|
||
|
||
self._running = True
|
||
self._thread = threading.Thread(target=self._run, daemon=True,
|
||
name="relay-link")
|
||
self._thread.start()
|
||
self._log(f"中转已启用:{self.url}")
|
||
return True
|
||
|
||
@property
|
||
def running(self) -> bool:
|
||
"""中转链路现在是不是真的在跑。
|
||
|
||
对外给 server.enable_relay() 判断用 ——
|
||
那边以前靠"对象建过没有"来判成功,会在没配地址时谎报。
|
||
"""
|
||
return bool(self._running)
|
||
|
||
def stop(self) -> None:
|
||
self._running = False
|
||
with self._socket_lock:
|
||
if self._socket is not None:
|
||
self._socket.close()
|
||
self._socket = None
|
||
if self.token:
|
||
try:
|
||
self._post("/v1/agent/bye", b"", {"X-Token": self.token},
|
||
timeout=5)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def status(self) -> dict:
|
||
return {
|
||
"enabled": bool(self.url),
|
||
"url": self.url,
|
||
"candidates": list(self.candidates),
|
||
"codeConflict": self.code_conflict,
|
||
"deviceId": self.device_id,
|
||
"code": self.code,
|
||
"online": self.online and (time.time() - self.last_ok < STALE_SECONDS),
|
||
"idleSeconds": int(time.time() - self.last_ok) if self.last_ok else -1,
|
||
"sessions": self.sessions_served,
|
||
"error": self.last_error,
|
||
}
|
||
|
||
def send_to_client(self, data: bytes) -> None:
|
||
"""把数据发给当前手表。"""
|
||
if not self.token:
|
||
raise OSError("还没连上中转")
|
||
if not data:
|
||
return
|
||
for start in range(0, len(data), 256 * 1024):
|
||
piece = data[start:start + 256 * 1024]
|
||
# dedicated=True:走发命令专用连接,不和长轮询挤一条。
|
||
# 这是「按一下没反应」的修复点 —— 以前这里和长轮询共用
|
||
# 一条 HTTP/1.1 连接,撞上状态机就抛 "Idle",
|
||
# 异常一路冒到 client-relay 线程外面把它打死。
|
||
self._post("/v1/agent/send", piece, {"X-Token": self.token},
|
||
timeout=POLL_TIMEOUT, dedicated=True)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 内部
|
||
# ------------------------------------------------------------------
|
||
|
||
def _probe(self, base: str, timeout: float = 8.0) -> bool:
|
||
"""这个地址通不通(打一下 /v1/health)。"""
|
||
try:
|
||
request = urllib.request.Request(
|
||
base + "/v1/health", headers={"User-Agent": USER_AGENT})
|
||
context = _ssl_context() if base.startswith("https://") else None
|
||
with urllib.request.urlopen(request, timeout=timeout,
|
||
context=context) as response:
|
||
if response.status != 200:
|
||
return False
|
||
body = response.read(4096).decode("utf-8", "replace")
|
||
return '"ok"' in body
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
def _pick_candidate(self, candidates: list) -> str:
|
||
for base in candidates:
|
||
if self._probe(base):
|
||
return base
|
||
self._log(f" {base} 不通,试下一个")
|
||
# 都不通也先返回第一个,让主循环去重试
|
||
return candidates[0]
|
||
|
||
def _prefer_lan(self) -> None:
|
||
"""定期回头看看:局域网那条路是不是又通了?
|
||
|
||
为什么需要:
|
||
实测局域网中转 3.5ms、公网隧道 1.5 秒(还只有 75% 可用)。
|
||
原来的逻辑只在**报错时**才切换地址,
|
||
所以一旦切到隧道,就算局域网恢复了也不会切回来 ——
|
||
白背着 400 倍的延迟和 25% 的丢包。
|
||
|
||
策略:每 60 秒挑一个"比当前更靠前"的候选试一下
|
||
(候选表是按优先级排的:局域网在前,隧道在后),
|
||
通了就切回去。只往前切、不往后切,避免来回抖。
|
||
"""
|
||
if not self.candidates or not self.url:
|
||
return
|
||
try:
|
||
current = self.candidates.index(self.url)
|
||
except ValueError:
|
||
current = len(self.candidates)
|
||
# 只试排在当前前面的那些(优先级更高)
|
||
for base in self.candidates[:current]:
|
||
if self._probe(base, timeout=3):
|
||
self._log(f"发现更好的中转地址,切回 {base}")
|
||
self.url = base
|
||
self._drop_http() # 换地址了,旧连接作废
|
||
return
|
||
|
||
def _refresh_url(self) -> None:
|
||
"""主地址连不上时,去候选里找找有没有能用的。
|
||
|
||
如果某个候选本身就是局域网中转,还能顺便问它
|
||
「现在的隧道地址是什么」(/url 接口),自动跟随地址变化。
|
||
"""
|
||
for base in list(self.candidates):
|
||
if base == self.url:
|
||
continue
|
||
if not self._probe(base, timeout=5):
|
||
continue
|
||
self._log(f"切换到备用中转地址:{base}")
|
||
self.url = base
|
||
return
|
||
|
||
def _lan_ips(self) -> list:
|
||
"""本机在局域网里的 IP(可能有多张网卡)。
|
||
|
||
手表靠这个判断"电脑是不是和我同一个网络"。
|
||
拿不到就返回空列表 —— 中转那边会当成"不知道",
|
||
手表就继续走中转,不会出错。
|
||
"""
|
||
found = []
|
||
try:
|
||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
try:
|
||
# 不会真的发包,只是让系统选出默认出口网卡
|
||
probe.connect(("8.8.8.8", 80))
|
||
addr = probe.getsockname()[0]
|
||
if addr and not addr.startswith("127."):
|
||
found.append(addr)
|
||
finally:
|
||
probe.close()
|
||
except OSError:
|
||
pass
|
||
try:
|
||
for info in socket.getaddrinfo(socket.gethostname(), None,
|
||
socket.AF_INET):
|
||
addr = info[4][0]
|
||
if (addr and not addr.startswith("127.")
|
||
and not addr.startswith("169.254.")
|
||
and addr not in found):
|
||
found.append(addr)
|
||
except OSError:
|
||
pass
|
||
return found
|
||
|
||
def _check_code(self, code: str) -> dict:
|
||
"""问中转:这个码被占了吗?(只读,不会占用它)
|
||
|
||
返回 {"ok": True, "taken": bool, "deviceName": str}
|
||
连不上中转就返回 {"ok": False},调用方自己决定怎么办。
|
||
"""
|
||
bases = list(self.candidates) or ([self.url] if self.url else [])
|
||
if not bases:
|
||
return {"ok": False, "error": "还没配置中转地址"}
|
||
quoted = urllib.parse.quote(code)
|
||
for base in bases:
|
||
try:
|
||
request = urllib.request.Request(
|
||
f"{base}/v1/code/check?code={quoted}",
|
||
headers={"User-Agent": USER_AGENT})
|
||
context = (_ssl_context() if base.startswith("https://")
|
||
else None)
|
||
with urllib.request.urlopen(request, timeout=10,
|
||
context=context) as response:
|
||
return json.loads(response.read(4096).decode(
|
||
"utf-8", "replace"))
|
||
except Exception as exc: # noqa: BLE001
|
||
last = f"{type(exc).__name__}: {exc}"
|
||
continue
|
||
return {"ok": False, "error": last if "last" in dir() else "连不上中转"}
|
||
|
||
def _ensure_device_id(self) -> str:
|
||
"""设备唯一标识。存下来,重装/重启都不变 —— 单设备单配对码要用它。"""
|
||
existing = str(self.store.get("relay_device_id") or "").strip()
|
||
if existing:
|
||
return existing
|
||
# 带上机器名,便于在服务器管理页上认出来
|
||
name = str(self.store.get("server_name") or socket.gethostname())
|
||
safe = "".join(ch for ch in name if ch.isalnum() or ch in "-_")[:24]
|
||
device_id = f"{safe or 'pc'}-{secrets.token_hex(6)}"
|
||
self.store.set("relay_device_id", device_id)
|
||
return device_id
|
||
|
||
def _run(self) -> None:
|
||
backoff = RETRY_MIN
|
||
while self._running:
|
||
try:
|
||
if not self.token:
|
||
self._register()
|
||
self._poll_once()
|
||
backoff = RETRY_MIN
|
||
# 每分钟回头看看有没有更快的中转(局域网优先)
|
||
now = time.time()
|
||
if now - getattr(self, "_last_prefer", 0.0) >= 60.0:
|
||
self._last_prefer = now
|
||
self._prefer_lan()
|
||
except _AuthLost:
|
||
self._log("中转说令牌失效,重新登记")
|
||
self.token = ""
|
||
time.sleep(1)
|
||
except RelayCodeTaken as exc:
|
||
# 配对码被别的设备占了。这不是网络问题,重试没用。
|
||
# 弹一次通知,然后**停下来等用户换码**。
|
||
message = str(exc) or "该配对码已被使用,请换一个配对码"
|
||
self.online = False
|
||
self.token = ""
|
||
self.last_error = message
|
||
self.code_conflict = message
|
||
current = self.store.pairing_code()
|
||
if current != self._alerted_code:
|
||
# 只在换了新码之后再弹,避免反复弹同一个码的通知
|
||
self._alerted_code = current
|
||
self._log(f"配对码冲突:{message}")
|
||
if self._on_alert:
|
||
try:
|
||
self._on_alert("配对码被占用", message)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# 等用户换码。每 5 秒看一次配对码变没变。
|
||
for _ in range(12):
|
||
if not self._running:
|
||
return
|
||
time.sleep(0.5)
|
||
if self.store.pairing_code() != current:
|
||
self._log("配对码变了,重新登记")
|
||
self._alerted_code = ""
|
||
break
|
||
continue
|
||
except Exception as exc: # noqa: BLE001
|
||
# 偶发的连接超时/重置不算断线,连续几次才算
|
||
self._fail_streak = getattr(self, "_fail_streak", 0) + 1
|
||
if self._fail_streak >= OFFLINE_AFTER:
|
||
self.online = False
|
||
self.last_error = f"{type(exc).__name__}: {exc}"
|
||
self._log(f"中转连接出问题(第 {self._fail_streak} 次):"
|
||
f"{self.last_error}")
|
||
# 网络断了:把当前会话关掉,让上层重开
|
||
self._drop_socket()
|
||
self.token = ""
|
||
# 连续失败几次就换一个候选地址试试
|
||
fail_count = getattr(self, "_fail_count", 0) + 1
|
||
self._fail_count = fail_count
|
||
if fail_count >= 3 and len(getattr(self, "candidates", [])) > 1:
|
||
self._fail_count = 0
|
||
self._refresh_url()
|
||
time.sleep(backoff)
|
||
backoff = min(RETRY_MAX, backoff * 1.6)
|
||
|
||
def _register(self) -> None:
|
||
# 每次登记都从设置里重新读配对码。
|
||
# 用户可能刚改过(比如上一个码被别的设备占了),
|
||
# 不重读就会一直拿旧码去报,永远卡在冲突里出不来。
|
||
latest = str(self.store.pairing_code() or "").strip()
|
||
if latest and latest != self.code:
|
||
self._log(f"配对码已改为 {latest},用新的重新登记")
|
||
self.code = latest
|
||
|
||
try:
|
||
listen_port = int(self.store.get("port") or 0)
|
||
except (TypeError, ValueError):
|
||
listen_port = 0
|
||
payload = json.dumps({
|
||
"deviceId": self.device_id,
|
||
"code": self.code,
|
||
"name": self.device_name or self.device_id,
|
||
"version": _version(),
|
||
# 手表用这两个判断要不要切成局域网直连
|
||
"lanIps": self._lan_ips(),
|
||
"port": listen_port,
|
||
}, ensure_ascii=False).encode("utf-8")
|
||
try:
|
||
status, body = self._post("/v1/agent/register", payload, {},
|
||
timeout=30)
|
||
except urllib.error.HTTPError as exc:
|
||
# 中转用 400 表示"登记被拒",错误详情在响应体里。
|
||
# urlopen 抛 HTTPError 时不会把 body 交出来,得自己读。
|
||
# 不读的话配对码冲突就被当成普通网络错误,
|
||
# 用户永远看不到"该配对码已被使用"的通知。
|
||
body = exc.read(MAX_BODY)
|
||
try:
|
||
reply = json.loads(body.decode("utf-8", "replace"))
|
||
except Exception: # noqa: BLE001
|
||
raise RelayError(f"登记响应看不懂:{body[:120]!r}") from None
|
||
if not reply.get("ok"):
|
||
message = str(reply.get("error") or "登记被拒")
|
||
# 配对码被占:精确判断(服务端会带 codeTaken),
|
||
# 兼容老服务端就匹配关键字。
|
||
if reply.get("codeTaken") or "已被" in message and "配对码" in message:
|
||
raise RelayCodeTaken(message)
|
||
raise RelayError(message)
|
||
# 登记成功 -> 之前的冲突解除了
|
||
self.code_conflict = ""
|
||
|
||
self.token = str(reply.get("token") or "")
|
||
# 中转可能按「单设备单配对码」把码改回旧的,这里跟着改
|
||
server_code = str(reply.get("code") or "")
|
||
if server_code and server_code != self.code:
|
||
self.code = server_code
|
||
try:
|
||
self.store.set_pairing_code(server_code)
|
||
self._log(f"中转按单设备单配对码把配对码改回 {server_code}")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
note = str(reply.get("note") or "")
|
||
if note:
|
||
self._log(note)
|
||
self.online = True
|
||
self.last_ok = time.time()
|
||
self.last_error = ""
|
||
self._log(f"已登记到中转:{self.device_name} 配对码 {self.code}")
|
||
|
||
def _poll_once(self) -> None:
|
||
headers = {"X-Token": self.token}
|
||
try:
|
||
status, body, extra = self._post(
|
||
"/v1/agent/poll", b"", headers, timeout=POLL_TIMEOUT,
|
||
want_headers=True)
|
||
except urllib.error.HTTPError as exc:
|
||
if exc.code == 401:
|
||
raise _AuthLost() from None
|
||
raise
|
||
|
||
self.online = True
|
||
self._fail_streak = 0
|
||
self.last_ok = time.time()
|
||
self.last_error = ""
|
||
|
||
# 会话变了 -> 手表换了一台/重连了 -> 重开一条会话
|
||
session_token = (extra.get("X-Relay-Session") or "-").strip()
|
||
self._sync_session(session_token)
|
||
|
||
if body and session_token not in ("", "-"):
|
||
with self._socket_lock:
|
||
if self._socket is not None:
|
||
self._socket.feed(body)
|
||
|
||
def _sync_session(self, session_token: str) -> None:
|
||
"""中转报的会话令牌和本地不一致时,重建 RelaySocket。"""
|
||
with self._socket_lock:
|
||
current = self._socket
|
||
if current is not None and current.session_token == session_token:
|
||
return
|
||
if session_token in ("", "-"):
|
||
# 手表都不在了,把当前会话关掉
|
||
if current is not None:
|
||
current.close()
|
||
self._socket = None
|
||
return
|
||
if current is not None:
|
||
current.close()
|
||
new_socket = RelaySocket(self, session_token)
|
||
self._socket = new_socket
|
||
self.sessions_served += 1
|
||
|
||
self._log(f"手表接入中转(会话 {session_token[:8]}…)")
|
||
if self._on_session is not None:
|
||
try:
|
||
self._on_session(new_socket)
|
||
except Exception as exc: # noqa: BLE001
|
||
self._log(f"建立会话失败:{exc}")
|
||
|
||
def _drop_socket(self) -> None:
|
||
with self._socket_lock:
|
||
if self._socket is not None:
|
||
self._socket.close()
|
||
self._socket = None
|
||
|
||
# ---- HTTP ----
|
||
|
||
def _open_http(self):
|
||
"""按 self.url 建一条 HTTP(S) 连接。"""
|
||
import http.client
|
||
parts = urllib.parse.urlsplit(self.url)
|
||
host = parts.hostname or ""
|
||
if parts.scheme == "https":
|
||
return http.client.HTTPSConnection(
|
||
host, parts.port or 443, timeout=POLL_TIMEOUT,
|
||
context=_ssl_context())
|
||
return http.client.HTTPConnection(host, parts.port or 80,
|
||
timeout=POLL_TIMEOUT)
|
||
|
||
def _drop_http(self, dedicated: bool = False) -> None:
|
||
"""把缓存的连接关掉(出错后调用,下次会重开)。
|
||
|
||
dedicated=True 时清的是"发命令专用连接",
|
||
否则清长轮询用的那条。
|
||
"""
|
||
if dedicated:
|
||
with self._send_lock:
|
||
connection, self._http_send = self._http_send, None
|
||
if connection is not None:
|
||
try:
|
||
connection.close()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return
|
||
with self._http_lock:
|
||
connection, self._http = self._http, None
|
||
self._http_busy = False
|
||
if connection is not None:
|
||
try:
|
||
connection.close()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def _post(self, path: str, body: bytes, headers: dict,
|
||
timeout: float = POLL_TIMEOUT, want_headers: bool = False,
|
||
dedicated: bool = False):
|
||
"""POST 一个请求,**复用同一条连接**。
|
||
|
||
为什么要复用(真机实测):
|
||
这条链路丢包约 40%,每次新建 TLS 连接约 30% 概率握手失败。
|
||
但连接建好之后,后续请求 100% 成功(TCP 会自己重传)。
|
||
实测:每次新建 -> 70% 可用;复用 -> 100%。
|
||
|
||
所以这里:
|
||
* 优先用缓存的那条连接
|
||
* 断了就重开一次再试(短抖动自己扛过去)
|
||
* 两次都失败才算真失败,往上抛
|
||
"""
|
||
send = dict(headers or {})
|
||
send["Content-Type"] = "application/octet-stream"
|
||
send["User-Agent"] = "LanRemote-Agent/" + _version()
|
||
send["Connection"] = "keep-alive"
|
||
|
||
last_error = None
|
||
for attempt in (1, 2):
|
||
# 取一条可以独占使用的连接:
|
||
# * dedicated=True -> 发命令专用连接(自己一把锁锁全程)
|
||
# * 普通请求 -> 缓存连接;**如果它正忙(长轮询在途)
|
||
# 就临时开一条,用完即关**
|
||
# 关键:锁必须盖住整个"发请求 + 读响应"周期,
|
||
# 不能只锁"取连接"那一下 —— 否则两个线程会用同一条连接。
|
||
if dedicated:
|
||
self._send_lock.acquire()
|
||
connection = self._http_send
|
||
if connection is None:
|
||
connection = self._open_http()
|
||
self._http_send = connection
|
||
temporary = False
|
||
else:
|
||
with self._http_lock:
|
||
if self._http is None:
|
||
self._http = self._open_http()
|
||
if self._http_busy:
|
||
# 缓存连接正被长轮询占着,临时另开一条
|
||
connection = self._open_http()
|
||
temporary = True
|
||
else:
|
||
connection = self._http
|
||
self._http_busy = True
|
||
temporary = False
|
||
try:
|
||
connection.timeout = timeout
|
||
connection.request("POST", path, body=body, headers=send)
|
||
response = connection.getresponse()
|
||
payload = response.read(MAX_BODY)
|
||
status = response.status
|
||
# 4xx/5xx:抛 HTTPError,但**必须把响应体带上**。
|
||
# 中转用 400 + JSON body 表示"登记被拒"(配对码被占),
|
||
# 上层要读这个 body 才能区分"码被占"和"普通网络错误"。
|
||
# 早先这里传 fp=None,导致 exc.read() 永远是 b'',
|
||
# 配对码冲突就识别不出来了。
|
||
if status >= 400:
|
||
import io as _io
|
||
self._drop_http()
|
||
reason = "unauthorized" if status == 401 else "http error"
|
||
raise urllib.error.HTTPError(
|
||
self.url + path, status, reason,
|
||
response.headers, _io.BytesIO(payload))
|
||
if want_headers:
|
||
extra = {name: value
|
||
for name, value in response.headers.items()}
|
||
return status, payload, extra
|
||
return status, payload
|
||
except urllib.error.HTTPError:
|
||
raise # HTTP 状态码是业务层的,不算连接问题
|
||
except Exception as exc: # noqa: BLE001
|
||
# 连接层面的问题:关掉,下一轮重开
|
||
last_error = exc
|
||
self._drop_http(dedicated=dedicated)
|
||
if attempt == 2:
|
||
break
|
||
self._log_quiet(f"连接断了,重开一次再试({exc})")
|
||
finally:
|
||
# 无论成功失败都要把"占用"还回去,
|
||
# 否则这条连接会被永久标记为忙,之后每次请求都另开临时连接。
|
||
if dedicated:
|
||
self._send_lock.release()
|
||
elif not temporary:
|
||
with self._http_lock:
|
||
self._http_busy = False
|
||
else:
|
||
try:
|
||
connection.close()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
raise RelayError(f"请求失败: {last_error}")
|
||
|
||
def _log_quiet(self, message: str) -> None:
|
||
"""记一条不打扰用户的日志(重连这种事不用弹通知)。"""
|
||
try:
|
||
self._log(message)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
class RelayCodeTaken(RelayError):
|
||
"""配对码被别的设备占了。
|
||
|
||
这**不是网络问题**,重试没用 —— 要等用户换一个配对码。
|
||
所以主循环遇到它不能当成普通错误一直重试(会刷屏),
|
||
而是弹一次通知然后停下等。
|
||
"""
|
||
|
||
|
||
class _AuthLost(Exception):
|
||
"""中转不认识我们的令牌了(重启过 / 被清理),要重新登记。"""
|
||
|
||
|
||
def _version() -> str:
|
||
try:
|
||
from . import __version__
|
||
return __version__
|
||
except Exception: # noqa: BLE001
|
||
return "1.4.0"
|