diff --git a/backend/atlas_portal/routes/account_wolf.py b/backend/atlas_portal/routes/account_wolf.py index 603b78c..988eb44 100644 --- a/backend/atlas_portal/routes/account_wolf.py +++ b/backend/atlas_portal/routes/account_wolf.py @@ -50,6 +50,29 @@ def _client_ip() -> str: return _public_ip_from_chain(chain) +def _public_payload_ip(payload: dict[str, Any] | None = None) -> str: + values = [] + if payload: + value = payload.get("ip") or payload.get("source_ip") + if isinstance(value, str): + values.append(value) + query_ip = request.args.get("source_ip", "") + if query_ip: + values.append(query_ip) + for value in values: + try: + ip = ipaddress.ip_address(value.strip()) + except ValueError: + continue + if ip.is_global: + return str(ip) + return "" + + +def _source_ip(payload: dict[str, Any] | None = None) -> str: + return _public_payload_ip(payload) or _client_ip() + + def _json_payload() -> dict[str, Any]: payload = request.get_json(silent=True) return payload if isinstance(payload, dict) else {} @@ -73,7 +96,7 @@ def register_account_wolf(app) -> None: ok, resp = _require_account() if not ok: return resp - return ariadne_client.proxy("GET", "/api/game-stream/status", params={"source_ip": _client_ip()}) + return ariadne_client.proxy("GET", "/api/game-stream/status", params={"source_ip": _source_ip()}) @app.route("/api/account/wolf/firewall/unlock", methods=["POST"]) @require_auth @@ -82,7 +105,7 @@ def register_account_wolf(app) -> None: if not ok: return resp payload = _json_payload() - payload["ip"] = _client_ip() + payload["ip"] = _source_ip(payload) return ariadne_client.proxy("POST", "/api/game-stream/firewall/unlock", payload=payload) @app.route("/api/account/wolf/firewall/revoke", methods=["POST"]) @@ -92,7 +115,7 @@ def register_account_wolf(app) -> None: if not ok: return resp payload = _json_payload() - payload["ip"] = _client_ip() + payload["ip"] = _source_ip(payload) return ariadne_client.proxy("POST", "/api/game-stream/firewall/revoke", payload=payload) @app.route("/api/account/wolf/pairing/status", methods=["GET"]) @@ -101,7 +124,7 @@ def register_account_wolf(app) -> None: ok, resp = _require_account() if not ok: return resp - return ariadne_client.proxy("GET", "/api/game-stream/pairing/status", params={"source_ip": _client_ip()}) + return ariadne_client.proxy("GET", "/api/game-stream/pairing/status", params={"source_ip": _source_ip()}) @app.route("/api/account/wolf/pairing/submit-pin", methods=["POST"]) @require_auth @@ -110,7 +133,7 @@ def register_account_wolf(app) -> None: if not ok: return resp payload = _json_payload() - payload["source_ip"] = _client_ip() + payload["source_ip"] = _source_ip(payload) return ariadne_client.proxy("POST", "/api/game-stream/pairing/submit-pin", payload=payload) @app.route("/api/account/wolf/game-mode/start", methods=["POST"]) diff --git a/backend/tests/test_account_wolf.py b/backend/tests/test_account_wolf.py index 577eab4..c024609 100644 --- a/backend/tests/test_account_wolf.py +++ b/backend/tests/test_account_wolf.py @@ -45,6 +45,18 @@ def test_wolf_status_proxies_source_ip(monkeypatch) -> None: assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"})] +def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None: + client, ariadne = make_client(monkeypatch) + + resp = client.get( + "/api/account/wolf?source_ip=181.1.87.186", + environ_base={"REMOTE_ADDR": "10.42.28.0"}, + ) + + assert resp.status_code == 200 + assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"})] + + def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None: client, ariadne = make_client(monkeypatch) @@ -59,6 +71,32 @@ def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None: assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)] +def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None: + client, ariadne = make_client(monkeypatch) + + resp = client.post( + "/api/account/wolf/firewall/unlock", + json={"ip": "181.1.87.186"}, + environ_base={"REMOTE_ADDR": "10.42.28.0"}, + ) + + assert resp.status_code == 200 + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)] + + +def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None: + client, ariadne = make_client(monkeypatch) + + resp = client.post( + "/api/account/wolf/firewall/unlock", + json={"ip": "10.0.0.5"}, + environ_base={"REMOTE_ADDR": "5.6.7.8"}, + ) + + assert resp.status_code == 200 + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None)] + + def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None: client, ariadne = make_client(monkeypatch) @@ -76,10 +114,10 @@ def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None: def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: client, ariadne = make_client(monkeypatch) - client.get("/api/account/wolf/pairing/status", environ_base={"REMOTE_ADDR": "1.2.3.4"}) + client.get("/api/account/wolf/pairing/status?source_ip=181.1.87.186", environ_base={"REMOTE_ADDR": "1.2.3.4"}) client.post( "/api/account/wolf/pairing/submit-pin", - json={"pair_secret": "secret", "pin": "1234"}, + json={"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, environ_base={"REMOTE_ADDR": "1.2.3.4"}, ) client.post("/api/account/wolf/game-mode/start", json={"game": "steam"}) @@ -87,15 +125,15 @@ def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: 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), + ("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}), + ("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, 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_user_revoke_cannot_choose_arbitrary_ip(monkeypatch) -> None: +def test_wolf_user_revoke_accepts_public_payload_ip(monkeypatch) -> None: client, ariadne = make_client(monkeypatch) resp = client.post( @@ -105,7 +143,7 @@ def test_wolf_user_revoke_cannot_choose_arbitrary_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "1.2.3.4"}, None)] + assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None)] def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None: diff --git a/frontend/src/account/useWolfDashboard.js b/frontend/src/account/useWolfDashboard.js index 17414f2..d52e4b9 100644 --- a/frontend/src/account/useWolfDashboard.js +++ b/frontend/src/account/useWolfDashboard.js @@ -2,6 +2,32 @@ import { reactive } from "vue"; import { auth, authFetch } from "@/auth"; import { formatActionError } from "./accountErrors"; +const IP_DISCOVERY_URL = "https://api.ipify.org?format=json"; + +async function resolveClientIp() { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 3000); + try { + const resp = await fetch(IP_DISCOVERY_URL, { + headers: { Accept: "application/json" }, + cache: "no-store", + signal: controller.signal, + }); + const data = await resp.json().catch(() => ({})); + return typeof data?.ip === "string" ? data.ip.trim() : ""; + } catch { + return ""; + } finally { + window.clearTimeout(timeout); + } +} + +function withSourceIp(path, sourceIp) { + if (!sourceIp) return path; + const query = new URLSearchParams({ source_ip: sourceIp }); + return `${path}?${query.toString()}`; +} + /** * Build the Wolf card state and actions for the Account page. * @@ -43,7 +69,8 @@ export function useWolfDashboard() { wolf.error = ""; wolf.loading = true; try { - const resp = await authFetch("/api/account/wolf", { + const sourceIp = await resolveClientIp(); + const resp = await authFetch(withSourceIp("/api/account/wolf", sourceIp), { headers: { Accept: "application/json" }, cache: "no-store", }); @@ -79,10 +106,11 @@ export function useWolfDashboard() { wolf.error = ""; wolf.unlocking = true; try { + const sourceIp = await resolveClientIp(); const resp = await authFetch("/api/account/wolf/firewall/unlock", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify(sourceIp ? { ip: sourceIp } : {}), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); @@ -100,10 +128,11 @@ export function useWolfDashboard() { wolf.pairing[pairSecret] = true; try { const pin = wolf.pinInputs[pairSecret] || ""; + const sourceIp = await resolveClientIp(); const resp = await authFetch("/api/account/wolf/pairing/submit-pin", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pair_secret: pairSecret, pin }), + body: JSON.stringify({ pair_secret: pairSecret, pin, ...(sourceIp ? { source_ip: sourceIp } : {}) }), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); diff --git a/testing/frontend/unit/account-dashboard.spec.js b/testing/frontend/unit/account-dashboard.spec.js index 961f910..3a184d6 100644 --- a/testing/frontend/unit/account-dashboard.spec.js +++ b/testing/frontend/unit/account-dashboard.spec.js @@ -205,6 +205,7 @@ describe("account dashboard", () => { const seen = []; installFetch((url, options = {}) => { seen.push({ url, body: options.body || "" }); + if (url.includes("api.ipify.org")) return jsonResponse({ ip: "181.1.87.186" }); 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" }); @@ -213,7 +214,7 @@ describe("account dashboard", () => { 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 }, + moonlight: { host: "moonlight.bstein.dev", source_ip: "181.1.87.186", unlock_ttl_seconds: 28800 }, gpu: { priority: "ai", game_mode: { status: "idle", active: false } }, wolf: { api_enabled: true, @@ -222,7 +223,7 @@ describe("account dashboard", () => { sessions: [], apps: [{ name: "Steam" }], }, - firewall: { active_unlocks: [{ ip: "1.2.3.4" }] }, + firewall: { active_unlocks: [{ ip: "181.1.87.186" }] }, }); } if (url.includes("/api/account/overview")) return jsonResponse(overview()); @@ -249,9 +250,14 @@ describe("account dashboard", () => { dashboard.wolf.manualUser = "olya"; await dashboard.adminUnlockWolfIp(); + expect(seen.some((item) => item.url.includes("/api/account/wolf?source_ip=181.1.87.186"))).toBe(true); + expect(JSON.parse(seen.find((item) => item.url.includes("/firewall/unlock")).body)).toEqual({ + ip: "181.1.87.186", + }); expect(JSON.parse(seen.find((item) => item.url.includes("/pairing/submit-pin")).body)).toEqual({ pair_secret: "secret-1", pin: "1234", + source_ip: "181.1.87.186", }); expect(JSON.parse(seen.find((item) => item.url.includes("/game-mode/start")).body)).toEqual({ game: "arc-raiders", @@ -266,6 +272,7 @@ describe("account dashboard", () => { it("handles Wolf login and action failures", async () => { installFetch((url) => { + if (url.includes("api.ipify.org")) return jsonResponse({ ip: "181.1.87.186" }); if (url.includes("/api/account/wolf/firewall/unlock")) return jsonResponse({ error: "unlock failed" }, 500); if (url.includes("/api/account/wolf/pairing/submit-pin")) return jsonResponse({ error: "pair failed" }, 500); if (url.includes("/api/account/wolf/game-mode/start")) return jsonResponse({ error: "mode failed" }, 500);