portal: modularize backend and add request code status

This commit is contained in:
Brad Stein 2026-01-01 23:17:19 -03:00
parent c22c27b8aa
commit 8ef4198646
17 changed files with 1250 additions and 922 deletions

View File

@ -1,922 +1,7 @@
from __future__ import annotations
import json
import os
import re
import time
from functools import wraps
from pathlib import Path
import secrets
import string
from typing import Any
from urllib.error import URLError
from urllib.parse import quote
from urllib.parse import urlencode
from urllib.request import urlopen
from atlas_portal.app_factory import create_app
from flask import Flask, g, jsonify, request, send_from_directory
from flask_cors import CORS
import httpx
import jwt
from jwt import PyJWKClient
from werkzeug.middleware.proxy_fix import ProxyFix
app = create_app()
app = Flask(__name__, static_folder="../frontend/dist", static_url_path="")
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
CORS(app, resources={r"/api/*": {"origins": "*"}})
MONERO_GET_INFO_URL = os.getenv("MONERO_GET_INFO_URL", "http://monerod.crypto.svc.cluster.local:18081/get_info")
VM_BASE_URL = os.getenv(
"VM_BASE_URL",
"http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428",
).rstrip("/")
VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2"))
HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2"))
LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30"))
GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health")
OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics")
AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/")
AI_CHAT_MODEL = os.getenv("AI_CHAT_MODEL", "qwen2.5-coder:7b-instruct-q4_0")
AI_CHAT_SYSTEM_PROMPT = os.getenv(
"AI_CHAT_SYSTEM_PROMPT",
"You are the Titan Lab assistant for bstein.dev. Be concise and helpful.",
)
AI_CHAT_TIMEOUT_SEC = float(os.getenv("AI_CHAT_TIMEOUT_SEC", "20"))
AI_NODE_NAME = os.getenv("AI_CHAT_NODE_NAME") or os.getenv("AI_NODE_NAME") or "ai-cluster"
AI_GPU_DESC = os.getenv("AI_CHAT_GPU_DESC") or "local GPU (dynamic)"
AI_PUBLIC_ENDPOINT = os.getenv("AI_PUBLIC_CHAT_ENDPOINT", "https://chat.ai.bstein.dev/api/chat")
AI_K8S_LABEL = os.getenv("AI_K8S_LABEL", "app=ollama")
AI_K8S_NAMESPACE = os.getenv("AI_K8S_NAMESPACE", "ai")
AI_MODEL_ANNOTATION = os.getenv("AI_MODEL_ANNOTATION", "ai.bstein.dev/model")
AI_GPU_ANNOTATION = os.getenv("AI_GPU_ANNOTATION", "ai.bstein.dev/gpu")
AI_WARM_INTERVAL_SEC = float(os.getenv("AI_WARM_INTERVAL_SEC", "300"))
AI_WARM_ENABLED = os.getenv("AI_WARM_ENABLED", "true").lower() in ("1", "true", "yes")
KEYCLOAK_ENABLED = os.getenv("KEYCLOAK_ENABLED", "false").lower() in ("1", "true", "yes")
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/")
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "atlas")
KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "bstein-dev-home")
KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}").rstrip("/")
KEYCLOAK_JWKS_URL = os.getenv("KEYCLOAK_JWKS_URL", f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs").rstrip("/")
KEYCLOAK_ADMIN_URL = os.getenv("KEYCLOAK_ADMIN_URL", KEYCLOAK_URL).rstrip("/")
KEYCLOAK_ADMIN_CLIENT_ID = os.getenv("KEYCLOAK_ADMIN_CLIENT_ID", "")
KEYCLOAK_ADMIN_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "")
KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
ACCOUNT_ALLOWED_GROUPS = [
g.strip()
for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",")
if g.strip()
]
PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()]
PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()]
ACCESS_REQUEST_ENABLED = os.getenv("ACCESS_REQUEST_ENABLED", "true").lower() in ("1", "true", "yes")
ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5"))
ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60)))
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv(
"MAILU_SYNC_URL",
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
).rstrip("/")
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None
_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0}
_KEYCLOAK_GROUP_ID_CACHE: dict[str, str] = {}
_ACCESS_REQUEST_RATE: dict[str, list[float]] = {}
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
@app.route("/api/healthz")
def healthz() -> Any:
return jsonify({"ok": True})
def _normalize_groups(groups: Any) -> list[str]:
if not isinstance(groups, list):
return []
cleaned: list[str] = []
for gname in groups:
if not isinstance(gname, str):
continue
cleaned.append(gname.lstrip("/"))
return [gname for gname in cleaned if gname]
def _extract_bearer_token() -> str | None:
header = request.headers.get("Authorization", "")
if not header:
return None
parts = header.split(None, 1)
if len(parts) != 2:
return None
scheme, token = parts[0].lower(), parts[1].strip()
if scheme != "bearer" or not token:
return None
return token
def _get_jwk_client() -> PyJWKClient:
global _KEYCLOAK_JWK_CLIENT
if _KEYCLOAK_JWK_CLIENT is None:
_KEYCLOAK_JWK_CLIENT = PyJWKClient(KEYCLOAK_JWKS_URL)
return _KEYCLOAK_JWK_CLIENT
def _verify_keycloak_token(token: str) -> dict[str, Any]:
if not KEYCLOAK_ENABLED:
raise ValueError("keycloak not enabled")
signing_key = _get_jwk_client().get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
options={"verify_aud": False},
issuer=KEYCLOAK_ISSUER,
)
# Ensure this token was minted for our frontend client (defense-in-depth).
azp = claims.get("azp")
aud = claims.get("aud")
aud_list: list[str] = []
if isinstance(aud, str):
aud_list = [aud]
elif isinstance(aud, list):
aud_list = [a for a in aud if isinstance(a, str)]
if azp != KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_ID not in aud_list:
raise ValueError("token not issued for this client")
return claims
def require_auth(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
token = _extract_bearer_token()
if not token:
return jsonify({"error": "missing bearer token"}), 401
try:
claims = _verify_keycloak_token(token)
except Exception:
return jsonify({"error": "invalid token"}), 401
g.keycloak_claims = claims
g.keycloak_username = claims.get("preferred_username") or ""
g.keycloak_email = claims.get("email") or ""
g.keycloak_groups = _normalize_groups(claims.get("groups"))
return fn(*args, **kwargs)
return wrapper
def _require_portal_admin() -> tuple[bool, Any]:
if not KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503)
username = getattr(g, "keycloak_username", "") or ""
groups = set(getattr(g, "keycloak_groups", []) or [])
if username and username in PORTAL_ADMIN_USERS:
return True, None
if PORTAL_ADMIN_GROUPS and groups.intersection(PORTAL_ADMIN_GROUPS):
return True, None
return False, (jsonify({"error": "forbidden"}), 403)
@app.route("/api/auth/config", methods=["GET"])
def auth_config() -> Any:
if not KEYCLOAK_ENABLED:
return jsonify({"enabled": False})
issuer = KEYCLOAK_ISSUER
public_origin = request.host_url.rstrip("/")
redirect_uri = quote(f"{public_origin}/", safe="")
login_url = (
f"{issuer}/protocol/openid-connect/auth"
f"?client_id={quote(KEYCLOAK_CLIENT_ID, safe='')}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope=openid"
)
return jsonify(
{
"enabled": True,
"url": KEYCLOAK_URL,
"realm": KEYCLOAK_REALM,
"client_id": KEYCLOAK_CLIENT_ID,
"login_url": login_url,
"reset_url": login_url,
}
)
def _require_account_access() -> tuple[bool, Any]:
if not KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503)
if not ACCOUNT_ALLOWED_GROUPS:
return True, None
groups = set(getattr(g, "keycloak_groups", []) or [])
if groups.intersection(ACCOUNT_ALLOWED_GROUPS):
return True, None
return False, (jsonify({"error": "forbidden"}), 403)
def _kc_admin_ready() -> bool:
return bool(KEYCLOAK_ADMIN_CLIENT_ID and KEYCLOAK_ADMIN_CLIENT_SECRET)
def _kc_admin_get_token() -> str:
if not _kc_admin_ready():
raise RuntimeError("keycloak admin client not configured")
now = time.time()
cached = _KEYCLOAK_ADMIN_TOKEN.get("token")
expires_at = float(_KEYCLOAK_ADMIN_TOKEN.get("expires_at") or 0.0)
if cached and now < expires_at - 30:
return str(cached)
token_url = f"{KEYCLOAK_ADMIN_URL}/realms/{KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token"
data = {
"grant_type": "client_credentials",
"client_id": KEYCLOAK_ADMIN_CLIENT_ID,
"client_secret": KEYCLOAK_ADMIN_CLIENT_SECRET,
}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(token_url, data=data)
resp.raise_for_status()
payload = resp.json()
token = payload.get("access_token") or ""
if not token:
raise RuntimeError("no access_token in response")
expires_in = int(payload.get("expires_in") or 60)
_KEYCLOAK_ADMIN_TOKEN["token"] = token
_KEYCLOAK_ADMIN_TOKEN["expires_at"] = now + expires_in
return token
def _kc_admin_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {_kc_admin_get_token()}"}
def _kc_admin_find_user(username: str) -> dict[str, Any] | None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
params = {"username": username, "exact": "true"}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=_kc_admin_headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list) or not users:
return None
# Keycloak returns a list even with exact=true; use first match.
user = users[0]
return user if isinstance(user, dict) else None
def _kc_admin_get_user(user_id: str) -> dict[str, Any]:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, headers=_kc_admin_headers())
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError("unexpected user payload")
return data
def _kc_admin_update_user(user_id: str, payload: dict[str, Any]) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
def _kc_set_user_attribute(username: str, key: str, value: str) -> None:
user = _kc_admin_find_user(username)
if not user:
raise RuntimeError("user not found")
user_id = user.get("id") or ""
if not user_id:
raise RuntimeError("user id missing")
full = _kc_admin_get_user(user_id)
attrs = full.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
attrs[key] = [value]
full["attributes"] = attrs
_kc_admin_update_user(user_id, full)
def _random_password(length: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _best_effort_post(url: str) -> None:
if not url:
return
try:
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
client.post(url, json={"ts": int(time.time())})
except Exception:
return
@app.route("/api/account/overview", methods=["GET"])
@require_auth
def account_overview() -> Any:
ok, resp = _require_account_access()
if not ok:
return resp
username = g.keycloak_username
mailu_username = f"{username}@{MAILU_DOMAIN}" if username else ""
mailu_status = "ready"
jellyfin_status = "ready"
if not _kc_admin_ready():
mailu_status = "server not configured"
jellyfin_status = "server not configured"
return jsonify(
{
"user": {"username": username, "email": g.keycloak_email, "groups": g.keycloak_groups},
"mailu": {"status": mailu_status, "username": mailu_username},
"jellyfin": {"status": jellyfin_status, "username": username},
}
)
def _rate_limit_allow(ip: str) -> bool:
if ACCESS_REQUEST_RATE_LIMIT <= 0:
return True
now = time.time()
window_start = now - ACCESS_REQUEST_RATE_WINDOW_SEC
bucket = _ACCESS_REQUEST_RATE.setdefault(ip, [])
bucket[:] = [t for t in bucket if t >= window_start]
if len(bucket) >= ACCESS_REQUEST_RATE_LIMIT:
return False
bucket.append(now)
return True
def _kc_admin_create_user(payload: dict[str, Any]) -> str:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
location = resp.headers.get("Location") or ""
if location:
return location.rstrip("/").split("/")[-1]
raise RuntimeError("failed to determine created user id")
def _kc_admin_delete_user(user_id: str) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.delete(url, headers=_kc_admin_headers())
resp.raise_for_status()
def _kc_admin_get_group_id(group_name: str) -> str | None:
cached = _KEYCLOAK_GROUP_ID_CACHE.get(group_name)
if cached:
return cached
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups"
params = {"search": group_name}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=_kc_admin_headers())
resp.raise_for_status()
items = resp.json()
if not isinstance(items, list):
return None
for item in items:
if not isinstance(item, dict):
continue
if item.get("name") == group_name and item.get("id"):
gid = str(item["id"])
_KEYCLOAK_GROUP_ID_CACHE[group_name] = gid
return gid
return None
def _kc_admin_add_user_to_group(user_id: str, group_id: str) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers=_kc_admin_headers())
resp.raise_for_status()
def _kc_admin_execute_actions_email(user_id: str, actions: list[str]) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/execute-actions-email"
params = {"client_id": KEYCLOAK_CLIENT_ID, "redirect_uri": request.host_url.rstrip("/") + "/"}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(
url,
params=params,
headers={**_kc_admin_headers(), "Content-Type": "application/json"},
json=actions,
)
resp.raise_for_status()
def _extract_request_payload() -> tuple[str, str, str]:
payload = request.get_json(silent=True) or {}
username = (payload.get("username") or "").strip()
email = (payload.get("email") or "").strip()
note = (payload.get("note") or "").strip()
return username, email, note
@app.route("/api/access/request", methods=["POST"])
def request_access() -> Any:
if not ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
ip = request.remote_addr or "unknown"
if not _rate_limit_allow(ip):
return jsonify({"error": "rate limited"}), 429
username, email, note = _extract_request_payload()
if not username or not email:
return jsonify({"error": "username and email are required"}), 400
if len(username) < 3 or len(username) > 32:
return jsonify({"error": "username must be 3-32 characters"}), 400
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return jsonify({"error": "username contains invalid characters"}), 400
if _kc_admin_find_user(username):
return jsonify({"error": "username already exists"}), 409
attrs: dict[str, Any] = {
"access_request_status": ["pending"],
"access_request_note": [note] if note else [""],
"access_request_created_at": [str(int(time.time()))],
}
user_payload = {
"username": username,
"enabled": False,
"email": email,
"emailVerified": False,
"attributes": attrs,
}
try:
user_id = _kc_admin_create_user(user_payload)
except Exception:
return jsonify({"error": "failed to submit request"}), 502
return jsonify({"ok": True, "id": user_id})
def _kc_admin_list_pending_requests(limit: int = 100) -> list[dict[str, Any]]:
def is_pending(user: dict[str, Any]) -> bool:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
return False
status = attrs.get("access_request_status")
if isinstance(status, list) and status:
return str(status[0]) == "pending"
if isinstance(status, str):
return status == "pending"
return False
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
# Prefer server-side search (q=) if supported by this Keycloak version.
# Always filter client-side for correctness.
candidates: list[dict[str, Any]] = []
for params in (
{"max": str(limit), "enabled": "false", "q": "access_request_status:pending"},
{"max": str(limit), "enabled": "false"},
{"max": str(limit)},
):
try:
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=_kc_admin_headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list):
continue
candidates = [u for u in users if isinstance(u, dict)]
break
except httpx.HTTPStatusError:
continue
pending = [u for u in candidates if is_pending(u)]
return pending[:limit]
@app.route("/api/admin/access/requests", methods=["GET"])
@require_auth
def admin_list_requests() -> Any:
ok, resp = _require_portal_admin()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
try:
items = _kc_admin_list_pending_requests()
except Exception:
return jsonify({"error": "failed to load requests"}), 502
output: list[dict[str, Any]] = []
for user in items:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
output.append(
{
"id": user.get("id") or "",
"username": user.get("username") or "",
"email": user.get("email") or "",
"created_at": (attrs.get("access_request_created_at") or [""])[0],
"note": (attrs.get("access_request_note") or [""])[0],
}
)
return jsonify({"requests": output})
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
@require_auth
def admin_approve_request(username: str) -> Any:
ok, resp = _require_portal_admin()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
user = _kc_admin_find_user(username)
if not user:
return jsonify({"error": "user not found"}), 404
user_id = user.get("id") or ""
if not user_id:
return jsonify({"error": "user id missing"}), 502
full = _kc_admin_get_user(user_id)
full["enabled"] = True
try:
_kc_admin_update_user(user_id, full)
except Exception:
return jsonify({"error": "failed to enable user"}), 502
group_id = _kc_admin_get_group_id("dev")
if group_id:
try:
_kc_admin_add_user_to_group(user_id, group_id)
except Exception:
pass
try:
_kc_admin_execute_actions_email(user_id, ["UPDATE_PASSWORD"])
except Exception:
pass
return jsonify({"ok": True})
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
@require_auth
def admin_deny_request(username: str) -> Any:
ok, resp = _require_portal_admin()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
user = _kc_admin_find_user(username)
if not user:
return jsonify({"ok": True})
user_id = user.get("id") or ""
if not user_id:
return jsonify({"error": "user id missing"}), 502
try:
_kc_admin_delete_user(user_id)
except Exception:
return jsonify({"error": "failed to delete user"}), 502
return jsonify({"ok": True})
@app.route("/api/account/mailu/rotate", methods=["POST"])
@require_auth
def account_mailu_rotate() -> Any:
ok, resp = _require_account_access()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = _random_password()
try:
_kc_set_user_attribute(username, "mailu_app_password", password)
except Exception:
return jsonify({"error": "failed to update mail password"}), 502
_best_effort_post(MAILU_SYNC_URL)
return jsonify({"password": password})
@app.route("/api/account/jellyfin/rotate", methods=["POST"])
@require_auth
def account_jellyfin_rotate() -> Any:
ok, resp = _require_account_access()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = _random_password()
try:
_kc_set_user_attribute(username, "jellyfin_app_password", password)
except Exception:
return jsonify({"error": "failed to update jellyfin password"}), 502
_best_effort_post(JELLYFIN_SYNC_URL)
return jsonify({"password": password})
@app.route("/api/monero/get_info")
def monero_get_info() -> Any:
try:
with urlopen(MONERO_GET_INFO_URL, timeout=2) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return jsonify(payload)
except (URLError, TimeoutError, ValueError) as exc:
return jsonify({"error": str(exc), "url": MONERO_GET_INFO_URL}), 503
def _vm_query(expr: str) -> float | None:
url = f"{VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
with urlopen(url, timeout=VM_QUERY_TIMEOUT_SEC) as resp:
payload = json.loads(resp.read().decode("utf-8"))
if payload.get("status") != "success":
return None
result = (payload.get("data") or {}).get("result") or []
if not result:
return None
values: list[float] = []
for item in result:
try:
values.append(float(item["value"][1]))
except (KeyError, IndexError, TypeError, ValueError):
continue
if not values:
return None
return max(values)
def _http_ok(url: str, expect_substring: str | None = None) -> bool:
try:
with urlopen(url, timeout=HTTP_CHECK_TIMEOUT_SEC) as resp:
if getattr(resp, "status", 200) != 200:
return False
if expect_substring:
chunk = resp.read(4096).decode("utf-8", errors="ignore")
return expect_substring in chunk
return True
except (URLError, TimeoutError, ValueError):
return False
@app.route("/api/lab/status")
def lab_status() -> Any:
now = time.time()
cached = _LAB_STATUS_CACHE.get("value")
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < LAB_STATUS_CACHE_SEC):
return jsonify(cached)
connected = False
atlas_up = False
atlas_known = False
atlas_source = "unknown"
oceanus_up = False
oceanus_known = False
oceanus_source = "unknown"
try:
atlas_value = _vm_query('max(up{job="kubernetes-apiservers"})')
oceanus_value = _vm_query('max(up{instance=~"(titan-23|192.168.22.24)(:9100)?"})')
connected = True
atlas_known = atlas_value is not None
atlas_up = bool(atlas_value and atlas_value > 0.5)
atlas_source = "victoria-metrics"
oceanus_known = oceanus_value is not None
oceanus_up = bool(oceanus_value and oceanus_value > 0.5)
oceanus_source = "victoria-metrics"
except (URLError, TimeoutError, ValueError):
atlas_value = None
oceanus_value = None
if not atlas_known:
if _http_ok(GRAFANA_HEALTH_URL):
connected = True
atlas_known = True
atlas_up = True
atlas_source = "grafana-health"
if not oceanus_up:
if _http_ok(OCEANUS_NODE_EXPORTER_URL, expect_substring="node_exporter_build_info"):
connected = True
oceanus_known = True
oceanus_up = True
oceanus_source = "node-exporter"
payload = {
"connected": connected,
"atlas": {"up": atlas_up, "known": atlas_known, "source": atlas_source},
"oceanus": {"up": oceanus_up, "known": oceanus_known, "source": oceanus_source},
"checked_at": int(now),
}
_LAB_STATUS_CACHE["ts"] = now
_LAB_STATUS_CACHE["value"] = payload
return jsonify(payload)
@app.route("/api/chat", methods=["POST"])
@app.route("/api/ai/chat", methods=["POST"])
def ai_chat() -> Any:
payload = request.get_json(silent=True) or {}
user_message = (payload.get("message") or "").strip()
history = payload.get("history") or []
if not user_message:
return jsonify({"error": "message required"}), 400
messages: list[dict[str, str]] = []
if AI_CHAT_SYSTEM_PROMPT:
messages.append({"role": "system", "content": AI_CHAT_SYSTEM_PROMPT})
for item in history:
role = item.get("role")
content = (item.get("content") or "").strip()
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": user_message})
body = {"model": AI_CHAT_MODEL, "messages": messages, "stream": False}
started = time.time()
try:
with httpx.Client(timeout=AI_CHAT_TIMEOUT_SEC) as client:
resp = client.post(f"{AI_CHAT_API}/api/chat", json=body)
resp.raise_for_status()
data = resp.json()
reply = (data.get("message") or {}).get("content") or ""
elapsed_ms = int((time.time() - started) * 1000)
return jsonify({"reply": reply, "latency_ms": elapsed_ms})
except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as exc:
return jsonify({"error": str(exc)}), 502
@app.route("/api/chat/info", methods=["GET"])
@app.route("/api/ai/info", methods=["GET"])
def ai_info() -> Any:
meta = _discover_ai_meta()
return jsonify(meta)
def _discover_ai_meta() -> dict[str, str]:
"""
Best-effort discovery of which node/gpu is hosting the AI service.
Tries the Kubernetes API using the service account if available; falls back to env.
"""
meta = {
"node": AI_NODE_NAME,
"gpu": AI_GPU_DESC,
"model": AI_CHAT_MODEL,
"endpoint": AI_PUBLIC_ENDPOINT or "/api/chat",
}
# Only attempt k8s if we're in-cluster and credentials exist.
sa_path = Path("/var/run/secrets/kubernetes.io/serviceaccount")
token_path = sa_path / "token"
ca_path = sa_path / "ca.crt"
ns_path = sa_path / "namespace"
if not token_path.exists() or not ca_path.exists() or not ns_path.exists():
return meta
try:
token = token_path.read_text().strip()
namespace = AI_K8S_NAMESPACE
base_url = "https://kubernetes.default.svc"
pod_url = f"{base_url}/api/v1/namespaces/{namespace}/pods?labelSelector={AI_K8S_LABEL}"
with httpx.Client(verify=str(ca_path), timeout=HTTP_CHECK_TIMEOUT_SEC, headers={"Authorization": f"Bearer {token}"}) as client:
resp = client.get(pod_url)
resp.raise_for_status()
data = resp.json()
items = data.get("items") or []
running = [p for p in items if p.get("status", {}).get("phase") == "Running"] or items
if running:
pod = running[0]
node_name = pod.get("spec", {}).get("nodeName") or meta["node"]
meta["node"] = node_name
annotations = pod.get("metadata", {}).get("annotations") or {}
gpu_hint = (
annotations.get(AI_GPU_ANNOTATION)
or annotations.get("ai.gpu/description")
or annotations.get("gpu/description")
)
if gpu_hint:
meta["gpu"] = gpu_hint
model_hint = annotations.get(AI_MODEL_ANNOTATION)
if not model_hint:
# Try to infer from container image tag.
containers = pod.get("spec", {}).get("containers") or []
if containers:
image = containers[0].get("image") or ""
model_hint = image.split(":")[-1] if ":" in image else image
if model_hint:
meta["model"] = model_hint
except Exception:
# swallow errors; keep fallbacks
pass
return meta
def _keep_warm() -> None:
"""Periodically ping the model to keep it warm."""
if not AI_WARM_ENABLED or AI_WARM_INTERVAL_SEC <= 0:
return
def loop() -> None:
while True:
time.sleep(AI_WARM_INTERVAL_SEC)
try:
body = {
"model": AI_CHAT_MODEL,
"messages": [{"role": "user", "content": "ping"}],
"stream": False,
}
with httpx.Client(timeout=min(AI_CHAT_TIMEOUT_SEC, 15)) as client:
client.post(f"{AI_CHAT_API}/api/chat", json=body)
except Exception:
# best-effort; ignore failures
continue
import threading
threading.Thread(target=loop, daemon=True, name="ai-keep-warm").start()
# Start keep-warm loop on import.
_keep_warm()
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_frontend(path: str) -> Any:
dist_path = Path(app.static_folder)
index_path = dist_path / "index.html"
if dist_path.exists() and index_path.exists():
target = dist_path / path
if path and target.exists():
return send_from_directory(app.static_folder, path)
return send_from_directory(app.static_folder, "index.html")
return jsonify(
{
"message": "Frontend not built yet. Run `npm install && npm run build` inside frontend/, then restart Flask.",
"available_endpoints": ["/api/healthz", "/api/monero/get_info"],
}
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

View File

@ -0,0 +1,6 @@
from __future__ import annotations
from .app_factory import create_app
__all__ = ["create_app"]

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS
from werkzeug.middleware.proxy_fix import ProxyFix
from .routes import access_requests, account, admin_access, ai, auth_config, health, lab, monero
def create_app() -> Flask:
app = Flask(__name__, static_folder="../frontend/dist", static_url_path="")
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
CORS(app, resources={r"/api/*": {"origins": "*"}})
health.register(app)
auth_config.register(app)
account.register(app)
access_requests.register(app)
admin_access.register(app)
monero.register(app)
lab.register(app)
ai.register(app)
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_frontend(path: str) -> Any:
dist_path = Path(app.static_folder)
index_path = dist_path / "index.html"
if dist_path.exists() and index_path.exists():
target = dist_path / path
if path and target.exists():
return send_from_directory(app.static_folder, path)
return send_from_directory(app.static_folder, "index.html")
return jsonify(
{
"message": "Frontend not built yet. Run `npm install && npm run build` inside frontend/, then restart Flask.",
"available_endpoints": ["/api/healthz", "/api/monero/get_info"],
}
)
return app

View File

@ -0,0 +1,280 @@
from __future__ import annotations
import time
from functools import wraps
from typing import Any
from urllib.parse import quote
from flask import g, jsonify, request
import httpx
import jwt
from jwt import PyJWKClient
from . import settings
class KeycloakOIDC:
def __init__(self) -> None:
self._jwk_client: PyJWKClient | None = None
def _client(self) -> PyJWKClient:
if self._jwk_client is None:
self._jwk_client = PyJWKClient(settings.KEYCLOAK_JWKS_URL)
return self._jwk_client
def verify(self, token: str) -> dict[str, Any]:
if not settings.KEYCLOAK_ENABLED:
raise ValueError("keycloak not enabled")
signing_key = self._client().get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
options={"verify_aud": False},
issuer=settings.KEYCLOAK_ISSUER,
)
azp = claims.get("azp")
aud = claims.get("aud")
aud_list: list[str] = []
if isinstance(aud, str):
aud_list = [aud]
elif isinstance(aud, list):
aud_list = [a for a in aud if isinstance(a, str)]
if azp != settings.KEYCLOAK_CLIENT_ID and settings.KEYCLOAK_CLIENT_ID not in aud_list:
raise ValueError("token not issued for this client")
return claims
class KeycloakAdminClient:
def __init__(self) -> None:
self._token: str = ""
self._expires_at: float = 0.0
self._group_id_cache: dict[str, str] = {}
def ready(self) -> bool:
return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET)
def _get_token(self) -> str:
if not self.ready():
raise RuntimeError("keycloak admin client not configured")
now = time.time()
if self._token and now < self._expires_at - 30:
return self._token
token_url = (
f"{settings.KEYCLOAK_ADMIN_URL}/realms/{settings.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token"
)
data = {
"grant_type": "client_credentials",
"client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID,
"client_secret": settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
}
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(token_url, data=data)
resp.raise_for_status()
payload = resp.json()
token = payload.get("access_token") or ""
if not token:
raise RuntimeError("no access_token in response")
expires_in = int(payload.get("expires_in") or 60)
self._token = token
self._expires_at = now + expires_in
return token
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self._get_token()}"}
def headers(self) -> dict[str, str]:
return self._headers()
def find_user(self, username: str) -> dict[str, Any] | None:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
params = {"username": username, "exact": "true"}
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=self._headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list) or not users:
return None
user = users[0]
return user if isinstance(user, dict) else None
def get_user(self, user_id: str) -> dict[str, Any]:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, headers=self._headers())
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError("unexpected user payload")
return data
def update_user(self, user_id: str, payload: dict[str, Any]) -> None:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
def create_user(self, payload: dict[str, Any]) -> str:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
location = resp.headers.get("Location") or ""
if location:
return location.rstrip("/").split("/")[-1]
raise RuntimeError("failed to determine created user id")
def set_user_attribute(self, username: str, key: str, value: str) -> None:
user = self.find_user(username)
if not user:
raise RuntimeError("user not found")
user_id = user.get("id") or ""
if not user_id:
raise RuntimeError("user id missing")
full = self.get_user(user_id)
attrs = full.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
attrs[key] = [value]
full["attributes"] = attrs
self.update_user(user_id, full)
def get_group_id(self, group_name: str) -> str | None:
cached = self._group_id_cache.get(group_name)
if cached:
return cached
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups"
params = {"search": group_name}
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=self._headers())
resp.raise_for_status()
items = resp.json()
if not isinstance(items, list):
return None
for item in items:
if not isinstance(item, dict):
continue
if item.get("name") == group_name and item.get("id"):
gid = str(item["id"])
self._group_id_cache[group_name] = gid
return gid
return None
def add_user_to_group(self, user_id: str, group_id: str) -> None:
url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
)
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers=self._headers())
resp.raise_for_status()
def execute_actions_email(self, user_id: str, actions: list[str], redirect_uri: str) -> None:
url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/execute-actions-email"
)
params = {"client_id": settings.KEYCLOAK_CLIENT_ID, "redirect_uri": redirect_uri}
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(
url,
params=params,
headers={**self._headers(), "Content-Type": "application/json"},
json=actions,
)
resp.raise_for_status()
_OIDC: KeycloakOIDC | None = None
_ADMIN: KeycloakAdminClient | None = None
def oidc_client() -> KeycloakOIDC:
global _OIDC
if _OIDC is None:
_OIDC = KeycloakOIDC()
return _OIDC
def admin_client() -> KeycloakAdminClient:
global _ADMIN
if _ADMIN is None:
_ADMIN = KeycloakAdminClient()
return _ADMIN
def _normalize_groups(groups: Any) -> list[str]:
if not isinstance(groups, list):
return []
cleaned: list[str] = []
for gname in groups:
if not isinstance(gname, str):
continue
cleaned.append(gname.lstrip("/"))
return [gname for gname in cleaned if gname]
def _extract_bearer_token() -> str | None:
header = request.headers.get("Authorization", "")
if not header:
return None
parts = header.split(None, 1)
if len(parts) != 2:
return None
scheme, token = parts[0].lower(), parts[1].strip()
if scheme != "bearer" or not token:
return None
return token
def require_auth(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
token = _extract_bearer_token()
if not token:
return jsonify({"error": "missing bearer token"}), 401
try:
claims = oidc_client().verify(token)
except Exception:
return jsonify({"error": "invalid token"}), 401
g.keycloak_claims = claims
g.keycloak_username = claims.get("preferred_username") or ""
g.keycloak_email = claims.get("email") or ""
g.keycloak_groups = _normalize_groups(claims.get("groups"))
return fn(*args, **kwargs)
return wrapper
def require_portal_admin() -> tuple[bool, Any]:
if not settings.KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503)
username = getattr(g, "keycloak_username", "") or ""
groups = set(getattr(g, "keycloak_groups", []) or [])
if username and username in settings.PORTAL_ADMIN_USERS:
return True, None
if settings.PORTAL_ADMIN_GROUPS and groups.intersection(settings.PORTAL_ADMIN_GROUPS):
return True, None
return False, (jsonify({"error": "forbidden"}), 403)
def require_account_access() -> tuple[bool, Any]:
if not settings.KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503)
if not settings.ACCOUNT_ALLOWED_GROUPS:
return True, None
groups = set(getattr(g, "keycloak_groups", []) or [])
if groups.intersection(settings.ACCOUNT_ALLOWED_GROUPS):
return True, None
return False, (jsonify({"error": "forbidden"}), 403)

