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
+2
View File
@@ -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)
+22
View File
@@ -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,
+273
View File
@@ -0,0 +1,273 @@
"""中控(Fleet)专用 APIX-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,请稍后探活",
}
+1
View File
@@ -218,6 +218,7 @@ async def index_page():
@app.get("/stats")
@app.get("/settings")
@app.get("/login")
@app.get("/fleet-login")
async def spa_pages(full_path: str = ""):
index = _DIST / "index.html"
if not index.exists():
+48
View File
@@ -0,0 +1,48 @@
"""Fleet token / ticket unit tests."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from app.api.fleet import (
clear_fleet_token,
consume_login_ticket,
create_login_ticket,
fleet_token_configured,
require_fleet_token,
set_fleet_token,
)
from app.models.db import Database, set_db
@pytest.fixture()
def db(tmp_path, monkeypatch):
monkeypatch.setenv("MODE", "SIM")
path = tmp_path / "t.db"
d = Database(path)
set_db(d)
yield d
d.close()
set_db(None)
def test_fleet_token_hash_and_auth(db):
assert not fleet_token_configured(db)
set_fleet_token("test-fleet-token-32chars-xxxx", db)
assert fleet_token_configured(db)
assert require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
with pytest.raises(HTTPException) as ei:
require_fleet_token(x_fleet_token="wrong-token-xxxxxxxxxx")
assert ei.value.status_code == 401
clear_fleet_token(db)
with pytest.raises(HTTPException):
require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
def test_login_ticket_once(db):
t, ttl = create_login_ticket("admin")
assert ttl >= 30
assert consume_login_ticket(t) == "admin"
with pytest.raises(HTTPException):
consume_login_ticket(t)