diff --git a/bundled-addons/vpn/addon.json b/bundled-addons/vpn/addon.json index 8951f14d..b6f980f9 100644 --- a/bundled-addons/vpn/addon.json +++ b/bundled-addons/vpn/addon.json @@ -1,7 +1,7 @@ { "id": "vpn", "name": "VPN", - "version": "0.1.1", + "version": "0.1.2", "description": "Route Theseus's traffic through a Silent Mode VPN endpoint. Runs sing-box locally, exits at one of our servers, and switches on with one click. Paste any vless:// URL to point it at your own endpoint instead.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=", diff --git a/bundled-addons/vpn/index.js b/bundled-addons/vpn/index.js index d300bcf8..ae541871 100644 --- a/bundled-addons/vpn/index.js +++ b/bundled-addons/vpn/index.js @@ -22,9 +22,12 @@ const crypto = require("node:crypto"); 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 CACHE_SUBDIR = "bin"; const CONFIG_SUBDIR = "run"; const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; +const SERVER_LIST_TTL_MS = 6 * 60 * 60_000; function platformKey() { const p = os.platform(); // "win32" | "linux" | "darwin" @@ -191,6 +194,64 @@ module.exports = { manifest = { platforms: {} }; } + // Server catalog β the dropdown of pre-configured Silent Mode endpoints. + // Read the baked-in list first so a first-run panel has something to show, + // then refresh from the gateway in the background. Entries with the same + // `id` in the gateway response replace the baked-in copy; a user's saved + // selection persists by id so a re-keyed server keeps its slot in the + // dropdown. + function readBundledServers() { + try { + const j = JSON.parse(fs.readFileSync(path.join(api.folder, SERVER_LIST_FILE), "utf8")); + return Array.isArray(j?.servers) ? j.servers : []; + } catch (e) { api.log("bundled server-list unreadable:", e?.message); return []; } + } + let serverList = readBundledServers(); + let serverListSource = "bundled"; + let serverListFetchedAt = 0; + + async function refreshServerList() { + const now = Date.now(); + if (now - serverListFetchedAt < SERVER_LIST_TTL_MS) return serverList; + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 15_000); + try { + const r = await fetch(SERVER_LIST_URL, { signal: controller.signal, redirect: "follow" }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const j = await r.json(); + const remote = Array.isArray(j?.servers) ? j.servers : []; + // Overlay by id: remote replaces bundled, new remote entries append. + const byId = new Map(readBundledServers().map((s) => [s.id, s])); + for (const s of remote) if (s && s.id) byId.set(s.id, s); + serverList = [...byId.values()]; + serverListSource = "gateway"; + serverListFetchedAt = now; + try { await api.storage.set("__serverListCache", { at: now, servers: serverList }); } catch {} + api.log(`server list refreshed: ${serverList.length} entries from gateway`); + } catch (e) { + api.log(`server list refresh failed (${e?.message || e}), keeping ${serverListSource}`); + } finally { + clearTimeout(t); + } + return serverList; + } + // On activation, hydrate from the last cached response if the gateway is + // currently unreachable; then kick off a background refresh. + (async () => { + try { + const cached = await api.storage.get("__serverListCache", null); + if (cached && Array.isArray(cached.servers) && cached.servers.length) { + // Overlay cached on top of bundled the same way. + const byId = new Map(readBundledServers().map((s) => [s.id, s])); + for (const s of cached.servers) if (s && s.id) byId.set(s.id, s); + serverList = [...byId.values()]; + serverListSource = "cache"; + serverListFetchedAt = cached.at || Date.now(); + } + } catch {} + refreshServerList().catch(() => {}); + })(); + function entryForCurrentPlatform() { const e = manifest.platforms && manifest.platforms[state.platform]; if (!e) throw new Error(`no VPN binary published for ${state.platform}`); @@ -235,9 +296,25 @@ module.exports = { return file; } - async function turnOn(vlessUrl) { + // 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. + function resolveEndpoint({ vless, serverId }) { + if (vless && /^vless:\/\//i.test(vless)) return vless; + if (serverId) { + 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"})`); + } + return hit.vless; + } + throw new Error("no endpoint β pass { vless } or { serverId }"); + } + + async function turnOn(payload) { if (state.running) return snapshot(); - if (!vlessUrl) throw new Error("no VLESS endpoint configured"); + const vlessUrl = resolveEndpoint(payload || {}); const vless = parseVless(vlessUrl); // The binary must exist and match its pinned sha256 BEFORE spawn. let bin; @@ -297,6 +374,16 @@ module.exports = { } function snapshot() { + // Trim the server catalog for the panel: never leak the raw vless URL + // (it's a credential in the free-tier model). The panel only needs the + // label, flag, country and whether the entry is usable. + const publicServers = serverList.map((s) => ({ + id: s.id, + label: s.label || s.id, + flag: s.flag || "π", + country: s.country || "", + status: s.vless && s.status !== "coming-soon" ? "ready" : (s.status || "coming-soon"), + })); return { running: state.running, platform: state.platform, @@ -312,15 +399,23 @@ module.exports = { availablePlatforms: Object.entries(manifest.platforms || {}) .filter(([, v]) => v && v.sha256 && v.sha256 !== "PENDING") .map(([k]) => k), + servers: publicServers, + serverListSource, + serverListFetchedAt, }; } // Message handlers ------------------------------------------------------ - api.onMessage("status", () => snapshot()); - api.onMessage("prepareBinary", () => ensureBinary().then(() => snapshot())); - api.onMessage("turnOn", (p) => turnOn(String(p && p.vless || ""))); - api.onMessage("turnOff", () => turnOff()); + api.onMessage("status", () => snapshot()); + api.onMessage("prepareBinary", () => ensureBinary().then(() => snapshot())); + api.onMessage("turnOn", (p) => turnOn(p || {})); + api.onMessage("turnOff", () => turnOff()); + api.onMessage("refreshServers", async () => { + serverListFetchedAt = 0; // force + await refreshServerList(); + return snapshot(); + }); api.onMessage("clearCache", () => { try { for (const f of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, f)); diff --git a/bundled-addons/vpn/panel.html b/bundled-addons/vpn/panel.html index 1aca0a24..d4e68161 100644 --- a/bundled-addons/vpn/panel.html +++ b/bundled-addons/vpn/panel.html @@ -90,15 +90,23 @@ button.btn.small { padding: 4px 9px; font-size: 11.5px; }
Paste a vless:// URL from Silent Mode or another operator. The URL stays on this device
- β it's used to build a sing-box config on the fly and never leaves the machine.
Pick a Silent Mode endpoint, or point at your own. Server credentials stay on this device; + the panel only sees which one you picked.