View File

@ -0,0 +1,21 @@
from __future__ import annotations
import time
from . import settings
_ACCESS_REQUEST_RATE: dict[str, list[float]] = {}
def rate_limit_allow(ip: str) -> bool:
if settings.ACCESS_REQUEST_RATE_LIMIT <= 0:
return True
now = time.time()
window_start = now - settings.ACCESS_REQUEST_RATE_WINDOW_SEC
bucket = _ACCESS_REQUEST_RATE.setdefault(ip, [])
bucket[:] = [t for t in bucket if t >= window_start]
if len(bucket) >= settings.ACCESS_REQUEST_RATE_LIMIT:
return False
bucket.append(now)
return True

View File

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import re
import secrets
import string
import time
from typing import Any
from flask import jsonify, request
from ..keycloak import admin_client
from ..rate_limit import rate_limit_allow
from .. import settings
def _extract_request_payload() -> tuple[str, str, str]:
payload = request.get_json(silent=True) or {}
username = (payload.get("username") or "").strip()
email = (payload.get("email") or "").strip()
note = (payload.get("note") or "").strip()
return username, email, note
def _random_request_code(username: str) -> str:
suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10))
return f"{username}~{suffix}"
def register(app) -> None:
@app.route("/api/access/request", methods=["POST"])
def request_access() -> Any:
if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
ip = request.remote_addr or "unknown"
if not rate_limit_allow(ip):
return jsonify({"error": "rate limited"}), 429
username, email, note = _extract_request_payload()
if not username:
return jsonify({"error": "username is required"}), 400
if len(username) < 3 or len(username) > 32:
return jsonify({"error": "username must be 3-32 characters"}), 400
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return jsonify({"error": "username contains invalid characters"}), 400
if email and "@" not in email:
return jsonify({"error": "invalid email"}), 400
if admin_client().find_user(username):
return jsonify({"error": "username already exists"}), 409
request_code = _random_request_code(username)
attrs: dict[str, Any] = {
"access_request_code": [request_code],
"access_request_status": ["pending"],
"access_request_note": [note] if note else [""],
"access_request_created_at": [str(int(time.time()))],
"access_request_contact_email": [email] if email else [""],
}
user_payload: dict[str, Any] = {"username": username, "enabled": False, "attributes": attrs}
if email:
user_payload["email"] = email
user_payload["emailVerified"] = False
try:
user_id = admin_client().create_user(user_payload)
except Exception:
return jsonify({"error": "failed to submit request"}), 502
return jsonify({"ok": True, "id": user_id, "request_code": request_code})
@app.route("/api/access/request/status", methods=["POST"])
def request_access_status() -> Any:
if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
ip = request.remote_addr or "unknown"
if not rate_limit_allow(ip):
return jsonify({"error": "rate limited"}), 429
payload = request.get_json(silent=True) or {}
code = (payload.get("request_code") or payload.get("code") or "").strip()
if not code:
return jsonify({"error": "request_code is required"}), 400
username = (payload.get("username") or "").strip()
if not username:
if "~" not in code:
return jsonify({"error": "invalid request code"}), 400
username = code.split("~", 1)[0].strip()
if not username:
return jsonify({"error": "invalid request code"}), 400
user = admin_client().find_user(username)
if not user:
return jsonify({"error": "not found"}), 404
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
return jsonify({"error": "not found"}), 404
stored = attrs.get("access_request_code") or [""]
stored_code = stored[0] if isinstance(stored, list) and stored else (stored if isinstance(stored, str) else "")
if stored_code != code:
return jsonify({"error": "not found"}), 404
status = attrs.get("access_request_status") or [""]
status_value = (
status[0] if isinstance(status, list) and status else (status if isinstance(status, str) else "")
)
return jsonify({"ok": True, "status": status_value or "unknown"})

