PharosVPN
§08 · docs

draft v2 · 2026-05-19 · source on github ↗ · synced 2026-05-19

Platform Design

PharosVPN — Platform Design

Status: draft v2 · 2026-05-17

This is the single source of truth for the PharosVPN platform. Every subproject README and BUILD.md defers to this document. When code and this document disagree, the document is wrong — fix it in the same PR.


0. What PharosVPN is

A self-hostable, open-source, dual-protocol VPN fleet platform. One codebase serves two postures from the same binaries:

Defaults differ; the engine is identical.

The data plane is AmneziaWG (obfuscated WireGuard) and XRay (VLESS + REALITY), both terminating end-user tunnels on UDP/TCP 443. The platform is the control plane, account system, and clients around that data plane.


1. Goals


2. The three node roles + clients

                  ┌──────────────────────────────────┐
                  │  coxswain  — CONTROLLER            │
                  │  (private network, behind NAT)    │
                  │  vpn-mgr daemon + admin Web UI     │
                  │  + SQLite state + CA + embedded    │
                  │    relay (toggleable)              │
                  └───────┬──────────────────┬─────────┘
        mTLS, coxswain-initiated outbound    │ reverse tunnel
        gRPC/HTTP2 to each node              │ (coxswain dials OUT to a
                  │                          │  remote relay)
        ┌─────────┼─────────┐                ▼
        ▼         ▼         ▼          ┌──────────────┐
   ┌────────┐┌────────┐┌────────┐      │    RELAY     │  (public)
   │  node  ││  node  ││  node  │ ...  │ mTLS ingress │
   │ NODE A ││ NODE B ││ NODE N │      │ for clients  │
   │ public ││ public ││ public │      └──────┬───────┘
   │ AWG udp ││  ...   ││  ...   │             │ mTLS
   │ XRay tcp││        ││        │             ▼
   └────┬───┘└────┬───┘└────┬───┘      ┌──────────────┐
        │ udp/tcp 443       │          │   caravel    │
        ▼        end users  ▼          │ mobile client│
     end-user tunnels                  └──────────────┘
RoleRepoNetwork postureJob
ControllercoxswainPrivate, behind NAT. Zero inbound ports.Source of truth, admin UI, issues certs/profiles, drives the fleet.
VPN nodenodePublic IP. Listens udp/tcp 443 + mTLS control port.Runs the data plane. Dumb agent — applies only validated config.
RelayrelayPublic. The only public ingress for clients.mTLS-terminating proxy. Lets clients reach a NAT’d controller. Always embedded in coxswain; optionally deployed remote.
Mobile clientcaravelEnd-user device.Runs the actual VPN tunnel + acquires profiles from multiple sources.

Key inversion: the controller dials out to everything. Nodes are already public (they must be, to terminate tunnels), so coxswain initiates outbound mTLS to each node. coxswain also dials out to a remote relay (reverse tunnel), so the controller needs zero inbound ports anywhere.

relay embedded vs remote. A relay always runs in-process inside coxswain (toggleable off in the admin UI). When the controller sits behind NAT and must serve clients, deploy a remote relay on a public host (its own VM, or co-located on a node); coxswain dials out to it over a persistent reverse tunnel. Embedded and remote are transport differences only — identical trust.


3. Component responsibilities

coxswain (controller)

node (VPN node agent)

Node network policy

Each node carries a network policy the operator sets per node from the admin UI — three independent toggles coxswain pushes over the control channel:

ToggleEffect
Forwardingroute client traffic onward; off ⇒ the node is a dead end
Masqueradesource-NAT client traffic to the node’s IP; off ⇒ the destination sees the client’s tunnel IP (internal-resource VPNs want this)
Client isolationdrop client-to-client forwarded traffic; off ⇒ clients route peer-to-peer

Masquerade and isolation require forwarding. The toggles translate to a canonical PostUp / PostDown rule setcoxswain generates it, shows it read-only in an advanced UI panel, and pushes the policy; node applies the same set (egress interface autodetected):

