theseus/bundled-addons/vpn/index.js

611 lines
24 KiB
JavaScript
Raw Normal View History

// 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 <userData>/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:<ephemeral SOCKS5 port> 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;
const SUBSCRIPTION_TTL_MS = 6 * 60 * 60_000;
const SUBSCRIPTION_FETCH_TIMEOUT_MS = 20_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:<socksPort> 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 <userData>/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;
}
// 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 "<subLabel>|<index>" 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 () => {
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 {}
// 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(() => {});
})();
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. A serverId of
// the form "sub-<hash>" 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") {
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 = await 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();
}
// 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 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,
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();
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));
} 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 || "?"}`);
},
};