View File

@ -0,0 +1,82 @@
from __future__ import annotations
import time
from typing import Any
from flask import jsonify, g
from .. import settings
from ..keycloak import admin_client, require_auth, require_account_access
from ..utils import best_effort_post, random_password
def register(app) -> None:
@app.route("/api/account/overview", methods=["GET"])
@require_auth
def account_overview() -> Any:
ok, resp = require_account_access()
if not ok:
return resp
username = g.keycloak_username
mailu_username = f"{username}@{settings.MAILU_DOMAIN}" if username else ""
mailu_status = "ready"
jellyfin_status = "ready"
if not admin_client().ready():
mailu_status = "server not configured"
jellyfin_status = "server not configured"
return jsonify(
{
"user": {"username": username, "email": g.keycloak_email, "groups": g.keycloak_groups},
"mailu": {"status": mailu_status, "username": mailu_username},
"jellyfin": {"status": jellyfin_status, "username": username},
}
)
@app.route("/api/account/mailu/rotate", methods=["POST"])
@require_auth
def account_mailu_rotate() -> Any:
ok, resp = require_account_access()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = random_password()
try:
admin_client().set_user_attribute(username, "mailu_app_password", password)
except Exception:
return jsonify({"error": "failed to update mail password"}), 502
best_effort_post(settings.MAILU_SYNC_URL)
return jsonify({"password": password})
@app.route("/api/account/jellyfin/rotate", methods=["POST"])
@require_auth
def account_jellyfin_rotate() -> Any:
ok, resp = require_account_access()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = random_password()
try:
admin_client().set_user_attribute(username, "jellyfin_app_password", password)
except Exception:
return jsonify({"error": "failed to update jellyfin password"}), 502
best_effort_post(settings.JELLYFIN_SYNC_URL)
return jsonify({"password": password})