forwarding   iptables -A FORWARD -i %i -j ACCEPT;  -A FORWARD -o %i -j ACCEPT
             sysctl net.ipv4/ipv6 ... forwarding = 1
masquerade   iptables -t nat -A POSTROUTING -o <egress> -j MASQUERADE
isolation    iptables -I FORWARD 1 -i %i -o %i -j DROP

PostDown removes each with the matching -D. The rule set is the contract: coxswain’s preview and node’s application must not drift.

Endpoint diversity & rotation

A node does not expose a single address. It accepts AmneziaWG on a set of public IPs and a UDP port range, yielding a large pool of reachable (ip, port) endpoints from a small config (node binds the IPs and DNATs the port range onto the WireGuard listener). A profile carries that pool plus a rotation policy{ enabled, interval, jitter }. The client picks a random endpoint and, when rotation is enabled, re-picks every interval ± jitter, updating the WireGuard peer endpoint live (WireGuard peers roam by design, so this costs nothing).

The purpose is anti-correlation. When every member of an organisation always reaches the same ip:port, a network observer clusters them by endpoint — same endpoint, same affiliation. A random, rotating endpoint per client removes that stable signal without touching the tunnel crypto. Rotation defaults off for --personal (a lone operator gains nothing from it) and on for --enterprise; operator-overridable either way.

Endpoints are always represented as an array, even when a node has a single ip:port — there is no singular-endpoint form.

relay

caravel (mobile client)


4. Trust model & PKI

A single in-repo root CA, generated on coxswain’s first run, stored in coxswain’s SQLite, never copied off the controller. Two intermediates under it:

CertificateIssued byHeld byValidity
Root CAself-signedcoxswain only10 years
Fleet / Device intermediatesRoot CAcoxswain only5 years
Controller client certFleet CAcoxswain1 year, auto-rotated
Node server certFleet CAeach node1 year, auto-rotated by push
Relay certFleet CAeach relay1 year, auto-rotated
Device leafDevice CAeach caravel / browser1 year
coxswain SSH keycoxswain (self)coxswainlong-lived, for agent deploy

Compromise containment:

Post-quantum hardening. Every AmneziaWG peer is issued a unique 256-bit preshared key, mixed into the WireGuard handshake. WireGuard’s ECDH (Curve25519) is quantum-vulnerable; the symmetric PSK is not — so recorded tunnel traffic stays confidential against a future harvest-now-decrypt-later attacker. coxswain generates the PSK per peer, ships it inside the E2E profile bundle (§8), and pushes it to node. This is a pragmatic interim measure, not a full post-quantum handshake.


5. Bootstrap & enrollment

Node enrollmentcox nodes add <ssh-host>:

  1. The operator creates a VM on any provider and adds coxswain’s SSH public key (printed by cox ssh-key) to its authorized_keys. coxswain has its own SSH keypair, generated on first run and stored in SQLite.
  2. coxswain connects out over SSH, pins the host key on first use (TOFU), and installs the node agent — either by uploading a bundled binary or running a one-line download.
  3. node generates its own keypair on the node and emits a CSR. coxswain pulls the CSR back over SSH, signs it with the Fleet CA, and pushes the certificate plus the CA back. The node’s private key never leaves the node and coxswain never holds it.
  4. coxswain starts the node service. From here every instruction is gRPC.

SSH is a deployment channel only — install and update of the agent. There is no enrollment-mode listener and no one-time bootstrap token; the trusted SSH channel replaces both.

Relay enrollment — same SSH pattern for a remote relay.

User / device enrollment — a user is given an enrollment ticket (QR or deep link, see §9). caravel scans it, contacts relaycoxswain, the device generates a keypair, coxswain issues a Device-CA leaf, and the device is bound to the user account. The enrollment ticket is the only moment of weakness: short TTL, one-use, scoped.


6. Wire protocol

gRPC over mTLS (HTTP/2, TLS 1.3). Decided — not plain JSON. The live event streaming in §7 needs server-streaming, which is native to gRPC and awkward otherwise. Both Go ends make codegen cheap; caravel consumes generated clients too.


