diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index 9acf3a4..8c5215b 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -4,6 +4,7 @@ from __future__ import annotations from .account_actions import register_account_actions from .account_overview import register_account_overview +from .account_wolf import register_account_wolf def register(app) -> None: @@ -11,3 +12,4 @@ def register(app) -> None: register_account_overview(app) register_account_actions(app) + register_account_wolf(app) diff --git a/backend/atlas_portal/routes/account_wolf.py b/backend/atlas_portal/routes/account_wolf.py new file mode 100644 index 0000000..15be7a1 --- /dev/null +++ b/backend/atlas_portal/routes/account_wolf.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from flask import jsonify, request + +from .. import ariadne_client +from ..keycloak import require_auth, require_account_access + + +def _client_ip() -> str: + for header in ("CF-Connecting-IP", "X-Forwarded-For", "X-Real-IP"): + value = request.headers.get(header, "").strip() + if value: + return value.split(",", 1)[0].strip() + return request.remote_addr or "" + + +def _json_payload() -> dict[str, Any]: + payload = request.get_json(silent=True) + return payload if isinstance(payload, dict) else {} + + +def _require_account() -> tuple[bool, Any]: + ok, resp = require_account_access() + if not ok: + return False, resp + if not ariadne_client.enabled(): + return False, (jsonify({"error": "ariadne not configured"}), 503) + return True, None + + +def register_account_wolf(app) -> None: + """Register Wolf/Moonlight self-service account routes.""" + + @app.route("/api/account/wolf", methods=["GET"]) + @require_auth + def account_wolf_status() -> Any: + ok, resp = _require_account() + if not ok: + return resp + return ariadne_client.proxy("GET", "/api/game-stream/status", params={"source_ip": _client_ip()}) + + @app.route("/api/account/wolf/firewall/unlock", methods=["POST"]) + @require_auth + def account_wolf_firewall_unlock() -> Any: + ok, resp = _require_account() + if not ok: + return resp + payload = _json_payload() + payload["ip"] = _client_ip() + return ariadne_client.proxy("POST", "/api/game-stream/firewall/unlock", payload=payload) + + @app.route("/api/account/wolf/firewall/revoke", methods=["POST"]) + @require_auth + def account_wolf_firewall_revoke() -> Any: + ok, resp = _require_account() + if not ok: + return resp + payload = _json_payload() + payload["ip"] = payload.get("ip") or _client_ip() + return ariadne_client.proxy("POST", "/api/game-stream/firewall/revoke", payload=payload) + + @app.route("/api/account/wolf/pairing/status", methods=["GET"]) + @require_auth + def account_wolf_pairing_status() -> Any: + ok, resp = _require_account() + if not ok: + return resp + return ariadne_client.proxy("GET", "/api/game-stream/pairing/status", params={"source_ip": _client_ip()}) + + @app.route("/api/account/wolf/pairing/submit-pin", methods=["POST"]) + @require_auth + def account_wolf_pairing_submit_pin() -> Any: + ok, resp = _require_account() + if not ok: + return resp + payload = _json_payload() + payload["source_ip"] = _client_ip() + return ariadne_client.proxy("POST", "/api/game-stream/pairing/submit-pin", payload=payload) + + @app.route("/api/account/wolf/game-mode/start", methods=["POST"]) + @require_auth + def account_wolf_game_mode_start() -> Any: + ok, resp = _require_account() + if not ok: + return resp + return ariadne_client.proxy("POST", "/api/admin/game-mode/start", payload=_json_payload()) + + @app.route("/api/account/wolf/game-mode/stop", methods=["POST"]) + @require_auth + def account_wolf_game_mode_stop() -> Any: + ok, resp = _require_account() + if not ok: + return resp + return ariadne_client.proxy("POST", "/api/admin/game-mode/stop", payload=_json_payload()) + + @app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"]) + @require_auth + def account_wolf_admin_firewall_unlock() -> Any: + ok, resp = _require_account() + if not ok: + return resp + return ariadne_client.proxy("POST", "/api/admin/game-stream/firewall/unlock", payload=_json_payload()) diff --git a/backend/tests/test_account_wolf.py b/backend/tests/test_account_wolf.py new file mode 100644 index 0000000..c91aa59 --- /dev/null +++ b/backend/tests/test_account_wolf.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from flask import Flask, jsonify + +from atlas_portal.routes import account_wolf + + +class DummyAriadne: + def __init__(self, enabled: bool = True) -> None: + self._enabled = enabled + self.calls: list[tuple[str, str, object | None, dict | None]] = [] + + def enabled(self) -> bool: + return self._enabled + + def proxy(self, method: str, path: str, payload: object | None = None, params: dict | None = None): + self.calls.append((method, path, payload, params)) + return jsonify({"path": path, "payload": payload, "params": params}) + + +def make_client(monkeypatch, *, ariadne: DummyAriadne | None = None, account_ok: bool = True): + app = Flask(__name__) + active_ariadne = ariadne or DummyAriadne() + monkeypatch.setattr(account_wolf, "ariadne_client", active_ariadne) + monkeypatch.setattr(account_wolf, "require_auth", lambda fn: fn) + monkeypatch.setattr( + account_wolf, + "require_account_access", + lambda: (True, None) if account_ok else (False, (jsonify({"error": "forbidden"}), 403)), + ) + account_wolf.register_account_wolf(app) + return app.test_client(), active_ariadne + + +def test_wolf_status_proxies_source_ip(monkeypatch) -> None: + client, ariadne = make_client(monkeypatch) + + resp = client.get("/api/account/wolf", headers={"X-Forwarded-For": "1.2.3.4, 10.0.0.1"}) + + assert resp.status_code == 200 + assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"})] + + +def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None: + client, ariadne = make_client(monkeypatch) + + resp = client.post("/api/account/wolf/firewall/unlock", json={"ttl_seconds": 120}, headers={"X-Real-IP": "5.6.7.8"}) + + assert resp.status_code == 200 + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)] + + +def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: + client, ariadne = make_client(monkeypatch) + + client.get("/api/account/wolf/pairing/status", headers={"X-Forwarded-For": "1.2.3.4"}) + client.post("/api/account/wolf/pairing/submit-pin", json={"pair_secret": "secret", "pin": "1234"}, headers={"X-Forwarded-For": "1.2.3.4"}) + client.post("/api/account/wolf/game-mode/start", json={"game": "steam"}) + client.post("/api/account/wolf/game-mode/stop", json={"game": "steam"}) + client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"}) + + assert ariadne.calls == [ + ("GET", "/api/game-stream/pairing/status", None, {"source_ip": "1.2.3.4"}), + ("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "1.2.3.4"}, None), + ("POST", "/api/admin/game-mode/start", {"game": "steam"}, None), + ("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None), + ("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None), + ] + + +def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None: + client, _ariadne = make_client(monkeypatch, account_ok=False) + assert client.get("/api/account/wolf").status_code == 403 + + client, _ariadne = make_client(monkeypatch, ariadne=DummyAriadne(enabled=False)) + assert client.get("/api/account/wolf").status_code == 503 diff --git a/frontend/src/account/useAccountDashboard.js b/frontend/src/account/useAccountDashboard.js index d7b11da..723292d 100644 --- a/frontend/src/account/useAccountDashboard.js +++ b/frontend/src/account/useAccountDashboard.js @@ -66,6 +66,33 @@ export function useAccountDashboard() { error: "", }); + const wolf = reactive({ + status: "loading", + error: "", + loading: false, + unlocking: false, + pairing: {}, + actioning: "", + manualUnlocking: false, + canControlGpu: false, + moonlightHost: "moonlight.bstein.dev", + sourceIp: "", + unlockTtlSeconds: 28800, + gpuPriority: "unknown", + gameModeStatus: "unknown", + gameModeActive: false, + selectedGame: "steam", + note: "", + manualIp: "", + manualUser: "", + clients: [], + pendingPairRequests: [], + activeUnlocks: [], + sessions: [], + apps: [], + pinInputs: {}, + }); + const admin = reactive({ enabled: false, loading: false, @@ -91,6 +118,7 @@ export function useAccountDashboard() { onMounted(() => { if (auth.ready && auth.authenticated) { refreshOverview(); + refreshWolf(); refreshAdminRequests(); refreshAdminFlags(); } else { @@ -100,6 +128,7 @@ export function useAccountDashboard() { vaultwarden.status = "login required"; wger.status = "login required"; firefly.status = "login required"; + wolf.status = "login required"; } }); @@ -114,6 +143,7 @@ export function useAccountDashboard() { vaultwarden.status = "login required"; wger.status = "login required"; firefly.status = "login required"; + wolf.status = "login required"; onboardingUrl.value = "/onboarding"; admin.enabled = false; admin.requests = []; @@ -121,6 +151,7 @@ export function useAccountDashboard() { return; } refreshOverview(); + refreshWolf(); refreshAdminRequests(); refreshAdminFlags(); }, @@ -187,6 +218,46 @@ export function useAccountDashboard() { } } + async function refreshWolf() { + if (!auth.authenticated) { + wolf.status = "login required"; + return; + } + wolf.error = ""; + wolf.loading = true; + try { + const resp = await authFetch("/api/account/wolf", { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + + wolf.canControlGpu = Boolean(data.can_control_gpu); + wolf.moonlightHost = data.moonlight?.host || "moonlight.bstein.dev"; + wolf.sourceIp = data.moonlight?.source_ip || ""; + wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800); + wolf.gpuPriority = data.gpu?.priority || "unknown"; + wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown"; + wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active); + wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : []; + wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : []; + wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : []; + wolf.apps = Array.isArray(data.wolf?.apps) ? data.wolf.apps : []; + wolf.activeUnlocks = Array.isArray(data.firewall?.active_unlocks) ? data.firewall.active_unlocks : []; + for (const req of wolf.pendingPairRequests) { + const key = req?.pair_secret || req?.name; + if (key && !(key in wolf.pinInputs)) wolf.pinInputs[key] = ""; + } + wolf.status = data.wolf?.api_enabled ? "ready" : "degraded"; + } catch (err) { + wolf.status = "unavailable"; + wolf.error = formatActionError(err, "Wolf status unavailable"); + } finally { + wolf.loading = false; + } + } + async function refreshAdminRequests() { if (!auth.authenticated) { admin.enabled = false; @@ -382,6 +453,90 @@ export function useAccountDashboard() { } } + async function unlockWolf() { + wolf.error = ""; + wolf.unlocking = true; + try { + const resp = await authFetch("/api/account/wolf/firewall/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "Unlock failed"); + } finally { + wolf.unlocking = false; + } + } + + async function pairWolf(pairSecret) { + if (!pairSecret) return; + wolf.error = ""; + wolf.pairing[pairSecret] = true; + try { + const pin = wolf.pinInputs[pairSecret] || ""; + const resp = await authFetch("/api/account/wolf/pairing/submit-pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pair_secret: pairSecret, pin }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + wolf.pinInputs[pairSecret] = ""; + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "Pairing failed"); + } finally { + wolf.pairing[pairSecret] = false; + } + } + + async function setWolfGameMode(action) { + wolf.error = ""; + wolf.actioning = action; + try { + const resp = await authFetch(`/api/account/wolf/game-mode/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ game: wolf.selectedGame, note: wolf.note }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "GPU priority update failed"); + } finally { + wolf.actioning = ""; + } + } + + async function adminUnlockWolfIp() { + if (!wolf.manualIp) return; + wolf.error = ""; + wolf.manualUnlocking = true; + try { + const payload = { ip: wolf.manualIp }; + if (wolf.manualUser) payload.target_user = wolf.manualUser; + const resp = await authFetch("/api/account/wolf/admin/firewall/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + wolf.manualIp = ""; + wolf.manualUser = ""; + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "Manual unlock failed"); + } finally { + wolf.manualUnlocking = false; + } + } + // WHY: Safari/private-mode clipboard support varies; @returns whether fallback copy completed. function fallbackCopy(text) { const textarea = document.createElement("textarea"); @@ -479,6 +634,7 @@ export function useAccountDashboard() { nextcloudMail, wger, firefly, + wolf, admin, onboardingUrl, vaultwardenReady, @@ -493,6 +649,11 @@ export function useAccountDashboard() { resetWger, resetFirefly, syncNextcloudMail, + refreshWolf, + unlockWolf, + pairWolf, + setWolfGameMode, + adminUnlockWolfIp, approve, deny, copy, diff --git a/frontend/src/styles/account.css b/frontend/src/styles/account.css index 52dc6ba..c9318ed 100644 --- a/frontend/src/styles/account.css +++ b/frontend/src/styles/account.css @@ -187,6 +187,39 @@ button.primary { gap: 8px; } +.input, +.wolf-controls select { + width: 100%; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(0, 0, 0, 0.22); + color: var(--text-primary); + padding: 8px 10px; +} + +.wolf-card .v { + text-align: right; + overflow-wrap: anywhere; +} + +.pair-row, +.wolf-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(90px, 0.7fr) auto; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.manual-unlock { + grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr) auto; + margin-top: 10px; +} + +.pair-name { + overflow-wrap: anywhere; +} + .copied { font-size: 12px; color: rgba(120, 255, 160, 0.9); @@ -240,6 +273,12 @@ button.primary { align-items: stretch; } + .pair-row, + .wolf-controls, + .manual-unlock { + grid-template-columns: 1fr; + } + .secret-head { flex-direction: column; align-items: flex-start; diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 08afc78..443b64e 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -192,6 +192,121 @@