View File

@ -0,0 +1,154 @@
from __future__ import annotations
import time
from typing import Any
from flask import jsonify, request
import httpx
from .. import settings
from ..keycloak import admin_client, require_auth, require_portal_admin
def _kc_admin_list_pending_requests(limit: int = 100) -> list[dict[str, Any]]:
def is_pending(user: dict[str, Any]) -> bool:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
return False
status = attrs.get("access_request_status")
if isinstance(status, list) and status:
return str(status[0]) == "pending"
if isinstance(status, str):
return status == "pending"
return False
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
candidates: list[dict[str, Any]] = []
for params in (
{"max": str(limit), "enabled": "false", "q": "access_request_status:pending"},
{"max": str(limit), "enabled": "false"},
{"max": str(limit)},
):
try:
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=admin_client().headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list):
continue
candidates = [u for u in users if isinstance(u, dict)]
break
except httpx.HTTPStatusError:
continue
pending = [u for u in candidates if is_pending(u)]
return pending[:limit]
def register(app) -> None:
@app.route("/api/admin/access/requests", methods=["GET"])
@require_auth
def admin_list_requests() -> Any:
ok, resp = require_portal_admin()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
try:
items = _kc_admin_list_pending_requests()
except Exception:
return jsonify({"error": "failed to load requests"}), 502
output: list[dict[str, Any]] = []
for user in items:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
output.append(
{
"id": user.get("id") or "",
"username": user.get("username") or "",
"email": user.get("email") or "",
"request_code": (attrs.get("access_request_code") or [""])[0],
"created_at": (attrs.get("access_request_created_at") or [""])[0],
"note": (attrs.get("access_request_note") or [""])[0],
}
)
return jsonify({"requests": output})
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
@require_auth
def admin_approve_request(username: str) -> Any:
ok, resp = require_portal_admin()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
user = admin_client().find_user(username)
if not user:
return jsonify({"error": "user not found"}), 404
user_id = user.get("id") or ""
if not user_id:
return jsonify({"error": "user id missing"}), 502
full = admin_client().get_user(user_id)
full["enabled"] = True
attrs = full.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
attrs["access_request_status"] = ["approved"]
attrs["access_request_approved_at"] = [str(int(time.time()))]
full["attributes"] = attrs
try:
admin_client().update_user(user_id, full)
except Exception:
return jsonify({"error": "failed to enable user"}), 502
group_id = admin_client().get_group_id("dev")
if group_id:
try:
admin_client().add_user_to_group(user_id, group_id)
except Exception:
pass
if (full.get("email") or "").strip():
try:
admin_client().execute_actions_email(user_id, ["UPDATE_PASSWORD"], request.host_url.rstrip("/") + "/")
except Exception:
pass
return jsonify({"ok": True})
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
@require_auth
def admin_deny_request(username: str) -> Any:
ok, resp = require_portal_admin()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
user = admin_client().find_user(username)
if not user:
return jsonify({"ok": True})
user_id = user.get("id") or ""
if not user_id:
return jsonify({"error": "user id missing"}), 502
full = admin_client().get_user(user_id)
full["enabled"] = False
attrs = full.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
attrs["access_request_status"] = ["denied"]
attrs["access_request_denied_at"] = [str(int(time.time()))]
full["attributes"] = attrs
try:
admin_client().update_user(user_id, full)
except Exception:
return jsonify({"error": "failed to deny user"}), 502
return jsonify({"ok": True})

