"""HTTP + WebSocket 接口。前端静态文件也由这里托管(同源,门户 vhost 直接反代到本进程)。 身份:统一门户在 vhost 里**覆盖式**注入 `X-Auth-User`(gen-vhosts.py),本进程只绑 127.0.0.1, 所以这个头只可能来自门户 —— 与 evse 一样按 L2 信任它,用来记审计「是谁按的」。 直接在服务器本机 curl(没有这个头)记作 `local`。 ⚠️ 这个信任**只在绑 127.0.0.1 时成立**。哪天改绑 0.0.0.0,外面加一个 `X-Auth-User: admin` 就能冒名写审计 —— 所以 main.py 拒绝非回环地址的绑定。 """ import asyncio import json from contextlib import asynccontextmanager from typing import Awaitable, Callable, List, Optional from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from . import protocol from .db import now_ms from .hub import CmdTimeout, Hub from .recovery import Params, RecoveryManager def _user(req: Request) -> str: return req.headers.get("x-auth-user") or "local" def _board_or_404(hub: Hub, board: str): if board not in hub.latest: raise HTTPException(404, "没有这块板子:%s" % board) Hook = Callable[[], Awaitable[None]] def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None, startup: Optional[List[Hook]] = None, shutdown: Optional[List[Hook]] = None) -> FastAPI: # 用 lifespan 而不是 on_event:后者在新版 Starlette 里已经删了,服务器上的旧版两种都认。 @asynccontextmanager async def lifespan(app): for fn in startup or []: await fn() yield for fn in shutdown or []: await fn() app = FastAPI(title="PhoneRail 机架管理", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan) @app.get("/api/health") def health(): return {"ok": True, "mqtt": hub.mqtt_up, "boards": len(hub.latest), "ts": now_ms()} @app.get("/api/me") def me(req: Request): return {"user": _user(req), "roles": req.headers.get("x-auth-roles", "")} # ---- 板子 ---------------------------------------------------------------- @app.get("/api/boards") def boards(): return hub.boards() @app.get("/api/boards/{board}") def board(board: str): _board_or_404(hub, board) return hub.snapshot(board) @app.patch("/api/boards/{board}") async def patch_board(board: str, req: Request): _board_or_404(hub, board) body = await req.json() for k in ("name", "location"): if k in body: hub.db.x("UPDATE boards SET %s=? WHERE id=?" % k, (str(body[k])[:64], board)) snap = hub.snapshot(board) hub.broadcast({"t": "board", "board": snap}) return snap @app.patch("/api/boards/{board}/rails/{rail}") async def patch_phone(board: str, rail: int, req: Request): _board_or_404(hub, board) if not 1 <= rail <= protocol.RAILS: raise HTTPException(400, "rail 必须是 1~5") body = await req.json() for k in ("label", "adb_addr", "notes"): if k in body: hub.db.x("UPDATE phones SET %s=? WHERE board_id=? AND rail=?" % k, (str(body[k])[:128], board, rail)) if "auto_recover" in body: hub.db.x("UPDATE phones SET auto_recover=? WHERE board_id=? AND rail=?", (1 if body["auto_recover"] else 0, board, rail)) rec.reload() snap = hub.snapshot(board) hub.broadcast({"t": "board", "board": snap}) return snap @app.post("/api/boards/{board}/cmd") async def command(board: str, req: Request): _board_or_404(hub, board) try: cmd = protocol.build_command(await req.json()) except (protocol.CmdError, ValueError) as e: raise HTTPException(400, str(e)) try: return await hub.call(board, cmd, _user(req)) except CmdTimeout as e: raise HTTPException(504, str(e)) @app.get("/api/boards/{board}/rails/{rail}/history") def history(board: str, rail: int, start: int, end: Optional[int] = None): _board_or_404(hub, board) return hub.db.history(board, rail, start, end or now_ms()) # ---- 事件 / 审计 / 恢复 ---------------------------------------------------- @app.get("/api/events") def events(board: Optional[str] = None, limit: int = 200, before: Optional[int] = None): sql = "SELECT * FROM events WHERE 1=1" args = [] if board: sql += " AND board_id=?" args.append(board) if before: sql += " AND received