portal: provision vaultwarden accounts
This commit is contained in:
parent
4dd991bc30
commit
cd39e77d0c
@ -9,6 +9,7 @@ from . import settings
|
|||||||
from .db import connect
|
from .db import connect
|
||||||
from .keycloak import admin_client
|
from .keycloak import admin_client
|
||||||
from .utils import random_password
|
from .utils import random_password
|
||||||
|
from .vaultwarden import invite_user
|
||||||
|
|
||||||
|
|
||||||
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
|
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
|
||||||
@ -18,6 +19,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
|
|||||||
"keycloak_groups",
|
"keycloak_groups",
|
||||||
"mailu_app_password",
|
"mailu_app_password",
|
||||||
"mailu_sync",
|
"mailu_sync",
|
||||||
|
"vaultwarden_invite",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -195,6 +197,27 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_upsert_task(conn, request_code, "mailu_sync", "error", "failed to sync mailu")
|
_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 everything is OK, advance to awaiting_onboarding.
|
||||||
if _all_tasks_ok(conn, request_code, required_tasks):
|
if _all_tasks_ok(conn, request_code, required_tasks):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@ -74,10 +74,10 @@ def register(app) -> None:
|
|||||||
elif isinstance(raw_pw, str) and raw_pw:
|
elif isinstance(raw_pw, str) and raw_pw:
|
||||||
mailu_app_password = raw_pw
|
mailu_app_password = raw_pw
|
||||||
except Exception:
|
except Exception:
|
||||||
mailu_status = "keycloak admin error"
|
mailu_status = "unavailable"
|
||||||
jellyfin_status = "keycloak admin error"
|
jellyfin_status = "unavailable"
|
||||||
jellyfin_sync_status = "unknown"
|
jellyfin_sync_status = "unknown"
|
||||||
jellyfin_sync_detail = "keycloak admin error"
|
jellyfin_sync_detail = "unavailable"
|
||||||
|
|
||||||
mailu_username = ""
|
mailu_username = ""
|
||||||
if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
|
if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
|
||||||
|
|||||||
@ -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_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip()
|
||||||
JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389"))
|
JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389"))
|
||||||
JELLYFIN_LDAP_CHECK_TIMEOUT_SEC = float(os.getenv("JELLYFIN_LDAP_CHECK_TIMEOUT_SEC", "1"))
|
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"))
|
||||||
|
|||||||
194
backend/atlas_portal/vaultwarden.py
Normal file
194
backend/atlas_portal/vaultwarden.py
Normal file
@ -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")
|
||||||
|
|
||||||
@ -412,6 +412,14 @@ async function copy(key, text) {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user