342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
|
|
// 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 CACHE_SUBDIR = "bin";
|
||
|
|
const CONFIG_SUBDIR = "run";
|
||
|
|
const DOWNLOAD_TIMEOUT_MS = 5 * 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:<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: {} };
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function turnOn(vlessUrl) {
|
||
|
|
if (state.running) return snapshot();
|
||
|
|
if (!vlessUrl) throw new Error("no VLESS endpoint configured");
|
||
|
|
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() {
|
||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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("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 || "?"}`);
|
||
|
|
},
|
||
|
|
};
|