From 668a01081e7d4f5534ba25afd17da3fc781e75ee Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 21 May 2026 15:53:17 -0300 Subject: [PATCH] account: add Wolf game streaming controls --- backend/atlas_portal/routes/account.py | 2 + backend/atlas_portal/routes/account_wolf.py | 104 +++++++++++ backend/tests/test_account_wolf.py | 76 +++++++++ frontend/src/account/useAccountDashboard.js | 161 ++++++++++++++++++ frontend/src/styles/account.css | 39 +++++ frontend/src/views/AccountView.vue | 121 +++++++++++++ .../frontend/unit/account-dashboard.spec.js | 66 +++++++ 7 files changed, 569 insertions(+) create mode 100644 backend/atlas_portal/routes/account_wolf.py create mode 100644 backend/tests/test_account_wolf.py 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 @@
+
+
+

Wolf

+ + {{ wolf.loading ? "loading..." : wolf.status }} + +
+
+
+ Moonlight + {{ wolf.moonlightHost }} +
+
+ Your IP + {{ wolf.sourceIp || "unknown" }} +
+
+ GPU priority + {{ wolf.gpuPriority }} +
+
+ Firewall unlocks + {{ wolf.activeUnlocks.length }} +
+
+ Paired devices + {{ wolf.clients.map((client) => client.name).join(", ") || "none" }} +
+
+ +
+ + +
+ +
+
+
Pairing
+
+
+
{{ req.name || "pending device" }}
+ + +
+
+ +
+
+
Admin
+
+
+ + + + +
+
+ + + +
+
+ +
+ Active sessions: {{ wolf.sessions.length }} +
+
+
{{ wolf.error }}
+
+
+

Vaultwarden

@@ -428,6 +543,7 @@ const { nextcloudMail, wger, firefly, + wolf, admin, onboardingUrl, vaultwardenReady, @@ -442,6 +558,11 @@ const { resetWger, resetFirefly, syncNextcloudMail, + refreshWolf, + unlockWolf, + pairWolf, + setWolfGameMode, + adminUnlockWolfIp, approve, deny, copy, diff --git a/testing/frontend/unit/account-dashboard.spec.js b/testing/frontend/unit/account-dashboard.spec.js index 40650d0..8e41ff5 100644 --- a/testing/frontend/unit/account-dashboard.spec.js +++ b/testing/frontend/unit/account-dashboard.spec.js @@ -103,6 +103,7 @@ describe("account dashboard", () => { expect(dashboard.mailu.status).toBe("login required"); expect(dashboard.vaultwardenDisplayStatus.value).toBe("login required"); + expect(dashboard.wolf.status).toBe("login required"); expect(dashboard.admin.enabled).toBe(false); wrapper.unmount(); @@ -144,6 +145,7 @@ describe("account dashboard", () => { expect(dashboard.vaultwardenReady.value).toBe(true); expect(dashboard.vaultwardenOrder.value).toBe(3); expect(dashboard.jellyfin.syncStatus).toBe("ok"); + expect(dashboard.wolf.status).toBe("degraded"); expect(dashboard.onboardingUrl.value).toBe("/onboarding?code=ada"); expect(dashboard.admin.enabled).toBe(true); expect(dashboard.admin.flags).toEqual(["media", "budget"]); @@ -161,6 +163,7 @@ describe("account dashboard", () => { const view = mount(AccountView); await flushPromises(); expect(view.text()).toContain("Firefly III"); + expect(view.text()).toContain("Wolf"); expect(view.text()).toContain("Admin Approvals"); expect(view.text()).toContain("newuser"); expect(view.find("a[href='https://sso.example.dev/account/password']").exists()).toBe(true); @@ -198,6 +201,69 @@ describe("account dashboard", () => { wrapper.unmount(); }); + it("loads and controls Wolf account state", async () => { + const seen = []; + installFetch((url, options = {}) => { + seen.push({ url, body: options.body || "" }); + if (url.includes("/api/account/wolf/firewall/unlock")) return jsonResponse({ success: true }); + if (url.includes("/api/account/wolf/pairing/submit-pin")) return jsonResponse({ success: true }); + if (url.includes("/api/account/wolf/game-mode/start")) return jsonResponse({ status: "active" }); + if (url.includes("/api/account/wolf/game-mode/stop")) return jsonResponse({ status: "idle" }); + if (url.includes("/api/account/wolf/admin/firewall/unlock")) return jsonResponse({ success: true }); + if (url.includes("/api/account/wolf")) { + return jsonResponse({ + can_control_gpu: true, + moonlight: { host: "moonlight.bstein.dev", source_ip: "1.2.3.4", unlock_ttl_seconds: 28800 }, + gpu: { priority: "ai", game_mode: { status: "idle", active: false } }, + wolf: { + api_enabled: true, + clients: [{ name: "Desktop" }], + pending_pair_requests: [{ name: "Laptop", pair_secret: "secret-1" }], + sessions: [], + apps: [{ name: "Steam" }], + }, + firewall: { active_unlocks: [{ ip: "1.2.3.4" }] }, + }); + } + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] }); + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] }); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + expect(dashboard.wolf.status).toBe("ready"); + expect(dashboard.wolf.canControlGpu).toBe(true); + expect(dashboard.wolf.clients[0].name).toBe("Desktop"); + expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1"); + + await dashboard.unlockWolf(); + dashboard.wolf.pinInputs["secret-1"] = "1234"; + await dashboard.pairWolf("secret-1"); + dashboard.wolf.selectedGame = "arc-raiders"; + dashboard.wolf.note = "now"; + await dashboard.setWolfGameMode("start"); + await dashboard.setWolfGameMode("stop"); + dashboard.wolf.manualIp = "5.6.7.8"; + dashboard.wolf.manualUser = "olya"; + await dashboard.adminUnlockWolfIp(); + + expect(JSON.parse(seen.find((item) => item.url.includes("/pairing/submit-pin")).body)).toEqual({ + pair_secret: "secret-1", + pin: "1234", + }); + expect(JSON.parse(seen.find((item) => item.url.includes("/game-mode/start")).body)).toEqual({ + game: "arc-raiders", + note: "now", + }); + expect(JSON.parse(seen.find((item) => item.url.includes("/admin/firewall/unlock")).body)).toEqual({ + ip: "5.6.7.8", + target_user: "olya", + }); + wrapper.unmount(); + }); + it("handles service action warnings and failures", async () => { const rotateResponses = [ { password: "mail-a", sync_enabled: false },