View File

@ -0,0 +1,139 @@
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
from typing import Any
from flask import jsonify, request
import httpx
from .. import settings
def register(app) -> None:
@app.route("/api/chat", methods=["POST"])
@app.route("/api/ai/chat", methods=["POST"])
def ai_chat() -> Any:
payload = request.get_json(silent=True) or {}
user_message = (payload.get("message") or "").strip()
history = payload.get("history") or []
if not user_message:
return jsonify({"error": "message required"}), 400
messages: list[dict[str, str]] = []
if settings.AI_CHAT_SYSTEM_PROMPT:
messages.append({"role": "system", "content": settings.AI_CHAT_SYSTEM_PROMPT})
for item in history:
role = item.get("role")
content = (item.get("content") or "").strip()
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": user_message})
body = {"model": settings.AI_CHAT_MODEL, "messages": messages, "stream": False}
started = time.time()
try:
with httpx.Client(timeout=settings.AI_CHAT_TIMEOUT_SEC) as client:
resp = client.post(f"{settings.AI_CHAT_API}/api/chat", json=body)
resp.raise_for_status()
data = resp.json()
reply = (data.get("message") or {}).get("content") or ""
elapsed_ms = int((time.time() - started) * 1000)
return jsonify({"reply": reply, "latency_ms": elapsed_ms})
except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as exc:
return jsonify({"error": str(exc)}), 502
@app.route("/api/chat/info", methods=["GET"])
@app.route("/api/ai/info", methods=["GET"])
def ai_info() -> Any:
meta = _discover_ai_meta()
return jsonify(meta)
_start_keep_warm()
def _discover_ai_meta() -> dict[str, str]:
meta = {
"node": settings.AI_NODE_NAME,
"gpu": settings.AI_GPU_DESC,
"model": settings.AI_CHAT_MODEL,
"endpoint": settings.AI_PUBLIC_ENDPOINT or "/api/chat",
}
sa_path = Path("/var/run/secrets/kubernetes.io/serviceaccount")
token_path = sa_path / "token"
ca_path = sa_path / "ca.crt"
ns_path = sa_path / "namespace"
if not token_path.exists() or not ca_path.exists() or not ns_path.exists():
return meta
try:
token = token_path.read_text().strip()
namespace = settings.AI_K8S_NAMESPACE
base_url = "https://kubernetes.default.svc"
pod_url = f"{base_url}/api/v1/namespaces/{namespace}/pods?labelSelector={settings.AI_K8S_LABEL}"
with httpx.Client(
verify=str(ca_path),
timeout=settings.HTTP_CHECK_TIMEOUT_SEC,
headers={"Authorization": f"Bearer {token}"},
) as client:
resp = client.get(pod_url)
resp.raise_for_status()
data = resp.json()
items = data.get("items") or []
running = [p for p in items if p.get("status", {}).get("phase") == "Running"] or items
if running:
pod = running[0]
node_name = pod.get("spec", {}).get("nodeName") or meta["node"]
meta["node"] = node_name
annotations = pod.get("metadata", {}).get("annotations") or {}
gpu_hint = (
annotations.get(settings.AI_GPU_ANNOTATION)
or annotations.get("ai.gpu/description")
or annotations.get("gpu/description")
)
if gpu_hint:
meta["gpu"] = gpu_hint
model_hint = annotations.get(settings.AI_MODEL_ANNOTATION)
if not model_hint:
containers = pod.get("spec", {}).get("containers") or []
if containers:
image = containers[0].get("image") or ""
model_hint = image.split(":")[-1] if ":" in image else image
if model_hint:
meta["model"] = model_hint
except Exception:
pass
return meta
def _start_keep_warm() -> None:
if not settings.AI_WARM_ENABLED or settings.AI_WARM_INTERVAL_SEC <= 0:
return
def loop() -> None:
while True:
time.sleep(settings.AI_WARM_INTERVAL_SEC)
try:
body = {
"model": settings.AI_CHAT_MODEL,
"messages": [{"role": "user", "content": "ping"}],
"stream": False,
}
with httpx.Client(timeout=min(settings.AI_CHAT_TIMEOUT_SEC, 15)) as client:
client.post(f"{settings.AI_CHAT_API}/api/chat", json=body)
except Exception:
continue
threading.Thread(target=loop, daemon=True, name="ai-keep-warm").start()

