From 676424db8745bce9da1e880681e258b6f7462ff1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 21 Sep 2026 23:39:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(theseus/vpn):=20VPN=20extension=20scaffold?= =?UTF-8?q?=20=E2=80=94=20sing-box=20under=20our=20own=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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:. 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. --- bundled-addons/vpn/DESIGN.md | 159 +++++++++++ bundled-addons/vpn/addon.json | 11 + bundled-addons/vpn/binary-manifest.json | 22 ++ bundled-addons/vpn/index.js | 341 ++++++++++++++++++++++++ bundled-addons/vpn/panel.html | 270 +++++++++++++++++++ 5 files changed, 803 insertions(+) create mode 100644 bundled-addons/vpn/DESIGN.md create mode 100644 bundled-addons/vpn/addon.json create mode 100644 bundled-addons/vpn/binary-manifest.json create mode 100644 bundled-addons/vpn/index.js create mode 100644 bundled-addons/vpn/panel.html diff --git a/bundled-addons/vpn/DESIGN.md b/bundled-addons/vpn/DESIGN.md new file mode 100644 index 0000000..3620490 --- /dev/null +++ b/bundled-addons/vpn/DESIGN.md @@ -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:
" | "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-.exe +bns/theseus.x/vpn-binaries/linux-x64/sing-box- +bns/theseus.x/vpn-binaries/darwin-arm64/sing-box- +``` + +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-.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. diff --git a/bundled-addons/vpn/addon.json b/bundled-addons/vpn/addon.json new file mode 100644 index 0000000..4779708 --- /dev/null +++ b/bundled-addons/vpn/addon.json @@ -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" +} diff --git a/bundled-addons/vpn/binary-manifest.json b/bundled-addons/vpn/binary-manifest.json new file mode 100644 index 0000000..08bcdbe --- /dev/null +++ b/bundled-addons/vpn/binary-manifest.json @@ -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 + } + } +} diff --git a/bundled-addons/vpn/index.js b/bundled-addons/vpn/index.js new file mode 100644 index 0000000..d300bcf --- /dev/null +++ b/bundled-addons/vpn/index.js @@ -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 /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 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: 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: {} }; + } + + 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 || "?"}`); + }, +}; diff --git a/bundled-addons/vpn/panel.html b/bundled-addons/vpn/panel.html new file mode 100644 index 0000000..1aca0a2 --- /dev/null +++ b/bundled-addons/vpn/panel.html @@ -0,0 +1,270 @@ + + + + +VPN + + + +
+

VPN

+ — +
+ +
+ +
+ loading… + + +
+
+ +
+

Endpoint

+

Paste a vless:// 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.

+
+ +
+
+ + +
+
+ +
+ Advanced + +
+ Platform— + Enginesing-box + Binary— + SOCKS5— +
+ +
+
+ +
Restore the VPN as soon as Theseus launches, if it was on when you last quit.
+
+ +
+ +
+ + +
+
+
+ + + +