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:
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from .auth_routes import router as auth_router
|
||||
from .backup_routes import router as backup_router
|
||||
from .fleet import router as fleet_router
|
||||
from .funds import router as funds_router
|
||||
from .market import router as market_router
|
||||
from .plan import router as plan_router
|
||||
@@ -20,3 +21,4 @@ router.include_router(stats_router)
|
||||
router.include_router(funds_router)
|
||||
router.include_router(settings_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(fleet_router)
|
||||
|
||||
@@ -11,9 +11,14 @@ from pydantic import BaseModel, Field
|
||||
from ..config import Settings, get_settings
|
||||
from ..credentials import get_credentials, update_credentials, upsert_env_file
|
||||
from .auth import LoginRequest, LoginResponse, issue_token, require_user
|
||||
from .fleet import consume_login_ticket
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class FleetExchangeRequest(BaseModel):
|
||||
ticket: str = Field(min_length=8, max_length=256)
|
||||
|
||||
_login_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
@@ -45,6 +50,23 @@ def _rate_limit_login(ip: str, settings: Settings) -> None:
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fleet-exchange", response_model=LoginResponse)
|
||||
async def fleet_exchange(
|
||||
body: FleetExchangeRequest,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> LoginResponse:
|
||||
"""中控签发的一次性 ticket 兑换为普通登录会话(免密)。"""
|
||||
username = consume_login_ticket(body.ticket)
|
||||
token, ttl = issue_token(username, settings)
|
||||
return LoginResponse(
|
||||
token=token,
|
||||
username=username,
|
||||
expires_in=ttl,
|
||||
env_name=settings.env_name,
|
||||
mode=settings.mode,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""中控(Fleet)专用 API:X-Fleet-Token 鉴权,不开放资金/下单。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
from ..credentials import get_credentials
|
||||
from ..models.db import get_db
|
||||
from ..strategy import get_engine
|
||||
from .auth import require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/fleet", tags=["fleet"])
|
||||
|
||||
_SETTING_HASH = "fleet_api_token_hash"
|
||||
_TICKET_TTL_SEC = 60
|
||||
_tickets: dict[str, dict] = {}
|
||||
_tickets_lock = threading.Lock()
|
||||
_update_lock = threading.Lock()
|
||||
_update_state: dict = {"running": False, "started_at_ms": 0, "last_error": ""}
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fleet_token_configured(db=None) -> bool:
|
||||
db = db or get_db()
|
||||
h = (db.get_setting(_SETTING_HASH, "") or "").strip()
|
||||
return bool(h)
|
||||
|
||||
|
||||
def set_fleet_token(plain: str, db=None) -> None:
|
||||
db = db or get_db()
|
||||
plain = (plain or "").strip()
|
||||
if not plain:
|
||||
db.set_setting(_SETTING_HASH, "")
|
||||
return
|
||||
if len(plain) < 16:
|
||||
raise ValueError("中控 API Token 至少 16 位")
|
||||
db.set_setting(_SETTING_HASH, _hash_token(plain))
|
||||
|
||||
|
||||
def clear_fleet_token(db=None) -> None:
|
||||
set_fleet_token("", db)
|
||||
|
||||
|
||||
def require_fleet_token(
|
||||
x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None,
|
||||
) -> str:
|
||||
db = get_db()
|
||||
stored = (db.get_setting(_SETTING_HASH, "") or "").strip()
|
||||
if not stored:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="策略机未配置中控 API Token",
|
||||
)
|
||||
provided = (x_fleet_token or "").strip()
|
||||
if not provided or not hmac.compare_digest(stored, _hash_token(provided)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid fleet token",
|
||||
)
|
||||
return provided
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _purge_tickets() -> None:
|
||||
now = time.time()
|
||||
dead = [k for k, v in _tickets.items() if float(v.get("exp", 0)) < now]
|
||||
for k in dead:
|
||||
_tickets.pop(k, None)
|
||||
|
||||
|
||||
def create_login_ticket(username: str) -> tuple[str, int]:
|
||||
with _tickets_lock:
|
||||
_purge_tickets()
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
_tickets[ticket] = {"exp": time.time() + _TICKET_TTL_SEC, "u": username}
|
||||
return ticket, _TICKET_TTL_SEC
|
||||
|
||||
|
||||
def consume_login_ticket(ticket: str) -> str:
|
||||
ticket = (ticket or "").strip()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=401, detail="invalid ticket")
|
||||
with _tickets_lock:
|
||||
_purge_tickets()
|
||||
meta = _tickets.pop(ticket, None)
|
||||
if not meta:
|
||||
raise HTTPException(status_code=401, detail="ticket invalid or used")
|
||||
if float(meta.get("exp", 0)) < time.time():
|
||||
raise HTTPException(status_code=401, detail="ticket expired")
|
||||
username = str(meta.get("u") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="invalid ticket")
|
||||
return username
|
||||
|
||||
|
||||
class FleetTokenBody(BaseModel):
|
||||
token: str = Field(default="", max_length=256)
|
||||
|
||||
|
||||
@router.get("/meta")
|
||||
async def fleet_meta(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
return {
|
||||
"configured": fleet_token_configured(),
|
||||
"hint": "在中控生成 Token 后粘贴到此保存;用于远程启停、更新与免密登录。",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/token")
|
||||
async def put_fleet_token(
|
||||
body: FleetTokenBody,
|
||||
_user: Annotated[str, Depends(require_user)],
|
||||
) -> dict:
|
||||
try:
|
||||
set_fleet_token(body.token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"ok": True, "configured": fleet_token_configured()}
|
||||
|
||||
|
||||
@router.delete("/token")
|
||||
async def delete_fleet_token(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||
clear_fleet_token()
|
||||
return {"ok": True, "configured": False}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
settings = get_settings()
|
||||
try:
|
||||
from ..exchange.runtime import load_runtime_settings
|
||||
from ..strategy.session import get_session
|
||||
|
||||
rt = load_runtime_settings()
|
||||
exchange_name = rt.exchange
|
||||
sess = get_session()
|
||||
snap = sess.snapshot() if sess else None
|
||||
except Exception:
|
||||
exchange_name = settings.exchange
|
||||
snap = None
|
||||
try:
|
||||
st = get_engine().state()
|
||||
except Exception:
|
||||
st = {}
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": settings.mode,
|
||||
"env_name": settings.env_name,
|
||||
"exchange": exchange_name,
|
||||
"sim": settings.is_sim,
|
||||
"market_connected": bool(snap.connected) if snap else False,
|
||||
"pair": snap.pair.to_dict() if snap and snap.pair else None,
|
||||
"updated_at_ms": snap.updated_at_ms if snap else None,
|
||||
"strategy": {
|
||||
"running": st.get("running"),
|
||||
"phase": st.get("phase"),
|
||||
"rounds_done": st.get("rounds_done"),
|
||||
"last_error": st.get("last_error"),
|
||||
"group_id": st.get("group_id"),
|
||||
},
|
||||
"update": {
|
||||
"running": bool(_update_state.get("running")),
|
||||
"started_at_ms": _update_state.get("started_at_ms") or 0,
|
||||
"last_error": _update_state.get("last_error") or "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def fleet_start(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
return await get_engine().start()
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
async def fleet_pause(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
return await get_engine().pause()
|
||||
|
||||
|
||||
@router.post("/issue-login")
|
||||
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
username, _ = get_credentials()
|
||||
ticket, ttl = create_login_ticket(username)
|
||||
return {
|
||||
"ok": True,
|
||||
"ticket": ticket,
|
||||
"expires_in": ttl,
|
||||
"login_path": f"/fleet-login?ticket={ticket}",
|
||||
}
|
||||
|
||||
|
||||
def _run_update_job() -> None:
|
||||
root = _repo_root()
|
||||
script = root / "deploy" / "lib" / "update.sh"
|
||||
if not script.is_file():
|
||||
script = root / "deploy" / "pull_and_restart.sh"
|
||||
try:
|
||||
if os.name == "nt":
|
||||
_update_state["last_error"] = "update script requires bash (Linux deploy host)"
|
||||
logger.error("fleet update skipped: not a Linux deploy host")
|
||||
return
|
||||
if not script.is_file():
|
||||
_update_state["last_error"] = f"update script missing: {script}"
|
||||
logger.error("fleet update: %s", _update_state["last_error"])
|
||||
return
|
||||
logger.info("fleet update starting: %s", script)
|
||||
proc = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "")[-2000:]
|
||||
_update_state["last_error"] = f"exit={proc.returncode} {err}"
|
||||
logger.error("fleet update failed: %s", _update_state["last_error"])
|
||||
else:
|
||||
_update_state["last_error"] = ""
|
||||
logger.info("fleet update finished ok")
|
||||
except Exception as e:
|
||||
_update_state["last_error"] = str(e)
|
||||
logger.exception("fleet update exception")
|
||||
finally:
|
||||
_update_state["running"] = False
|
||||
|
||||
|
||||
@router.post("/update")
|
||||
async def fleet_update(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||
"""接受更新请求:后台跑 deploy update(会 reload 本进程)。"""
|
||||
with _update_lock:
|
||||
if _update_state.get("running"):
|
||||
return {
|
||||
"ok": True,
|
||||
"accepted": False,
|
||||
"running": True,
|
||||
"msg": "更新已在进行中",
|
||||
}
|
||||
_update_state["running"] = True
|
||||
_update_state["started_at_ms"] = int(time.time() * 1000)
|
||||
_update_state["last_error"] = ""
|
||||
|
||||
def _deferred() -> None:
|
||||
time.sleep(0.8)
|
||||
_run_update_job()
|
||||
|
||||
threading.Thread(target=_deferred, name="fleet-update", daemon=True).start()
|
||||
return {
|
||||
"ok": True,
|
||||
"accepted": True,
|
||||
"running": True,
|
||||
"msg": "已接受更新,进程即将 reload,请稍后探活",
|
||||
}
|
||||
Reference in New Issue
Block a user