View File

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
from urllib.parse import quote
from flask import jsonify, request
from .. import settings
def register(app) -> None:
@app.route("/api/auth/config", methods=["GET"])
def auth_config() -> Any:
if not settings.KEYCLOAK_ENABLED:
return jsonify({"enabled": False})
issuer = settings.KEYCLOAK_ISSUER
public_origin = request.host_url.rstrip("/")
redirect_uri = quote(f"{public_origin}/", safe="")
login_url = (
f"{issuer}/protocol/openid-connect/auth"
f"?client_id={quote(settings.KEYCLOAK_CLIENT_ID, safe='')}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope=openid"
)
return jsonify(
{
"enabled": True,
"url": settings.KEYCLOAK_URL,
"realm": settings.KEYCLOAK_REALM,
"client_id": settings.KEYCLOAK_CLIENT_ID,
"login_url": login_url,
"reset_url": login_url,
}
)

View File

@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Any
from flask import jsonify
def register(app) -> None:
@app.route("/api/healthz")
def healthz() -> Any:
return jsonify({"ok": True})

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import json
import time
from typing import Any
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import urlopen
from flask import jsonify
from .. import settings
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
def _vm_query(expr: str) -> float | None:
url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp:
payload = json.loads(resp.read().decode("utf-8"))
if payload.get("status") != "success":
return None
result = (payload.get("data") or {}).get("result") or []
if not result:
return None
values: list[float] = []
for item in result:
try:
values.append(float(item["value"][1]))
except (KeyError, IndexError, TypeError, ValueError):
continue
if not values:
return None
return max(values)
def _http_ok(url: str, expect_substring: str | None = None) -> bool:
try:
with urlopen(url, timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as resp:
if getattr(resp, "status", 200) != 200:
return False
if expect_substring:
chunk = resp.read(4096).decode("utf-8", errors="ignore")
return expect_substring in chunk
return True
except (URLError, TimeoutError, ValueError):
return False
def register(app) -> None:
@app.route("/api/lab/status")
def lab_status() -> Any:
now = time.time()
cached = _LAB_STATUS_CACHE.get("value")
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC):
return jsonify(cached)
connected = False
atlas_up = False
atlas_known = False
atlas_source = "unknown"
oceanus_up = False
oceanus_known = False
oceanus_source = "unknown"
# Atlas
try:
atlas_grafana_ok = _http_ok(settings.GRAFANA_HEALTH_URL, expect_substring="ok")
if atlas_grafana_ok:
connected = True
atlas_up = True
atlas_known = True
atlas_source = "grafana"
except Exception:
pass
if not atlas_known:
try:
value = _vm_query("up")
if value is not None:
connected = True
atlas_known = True
atlas_up = value > 0
atlas_source = "victoria-metrics"
except Exception:
pass
# Oceanus (node-exporter direct probe)
try:
if _http_ok(settings.OCEANUS_NODE_EXPORTER_URL):
connected = True
oceanus_known = True
oceanus_up = True
oceanus_source = "node-exporter"
except Exception:
pass
payload = {
"connected": connected,
"atlas": {"up": atlas_up, "known": atlas_known, "source": atlas_source},
"oceanus": {"up": oceanus_up, "known": oceanus_known, "source": oceanus_source},
"checked_at": int(now),
}
_LAB_STATUS_CACHE["ts"] = now
_LAB_STATUS_CACHE["value"] = payload
return jsonify(payload)

