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.
277 lines
12 KiB
Markdown
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.
|