diff --git a/bundled-addons/vpn/addon.json b/bundled-addons/vpn/addon.json index b6f980f..fc1064f 100644 --- a/bundled-addons/vpn/addon.json +++ b/bundled-addons/vpn/addon.json @@ -1,7 +1,7 @@ { "id": "vpn", "name": "VPN", - "version": "0.1.2", + "version": "0.1.3", "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 ae54187..4fa87b1 100644 --- a/bundled-addons/vpn/index.js +++ b/bundled-addons/vpn/index.js @@ -28,6 +28,8 @@ const CACHE_SUBDIR = "bin"; const CONFIG_SUBDIR = "run"; const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; const SERVER_LIST_TTL_MS = 6 * 60 * 60_000; +const SUBSCRIPTION_TTL_MS = 6 * 60 * 60_000; +const SUBSCRIPTION_FETCH_TIMEOUT_MS = 20_000; function platformKey() { const p = os.platform(); // "win32" | "linux" | "darwin" @@ -235,6 +237,121 @@ module.exports = { } return serverList; } + // Subscriptions — user pastes an HTTPS URL that returns a list of vless + // URLs (either newline-separated or base64-encoded newline-separated, + // whichever the operator publishes). We fetch, parse, and merge each + // entry into the server list on activation and on demand. + // + // Every subscription entry gets a stable id derived from its URL so a + // refresh does not duplicate servers when the same subscription is + // re-fetched. The full vless URL for a subscription server lives in the + // add-on's private storage — never leaves this device. + function subServerId(subLabel, index) { + // 8-char prefix of a sha256 of "|" so the id is + // deterministic across refreshes but does not leak the subscription URL. + const h = crypto.createHash("sha256").update(`${subLabel}|${index}`).digest("hex").slice(0, 8); + return `sub-${h}`; + } + function decodeSubscriptionBody(text) { + // Two shapes are common in the wild. Try the plain-text form first — + // whitespace-separated URLs starting with vless://. If that yields + // nothing, try base64 (both standard and URL-safe alphabets, with or + // without padding). + const trimmed = String(text || "").trim(); + const isPlain = /\bvless:\/\//i.test(trimmed); + if (isPlain) return trimmed; + try { + const b64 = trimmed.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - b64.length % 4) % 4); + const decoded = Buffer.from(padded, "base64").toString("utf8"); + if (/\bvless:\/\//i.test(decoded)) return decoded; + } catch {} + return ""; + } + function parseSubscription(subLabel, body) { + const decoded = decodeSubscriptionBody(body); + if (!decoded) return []; + const urls = decoded.split(/\r?\n|[\t ]+/).map((s) => s.trim()).filter((s) => /^vless:\/\//i.test(s)); + const out = []; + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + let label; + try { + const u = new URL(url); + label = decodeURIComponent(u.hash.replace(/^#/, "")) || u.hostname; + } catch { continue; } + out.push({ + id: subServerId(subLabel, i), + label, + flag: "🔗", + country: "", + status: "ready", + vless: url, + _sub: subLabel, + }); + } + return out; + } + async function fetchSubscription(url) { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), SUBSCRIPTION_FETCH_TIMEOUT_MS); + try { + const r = await fetch(url, { signal: controller.signal, redirect: "follow" }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return await r.text(); + } finally { clearTimeout(t); } + } + // Subscriptions the user has added, keyed by their URL. Value is + // { label, addedAt, fetchedAt, servers[] } — servers[].vless is what + // resolveEndpoint() reads when the user clicks Turn on for a sub server. + async function readSubscriptions() { + try { return (await api.storage.get("subscriptions", null)) || {}; } + catch { return {}; } + } + async function writeSubscriptions(subs) { + try { await api.storage.set("subscriptions", subs); } catch {} + } + async function addSubscription({ url, label }) { + const clean = String(url || "").trim(); + if (!/^https?:\/\//i.test(clean)) throw new Error("subscription URL must be http:// or https://"); + const body = await fetchSubscription(clean); + const nice = String(label || new URL(clean).host).trim(); + const parsed = parseSubscription(nice, body); + if (!parsed.length) throw new Error("no vless:// entries in that subscription"); + const subs = await readSubscriptions(); + subs[clean] = { label: nice, url: clean, addedAt: (subs[clean]?.addedAt) || Date.now(), fetchedAt: Date.now(), servers: parsed }; + await writeSubscriptions(subs); + api.log(`subscription "${nice}" imported: ${parsed.length} servers`); + return { count: parsed.length, label: nice }; + } + async function refreshSubscription(url) { + const subs = await readSubscriptions(); + const entry = subs[url]; + if (!entry) throw new Error("no such subscription"); + if (Date.now() - (entry.fetchedAt || 0) < SUBSCRIPTION_TTL_MS) return { skipped: true }; + const body = await fetchSubscription(url); + const parsed = parseSubscription(entry.label, body); + entry.servers = parsed; + entry.fetchedAt = Date.now(); + await writeSubscriptions(subs); + return { count: parsed.length }; + } + async function removeSubscription(url) { + const subs = await readSubscriptions(); + if (!subs[url]) return false; + delete subs[url]; + await writeSubscriptions(subs); + return true; + } + async function subscriptionServers() { + const subs = await readSubscriptions(); + const out = []; + for (const sub of Object.values(subs)) { + for (const s of (sub.servers || [])) out.push(s); + } + return out; + } + // On activation, hydrate from the last cached response if the gateway is // currently unreachable; then kick off a background refresh. (async () => { @@ -249,6 +366,15 @@ module.exports = { serverListFetchedAt = cached.at || Date.now(); } } catch {} + // Merge subscription servers on top so a fresh-boot panel shows them. + try { + const subServers = await subscriptionServers(); + if (subServers.length) { + const byId = new Map(serverList.map((s) => [s.id, s])); + for (const s of subServers) byId.set(s.id, s); + serverList = [...byId.values()]; + } + } catch {} refreshServerList().catch(() => {}); })(); @@ -298,10 +424,18 @@ module.exports = { // 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 }) { + // { vless } for a custom paste; either shape is accepted. A serverId of + // the form "sub-" refers to a subscription-imported server whose + // vless URL lives in the addon's private storage, never in the panel. + async function resolveEndpoint({ vless, serverId }) { if (vless && /^vless:\/\//i.test(vless)) return vless; if (serverId) { + if (serverId.startsWith("sub-")) { + const subServers = await subscriptionServers(); + const hit = subServers.find((s) => s.id === serverId); + if (!hit || !hit.vless) throw new Error(`no subscription server with id ${serverId}`); + return hit.vless; + } 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") { @@ -314,7 +448,7 @@ module.exports = { async function turnOn(payload) { if (state.running) return snapshot(); - const vlessUrl = resolveEndpoint(payload || {}); + const vlessUrl = await resolveEndpoint(payload || {}); const vless = parseVless(vlessUrl); // The binary must exist and match its pinned sha256 BEFORE spawn. let bin; @@ -373,16 +507,32 @@ module.exports = { return snapshot(); } + // Cached subscription-server list so snapshot() stays synchronous. The + // storage read for the current subscription set is refreshed after every + // add/remove/refresh op. + let subServerCache = []; + async function refreshSubServerCache() { + try { subServerCache = await subscriptionServers(); } catch { subServerCache = []; } + } + // Warm the cache on activation once storage is ready. Not awaited on + // purpose — the panel polls status() and will pick up entries once loaded. + refreshSubServerCache(); + 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) => ({ + const combined = [ + ...serverList, + ...subServerCache.filter((s) => !serverList.find((x) => x.id === s.id)), + ]; + const publicServers = combined.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"), + sub: s._sub || null, })); return { running: state.running, @@ -414,8 +564,32 @@ module.exports = { api.onMessage("refreshServers", async () => { serverListFetchedAt = 0; // force await refreshServerList(); + await refreshSubServerCache(); return snapshot(); }); + api.onMessage("listSubscriptions", async () => { + const subs = await readSubscriptions(); + return Object.values(subs).map((s) => ({ + url: s.url, label: s.label, + addedAt: s.addedAt, fetchedAt: s.fetchedAt, + count: (s.servers || []).length, + })); + }); + api.onMessage("addSubscription", async (p) => { + const r = await addSubscription(p || {}); + await refreshSubServerCache(); + return { ...r, snapshot: snapshot() }; + }); + api.onMessage("refreshSubscription", async (p) => { + const r = await refreshSubscription(String(p && p.url || "")); + await refreshSubServerCache(); + return { ...r, snapshot: snapshot() }; + }); + api.onMessage("removeSubscription", async (p) => { + const ok = await removeSubscription(String(p && p.url || "")); + await refreshSubServerCache(); + return { ok, snapshot: 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 d4e6816..f7e90b1 100644 --- a/bundled-addons/vpn/panel.html +++ b/bundled-addons/vpn/panel.html @@ -110,6 +110,18 @@ button.btn.small { padding: 4px 9px; font-size: 11.5px; } +
+

Subscription

+

Paste an HTTPS URL that returns a list of vless:// servers. + The URL and its servers stay on this device.

+
+ + +
+
+
+
+
Advanced @@ -309,6 +321,46 @@ $("refreshServers").addEventListener("click", async () => { catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); } }); +// ---- subscription import ---- +async function renderSubList() { + const subs = await SM().invoke("listSubscriptions", {}); + const host = document.getElementById("subList"); + if (!subs.length) { host.innerHTML = ""; return; } + host.innerHTML = subs.map((s) => { + const url = s.url.replace(/"/g, """); + return `
${s.label}
${s.count} servers · ${s.url}
+ + `; + }).join(""); + host.querySelectorAll("[data-sub-refresh]").forEach((b) => b.addEventListener("click", async () => { + const subHint = document.getElementById("subHint"); + subHint.className = "hint"; subHint.textContent = "refreshing…"; + try { + const r = await SM().invoke("refreshSubscription", { url: b.dataset.subRefresh }); + subHint.textContent = r.skipped ? "recent enough, skipped" : `${r.count} servers`; + render(r.snapshot); + renderSubList(); + } catch (e) { subHint.className = "hint err"; subHint.textContent = e?.message || String(e); } + })); + host.querySelectorAll("[data-sub-remove]").forEach((b) => b.addEventListener("click", async () => { + const r = await SM().invoke("removeSubscription", { url: b.dataset.subRemove }); + render(r.snapshot); renderSubList(); + })); +} +document.getElementById("subAdd").addEventListener("click", async () => { + const url = document.getElementById("subUrl").value.trim(); + const subHint = document.getElementById("subHint"); + if (!url) { subHint.className = "hint err"; subHint.textContent = "paste a URL first"; return; } + subHint.className = "hint"; subHint.textContent = "fetching…"; + try { + const r = await SM().invoke("addSubscription", { url }); + document.getElementById("subUrl").value = ""; + subHint.className = "hint"; subHint.textContent = `added ${r.label} — ${r.count} servers`; + render(r.snapshot); + renderSubList(); + } catch (e) { subHint.className = "hint err"; subHint.textContent = e?.message || String(e); } +}); + $("save").addEventListener("click", saveEndpoint); $("paste").addEventListener("click", async () => { try { $("vless").value = await navigator.clipboard.readText(); } catch {} @@ -339,6 +391,7 @@ try { await loadSelection(); await loadAuto(); render(await SM().invoke("status", {})); + renderSubList(); // 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.