diff --git a/backend/app.py b/backend/app.py index 23487e9..1d5cdbd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,922 +1,7 @@ from __future__ import annotations -import json -import os -import re -import time -from functools import wraps -from pathlib import Path -import secrets -import string -from typing import Any -from urllib.error import URLError -from urllib.parse import quote -from urllib.parse import urlencode -from urllib.request import urlopen +from atlas_portal.app_factory import create_app -from flask import Flask, g, jsonify, request, send_from_directory -from flask_cors import CORS -import httpx -import jwt -from jwt import PyJWKClient -from werkzeug.middleware.proxy_fix import ProxyFix +app = create_app() -app = Flask(__name__, static_folder="../frontend/dist", static_url_path="") -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) -CORS(app, resources={r"/api/*": {"origins": "*"}}) - -MONERO_GET_INFO_URL = os.getenv("MONERO_GET_INFO_URL", "http://monerod.crypto.svc.cluster.local:18081/get_info") -VM_BASE_URL = os.getenv( - "VM_BASE_URL", - "http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428", -).rstrip("/") -VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2")) -HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2")) -LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30")) -GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health") -OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics") -AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/") -AI_CHAT_MODEL = os.getenv("AI_CHAT_MODEL", "qwen2.5-coder:7b-instruct-q4_0") -AI_CHAT_SYSTEM_PROMPT = os.getenv( - "AI_CHAT_SYSTEM_PROMPT", - "You are the Titan Lab assistant for bstein.dev. Be concise and helpful.", -) -AI_CHAT_TIMEOUT_SEC = float(os.getenv("AI_CHAT_TIMEOUT_SEC", "20")) -AI_NODE_NAME = os.getenv("AI_CHAT_NODE_NAME") or os.getenv("AI_NODE_NAME") or "ai-cluster" -AI_GPU_DESC = os.getenv("AI_CHAT_GPU_DESC") or "local GPU (dynamic)" -AI_PUBLIC_ENDPOINT = os.getenv("AI_PUBLIC_CHAT_ENDPOINT", "https://chat.ai.bstein.dev/api/chat") -AI_K8S_LABEL = os.getenv("AI_K8S_LABEL", "app=ollama") -AI_K8S_NAMESPACE = os.getenv("AI_K8S_NAMESPACE", "ai") -AI_MODEL_ANNOTATION = os.getenv("AI_MODEL_ANNOTATION", "ai.bstein.dev/model") -AI_GPU_ANNOTATION = os.getenv("AI_GPU_ANNOTATION", "ai.bstein.dev/gpu") -AI_WARM_INTERVAL_SEC = float(os.getenv("AI_WARM_INTERVAL_SEC", "300")) -AI_WARM_ENABLED = os.getenv("AI_WARM_ENABLED", "true").lower() in ("1", "true", "yes") - -KEYCLOAK_ENABLED = os.getenv("KEYCLOAK_ENABLED", "false").lower() in ("1", "true", "yes") -KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/") -KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "atlas") -KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "bstein-dev-home") -KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}").rstrip("/") -KEYCLOAK_JWKS_URL = os.getenv("KEYCLOAK_JWKS_URL", f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs").rstrip("/") - -KEYCLOAK_ADMIN_URL = os.getenv("KEYCLOAK_ADMIN_URL", KEYCLOAK_URL).rstrip("/") -KEYCLOAK_ADMIN_CLIENT_ID = os.getenv("KEYCLOAK_ADMIN_CLIENT_ID", "") -KEYCLOAK_ADMIN_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "") -KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM) - -ACCOUNT_ALLOWED_GROUPS = [ - g.strip() - for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",") - if g.strip() -] - -PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()] -PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()] - -ACCESS_REQUEST_ENABLED = os.getenv("ACCESS_REQUEST_ENABLED", "true").lower() in ("1", "true", "yes") -ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5")) -ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60))) - -MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev") -MAILU_SYNC_URL = os.getenv( - "MAILU_SYNC_URL", - "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", -).rstrip("/") - -JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") - -_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None -_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0} -_KEYCLOAK_GROUP_ID_CACHE: dict[str, str] = {} -_ACCESS_REQUEST_RATE: dict[str, list[float]] = {} - -_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None} - -@app.route("/api/healthz") -def healthz() -> Any: - return jsonify({"ok": True}) - - -def _normalize_groups(groups: Any) -> list[str]: - if not isinstance(groups, list): - return [] - cleaned: list[str] = [] - for gname in groups: - if not isinstance(gname, str): - continue - cleaned.append(gname.lstrip("/")) - return [gname for gname in cleaned if gname] - - -def _extract_bearer_token() -> str | None: - header = request.headers.get("Authorization", "") - if not header: - return None - parts = header.split(None, 1) - if len(parts) != 2: - return None - scheme, token = parts[0].lower(), parts[1].strip() - if scheme != "bearer" or not token: - return None - return token - - -def _get_jwk_client() -> PyJWKClient: - global _KEYCLOAK_JWK_CLIENT - if _KEYCLOAK_JWK_CLIENT is None: - _KEYCLOAK_JWK_CLIENT = PyJWKClient(KEYCLOAK_JWKS_URL) - return _KEYCLOAK_JWK_CLIENT - - -def _verify_keycloak_token(token: str) -> dict[str, Any]: - if not KEYCLOAK_ENABLED: - raise ValueError("keycloak not enabled") - - signing_key = _get_jwk_client().get_signing_key_from_jwt(token).key - claims = jwt.decode( - token, - signing_key, - algorithms=["RS256"], - options={"verify_aud": False}, - issuer=KEYCLOAK_ISSUER, - ) - - # Ensure this token was minted for our frontend client (defense-in-depth). - azp = claims.get("azp") - aud = claims.get("aud") - aud_list: list[str] = [] - if isinstance(aud, str): - aud_list = [aud] - elif isinstance(aud, list): - aud_list = [a for a in aud if isinstance(a, str)] - - if azp != KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_ID not in aud_list: - raise ValueError("token not issued for this client") - - return claims - - -def require_auth(fn): - @wraps(fn) - def wrapper(*args, **kwargs): - token = _extract_bearer_token() - if not token: - return jsonify({"error": "missing bearer token"}), 401 - try: - claims = _verify_keycloak_token(token) - except Exception: - return jsonify({"error": "invalid token"}), 401 - - g.keycloak_claims = claims - g.keycloak_username = claims.get("preferred_username") or "" - g.keycloak_email = claims.get("email") or "" - g.keycloak_groups = _normalize_groups(claims.get("groups")) - return fn(*args, **kwargs) - - return wrapper - - -def _require_portal_admin() -> tuple[bool, Any]: - if not KEYCLOAK_ENABLED: - return False, (jsonify({"error": "keycloak not enabled"}), 503) - - username = getattr(g, "keycloak_username", "") or "" - groups = set(getattr(g, "keycloak_groups", []) or []) - - if username and username in PORTAL_ADMIN_USERS: - return True, None - if PORTAL_ADMIN_GROUPS and groups.intersection(PORTAL_ADMIN_GROUPS): - return True, None - return False, (jsonify({"error": "forbidden"}), 403) - - -@app.route("/api/auth/config", methods=["GET"]) -def auth_config() -> Any: - if not KEYCLOAK_ENABLED: - return jsonify({"enabled": False}) - - issuer = KEYCLOAK_ISSUER - public_origin = request.host_url.rstrip("/") - redirect_uri = quote(f"{public_origin}/", safe="") - login_url = ( - f"{issuer}/protocol/openid-connect/auth" - f"?client_id={quote(KEYCLOAK_CLIENT_ID, safe='')}" - f"&redirect_uri={redirect_uri}" - f"&response_type=code" - f"&scope=openid" - ) - - return jsonify( - { - "enabled": True, - "url": KEYCLOAK_URL, - "realm": KEYCLOAK_REALM, - "client_id": KEYCLOAK_CLIENT_ID, - "login_url": login_url, - "reset_url": login_url, - } - ) - - -def _require_account_access() -> tuple[bool, Any]: - if not KEYCLOAK_ENABLED: - return False, (jsonify({"error": "keycloak not enabled"}), 503) - if not ACCOUNT_ALLOWED_GROUPS: - return True, None - groups = set(getattr(g, "keycloak_groups", []) or []) - if groups.intersection(ACCOUNT_ALLOWED_GROUPS): - return True, None - return False, (jsonify({"error": "forbidden"}), 403) - - -def _kc_admin_ready() -> bool: - return bool(KEYCLOAK_ADMIN_CLIENT_ID and KEYCLOAK_ADMIN_CLIENT_SECRET) - - -def _kc_admin_get_token() -> str: - if not _kc_admin_ready(): - raise RuntimeError("keycloak admin client not configured") - - now = time.time() - cached = _KEYCLOAK_ADMIN_TOKEN.get("token") - expires_at = float(_KEYCLOAK_ADMIN_TOKEN.get("expires_at") or 0.0) - if cached and now < expires_at - 30: - return str(cached) - - token_url = f"{KEYCLOAK_ADMIN_URL}/realms/{KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" - data = { - "grant_type": "client_credentials", - "client_id": KEYCLOAK_ADMIN_CLIENT_ID, - "client_secret": KEYCLOAK_ADMIN_CLIENT_SECRET, - } - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.post(token_url, data=data) - resp.raise_for_status() - payload = resp.json() - token = payload.get("access_token") or "" - if not token: - raise RuntimeError("no access_token in response") - expires_in = int(payload.get("expires_in") or 60) - _KEYCLOAK_ADMIN_TOKEN["token"] = token - _KEYCLOAK_ADMIN_TOKEN["expires_at"] = now + expires_in - return token - - -def _kc_admin_headers() -> dict[str, str]: - return {"Authorization": f"Bearer {_kc_admin_get_token()}"} - - -def _kc_admin_find_user(username: str) -> dict[str, Any] | None: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" - params = {"username": username, "exact": "true"} - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.get(url, params=params, headers=_kc_admin_headers()) - resp.raise_for_status() - users = resp.json() - if not isinstance(users, list) or not users: - return None - # Keycloak returns a list even with exact=true; use first match. - user = users[0] - return user if isinstance(user, dict) else None - - -def _kc_admin_get_user(user_id: str) -> dict[str, Any]: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.get(url, headers=_kc_admin_headers()) - resp.raise_for_status() - data = resp.json() - if not isinstance(data, dict): - raise RuntimeError("unexpected user payload") - return data - - -def _kc_admin_update_user(user_id: str, payload: dict[str, Any]) -> None: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.put(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload) - resp.raise_for_status() - - -def _kc_set_user_attribute(username: str, key: str, value: str) -> None: - user = _kc_admin_find_user(username) - if not user: - raise RuntimeError("user not found") - user_id = user.get("id") or "" - if not user_id: - raise RuntimeError("user id missing") - - full = _kc_admin_get_user(user_id) - attrs = full.get("attributes") or {} - if not isinstance(attrs, dict): - attrs = {} - - attrs[key] = [value] - full["attributes"] = attrs - _kc_admin_update_user(user_id, full) - - -def _random_password(length: int = 32) -> str: - alphabet = string.ascii_letters + string.digits - return "".join(secrets.choice(alphabet) for _ in range(length)) - - -def _best_effort_post(url: str) -> None: - if not url: - return - try: - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - client.post(url, json={"ts": int(time.time())}) - except Exception: - return - - -@app.route("/api/account/overview", methods=["GET"]) -@require_auth -def account_overview() -> Any: - ok, resp = _require_account_access() - if not ok: - return resp - - username = g.keycloak_username - mailu_username = f"{username}@{MAILU_DOMAIN}" if username else "" - - mailu_status = "ready" - jellyfin_status = "ready" - - if not _kc_admin_ready(): - mailu_status = "server not configured" - jellyfin_status = "server not configured" - - return jsonify( - { - "user": {"username": username, "email": g.keycloak_email, "groups": g.keycloak_groups}, - "mailu": {"status": mailu_status, "username": mailu_username}, - "jellyfin": {"status": jellyfin_status, "username": username}, - } - ) - - -def _rate_limit_allow(ip: str) -> bool: - if ACCESS_REQUEST_RATE_LIMIT <= 0: - return True - now = time.time() - window_start = now - ACCESS_REQUEST_RATE_WINDOW_SEC - bucket = _ACCESS_REQUEST_RATE.setdefault(ip, []) - bucket[:] = [t for t in bucket if t >= window_start] - if len(bucket) >= ACCESS_REQUEST_RATE_LIMIT: - return False - bucket.append(now) - return True - - -def _kc_admin_create_user(payload: dict[str, Any]) -> str: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.post(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload) - resp.raise_for_status() - location = resp.headers.get("Location") or "" - if location: - return location.rstrip("/").split("/")[-1] - raise RuntimeError("failed to determine created user id") - - -def _kc_admin_delete_user(user_id: str) -> None: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.delete(url, headers=_kc_admin_headers()) - resp.raise_for_status() - - -def _kc_admin_get_group_id(group_name: str) -> str | None: - cached = _KEYCLOAK_GROUP_ID_CACHE.get(group_name) - if cached: - return cached - - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups" - params = {"search": group_name} - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.get(url, params=params, headers=_kc_admin_headers()) - resp.raise_for_status() - items = resp.json() - if not isinstance(items, list): - return None - for item in items: - if not isinstance(item, dict): - continue - if item.get("name") == group_name and item.get("id"): - gid = str(item["id"]) - _KEYCLOAK_GROUP_ID_CACHE[group_name] = gid - return gid - return None - - -def _kc_admin_add_user_to_group(user_id: str, group_id: str) -> None: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}" - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.put(url, headers=_kc_admin_headers()) - resp.raise_for_status() - - -def _kc_admin_execute_actions_email(user_id: str, actions: list[str]) -> None: - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/execute-actions-email" - params = {"client_id": KEYCLOAK_CLIENT_ID, "redirect_uri": request.host_url.rstrip("/") + "/"} - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.put( - url, - params=params, - headers={**_kc_admin_headers(), "Content-Type": "application/json"}, - json=actions, - ) - resp.raise_for_status() - - -def _extract_request_payload() -> tuple[str, str, str]: - payload = request.get_json(silent=True) or {} - username = (payload.get("username") or "").strip() - email = (payload.get("email") or "").strip() - note = (payload.get("note") or "").strip() - return username, email, note - - -@app.route("/api/access/request", methods=["POST"]) -def request_access() -> Any: - if not ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not _kc_admin_ready(): - return jsonify({"error": "server not configured"}), 503 - - ip = request.remote_addr or "unknown" - if not _rate_limit_allow(ip): - return jsonify({"error": "rate limited"}), 429 - - username, email, note = _extract_request_payload() - if not username or not email: - return jsonify({"error": "username and email are required"}), 400 - - if len(username) < 3 or len(username) > 32: - return jsonify({"error": "username must be 3-32 characters"}), 400 - if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): - return jsonify({"error": "username contains invalid characters"}), 400 - - if _kc_admin_find_user(username): - return jsonify({"error": "username already exists"}), 409 - - attrs: dict[str, Any] = { - "access_request_status": ["pending"], - "access_request_note": [note] if note else [""], - "access_request_created_at": [str(int(time.time()))], - } - - user_payload = { - "username": username, - "enabled": False, - "email": email, - "emailVerified": False, - "attributes": attrs, - } - try: - user_id = _kc_admin_create_user(user_payload) - except Exception: - return jsonify({"error": "failed to submit request"}), 502 - - return jsonify({"ok": True, "id": user_id}) - - -def _kc_admin_list_pending_requests(limit: int = 100) -> list[dict[str, Any]]: - def is_pending(user: dict[str, Any]) -> bool: - attrs = user.get("attributes") or {} - if not isinstance(attrs, dict): - return False - status = attrs.get("access_request_status") - if isinstance(status, list) and status: - return str(status[0]) == "pending" - if isinstance(status, str): - return status == "pending" - return False - - url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" - - # Prefer server-side search (q=) if supported by this Keycloak version. - # Always filter client-side for correctness. - candidates: list[dict[str, Any]] = [] - for params in ( - {"max": str(limit), "enabled": "false", "q": "access_request_status:pending"}, - {"max": str(limit), "enabled": "false"}, - {"max": str(limit)}, - ): - try: - with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.get(url, params=params, headers=_kc_admin_headers()) - resp.raise_for_status() - users = resp.json() - if not isinstance(users, list): - continue - candidates = [u for u in users if isinstance(u, dict)] - break - except httpx.HTTPStatusError: - continue - - pending = [u for u in candidates if is_pending(u)] - return pending[:limit] - - -@app.route("/api/admin/access/requests", methods=["GET"]) -@require_auth -def admin_list_requests() -> Any: - ok, resp = _require_portal_admin() - if not ok: - return resp - if not _kc_admin_ready(): - return jsonify({"error": "server not configured"}), 503 - - try: - items = _kc_admin_list_pending_requests() - except Exception: - return jsonify({"error": "failed to load requests"}), 502 - - output: list[dict[str, Any]] = [] - for user in items: - attrs = user.get("attributes") or {} - if not isinstance(attrs, dict): - attrs = {} - output.append( - { - "id": user.get("id") or "", - "username": user.get("username") or "", - "email": user.get("email") or "", - "created_at": (attrs.get("access_request_created_at") or [""])[0], - "note": (attrs.get("access_request_note") or [""])[0], - } - ) - return jsonify({"requests": output}) - - -@app.route("/api/admin/access/requests//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//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("/") -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) diff --git a/backend/atlas_portal/__init__.py b/backend/atlas_portal/__init__.py new file mode 100644 index 0000000..fbf7c38 --- /dev/null +++ b/backend/atlas_portal/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .app_factory import create_app + +__all__ = ["create_app"] + diff --git a/backend/atlas_portal/app_factory.py b/backend/atlas_portal/app_factory.py new file mode 100644 index 0000000..d0f4d0e --- /dev/null +++ b/backend/atlas_portal/app_factory.py @@ -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("/") + 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 + diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py new file mode 100644 index 0000000..a68f27f --- /dev/null +++ b/backend/atlas_portal/keycloak.py @@ -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) diff --git a/backend/atlas_portal/rate_limit.py b/backend/atlas_portal/rate_limit.py new file mode 100644 index 0000000..9cbc98d --- /dev/null +++ b/backend/atlas_portal/rate_limit.py @@ -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 + diff --git a/backend/atlas_portal/routes/__init__.py b/backend/atlas_portal/routes/__init__.py new file mode 100644 index 0000000..a04cd9d --- /dev/null +++ b/backend/atlas_portal/routes/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +__all__ = [] + diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py new file mode 100644 index 0000000..f57dbde --- /dev/null +++ b/backend/atlas_portal/routes/access_requests.py @@ -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"}) + diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py new file mode 100644 index 0000000..c26854e --- /dev/null +++ b/backend/atlas_portal/routes/account.py @@ -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}) + diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py new file mode 100644 index 0000000..510f09a --- /dev/null +++ b/backend/atlas_portal/routes/admin_access.py @@ -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//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//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}) diff --git a/backend/atlas_portal/routes/ai.py b/backend/atlas_portal/routes/ai.py new file mode 100644 index 0000000..3ad8688 --- /dev/null +++ b/backend/atlas_portal/routes/ai.py @@ -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() + diff --git a/backend/atlas_portal/routes/auth_config.py b/backend/atlas_portal/routes/auth_config.py new file mode 100644 index 0000000..46c9562 --- /dev/null +++ b/backend/atlas_portal/routes/auth_config.py @@ -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, + } + ) + diff --git a/backend/atlas_portal/routes/health.py b/backend/atlas_portal/routes/health.py new file mode 100644 index 0000000..5247b0f --- /dev/null +++ b/backend/atlas_portal/routes/health.py @@ -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}) + diff --git a/backend/atlas_portal/routes/lab.py b/backend/atlas_portal/routes/lab.py new file mode 100644 index 0000000..9dba035 --- /dev/null +++ b/backend/atlas_portal/routes/lab.py @@ -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) + diff --git a/backend/atlas_portal/routes/monero.py b/backend/atlas_portal/routes/monero.py new file mode 100644 index 0000000..fb724a3 --- /dev/null +++ b/backend/atlas_portal/routes/monero.py @@ -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 + diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py new file mode 100644 index 0000000..92ba685 --- /dev/null +++ b/backend/atlas_portal/settings.py @@ -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("/") + diff --git a/backend/atlas_portal/utils.py b/backend/atlas_portal/utils.py new file mode 100644 index 0000000..44ef125 --- /dev/null +++ b/backend/atlas_portal/utils.py @@ -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 + diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index 586deab..0d36239 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -19,7 +19,7 @@

