From c927d38f7521337b2c872d7dd5cb6cacf58cc1f0 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 21 May 2026 15:53:17 -0300 Subject: [PATCH] game-stream: add Wolf portal APIs --- ariadne/app.py | 2 + ariadne/app_game_routes.py | 358 +++++++++++++++++++++++++ ariadne/metrics/metrics.py | 33 +++ ariadne/services/wolf_api.py | 44 +++ ariadne/services/wolf_gatekeeper.py | 41 +++ ariadne/settings.py | 6 + ariadne/settings_sections.py | 6 + tests/test_game_mode_metrics.py | 4 + tests/test_settings.py | 8 + tests/test_wolf_services.py | 115 ++++++++ tests/unit/app/test_app_game_routes.py | 131 +++++++++ 11 files changed, 748 insertions(+) create mode 100644 ariadne/services/wolf_api.py create mode 100644 ariadne/services/wolf_gatekeeper.py create mode 100644 tests/test_wolf_services.py diff --git a/ariadne/app.py b/ariadne/app.py index 8bc3984..8eeab24 100644 --- a/ariadne/app.py +++ b/ariadne/app.py @@ -47,6 +47,8 @@ from .services.testing_triage import ( from .services.vault import vault from .services.vaultwarden_sync import run_vaultwarden_sync from .services.wger import wger +from .services.wolf_api import wolf_api +from .services.wolf_gatekeeper import wolf_gatekeeper from .settings import settings from .utils.http import extract_bearer_token from .utils.logging import LogConfig, configure_logging, get_logger diff --git a/ariadne/app_game_routes.py b/ariadne/app_game_routes.py index 7bc203e..80bf966 100644 --- a/ariadne/app_game_routes.py +++ b/ariadne/app_game_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timezone from html import escape +import ipaddress import secrets from typing import Any, Callable @@ -10,6 +11,11 @@ from fastapi.responses import HTMLResponse, JSONResponse from .auth.keycloak import AuthContext from .db.storage import TaskRunRecord +from .metrics.metrics import ( + record_game_stream_firewall_unlock, + record_game_stream_pairing_attempt, + set_game_stream_firewall_active_unlocks, +) from .utils.errors import safe_error_detail @@ -18,6 +24,176 @@ def _game_from_payload(payload: dict[str, Any]) -> str: return str(game).strip() if isinstance(game, str) and game.strip() else "wolf" +def _clean_ip(value: Any) -> str: + text = str(value or "").split(",", 1)[0].strip() + if not text: + raise HTTPException(status_code=400, detail="missing ip") + try: + return str(ipaddress.ip_address(text)) + except ValueError: + raise HTTPException(status_code=400, detail="invalid ip") + + +def _source_ip(request: Request) -> str: + for header in ("x-portal-client-ip", "x-forwarded-for", "x-real-ip"): + value = request.headers.get(header) + if value: + return _clean_ip(value) + return _clean_ip(request.client.host if request.client else "") + + +def _is_game_stream_admin(module: Any, ctx: AuthContext) -> bool: + groups = {group.strip().lstrip("/") for group in ctx.groups if isinstance(group, str)} + admin_group = str(module.settings.game_stream_admin_group or "").strip().lstrip("/") + return bool((ctx.username and ctx.username in module.settings.portal_admin_users) or (admin_group and admin_group in groups)) + + +def _require_game_stream_access(module: Any, ctx: AuthContext) -> dict[str, Any]: + profile = module.game_stream_profiles.profile_for(ctx.username or "", ctx.groups) + if not profile.get("allowed"): + raise HTTPException(status_code=403, detail="forbidden") + return profile + + +def _safe_wolf_call(fn: Callable[[], dict[str, Any]], fallback_key: str) -> dict[str, Any]: + try: + return fn() + except Exception as exc: + return {"success": False, fallback_key: [], "error": safe_error_detail(exc, "wolf api unavailable")} + + +def _as_list(payload: dict[str, Any], keys: tuple[str, ...]) -> list[dict[str, Any]]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _client_name(client: dict[str, Any], fallback: str) -> str: + for key in ("name", "hostname", "app_state_folder", "client_name", "device_name"): + value = client.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + client_id = str(client.get("client_id") or client.get("uuid") or fallback) + return f"paired-{client_id[-6:]}" if client_id else fallback + + +def _summarize_clients(payload: dict[str, Any]) -> list[dict[str, Any]]: + clients = _as_list(payload, ("clients", "data")) + summary: list[dict[str, Any]] = [] + for idx, client in enumerate(clients, start=1): + client_id = str(client.get("client_id") or client.get("uuid") or client.get("id") or "") + summary.append({"id": client_id, "name": _client_name(client, f"paired-{idx}"), "raw": client}) + return summary + + +def _pending_secret(request_item: dict[str, Any]) -> str: + for key in ("pair_secret", "pairSecret", "secret", "id"): + value = request_item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _pending_ip(request_item: dict[str, Any]) -> str: + for key in ("client_ip", "clientIp", "ip", "address", "remote_address"): + value = request_item.get(key) + if isinstance(value, str) and value.strip(): + return value.split(":", 1)[0].strip() + return "" + + +def _summarize_pending(payload: dict[str, Any], source_ip: str | None = None, include_secrets: bool = True) -> list[dict[str, Any]]: + requests = _as_list(payload, ("requests", "pending", "data")) + summary: list[dict[str, Any]] = [] + for idx, request_item in enumerate(requests, start=1): + client_ip = _pending_ip(request_item) + if source_ip and client_ip and client_ip != source_ip: + continue + pair_secret = _pending_secret(request_item) + item = { + "name": _client_name(request_item, f"pending-{idx}"), + "client_ip": client_ip, + "raw": request_item, + } + if include_secrets: + item["pair_secret"] = pair_secret + summary.append(item) + return summary + + +def _gpu_priority(game_mode: dict[str, Any]) -> str: + if game_mode.get("active"): + return "wolf" + workloads = game_mode.get("workloads") if isinstance(game_mode.get("workloads"), list) else [] + effective = [ + item.get("effective_replicas") + for item in workloads + if isinstance(item, dict) and isinstance(item.get("effective_replicas"), int) + ] + if not effective: + return "unknown" + if any(value == 0 for value in effective) and any(value > 0 for value in effective): + return "mixed" + return "ai" + + +def _status_snapshot(module: Any, profile: dict[str, Any], can_control_gpu: bool, source_ip: str | None = None) -> dict[str, Any]: + try: + game_mode = module.game_mode.status() + except Exception as exc: + game_mode = {"status": "unavailable", "active": False, "error": safe_error_detail(exc, "failed to load game mode status")} + + if module.wolf_api.enabled(): + clients_raw = _safe_wolf_call(module.wolf_api.clients, "clients") + pending_raw = _safe_wolf_call(module.wolf_api.pending_pair_requests, "requests") + sessions_raw = _safe_wolf_call(module.wolf_api.sessions, "sessions") + apps_raw = _safe_wolf_call(module.wolf_api.apps, "apps") + else: + disabled = {"success": False, "error": "wolf api not configured"} + clients_raw = {**disabled, "clients": []} + pending_raw = {**disabled, "requests": []} + sessions_raw = {**disabled, "sessions": []} + apps_raw = {**disabled, "apps": []} + + firewall = {"success": False, "enabled": module.wolf_gatekeeper.enabled(), "active_unlocks": []} + if module.wolf_gatekeeper.enabled(): + firewall = _safe_wolf_call(module.wolf_gatekeeper.status, "active_unlocks") + active_unlocks = firewall.get("active_unlocks") + if isinstance(active_unlocks, list): + set_game_stream_firewall_active_unlocks(len(active_unlocks)) + + pending = _summarize_pending(pending_raw, source_ip=source_ip, include_secrets=True) + return { + "profile": profile, + "can_control_gpu": can_control_gpu, + "moonlight": { + "host": module.settings.game_stream_moonlight_host, + "ports": { + "tcp": [47984, 47989, 48010], + "udp": [47999, 48100, 48200], + }, + "unlock_ttl_seconds": module.settings.game_stream_firewall_unlock_ttl_sec, + "source_ip": source_ip or "", + }, + "gpu": {"priority": _gpu_priority(game_mode), "game_mode": game_mode}, + "wolf": { + "api_enabled": module.wolf_api.enabled(), + "clients": _summarize_clients(clients_raw), + "pending_pair_requests": pending, + "sessions": _as_list(sessions_raw, ("sessions", "data")), + "apps": _as_list(apps_raw, ("apps", "data")), + "errors": [ + payload.get("error") + for payload in (clients_raw, pending_raw, sessions_raw, apps_raw) + if isinstance(payload.get("error"), str) + ], + }, + "firewall": firewall, + } + + def _record_simple_task(module: Any, task_name: str, started: datetime, status: str, detail: str | None = None) -> None: finished = datetime.now(timezone.utc) duration_sec = (finished - started).total_seconds() @@ -333,6 +509,188 @@ def _register_game_routes(app: FastAPI, require_auth: Callable, deps: Callable[[ module = deps() return JSONResponse(module.game_stream_profiles.profile_for(ctx.username or "", ctx.groups)) + @app.get("/api/game-stream/status") + def get_game_stream_status(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Return Wolf, Moonlight firewall, and GPU state for game-stream users.""" + + module = deps() + profile = _require_game_stream_access(module, ctx) + source_ip = request.query_params.get("source_ip") + clean_source_ip = _clean_ip(source_ip) if source_ip else None + return JSONResponse(_status_snapshot(module, profile, _is_game_stream_admin(module, ctx), clean_source_ip)) + + @app.get("/api/game-stream/firewall/status") + def get_game_stream_firewall_status(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Return the Moonlight firewall allowlist status for game-stream users.""" + + module = deps() + _require_game_stream_access(module, ctx) + if not module.wolf_gatekeeper.enabled(): + raise HTTPException(status_code=503, detail="wolf gatekeeper not configured") + try: + result = module.wolf_gatekeeper.status() + active_unlocks = result.get("active_unlocks") + if isinstance(active_unlocks, list): + set_game_stream_firewall_active_unlocks(len(active_unlocks)) + return JSONResponse(result) + except Exception as exc: + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf gatekeeper unavailable")) + + @app.post("/api/game-stream/firewall/unlock") + async def unlock_game_stream_firewall(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Temporarily allow the user's current IP to reach Moonlight ports.""" + + module = deps() + _require_game_stream_access(module, ctx) + if not module.wolf_gatekeeper.enabled(): + record_game_stream_firewall_unlock("error") + raise HTTPException(status_code=503, detail="wolf gatekeeper not configured") + + payload = await module._read_json_payload(request) + ip = _clean_ip(payload.get("ip") or request.query_params.get("source_ip") or _source_ip(request)) + try: + ttl_seconds = int(payload.get("ttl_seconds") or module.settings.game_stream_firewall_unlock_ttl_sec) + except (TypeError, ValueError): + ttl_seconds = module.settings.game_stream_firewall_unlock_ttl_sec + ttl_seconds = max(60, min(ttl_seconds, module.settings.game_stream_firewall_unlock_ttl_sec)) + + try: + result = module.wolf_gatekeeper.unlock(ip, ttl_seconds, ctx.username or "unknown", ctx.username or None) + record_game_stream_firewall_unlock("ok") + module._record_event("game_stream_firewall_unlock", {"actor": ctx.username or "", "ip": ip, "ttl_seconds": ttl_seconds}) + return JSONResponse(result) + except Exception as exc: + record_game_stream_firewall_unlock("error") + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf gatekeeper unlock failed")) + + @app.post("/api/admin/game-stream/firewall/unlock") + async def admin_unlock_game_stream_firewall(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Temporarily allow any requested IP to reach Moonlight ports.""" + + module = deps() + module._require_admin(ctx) + if not module.wolf_gatekeeper.enabled(): + record_game_stream_firewall_unlock("error") + raise HTTPException(status_code=503, detail="wolf gatekeeper not configured") + + payload = await module._read_json_payload(request) + ip = _clean_ip(payload.get("ip")) + try: + ttl_seconds = int(payload.get("ttl_seconds") or module.settings.game_stream_firewall_unlock_ttl_sec) + except (TypeError, ValueError): + ttl_seconds = module.settings.game_stream_firewall_unlock_ttl_sec + ttl_seconds = max(60, min(ttl_seconds, module.settings.game_stream_firewall_unlock_ttl_sec)) + target_user = str(payload.get("target_user") or "").strip() or None + + try: + result = module.wolf_gatekeeper.unlock(ip, ttl_seconds, ctx.username or "admin", target_user) + record_game_stream_firewall_unlock("ok") + module._record_event( + "game_stream_firewall_admin_unlock", + {"actor": ctx.username or "", "target_user": target_user or "", "ip": ip, "ttl_seconds": ttl_seconds}, + ) + return JSONResponse(result) + except Exception as exc: + record_game_stream_firewall_unlock("error") + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf gatekeeper unlock failed")) + + @app.post("/api/game-stream/firewall/revoke") + async def revoke_game_stream_firewall(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Remove one Moonlight firewall unlock.""" + + module = deps() + _require_game_stream_access(module, ctx) + if not module.wolf_gatekeeper.enabled(): + raise HTTPException(status_code=503, detail="wolf gatekeeper not configured") + payload = await module._read_json_payload(request) + ip = _clean_ip(payload.get("ip") or request.query_params.get("source_ip") or _source_ip(request)) + try: + return JSONResponse(module.wolf_gatekeeper.revoke(ip)) + except Exception as exc: + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf gatekeeper revoke failed")) + + @app.get("/api/game-stream/pairing/status") + def get_game_stream_pairing_status(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Return pending Moonlight pair requests and paired client names.""" + + module = deps() + _require_game_stream_access(module, ctx) + if not module.wolf_api.enabled(): + raise HTTPException(status_code=503, detail="wolf api not configured") + source_ip = request.query_params.get("source_ip") + clean_source_ip = _clean_ip(source_ip) if source_ip else None + try: + pending = module.wolf_api.pending_pair_requests() + clients = module.wolf_api.clients() + return JSONResponse( + { + "pending_pair_requests": _summarize_pending( + pending, + source_ip=None if _is_game_stream_admin(module, ctx) else clean_source_ip, + include_secrets=True, + ), + "clients": _summarize_clients(clients), + } + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf api unavailable")) + + @app.get("/api/game-stream/clients") + def get_game_stream_clients(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Return paired Moonlight client names for game-stream users.""" + + module = deps() + _require_game_stream_access(module, ctx) + if not module.wolf_api.enabled(): + raise HTTPException(status_code=503, detail="wolf api not configured") + try: + return JSONResponse({"clients": _summarize_clients(module.wolf_api.clients())}) + except Exception as exc: + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf api unavailable")) + + @app.post("/api/game-stream/pairing/submit-pin") + async def submit_game_stream_pairing_pin(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Pair a pending Moonlight client with the supplied PIN.""" + + module = deps() + _require_game_stream_access(module, ctx) + if not module.wolf_api.enabled(): + record_game_stream_pairing_attempt("error") + raise HTTPException(status_code=503, detail="wolf api not configured") + + payload = await module._read_json_payload(request) + pair_secret = str(payload.get("pair_secret") or "").strip() + pin = str(payload.get("pin") or "").strip() + if not pair_secret: + raise HTTPException(status_code=400, detail="missing pair_secret") + if not pin: + raise HTTPException(status_code=400, detail="missing pin") + + if not _is_game_stream_admin(module, ctx): + source_ip = _clean_ip(payload.get("source_ip")) if payload.get("source_ip") else None + try: + pending = module.wolf_api.pending_pair_requests() + except Exception as exc: + record_game_stream_pairing_attempt("error") + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf api unavailable")) + allowed = False + for item in _summarize_pending(pending, source_ip=source_ip, include_secrets=True): + if item.get("pair_secret") == pair_secret: + allowed = True + break + if not allowed: + record_game_stream_pairing_attempt("forbidden") + raise HTTPException(status_code=403, detail="pair request is not available for this user") + + try: + result = module.wolf_api.pair_client(pair_secret, pin) + record_game_stream_pairing_attempt("ok") + module._record_event("game_stream_pairing", {"actor": ctx.username or "", "status": "ok"}) + return JSONResponse(result) + except Exception as exc: + record_game_stream_pairing_attempt("error") + raise HTTPException(status_code=502, detail=safe_error_detail(exc, "wolf pairing failed")) + @app.get("/api/admin/game-mode/status") def get_game_mode_status(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: """Return the current game-mode state for authenticated administrators.""" diff --git a/ariadne/metrics/metrics.py b/ariadne/metrics/metrics.py index b363889..1896a1d 100644 --- a/ariadne/metrics/metrics.py +++ b/ariadne/metrics/metrics.py @@ -89,6 +89,20 @@ GAME_MODE_LAST_TRANSITION_TS = Gauge( "Last Ariadne game mode transition timestamp", ["action", "status", "game"], ) +GAME_STREAM_FIREWALL_UNLOCKS_TOTAL = Counter( + "ariadne_game_stream_firewall_unlocks_total", + "Game-stream firewall unlock attempts by status", + ["status"], +) +GAME_STREAM_FIREWALL_ACTIVE_UNLOCKS = Gauge( + "ariadne_game_stream_firewall_active_unlocks", + "Active game-stream firewall unlock count reported by the gatekeeper", +) +GAME_STREAM_PAIRING_ATTEMPTS_TOTAL = Counter( + "ariadne_game_stream_pairing_attempts_total", + "Moonlight pairing attempts through Ariadne by status", + ["status"], +) def record_task_run(task: str, status: str, duration_sec: float | None) -> None: @@ -154,3 +168,22 @@ def set_game_mode_managed_replicas(namespace: str, deployment: str, replicas: in if replicas is not None: GAME_MODE_MANAGED_REPLICAS.labels(namespace=namespace, deployment=deployment).set(replicas) + + +def record_game_stream_firewall_unlock(status: str) -> None: + """Increment the game-stream firewall unlock counter.""" + + GAME_STREAM_FIREWALL_UNLOCKS_TOTAL.labels(status=status or "unknown").inc() + + +def set_game_stream_firewall_active_unlocks(count: int | None) -> None: + """Publish the number of active Moonlight firewall unlocks.""" + + if count is not None: + GAME_STREAM_FIREWALL_ACTIVE_UNLOCKS.set(count) + + +def record_game_stream_pairing_attempt(status: str) -> None: + """Increment the game-stream pairing attempt counter.""" + + GAME_STREAM_PAIRING_ATTEMPTS_TOTAL.labels(status=status or "unknown").inc() diff --git a/ariadne/services/wolf_api.py b/ariadne/services/wolf_api.py new file mode 100644 index 0000000..66d4a23 --- /dev/null +++ b/ariadne/services/wolf_api.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from ..settings import settings + + +class WolfApiService: + """Talk to the in-cluster proxy for Wolf's local management API.""" + + def enabled(self) -> bool: + return bool(settings.wolf_api_url) + + def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.enabled(): + raise RuntimeError("wolf api not configured") + url = f"{settings.wolf_api_url.rstrip('/')}/{path.lstrip('/')}" + with httpx.Client(timeout=settings.wolf_api_timeout_sec) as client: + resp = client.request(method, url, json=payload) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("unexpected wolf api response") + return data + + def clients(self) -> dict[str, Any]: + return self._request("GET", "/api/v1/clients") + + def pending_pair_requests(self) -> dict[str, Any]: + return self._request("GET", "/api/v1/pair/pending") + + def pair_client(self, pair_secret: str, pin: str) -> dict[str, Any]: + return self._request("POST", "/api/v1/pair/client", {"pair_secret": pair_secret, "pin": pin}) + + def sessions(self) -> dict[str, Any]: + return self._request("GET", "/api/v1/sessions") + + def apps(self) -> dict[str, Any]: + return self._request("GET", "/api/v1/apps") + + +wolf_api = WolfApiService() diff --git a/ariadne/services/wolf_gatekeeper.py b/ariadne/services/wolf_gatekeeper.py new file mode 100644 index 0000000..9465b76 --- /dev/null +++ b/ariadne/services/wolf_gatekeeper.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from ..settings import settings + + +class WolfGatekeeperService: + """Manage temporary Moonlight firewall unlocks through the titan-24 agent.""" + + def enabled(self) -> bool: + return bool(settings.wolf_gatekeeper_url) + + def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.enabled(): + raise RuntimeError("wolf gatekeeper not configured") + url = f"{settings.wolf_gatekeeper_url.rstrip('/')}/{path.lstrip('/')}" + with httpx.Client(timeout=settings.wolf_gatekeeper_timeout_sec) as client: + resp = client.request(method, url, json=payload) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("unexpected wolf gatekeeper response") + return data + + def status(self) -> dict[str, Any]: + return self._request("GET", "/status") + + def unlock(self, ip: str, ttl_seconds: int, actor: str, target_user: str | None = None) -> dict[str, Any]: + payload = {"ip": ip, "ttl_seconds": ttl_seconds, "actor": actor} + if target_user: + payload["target_user"] = target_user + return self._request("POST", "/unlock", payload) + + def revoke(self, ip: str) -> dict[str, Any]: + return self._request("POST", "/revoke", {"ip": ip}) + + +wolf_gatekeeper = WolfGatekeeperService() diff --git a/ariadne/settings.py b/ariadne/settings.py index c458d90..f72cf91 100644 --- a/ariadne/settings.py +++ b/ariadne/settings.py @@ -237,6 +237,12 @@ class Settings: wolf_oidc_base_url: str wolf_oidc_vault_path: str wolf_oidc_cron: str + wolf_api_url: str + wolf_api_timeout_sec: float + wolf_gatekeeper_url: str + wolf_gatekeeper_timeout_sec: float + game_stream_firewall_unlock_ttl_sec: int + game_stream_moonlight_host: str game_stream_user_group: str game_stream_admin_group: str game_stream_profile_group_prefix: str diff --git a/ariadne/settings_sections.py b/ariadne/settings_sections.py index 60e905f..2e6bbd3 100644 --- a/ariadne/settings_sections.py +++ b/ariadne/settings_sections.py @@ -345,6 +345,12 @@ def _game_stream_config() -> dict[str, Any]: _env("SUNSHINE_OIDC_BASE_URL", "https://wolf.bstein.dev"), ).rstrip("/"), "wolf_oidc_vault_path": _env("WOLF_OIDC_VAULT_PATH", _env("SUNSHINE_OIDC_VAULT_PATH", "game-stream/wolf-oidc")), + "wolf_api_url": _env("WOLF_API_URL", "").rstrip("/"), + "wolf_api_timeout_sec": _env_float("WOLF_API_TIMEOUT_SEC", 5.0), + "wolf_gatekeeper_url": _env("WOLF_GATEKEEPER_URL", "").rstrip("/"), + "wolf_gatekeeper_timeout_sec": _env_float("WOLF_GATEKEEPER_TIMEOUT_SEC", 5.0), + "game_stream_firewall_unlock_ttl_sec": _env_int("GAME_STREAM_FIREWALL_UNLOCK_TTL_SEC", 28800), + "game_stream_moonlight_host": _env("GAME_STREAM_MOONLIGHT_HOST", "moonlight.bstein.dev"), "game_stream_user_group": _env("GAME_STREAM_USER_GROUP", "game-stream-users"), "game_stream_admin_group": _env("GAME_STREAM_ADMIN_GROUP", "admin"), "game_stream_profile_group_prefix": _env("GAME_STREAM_PROFILE_GROUP_PREFIX", "game-stream-profile-"), diff --git a/tests/test_game_mode_metrics.py b/tests/test_game_mode_metrics.py index 2bc8d9e..229e432 100644 --- a/tests/test_game_mode_metrics.py +++ b/tests/test_game_mode_metrics.py @@ -8,3 +8,7 @@ def test_game_mode_metric_helpers() -> None: metrics.record_game_mode_transition("", "", "") metrics.set_game_mode_managed_replicas("openclaw", "ollama", 0) metrics.set_game_mode_managed_replicas("openclaw", "ollama", None) + metrics.record_game_stream_firewall_unlock("") + metrics.set_game_stream_firewall_active_unlocks(1) + metrics.set_game_stream_firewall_active_unlocks(None) + metrics.record_game_stream_pairing_attempt("") diff --git a/tests/test_settings.py b/tests/test_settings.py index c42d3e6..631f006 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -61,6 +61,10 @@ def test_from_env_includes_game_stream_settings(monkeypatch) -> None: monkeypatch.setenv("WOLF_OIDC_BASE_URL", "https://wolf.bstein.dev/") monkeypatch.setenv("WOLF_OIDC_VAULT_PATH", "game-stream/wolf-oidc") monkeypatch.setenv("ARIADNE_SCHEDULE_WOLF_OIDC", "*/13 * * * *") + monkeypatch.setenv("WOLF_API_URL", "http://wolf-api.game-stream.svc.cluster.local:8088/") + monkeypatch.setenv("WOLF_GATEKEEPER_URL", "http://wolf-gatekeeper.game-stream.svc.cluster.local:8087/") + monkeypatch.setenv("GAME_STREAM_FIREWALL_UNLOCK_TTL_SEC", "28800") + monkeypatch.setenv("GAME_STREAM_MOONLIGHT_HOST", "moonlight.bstein.dev") cfg = Settings.from_env() @@ -71,3 +75,7 @@ def test_from_env_includes_game_stream_settings(monkeypatch) -> None: assert cfg.wolf_oidc_base_url == "https://wolf.bstein.dev" assert cfg.wolf_oidc_vault_path == "game-stream/wolf-oidc" assert cfg.wolf_oidc_cron == "*/13 * * * *" + assert cfg.wolf_api_url == "http://wolf-api.game-stream.svc.cluster.local:8088" + assert cfg.wolf_gatekeeper_url == "http://wolf-gatekeeper.game-stream.svc.cluster.local:8087" + assert cfg.game_stream_firewall_unlock_ttl_sec == 28800 + assert cfg.game_stream_moonlight_host == "moonlight.bstein.dev" diff --git a/tests/test_wolf_services.py b/tests/test_wolf_services.py new file mode 100644 index 0000000..cb05eed --- /dev/null +++ b/tests/test_wolf_services.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from ariadne.services import wolf_api as wolf_api_module +from ariadne.services import wolf_gatekeeper as wolf_gatekeeper_module +from ariadne.services.wolf_api import WolfApiService +from ariadne.services.wolf_gatekeeper import WolfGatekeeperService + + +class DummyResponse: + def __init__(self, payload) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + pass + + def json(self): + return self._payload + + +class DummyClient: + calls: list[tuple[str, str, object | None]] = [] + payload: object = {"success": True} + + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method: str, url: str, json=None): + self.calls.append((method, url, json)) + return DummyResponse(self.payload) + + +def _settings(**overrides): + defaults = { + "wolf_api_url": "http://wolf-api", + "wolf_api_timeout_sec": 2.0, + "wolf_gatekeeper_url": "http://wolf-gatekeeper", + "wolf_gatekeeper_timeout_sec": 3.0, + } + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_wolf_api_service_requests(monkeypatch) -> None: + DummyClient.calls = [] + DummyClient.payload = {"success": True, "clients": []} + monkeypatch.setattr(wolf_api_module, "settings", _settings()) + monkeypatch.setattr(wolf_api_module.httpx, "Client", DummyClient) + svc = WolfApiService() + + assert svc.enabled() is True + assert svc.clients()["success"] is True + assert svc.pending_pair_requests()["success"] is True + assert svc.sessions()["success"] is True + assert svc.apps()["success"] is True + assert svc.pair_client("secret", "1234")["success"] is True + + assert DummyClient.calls[-1] == ("POST", "http://wolf-api/api/v1/pair/client", {"pair_secret": "secret", "pin": "1234"}) + + +def test_wolf_api_service_errors(monkeypatch) -> None: + monkeypatch.setattr(wolf_api_module, "settings", _settings(wolf_api_url="")) + svc = WolfApiService() + assert svc.enabled() is False + with pytest.raises(RuntimeError, match="not configured"): + svc.clients() + + DummyClient.payload = [] + monkeypatch.setattr(wolf_api_module, "settings", _settings()) + monkeypatch.setattr(wolf_api_module.httpx, "Client", DummyClient) + with pytest.raises(RuntimeError, match="unexpected"): + svc.clients() + + +def test_wolf_gatekeeper_service_requests(monkeypatch) -> None: + DummyClient.calls = [] + DummyClient.payload = {"success": True, "active_unlocks": []} + monkeypatch.setattr(wolf_gatekeeper_module, "settings", _settings()) + monkeypatch.setattr(wolf_gatekeeper_module.httpx, "Client", DummyClient) + svc = WolfGatekeeperService() + + assert svc.enabled() is True + assert svc.status()["success"] is True + assert svc.unlock("1.2.3.4", 300, "olya", "olya")["success"] is True + assert svc.revoke("1.2.3.4")["success"] is True + + assert DummyClient.calls[1] == ( + "POST", + "http://wolf-gatekeeper/unlock", + {"ip": "1.2.3.4", "ttl_seconds": 300, "actor": "olya", "target_user": "olya"}, + ) + assert DummyClient.calls[2] == ("POST", "http://wolf-gatekeeper/revoke", {"ip": "1.2.3.4"}) + + +def test_wolf_gatekeeper_service_errors(monkeypatch) -> None: + monkeypatch.setattr(wolf_gatekeeper_module, "settings", _settings(wolf_gatekeeper_url="")) + svc = WolfGatekeeperService() + assert svc.enabled() is False + with pytest.raises(RuntimeError, match="not configured"): + svc.status() + + DummyClient.payload = [] + monkeypatch.setattr(wolf_gatekeeper_module, "settings", _settings()) + monkeypatch.setattr(wolf_gatekeeper_module.httpx, "Client", DummyClient) + with pytest.raises(RuntimeError, match="unexpected"): + svc.status() diff --git a/tests/unit/app/test_app_game_routes.py b/tests/unit/app/test_app_game_routes.py index ba9c904..5a34a27 100644 --- a/tests/unit/app/test_app_game_routes.py +++ b/tests/unit/app/test_app_game_routes.py @@ -1,6 +1,48 @@ from tests.unit.app.app_route_helpers import * +class DummyWolfApi: + def __init__(self) -> None: + self.paired: list[tuple[str, str]] = [] + + def enabled(self) -> bool: + return True + + def clients(self): + return {"success": True, "clients": [{"client_id": "abc123456", "hostname": "Moonlight Deck"}]} + + def pending_pair_requests(self): + return {"success": True, "requests": [{"pair_secret": "secret-1", "client_ip": "1.2.3.4", "hostname": "Laptop"}]} + + def sessions(self): + return {"success": True, "sessions": [{"id": "session-1"}]} + + def apps(self): + return {"success": True, "apps": [{"name": "Steam"}]} + + def pair_client(self, pair_secret: str, pin: str): + self.paired.append((pair_secret, pin)) + return {"success": True} + + +class DummyGatekeeper: + def __init__(self) -> None: + self.unlocks: list[tuple[str, int, str, str | None]] = [] + + def enabled(self) -> bool: + return True + + def status(self): + return {"success": True, "active_unlocks": [{"ip": "1.2.3.4", "expires_in_seconds": 300}]} + + def unlock(self, ip: str, ttl_seconds: int, actor: str, target_user: str | None = None): + self.unlocks.append((ip, ttl_seconds, actor, target_user)) + return {"success": True, "ip": ip, "ttl_seconds": ttl_seconds, "target_user": target_user} + + def revoke(self, ip: str): + return {"success": True, "revoked": ip} + + def test_game_stream_dashboard(monkeypatch) -> None: ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) client = _client(monkeypatch, ctx) @@ -20,6 +62,95 @@ def test_game_stream_profile_me(monkeypatch) -> None: assert resp.json()["allowed"] is True +def test_game_stream_status_for_user(monkeypatch) -> None: + ctx = AuthContext(username="olya", email="", groups=["game-stream-users"], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module, "wolf_api", DummyWolfApi()) + monkeypatch.setattr(app_module, "wolf_gatekeeper", DummyGatekeeper()) + monkeypatch.setattr( + app_module.game_mode, + "status", + lambda: { + "status": "idle", + "active": False, + "node": "titan-24", + "game": "unknown", + "workloads": [{"namespace": "openclaw", "name": "openclaw-ollama", "effective_replicas": 1}], + }, + ) + + resp = client.get("/api/game-stream/status?source_ip=1.2.3.4", headers={"Authorization": "Bearer token"}) + data = resp.json() + + assert resp.status_code == 200 + assert data["can_control_gpu"] is False + assert data["gpu"]["priority"] == "ai" + assert data["moonlight"]["host"] == app_module.settings.game_stream_moonlight_host + assert data["wolf"]["clients"][0]["name"] == "Moonlight Deck" + assert data["wolf"]["pending_pair_requests"][0]["pair_secret"] == "secret-1" + + +def test_game_stream_firewall_unlocks(monkeypatch) -> None: + ctx = AuthContext(username="olya", email="", groups=["game-stream-users"], claims={}) + client = _client(monkeypatch, ctx) + gatekeeper = DummyGatekeeper() + monkeypatch.setattr(app_module, "wolf_gatekeeper", gatekeeper) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + resp = client.post("/api/game-stream/firewall/unlock", headers={"Authorization": "Bearer token"}, json={"ip": "1.2.3.4"}) + + assert resp.status_code == 200 + assert gatekeeper.unlocks == [("1.2.3.4", app_module.settings.game_stream_firewall_unlock_ttl_sec, "olya", "olya")] + + +def test_game_stream_admin_manual_unlock(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + gatekeeper = DummyGatekeeper() + monkeypatch.setattr(app_module, "wolf_gatekeeper", gatekeeper) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + resp = client.post( + "/api/admin/game-stream/firewall/unlock", + headers={"Authorization": "Bearer token"}, + json={"ip": "5.6.7.8", "target_user": "olya"}, + ) + + assert resp.status_code == 200 + assert gatekeeper.unlocks == [("5.6.7.8", app_module.settings.game_stream_firewall_unlock_ttl_sec, "bstein", "olya")] + + +def test_game_stream_pairing_submit_pin(monkeypatch) -> None: + ctx = AuthContext(username="olya", email="", groups=["game-stream-users"], claims={}) + client = _client(monkeypatch, ctx) + wolf = DummyWolfApi() + monkeypatch.setattr(app_module, "wolf_api", wolf) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + resp = client.post( + "/api/game-stream/pairing/submit-pin", + headers={"Authorization": "Bearer token"}, + json={"source_ip": "1.2.3.4", "pair_secret": "secret-1", "pin": "1234"}, + ) + + assert resp.status_code == 200 + assert wolf.paired == [("secret-1", "1234")] + + +def test_game_stream_pairing_blocks_other_pending_ip(monkeypatch) -> None: + ctx = AuthContext(username="olya", email="", groups=["game-stream-users"], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module, "wolf_api", DummyWolfApi()) + + resp = client.post( + "/api/game-stream/pairing/submit-pin", + headers={"Authorization": "Bearer token"}, + json={"source_ip": "5.6.7.8", "pair_secret": "secret-1", "pin": "1234"}, + ) + + assert resp.status_code == 403 + + def test_game_mode_admin_start_and_stop(monkeypatch) -> None: ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) client = _client(monkeypatch, ctx)