View File

@ -0,0 +1,22 @@
from __future__ import annotations
import json
from typing import Any
from urllib.error import URLError
from urllib.request import urlopen
from flask import jsonify
from .. import settings
def register(app) -> None:
@app.route("/api/monero/get_info")
def monero_get_info() -> Any:
try:
with urlopen(settings.MONERO_GET_INFO_URL, timeout=2) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return jsonify(payload)
except (URLError, TimeoutError, ValueError) as exc:
return jsonify({"error": str(exc), "url": settings.MONERO_GET_INFO_URL}), 503

View File

@ -0,0 +1,70 @@
from __future__ import annotations
import os
def _env_bool(name: str, default: str = "false") -> bool:
return os.getenv(name, default).lower() in ("1", "true", "yes")
MONERO_GET_INFO_URL = os.getenv("MONERO_GET_INFO_URL", "http://monerod.crypto.svc.cluster.local:18081/get_info")
VM_BASE_URL = os.getenv(
"VM_BASE_URL",
"http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428",
).rstrip("/")
VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2"))
HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2"))
LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30"))
GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health")
OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics")
AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/")
AI_CHAT_MODEL = os.getenv("AI_CHAT_MODEL", "qwen2.5-coder:7b-instruct-q4_0")
AI_CHAT_SYSTEM_PROMPT = os.getenv(
"AI_CHAT_SYSTEM_PROMPT",
"You are the Titan Lab assistant for bstein.dev. Be concise and helpful.",
)
AI_CHAT_TIMEOUT_SEC = float(os.getenv("AI_CHAT_TIMEOUT_SEC", "20"))
AI_NODE_NAME = os.getenv("AI_CHAT_NODE_NAME") or os.getenv("AI_NODE_NAME") or "ai-cluster"
AI_GPU_DESC = os.getenv("AI_CHAT_GPU_DESC") or "local GPU (dynamic)"
AI_PUBLIC_ENDPOINT = os.getenv("AI_PUBLIC_CHAT_ENDPOINT", "https://chat.ai.bstein.dev/api/chat")
AI_K8S_LABEL = os.getenv("AI_K8S_LABEL", "app=ollama")
AI_K8S_NAMESPACE = os.getenv("AI_K8S_NAMESPACE", "ai")
AI_MODEL_ANNOTATION = os.getenv("AI_MODEL_ANNOTATION", "ai.bstein.dev/model")
AI_GPU_ANNOTATION = os.getenv("AI_GPU_ANNOTATION", "ai.bstein.dev/gpu")
AI_WARM_INTERVAL_SEC = float(os.getenv("AI_WARM_INTERVAL_SEC", "300"))
AI_WARM_ENABLED = _env_bool("AI_WARM_ENABLED", "true")
KEYCLOAK_ENABLED = _env_bool("KEYCLOAK_ENABLED", "false")
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/")
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "atlas")
KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "bstein-dev-home")
KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}").rstrip("/")
KEYCLOAK_JWKS_URL = os.getenv("KEYCLOAK_JWKS_URL", f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs").rstrip("/")
KEYCLOAK_ADMIN_URL = os.getenv("KEYCLOAK_ADMIN_URL", KEYCLOAK_URL).rstrip("/")
KEYCLOAK_ADMIN_CLIENT_ID = os.getenv("KEYCLOAK_ADMIN_CLIENT_ID", "")
KEYCLOAK_ADMIN_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "")
KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
ACCOUNT_ALLOWED_GROUPS = [
g.strip()
for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",")
if g.strip()
]
PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()]
PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()]
ACCESS_REQUEST_ENABLED = _env_bool("ACCESS_REQUEST_ENABLED", "true")
ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5"))
ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60)))
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv(
"MAILU_SYNC_URL",
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
).rstrip("/")
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")

