High-performance, censorship-resistant VPN built with Rust
⚠️ Mavi VPN is early beta software and has not been independently audited. Do not rely on it for high-risk security use cases yet.
Mavi VPN tunnels all network traffic over QUIC via the quinn fork and the h3 fork, both tracked on main, to deliver secure, reliable, low-latency connectivity — even on unstable mobile networks. It supports Windows, Linux, and Android with native clients and an optional cross-platform Tauri GUI.
| Category | Feature | Details |
|---|---|---|
| Censorship Resistance | Layer 7 Obfuscation | VPN traffic masquerades as HTTP/3 via ALPN h3 |
| Probe Resistance | Unauthorized connections receive a fake nginx welcome page (H3 200 OK) | |
| MASQUE / RFC 9484 | Optional connect-ip capsule framing for DPI-proof wire format |
|
| Encrypted Client Hello | ECH GREASE + SNI spoofing via X25519/HPKE (RFC 9180) | |
| Certificate Pinning | SHA-256 cert fingerprint verification on all clients | |
| Performance | Zero-Copy Path | bytes/BytesMut across the entire packet pipeline |
| BBR Congestion Control | Optimized for high-bandwidth, high-latency mobile networks | |
| GSO/GRO | Generic Segmentation Offload to reduce syscall overhead | |
| 4 MB UDP Buffers | Auto-tuned OS-level socket buffers for burst resilience | |
| mimalloc | High-performance memory allocator on the server | |
| Mobile-First | Seamless Roaming | QUIC connection migration — no handshake restart on IP change |
| MTU Pinning (1280/1360) | Avoids PMTUD black holes; ICMP PTB signal generation (RFC 4443) | |
| Split Tunneling | Per-app VPN bypass on Android | |
| Auth | Static Token | Simple pre-shared key authentication |
| Keycloak OIDC | Enterprise SSO with JWT validation, PKCE, and JWKS rotation | |
| Network | Dual-Stack | Full IPv4 + IPv6 support (NAT66 via ip6tables) |
| DNS Isolation | NRPT rules on Windows; per-tunnel DNS on Linux/Android |
graph TD
subgraph "Client — Windows / Linux / Android"
GUI["Tauri GUI / CLI / Android App"]
SVC["Background Service / Daemon / JNI Core"]
TUN_C["Virtual TUN Adapter"]
GUI <-->|"IPC (TCP 14433)"| SVC
SVC <-->|"Packet I/O"| TUN_C
end
subgraph "Transport — UDP/QUIC"
QUIC["QUIC Datagrams\n(or MASQUE connect-ip capsules)"]
end
subgraph "Server — Linux Docker Container"
AUTH["Auth Handshake\n(Token / Keycloak JWT)"]
HUB["Packet Routing Hub\n(DashMap peer table)"]
TUN_S["Virtual TUN Adapter"]
AUTH <--> QUIC
HUB <--> QUIC
HUB <-->|"Packet I/O"| TUN_S
end
SVC <-->|"QUIC payload ≤1360 bytes"| QUIC
QUIC <-->|"QUIC payload ≤1360 bytes"| HUB
mavi-vpn/
├── backend/ # Linux VPN server (Rust) — QUIC endpoint, IP pool, routing, Keycloak
│ ├── src/
│ │ ├── main.rs # Entry point, connection accept loop
│ │ ├── config.rs # CLI/env config (clap)
│ │ ├── state.rs # AppState: IP pool (v4+v6), peer DashMap
│ │ ├── routing.rs # TUN reader/writer tasks with local peer cache
│ │ ├── cert.rs # TLS cert generation & SHA-256 PIN export
│ │ ├── ech.rs # ECH key generation & ECHConfigList persistence
│ │ ├── keycloak.rs # OIDC JWT validator with JWKS refresh
│ │ ├── handlers/ # Per-connection QUIC session handler
│ │ ├── network/ # TUN device creation, h3-quinn adapter
│ │ └── server/ # QUIC endpoint builder (BBR, timeouts, buffers)
│ ├── docker-compose.yml # Full stack: VPN + optional Traefik + Keycloak
│ ├── entrypoint.sh # iptables NAT, IPv6 forwarding, MSS clamping
│ └── .env.example # All configuration variables documented
│
├── windows/ # Windows client (Rust) — WinTUN, Service/Client IPC
│ └── src/
│ ├── main.rs # CLI client (start/stop/status)
│ ├── bin/service.rs # Windows Service (WinTUN, routing, NRPT DNS)
│ ├── vpn_core.rs # QUIC tunnel logic, ECH, MASQUE framing
│ └── oauth.rs # PKCE OAuth2 flow for Keycloak
│
├── linux/ # Linux client (Rust) — TUN via /dev/net/tun, systemd
│ └── src/
│ ├── main.rs # CLI + daemon mode + IPC client
│ ├── vpn_core.rs # QUIC tunnel logic with network change detection
│ ├── daemon.rs # IPC server (TCP 14433) for GUI integration
│ ├── network.rs # Route setup, DNS config, cleanup
│ └── tun.rs # Raw TUN device via ioctl
│
├── android/ # Android app (Kotlin + Rust JNI)
│ └── app/src/main/
│ ├── kotlin/ # Jetpack Compose UI, VpnService, NetworkCallback
│ └── rust/src/lib.rs # JNI core: QUIC, cert pinning, connection migration
│
├── gui/ # Cross-platform Tauri v2 GUI (HTML/CSS/JS + Rust)
│ ├── src/ # Frontend (vanilla HTML/CSS/JS)
│ └── src-tauri/ # Tauri backend (IPC bridge, system tray, WiX installer)
│
├── shared/ # Shared library (Rust)
│ └── src/
│ ├── lib.rs # ControlMessage protocol (Auth → Config → Datagrams)
│ ├── icmp.rs # ICMP "Packet Too Big" generation (RFC 792/4443)
│ ├── ipc.rs # IPC protocol (SecureIpcRequest, Config, Response)
│ ├── masque.rs # MASQUE connect-ip: capsules, varints, datagram framing
│ └── hex.rs # Hex encode/decode utilities
│
├── quic-tester/ # DPI probe simulator — verifies censorship resistance
├── docs/ # INSTALLATION.md, NGINX_PROXY.md, whitepaper.tex
├── Dockerfile # Multi-stage build (rust:1.94 → debian:trixie-slim)
└── .github/workflows/ # CI: build (Linux CLI, Android APK, Linux/Windows GUI), tests
cd backend
cp .env.example .env
nano .env # Set VPN_AUTH_TOKEN, VPN_PORT, etc.
docker compose up -d --buildTo force-refresh the forked Rust Git dependencies during deployment:
docker compose pull --ignore-buildable
docker compose build --pull --no-cache vpn-server
docker compose up -d --force-recreateRetrieve the certificate PIN for clients:
cat data/cert_pin.txtPort: The server listens on UDP (default
10443). Ensure your firewall allows this.
Automated (recommended):
# Run PowerShell as Administrator
python install_cli_windows.py # Installs CLI + Windows Service
python install_gui_windows.py # Installs Tauri GUI (optional)Usage:
mavi-vpn-client start # Connect (prompts for config on first run)
mavi-vpn-client stop # Disconnect
mavi-vpn-client status # Check connection statusAutomated (recommended):
python3 install_cli_linux.py # Installs CLI + optional systemd service
python3 install_gui_linux.py # Installs Tauri GUI (deb/rpm/AppImage)The CLI installer creates the mavivpn group and adds your desktop user so the GUI/CLI can control the root daemon after you log out and back in.
Usage:
sudo mavi-vpn # Interactive connect (direct mode)
sudo mavi-vpn daemon & # Start IPC daemon (for GUI)
mavi-vpn start # Connect via daemon
mavi-vpn stop # Disconnect
mavi-vpn status # Check VPN status- Install Rust targets +
cargo-ndk:cargo install cargo-ndk rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- Open the
android/folder in Android Studio - Build → Build APK — the Rust core compiles automatically via Gradle
cd gui
npm install
npm run tauri -- dev # Development
npm run tauri -- build # Production (generates MSI/DEB/RPM)Mavi VPN offers three escalating levels of traffic obfuscation:
| Level | Mode | Wire Format | Activate |
|---|---|---|---|
| 0 | Standard | Raw QUIC datagrams | Default |
| 1 | CR Mode | QUIC + ALPN h3 + probe resistance |
censorship_resistant: true |
| 2 | HTTP/3 Framing | Full MASQUE connect-ip (RFC 9484) capsules | http3_framing: true |
| + | ECH | SNI spoofing + HPKE GREASE (RFC 9180) | Provide ech_config hex |
When CR Mode is enabled, the server responds to unauthorized connections with a fabricated HTTP/3 nginx welcome page. This makes the server indistinguishable from a regular web server to active probes and DPI systems.
ECH is supported on clients via EchMode::Grease — the real SNI is hidden behind a cover domain (e.g. cloudflare-ech.com). The server generates and persists the ECH keypair in data/ech_config_hex.txt.
Set VPN_AUTH_TOKEN on the server. Clients send this token during the QUIC handshake.
Full enterprise SSO with Keycloak:
- Enable in server
.env:VPN_KEYCLOAK_ENABLED=true VPN_KEYCLOAK_URL=https://auth.example.com VPN_KEYCLOAK_REALM=mavi-vpn VPN_KEYCLOAK_CLIENT_ID=mavi-client
- Deploy Keycloak via the included
docker-compose:COMPOSE_FILE=docker-compose.yml:keycloak/docker-compose.yml COMPOSE_PROFILES=traefik,keycloak
- On first start, Keycloak auto-imports the
mavi-vpnrealm frombackend/keycloak/mavi-vpn-realm.json— including themavi-clientpublic PKCE client, thevpn-userrealm role, and token lifespans tuned for the VPN refresh cycle (10 min access token, 1 h SSO idle, 24 h SSO max). You only need to create your users in the Keycloak admin console; the realm and client setup is automated. Seedocs/INSTALLATION.mdStep 4 for details. - Clients authenticate via browser-based PKCE OAuth2 — the CLI/GUI opens a local HTTP server on port
18923, redirects to Keycloak, and captures the JWT automatically.
The server validates JWTs using Keycloak's JWKS endpoint with automatic key rotation and constant-time
azpcomparison.
| Setting | Value | Why |
|---|---|---|
| Inner TUN MTU | 1280 | IPv6 minimum — universally supported, avoids fragmentation |
| QUIC Payload | 1360 | Fits within 1460-MTU networks (e.g. Vodafone) without fragmentation |
| Congestion Control | BBR | Bandwidth-based, not loss-based — optimal for mobile/high-latency |
| UDP Socket Buffers | 4 MB | Prevents kernel drops during GSO bursts |
| Allocator | system default | Avoids an unused native allocator dependency in test and build paths |
| Release Profile | lto=true, codegen-units=1, strip=true |
Maximally optimized binary |
All server settings can be configured via environment variables or CLI flags:
| Variable | Default | Description |
|---|---|---|
VPN_BIND_ADDR |
0.0.0.0:4433 |
QUIC listen address |
VPN_AUTH_TOKEN |
(required) | Pre-shared authentication token |
VPN_NETWORK |
10.8.0.0/24 |
IPv4 client subnet (supports /8 to /30) |
VPN_NETWORK_V6 |
fd00::/64 |
IPv6 client subnet (ULA) |
VPN_DISABLE_IPV6 |
false |
Skip all IPv6 setup and run IPv4-only |
VPN_IPV6_WAIT |
30 |
Seconds to wait for the WAN's global IPv6 to appear before continuing |
VPN_DNS |
1.1.1.1 |
DNS server pushed to clients |
VPN_MTU |
1280 |
TUN interface MTU |
VPN_CENSORSHIP_RESISTANT |
false |
Enable Layer 7 obfuscation |
VPN_MSS_CLAMPING |
false |
TCP MSS rewriting via iptables mangle (MSS derived from VPN_MTU) |
VPN_ALLOW_CLIENT_TO_CLIENT |
false |
Allow VPN clients to reach each other (blocked by default) |
VPN_ECH_PUBLIC_NAME |
cloudflare-ech.com |
ECH cover SNI domain |
VPN_KEYCLOAK_ENABLED |
false |
Enable Keycloak JWT auth |
VPN_KEYCLOAK_URL |
— | Keycloak server URL (must be https://; plain HTTP only for localhost) |
VPN_KEYCLOAK_REALM |
mavi-vpn |
Keycloak realm name |
VPN_KEYCLOAK_CLIENT_ID |
mavi-client |
Keycloak OIDC client ID |
VPN_KEYCLOAK_REQUIRED_ROLE |
— | Optional fail-closed: accepted JWTs must carry this realm/client role |
VPN_KEYCLOAK_REQUIRED_SCOPE |
— | Optional fail-closed: accepted JWTs must carry this OAuth scope |
Token lifetimes: The auto-imported realm pre-configures Access Token Lifespan = 10 min, SSO Session Idle = 1 h, SSO Session Max = 24 h — matching the client's 300 s refresh skew to avoid mid-session disconnects. For existing deployments or to customize, see
docs/INSTALLATION.mdStep 4.
# Run the portable Rust core without Tauri/WebView or OS service deps
cargo test-core-workspace --verbose
# Run the Tauri Rust backend separately when WebView/Tauri deps are installed
cargo test-gui-backend --verbose
# Focused core checks
cargo test -p shared --verbose
cargo test -p mavi-vpn --verboseThe quic-tester/ tool simulates a DPI scanner to verify censorship resistance:
cargo run -p quic-tester -- <server:port>
# Expects HTTP/3 nginx response → confirms probe resistance is activeOn AWS Lightsail (and similar clouds) the instance receives its public IPv6 address and default route via Router Advertisements (RA) on the WAN interface (e.g. ens5), and the public address is typically a single /128. Mavi VPN does not hand that public prefix to clients — clients get internal ULA addresses from fd00::/64 and reach the internet through NAT66. For that to work:
- Forwarding must be enabled on the host. The VPN container is deliberately hardened (non-privileged,
cap_drop: ALL+NET_ADMIN), so its/proc/sysis read-only and it cannot set host sysctls itself. Enable forwarding on the host and persist it (seedocs/INSTALLATION.md):sudo sysctl -w net.ipv6.conf.all.forwarding=1
- Keep
accept_ra=2on the WAN while forwarding is on. Turning the host into a router makes Linux stop accepting RAs (which drops the IPv6 default route) unless the WAN interface usesaccept_ra=2:WAN=$(ip route get 8.8.8.8 | awk '{print $5; exit}') sudo sysctl -w "net.ipv6.conf.${WAN}.accept_ra=2"
If the host has public IPv6 but forwarding is not enabled, the container now fails loudly at startup (instead of pretending IPv6 works) and prints the exact host commands to run. To run IPv4-only on purpose, set VPN_DISABLE_IPV6=true.
If IPv6 still fails, check (replace <wan> with your interface, e.g. ens5):
cat /proc/sys/net/ipv6/conf/all/forwarding # expect: 1
cat /proc/sys/net/ipv6/conf/<wan>/accept_ra # expect: 2
ip -6 route show default # expect: default via fe80::… dev <wan> proto ra
ip6tables -t nat -S POSTROUTING # expect: -A POSTROUTING -s fd00::/64 -o <wan> -j MASQUERADE| Document | Description |
|---|---|
docs/INSTALLATION.md |
Comprehensive installation guide for all platforms |
docs/NGINX_PROXY.md |
Deploying behind an existing Nginx with wildcard SSL |
CODEWIKI.md |
Deep technical encyclopedia of the entire codebase |
docs/whitepaper.tex |
Academic whitepaper (LaTeX) |
- Socket Sharding —
SO_REUSEPORTfor multi-core UDP scaling - eBPF Data Plane — Kernel-level packet routing for zero-copy efficiency
- iOS Support — Rust core via C-FFI +
NEPacketTunnelProvider - Server-side ECH — Full ECH decryption when rustls adds support
