From 3d955720cbfdf06b68b1b89a51834e3a9d0544da Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 22 Sep 2026 19:56:01 +0200 Subject: [PATCH] =?UTF-8?q?vpn=200.1.1=20=E2=86=92=200.1.2:=20server-list?= =?UTF-8?q?=20dropdown=20+=20gateway=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every commercial VPN client stores its server catalog as a JSON on the backend and lets the panel pick from a dropdown; this pulls that shape into the extension. - server-list.json: baked-in default the tarball ships with. Three Silent Mode slots (sm-1..sm-3), status "coming-soon" until the VLESS URLs land — the toggle stays disabled for any entry whose status is not "ready", so a placeholder cannot be selected by accident. - Gateway overlay: index.js fetches https://navigate.st/api/vpn/servers on activation (with a 6-hour TTL and a "refresh" button in the panel) and merges by id — remote wins, new remote entries append. Cached to per-addon storage so an offline boot still has the last-good catalog. - turnOn now accepts { serverId } or { vless }. Server id is resolved through the catalog inside the addon; the panel only sees a public view (label, flag, country, ready/coming-soon), never the raw URL. - Panel: dropdown of servers + a "Custom vless://" option that reveals the paste box. Selection persists per-machine, refresh button forces a re-fetch, disabled toggle explains why in the hint area. No behavioural change for anyone with a saved vless:// paste — that path is now "Custom" in the dropdown and still works identically. --- bundled-addons/vpn/addon.json | 2 +- bundled-addons/vpn/index.js | 107 +++++++++++++++++++++-- bundled-addons/vpn/panel.html | 128 +++++++++++++++++++++++----- bundled-addons/vpn/server-list.json | 30 +++++++ 4 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 bundled-addons/vpn/server-list.json diff --git a/bundled-addons/vpn/addon.json b/bundled-addons/vpn/addon.json index 8951f14..b6f980f 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 d300bcf..ae54187 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 1aca0a2..d4e6816 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 0000000..b55618e --- /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" + } + ] +}