game-stream: add Wolf portal APIs

This commit is contained in:
codex 2026-05-21 15:53:17 -03:00
parent 0bbcdfb7a7
commit c927d38f75
11 changed files with 748 additions and 0 deletions

View File

@ -47,6 +47,8 @@ from .services.testing_triage import (
from .services.vault import vault from .services.vault import vault
from .services.vaultwarden_sync import run_vaultwarden_sync from .services.vaultwarden_sync import run_vaultwarden_sync
from .services.wger import wger from .services.wger import wger
from .services.wolf_api import wolf_api
from .services.wolf_gatekeeper import wolf_gatekeeper
from .settings import settings from .settings import settings
from .utils.http import extract_bearer_token from .utils.http import extract_bearer_token
from .utils.logging import LogConfig, configure_logging, get_logger from .utils.logging import LogConfig, configure_logging, get_logger

View File

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from html import escape from html import escape
import ipaddress
import secrets import secrets
from typing import Any, Callable from typing import Any, Callable
@ -10,6 +11,11 @@ from fastapi.responses import HTMLResponse, JSONResponse
from .auth.keycloak import AuthContext from .auth.keycloak import AuthContext
from .db.storage import TaskRunRecord 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 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" 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: def _record_simple_task(module: Any, task_name: str, started: datetime, status: str, detail: str | None = None) -> None:
finished = datetime.now(timezone.utc) finished = datetime.now(timezone.utc)
duration_sec = (finished - started).total_seconds() duration_sec = (finished - started).total_seconds()
@ -333,6 +509,188 @@ def _register_game_routes(app: FastAPI, require_auth: Callable, deps: Callable[[
module = deps() module = deps()
return JSONResponse(module.game_stream_profiles.profile_for(ctx.username or "", ctx.groups)) 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") @app.get("/api/admin/game-mode/status")
def get_game_mode_status(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: def get_game_mode_status(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
"""Return the current game-mode state for authenticated administrators.""" """Return the current game-mode state for authenticated administrators."""

View File

@ -89,6 +89,20 @@ GAME_MODE_LAST_TRANSITION_TS = Gauge(
"Last Ariadne game mode transition timestamp", "Last Ariadne game mode transition timestamp",
["action", "status", "game"], ["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: 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: if replicas is not None:
GAME_MODE_MANAGED_REPLICAS.labels(namespace=namespace, deployment=deployment).set(replicas) 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()

View File

@ -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()

View File

@ -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()

View File

@ -237,6 +237,12 @@ class Settings:
wolf_oidc_base_url: str wolf_oidc_base_url: str
wolf_oidc_vault_path: str wolf_oidc_vault_path: str
wolf_oidc_cron: 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_user_group: str
game_stream_admin_group: str game_stream_admin_group: str
game_stream_profile_group_prefix: str game_stream_profile_group_prefix: str

View File

@ -345,6 +345,12 @@ def _game_stream_config() -> dict[str, Any]:
_env("SUNSHINE_OIDC_BASE_URL", "https://wolf.bstein.dev"), _env("SUNSHINE_OIDC_BASE_URL", "https://wolf.bstein.dev"),
).rstrip("/"), ).rstrip("/"),
"wolf_oidc_vault_path": _env("WOLF_OIDC_VAULT_PATH", _env("SUNSHINE_OIDC_VAULT_PATH", "game-stream/wolf-oidc")), "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_user_group": _env("GAME_STREAM_USER_GROUP", "game-stream-users"),
"game_stream_admin_group": _env("GAME_STREAM_ADMIN_GROUP", "admin"), "game_stream_admin_group": _env("GAME_STREAM_ADMIN_GROUP", "admin"),
"game_stream_profile_group_prefix": _env("GAME_STREAM_PROFILE_GROUP_PREFIX", "game-stream-profile-"), "game_stream_profile_group_prefix": _env("GAME_STREAM_PROFILE_GROUP_PREFIX", "game-stream-profile-"),

View File

@ -8,3 +8,7 @@ def test_game_mode_metric_helpers() -> None:
metrics.record_game_mode_transition("", "", "") metrics.record_game_mode_transition("", "", "")
metrics.set_game_mode_managed_replicas("openclaw", "ollama", 0) metrics.set_game_mode_managed_replicas("openclaw", "ollama", 0)
metrics.set_game_mode_managed_replicas("openclaw", "ollama", None) 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("")

View File

@ -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_BASE_URL", "https://wolf.bstein.dev/")
monkeypatch.setenv("WOLF_OIDC_VAULT_PATH", "game-stream/wolf-oidc") monkeypatch.setenv("WOLF_OIDC_VAULT_PATH", "game-stream/wolf-oidc")
monkeypatch.setenv("ARIADNE_SCHEDULE_WOLF_OIDC", "*/13 * * * *") 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() 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_base_url == "https://wolf.bstein.dev"
assert cfg.wolf_oidc_vault_path == "game-stream/wolf-oidc" assert cfg.wolf_oidc_vault_path == "game-stream/wolf-oidc"
assert cfg.wolf_oidc_cron == "*/13 * * * *" 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"

115
tests/test_wolf_services.py Normal file
View File

@ -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()

View File

@ -1,6 +1,48 @@
from tests.unit.app.app_route_helpers import * 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: def test_game_stream_dashboard(monkeypatch) -> None:
ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={})
client = _client(monkeypatch, ctx) client = _client(monkeypatch, ctx)
@ -20,6 +62,95 @@ def test_game_stream_profile_me(monkeypatch) -> None:
assert resp.json()["allowed"] is True 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: def test_game_mode_admin_start_and_stop(monkeypatch) -> None:
ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={})
client = _client(monkeypatch, ctx) client = _client(monkeypatch, ctx)