account: resolve Wolf unlock public IP

This commit is contained in:
codex 2026-05-22 02:55:51 -03:00
parent 1a231dd474
commit 9ff34e7661
4 changed files with 113 additions and 16 deletions

View File

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

View File

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

View File

@ -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}`);

View File

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