ariadne/ariadne/services/vaultwarden.py

152 lines
6.0 KiB
Python

from __future__ import annotations
import threading
import time
from dataclasses import dataclass
import httpx
from ..k8s.client import get_secret_value, get_json
from ..settings import settings
@dataclass(frozen=True)
class VaultwardenInvite:
ok: bool
status: str
detail: str = ""
class VaultwardenService:
def __init__(self) -> None:
self._admin_lock = threading.Lock()
self._admin_client: httpx.Client | None = None
self._admin_session_expires_at: float = 0.0
self._admin_session_base_url: str = ""
self._rate_limited_until: float = 0.0
def invite_user(self, email: str) -> VaultwardenInvite:
email = (email or "").strip()
if not email or "@" not in email:
return VaultwardenInvite(ok=False, status="invalid_email", detail="email invalid")
if self._rate_limited_until and time.time() < self._rate_limited_until:
return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
base_url = f"http://{settings.vaultwarden_service_host}"
fallback_url = ""
try:
pod_ip = self._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 = self._admin_session(candidate)
resp = session.post("/admin/invite", json={"email": email})
if resp.status_code == 429:
self._rate_limited_until = time.time() + float(settings.vaultwarden_admin_rate_limit_backoff_sec)
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")
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)
if "rate limited" in last_error.lower():
return VaultwardenInvite(ok=False, status="rate_limited", detail="vaultwarden rate limited")
continue
return VaultwardenInvite(ok=False, status="error", detail=last_error or "failed to invite")
def _admin_session(self, base_url: str) -> httpx.Client:
now = time.time()
with self._admin_lock:
if self._rate_limited_until and now < self._rate_limited_until:
raise RuntimeError("vaultwarden rate limited")
if self._admin_client and now < self._admin_session_expires_at and self._admin_session_base_url == base_url:
return self._admin_client
if self._admin_client:
try:
self._admin_client.close()
except Exception:
pass
self._admin_client = None
token = get_secret_value(
settings.vaultwarden_namespace,
settings.vaultwarden_admin_secret_name,
settings.vaultwarden_admin_secret_key,
)
client = httpx.Client(
base_url=base_url,
timeout=10.0,
follow_redirects=True,
headers={"User-Agent": "ariadne/1"},
)
resp = client.post("/admin", data={"token": token})
if resp.status_code == 429:
self._rate_limited_until = now + float(settings.vaultwarden_admin_rate_limit_backoff_sec)
raise RuntimeError("vaultwarden rate limited")
resp.raise_for_status()
self._admin_client = client
self._admin_session_base_url = base_url
self._admin_session_expires_at = now + float(settings.vaultwarden_admin_session_ttl_sec)
return client
@staticmethod
def _find_pod_ip(namespace: str, label_selector: str) -> str:
data = 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) -> 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
vaultwarden = VaultwardenService()