7. Real-time & multi-admin

The admin UI must feel live — a client connecting to a node appears immediately, not on a 30-second poll.

Optimistic concurrency. Every mutable record carries a version integer. A mutation must send the version the admin loaded. If coxswain’s current version is higher, it rejects with HTTP 409 Conflict — “changed by someone else, reload.” Admin A editing a stale copy of a user is refused because Admin B already bumped it. Live WebSocket replication makes conflicts rare (A’s screen usually updates before A saves); the version check is the hard safety net.


8. Accounts, profiles & sync

Accounts and roles

users are authentication principals (unlike the old codebase, where only admins logged in). On login the role decides the surface: user → own profiles only; admin → also the admin console (web = full; caravel = a small glance-and-quick-actions subset).

Profile sources (the unified-client model)

caravel has a VPN engine that only ever reads a local profile store. Profiles enter that store from interchangeable sources — “synced vs unsynced” is just which sources are enabled, not two apps:

SourceAudienceMechanism
Account syncPersonallogin → relaycoxswain → pull, E2E-decrypt on device
QR scanAnyonescan an enrollment ticket or a self-contained profile QR
File importAnyoneopen a .pharos file (Mail/Files/AirDrop/portal)
MDM managed configEnterpriseMDM pushes profiles + policy into managed config
Deep linkPortal-drivenpharosvpn://import?...

The account/sync service + relay are an optional platform component: an enterprise doing only MDM/QR runs no relay and no account service.

End-to-end profile encryption

Each user has a long-lived keypair. coxswain holds only the public key. coxswain generates a profile, encrypts it to the user’s public key, stores ciphertext, discards plaintext. Only the user’s devices decrypt. Hybrid envelope:

Private-key storage — DECIDED: passphrase-wrapped blob held by coxswain. The user’s private key is encrypted with a key derived (Argon2id) from the user’s passphrase; coxswain stores only that opaque blob and never the passphrase or a usable private key. Any new device unwraps it with the passphrase. This gives seamless multi-device + recovery; a coxswain compromise yields only a passphrase-encrypted blob. (Device-to-device-only transfer was the alternative — stronger, but no recovery and clunkier enrollment; rejected for v1.)


9. The .pharos file format

One extension: .pharos. Encryption is a property inside the file, not a separate extension (no .spharos). The app reads the header and auto-routes.

A .pharos file is a JSON container with an always-readable header:

{
  "fmt": "pharos-profile",
  "v": 1,
  "enc": "none" | "password" | "account",
  // password mode: "kdf": {argon2id params + salt}, "cipher", "nonce"
  // account mode:  "recipient", "wrapped_key", "cipher", "nonce"
  "payload": <profile object>  |  "<base64 AEAD ciphertext>"
}
encMeaningClient handling
nonePlaintextLoad directly
passwordArgon2id → XChaCha20-Poly1305Prompt for password, decrypt
accountPer-user hybrid envelope (§8)Decrypt silently with device key

The header is fed as AAD so enc/v/KDF params are authenticated (no downgrade). Content-sniff on fmt so renamed files still import. Register MIME application/vnd.pharosvpn.profile, an iOS UTI, an Android intent filter.

Future-proof protocol model. The profile carries nodes, each with a versioned, tagged list of protocols:

{ "nodes": [ { "id": "...", "endpoints": ["..."], "protocols": [
    { "type": "amneziawg",    "v": 2, "params": { } },
    { "type": "xray-reality", "v": 1, "params": { } }
] } ] }

Clients keep a registry of handlers keyed by type. Ignore-unknown, never reject: an old client skips a protocol/node it can’t handle. Adding a protocol = a new type string + a handler, zero format break. Plus metadata: user, device, fleet_id, issued_at, expires_at, revision.

QR codes

Compression does not solve QR size — profiles are mostly incompressible key material. A reliably-scannable QR holds ~150–300 bytes; a multi-node profile is kilobytes. Two QR kinds:

