feat: Python 版前置机 Agent 完整实现

- WebSocket 连接 B 端(注册/心跳/指数退避重连)
- 命令分发(10 种远程运维命令)
- 看门狗守护、日志实时流、在线升级含自动回滚
- 修复 main.py 日志格式 style 与占位符不匹配的问题
- 补充 README、配置示例、依赖清单

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:58:31 +08:00
co-authored by Claude
parent cc35a41ea4
commit 68751c2354
13 changed files with 1188 additions and 3 deletions
+161
View File
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""Agent WebSocket 客户端。
- 主动出站连接 B 端(穿透白名单,无需 A 端开放入站端口)
- 断线指数退避重连(1s → 2s → 4s → ... 最大 60s
- 周期心跳:拉取主程序 /api/admin/health 并转发给 B 端
- 收到下行命令交给 CommandDispatcher 处理
上行消息格式:{"type":"HEARTBEAT","deviceId":"...","ts":...,"payload":{...}}
"""
import json
import logging
import threading
import time
import websocket # websocket-client
log = logging.getLogger(__name__)
class AgentClient:
"""WebSocket 长连接客户端(连接管理 + 心跳 + 重连)。"""
def __init__(self, config, dispatcher):
self.config = config
self.dispatcher = dispatcher
self._ws = None # 当前 WebSocket 连接
self._connected = False # 是否已连接
self._reconnect_attempts = 0 # 连续重连次数(成功后清零)
self._lock = threading.Lock() # 保护 _ws/_connected 的并发访问
self._heartbeat_stop = threading.Event()
self._threads: list[threading.Thread] = []
# ================= 生命周期 =================
def start(self):
"""启动:连接 + 心跳线程(首次心跳立即发送,避免网关空闲超时在首个周期内掐断连接)。"""
self._connect()
t = threading.Thread(target=self._heartbeat_loop, name="heartbeat", daemon=True)
t.start()
self._threads.append(t)
def stop(self):
"""停止并释放资源。"""
self._heartbeat_stop.set()
with self._lock:
if self._ws is not None:
try:
self._ws.close()
except Exception:
pass
self._ws = None
self._connected = False
# ================= 连接管理 =================
def _connect(self):
"""建立 WebSocket 连接(阻塞直到连接断开,内部自行调度重连)。"""
url = self.config.server_url
log.info("连接管理服务器:%s", url)
headers = [
f"X-Device-Id: {self.config.device_id}",
f"X-Device-Token: {self.config.device_token}",
]
def on_open(ws):
self._connected = True
self._reconnect_attempts = 0
log.info("已连接管理服务器")
self.send("REGISTER", {
"deviceId": self.config.device_id,
"version": "1.0.0",
})
def on_message(ws, text):
try:
node = json.loads(text)
msg_type = node.get("type")
payload = node.get("payload")
self.dispatcher.dispatch(msg_type, payload)
except Exception as e:
log.warning("命令处理异常:%s", e)
def on_close(ws, code, reason):
self._connected = False
log.info("连接关闭:code=%s, reason=%s", code, reason)
self._schedule_reconnect()
def on_error(ws, error):
self._connected = False
log.warning("连接失败:%s,将重连", error)
# websocket-client 出错后必定回调 on_close,重连由 on_close 统一调度
# 此处不重复调度,避免双重重连
ws = websocket.WebSocketApp(
url,
header=headers,
on_open=on_open,
on_message=on_message,
on_close=on_close,
on_error=on_error,
)
with self._lock:
self._ws = ws
# 阻塞运行(跳过协议层 ping,应用层心跳已足够;关掉默认 ping 避免双重心跳)
ws.run_forever(ping_interval=25, ping_timeout=10)
def _schedule_reconnect(self):
"""指数退避重连:1s 起,翻倍至 60s 封顶。"""
self._reconnect_attempts += 1
attempt = self._reconnect_attempts
delay = min(1000 * (2 ** min(attempt - 1, 6)), 60_000) / 1000.0
log.info("%.0fs 后进行第 %d 次重连", delay, attempt)
time.sleep(delay)
if not self._heartbeat_stop.is_set():
self._connect()
# ================= 心跳 =================
def _heartbeat_loop(self):
"""周期心跳:拉主程序健康数据转发。"""
while not self._heartbeat_stop.wait(self.config.heartbeat_sec):
try:
self._heartbeat_once()
except Exception as e:
log.warning("心跳发送异常:%s", e)
def _heartbeat_once(self):
main_health = self.dispatcher.main_app_client.health()
self.send("HEARTBEAT", {
"deviceId": self.config.device_id,
"mainApp": main_health,
})
# ================= 上行消息 =================
def send(self, msg_type: str, payload):
"""发送上行消息(未连接时丢弃并记日志)。"""
with self._lock:
ws = self._ws
connected = self._connected
if ws is None or not connected:
log.debug("未连接,丢弃上行消息:%s", msg_type)
return
envelope = {
"type": msg_type,
"deviceId": self.config.device_id,
"ts": int(time.time()),
"payload": payload,
}
try:
ws.send(json.dumps(envelope, ensure_ascii=False))
except Exception as e:
log.warning("上行消息发送失败(%s):%s", msg_type, e)
def is_connected(self) -> bool:
"""当前是否已连接(供健康状态展示)。"""
return self._connected