account: resolve Wolf unlock public IP
This commit is contained in:
parent
1a231dd474
commit
9ff34e7661
@ -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"])
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user