A separate Amnezia-compatible .vpn export remains for users on the Amnezia client. .pharos is our format and is not asked to do that job.


10. Persistence

Single SQLite database on coxswain (state/app.db), Goose migrations.

Tables: ca (the root + intermediate CAs, §4), nodes, profiles, users, devices, peers, admins, sessions, node_certs, device_certs, bootstrap_tokens, audit_log, metrics_samples, relays. Every mutable row carries version INTEGER and updated_at for §7 optimistic concurrency. YAML projections under state/snapshots/ continue for git-friendly diffs. node and relay have no database.


11. Failure modes

FailureBehaviour
coxswain crashesAll nodes keep serving tunnels. No new peers until back.
coxswain ↔ node unreachableNode keeps serving. coxswain marks unreachable after 3 missed polls, alerts, retries with backoff.
node crashesIts tunnels drop. Clients fail over to other nodes in the profile.
node compromisedAttacker has that node’s keys, not the CA key. Operator revokes the cert.
Remote relay compromisedTraffic metadata exposed; profile bundles are ciphertext. No cert minting.
coxswain compromisedWorst case. CA key lost → rotate CA, mass re-enroll. User profiles stay encrypted.
Account service / relay downcaravel connects from cached local profiles.

12. Defaults: personal vs enterprise

Same binaries, two presets at cox init:

--personal--enterprise
Regions1, nearestoperator picks
Idle nodesnoneencouraged (pre-positioned, stopped)
ProtocolsAmneziaWG default, XRay optionalboth
relayembeddedembedded + remote relays
Account synconoptional (MDM-only deployments run none)
Adminsone (the operator)core admin + UI-added others
Audit retention30 days1 year
Metrics retention7 days90 days
REALITY decoy sitewww.microsoft.comconfigurable, rotated
Endpoint rotationoffon (anti-correlation, §3)

13. License & contribution


14. Repo map

RepoWhatStackOwner
docsThis document, BUILD.md, protobuf contractsMarkdown / protocore
coxswainController / management plane + admin UIGo + SvelteKitcore (you + Claude)
nodeVPN node agentGosubagent
relayRelayGosubagent
caravelClient engineshared Go core + native shellssubagent
.githubOrg profileMarkdowncore

15. Decisions log

#DecisionDate
1Name: PharosVPN. Org github.com/PharosVPN.2026-05-17
2License Apache-2.0 + DCO, no CLA.2026-05-17
3Wire protocol: gRPC over mTLS (not plain JSON).2026-05-17
4Three roles: coxswain / node / relay; client caravel.2026-05-17
5relay always embedded in coxswain, optionally remote.2026-05-17
6Live UI: node→coxswain event stream + coxswain→browser WebSocket.2026-05-17
7Optimistic concurrency: per-row version, 409 on stale write.2026-05-17
8Per-user E2E profile encryption; hybrid envelope.2026-05-17
9Private key: passphrase-wrapped blob on coxswain (Argon2id).2026-05-17
10File format: single .pharos extension, enc in-header.2026-05-17
11Protocols: versioned tagged list, ignore-unknown.2026-05-17
12QR: enrollment ticket default; self-contained QR for offline.2026-05-17
13Reuse proven reverse-tunnel / transparent-proxy / device-CA machinery for relay.2026-05-17
14Node/relay onboarding over SSH (agent install + update); no cloud-provider API. Node keys are generated on-node and signed via CSR; no bootstrap token. Supersedes the §3 CloudProvider interface.2026-05-18
15Per-peer 256-bit AmneziaWG preshared keys, for post-quantum (harvest-now-decrypt-later) hardening of the data plane. See §4.2026-05-19
16Per-node network policy — forwarding / masquerade / client-isolation toggles, set per node from the admin UI. See §3.2026-05-19
17Multi-IP/port node endpoints + client-side endpoint rotation, for anti-correlation. Endpoints are always an array. Rotation default off (personal) / on (enterprise). See §3.2026-05-19

Still open