VPN service built with Rust — daemon, Telegram bot, admin panel, and Tauri 2 client app.
Three tunnel protocols, two VPS regions:
- AmneziaWG — the default protocol. WireGuard plus DPI-resistant obfuscation; client connects to Moscow VPS (:51821), traffic routes to Europe VPS via a site-to-site WireGuard tunnel (policy routing + MASQUERADE)
- WireGuard — plain WireGuard; client connects to Moscow VPS (:51820), same site-to-site routing to Europe
- VLESS+REALITY — client connects to Moscow HAProxy (:443), which forwards non-web TLS to the local REALITY proxy; proxied traffic exits through Europe
graph TD
Client["<b>Client Apps</b><br/>Tauri 2 — Linux, Windows, Android"]
Client -- "AmneziaWG :51821" --> Daemon
Client -- "WireGuard :51820" --> Daemon
Client -- "VLESS+REALITY :443" --> HAProxy
Client -- "HTTPS" --> HAProxy
subgraph Moscow["Moscow VPS"]
HAProxy["<b>HAProxy</b><br/>TLS SNI routing"]
HAProxy -- "known web SNI :8443" --> Nginx
HAProxy -- "default backend :8444" --> Vless
Vless["<b>floppa-vless</b><br/>VLESS+REALITY proxy · per-user rate limits"]
Vless <-- "pg LISTEN / NOTIFY" --> DB
Vless -- "scrape :9103" --> VM
Nginx["<b>Nginx</b><br/>Reverse proxy + TLS"]
Nginx -- ":3000" --> Server
Server["<b>floppa-server</b><br/>Telegram bot · REST API · Vue admin panel"]
Server <-- "pg LISTEN / NOTIFY" --> DB
Server -- "query traffic" --> VM
DB[("<b>PostgreSQL</b><br/>Source of truth")]
DB <-- "pg LISTEN / NOTIFY" --> Daemon
Daemon["<b>floppa-daemon</b><br/>WireGuard + AmneziaWG sync · tc HFSC rate limits"]
Daemon -- "scrape :9101" --> VM
VM[("<b>VictoriaMetrics</b><br/>Traffic metrics")]
end
subgraph Europe["Europe VPS (exit node)"]
NAT["NAT to public internet"]
end
Daemon -- "site-to-site WG tunnel" --> NAT
Vless -- "UID policy route over site-to-site WG" --> NAT
How it works: Server writes peer changes to PostgreSQL (e.g. sync_status = 'pending_add') → DB trigger fires pg_notify('peer_changed') → daemon picks it up, syncs the peer to its protocol's interface (WireGuard via wg, AmneziaWG via awg — each peer carries a protocol), applies rate limits, and marks the peer active. HAProxy on Moscow sends VLESS/REALITY connections to the local floppa-vless process, which syncs its user registry from the same local database via pg LISTEN/NOTIFY. WireGuard, AmneziaWG, and VLESS egress are policy-routed through the site-to-site tunnel and NATed by Europe. Traffic metrics from both daemon and VLESS are scraped by VictoriaMetrics; the server queries VM to serve traffic stats in the API.
- Stateless WireGuard + AmneziaWG peer synchronization via
wg set/awg set(one interface per protocol; AmneziaWG adds DPI-resistant obfuscation) - Per-peer HFSC traffic shaping (bidirectional — egress + IFB ingress)
- Prometheus metrics endpoint — traffic counters scraped by VictoriaMetrics
- Auto-runs database migrations on startup
- shoes-lite — VLESS+REALITY with Vision flow control
- Per-user token-bucket rate limiting synced from subscription plans
- Real-time user registry via
pg LISTEN/NOTIFY+ periodic full sync - Prometheus metrics endpoint for per-user traffic counters
- Constant-time UUID comparison (timing-attack resistant)
- User registration with automatic 7-day trial
- Subscription status, language switching (en/ru)
- Inline button to open the web app
- Dashboard with server stats and traffic overview
- User management — create, search, subscription control
- Plan management — speed limits, peer limits, pricing
- Peer monitoring — sync status, traffic, last handshake
- Paginated lists (users, peers, installations, VLESS) — 100 rows/page
Telegram profile photos are served from a CDN that's unreachable from clients in Russia (and sends no CORS headers), so the server downloads each user's photo — via the Bot API (getUserProfilePhotos → getFile), falling back to the stored photo_url — caches it as a blob in PostgreSQL, and serves it from our own origin. Populated on demand (first avatar request triggers a background fetch) with a periodic TTL refresh; the admin user list fetches avatars for the visible page in one batch.
- Standalone WireGuard / AmneziaWG / VLESS client (
floppa) for headless/server use:login(Telegram in the browser),config,connect --protocol wireguard|amneziawg|vless(AmneziaWG by default, like the app),peers,logout - The same binary is also the system service (
floppa service, Linux, root), which holds the tunnel in a process that outlives every client.connectwithout--configthen asks the service instead of building a tunnel here, andimport,status,disconnectandresumedrive it.--configkeeps the old meaning: a tunnel this command builds and holds. Connecting on boot isfloppa autostart on, or a switch in the app's settings. Seedocs/DESKTOP-TUNNEL-SERVICE.md - Login token:
<config dir>/floppa/token(0600; undersudothe invoking user's config dir), or--token-file/FLOPPA_TOKEN_FILE, or inline--token/FLOPPA_TOKEN - Device identity generated once and persisted next to the token as
device.json, so every run finds its own peer instead of adopting another device's. The same code the app uses — one generator, because the server tells installations apart by that id and a second one would be a second device, with its own peers taken from the account's limit - DNS on Linux goes through
resolvectlwhen systemd-resolved manages/etc/resolv.conf(resolvectl reverton exit),/etc/resolv.confotherwise;--no-dnsskips it - Exits cleanly on SIGINT, SIGTERM (systemd/docker stop) and SIGHUP, restoring routes and DNS
- Also used as the tunnel binary for integration tests
- Cross-platform: Linux, Windows, Android
- AmneziaWG (default), WireGuard, and VLESS+REALITY tunnel support
- gotatun — WireGuard and AmneziaWG in userspace, a fork of Mullvad's boringtun carrying the AmneziaWG obfuscation work
- Split tunneling with per-app selection (Android)
- VPN config persistence in the OS keyring (desktop, with a 0600 file fallback when the keyring is unavailable) or a 0600 file in the app's private data directory (Android)
- Deep-link authentication (Telegram Login Widget → JWT)
- Two-process architecture on Android (VPN survives app swipe-close)
- Always-on VPN and lockdown ("block connections without VPN") on Android: the service rebuilds the last successful tunnel on its own when the system starts it — at boot, or with the app never opened — and the app adopts it when opened
The client uses trait-based abstraction (VpnBackend + Platform) to share Tauri commands across platforms while handling OS differences underneath.
Language split:
- Rust — all VPN logic: WireGuard/AmneziaWG tunnel (gotatun), VLESS tunnel (shoes-lite), connection management, route/DNS/TUN setup, config persistence, IPC between processes, Tauri commands. The entire
VpnBackendandPlatformtrait hierarchy is Rust - TypeScript / Vue — UI layer: connection controls, stats display, settings, split tunneling picker, update checks, theme management
- Kotlin (Android only) — thin platform bridge via
tauri-plugin-vpn: VPN service lifecycle, TUN fd creation,VpnService.Builderfor split tunneling, foreground notification, system API access (battery optimization, notification permissions, status bar style, safe area insets, device name)
graph LR
subgraph "Single Process"
WebView["Vue WebView"]
Commands["Tauri Commands"]
Backend["InProcessBackend"]
Tunnel["gotatun tunnel<br/>(Mullvad WireGuard)"]
Platform["Platform trait<br/>Linux: pkexec + helper script<br/>Windows: netsh"]
end
WebView -- "tauri-specta" --> Commands
Commands --> Backend
Backend --> Tunnel
Commands --> Platform
Platform -- "routes, DNS,<br/>TUN device" --> OS["OS Network Stack"]
Single-process: gotatun runs the WireGuard tunnel in-process. The Platform trait handles OS-specific network setup — Linux uses a polkit helper script for privilege escalation, Windows uses netsh. Config is persisted in the OS keyring (secret-service / DPAPI); a 0600 file in the config directory is the fallback while the keyring is unavailable, and whichever copy was written last wins (the file is migrated into the keyring and removed once it is usable again). Graceful cleanup on exit restores DNS and routes.
On Linux there is a second, preferred arrangement: floppa service, a root systemd service that
holds the actor in a process outliving every client, so the tunnel survives closing the app. Clients
reach it over /run/floppa-vpn/vpn.sock, gated by the floppa group, and choose between the two
modes by probing that socket once at startup. The single-process mode above stays exactly as it is —
it is what a tarball, an AppImage, Windows and macOS use. See
docs/DESKTOP-TUNNEL-SERVICE.md.
graph LR
subgraph "UI Process"
WebView["Vue WebView"]
Commands["Tauri Commands"]
Plugin["tauri-plugin-vpn<br/>(Kotlin ↔ Rust)"]
IPC_Client["tarpc client"]
end
subgraph ":vpn Process"
Service["FloppaVpnService<br/>(foreground service)"]
JNI["JNI bridge"]
Tunnel["gotatun tunnel"]
IPC_Server["tarpc server"]
end
WebView -- "tauri-specta" --> Commands
Commands --> Plugin
Plugin -- "startService()" --> Service
Service -- "TUN fd" --> JNI
JNI --> Tunnel
IPC_Client <-- "Unix socket<br/>(stats, stop)" --> IPC_Server
Tunnel -- "protectSocket()<br/>via JNI" --> Service
Two-process model so the VPN survives app swipe-close:
Single .so, two entry points, two processes — Tauri compiles all Rust code into one libfloppa_client_lib.so with two entry points: the standard Tauri/JNI entry for the UI process, and nativeInit / nativeStartServer / nativeStop JNI exports for the VPN process. The Kotlin FloppaVpnService is declared with android:process=":vpn" in the manifest, so Android loads the same .so into a separate process. JNI statics (JAVA_VM, TOKIO_RUNTIME, TUNNEL_MANAGER) are per-process — each process gets its own isolated Rust state from the same binary.
Why tarpc? Android's standard IPC (AIDL, Messenger) is Java/Kotlin-only — useless when both ends are Rust. gRPC adds HTTP/2 overhead. tarpc is pure Rust, async-native, and works directly over Unix domain sockets with bincode serialization. The UI process connects to vpn.sock in the app data directory to query stats or request stop.
The flow: tauri-plugin-vpn (Kotlin) starts FloppaVpnService as a foreground service → service creates TUN via Android's VpnService.Builder → passes the raw fd to Rust via JNI (nativeStartServer) → the tarpc server starts listening on vpn.sock and the tunnel (gotatun or shoes-lite) is brought up over that fd. When gotatun creates UDP sockets, it calls back into Kotlin via JNI (protectSocket) to mark them as bypass — preventing WireGuard packets from routing through the VPN itself. Split tunneling uses Android's per-app VPN API (addAllowedApplication / addDisallowedApplication).
Always-on: after every verified connect the UI process writes autostart.json (the TUN parameters, the protocol config, the resolved endpoint and the split rules; 0600 in the app's private data directory, like vpn-config.json). When the system starts the service with no configuration — the always-on toggle, boot, a lockdown restore — the service reads that bundle through nativeLoadAutostart, builds the same TUN, binds the RPC and starts the tunnel in-process (nativeStartTunnelFromBundle) under an epoch from a range the UI never uses. The UI, once opened, finds the tunnel over the RPC with its protocol and split rules reported by the service and adopts it as connected. Forgetting the configs removes the bundle, so a later system start finds nothing and stops.
Three apps — admin panel (floppa-face), Tauri client (floppa-client), and Telegram Mini App — share a single codebase via the floppa-web-shared package in a Bun workspace.
graph TD
Shared["<b>floppa-web-shared</b><br/>Views · Components · Router · Stores<br/>OpenAPI client · i18n · Utils"]
Face["<b>floppa-face</b><br/>Admin panel<br/>Uses shared routes as-is"]
Client["<b>floppa-client</b><br/>Tauri app<br/>Overrides login + dashboard"]
MiniApp["<b>Telegram Mini App</b><br/>Same as admin panel<br/>Auto-login via initData"]
Shared --> Face
Shared --> Client
Shared --> MiniApp
How it works:
- Shared router —
createAppRoutes()returns all routes,installAuthGuard()adds auth checks. The admin panel uses them as-is; the client app overridesloginanddashboardroutes with its own components - Slot-based composition — shared views expose named slots (e.g.
UserDashboardViewhas a#vpn-widgetslot). The client fills it withVpnCard, the admin panel leaves it empty - Three auth flows, one component — the shared
LoginViewhandles all three via props:- Admin panel — embedded Telegram Login Widget (JavaScript callback)
- Tauri client — opens browser for Telegram OAuth, server redirects to
floppa://auth?token=..., Tauri captures via deep-link plugin - Mini App — auto-login with
window.Telegram.WebApp.initData(no user interaction, already authenticated inside Telegram)
- OpenAPI → Pinia Colada — the server generates an OpenAPI spec via utoipa,
@hey-api/openapi-tsgenerates a typed SDK + Pinia Colada query/mutation hooks. All apps share the same auto-generated API client - Nuxt UI v4 without Nuxt — used as a Vue plugin (
@nuxt/ui/vue-plugin) for the component library without the full Nuxt framework - Tailwind v4 cross-scanning — each app's CSS includes
@source "../../floppa-web-shared/src"so Tailwind picks up classes from shared components
| Layer | Tech |
|---|---|
| Server | Rust, Axum, teloxide, sqlx, utoipa (OpenAPI), memory-serve |
| Daemon | Rust, WireGuard (wg) + AmneziaWG (awg, kernel DKMS module), Linux tc HFSC, Prometheus metrics |
| VLESS Proxy | Rust, shoes-lite (VLESS+REALITY+Vision), Prometheus metrics |
| Frontend | Vue 3, Nuxt UI v4, Pinia Colada, Tailwind v4 |
| Client | Tauri 2, gotatun (WireGuard + AmneziaWG obfuscation, fork of Mullvad's boringtun), shoes-lite (VLESS), tauri-specta, custom tauri-plugin-vpn; floppa-tunnel-config (config parser, AmneziaWG params, route helpers) shared with floppa |
| Database | PostgreSQL with LISTEN/NOTIFY |
| Metrics | VictoriaMetrics (Prometheus-compatible TSDB) |
| Crypto | x25519-dalek (WG keys), ChaCha20-Poly1305 (storage), XTLS REALITY, JWT |
# Prerequisites: Rust toolchain, Vite+ (`vp`), just
# Install frontend dependencies
vp install
# Run all checks (fmt, clippy, tests, type-check, lint)
just check
# Dev servers
cd floppa-face && vp dev # Admin panel (proxies /api → :3000)
cd floppa-client && vp exec tauri dev # Client app
# Regenerate OpenAPI TypeScript client
just openapi
# Build Android APK
just build-android
# Build deployment archive (frontend + server binaries)
just packageSee DEPLOYMENT.md for the full guide. Ansible deploys across two VPS regions:
- Moscow —
floppa-daemon(root, WireGuard + tc),floppa-server(bot + API + embedded frontend),floppa-vlessbehind HAProxy, VictoriaMetrics, Grafana, nginx with Let's Encrypt - Europe — site-to-site WireGuard endpoint and NAT exit for all VPN protocols
