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)
|
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]:
|
def _json_payload() -> dict[str, Any]:
|
||||||
payload = request.get_json(silent=True)
|
payload = request.get_json(silent=True)
|
||||||
return payload if isinstance(payload, dict) else {}
|
return payload if isinstance(payload, dict) else {}
|
||||||
@ -73,7 +96,7 @@ def register_account_wolf(app) -> None:
|
|||||||
ok, resp = _require_account()
|
ok, resp = _require_account()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
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"])
|
@app.route("/api/account/wolf/firewall/unlock", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
@ -82,7 +105,7 @@ def register_account_wolf(app) -> None:
|
|||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
payload = _json_payload()
|
payload = _json_payload()
|
||||||
payload["ip"] = _client_ip()
|
payload["ip"] = _source_ip(payload)
|
||||||
return ariadne_client.proxy("POST", "/api/game-stream/firewall/unlock", payload=payload)
|
return ariadne_client.proxy("POST", "/api/game-stream/firewall/unlock", payload=payload)
|
||||||
|
|
||||||
@app.route("/api/account/wolf/firewall/revoke", methods=["POST"])
|
@app.route("/api/account/wolf/firewall/revoke", methods=["POST"])
|
||||||
@ -92,7 +115,7 @@ def register_account_wolf(app) -> None:
|
|||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
payload = _json_payload()
|
payload = _json_payload()
|
||||||
payload["ip"] = _client_ip()
|
payload["ip"] = _source_ip(payload)
|
||||||
return ariadne_client.proxy("POST", "/api/game-stream/firewall/revoke", payload=payload)
|
return ariadne_client.proxy("POST", "/api/game-stream/firewall/revoke", payload=payload)
|
||||||
|
|
||||||
@app.route("/api/account/wolf/pairing/status", methods=["GET"])
|
@app.route("/api/account/wolf/pairing/status", methods=["GET"])
|
||||||
@ -101,7 +124,7 @@ def register_account_wolf(app) -> None:
|
|||||||
ok, resp = _require_account()
|
ok, resp = _require_account()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
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"])
|
@app.route("/api/account/wolf/pairing/submit-pin", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
@ -110,7 +133,7 @@ def register_account_wolf(app) -> None:
|
|||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
payload = _json_payload()
|
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)
|
return ariadne_client.proxy("POST", "/api/game-stream/pairing/submit-pin", payload=payload)
|
||||||
|
|
||||||
@app.route("/api/account/wolf/game-mode/start", methods=["POST"])
|
@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"})]
|
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:
|
def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
|
||||||
client, ariadne = make_client(monkeypatch)
|
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)]
|
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:
|
def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None:
|
||||||
client, ariadne = make_client(monkeypatch)
|
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:
|
def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
|
||||||
client, ariadne = make_client(monkeypatch)
|
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(
|
client.post(
|
||||||
"/api/account/wolf/pairing/submit-pin",
|
"/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"},
|
environ_base={"REMOTE_ADDR": "1.2.3.4"},
|
||||||
)
|
)
|
||||||
client.post("/api/account/wolf/game-mode/start", json={"game": "steam"})
|
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"})
|
client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"})
|
||||||
|
|
||||||
assert ariadne.calls == [
|
assert ariadne.calls == [
|
||||||
("GET", "/api/game-stream/pairing/status", None, {"source_ip": "1.2.3.4"}),
|
("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": "1.2.3.4"}, None),
|
("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/start", {"game": "steam"}, None),
|
||||||
("POST", "/api/admin/game-mode/stop", {"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),
|
("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)
|
client, ariadne = make_client(monkeypatch)
|
||||||
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@ -105,7 +143,7 @@ def test_wolf_user_revoke_cannot_choose_arbitrary_ip(monkeypatch) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
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:
|
def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None:
|
||||||
|
|||||||
@ -2,6 +2,32 @@ import { reactive } from "vue";
|
|||||||
import { auth, authFetch } from "@/auth";
|
import { auth, authFetch } from "@/auth";
|
||||||
import { formatActionError } from "./accountErrors";
|
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.
|
* Build the Wolf card state and actions for the Account page.
|
||||||
*
|
*
|
||||||
@ -43,7 +69,8 @@ export function useWolfDashboard() {
|
|||||||
wolf.error = "";
|
wolf.error = "";
|
||||||
wolf.loading = true;
|
wolf.loading = true;
|
||||||
try {
|
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" },
|
headers: { Accept: "application/json" },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@ -79,10 +106,11 @@ export function useWolfDashboard() {
|
|||||||
wolf.error = "";
|
wolf.error = "";
|
||||||
wolf.unlocking = true;
|
wolf.unlocking = true;
|
||||||
try {
|
try {
|
||||||
|
const sourceIp = await resolveClientIp();
|
||||||
const resp = await authFetch("/api/account/wolf/firewall/unlock", {
|
const resp = await authFetch("/api/account/wolf/firewall/unlock", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify(sourceIp ? { ip: sourceIp } : {}),
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||||
@ -100,10 +128,11 @@ export function useWolfDashboard() {
|
|||||||
wolf.pairing[pairSecret] = true;
|
wolf.pairing[pairSecret] = true;
|
||||||
try {
|
try {
|
||||||
const pin = wolf.pinInputs[pairSecret] || "";
|
const pin = wolf.pinInputs[pairSecret] || "";
|
||||||
|
const sourceIp = await resolveClientIp();
|
||||||
const resp = await authFetch("/api/account/wolf/pairing/submit-pin", {
|
const resp = await authFetch("/api/account/wolf/pairing/submit-pin", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||||
|
|||||||
@ -205,6 +205,7 @@ describe("account dashboard", () => {
|
|||||||
const seen = [];
|
const seen = [];
|
||||||
installFetch((url, options = {}) => {
|
installFetch((url, options = {}) => {
|
||||||
seen.push({ url, body: options.body || "" });
|
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/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/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/start")) return jsonResponse({ status: "active" });
|
||||||
@ -213,7 +214,7 @@ describe("account dashboard", () => {
|
|||||||
if (url.includes("/api/account/wolf")) {
|
if (url.includes("/api/account/wolf")) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
can_control_gpu: true,
|
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 } },
|
gpu: { priority: "ai", game_mode: { status: "idle", active: false } },
|
||||||
wolf: {
|
wolf: {
|
||||||
api_enabled: true,
|
api_enabled: true,
|
||||||
@ -222,7 +223,7 @@ describe("account dashboard", () => {
|
|||||||
sessions: [],
|
sessions: [],
|
||||||
apps: [{ name: "Steam" }],
|
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());
|
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||||
@ -249,9 +250,14 @@ describe("account dashboard", () => {
|
|||||||
dashboard.wolf.manualUser = "olya";
|
dashboard.wolf.manualUser = "olya";
|
||||||
await dashboard.adminUnlockWolfIp();
|
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({
|
expect(JSON.parse(seen.find((item) => item.url.includes("/pairing/submit-pin")).body)).toEqual({
|
||||||
pair_secret: "secret-1",
|
pair_secret: "secret-1",
|
||||||
pin: "1234",
|
pin: "1234",
|
||||||
|
source_ip: "181.1.87.186",
|
||||||
});
|
});
|
||||||
expect(JSON.parse(seen.find((item) => item.url.includes("/game-mode/start")).body)).toEqual({
|
expect(JSON.parse(seen.find((item) => item.url.includes("/game-mode/start")).body)).toEqual({
|
||||||
game: "arc-raiders",
|
game: "arc-raiders",
|
||||||
@ -266,6 +272,7 @@ describe("account dashboard", () => {
|
|||||||
|
|
||||||
it("handles Wolf login and action failures", async () => {
|
it("handles Wolf login and action failures", async () => {
|
||||||
installFetch((url) => {
|
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/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/pairing/submit-pin")) return jsonResponse({ error: "pair failed" }, 500);
|
||||||
if (url.includes("/api/account/wolf/game-mode/start")) return jsonResponse({ error: "mode 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