- 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).

@@ -37,7 +37,7 @@ @@ -61,7 +60,7 @@
- Requests are rate-limited. @@ -70,7 +69,42 @@
Request submitted.
-
If approved, you'll get an email to finish setup.
+
+ Save this request code. You can use it to check the status of your request. +
+
+ Request Code + +
+
+ +
+
+

Check status

+ + {{ status || "unknown" }} + +
+ +

+ Enter your request code to see whether it is pending, approved, or denied. +

+ +
+ + +
@@ -92,6 +126,14 @@ const form = reactive({ const submitting = ref(false); const submitted = ref(false); const error = ref(""); +const requestCode = ref(""); +const copied = ref(false); + +const statusForm = reactive({ + request_code: "", +}); +const checking = ref(false); +const status = ref(""); async function submit() { if (submitting.value) return; @@ -110,12 +152,46 @@ async function submit() { const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); submitted.value = true; + requestCode.value = data.request_code || ""; + statusForm.request_code = requestCode.value; + status.value = "pending"; } catch (err) { error.value = err.message || "Failed to submit request"; } finally { submitting.value = false; } } + +async function copyRequestCode() { + if (!requestCode.value) return; + try { + await navigator.clipboard.writeText(requestCode.value); + copied.value = true; + setTimeout(() => (copied.value = false), 1500); + } catch (err) { + error.value = err?.message || "Failed to copy request code"; + } +} + +async function checkStatus() { + if (checking.value) return; + error.value = ""; + checking.value = true; + try { + const resp = await fetch("/api/access/request/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request_code: statusForm.request_code.trim() }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); + status.value = data.status || "unknown"; + } catch (err) { + error.value = err.message || "Failed to check status"; + } finally { + checking.value = false; + } +}