account: add Wolf game streaming controls

This commit is contained in:
codex 2026-05-21 15:53:17 -03:00
parent 62540dfb59
commit 668a01081e
7 changed files with 569 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -192,6 +192,121 @@
</div>
<div class="account-stack">
<div class="card module wolf-card" :style="{ order: 0 }">
<div class="module-head">
<h2>Wolf</h2>
<span
class="pill mono"
:class="
wolf.status === 'ready'
? 'pill-ok'
: wolf.status === 'degraded'
? 'pill-warn'
: wolf.status === 'unavailable' || wolf.status === 'error'
? 'pill-bad'
: ''
"
>
{{ wolf.loading ? "loading..." : wolf.status }}
</span>
</div>
<div class="kv">
<div class="row">
<span class="k mono">Moonlight</span>
<span class="v mono">{{ wolf.moonlightHost }}</span>
</div>
<div class="row">
<span class="k mono">Your IP</span>
<span class="v mono">{{ wolf.sourceIp || "unknown" }}</span>
</div>
<div class="row">
<span class="k mono">GPU priority</span>
<span class="v mono">{{ wolf.gpuPriority }}</span>
</div>
<div class="row">
<span class="k mono">Firewall unlocks</span>
<span class="v mono">{{ wolf.activeUnlocks.length }}</span>
</div>
<div class="row">
<span class="k mono">Paired devices</span>
<span class="v mono">{{ wolf.clients.map((client) => client.name).join(", ") || "none" }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="wolf.unlocking" @click="unlockWolf">
{{ wolf.unlocking ? "Unlocking..." : "Unlock Moonlight" }}
</button>
<button class="copy mono" type="button" :disabled="wolf.loading" @click="refreshWolf">refresh</button>
</div>
<div v-if="wolf.pendingPairRequests.length" class="secret-box">
<div class="secret-head">
<div class="pill mono">Pairing</div>
</div>
<div v-for="req in wolf.pendingPairRequests" :key="req.pair_secret || req.name" class="pair-row">
<div class="mono pair-name">{{ req.name || "pending device" }}</div>
<input
v-model="wolf.pinInputs[req.pair_secret]"
class="input mono"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="PIN"
:disabled="wolf.pairing[req.pair_secret]"
/>
<button
class="primary"
type="button"
:disabled="wolf.pairing[req.pair_secret]"
@click="pairWolf(req.pair_secret)"
>
{{ wolf.pairing[req.pair_secret] ? "Pairing..." : "Pair" }}
</button>
</div>
</div>
<div v-if="wolf.canControlGpu" class="secret-box">
<div class="secret-head">
<div class="pill mono">Admin</div>
</div>
<div class="wolf-controls">
<select v-model="wolf.selectedGame" class="input mono">
<option value="steam">Steam</option>
<option value="arc-raiders">Arc Raiders</option>
<option value="satisfactory">Satisfactory</option>
<option value="wolf">Wolf</option>
</select>
<input v-model="wolf.note" class="input mono" type="text" placeholder="note" />
<button
class="primary"
type="button"
:disabled="Boolean(wolf.actioning)"
@click="setWolfGameMode('start')"
>
{{ wolf.actioning === "start" ? "Starting..." : "Prioritize Wolf" }}
</button>
<button class="copy mono" type="button" :disabled="Boolean(wolf.actioning)" @click="setWolfGameMode('stop')">
{{ wolf.actioning === "stop" ? "Stopping..." : "Restore AI" }}
</button>
</div>
<div class="wolf-controls manual-unlock">
<input v-model="wolf.manualIp" class="input mono" type="text" placeholder="IP address" />
<input v-model="wolf.manualUser" class="input mono" type="text" placeholder="user" />
<button class="primary" type="button" :disabled="wolf.manualUnlocking" @click="adminUnlockWolfIp">
{{ wolf.manualUnlocking ? "Unlocking..." : "Unlock IP" }}
</button>
</div>
</div>
<div v-if="wolf.sessions.length" class="hint mono">
Active sessions: {{ wolf.sessions.length }}
</div>
<div v-if="wolf.error" class="error-box">
<div class="mono">{{ wolf.error }}</div>
</div>
</div>
<div class="card module" :style="{ order: vaultwardenOrder }">
<div class="module-head">
<h2>Vaultwarden</h2>
@ -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,

View File

@ -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 },