game-stream: add Wolf portal APIs
This commit is contained in:
parent
0bbcdfb7a7
commit
c927d38f75
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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()
|
||||
|
||||
44
ariadne/services/wolf_api.py
Normal file
44
ariadne/services/wolf_api.py
Normal 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()
|
||||
41
ariadne/services/wolf_gatekeeper.py
Normal file
41
ariadne/services/wolf_gatekeeper.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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-"),
|
||||
|
||||
@ -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("")
|
||||
|
||||
@ -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
115
tests/test_wolf_services.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user