modelbeast/server/main.py
MODELBEAST 810400fb60 Auth + dashboard (HANDOFF2 phases 1-2): guests local-only, system snapshot
Phase 1 — multi-user auth:
- server/auth.py: bcrypt passwords, itsdangerous signed-cookie sessions, sha256
  bearer tokens, FastAPI current_user/require_owner deps, login rate limit
- users + api_tokens tables + jobs/assets.user_id (additive migrations)
- HARD RULE enforced server-side: guests are local-only — /api/operators filters
  out requires_env operators, POST /api/jobs 403s cloud ops for non-owners (proven
  via direct POST in smoke.sh, not just UI). Settings owner-only. auth_secret
  hidden from the settings API. Per-user active-job cap (owner exempt). Own-asset/
  own-job checks. WS auth via cookie or ?token=. Owner bootstrap prints pw once.
- mb-ready: bearer MB_TOKEN; scripts/users.py for out-of-band management
- Frontend: Login gate, header user chip + logout, guest note, username on jobs,
  Users panel in Settings (owner)

Phase 2 — dashboard:
- server/sysinfo.py: psutil CPU/RAM/disk + macmon Apple GPU (util/power/temp, no
  sudo), computed lane occupancy, 24h job summary, recent jobs w/ output thumbs;
  all cached (5s stats, 5min du). /api/system + /api/jobs/recent.
- Dashboard.jsx: snapshot-on-refresh (no polling) — stat cards, per-core strip,
  lane strip, running/queued, recent grid.

tests/smoke.sh rewritten for auth: 28 checks passing incl. all guest-security
rules. Browser-verified owner + guest + dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:06:52 +10:00

373 lines
14 KiB
Python

