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 @@
|
||||
# 使 `uvicorn app.main:app` 在 control/backend 下可运行
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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}",
|
||||
}
|
||||
Reference in New Issue
Block a user