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; }
-

Endpoint

-

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.

+

Server

+

Pick a Silent Mode endpoint, or point at your own. Server credentials stay on this device; + the panel only sees which one you picked.

- + +
-
- - +
+ +
@@ -144,16 +152,67 @@ const dSocks = $("d-socks"); let pending = false; let currentVless = ""; +let selectedServerId = ""; +let serverCatalog = []; -async function loadEndpoint() { - try { currentVless = await SM().storage.get("vless", ""); } catch { currentVless = ""; } +const CUSTOM_ID = "__custom"; + +async function loadSelection() { + try { selectedServerId = await SM().storage.get("serverId", "") || ""; } catch { selectedServerId = ""; } + try { currentVless = await SM().storage.get("vless", "") || ""; } catch { currentVless = ""; } $("vless").value = currentVless; } +async function saveSelection() { + try { await SM().storage.set("serverId", selectedServerId); } catch {} +} async function saveEndpoint() { currentVless = $("vless").value.trim(); try { await SM().storage.set("vless", currentVless); } catch {} render(await SM().invoke("status", {})); } + +function renderServerDropdown(servers) { + serverCatalog = Array.isArray(servers) ? servers : []; + const sel = $("serverSel"); + const prev = sel.value || selectedServerId; + sel.innerHTML = ""; + if (serverCatalog.length) { + const group = document.createElement("optgroup"); + group.label = "Silent Mode servers"; + for (const s of serverCatalog) { + const opt = document.createElement("option"); + opt.value = s.id; + opt.textContent = `${s.flag || "🌐"} ${s.label || s.id}${s.status === "ready" ? "" : " Β· " + s.status}`; + opt.disabled = s.status !== "ready"; + group.appendChild(opt); + } + sel.appendChild(group); + } + const custom = document.createElement("option"); + custom.value = CUSTOM_ID; + custom.textContent = "βš™οΈ Custom vless:// URL"; + sel.appendChild(custom); + // Restore the previous selection if it survived the refresh. + if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; + else if (currentVless) sel.value = CUSTOM_ID; + else if (serverCatalog.find((s) => s.status === "ready")) sel.value = serverCatalog.find((s) => s.status === "ready").id; + selectedServerId = sel.value; + onSelectionChanged(); +} + +function onSelectionChanged() { + const isCustom = selectedServerId === CUSTOM_ID; + $("customBlock").hidden = !isCustom; + const hit = serverCatalog.find((s) => s.id === selectedServerId); + const hint = $("serverHint"); + if (isCustom) { + hint.className = "hint"; hint.textContent = currentVless ? "" : "Paste a vless:// URL below."; + } else if (hit && hit.status !== "ready") { + hint.className = "hint err"; hint.textContent = `${hit.label || hit.id}: ${hit.status}`; + } else { + hint.className = "hint"; hint.textContent = ""; + } +} async function loadAuto() { try { $("autoOn").checked = !!(await SM().storage.get("autoOn", false)); } catch {} } @@ -169,6 +228,9 @@ function render(s) { ? `sing-box ${s.binaryVersion || "?"} (cached)` : (s.binaryError || "not downloaded"); dSocks.textContent = s.running ? `127.0.0.1:${s.socksPort}` : "off"; + // Refresh the dropdown from the latest catalog snapshot. renderServerDropdown + // is a no-op when the id list hasn't changed since the last paint. + if (Array.isArray(s.servers)) renderServerDropdown(s.servers); if (s.running) { statusEl.className = "pill on"; statusTx.textContent = "connected"; @@ -189,9 +251,17 @@ function render(s) { } else { statusEl.className = "pill"; statusTx.textContent = "off"; toggle.className = "toggle off"; toggle.textContent = "Turn on"; - toggle.disabled = pending || !currentVless; + const readyServer = serverCatalog.find((sv) => sv.id === selectedServerId && sv.status === "ready"); + const canRun = (selectedServerId === CUSTOM_ID && !!currentVless) || !!readyServer; + toggle.disabled = pending || !canRun; hint.className = "hint"; - hint.textContent = currentVless ? "" : "Paste a vless:// URL below to enable the toggle."; + if (!canRun) { + hint.textContent = selectedServerId === CUSTOM_ID + ? "Paste a vless:// URL below to enable the toggle." + : "Pick a server (or Custom) to enable the toggle."; + } else { + hint.textContent = ""; + } progressBar.hidden = true; } @@ -211,9 +281,11 @@ toggle.addEventListener("click", async () => { try { if (s.running) { render(await SM().invoke("turnOff", {})); - } else { - if (!currentVless) throw new Error("no endpoint saved"); + } else if (selectedServerId === CUSTOM_ID) { + if (!currentVless) throw new Error("paste a vless:// URL first"); render(await SM().invoke("turnOn", { vless: currentVless })); + } else { + render(await SM().invoke("turnOn", { serverId: selectedServerId })); } } catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); @@ -225,6 +297,18 @@ toggle.addEventListener("click", async () => { } }); +$("serverSel").addEventListener("change", async () => { + selectedServerId = $("serverSel").value; + onSelectionChanged(); + await saveSelection(); + render(await SM().invoke("status", {})); +}); +$("refreshServers").addEventListener("click", async () => { + hint.className = "hint"; hint.textContent = "refreshing catalog…"; + try { render(await SM().invoke("refreshServers", {})); } + catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); } +}); + $("save").addEventListener("click", saveEndpoint); $("paste").addEventListener("click", async () => { try { $("vless").value = await navigator.clipboard.readText(); } catch {} @@ -252,14 +336,20 @@ try { } catch {} (async () => { - await loadEndpoint(); + await loadSelection(); await loadAuto(); render(await SM().invoke("status", {})); - // Auto-on: if it was on before, try to bring it back β€” silent failure is - // fine, the toggle stays in "off" and the hint carries the reason. - if ($("autoOn").checked && currentVless) { - try { render(await SM().invoke("turnOn", { vless: currentVless })); } - catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); } + // Auto-on: if it was on before, try to bring it back with the last saved + // choice β€” server id if any, else custom vless. Silent failure is fine, + // the toggle stays in "off" and the hint carries the reason. + if ($("autoOn").checked) { + try { + if (selectedServerId === CUSTOM_ID && currentVless) { + render(await SM().invoke("turnOn", { vless: currentVless })); + } else if (selectedServerId && selectedServerId !== CUSTOM_ID) { + render(await SM().invoke("turnOn", { serverId: selectedServerId })); + } + } catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); } } // Periodic refresh for slow status changes (download bytes, unexpected // sing-box exit). Cheap; runs only while the panel is visible. diff --git a/bundled-addons/vpn/server-list.json b/bundled-addons/vpn/server-list.json new file mode 100644 index 00000000..b55618e0 --- /dev/null +++ b/bundled-addons/vpn/server-list.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "note": "Baked-in default server list. The addon fetches https://navigate.st/api/vpn/servers on activation and overlays that on top of this β€” any entry with a matching id replaces the baked-in one, new entries append. Users' saved selection persists by id, so a server can be re-parameterised (Reality key rotated, new host) without the panel losing its state.", + "servers": [ + { + "id": "sm-1", + "label": "Silent Mode Β· 1", + "flag": "🌐", + "country": "", + "vless": "", + "status": "coming-soon" + }, + { + "id": "sm-2", + "label": "Silent Mode Β· 2", + "flag": "🌐", + "country": "", + "vless": "", + "status": "coming-soon" + }, + { + "id": "sm-3", + "label": "Silent Mode Β· 3", + "flag": "🌐", + "country": "", + "vless": "", + "status": "coming-soon" + } + ] +}