// Контракт с бэкендом Netrunner (control-plane). // // Базовый URL берётся из VITE_API_BASE_URL (см. .env). По умолчанию — прод. // Эндпоинт списка серверов закрыт auth_guard (JWT), поэтому клиент анонимно // бутстрапит токен через Ghost Protocol (/auth/seed/*): при первом запуске // генерирует seed, дальше переиспользует его для логина. JWT короткоживущий // (~10 мин), поэтому кешируется и автоматически обновляется по истечении. const API_BASE = ( import.meta.env.VITE_API_BASE_URL ?? "https://netrunner-vpn.com" ).replace(/\/$/, ""); const API_V1 = `${API_BASE}/api/v1`; const SEED_KEY = "nrxp_seed"; const TOKEN_KEY = "nrxp_token"; // --- Доменная модель сервера (камелкейс-зеркало VpnNode из бэкенда) --- export interface ServerNode { id: string; name: string; countryCode: string; ipAddress: string; port: number; // Домен-декой (SNI) — под него маскируется ClientHello при подключении // именно к этому серверу. Должен совпадать с --decoy-host на сервере. sni: string; status: string; currentLoad: number; tunnelAddress: string; tunnelPrefix: number; tunnelDns: string; tunnelMtu: number; } // Сырой ответ бэкенда (snake_case, сериализация VpnNode по умолчанию). interface RawVpnNode { id: string; name: string; country_code: string; ip_address: string; port: number; sni: string; status: string; current_load: number; tunnel_address: string; tunnel_prefix: number; tunnel_dns: string; tunnel_mtu: number; } function mapNode(n: RawVpnNode): ServerNode { return { id: n.id, name: n.name, countryCode: n.country_code, ipAddress: n.ip_address, port: n.port, sni: n.sni, status: n.status, currentLoad: n.current_load, tunnelAddress: n.tunnel_address, tunnelPrefix: n.tunnel_prefix, tunnelDns: n.tunnel_dns, tunnelMtu: n.tunnel_mtu, }; } // --- Работа с токеном --- function decodeJwtExp(token: string): number | null { try { const payload = token.split(".")[1]; const json = JSON.parse( atob(payload.replace(/-/g, "+").replace(/_/g, "/")), ); return typeof json.exp === "number" ? json.exp : null; } catch { return null; } } function isTokenValid(token: string | null): token is string { if (!token) return false; const exp = decodeJwtExp(token); if (exp === null) return false; // 30 секунд запаса, чтобы не ловить протухание прямо в полёте. return exp * 1000 > Date.now() + 30_000; } async function generateSeed(): Promise { const res = await fetch(`${API_V1}/auth/seed/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, }); if (!res.ok) throw new Error(`seed/generate failed: ${res.status}`); const data = (await res.json()) as { seed: string; token: string }; localStorage.setItem(SEED_KEY, data.seed); localStorage.setItem(TOKEN_KEY, data.token); return data.token; } async function loginSeed(seed: string): Promise { const res = await fetch(`${API_V1}/auth/seed/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seed }), }); if (!res.ok) throw new Error(`seed/login failed: ${res.status}`); const data = (await res.json()) as { token: string }; localStorage.setItem(TOKEN_KEY, data.token); return data.token; } // Возвращает валидный JWT: из кеша, через логин по сохранённому seed // либо генерируя новый seed при первом запуске. async function ensureToken(): Promise { const cached = localStorage.getItem(TOKEN_KEY); if (isTokenValid(cached)) return cached; const seed = localStorage.getItem(SEED_KEY); if (seed) { try { return await loginSeed(seed); } catch { // Seed мог протухнуть/быть удалён на сервере — перегенерируем. } } return generateSeed(); } // --- Публичный API --- export async function fetchNodes(): Promise { const token = await ensureToken(); const res = await fetch(`${API_V1}/nodes`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`GET /nodes failed: ${res.status}`); const data = (await res.json()) as RawVpnNode[]; return data.map(mapNode); } /** Текущий JWT, если уже есть (для передачи прокси на `--require-auth` инстансах). */ export function getCachedToken(): string | null { const cached = localStorage.getItem(TOKEN_KEY); return isTokenValid(cached) ? cached : null; } /** Гарантирует, что seed/токен существуют (Ghost Protocol), и возвращает seed для бэкапа. */ export async function getCurrentSeed(): Promise { await ensureToken(); const seed = localStorage.getItem(SEED_KEY); if (!seed) { // Юзер залогинен через Telegram (seed никогда не выдавался) — сгенерировать // отдельный seed поверх текущей сессии сервер сейчас не умеет, только через // новую анонимную регистрацию, что разлогинило бы текущий аккаунт. throw new Error("NO_SEED_FOR_ACCOUNT"); } return seed; } /** Вход по чужому/сохранённому seed (например, с другого устройства). */ export async function loginWithSeed(seed: string): Promise { await loginSeed(seed); localStorage.setItem(SEED_KEY, seed); } /** * Обменивает код на сессию. Код теперь генерирует сам бот по кнопке * "Код для входа" (уже подтверждённым — см. AuthService::create_bot_login_code), * поэтому это одноразовый обмен, а не поллинг: `true` при первом же успешном вызове. */ export async function pollTelegramLogin(authCode: string): Promise { const res = await fetch(`${API_V1}/auth/telegram/session/${authCode}`); if (res.status === 401 || res.status === 410) { throw new Error("TELEGRAM_LOGIN_EXPIRED"); } if (!res.ok) throw new Error(`telegram/session poll failed: ${res.status}`); const data = (await res.json()) as { status: string; token?: string }; if (data.status === "success" && data.token) { localStorage.setItem(TOKEN_KEY, data.token); localStorage.removeItem(SEED_KEY); return true; } return false; } export interface UsageInfo { usedBytes: number; limitBytes: number | null; } /** Текущий расход/лимит трафика юзера — для индикатора на `VpnStats`. */ export async function fetchUsage(): Promise { const token = await ensureToken(); const res = await fetch(`${API_V1}/users/me`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`GET /users/me failed: ${res.status}`); const data = (await res.json()) as { data_used_bytes: number; data_limit_bytes: number | null; }; return { usedBytes: data.data_used_bytes, limitBytes: data.data_limit_bytes }; } let cachedBotUsername: string | null = null; /** Юзернейм бота для ссылки "открыть чат" на экране входа (без `?start=` — * код теперь вводится вручную, см. pollTelegramLogin). */ export async function getBotUsername(): Promise { if (cachedBotUsername) return cachedBotUsername; try { const res = await fetch(`${API_V1}/config`); if (!res.ok) return null; const data = (await res.json()) as { bot_username: string }; cachedBotUsername = data.bot_username; return cachedBotUsername; } catch { return null; } }