diff --git a/bundled-addons/vpn/index.js b/bundled-addons/vpn/index.js index 4fa87b13..286361f1 100644 --- a/bundled-addons/vpn/index.js +++ b/bundled-addons/vpn/index.js @@ -24,6 +24,7 @@ const { spawn } = require("node:child_process"); const BINARY_MANIFEST_FILE = "binary-manifest.json"; const SERVER_LIST_FILE = "server-list.json"; const SERVER_LIST_URL = "https://navigate.st/api/vpn/servers"; +const SESSION_URL = "https://navigate.st/api/vpn/session"; const CACHE_SUBDIR = "bin"; const CONFIG_SUBDIR = "run"; const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; @@ -422,6 +423,40 @@ module.exports = { return file; } + // Ask the key-issuer for a session credential on this exit. Leases are + // cached under their serverId and reused until they expire — the gateway + // returns the same lease for a repeat caller anyway, but not re-asking on + // every toggle keeps the round trip off the common path and means a brief + // gateway outage does not break an already-working exit. + async function leaseEndpoint(srv) { + const cacheKey = `__lease:${srv.id}`; + try { + const cached = await api.storage.get(cacheKey, null); + if (cached && cached.vless && cached.expiresAt > Date.now() + 60_000) return cached.vless; + } catch {} + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 20_000); + let r, body; + try { + r = await fetch(SESSION_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ serverId: srv.id, tier: "free" }), + signal: controller.signal, + }); + body = await r.text(); + } catch (e) { + throw new Error(`could not reach the key issuer: ${e?.message || e}`); + } finally { clearTimeout(t); } + let j; + try { j = JSON.parse(body); } + catch { throw new Error(`key issuer returned non-JSON (${body.slice(0, 80)}…)`); } + if (!r.ok || !j.vless) throw new Error(j.error || `key issuer said HTTP ${r.status}`); + try { await api.storage.set(cacheKey, { vless: j.vless, expiresAt: j.expiresAt || 0 }); } catch {} + api.log(`leased a session on ${srv.id}${j.reused ? " (reused)" : ""}`); + return j.vless; + } + // Resolve either a raw vless:// URL or a serverId lookup into the URL to // hand to sing-box. The panel usually sends { serverId } for a preset and // { vless } for a custom paste; either shape is accepted. A serverId of @@ -438,10 +473,15 @@ module.exports = { } const hit = serverList.find((s) => s.id === serverId); if (!hit) throw new Error(`no server with id ${serverId} in the catalog`); - if (!hit.vless || hit.status === "coming-soon") { - throw new Error(`${hit.label || serverId} is not yet configured (${hit.status || "no vless URL"})`); + // A baked-in or subscription entry carries its own URL. A Silent Mode + // exit from the gateway catalogue deliberately does not: the catalogue + // is public, and a vless URL is the credential. For those we lease one + // from the key-issuer, which hands out a per-caller UUID. + if (hit.vless) return hit.vless; + if (hit.status && hit.status !== "ready") { + throw new Error(`${hit.label || serverId} is not available (${hit.status})`); } - return hit.vless; + return await leaseEndpoint(hit); } throw new Error("no endpoint — pass { vless } or { serverId }"); }