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.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

View File

@ -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."""

View File

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

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_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

View File

@ -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-"),

View File

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

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_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"

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 *
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)