feat(theseus/vpn): VPN extension scaffold — sing-box under our own UI
Ships the extension small (~50 KB tarball). No binaries in it — the
platform-matched sing-box is downloaded on first "Turn on" from
bns/theseus.x/vpn-binaries/<platform>/, sha256-verified against the
manifest that ships inside this operator-signed tarball, and cached
under extensions-data/vpn/bin/. Every subsequent launch re-verifies
before spawning; a mismatch redownloads rather than trusts what is on
disk.
Config generator produces a sing-box config from a vless:// URL (the
shape a 3x-UI VLESS+Reality inbound produces), plus a SOCKS5 inbound
on 127.0.0.1:<ephemeral>. api.setSessionProxy points every Theseus
request at that port while the tunnel is up; child.on("exit") clears
it if sing-box dies. Off again clears the proxy back to whatever the
browser had.
Panel is a big on/off toggle with a status pill, a paste-and-save
endpoint box, and an Advanced disclosure with "auto-on at browser
start", "re-download binary" and "clear cache". Any user with a
vless:// URL can flip it on today; the free tier and the Silent Mode
exit inbound are the server-side half, documented under DESIGN.md.
Binary manifest ships with PENDING sha256s until the binaries are
uploaded to Sia — ensureBinary refuses to activate on a platform whose
sha256 is PENDING, so a user cannot flip it on against an unverified
download.
This commit is contained in:
parent
8d02a2464b
commit
676424db87
5 changed files with 803 additions and 0 deletions
159
bundled-addons/vpn/DESIGN.md
Normal file
159
bundled-addons/vpn/DESIGN.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# VPN extension — design notes
|
||||
|
||||
Client is done in v0.1.0. This file covers **what the client expects the
|
||||
server side to look like**, so the two halves can be built independently
|
||||
without either side inventing a contract the other did not agree to.
|
||||
|
||||
## Model, in one line
|
||||
|
||||
3x-UI + xray-core on our VPS provides a VLESS+Reality inbound; the client
|
||||
runs sing-box locally and speaks that inbound; a small key-issuer service
|
||||
on our gateway mints per-user client entries in the 3x-UI panel on demand.
|
||||
This is the SoloBot / YAK VPN pattern with the payment stack, the referral
|
||||
program and the Telegram-bot UI stripped out — everything Silent Mode does
|
||||
not care about — and with the client wired to Theseus's session proxy
|
||||
instead of a system-wide tunnel.
|
||||
|
||||
Anchors:
|
||||
- `D:\Dev\VPN\vpn_bot` — the original bot the code and inbound layout are
|
||||
adapted from. `client.py` (its `py3xui` calls) is the reference for our
|
||||
key-issuer.
|
||||
- `D:\Dev\VPN\VPN module` — the YAK web frontend the payment-less flow
|
||||
descends from.
|
||||
|
||||
## Server side (VPS)
|
||||
|
||||
1. **Install 3x-UI** as a Docker container or systemd service. One VLESS
|
||||
inbound, Reality mode, port 443, `flow=xtls-rprx-vision`, one Reality
|
||||
short-id per operator server.
|
||||
2. **Provision at least one inbound per exit country** (`nl-1`, `de-1`, …)
|
||||
and record its `inbound_id` in the key-issuer's config. Multiserver is
|
||||
phase-two; v1 ships one exit.
|
||||
3. **Key-issuer** — small FastAPI service (or Node in Argus, matching our
|
||||
stack) that:
|
||||
- Reads 3x-UI's local API on `127.0.0.1:2053`.
|
||||
- Exposes one POST endpoint the extension calls (below).
|
||||
- Talks to Aegis-side wallet signatures when a request claims to be paid.
|
||||
|
||||
### `POST /api/vpn/new-client`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"identity": "bch:<address>" | "anon:<128-bit random>",
|
||||
"tier": "free" | "paid",
|
||||
"duration_days": 1, // free tier ignores this; paid honours it
|
||||
"signature": "…" // wallet signature over the JSON, iff tier=paid
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"vless": "vless://…",
|
||||
"expires_at": 1727000000,
|
||||
"quota_bytes": 524288000 // 500 MB for free tier per day
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `anon:` requests get one key per source IP per 24h, hard-capped at
|
||||
500 MB / 30 minutes of active use (enforced in the xray inbound's
|
||||
`total` and `expiry`). One key at a time — reissuing supersedes.
|
||||
- `bch:` requests without a signature get the same free-tier treatment.
|
||||
- `bch:` requests with a valid signature and a `tier=paid` claim get a
|
||||
key sized to what their signed message says. `duration_days` and the
|
||||
price paid live in the signed message. Payment verification is a
|
||||
separate concern (see next section).
|
||||
|
||||
### Paid tier
|
||||
|
||||
BCH-native, no Stripe. Options in decreasing order of Silent Mode-fit:
|
||||
|
||||
1. **BCH invoice covenant on Aegis** — the extension posts to
|
||||
`/api/vpn/invoice` which returns an address + memo. User pays from
|
||||
Aegis. Gateway watches the address, unlocks the corresponding key
|
||||
when the tx clears. Same on-chain-watcher pattern the marketplace
|
||||
already uses in Argus.
|
||||
2. **Sirius Studio hosted key sale** — a page under `theseus.x/vpn/buy`
|
||||
that runs an offer covenant (like the name-sale covenants shipped
|
||||
2026-09-17); paying it produces a signed receipt the extension shows
|
||||
to `/api/vpn/new-client`. Consistent with the name-marketplace flow.
|
||||
|
||||
Either way, no card processor, no email, no account. A signed receipt +
|
||||
a fresh `bch:` identity is the whole login.
|
||||
|
||||
## Binary hosting
|
||||
|
||||
sing-box binaries live at:
|
||||
|
||||
```
|
||||
bns/theseus.x/vpn-binaries/win32-x64/sing-box-<version>.exe
|
||||
bns/theseus.x/vpn-binaries/linux-x64/sing-box-<version>
|
||||
bns/theseus.x/vpn-binaries/darwin-arm64/sing-box-<version>
|
||||
```
|
||||
|
||||
Publisher: operator, uploaded with `sia-upload.js`. The addon's
|
||||
`binary-manifest.json` pins the sha256 for each platform; that manifest
|
||||
ships inside the operator-Ed25519-signed addon tarball, so an attacker
|
||||
who compromises Sia cannot swap a binary without matching the pinned
|
||||
hash, and cannot change the pinned hash without the operator OTA key.
|
||||
|
||||
To publish a new sing-box version:
|
||||
|
||||
1. Download upstream from `github.com/SagerNet/sing-box/releases`,
|
||||
record the upstream sha256.
|
||||
2. `sia-upload.js sing-box-<version>.exe bns/theseus.x/vpn-binaries/win32-x64`
|
||||
(and the two other platforms).
|
||||
3. Bump `bundled-addons/vpn/binary-manifest.json` — new sha256s, new URLs,
|
||||
new version.
|
||||
4. Ship a new addon tarball via the Ed25519 OTA channel — same
|
||||
`sign-addon-update.mjs` flow as translate and pdf-editor.
|
||||
|
||||
Users get the new manifest via OTA, notice their cached sha256 no longer
|
||||
matches, and re-download.
|
||||
|
||||
## System-wide vs Theseus-only
|
||||
|
||||
v1 = Theseus-only. `api.setSessionProxy` routes only the Chromium session
|
||||
Theseus owns. Other apps on the machine are untouched.
|
||||
|
||||
For system-wide, sing-box supports TUN inbounds. This requires:
|
||||
- Admin / sudo on first run (Windows: WinTUN driver install, prompt).
|
||||
- Different sing-box config (a `tun` inbound instead of `socks`).
|
||||
- A different "placement" mode in the addon panel — currently stubbed
|
||||
as "Turn on at browser start" but the same UI slot could take a
|
||||
three-way selector: `off | Theseus only | System-wide`.
|
||||
|
||||
Not in v1. When we add it, the extension activates the TUN inbound only
|
||||
after a fresh admin-elevation prompt, and only if the user has ticked
|
||||
"system-wide" explicitly. Default forever is browser-scoped — a leaky
|
||||
TUN interface on a mistyped click is not a Silent Mode failure mode.
|
||||
|
||||
## Threat model, first pass
|
||||
|
||||
- **Operator OTA key compromise** ⇒ attacker can push a malicious
|
||||
sing-box binary. Same threat model the Ed25519 OTA channel already
|
||||
has; the mitigations (multi-sig, on-BCNR pubkey publication) are
|
||||
logged in `docs/ADDON-UPDATES.md` under Roadmap.
|
||||
- **Malicious VPS operator** ⇒ can see all VPN traffic that leaves the
|
||||
exit. Standard VPN threat model. Users who don't want to trust
|
||||
Silent Mode's operator paste their own `vless://` URL in the panel;
|
||||
the sing-box config is built locally from whatever URL they give it,
|
||||
no server-side involvement.
|
||||
- **Sia mirror MITM** ⇒ swaps the sing-box download. Blocked by the
|
||||
sha256 check against the OTA-signed manifest.
|
||||
- **Reality parameter leak** ⇒ someone who learns the inbound's private
|
||||
key can decrypt captured traffic. Same as any Reality deployment; key
|
||||
rotation is a 3x-UI operational task, not a Theseus concern.
|
||||
|
||||
## What is deliberately not in v1
|
||||
|
||||
- Server selector — one exit for now, no city/country picker.
|
||||
- Kill switch — Chromium's session either has the proxy or it does not;
|
||||
if sing-box dies mid-session, `child.on("exit")` clears the proxy but
|
||||
live requests may drift briefly. A real kill switch needs the TUN
|
||||
inbound.
|
||||
- Split tunneling by domain — belongs in `api.setSessionProxy`'s
|
||||
`bypassList`; UI for it later.
|
||||
- Telemetry, quota display, referrals — deliberately out.
|
||||
11
bundled-addons/vpn/addon.json
Normal file
11
bundled-addons/vpn/addon.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "vpn",
|
||||
"name": "VPN",
|
||||
"version": "0.1.0",
|
||||
"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=",
|
||||
"main": "index.js",
|
||||
"capabilities": ["sidebar-panel", "session-proxy"],
|
||||
"updateURL": "https://navigate.st/bns/theseus.x/extensions/vpn/updates.json"
|
||||
}
|
||||
22
bundled-addons/vpn/binary-manifest.json
Normal file
22
bundled-addons/vpn/binary-manifest.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"version": "1.10.2",
|
||||
"engine": "sing-box",
|
||||
"note": "sha256s are placeholders until the binaries are uploaded to bns/theseus.x/vpn-binaries/. Refuse to activate on a platform whose sha256 is 'PENDING' — a real hash lands here in the addon tarball, signed by the operator OTA key, before that platform's users can flip the toggle on.",
|
||||
"platforms": {
|
||||
"win32-x64": {
|
||||
"url": "https://navigate.st/bns/theseus.x/vpn-binaries/win32-x64/sing-box-1.10.2.exe",
|
||||
"sha256": "PENDING",
|
||||
"bytes": 0
|
||||
},
|
||||
"linux-x64": {
|
||||
"url": "https://navigate.st/bns/theseus.x/vpn-binaries/linux-x64/sing-box-1.10.2",
|
||||
"sha256": "PENDING",
|
||||
"bytes": 0
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://navigate.st/bns/theseus.x/vpn-binaries/darwin-arm64/sing-box-1.10.2",
|
||||
"sha256": "PENDING",
|
||||
"bytes": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
341
bundled-addons/vpn/index.js
Normal file
341
bundled-addons/vpn/index.js
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
// 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 || "?"}`);
|
||||
},
|
||||
};
|
||||
270
bundled-addons/vpn/panel.html
Normal file
270
bundled-addons/vpn/panel.html
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>VPN</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#0ac18e; --danger:#ff5b5b; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; display: flex; flex-direction: column; }
|
||||
header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
header .plat { margin-left: auto; color: var(--dim); font-size: 11px; }
|
||||
|
||||
.body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.big {
|
||||
border: 1px solid var(--line); background: var(--panel); border-radius: 14px;
|
||||
padding: 18px; display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||
}
|
||||
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: rgba(139,152,169,.14); color: var(--mut); }
|
||||
.pill.on { color: #0b0e14; background: var(--acid); }
|
||||
.pill.err { color: #fff; background: var(--danger); }
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.toggle {
|
||||
border: none; padding: 14px 28px; border-radius: 12px; font: inherit; font-weight: 700; font-size: 15px;
|
||||
cursor: pointer; letter-spacing: .04em;
|
||||
background: var(--acid); color: #0b0e14; min-width: 190px;
|
||||
}
|
||||
.toggle:disabled { opacity: .5; cursor: default; }
|
||||
.toggle.off { background: var(--panel2); color: var(--ink); border: 1px solid var(--line); }
|
||||
|
||||
.progress { width: 100%; height: 4px; background: var(--panel2); border-radius: 999px; overflow: hidden; margin-top: 4px; }
|
||||
.progress > span { display: block; height: 100%; background: var(--acid); width: 0%; transition: width .18s ease; }
|
||||
.hint { color: var(--dim); font-size: 11.5px; text-align: center; }
|
||||
.hint.err { color: var(--danger); }
|
||||
|
||||
.card { border: 1px solid var(--line); background: var(--panel); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.card h2 { margin: 0 0 4px; font-size: 12.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
|
||||
.card p { margin: 0; color: var(--mut); font-size: 12px; }
|
||||
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.row textarea, .row input[type=text] {
|
||||
flex: 1; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
|
||||
border-radius: 8px; padding: 8px 10px; font: 12px/1.5 ui-monospace, monospace; resize: vertical;
|
||||
}
|
||||
button.btn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||||
border-radius: 8px; padding: 7px 12px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
button.btn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
||||
button.btn.small { padding: 4px 9px; font-size: 11.5px; }
|
||||
|
||||
.opt { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; }
|
||||
.opt label { color: var(--ink); font-size: 12.5px; }
|
||||
.opt .sub { color: var(--dim); font-size: 11.5px; }
|
||||
.opt input[type=checkbox] { transform: scale(1.15); accent-color: var(--acid); }
|
||||
|
||||
.kv { display: grid; grid-template-columns: 100px 1fr; gap: 4px 12px; font-size: 11.5px; }
|
||||
.kv b { font-weight: 600; color: var(--mut); }
|
||||
.kv code { font: 11px ui-monospace, monospace; color: var(--ink); overflow-wrap: anywhere; }
|
||||
|
||||
.disclosure { border: 1px solid var(--line); background: var(--panel); border-radius: 12px; padding: 10px 12px; font-size: 12px; }
|
||||
.disclosure summary { cursor: pointer; color: var(--mut); font-weight: 500; list-style: none; }
|
||||
.disclosure summary::-webkit-details-marker { display: none; }
|
||||
.disclosure[open] { padding-bottom: 14px; }
|
||||
.disclosure[open] > *:not(summary) { margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>VPN</h1>
|
||||
<span class="plat" id="platform">—</span>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
|
||||
<div class="big">
|
||||
<span class="pill" id="status"><span class="dot"></span><span id="statusText">loading…</span></span>
|
||||
<button class="toggle off" id="toggle" disabled>Turn on</button>
|
||||
<div class="progress" id="progressBar" hidden><span id="progress"></span></div>
|
||||
<div class="hint" id="hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Endpoint</h2>
|
||||
<p>Paste a <code>vless://</code> 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.</p>
|
||||
<div class="row">
|
||||
<textarea id="vless" rows="3" placeholder="vless://uuid@host:443?type=tcp&security=reality&pbk=…&sni=…"></textarea>
|
||||
</div>
|
||||
<div class="row" style="justify-content:flex-end">
|
||||
<button class="btn small" id="paste">Paste</button>
|
||||
<button class="btn small" id="save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="disclosure">
|
||||
<summary>Advanced</summary>
|
||||
|
||||
<div class="kv" id="details">
|
||||
<b>Platform</b><code id="d-platform">—</code>
|
||||
<b>Engine</b><code id="d-engine">sing-box</code>
|
||||
<b>Binary</b><code id="d-binary">—</code>
|
||||
<b>SOCKS5</b><code id="d-socks">—</code>
|
||||
</div>
|
||||
|
||||
<div class="opt" style="margin-top:10px">
|
||||
<div>
|
||||
<label for="autoOn">Turn on at browser start</label>
|
||||
<div class="sub">Restore the VPN as soon as Theseus launches, if it was on when you last quit.</div>
|
||||
</div>
|
||||
<input type="checkbox" id="autoOn">
|
||||
</div>
|
||||
|
||||
<div class="row" style="justify-content:flex-end;margin-top:6px">
|
||||
<button class="btn small" id="refreshBinary">Re-download binary</button>
|
||||
<button class="btn small" id="clearCache">Clear cache</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SM = () => window.silentmode;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const toggle = $("toggle");
|
||||
const statusEl = $("status");
|
||||
const statusTx = $("statusText");
|
||||
const hint = $("hint");
|
||||
const progress = $("progress");
|
||||
const progressBar = $("progressBar");
|
||||
const platEl = $("platform");
|
||||
const dPlat = $("d-platform");
|
||||
const dBinary = $("d-binary");
|
||||
const dSocks = $("d-socks");
|
||||
|
||||
let pending = false;
|
||||
let currentVless = "";
|
||||
|
||||
async function loadEndpoint() {
|
||||
try { currentVless = await SM().storage.get("vless", ""); } catch { currentVless = ""; }
|
||||
$("vless").value = currentVless;
|
||||
}
|
||||
async function saveEndpoint() {
|
||||
currentVless = $("vless").value.trim();
|
||||
try { await SM().storage.set("vless", currentVless); } catch {}
|
||||
render(await SM().invoke("status", {}));
|
||||
}
|
||||
async function loadAuto() {
|
||||
try { $("autoOn").checked = !!(await SM().storage.get("autoOn", false)); } catch {}
|
||||
}
|
||||
async function saveAuto() {
|
||||
try { await SM().storage.set("autoOn", $("autoOn").checked); } catch {}
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
if (!s) return;
|
||||
platEl.textContent = s.platform;
|
||||
dPlat.textContent = s.platform;
|
||||
dBinary.textContent = s.binaryReady
|
||||
? `sing-box ${s.binaryVersion || "?"} (cached)`
|
||||
: (s.binaryError || "not downloaded");
|
||||
dSocks.textContent = s.running ? `127.0.0.1:${s.socksPort}` : "off";
|
||||
|
||||
if (s.running) {
|
||||
statusEl.className = "pill on"; statusTx.textContent = "connected";
|
||||
toggle.className = "toggle"; toggle.textContent = "Turn off"; toggle.disabled = pending;
|
||||
hint.className = "hint"; hint.textContent = "";
|
||||
progressBar.hidden = true;
|
||||
} else if (s.downloadPct > 0 && s.downloadPct < 100 && pending) {
|
||||
statusEl.className = "pill"; statusTx.textContent = "downloading";
|
||||
toggle.textContent = "Preparing…"; toggle.disabled = true;
|
||||
progressBar.hidden = false;
|
||||
progress.style.width = `${s.downloadPct}%`;
|
||||
hint.className = "hint"; hint.textContent = `sing-box · ${s.downloadPct}%`;
|
||||
} else if (s.binaryError) {
|
||||
statusEl.className = "pill err"; statusTx.textContent = "unavailable";
|
||||
toggle.className = "toggle off"; toggle.textContent = "Turn on"; toggle.disabled = true;
|
||||
hint.className = "hint err"; hint.textContent = s.binaryError;
|
||||
progressBar.hidden = true;
|
||||
} else {
|
||||
statusEl.className = "pill"; statusTx.textContent = "off";
|
||||
toggle.className = "toggle off"; toggle.textContent = "Turn on";
|
||||
toggle.disabled = pending || !currentVless;
|
||||
hint.className = "hint";
|
||||
hint.textContent = currentVless ? "" : "Paste a vless:// URL below to enable the toggle.";
|
||||
progressBar.hidden = true;
|
||||
}
|
||||
|
||||
if (s.lastError && !s.running) {
|
||||
hint.className = "hint err"; hint.textContent = s.lastError;
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try { render(await SM().invoke("status", {})); } catch {}
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", async () => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
const s = await SM().invoke("status", {});
|
||||
try {
|
||||
if (s.running) {
|
||||
render(await SM().invoke("turnOff", {}));
|
||||
} else {
|
||||
if (!currentVless) throw new Error("no endpoint saved");
|
||||
render(await SM().invoke("turnOn", { vless: currentVless }));
|
||||
}
|
||||
} catch (e) {
|
||||
hint.className = "hint err"; hint.textContent = e?.message || String(e);
|
||||
statusEl.className = "pill err"; statusTx.textContent = "failed";
|
||||
progressBar.hidden = true; toggle.disabled = false;
|
||||
toggle.className = "toggle off"; toggle.textContent = "Turn on";
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("save").addEventListener("click", saveEndpoint);
|
||||
$("paste").addEventListener("click", async () => {
|
||||
try { $("vless").value = await navigator.clipboard.readText(); } catch {}
|
||||
});
|
||||
$("autoOn").addEventListener("change", saveAuto);
|
||||
|
||||
$("refreshBinary").addEventListener("click", async () => {
|
||||
pending = true;
|
||||
try {
|
||||
hint.className = "hint"; hint.textContent = "downloading…";
|
||||
await SM().invoke("clearCache", {});
|
||||
await SM().invoke("prepareBinary", {});
|
||||
render(await SM().invoke("status", {}));
|
||||
} catch (e) {
|
||||
hint.className = "hint err"; hint.textContent = e?.message || String(e);
|
||||
} finally { pending = false; }
|
||||
});
|
||||
$("clearCache").addEventListener("click", async () => {
|
||||
render(await SM().invoke("clearCache", {}));
|
||||
});
|
||||
|
||||
// Live updates from index.js — download progress, spawn crashes, etc.
|
||||
try {
|
||||
window.silentmode.on?.("state", (s) => render(s));
|
||||
} catch {}
|
||||
|
||||
(async () => {
|
||||
await loadEndpoint();
|
||||
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); }
|
||||
}
|
||||
// Periodic refresh for slow status changes (download bytes, unexpected
|
||||
// sing-box exit). Cheap; runs only while the panel is visible.
|
||||
setInterval(tick, 800);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue