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:
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user