vpn: lease a credential from the key-issuer instead of expecting one in the catalogue

Found by driving the add-on in a real Theseus rather than reasoning
about it. turnOn() failed with "Silent Mode · 1 is not yet configured
(ready)" — resolveEndpoint required the catalogue entry to carry a
vless URL, but the gateway catalogue deliberately ships none, because a
vless URL is the credential and that endpoint is public. The add-on
predates the key-issuer and was never taught to ask for a lease.

It now POSTs to /api/vpn/session for any ready exit that has no URL of
its own, and caches the lease under its serverId until a minute before
expiry. Baked-in and subscription entries still use their own URL and
never hit the network.
This commit is contained in:
Local Dev 2026-09-27 20:45:47 +02:00
parent 890e6fae4b
commit e31c685550

View file

@ -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 }");
}