Add Fleet control plane and split manage.sh deploy menu.

Strategy nodes gain fleet token APIs; control/ app for local ops; manage.sh offers strategy vs control one-click deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 10:46:40 +08:00
parent 200702d066
commit da5eb4c18c
44 changed files with 4623 additions and 15 deletions
+1
View File
@@ -0,0 +1 @@
# 使 `uvicorn app.main:app` 在 control/backend 下可运行
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .nodes import router as nodes_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(nodes_router)
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import hmac
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..auth import issue_token, require_control_user
from ..config import ControlSettings, get_control_settings
from ..envfile import update_control_credentials
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginBody(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class ChangeCredentialsBody(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=6, max_length=128)
@router.post("/login")
async def login(
body: LoginBody,
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
user_ok = hmac.compare_digest(
body.username.encode("utf-8"),
settings.control_auth_username.encode("utf-8"),
)
pwd_ok = hmac.compare_digest(
body.password.encode("utf-8"),
settings.control_auth_password.encode("utf-8"),
)
if not (user_ok and pwd_ok):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token, ttl = issue_token(body.username, settings)
return {"token": token, "username": body.username, "expires_in": ttl}
@router.get("/me")
async def me(
username: Annotated[str, Depends(require_control_user)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
return {
"username": username,
"poll_interval_sec": settings.control_poll_interval_sec,
}
@router.post("/change-credentials")
async def change_credentials(
body: ChangeCredentialsBody,
username: Annotated[str, Depends(require_control_user)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
if not hmac.compare_digest(
body.current_password.encode("utf-8"),
settings.control_auth_password.encode("utf-8"),
):
raise HTTPException(status_code=400, detail="当前密码不正确")
try:
update_control_credentials(
new_username=body.new_username,
new_password=body.new_password,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
settings2 = get_control_settings()
token, ttl = issue_token(body.new_username.strip(), settings2)
return {
"token": token,
"username": body.new_username.strip(),
"expires_in": ttl,
}
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import secrets
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..auth import require_control_user
from ..config import get_control_settings
from ..crypto import seal
from ..db import get_control_db
from ..proxy import call_node, probe_health
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
def _public_node(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"base_url": row["base_url"],
"token_configured": bool((row.get("token_sealed") or "").strip()),
"created_at_ms": row["created_at_ms"],
"updated_at_ms": row["updated_at_ms"],
}
def _http_detail(data: Any) -> str:
if isinstance(data, dict):
d = data.get("detail", data)
return d if isinstance(d, str) else str(d)
return str(data)
class NodeCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
base_url: str = Field(min_length=8, max_length=256)
class NodeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
base_url: str | None = Field(default=None, min_length=8, max_length=256)
@router.get("/")
async def list_nodes(_user: Annotated[str, Depends(require_control_user)]) -> dict:
db = get_control_db()
nodes = [_public_node(n) for n in db.list_nodes()]
return {"nodes": nodes}
@router.post("/")
async def create_node(
body: NodeCreate,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
try:
row = db.create_node(body.name, body.base_url)
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建失败: {e}") from e
return _public_node(row)
@router.get("/status/all")
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
db = get_control_db()
items = []
for node in db.list_nodes():
probe = await probe_health(node)
item = {**_public_node(node), **probe}
if probe.get("online") and node.get("token_sealed"):
code, data = await call_node(node, "GET", "/api/fleet/status")
if code == 200 and isinstance(data, dict):
item["fleet"] = data
items.append(item)
return {"nodes": items}
@router.post("/update-batch")
async def update_batch(
body: dict,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
ids = body.get("ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ids 不能为空")
db = get_control_db()
results = []
for nid in ids:
node = db.get_node(int(nid))
if not node:
results.append({"id": nid, "ok": False, "detail": "不存在"})
continue
code, data = await call_node(node, "POST", "/api/fleet/update")
results.append(
{
"id": nid,
"ok": code < 400,
"status": code,
"result": data,
}
)
return {"results": results}
@router.patch("/{node_id}")
async def update_node(
node_id: int,
body: NodeUpdate,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
row = db.update_node(node_id, name=body.name, base_url=body.base_url)
if not row:
raise HTTPException(status_code=404, detail="节点不存在")
return _public_node(row)
@router.delete("/{node_id}")
async def delete_node(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
if not db.delete_node(node_id):
raise HTTPException(status_code=404, detail="节点不存在")
return {"ok": True}
@router.post("/{node_id}/generate-token")
async def generate_token(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""生成新 Token,加密存中控;明文仅返回一次,需粘贴到策略机设置。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
plain = secrets.token_urlsafe(32)
sealed = seal(plain, get_control_settings().control_auth_secret)
db.update_node(node_id, token_sealed=sealed)
return {
"ok": True,
"token": plain,
"msg": "请立即复制并到策略机「系统设置 → 登录账户 → 中控 API Token」保存",
}
@router.get("/{node_id}/status")
async def node_status(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
probe = await probe_health(node)
out = {**_public_node(node), **probe}
if probe.get("online") and node.get("token_sealed"):
code, data = await call_node(node, "GET", "/api/fleet/status")
if code == 200 and isinstance(data, dict):
out["fleet"] = data
return out
@router.post("/{node_id}/start")
async def node_start(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/start")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
return {"ok": True, "result": data}
@router.post("/{node_id}/pause")
async def node_pause(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/pause")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
return {"ok": True, "result": data}
@router.post("/{node_id}/update")
async def node_update(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/update")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
return {"ok": True, "result": data}
@router.post("/{node_id}/login-url")
async def node_login_url(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""用 Fleet Token 向策略机签发一次性登录票,返回可打开的 URL。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
if code >= 400:
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=_http_detail(data),
)
path = ""
if isinstance(data, dict):
path = str(data.get("login_path") or "")
if not path:
raise HTTPException(status_code=502, detail="策略机未返回 login_path")
base = str(node["base_url"]).rstrip("/")
return {
"ok": True,
"url": f"{base}{path}",
"expires_in": data.get("expires_in") if isinstance(data, dict) else None,
}
+77
View File
@@ -0,0 +1,77 @@
"""中控登录 HMAC Token。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .config import ControlSettings, get_control_settings
_bearer = HTTPBearer(auto_error=False)
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def issue_token(username: str, settings: ControlSettings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.control_token_ttl_sec)
payload = {
"u": username,
"exp": exp,
"v": int(settings.control_auth_token_version),
}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.control_auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
return f"{raw}.{sig}", settings.control_token_ttl_sec
def verify_token(token: str, settings: ControlSettings) -> str:
try:
raw, sig = token.rsplit(".", 1)
except ValueError as e:
raise HTTPException(status_code=401, detail="invalid token") from e
expect = hmac.new(
settings.control_auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expect, sig):
raise HTTPException(status_code=401, detail="invalid token")
try:
payload = json.loads(_b64url_decode(raw))
except Exception as e:
raise HTTPException(status_code=401, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=401, detail="token expired")
if int(payload.get("v") or 0) != int(settings.control_auth_token_version):
raise HTTPException(status_code=401, detail="token revoked")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=401, detail="invalid token")
return username
def require_control_user(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> str:
if creds is None or not creds.credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return verify_token(creds.credentials, settings)
+46
View File
@@ -0,0 +1,46 @@
"""中控配置。"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
def _control_root() -> Path:
# control/backend/app/config.py -> control/
return Path(__file__).resolve().parents[2]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
class ControlSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=str(_repo_root() / ".env.control"),
env_file_encoding="utf-8",
extra="ignore",
)
control_auth_username: str = "admin"
control_auth_password: str = "admin123"
control_auth_secret: str = "change-me-control-secret-please"
control_auth_token_version: int = 1
control_token_ttl_sec: int = 7 * 24 * 3600
control_db_path: str = ""
control_poll_interval_sec: int = 8
control_http_timeout_sec: float = 12.0
control_port: int = 5160
@property
def db_path(self) -> Path:
if self.control_db_path.strip():
return Path(self.control_db_path)
return _control_root() / "data" / "control.db"
@lru_cache
def get_control_settings() -> ControlSettings:
return ControlSettings()
+42
View File
@@ -0,0 +1,42 @@
"""简易密封:无需 cryptography 依赖。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
def _keystream(key: bytes, n: int) -> bytes:
out = bytearray()
counter = 0
while len(out) < n:
block = hashlib.sha256(key + counter.to_bytes(8, "big")).digest()
out.extend(block)
counter += 1
return bytes(out[:n])
def seal(plaintext: str, secret: str) -> str:
raw = plaintext.encode("utf-8")
key = hashlib.sha256(secret.encode("utf-8")).digest()
iv = os.urandom(16)
stream = _keystream(key + iv, len(raw))
cipher = bytes(a ^ b for a, b in zip(raw, stream))
mac = hmac.new(key, iv + cipher, hashlib.sha256).digest()
return base64.urlsafe_b64encode(iv + mac + cipher).decode("ascii")
def unseal(blob: str, secret: str) -> str:
data = base64.urlsafe_b64decode(blob.encode("ascii"))
if len(data) < 16 + 32:
raise ValueError("invalid sealed blob")
iv, mac, cipher = data[:16], data[16:48], data[48:]
key = hashlib.sha256(secret.encode("utf-8")).digest()
expect = hmac.new(key, iv + cipher, hashlib.sha256).digest()
if not hmac.compare_digest(expect, mac):
raise ValueError("sealed blob mac mismatch")
stream = _keystream(key + iv, len(cipher))
raw = bytes(a ^ b for a, b in zip(cipher, stream))
return raw.decode("utf-8")
+119
View File
@@ -0,0 +1,119 @@
"""中控 SQLite。"""
from __future__ import annotations
from pathlib import Path
from threading import Lock
from typing import Any
from .config import get_control_settings
_SCHEMA = """
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL UNIQUE,
token_sealed TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
_db: "ControlDB | None" = None
_lock = Lock()
def set_control_db(db: "ControlDB | None") -> None:
global _db
_db = db
def get_control_db() -> "ControlDB":
if _db is None:
raise RuntimeError("control db not initialized")
return _db
class ControlDB:
def __init__(self, path: Path | None = None) -> None:
import sqlite3
import time
settings = get_control_settings()
self.path = path or settings.db_path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_SCHEMA)
self._conn.commit()
self._lock = Lock()
# touch
_ = time.time()
def close(self) -> None:
self._conn.close()
def list_nodes(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT id, name, base_url, token_sealed, created_at_ms, updated_at_ms FROM nodes ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def get_node(self, node_id: int) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute(
"SELECT id, name, base_url, token_sealed, created_at_ms, updated_at_ms FROM nodes WHERE id=?",
(node_id,),
).fetchone()
return dict(row) if row else None
def create_node(self, name: str, base_url: str) -> dict[str, Any]:
import time
now = int(time.time() * 1000)
name = name.strip()
base_url = base_url.strip().rstrip("/")
with self._lock:
cur = self._conn.execute(
"INSERT INTO nodes(name, base_url, token_sealed, created_at_ms, updated_at_ms) VALUES (?,?,?,?,?)",
(name, base_url, "", now, now),
)
self._conn.commit()
nid = int(cur.lastrowid)
return self.get_node(nid) # type: ignore[return-value]
def update_node(
self,
node_id: int,
*,
name: str | None = None,
base_url: str | None = None,
token_sealed: str | None = None,
) -> dict[str, Any] | None:
import time
node = self.get_node(node_id)
if not node:
return None
now = int(time.time() * 1000)
new_name = name.strip() if name is not None else node["name"]
new_url = base_url.strip().rstrip("/") if base_url is not None else node["base_url"]
new_tok = token_sealed if token_sealed is not None else node["token_sealed"]
with self._lock:
self._conn.execute(
"UPDATE nodes SET name=?, base_url=?, token_sealed=?, updated_at_ms=? WHERE id=?",
(new_name, new_url, new_tok, now, node_id),
)
self._conn.commit()
return self.get_node(node_id)
def delete_node(self, node_id: int) -> bool:
with self._lock:
cur = self._conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
self._conn.commit()
return cur.rowcount > 0
+121
View File
@@ -0,0 +1,121 @@
"""读写仓库根 .env.control(仅补缺,不覆盖已有值)。"""
from __future__ import annotations
import re
from pathlib import Path
from .config import _repo_root, get_control_settings
def env_control_path() -> Path:
return _repo_root() / ".env.control"
def _read_text(path: Path) -> str:
if not path.is_file():
return ""
return path.read_text(encoding="utf-8")
def _write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if text and not text.endswith("\n"):
text += "\n"
path.write_text(text, encoding="utf-8")
try:
path.chmod(0o600)
except Exception:
pass
def get_env_value(key: str, text: str | None = None) -> str | None:
raw = text if text is not None else _read_text(env_control_path())
m = re.search(rf"(?m)^{re.escape(key)}=(.*)$", raw)
if not m:
return None
val = m.group(1).strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
val = val[1:-1]
return val
def upsert_env_control(key: str, value: str, *, overwrite: bool = True) -> Path:
"""写入/更新单个键。overwrite=False 时若已有非空值则跳过。"""
if "\n" in value or "\r" in value:
raise ValueError(f"{key} 值不能包含换行")
path = env_control_path()
text = _read_text(path)
existing = get_env_value(key, text)
if not overwrite and existing is not None and existing.strip() != "":
return path
safe = value.replace("\\", "\\\\").replace('"', '\\"')
line = f'{key}="{safe}"'
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
if pattern.search(text):
if not overwrite:
return path
text = pattern.sub(line, text)
else:
if text and not text.endswith("\n"):
text += "\n"
text += line + "\n"
_write_text(path, text)
return path
# 一键部署默认项:仅在缺失或为空时写入
_DEPLOY_DEFAULTS: dict[str, str] = {
"CONTROL_AUTH_USERNAME": "admin",
"CONTROL_AUTH_PASSWORD": "admin123",
"CONTROL_AUTH_SECRET": "change-me-control-secret-please",
"CONTROL_TOKEN_TTL_SEC": "604800",
"CONTROL_POLL_INTERVAL_SEC": "8",
"CONTROL_HTTP_TIMEOUT_SEC": "12",
"CONTROL_AUTH_TOKEN_VERSION": "1",
}
def ensure_env_control_defaults() -> dict[str, bool]:
"""
确保 .env.control 存在且关键键有值。
已有非空值绝不覆盖。返回 {key: written?}。
"""
written: dict[str, bool] = {}
path = env_control_path()
before = _read_text(path)
for key, default in _DEPLOY_DEFAULTS.items():
old = get_env_value(key, before)
if old is not None and old.strip() != "":
written[key] = False
continue
upsert_env_control(key, default, overwrite=False)
# 若文件原先无该键,before 里也没有;重新读确认
after = get_env_value(key)
written[key] = after == default or (old is None or old.strip() == "")
get_control_settings.cache_clear()
return written
def update_control_credentials(
*,
new_username: str,
new_password: str,
bump_token_version: bool = True,
) -> None:
user = new_username.strip()
pwd = new_password
if not user or not pwd:
raise ValueError("用户名和密码不能为空")
if len(pwd) < 6:
raise ValueError("密码至少 6 位")
upsert_env_control("CONTROL_AUTH_USERNAME", user, overwrite=True)
upsert_env_control("CONTROL_AUTH_PASSWORD", pwd, overwrite=True)
if bump_token_version:
cur = get_env_value("CONTROL_AUTH_TOKEN_VERSION") or "1"
try:
ver = int(cur) + 1
except ValueError:
ver = 2
upsert_env_control("CONTROL_AUTH_TOKEN_VERSION", str(ver), overwrite=True)
get_control_settings.cache_clear()
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .api import router as api_router
from .config import get_control_settings
from .db import ControlDB, set_control_db
def resolve_frontend_dist() -> Path:
# control/backend/app -> control/frontend/dist
return Path(__file__).resolve().parents[2] / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
from .envfile import ensure_env_control_defaults
# 首次启动补全 .env.control 缺项(已有值不覆盖)
ensure_env_control_defaults()
db = ControlDB()
set_control_db(db)
yield
db.close()
set_control_db(None)
app = FastAPI(
title="比特骆驼中控",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health() -> dict:
s = get_control_settings()
return {"ok": True, "app": "control", "poll_interval_sec": s.control_poll_interval_sec}
_DIST = resolve_frontend_dist()
if (_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
@app.get("/")
@app.get("/monitor")
@app.get("/settings")
@app.get("/login")
async def spa(full_path: str = ""):
index = _DIST / "index.html"
if index.exists():
return FileResponse(index)
if Path(__file__).name:
return {
"ok": True,
"msg": "control frontend not built; cd control/frontend && npm ci && npm run build",
"health": "/health",
}
raise HTTPException(status_code=404, detail="frontend missing")
+73
View File
@@ -0,0 +1,73 @@
"""代理调用策略机。"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from .config import get_control_settings
from .crypto import unseal
logger = logging.getLogger(__name__)
def node_token_plain(node: dict[str, Any]) -> str | None:
sealed = (node.get("token_sealed") or "").strip()
if not sealed:
return None
secret = get_control_settings().control_auth_secret
try:
return unseal(sealed, secret)
except Exception:
logger.exception("unseal fleet token failed node=%s", node.get("id"))
return None
async def call_node(
node: dict[str, Any],
method: str,
path: str,
*,
require_token: bool = True,
json_body: dict | None = None,
) -> tuple[int, Any]:
settings = get_control_settings()
base = str(node["base_url"]).rstrip("/")
headers: dict[str, str] = {}
if require_token:
tok = node_token_plain(node)
if not tok:
return 400, {"detail": "该机尚未生成/保存中控 Token"}
headers["X-Fleet-Token"] = tok
url = f"{base}{path}"
timeout = settings.control_http_timeout_sec
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
res = await client.request(method, url, headers=headers, json=json_body)
try:
data = res.json()
except Exception:
data = {"detail": res.text[:500]}
return res.status_code, data
except Exception as e:
return 502, {"detail": f"连接失败: {e}"}
async def probe_health(node: dict[str, Any]) -> dict[str, Any]:
code, data = await call_node(node, "GET", "/health", require_token=False)
if code == 200 and isinstance(data, dict):
return {"online": True, "health": data, "error": None}
# fallback fleet status if health blocked
code2, data2 = await call_node(node, "GET", "/api/fleet/status", require_token=True)
if code2 == 200 and isinstance(data2, dict):
return {"online": True, "health": data2, "error": None}
detail = ""
if isinstance(data, dict):
detail = str(data.get("detail") or "")
return {
"online": False,
"health": None,
"error": detail or f"HTTP {code}",
}