ON_OFF Phase 0-3: service-controller (.13:8443, token-gated, containers+systemd-user, audit log) + portal toggle cards (live status, per-user who, failed token, RAM) — full E2E verified
This commit is contained in:
104
portal/main.py
104
portal/main.py
@@ -679,4 +679,106 @@ async def api_status(request: Request) -> HTMLResponse:
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
return {"ok": True, "app": settings.APP_NAME}
|
||||
return {"ok": True, "app": settings.APP_NAME}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ON/OFF control (ON_OFF.md) — live status + start/stop via the host-side
|
||||
# service-controller (.13:8443, token-gated, firewall-allowed port). No docker access here.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SC_HEADERS = None
|
||||
SC_CACHE: dict = {"at": 0.0, "data": None}
|
||||
_SC_URL = settings.SC_URL.rstrip("/")
|
||||
|
||||
|
||||
def _sc_headers() -> dict:
|
||||
global SC_HEADERS
|
||||
if SC_HEADERS is None:
|
||||
SC_HEADERS = {"X-Controller-Token": settings.SC_TOKEN}
|
||||
return SC_HEADERS
|
||||
|
||||
|
||||
async def _sc_get(path: str) -> dict | None:
|
||||
"""GET the controller. Returns None on any failure (graceful degrade)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
r = await client.get(f"{_SC_URL}{path}", headers=_sc_headers())
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return r.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _sc_post(path: str, who: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{_SC_URL}{path}",
|
||||
headers={**_sc_headers(), "Who": who},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return r.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _live_services() -> list[dict]:
|
||||
"""Controller service list, lightly cached (3s) so the dashboard doesn't
|
||||
hammer it on HTMX polls."""
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
if SC_CACHE["data"] is not None and now - SC_CACHE["at"] < 3.0:
|
||||
return SC_CACHE["data"]
|
||||
data = await _sc_get("/services")
|
||||
if data is None:
|
||||
return SC_CACHE["data"] or []
|
||||
SC_CACHE.update(at=now, data=data.get("services", []))
|
||||
return SC_CACHE["data"]
|
||||
|
||||
|
||||
def _can_toggle(user, svc: dict) -> bool:
|
||||
"""Admin, or the user is on the service's allowlist (`who`)."""
|
||||
if user.is_admin:
|
||||
return True
|
||||
who = svc.get("who") or []
|
||||
return user.username in who
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
async def api_services(request: Request) -> dict:
|
||||
"""Live service list (allowlist + current state) for the o/o cards."""
|
||||
user = await _current_user(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401)
|
||||
svcs = await _live_services()
|
||||
out = []
|
||||
for s in svcs:
|
||||
out.append({
|
||||
**s,
|
||||
"can_toggle": _can_toggle(user, s),
|
||||
"ram_mb": s.get("ram_mb"),
|
||||
})
|
||||
return {"ok": True, "services": out}
|
||||
|
||||
|
||||
@app.post("/service/{sid}/{action}")
|
||||
async def service_toggle(sid: str, action: str, request: Request) -> dict:
|
||||
"""Start/stop a service. Admin or allowlisted user; audited controller-side."""
|
||||
user = await _current_user(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401)
|
||||
if action not in ("start", "stop"):
|
||||
raise HTTPException(status_code=400)
|
||||
svcs = await _live_services()
|
||||
svc = next((s for s in svcs if s["id"] == sid), None)
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=404, detail="unknown service")
|
||||
if not _can_toggle(user, svc):
|
||||
raise HTTPException(status_code=403, detail="not allowed")
|
||||
res = await _sc_post(f"/services/{sid}/{action}", who=user.username)
|
||||
if res is None:
|
||||
raise HTTPException(status_code=502, detail="service-controller unreachable")
|
||||
SC_CACHE.update(at=0.0) # force refresh next poll
|
||||
return res
|
||||
Reference in New Issue
Block a user