View File

@ -0,0 +1,25 @@
from __future__ import annotations
import secrets
import string
import time
import httpx
from . import settings
def random_password(length: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def best_effort_post(url: str) -> None:
if not url:
return
try:
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
client.post(url, json={"ts": int(time.time())})
except Exception:
return

View File

@ -19,7 +19,7 @@
</div>
<p class="muted">
This creates a pending request in Atlas. If approved, you'll receive an email with next steps.
This creates a pending request in Atlas. If approved, you'll receive an email with next steps (if you provided one).
</p>
<form class="form" @submit.prevent="submit" v-if="!submitted">
@ -37,7 +37,7 @@
</label>
<label class="field">
<span class="label mono">Email</span>
<span class="label mono">Email (optional)</span>
<input
v-model="form.email"
class="input mono"
@ -45,7 +45,6 @@
autocomplete="email"
placeholder="you@example.com"
:disabled="submitting"
required
/>
</label>
@ -61,7 +60,7 @@
</label>
<div class="actions">
<button class="primary" type="submit" :disabled="submitting || !form.username.trim() || !form.email.trim()">
<button class="primary" type="submit" :disabled="submitting || !form.username.trim()">
{{ submitting ? "Submitting..." : "Submit request" }}
</button>
<span class="hint mono">Requests are rate-limited.</span>
@ -70,7 +69,42 @@
<div v-else class="success-box">
<div class="mono">Request submitted.</div>
<div class="muted">If approved, you'll get an email to finish setup.</div>
<div class="muted">
Save this request code. You can use it to check the status of your request.
</div>
<div class="request-code-row">
<span class="label mono">Request Code</span>
<button class="copy mono" type="button" @click="copyRequestCode">
{{ requestCode }}
<span v-if="copied" class="copied">copied</span>
</button>
</div>
</div>
<div class="card module status-module">
<div class="module-head">
<h2>Check status</h2>
<span class="pill mono" :class="status ? 'pill-ok' : 'pill-warn'">
{{ status || "unknown" }}
</span>
</div>
<p class="muted">
Enter your request code to see whether it is pending, approved, or denied.
</p>
<div class="status-form">
<input
v-model="statusForm.request_code"
class="input mono"
type="text"
placeholder="username~XXXXXXXXXX"
:disabled="checking"
/>
<button class="primary" type="button" @click="checkStatus" :disabled="checking || !statusForm.request_code.trim()">
{{ checking ? "Checking..." : "Check" }}
</button>
</div>
</div>
<div v-if="error" class="error-box">
@ -92,6 +126,14 @@ const form = reactive({
const submitting = ref(false);
const submitted = ref(false);
const error = ref("");
const requestCode = ref("");
const copied = ref(false);
const statusForm = reactive({
request_code: "",
});
const checking = ref(false);
const status = ref("");
async function submit() {
if (submitting.value) return;
@ -110,12 +152,46 @@ async function submit() {
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
submitted.value = true;
requestCode.value = data.request_code || "";
statusForm.request_code = requestCode.value;
status.value = "pending";
} catch (err) {
error.value = err.message || "Failed to submit request";
} finally {
submitting.value = false;
}
}
async function copyRequestCode() {
if (!requestCode.value) return;
try {
await navigator.clipboard.writeText(requestCode.value);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
} catch (err) {
error.value = err?.message || "Failed to copy request code";
}
}
async function checkStatus() {
if (checking.value) return;
error.value = "";
checking.value = true;
try {
const resp = await fetch("/api/access/request/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: statusForm.request_code.trim() }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || "unknown";
} catch (err) {
error.value = err.message || "Failed to check status";
} finally {
checking.value = false;
}
}
</script>
<style scoped>
@ -156,6 +232,10 @@ h1 {
padding: 18px;
}
.status-module {
margin-top: 14px;
}
.module-head {
display: flex;
align-items: center;
@ -212,6 +292,12 @@ h1 {
margin-top: 6px;
}
.status-form {
display: flex;
gap: 10px;
margin-top: 12px;
}
.hint {
color: var(--text-muted);
font-size: 12px;
@ -233,6 +319,30 @@ h1 {
padding: 12px;
}
.request-code-row {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.copy {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text);
padding: 10px 12px;
cursor: pointer;
}
.copied {
font-size: 12px;
color: rgba(120, 255, 160, 0.9);
}
.pill {
padding: 6px 10px;
border-radius: 999px;