import asyncio
import json
import secrets
from pathlib import Path
from fastapi import (Depends, FastAPI, File, HTTPException, Request, Response,
UploadFile, WebSocket, WebSocketDisconnect)
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import auth, db, registry, settings as settings_mod, store, sysinfo
from .runner import runner
app = FastAPI(title="MODELBEAST")
WEB_DIST = db.ROOT / "web" / "dist"
INBOX = db.DATA / "inbox"
MAX_UPLOAD = 1024 * 1024 * 1024 # 1 GB
@app.on_event("startup")
async def startup():
runner.operators = registry.load_operators()
con = db.connect()
app.state.con = con
runner.get_settings = lambda: settings_mod.get_all(con)
auth.ensure_auth_secret(con)
_bootstrap_owner(con)
asyncio.create_task(runner.worker())
asyncio.create_task(watch_inbox())
def _bootstrap_owner(con):
if con.execute("SELECT 1 FROM users LIMIT 1").fetchone():
return
pw = secrets.token_urlsafe(12)
auth.create_user(con, "monster", pw, role="owner", max_active_jobs=999)
banner = "=" * 62
print(f"\n{banner}\n MODELBEAST owner account created (first run):\n"
f" username: monster\n password: {pw}\n"
f" change it: MB_TOKEN=... mb user passwd monster (or the UI)\n{banner}\n",
flush=True)
def _con(request: Request):
return request.app.state.con
def _with_usernames(con, rows: list[dict]) -> list[dict]:
"""Attach a 'username' field to job/asset dicts for display."""
umap = {u["id"]: u["username"] for u in auth.list_users(con)}
for r in rows:
r["username"] = umap.get(r.get("user_id")) or ("" if r.get("user_id") else "owner")
return rows
# -- auth / session ------------------------------------------------------------
@app.post("/api/login")
async def login(payload: dict, request: Request, response: Response):
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() \
or (request.client.host if request.client else "?")
if not auth.rate_ok(ip):
raise HTTPException(429, "too many attempts — wait a minute")
con = _con(request)
username = (payload.get("username") or "").strip().lower()
password = payload.get("password") or ""
u = auth.get_user_by_name(con, username)
if not u or not auth.verify_pw(password, u["pw_hash"]):
auth.note_fail(ip)
raise HTTPException(401, "invalid username or password")
token = auth.make_session(con, u["id"])
secure = request.headers.get("x-forwarded-proto", "http") == "https"
response.set_cookie(auth.SESSION_COOKIE, token, max_age=auth.SESSION_MAX_AGE,
httponly=True, samesite="lax", secure=secure, path="/")
return auth.public_user(u)
@app.post("/api/logout")
async def logout(response: Response):
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
@app.get("/api/me")
def me(user=Depends(auth.current_user)):
return auth.public_user(user)
# -- users (owner only) --------------------------------------------------------
@app.get("/api/users")
def get_users(request: Request, owner=Depends(auth.require_owner)):
return auth.list_users(_con(request))
@app.post("/api/users")
def add_user(payload: dict, request: Request, owner=Depends(auth.require_owner)):
try:
u = auth.create_user(_con(request), payload.get("username", ""),
payload.get("password", ""), role=payload.get("role", "guest"),
max_active_jobs=int(payload.get("max_active_jobs", 4)))
except ValueError as e:
raise HTTPException(400, str(e))
return auth.public_user(u)
@app.patch("/api/users/{username}")
def patch_user(username: str, payload: dict, request: Request, owner=Depends(auth.require_owner)):
con = _con(request)
u = auth.get_user_by_name(con, username)
if not u:
raise HTTPException(404, "no such user")
if payload.get("password"):
try:
auth.set_password(con, u["id"], payload["password"])
except ValueError as e:
raise HTTPException(400, str(e))
if "max_active_jobs" in payload:
con.execute("UPDATE users SET max_active_jobs = ? WHERE id = ?",
(int(payload["max_active_jobs"]), u["id"]))
con.commit()
return auth.public_user(auth.get_user(con, u["id"]))
@app.delete("/api/users/{username}")
def del_user(username: str, request: Request, owner=Depends(auth.require_owner)):
con = _con(request)
u = auth.get_user_by_name(con, username)
if not u:
raise HTTPException(404, "no such user")
if u["role"] == "owner":
raise HTTPException(400, "cannot delete an owner")
auth.delete_user(con, u["id"])
return {"ok": True}
@app.post("/api/tokens")
def make_token(payload: dict, request: Request, owner=Depends(auth.require_owner)):
# owner mints a token for a user (default: self)
con = _con(request)
target = payload.get("username")
u = auth.get_user_by_name(con, target) if target else owner
if not u:
raise HTTPException(404, "no such user")
raw = auth.create_token(con, u["id"], payload.get("name", ""))
return {"token": raw, "username": u["username"]} # shown ONCE
# -- operators -----------------------------------------------------------------
@app.get("/api/operators")
def list_operators(user=Depends(auth.current_user)):
is_owner = user["role"] == "owner"
out = []
for op in runner.operators.values():
# guests are LOCAL-ONLY: cloud operators are filtered out entirely
if not is_owner and op.get("requires_env"):
continue
out.append({k: v for k, v in op.items() if k != "dir"})
return out
# -- settings (owner only) -----------------------------------------------------
@app.get("/api/settings")
def get_settings(request: Request, owner=Depends(auth.require_owner)):
s = settings_mod.get_all(_con(request))
view = settings_mod.public_view(s)
view["_secret_keys"] = sorted(settings_mod.SECRET_KEYS)
view["_env_keys"] = sorted(settings_mod.ENV_MAP.keys())
view["_env_set"] = sorted(env for key, env in settings_mod.ENV_MAP.items() if s.get(key))
return view
@app.put("/api/settings")
def put_settings(payload: dict, request: Request, owner=Depends(auth.require_owner)):
clean = {k: v for k, v in payload.items()
if not (k in settings_mod.SECRET_KEYS and v and set(str(v)) <= {""})}
settings_mod.set_many(_con(request), clean)
return get_settings(request, owner)
# -- assets --------------------------------------------------------------------
@app.get("/api/assets")
def list_assets(user=Depends(auth.current_user), request: Request = None):
con = _con(request)
return _with_usernames(con, store.list_assets(con))
@app.post("/api/assets")
async def upload_asset(request: Request, file: UploadFile = File(...),
user=Depends(auth.current_user)):
cl = request.headers.get("content-length")
if cl and int(cl) > MAX_UPLOAD:
raise HTTPException(413, "file too large (1 GB max)")
data = await file.read()
if not data:
raise HTTPException(400, "empty file")
if len(data) > MAX_UPLOAD:
raise HTTPException(413, "file too large (1 GB max)")
return store.register_upload(_con(request), file.filename or "unnamed", data, user_id=user["id"])
@app.delete("/api/assets/{asset_id}")
def delete_asset(asset_id: str, user=Depends(auth.current_user), request: Request = None):
con = _con(request)
asset = store.get_asset(con, asset_id)
if not asset:
raise HTTPException(404, "no such asset")
if user["role"] != "owner" and asset.get("user_id") != user["id"]:
raise HTTPException(403, "not your asset")
store.delete_asset(con, asset_id)
return {"ok": True}
@app.get("/api/assets/{asset_id}/file")
def asset_file(asset_id: str, member: str | None = None,
user=Depends(auth.current_user), request: Request = None):
asset = store.get_asset(_con(request), asset_id)
if not asset:
raise HTTPException(404, "no such asset")
path = Path(asset["path"])
if member:
target = (path / member).resolve()
if not str(target).startswith(str(path.resolve())) or not target.is_file():
raise HTTPException(404, "no such member")
return FileResponse(target)
if path.is_dir():
files = sorted(p.name for p in path.iterdir() if p.is_file())
return JSONResponse({"folder": True, "files": files})
return FileResponse(path, filename=asset["name"])
# -- jobs ----------------------------------------------------------------------
@app.get("/api/jobs")
def list_jobs(user=Depends(auth.current_user), request: Request = None):
con = _con(request)
return _with_usernames(con, runner.list_jobs(con))
@app.get("/api/jobs/recent")
def recent_jobs(user=Depends(auth.current_user), request: Request = None):
con = _con(request)
return sysinfo.recent_jobs(con, runner)
@app.post("/api/jobs")
async def create_job(payload: dict, user=Depends(auth.current_user), request: Request = None):
con = _con(request)
operator = payload.get("operator")
if operator not in runner.operators:
raise HTTPException(400, f"unknown operator: {operator}")
op = runner.operators[operator]
# HARD RULE: guests run local models only — enforced here regardless of UI
if op.get("requires_env") and user["role"] != "owner":
raise HTTPException(403, "guests run local models only")
if user["role"] != "owner" and runner.active_job_count(con, user["id"]) >= user["max_active_jobs"]:
raise HTTPException(429, "too many active jobs — wait for some to finish")
asset_ids = payload.get("asset_ids")
if not asset_ids:
asset_ids = [payload["asset_id"]] if payload.get("asset_id") else []
for aid in asset_ids:
if not store.get_asset(con, aid):
raise HTTPException(400, f"unknown asset: {aid}")
params = payload.get("params") or {}
job = runner.create_job(con, operator, asset_ids, params, user_id=user["id"])
await runner.broadcast({"type": "job", "job": job})
return job
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
job = runner.get_job(_con(request), job_id)
if not job:
raise HTTPException(404, "no such job")
return job
def _own_job_or_owner(con, job_id, user):
job = runner.get_job(con, job_id)
if not job:
raise HTTPException(404, "no such job")
if user["role"] != "owner" and job.get("user_id") != user["id"]:
raise HTTPException(403, "not your job")
return job
@app.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
con = _con(request)
_own_job_or_owner(con, job_id, user)
if not await runner.cancel(con, job_id):
raise HTTPException(400, "job not cancellable")
return {"ok": True}
@app.post("/api/jobs/{job_id}/retry")
async def retry_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
con = _con(request)
job = _own_job_or_owner(con, job_id, user)
op = runner.operators.get(job["operator"], {})
if op.get("requires_env") and user["role"] != "owner":
raise HTTPException(403, "guests run local models only")
new = runner.create_job(con, job["operator"], job.get("asset_ids") or [],
job["params"], user_id=user["id"])
await runner.broadcast({"type": "job", "job": new})
return new
@app.delete("/api/jobs/{job_id}")
def delete_job(job_id: str, user=Depends(auth.current_user), request: Request = None):
con = _con(request)
_own_job_or_owner(con, job_id, user)
if not runner.delete_job(con, job_id):
raise HTTPException(400, "job running or missing")
return {"ok": True}
# -- system / dashboard --------------------------------------------------------
@app.get("/api/system")
def system(user=Depends(auth.current_user), request: Request = None):
return sysinfo.snapshot(_con(request), runner)
# -- inbox watch folder --------------------------------------------------------
async def watch_inbox():
INBOX.mkdir(parents=True, exist_ok=True)
seen: dict[str, int] = {}
while True:
await asyncio.sleep(3)
try:
for p in INBOX.iterdir():
if p.name.startswith(".") or not p.exists():
continue
size = p.stat().st_size if p.is_file() else sum(
f.stat().st_size for f in p.rglob("*") if f.is_file())
if seen.get(str(p)) == size and size > 0:
asset = store.register_file(app.state.con, p, move=True,
meta={"source": "inbox"})
seen.pop(str(p), None)
await runner.broadcast({"type": "assets_changed", "assets": [asset["id"]]})
else:
seen[str(p)] = size
except Exception as e:
print(f"[inbox] {e}")
# -- websocket (auth: session cookie or ?token=) -------------------------------
@app.websocket("/ws")
async def ws(websocket: WebSocket):
con = websocket.app.state.con
user = None
tok = websocket.cookies.get(auth.SESSION_COOKIE)
if tok:
uid = auth.read_session(con, tok)
if uid:
user = auth.get_user(con, uid)
if not user:
qtok = websocket.query_params.get("token")
if qtok:
user = auth.user_by_token(con, qtok)
if not user:
await websocket.close(code=1008) # policy violation
return
await websocket.accept()
runner.subscribers.add(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
runner.subscribers.discard(websocket)
# -- frontend (built) — public so the SPA can load and show the login screen ----
if WEB_DIST.exists():
app.mount("/", StaticFiles(directory=WEB_DIST, html=True), name="web")