// VPN — route Theseus's session through a sing-box tunnel out to a Silent // Mode (or user-supplied) VLESS endpoint. // // The tarball ships tiny on purpose: no binaries, just the UI, the config // generator and the process manager. sing-box for the running platform is // downloaded on first "turn on", verified against the sha256 pinned in // binary-manifest.json (which travels inside this signed tarball), then // cached under /extensions-data/vpn/bin/. Every subsequent launch // re-verifies the cached binary before spawning it — a mismatch redownloads // rather than trusts what is on disk. // // While the tunnel is up, api.setSessionProxy points every Theseus request // at 127.0.0.1: that sing-box is listening on. Off // again clears the proxy back to whatever the browser had before. const fs = require("node:fs"); const fsp = require("node:fs/promises"); const path = require("node:path"); const os = require("node:os"); const net = require("node:net"); 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" const a = os.arch(); // "x64" | "arm64" | ... return `${p}-${a}`; } async function sha256File(file) { const hash = crypto.createHash("sha256"); await new Promise((resolve, reject) => { const s = fs.createReadStream(file); s.on("data", (b) => hash.update(b)); s.on("end", resolve); s.on("error", reject); }); return hash.digest("hex"); } // pick a free localhost port. sing-box wants a fixed port, so we bind briefly // to grab one from the OS and release it before spawn. function pickPort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on("error", reject); srv.listen(0, "127.0.0.1", () => { const port = srv.address().port; srv.close(() => resolve(port)); }); }); } // A vless://user@host:port?params#label URL. Return the pieces sing-box // needs; we accept only the shape a 3x-UI VLESS+Reality inbound produces. function parseVless(url) { if (!/^vless:\/\//i.test(url)) throw new Error("not a vless:// URL"); const u = new URL(url); const params = Object.fromEntries(u.searchParams); return { uuid: decodeURIComponent(u.username), address: u.hostname, port: parseInt(u.port || "443", 10), flow: params.flow || "", encryption: params.encryption || "none", security: params.security || "reality", sni: params.sni || "", fp: params.fp || "chrome", pbk: params.pbk || "", sid: params.sid || "", spx: params.spx || "", type: params.type || "tcp", label: decodeURIComponent(u.hash.replace(/^#/, "")) || u.hostname, }; } // Minimal sing-box outbound config for VLESS+Reality, plus a SOCKS5 inbound // on 127.0.0.1: that Theseus's session proxy points at. function buildSingBoxConfig(vless, socksPort) { return { log: { level: "warn", timestamp: true }, inbounds: [ { type: "socks", tag: "in-socks", listen: "127.0.0.1", listen_port: socksPort, sniff: true, }, ], outbounds: [ { type: "vless", tag: "out-vless", server: vless.address, server_port: vless.port, uuid: vless.uuid, flow: vless.flow || undefined, packet_encoding: "xudp", tls: vless.security === "reality" ? { enabled: true, server_name: vless.sni || vless.address, utls: { enabled: true, fingerprint: vless.fp || "chrome" }, reality: { enabled: true, public_key: vless.pbk, short_id: vless.sid || "", }, } : { enabled: true, server_name: vless.sni || vless.address }, }, { type: "direct", tag: "out-direct" }, { type: "block", tag: "out-block" }, ], route: { final: "out-vless", rules: [{ inbound: ["in-socks"], outbound: "out-vless" }], }, }; } async function downloadTo(url, dest, onProgress) { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); try { const r = await fetch(url, { signal: controller.signal, redirect: "follow" }); if (!r.ok) throw new Error(`download ${url}: HTTP ${r.status}`); const total = Number(r.headers.get("content-length")) || 0; await fsp.mkdir(path.dirname(dest), { recursive: true }); const out = fs.createWriteStream(dest); let got = 0; const reader = r.body.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; got += value.byteLength; out.write(Buffer.from(value)); if (onProgress) onProgress(got, total); } out.end(); await new Promise((res, rej) => { out.on("finish", res); out.on("error", rej); }); } finally { clearTimeout(t); } } module.exports = { activate(api) { api.registerSidebarPanel({ id: "main", title: "VPN", page: "panel.html", }); // Runtime state kept in module scope — one add-on instance per Theseus // process, so this is safe. Panel reads it via the "status" message. const state = { running: false, child: null, socksPort: 0, platform: platformKey(), binaryPath: "", binaryReady: false, binaryError: "", downloadPct: 0, lastError: "", }; // Where cached binaries and one-shot configs live. api.dataDir is the // per-addon folder under /extensions-data/. const dataDir = api.dataDir || path.join(os.homedir(), ".theseus-vpn"); const cacheDir = path.join(dataDir, CACHE_SUBDIR); const runDir = path.join(dataDir, CONFIG_SUBDIR); try { fs.mkdirSync(cacheDir, { recursive: true }); } catch {} try { fs.mkdirSync(runDir, { recursive: true }); } catch {} // Read the shipped binary manifest — the only source of truth for what // sha256 a sing-box binary MUST have on disk before we spawn it. let manifest; try { manifest = JSON.parse(fs.readFileSync(path.join(api.folder, BINARY_MANIFEST_FILE), "utf8")); } catch (e) { api.log("failed to read binary-manifest.json:", e?.message); 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}`); if (!e.sha256 || e.sha256 === "PENDING") { throw new Error(`VPN binary for ${state.platform} not yet published (sha256 pending)`); } return e; } async function ensureBinary({ force = false } = {}) { const e = entryForCurrentPlatform(); const ext = state.platform.startsWith("win32") ? ".exe" : ""; const file = path.join(cacheDir, `sing-box-${manifest.version || "0"}-${state.platform}${ext}`); // Cache hit path: if the sha256 already matches, no download. if (!force && fs.existsSync(file)) { try { const have = await sha256File(file); if (have.toLowerCase() === e.sha256.toLowerCase()) { state.binaryPath = file; state.binaryReady = true; state.binaryError = ""; return file; } } catch {} } state.downloadPct = 0; state.binaryReady = false; await downloadTo(e.url, file, (got, total) => { state.downloadPct = total ? Math.round((got * 100) / total) : 0; api.emit("state", snapshot()); }); const got = await sha256File(file); if (got.toLowerCase() !== e.sha256.toLowerCase()) { try { fs.unlinkSync(file); } catch {} throw new Error(`downloaded binary sha256 mismatch (expected ${e.sha256.slice(0,12)}…, got ${got.slice(0,12)}…)`); } if (!state.platform.startsWith("win32")) { try { fs.chmodSync(file, 0o755); } catch {} } state.binaryPath = file; state.binaryReady = true; state.binaryError = ""; state.downloadPct = 100; return file; } // 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(); const vlessUrl = resolveEndpoint(payload || {}); const vless = parseVless(vlessUrl); // The binary must exist and match its pinned sha256 BEFORE spawn. let bin; try { bin = await ensureBinary(); } catch (e) { state.binaryError = e?.message || String(e); throw e; } const socksPort = await pickPort(); const cfg = buildSingBoxConfig(vless, socksPort); const cfgPath = path.join(runDir, `sing-box-${socksPort}.json`); fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); const child = spawn(bin, ["run", "-c", cfgPath], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); state.child = child; state.socksPort = socksPort; state.running = true; state.lastError = ""; child.stdout.on("data", (b) => api.log(`sing-box: ${b.toString().trim()}`)); child.stderr.on("data", (b) => api.log(`sing-box[err]: ${b.toString().trim()}`)); child.on("exit", (code, signal) => { api.log(`sing-box exited code=${code} signal=${signal}`); state.running = false; state.child = null; state.socksPort = 0; if (code && code !== 0) state.lastError = `sing-box exited with code ${code}`; api.setSessionProxy(null).catch((e) => api.log("clearProxy:", e?.message)); api.emit("state", snapshot()); }); // Give sing-box a moment to bind its socket. If it fails immediately, // the exit handler above flips state.running back off and we surface // lastError. await new Promise((r) => setTimeout(r, 350)); if (!state.running) throw new Error(state.lastError || "sing-box refused to start"); await api.setSessionProxy(`socks5://127.0.0.1:${socksPort}`); api.log(`VPN on: ${vless.label} · SOCKS5 127.0.0.1:${socksPort}`); return snapshot(); } async function turnOff() { const child = state.child; state.child = null; state.running = false; const port = state.socksPort; state.socksPort = 0; try { await api.setSessionProxy(null); } catch (e) { api.log("clearProxy:", e?.message); } if (child) { try { child.kill(); } catch {} } // Best-effort cleanup of the run-time config file. try { for (const f of fs.readdirSync(runDir)) { if (f === `sing-box-${port}.json`) fs.unlinkSync(path.join(runDir, f)); } } catch {} api.log("VPN off"); return snapshot(); } 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, binaryVersion: manifest.version || null, binaryReady: state.binaryReady, binaryError: state.binaryError, downloadPct: state.downloadPct, socksPort: state.socksPort, lastError: state.lastError, // Which platforms the manifest lists a real sha256 for — the panel // shows a "not yet published for your platform" message otherwise // instead of a bare failure. 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(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)); } catch {} state.binaryPath = ""; state.binaryReady = false; return snapshot(); }); // Shut the tunnel down if the browser closes with the VPN still on. // Electron does not fire "before-quit" on the addon side but the addon // host disposes handlers on quit; the OS reaps our child anyway. Belt // and braces: process.on for the rare case the addon is unloaded but // the app keeps running. process.on("exit", () => { try { state.child?.kill(); } catch {} }); api.log(`registered — platform ${state.platform}, binary v${manifest.version || "?"}`); }, };