iOS and Android VPN clients plus a Linux server stack for a multi-hop SSH overlay: traffic is tunneled as raw IP packets over SSH, routed through a chain of nodes, and NAT’d at the exit. One app, one server daemon (hopperd), no legacy relays.
| Platform | Status |
|---|---|
| iOS (TestFlight) | Join the beta — install TestFlight first, then open the link on your iPhone or iPad |
| iOS / Android (developer-signed) | Available in GitHub Releases — install the IPA or APK from the latest release |
| Apple App Store | Pending |
| Google Play | Pending |
Store listings are in progress. For iOS, TestFlight is the easiest option. Alternatively, use the signed builds from Releases (you may need to trust the developer certificate on iOS or allow installs from unknown sources on Android).
Each hop in your chain runs hopperd on a Linux VPS. You need one VPS per hop (a single VPS works for a one-hop chain where entry = exit).
For the simplest setup, rent the cheapest Ubuntu VPS you can find (any provider — Hetzner, DigitalOcean, Vultr, etc.). Use a fresh machine with:
- User:
root - Auth: root password (SSH password login enabled)
The server must have TUN support (/dev/net/tun), python3, ip, and iptables (for exit NAT). Ubuntu images from major providers include these by default.
You do not need a Mac or deploy.sh to get started. The app installs and configures the server for you:
- Open ɹǝddoH → Configure chains → Server library.
- Tap Deploy.
- Enter the VPS host/IP, user (
root), SSH port (22), and root password. - Wait for the deploy log to finish — the server is added to your library automatically (no QR scan needed).
The app uploads hopperd, runs configure_server.sh, generates a deploy key, and saves the server profile locally. Repeat for each hop.
Alternative (developers): deploy from your Mac with ./deploy.sh — see Deploy from the command line.
You can also add servers via Scan QR or Import JSON if you used the CLI deploy flow.
flowchart LR
subgraph clients [Mobile clients]
App[iOS / Android app]
Tunnel[Packet tunnel]
App -->|provision chain| Tunnel
Tunnel -->|SSH + iptunnel| Entry
end
subgraph chain [Server chain entry to exit]
Entry[hopperd entry]
Relay[hopperd relay]
Exit[hopperd exit]
Entry -->|SSH pipe| Relay
Relay -->|SSH pipe| Exit
end
Tunnel --> Entry
Exit -->|TUN + NAT| Internet[(Internet)]
| Layer | Role |
|---|---|
| iOS | L3 VPN (NEPacketTunnelProvider). All IPv4 default traffic → leased overlay client address. |
| Android | L3 VPN (VpnService). Same overlay address and iptunnel data plane as iOS. |
| iptunnel | Framed IP over a byte stream (SSH direct-tcpip to 127.0.0.1:7400). |
| hopperd | Userspace routing between ingress (client), next (downstream hop), and TUN (internet on exit). |
| SSH | App → entry hop; each hop → next hop via local ~/.hopper/id_ed25519. |
- Provision (exit → entry) — The app SSH-execs
start_server.shon each hop, last to first: trust upstream keys, writehopper.json, starthopperd. - VPN (entry only) — Extension SSH-connects to the first hop in the chain, opens iptunnel to local
hopperd, and carries packets.
Chain order in the app: first = entry, last = exit.
Hopper supports multiple chains and multiple clients per chain on the same servers.
Multi-chain — Each chain has its own UUID. That ID derives a dedicated overlay subnet (10.64.{octet}.0/24), local hopperd listen port (7400 + octet), and TUN interface (hopper_*) on every hop. State lives under ~/.hopper/chains/{chain_id}/; active chains are tracked in ~/.hopper/registry.json. The same VPS can serve several chains at once (as entry, relay, or exit in different chains).
Multi-device — Several phones or tablets can connect to the same chain at the same time. Each app install gets a stable device ID; the entry hop assigns a unique client address from that chain's pool (10.64.{octet}.2–.254) via lease. Leases renew while connected and expire after idle timeout (default 1 hour).
TUN limits — Each active chain uses one TUN interface per hop where hopperd runs. How many chains you can run in parallel on a server depends on how many TUN devices the host allows (typically many on a stock Linux VPS, but the limit varies by kernel and provider).
Each chain gets its own /24 subnet: 10.64.{octet}.0/24, where {octet} is derived from the chain UUID (1–254).
| Address | Use |
|---|---|
10.64.{octet}.2–.254 |
Mobile clients (leased per device) |
10.64.{octet}.10 + index |
Hop i in chain (entry = .10) |
0.0.0.0/0 |
Relay → next; exit → TUN + NAT |
- Linux with TUN (
/dev/net/tun) python3,ip,iptables(exit NAT)- Root or
cap_net_adminonhopperd(set byconfigure_server.shwhen run as root) - SSH access for deploy and for inter-hop / client connections
- Xcode 16+, iOS 17+
- Apple Developer account with Network Extension (Packet Tunnel) entitlement
- App Group:
group.com.aengix.hopper
- Android Studio with SDK 35 (API 26+ devices)
- JDK 17 (bundled with Android Studio on macOS)
- Release signing:
~/googlePlayKeys.jks+app-android/keystore.properties(see Android build & run)
- Go 1.22+ (build server binaries)
- SSH key to target servers (
~/.ssh/id_rsaby default)
- Get the app and set up one or more servers.
- New chain → name it → Add server… in order entry → exit (pick servers from the library).
- Swipe Use (iOS) or tap Use (Android), or pick the chain on the home screen → Connect.
./remove.sh YOUR_SERVER_IPStops hopperd, removes ~/hopper, ~/.hopper, TUN hopper0, hopper NAT rules, and hopper lines in authorized_keys.
From your Mac:
cd server
./deploy.sh YOUR_SERVER_IPThis will:
- Build
dist/hopperd-linux-{amd64,arm64} - Upload bundle to
~/hopperon the server - Run
configure_server.sh --json-onlyand open a local QR page in the browser (deleted after 5 seconds)
Options (same as remove.sh):
| Flag | Default | Meaning |
|---|---|---|
-u |
root |
SSH user |
-p |
22 |
SSH port |
-i |
~/.ssh/id_rsa |
SSH private key |
-P |
~/hopper |
Remote install path |
-y |
— | Skip confirmation |
--no-build |
— | Skip build_dist.sh |
Environment: DEPLOY_HOST, DEPLOY_USER, DEPLOY_PORT, DEPLOY_KEY, DEPLOY_PATH.
~/hopper/
configure_server.sh # one-time / re-run: keys + JSON profile
start_server.sh # app-invoked: config + start hopperd
hopper_common.sh
dist/
hopperd-linux-amd64
hopperd-linux-arm64
~/.hopper/
id_ed25519 # inter-hop + hopperd SSH identity
hopper.json # runtime config
hopper.log
| Script | Who runs it | Purpose |
|---|---|---|
configure_server.sh |
Admin / deploy.sh |
Generate host keypair, authorized_keys, optional setcap, emit QR JSON |
start_server.sh |
Mobile app via SSH exec | --stop-only, --trust-pubkey, write config, start hopperd, print ready JSON |
deploy.sh |
Developer | Build, upload, configure, browser QR |
remove.sh |
Developer | Uninstall |
build_dist.sh |
Developer | Cross-compile hopperd |
./configure_server.sh # interactive on server
./configure_server.sh --json-only --host 1.2.3.4 --port 22./start_server.sh --role exit --addr 10.64.0.12 --index 2 \
--overlay 10.64.0.0/24 --client-addr 10.64.0.2
./start_server.sh --role relay --addr 10.64.0.11 --index 1 \
--client-addr 10.64.0.2 \
--next-host hop2.example.com --next-port 22 --next-user rootStdout: one JSON line, e.g. {"ready":true,"mode":"exit","addr":"10.64.0.12",...}.
~/.hopper/hopper.json # default config path
./dist/hopperd-linux-amd64 -verbose --config ~/.hopper/hopper.json --ready-file ~/.hopper/hopper-readyListens on 127.0.0.1:7400 only (reached via SSH forwarding).
Example config: server/hopper.example.json.
cd server
./build_dist.shBinaries land in server/dist/ (gitignored).
| Screen | Purpose |
|---|---|
| Home | Select chain, connect/disconnect, route preview |
| Configure chains | Create/delete chains, open server library |
| Chain detail | Name, reorder hops, add/remove servers |
| Server library | Deploy new servers, scan QR, import JSON, delete saved servers |
Profiles persist in the App Group (hopper-profiles.json).
Scan QR, Import JSON, and ./deploy.sh all use the same v2 wire format (HopProfileCodec on iOS/Android, server/hopper/node_profile.py on the server). The app stores servers in a library; chains reference server IDs in order.
Example (as emitted by configure_server.sh --json-only):
{
"v": 2,
"name": "vps.example.com",
"host": "203.0.113.10",
"port": "22",
"user": "root",
"private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
"install_dir": "~/hopper",
"server_version": "2.0.0",
"min_app_version": "2.0.0",
"host_key": ["ssh-ed25519 AAAA..."]
}| Field | Required | Notes |
|---|---|---|
v |
No | Must be 2 when present; other values are rejected |
host |
Yes | SSH hostname or IP. Alias: server |
user |
Yes | SSH user. Alias: username |
private_key |
Yes | OpenSSH private key for this hop (~/.hopper/id_ed25519 from configure). Alias: privateKey |
port |
No | SSH port as a string; default 22 |
name |
No | Display label; defaults to host or Untitled. Aliases: title, remarks |
install_dir |
No | Remote bundle path; default ~/hopper. Aliases: installDir, hopper_dir |
server_version |
No | Server bundle version from VERSION.json; export uses unknown if empty. Alias: serverVersion |
min_app_version |
No | Minimum client version required by the server; export uses unknown if empty. Alias: minAppVersion |
host_key |
No | SSH host key pin(s) for first connect — string or array. Alias: hostKeys |
Treat exported JSON and QR codes as secrets (they contain the hop private key).
cd app
open Hopper.xcodeproj- Target Hopper (app) + HopperExtension (packet tunnel)
- Citadel (vendored SSH) is linked to both app (provision) and extension (data plane)
- Signing: set your
DEVELOPMENT_TEAM, enable Network Extension + App Groups
Regenerate Xcode project (optional):
ruby app/Scripts/generate_xcodeproj.rbapp/
Hopper/ SwiftUI app, VPNController, ChainProvisioner
HopperExtension/ PacketTunnelProvider
TunnelCore/ SSHHopConnector, IPTunnelEngine, HopSSH
Shared/ Models, HopConstants, ProfileStore
Vendor/Citadel/ SSH client library
app-android/
app/ Jetpack Compose UI, VpnController, HopperVpnService
build-apk.sh Signed release APK (bumps versionCode)
build-aab.sh Signed Play Store bundle (bumps versionCode)
generate-keystore.sh Create hopper-upload signing key
server/
cmd/hopperd/ Daemon entrypoint
internal/hopper/ Config, session routing, NAT, SSH next-hop
internal/iptunnel/ Frame protocol + Linux TUN
Same screens and flow as iOS: home (chain + connect), chain configurator, chain detail, server library (Deploy, QR scan + JSON import).
Profiles persist in app-private storage (hopper-profiles.json). Server profile JSON format is identical to iOS — see Server profile JSON (v2).
Open the project in Android Studio:
cd app-android
open -a "Android Studio" .Or build a debug APK from the command line:
cd app-android
./gradlew assembleDebugOutput: app/build/outputs/apk/debug/app-debug.apk
Install on a connected device:
adb install app/build/outputs/apk/debug/app-debug.apkOn first Connect, Android prompts for VPN permission — required for the tunnel.
Release builds use the same keystore layout as other AENGIX Android apps. The keystore lives outside the repo:
~/googlePlayKeys.jks
Create app-android/keystore.properties (gitignored) before running the release scripts:
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=hopper-uploadHopper uses alias hopper-upload in the shared AENGIX keystore (upload is reserved for the TV browser app).
To generate a new Hopper signing key (adds to existing keystore or creates a fresh one):
cd app-android
./generate-keystore.shOr manually:
keytool -genkeypair -v \
-keystore ~/googlePlayKeys.jks \
-alias hopper-upload \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass YOUR_STORE_PASSWORD \
-keypass YOUR_KEY_PASSWORD \
-dname "CN=Hopper, OU=Mobile, O=AENGIX SL, L=Barcelona, ST=Barcelona, C=ES"Both scripts auto-detect the Android Studio JDK, validate signing credentials, bump versionCode by 1, and produce a signed artifact:
cd app-android
./build-apk.sh # app/build/outputs/apk/release/app-release.apk
./build-aab.sh # app/build/outputs/bundle/release/app-release.aabFor manual Gradle release builds (without auto bump):
cd app-android
./gradlew assembleRelease # APK
./gradlew bundleRelease # AABapp-android/
app/src/main/java/com/aengix/hopper/
ui/ Compose screens
vpn/ VpnController, HopperVpnService, TunnelCoordinator
ssh/ HopSSH, SSHHopConnector (SSHJ)
tunnel/ IPTunnelFrame, IPTunnelEngine
provision/ ChainProvisioner
model/ AppState, HopNodeProfile, HopChain
data/ ProfileStore, HopQRParser
| Symptom | Things to check |
|---|---|
| VPN connects, no internet | Exit NAT: iptables -t nat -L; hopper.log on exit; re-connect to re-provision |
| Chain provision fails | SSH from app to each hop; start_server.sh on server; keys in authorized_keys |
hopperd won’t start |
Root/setcap cap_net_admin; read ~/.hopper/hopper.log |
| Extension / VPN errors | iOS: App Group + embedded extension; reinstall VPN profile. Android: revoke/re-grant VPN permission; check logcat Hopper |
Logs
- Server:
~/.hopper/hopper.log - iOS: Xcode → Window → Devices → open console for device, filter
Hopper - Android:
adb logcat -s Hopper
Manual stop on server
cd ~/hopper && ./start_server.sh --stop-only- QR and deploy HTML contain private keys — treat as secrets; deploy deletes local HTML after 5s.
- Each hop has its own
~/.hopper/id_ed25519; provision adds upstream pubkeys to downstreamauthorized_keys. hopperdbinds to loopback; only SSH-forwarded clients reach iptunnel.- Review
authorized_keysafterremove.shif you added keys manually.
See repository for license terms. Citadel is vendored under its own license in app/Vendor/Citadel/.












