diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index 493bad4..8a406ee 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -9,6 +9,7 @@ from . import settings from .db import connect from .keycloak import admin_client from .utils import random_password +from .vaultwarden import invite_user MAILU_APP_PASSWORD_ATTR = "mailu_app_password" @@ -18,6 +19,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( "keycloak_groups", "mailu_app_password", "mailu_sync", + "vaultwarden_invite", ) @@ -195,6 +197,27 @@ def provision_access_request(request_code: str) -> ProvisionResult: except Exception: _upsert_task(conn, request_code, "mailu_sync", "error", "failed to sync mailu") + # Task: ensure Vaultwarden account exists (invite flow) + try: + if user_id: + full = admin_client().get_user(user_id) + keycloak_email = str(full.get("email") or "") + email = "" + if keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): + email = keycloak_email + else: + email = f"{username}@{settings.MAILU_DOMAIN}" + + result = invite_user(email) + if result.ok: + _upsert_task(conn, request_code, "vaultwarden_invite", "ok", result.status) + else: + _upsert_task(conn, request_code, "vaultwarden_invite", "error", result.detail or result.status) + else: + raise RuntimeError("missing user id") + except Exception: + _upsert_task(conn, request_code, "vaultwarden_invite", "error", "failed to provision vaultwarden") + # If everything is OK, advance to awaiting_onboarding. if _all_tasks_ok(conn, request_code, required_tasks): conn.execute( diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index b95fc5a..be13ab4 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -74,10 +74,10 @@ def register(app) -> None: elif isinstance(raw_pw, str) and raw_pw: mailu_app_password = raw_pw except Exception: - mailu_status = "keycloak admin error" - jellyfin_status = "keycloak admin error" + mailu_status = "unavailable" + jellyfin_status = "unavailable" jellyfin_sync_status = "unknown" - jellyfin_sync_detail = "keycloak admin error" + jellyfin_sync_detail = "unavailable" mailu_username = "" if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index e6deb7c..fbf6b9e 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -82,3 +82,11 @@ JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") JELLYFIN_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip() JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389")) JELLYFIN_LDAP_CHECK_TIMEOUT_SEC = float(os.getenv("JELLYFIN_LDAP_CHECK_TIMEOUT_SEC", "1")) + +VAULTWARDEN_NAMESPACE = os.getenv("VAULTWARDEN_NAMESPACE", "vaultwarden").strip() +VAULTWARDEN_POD_LABEL = os.getenv("VAULTWARDEN_POD_LABEL", "app=vaultwarden").strip() +VAULTWARDEN_POD_PORT = int(os.getenv("VAULTWARDEN_POD_PORT", "80")) +VAULTWARDEN_SERVICE_HOST = os.getenv("VAULTWARDEN_SERVICE_HOST", "vaultwarden-service.vaultwarden.svc.cluster.local").strip() +VAULTWARDEN_ADMIN_SECRET_NAME = os.getenv("VAULTWARDEN_ADMIN_SECRET_NAME", "vaultwarden-admin").strip() +VAULTWARDEN_ADMIN_SECRET_KEY = os.getenv("VAULTWARDEN_ADMIN_SECRET_KEY", "ADMIN_TOKEN").strip() +VAULTWARDEN_ADMIN_SESSION_TTL_SEC = float(os.getenv("VAULTWARDEN_ADMIN_SESSION_TTL_SEC", "300")) diff --git a/backend/atlas_portal/vaultwarden.py b/backend/atlas_portal/vaultwarden.py new file mode 100644 index 0000000..55c7773 --- /dev/null +++ b/backend/atlas_portal/vaultwarden.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import base64 +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx + +from . import settings + + +_K8S_BASE_URL = "https://kubernetes.default.svc" +_SA_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount") + + +def _read_service_account() -> tuple[str, str]: + token_path = _SA_PATH / "token" + ca_path = _SA_PATH / "ca.crt" + if not token_path.exists() or not ca_path.exists(): + raise RuntimeError("kubernetes service account token missing") + token = token_path.read_text().strip() + if not token: + raise RuntimeError("kubernetes service account token empty") + return token, str(ca_path) + + +def _k8s_get_json(path: str) -> dict[str, Any]: + token, ca_path = _read_service_account() + url = f"{_K8S_BASE_URL}{path}" + with httpx.Client( + verify=ca_path, + timeout=settings.HTTP_CHECK_TIMEOUT_SEC, + headers={"Authorization": f"Bearer {token}"}, + ) as client: + resp = client.get(url) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("unexpected kubernetes response") + return data + + +def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str: + data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/pods?labelSelector={label_selector}") + items = data.get("items") or [] + if not isinstance(items, list) or not items: + raise RuntimeError("no vaultwarden pods found") + + def _pod_ready(pod: dict[str, Any]) -> bool: + status = pod.get("status") if isinstance(pod.get("status"), dict) else {} + if status.get("phase") != "Running": + return False + ip = status.get("podIP") + if not isinstance(ip, str) or not ip: + return False + conditions = status.get("conditions") if isinstance(status.get("conditions"), list) else [] + for cond in conditions: + if not isinstance(cond, dict): + continue + if cond.get("type") == "Ready": + return cond.get("status") == "True" + return True + + ready = [p for p in items if isinstance(p, dict) and _pod_ready(p)] + candidates = ready or [p for p in items if isinstance(p, dict)] + status = candidates[0].get("status") or {} + ip = status.get("podIP") if isinstance(status, dict) else None + if not isinstance(ip, str) or not ip: + raise RuntimeError("vaultwarden pod has no IP") + return ip + + +def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str: + data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/secrets/{name}") + blob = data.get("data") if isinstance(data.get("data"), dict) else {} + raw = blob.get(key) + if not isinstance(raw, str) or not raw: + raise RuntimeError("secret key missing") + try: + decoded = base64.b64decode(raw).decode("utf-8").strip() + except Exception as exc: + raise RuntimeError("failed to decode secret") from exc + if not decoded: + raise RuntimeError("secret value empty") + return decoded + + +@dataclass(frozen=True) +class VaultwardenInvite: + ok: bool + status: str + detail: str = "" + + +_ADMIN_LOCK = threading.Lock() +_ADMIN_SESSION: httpx.Client | None = None +_ADMIN_SESSION_EXPIRES_AT: float = 0.0 +_ADMIN_SESSION_BASE_URL: str = "" + + +def _admin_session(base_url: str) -> httpx.Client: + global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL + now = time.time() + with _ADMIN_LOCK: + if _ADMIN_SESSION and now < _ADMIN_SESSION_EXPIRES_AT and _ADMIN_SESSION_BASE_URL == base_url: + return _ADMIN_SESSION + + if _ADMIN_SESSION: + try: + _ADMIN_SESSION.close() + except Exception: + pass + _ADMIN_SESSION = None + + token = _k8s_get_secret_value( + settings.VAULTWARDEN_NAMESPACE, + settings.VAULTWARDEN_ADMIN_SECRET_NAME, + settings.VAULTWARDEN_ADMIN_SECRET_KEY, + ) + + client = httpx.Client( + base_url=base_url, + timeout=settings.HTTP_CHECK_TIMEOUT_SEC, + follow_redirects=True, + headers={"User-Agent": "atlas-portal/1"}, + ) + + # Authenticate to the admin UI to establish a session cookie. + # Vaultwarden can rate-limit admin login attempts, so keep this session cached briefly. + resp = client.post("/admin", data={"token": token}) + if resp.status_code == 429: + raise RuntimeError("vaultwarden rate limited") + resp.raise_for_status() + + _ADMIN_SESSION = client + _ADMIN_SESSION_BASE_URL = base_url + _ADMIN_SESSION_EXPIRES_AT = now + float(settings.VAULTWARDEN_ADMIN_SESSION_TTL_SEC) + return client + + +def invite_user(email: str) -> VaultwardenInvite: + email = (email or "").strip() + if not email or "@" not in email: + return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid") + + # Prefer the service name when it works; fall back to pod IP because the Service can be misconfigured. + base_url = f"http://{settings.VAULTWARDEN_SERVICE_HOST}" + fallback_url = "" + try: + pod_ip = _k8s_find_pod_ip(settings.VAULTWARDEN_NAMESPACE, settings.VAULTWARDEN_POD_LABEL) + fallback_url = f"http://{pod_ip}:{settings.VAULTWARDEN_POD_PORT}" + except Exception: + fallback_url = "" + + last_error = "" + for candidate in [base_url, fallback_url]: + if not candidate: + continue + try: + session = _admin_session(candidate) + resp = session.post("/admin/invite", json={"email": email}) + if resp.status_code == 429: + return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited") + + if resp.status_code in {200, 201, 204}: + return VaultwardenInvite(ok=True, status="invited", detail="invite created") + + # Treat "already exists/invited" as success for idempotency. + body = "" + try: + body = resp.text or "" + except Exception: + body = "" + if resp.status_code in {400, 409} and any( + marker in body.lower() + for marker in ( + "already invited", + "already exists", + "already registered", + "user already exists", + ) + ): + return VaultwardenInvite(ok=True, status="already_present", detail="user already present") + + last_error = f"status {resp.status_code}" + except Exception as exc: + last_error = str(exc) + continue + + return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite") + diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 0cf2a3b..0c280f4 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -412,6 +412,14 @@ async function copy(key, text) { margin-bottom: 12px; } +.hero-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + .eyebrow { text-transform: uppercase; letter-spacing: 0.08em;