Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ jobs:
- name: Type-check frontend
run: |
npm run check:window-scale-config
npm run test:api-fallback
npx tsc --noEmit

- name: Check and test Rust core
Expand Down Expand Up @@ -549,6 +550,7 @@ jobs:
- name: Type-check frontend
run: |
npm run check:window-scale-config
npm run test:api-fallback
npx tsc --noEmit

- name: Test Windows Rust core
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.97",
"type": "module",
"scripts": {
"test:api-fallback": "node --test scripts/api-fallback.test.mjs",
"check:window-scale-config": "node scripts/check-window-scale-config.mjs",
"inject-host": "bash scripts/inject-backend-host.sh",
"restore-host": "bash scripts/restore-placeholder.sh",
Expand Down
243 changes: 243 additions & 0 deletions scripts/api-fallback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import ts from "typescript";

// Run the production client with an in-memory session and HTTP transport.
// No real endpoints or credentials are used by these regression tests.
const source = await readFile(new URL("../src/api/client.ts", import.meta.url), "utf8");
const compiled = ts.transpileModule(source.replaceAll("import.meta.env.DEV", "false"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const PRIMARY = "https://primary.test/";
const FALLBACK = "https://fallback.test/function?region=test&u=";
const tokens = {
access_token: "renewed-access",
refresh_token: "renewed-refresh",
expires_in: 1800,
refresh_expires_in: 86400,
is_linked: true,
};
const json = (body, status = 200) => Response.json(body, { status });
const tokenResponse = () => json({ success: true, data: tokens });

function createClient({ fetch, expired = false, fallback = FALLBACK }) {
let session = {
deviceId: "test-device",
isLinked: true,
accessToken: "initial-access",
refreshToken: "initial-refresh",
accessTokenExpiresAt: Date.now() + (expired ? -1000 : 60000),
refreshTokenExpiresAt: Date.now() + 86400000,
};
const calls = [];
const diagnostics = [];
let cleared = 0;
const transport = async (rawUrl, init) => {
const url = new URL(rawUrl);
const isFallback = url.hostname === "fallback.test";
const target = isFallback ? new URL(`https://${url.searchParams.get("u")}`) : url;
const call = {
url, target, isFallback, method: init.method ?? "GET",
headers: new Headers(init.headers), body: init.body,
};
calls.push(call);
return fetch(call);
};
const imports = {
"@tauri-apps/plugin-http": { fetch: transport },
"./config": {
BOT_API_BASE_URL: PRIMARY,
BOT_API_FALLBACK_URL: fallback,
CONTROL_PLANE_BYPASS_HOSTS: ["primary.test", "fallback.test"],
},
"../session/fingerprint": {
getDeviceFingerprint: async () => ({ hwid: "test-hwid", userAgent: "ToBeVPN/test" }),
},
"../session/diagnostics": {
recordDiagnosticEvent: (...event) => diagnostics.push(event),
},
"../session/store": {
getSession: () => session,
getSessionGeneration: () => 1,
hasValidAccessToken: (value) => Boolean(value.accessToken && value.accessTokenExpiresAt > Date.now()),
hasValidRefreshToken: (value) => Boolean(value.refreshToken && value.refreshTokenExpiresAt > Date.now()),
updateSession: (patch) => (session = { ...session, ...patch }),
updateSessionFromTokens: (value) => (session = {
...session,
accessToken: value.access_token,
refreshToken: value.refresh_token,
accessTokenExpiresAt: Date.now() + value.expires_in * 1000,
refreshTokenExpiresAt: Date.now() + value.refresh_expires_in * 1000,
}),
clearSessionTokens: () => {
cleared += 1;
session = { ...session, accessToken: null, refreshToken: null };
},
},
"../session/secureSession": {
clearSecureSession: async () => {},
persistCurrentSessionSecrets: async () => {},
},
};
const exports = {};
vm.runInNewContext(compiled, {
exports,
require: (name) => {
assert.ok(Object.hasOwn(imports, name), `Unexpected import: ${name}`);
return imports[name];
},
URL, Headers, Response, AbortController, TextEncoder, crypto, performance, Error,
console: { warn() {} },
clearTimeout,
window: { setTimeout: (callback, ms) => setTimeout(callback, ms === 400 ? 0 : ms) },
}, { filename: "src/api/client.ts" });
return { api: exports, calls, diagnostics, session: () => session, cleared: () => cleared };
}

test("expired session refreshes through fallback before an authenticated GET", async () => {
const client = createClient({
expired: true,
fetch: (call) => {
if (!call.isFallback) throw new Error("Primary network unavailable");
if (call.target.pathname === "/api/device/refresh") return tokenResponse();
assert.equal(call.headers.get("X-Proxy-Authorization"), "Bearer renewed-access");
assert.equal(call.headers.has("Authorization"), false);
return json({ response: [] });
},
});
const result = await client.api.getNodes();
assert.deepEqual(result, { response: [] });
const refresh = client.calls.filter((call) => call.target.pathname === "/api/device/refresh");
assert.equal(refresh.length, 2);
assert.deepEqual(refresh.map((call) => call.isFallback), [false, true]);
assert.equal(refresh[1].method, "POST");
assert.equal(refresh[1].body, refresh[0].body);
assert.deepEqual(JSON.parse(refresh[1].body), { refresh_token: "initial-refresh" });
assert.equal(refresh[1].url.searchParams.get("region"), "test");
assert.equal(refresh[1].url.searchParams.get("u"), "primary.test/api/device/refresh");
assert.equal(client.session().accessToken, "renewed-access");
assert.equal(client.cleared(), 0);
});

test("bootstrap falls back with the same device identity and JSON body", async () => {
const client = createClient({ fetch: (call) => {
if (!call.isFallback) throw new Error("Primary network unavailable");
return tokenResponse();
} });
await client.api.bootstrapDeviceSession();
assert.equal(client.calls.length, 2);
assert.equal(client.calls[1].method, "POST");
assert.equal(client.calls[1].body, client.calls[0].body);
assert.deepEqual(JSON.parse(client.calls[1].body), { device_id: "test-device", platform: "desktop" });
assert.equal(client.calls[1].headers.get("Content-Type"), "application/json");
});

test("a primary HTTP 403 can use the session fallback", async () => {
const client = createClient({ fetch: (call) => call.isFallback
? tokenResponse()
: json({ message: "Route blocked" }, 403) });
await client.api.bootstrapDeviceSession();
assert.deepEqual(client.calls.map((call) => call.isFallback), [false, true]);
});

test("an authoritative primary HTTP 401 is not replayed through fallback", async () => {
const client = createClient({ fetch: () => json({ message: "Invalid session" }, 401) });
await assert.rejects(client.api.bootstrapDeviceSession(), { status: 401 });
assert.equal(client.calls.length, 1);
});

test("writes use the known fallback route once, with proxy authorization", async () => {
const client = createClient({ fetch: (call) => {
if (!call.isFallback) throw new Error("Primary network unavailable");
return json({ success: true, response: [] });
} });
await client.api.getNodes();
client.calls.length = 0;
const body = { device_name: "test PC", device_type: "pc", platform: "windows" };
await client.api.registerDevice(body);
assert.equal(client.calls.length, 1);
const [call] = client.calls;
assert.equal(call.isFallback, true);
assert.equal(call.method, "POST");
assert.deepEqual(JSON.parse(call.body), body);
assert.equal(call.headers.get("X-Proxy-Authorization"), "Bearer initial-access");
assert.equal(call.headers.has("Authorization"), false);
});

test("a write the fallback proxy cannot route is retried once on primary", async () => {
const client = createClient({ fetch: (call) => {
if (call.method === "GET") {
if (!call.isFallback) throw new Error("Primary network unavailable");
return json({ response: [] });
}
return call.isFallback ? json({ message: "Unknown route" }, 404) : json({ success: true });
} });
await client.api.getNodes();
client.calls.length = 0;
const body = { device_name: "test PC", device_type: "pc", platform: "windows" };
await client.api.registerDevice(body);
assert.deepEqual(client.calls.map((call) => call.isFallback), [true, false]);
assert.deepEqual(JSON.parse(client.calls[1].body), body);
assert.equal(client.calls[1].headers.get("Authorization"), "Bearer initial-access");
});

test("a gateway 403 on a fallback write is retried once on primary", async () => {
const client = createClient({ fetch: (call) => {
if (call.method === "GET") {
if (!call.isFallback) throw new Error("Primary network unavailable");
return json({ response: [] });
}
return call.isFallback
? json({ errorCode: 403, errorMessage: "Forbidden: Not authorized", errorType: "ClientError" }, 403)
: json({ success: true });
} });
await client.api.getNodes();
client.calls.length = 0;
await client.api.registerDevice({ device_name: "test PC", device_type: "pc", platform: "windows" });
assert.deepEqual(client.calls.map((call) => call.isFallback), [true, false]);
});

test("a failed mutation is not replayed on another route", async () => {
const client = createClient({ fetch: () => { throw new Error("Response lost"); } });
await assert.rejects(client.api.unlinkDevice({ device_id: "another-device" }), /Network request failed/);
assert.equal(client.calls.length, 1);
});

test("a failed write on the known fallback is not replayed on primary", async () => {
const client = createClient({ fetch: (call) => {
if (!call.isFallback || call.method === "POST") throw new Error("Response lost");
return json({ response: [] });
} });
await client.api.getNodes();
client.calls.length = 0;
await assert.rejects(client.api.unlinkDevice({ device_id: "another-device" }), /Network request failed/);
assert.equal(client.calls.length, 1);
assert.equal(client.calls[0].isFallback, true);
});

test("fallback transport failure preserves the stored session", async () => {
const client = createClient({ expired: true, fetch: () => { throw new Error("Network unavailable"); } });
await assert.rejects(client.api.ensureDeviceSession(), /Network request failed/);
assert.equal(client.calls.length, 2);
assert.equal(client.cleared(), 0);
assert.equal(client.session().refreshToken, "initial-refresh");
});

test("a technical proxy HTTP 403 does not invalidate the refresh token", async () => {
const client = createClient({ expired: true, fetch: (call) => {
if (!call.isFallback) throw new Error("Primary network unavailable");
return json({ errorCode: 403, errorMessage: "Forbidden: Not authorized", errorType: "ClientError" }, 403);
} });
await assert.rejects(client.api.ensureDeviceSession(), /Fallback route rejected request/);
assert.equal(client.calls.length, 2);
assert.equal(client.cleared(), 0);
});

test("a missing fallback keeps a single primary session request", async () => {
const client = createClient({ fallback: null, expired: true, fetch: () => { throw new Error("Network unavailable"); } });
await assert.rejects(client.api.ensureDeviceSession(), /Network request failed/);
assert.equal(client.calls.length, 1);
assert.equal(client.cleared(), 0);
});
82 changes: 77 additions & 5 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,15 @@ function splitPrimaryFallbackInit(init: RequestInit, headers: Headers): {
};
}

// Answers by which the fallback proxy rejects a route before it reaches the
// backend: an unknown route (400/404/405) or the gateway's own 403.
const FALLBACK_ROUTE_REFUSAL_STATUSES = new Set([400, 404, 405]);

async function isFallbackRouteRefusal(response: Response): Promise<boolean> {
if (FALLBACK_ROUTE_REFUSAL_STATUSES.has(response.status)) return true;
return response.status === FALLBACK_HTTP_STATUS && (await isProxyGatewayAuthError(response));
}

async function rejectProxyGatewayAuthError(response: Response): Promise<Response> {
if (response.status !== FALLBACK_HTTP_STATUS) return response;
if (!(await isProxyGatewayAuthError(response))) return response;
Expand Down Expand Up @@ -460,8 +469,51 @@ async function performFetchRoute(
: null;
const method = (init.method ?? "GET").toUpperCase();
const isSafeMethod = method === "GET" || method === "HEAD";
// Session acquisition must remain available when only the proxy is reachable.
// Keep these POSTs sequential: racing refreshes can rotate the same token twice.
const isSessionRequest = method === "POST" && [
"api/device/bootstrap",
"api/device/refresh",
].includes(path.replace(/^\/+/, ""));
const diagnosticRoute = diagnosticApiRoute(path);

if (fallbackUrl && !isSafeMethod && primaryUnavailableUntil > performance.now()) {
// Select the known reachable route before sending a mutation. Never replay
// it on primary if the fallback response is lost after committing the write.
recordDiagnosticEvent(
"API-Route",
`Write request using fallback route; route=${diagnosticRoute}`,
"D",
);
const response = await attemptFetch(fallbackUrl, fallbackInit, FALLBACK_TIMEOUT_MS, userSignal);
if (userSignal?.aborted || !(await isFallbackRouteRefusal(response))) {
return await rejectProxyGatewayAuthError(response);
}
// The proxy refused the route itself (it can lack newer routes), so the
// write never reached the backend and the primary may still serve it.
// A transport failure above is not retried: the write may have landed.
recordDiagnosticEvent(
"API-Route",
`Fallback route refused write with HTTP ${response.status}; retrying primary; route=${diagnosticRoute}`,
"W",
);
let primaryResponse: Response;
try {
primaryResponse = await attemptFetch(
primaryUrl,
primaryInit,
BOT_API_FALLBACK_URL ? PRIMARY_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
userSignal,
);
} catch (primaryError) {
if (response.status === FALLBACK_HTTP_STATUS) throw primaryError;
return response;
}
void response.body?.cancel().catch(() => {});
primaryUnavailableUntil = 0;
return primaryResponse;
}

// Endpoints that explicitly require a successful response must not inherit
// the global fallback-first cooldown. The fallback proxy can legitimately
// lack a newer/account-specific route (for example purchase plans) and
Expand Down Expand Up @@ -496,21 +548,26 @@ async function performFetchRoute(
);
}

let primaryResponse: Response;
try {
return await attemptFetch(
primaryResponse = await attemptFetch(
primaryUrl,
primaryInit,
BOT_API_FALLBACK_URL ? PRIMARY_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
userSignal,
);
} catch (primaryError) {
// Retrying a state-changing request can duplicate an unlink, purchase,
// or registration when the primary committed it but its response was lost.
if (!BOT_API_FALLBACK_URL || !isSafeMethod) throw primaryError;
if (userSignal?.aborted) throw primaryError;
console.warn(`[bot-api] primary request to ${path} failed, retrying via fallback`);
if (!fallbackUrl) throw primaryError;
primaryUnavailableUntil = performance.now() + PRIMARY_FAILURE_COOLDOWN_MS;
// Only session acquisition may retry a POST. Replaying another mutation
// could duplicate an unlink or activation after a lost primary response.
if (!isSafeMethod && !isSessionRequest) throw primaryError;
recordDiagnosticEvent(
"API-Route",
`Primary transport failed; retrying via fallback; route=${diagnosticRoute}`,
"W",
);
const response = await attemptFetch(
fallbackUrl,
fallbackInit,
Expand All @@ -519,6 +576,21 @@ async function performFetchRoute(
);
return await rejectProxyGatewayAuthError(response);
}

if (!fallbackUrl || !isSessionRequest || primaryResponse.status !== FALLBACK_HTTP_STATUS) {
return primaryResponse;
}
// Restricted networks can answer with 403 instead of a transport error,
// including on the bootstrap/refresh endpoints (as on the Android client).
recordDiagnosticEvent(
"API-Route",
`Primary session route returned HTTP 403; retrying via fallback; route=${diagnosticRoute}`,
"W",
);
const response = await attemptFetch(fallbackUrl, fallbackInit, FALLBACK_TIMEOUT_MS, userSignal);
const validated = await rejectProxyGatewayAuthError(response);
if (validated.ok) primaryUnavailableUntil = performance.now() + PRIMARY_FAILURE_COOLDOWN_MS;
return validated;
}

async function performFetch(
Expand Down
Loading