portal: modularize backend and add request code status
This commit is contained in:
parent
c22c27b8aa
commit
8ef4198646
919
backend/app.py
919
backend/app.py
@ -1,922 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from atlas_portal.app_factory import create_app
|
||||||
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 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)
|
|
||||||
|
|||||||
6
backend/atlas_portal/__init__.py
Normal file
6
backend/atlas_portal/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .app_factory import create_app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
|
|
||||||
47
backend/atlas_portal/app_factory.py
Normal file
47
backend/atlas_portal/app_factory.py
Normal 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
|
||||||
|
|
||||||
280
backend/atlas_portal/keycloak.py
Normal file
280
backend/atlas_portal/keycloak.py
Normal 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)
|
||||||
21
backend/atlas_portal/rate_limit.py
Normal file
21
backend/atlas_portal/rate_limit.py
Normal 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
|
||||||
|
|
||||||
4
backend/atlas_portal/routes/__init__.py
Normal file
4
backend/atlas_portal/routes/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
|
|
||||||
119
backend/atlas_portal/routes/access_requests.py
Normal file
119
backend/atlas_portal/routes/access_requests.py
Normal 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"})
|
||||||
|
|
||||||
82
backend/atlas_portal/routes/account.py
Normal file
82
backend/atlas_portal/routes/account.py
Normal 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})
|
||||||
|
|
||||||
154
backend/atlas_portal/routes/admin_access.py
Normal file
154
backend/atlas_portal/routes/admin_access.py
Normal 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})
|
||||||
139
backend/atlas_portal/routes/ai.py
Normal file
139
backend/atlas_portal/routes/ai.py
Normal 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()
|
||||||
|
|
||||||
38
backend/atlas_portal/routes/auth_config.py
Normal file
38
backend/atlas_portal/routes/auth_config.py
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
12
backend/atlas_portal/routes/health.py
Normal file
12
backend/atlas_portal/routes/health.py
Normal 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})
|
||||||
|
|
||||||
114
backend/atlas_portal/routes/lab.py
Normal file
114
backend/atlas_portal/routes/lab.py
Normal 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)
|
||||||
|
|
||||||
22
backend/atlas_portal/routes/monero.py
Normal file
22
backend/atlas_portal/routes/monero.py
Normal 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
|
||||||
|
|
||||||
70
backend/atlas_portal/settings.py
Normal file
70
backend/atlas_portal/settings.py
Normal 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("/")
|
||||||
|
|
||||||
25
backend/atlas_portal/utils.py
Normal file
25
backend/atlas_portal/utils.py
Normal 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
|
||||||
|
|
||||||
@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted">
|
<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>
|
</p>
|
||||||
|
|
||||||
<form class="form" @submit.prevent="submit" v-if="!submitted">
|
<form class="form" @submit.prevent="submit" v-if="!submitted">
|
||||||
@ -37,7 +37,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label mono">Email</span>
|
<span class="label mono">Email (optional)</span>
|
||||||
<input
|
<input
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
class="input mono"
|
class="input mono"
|
||||||
@ -45,7 +45,6 @@
|
|||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -61,7 +60,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="actions">
|
<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" }}
|
{{ submitting ? "Submitting..." : "Submit request" }}
|
||||||
</button>
|
</button>
|
||||||
<span class="hint mono">Requests are rate-limited.</span>
|
<span class="hint mono">Requests are rate-limited.</span>
|
||||||
@ -70,7 +69,42 @@
|
|||||||
|
|
||||||
<div v-else class="success-box">
|
<div v-else class="success-box">
|
||||||
<div class="mono">Request submitted.</div>
|
<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>
|
||||||
|
|
||||||
<div v-if="error" class="error-box">
|
<div v-if="error" class="error-box">
|
||||||
@ -92,6 +126,14 @@ const form = reactive({
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const error = ref("");
|
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() {
|
async function submit() {
|
||||||
if (submitting.value) return;
|
if (submitting.value) return;
|
||||||
@ -110,12 +152,46 @@ async function submit() {
|
|||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
|
requestCode.value = data.request_code || "";
|
||||||
|
statusForm.request_code = requestCode.value;
|
||||||
|
status.value = "pending";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "Failed to submit request";
|
error.value = err.message || "Failed to submit request";
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -156,6 +232,10 @@ h1 {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-module {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-head {
|
.module-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -212,6 +292,12 @@ h1 {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -233,6 +319,30 @@ h1 {
|
|||||||
padding: 12px;
|
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 {
|
.pill {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user