theseus/bundled-addons/vpn/DESIGN.md

278 lines
12 KiB
Markdown
Raw Normal View History

# 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.
### Why the bundled list ships with no credentials
A `vless://` URL **is** the credential for its exit — UUID plus Reality
parameters is all a client needs. That leads to a rule worth stating
explicitly, because it has now been violated once and nearly a second
time in a different wrapper:
> A shared exit credential must never be written anywhere immutable or
> world-readable. Not the addon tarball (signed, immutable, mirrored
> forever). Not a git commit (history is permanent). Not a Sia object
> (mutable, but still world-readable, and the addon source that names
> the URL is public).
The first attempt baked the three URLs into `server-list.json` and
shipped it as `vpn-0.1.3.tar.gz`; that was rolled back, the tarball
deleted from Sia, and all three servers' UUID + Reality keypair +
shortId rotated. The second attempt was going to host the same JSON on
Sia and overlay it — rotatable and out of git, which is genuinely
better, but still publishing a secret. Both are the same category of
mistake.
The distinction that actually matters is not *where* the shared
credential lives but *whether a shared credential exists at all*. With
per-session minting there is nothing to leak: each connect gets its own
UUID with a TTL, revocable on its own, attributable to one requester.
That is why the bundled entries sit at `awaiting-key-issuer` rather
than being filled in — the honest state, not a placeholder someone
should be tempted to complete.
Working credentials for the three live exits are in the repo-root
`.keys/vpn-servers.json` (gitignored via `.gitignore:70 /.keys/`) for
operator testing via the panel's Custom box. They stay there.
### Built: the gateway side
Landed in `Argus/src/gateway/public-gateway.mjs` as two routes. Smoke
test: `node scratchpad/vpn-session-smoke.mjs` (17 checks, stages the
flat VPS layout so it runs the real file).
**`GET /api/vpn/servers`** — exit list, Reality material only, no UUID.
Cached 5 min. This is what the addon's `SERVER_LIST_URL` already points
at, so once the pool file exists on the VPS the dropdown lights up with
no addon change.
**`POST /api/vpn/session`** `{ serverId, tier?, address?, ts?, sig? }`
→ `{ vless, uuid, serverId, tier, expiresAt, reused }`. Leases one
pre-provisioned UUID for `BNS_VPN_TTL_MS` (default 24 h). Re-asking
inside the TTL returns the same lease.
#### What still has to happen on the servers
1. **Provision a UUID pool per inbound.** Each exit's xray config needs
N clients instead of one. On each box, for N=50:
```bash
for i in $(seq 50); do /usr/local/bin/xray uuid; done
```
then put them in that inbound's `settings.clients` array (each with
`"flow": "xtls-rprx-vision"`) and `systemctl restart xray`.
2. **Write `/opt/bns-gateway/vpn-pool.json`** on the gateway box. Shape:
```json
{ "servers": [
{ "id": "sm-1", "label": "Silent Mode · 1",
"host": "…", "port": 8443, "sni": "www.cloudflare.com",
"fp": "chrome", "pbk": "<reality public key>", "sid": "<short id>",
"flow": "xtls-rprx-vision",
"tiers": ["free","pro","max"],
"pool": ["<uuid>", "…"] } ] }
```
`chmod 600`, owned by the gateway user. Never committed, never
uploaded, never served. `tiers` is what gates free/pro/max, so
moving an exit between tiers is an edit here, not a code change.
3. **Add `X-Forwarded-For` to the vhost.** In
`VPS/SilentMode/silentmode-st.angie.conf`, the `/api/` location
forwards only `Host`. Without the client address the gateway cannot
tell callers apart and `/api/vpn/session` answers 503 by design:
```
location /api/ {
proxy_pass http://127.0.0.1:8700;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; # <- add
}
```
4. **Deploy the gateway from the repo** — per
[gateway-deploy-from-repo](../../../../Users/valer/.claude/projects/D--Dev-SilentMode/memory/gateway-deploy-from-repo.md),
never edit `/opt/bns-gateway` in place:
```bash
ssh silentmode 'cp /opt/bns-gateway/public-gateway.mjs /opt/bns-gateway/public-gateway.mjs.bak.$(date +%s)'
scp Argus/src/gateway/public-gateway.mjs silentmode:/opt/bns-gateway/
ssh silentmode 'systemctl restart bns-gateway'
curl https://navigate.st/api/tlds | head # must still say TLD_BEACON
curl -s https://navigate.st/api/vpn/servers | head
```
#### The gap this does not close
`expiresAt` is bookkeeping. xray keeps honouring a leased UUID until the
inbound config is rotated, so a TTL returns the slot to the pool but
does not disconnect anyone. Two consequences worth holding onto:
- A user who keeps their UUID after expiry keeps working. Capacity, not
access, is what the TTL protects.
- Two people can end up on the same UUID over time (issued, expired,
re-issued), which weakens attribution.
Closing it needs xray's handler API reachable per box — enable the `api`
inbound in each xray config, run a small localhost agent that does
`xray api adduser` / `rmuser`, and have the gateway call those agents
over an authenticated channel. At that point `pool` disappears from the
pool file and only the Reality material stays.
A cheaper partial mitigation worth checking first: xray supports a
per-client byte `total` with the stats/policy services enabled. If that
works on a VLESS+Reality inbound it bounds abuse per UUID without any
new service. **Unverified — test before relying on it.**
### `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.