theseus/bundled-addons/vpn/DESIGN.md
Local Dev 5a17b0e866 docs(vpn): runbook for the key-issuer, and the gap it leaves open
Records what the gateway routes do, the four things that still have to
happen on the servers before the dropdown can light up (UUID pool per
inbound, the pool file, X-Forwarded-For on the vhost, deploy-from-repo),
and — deliberately at the same prominence — that expiry is bookkeeping
rather than enforcement until xray's handler API is wired per box.

Also flags the per-client byte cap as an unverified cheaper mitigation,
marked as needing a test rather than written up as though it works.
2026-09-27 19:06:58 +02:00

277 lines
12 KiB
Markdown

# 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.