The Private Mesh VPN
Tunnel your phone through a trusted exit.
Tunnel your phone through a trusted exit. The same encrypted transport that carries your files routes arbitrary TCP — and UDP — traffic.
Concepts
- Entry: where you start the tunnel (your phone, your laptop).
- Middle hops (optional): relay nodes that can't decrypt anything.
- Exit: the node whose IP you appear to be using.
ChaCha20-Poly1305 AEAD runs end-to-end between entry and exit; yamux multiplexes many TCP sub-streams inside one chain.
1. Issue a tunnel-capable mgmt key on the exit node
meshhold mgmt-keys add --name="Phone" --caps=tunnel --expires-in=30d
Copy the resulting QR / string.
2. Pair the phone
Scan the QR. The phone stores the key locally and uses it when initiating tunnels.
3. Pick a transport mode
HTTP CONNECT proxy
The daemon exposes a local proxy on 127.0.0.1:<random>. Configure your
browser to use it.
System VPN (Windows & Android)
From the Network page, start the system VPN. The daemon brings up a
TUN device — VpnService.Builder + gVisor netstack on Android, the
meshhold-vpnhelper Wintun adapter on Windows — and routes traffic through
the chosen exit. What stays off the tunnel (your LAN, loopback, custom
exceptions) is configurable per node — see Exit routing next.
4. Exit routing — split-tunnel & multi-exit
An exit is a rule: it routes a destination range through a chosen exit
node. A rule covering 0.0.0.0/0 is a full tunnel — everything egresses
through that node; a narrower range (say 203.0.113.0/24) sends only those
destinations there and leaves the rest local. Manage exits from the Exit
nodes section on the Network page (each node has an Exits list), the
meshhold vpn … CLI, the vpn: config
block, or the
/api/v1/vpn/* endpoints. Enabling
the first exit brings the system VPN up; disabling the last one takes it down
— there's one session and the daemon reconciles the live exit set as you
toggle.
Per-flow decision. For each destination the daemon picks, in order:
- mesh-LAN range → stays on the overlay.
- Kept-local (exclude) set → dialed straight out the host, so LAN hosts — and RDP into this box — keep working under a full tunnel. The set is system (loopback, link-local, the mesh range — always on), auto (the host's own directly-connected LAN subnets, detected and kept local by default, each toggleable), and custom (your own CIDRs).
- Exit rules → the destination is matched against every enabled exit
rule. The daemon tries the matching exits highest-priority first and
fails over down the chain to the next live one. So two
0.0.0.0/0rules at different priorities form a hot-standby pair (traffic rides the higher one until it drops, then the lower); a specific/32ranked above a broad/8carves out an exception. The Default egress (priority order) table on the Network page shows this evaluation order live, each exit badged active or down. - No matching exit → the Local network (direct) tier: on, the packet egresses locally through your physical router; off, it's dropped (fail-closed). This one toggle also governs what happens when a matched exit's whole chain is down — fall through to the router, or drop.
The legacy whole-device
default_route=exit/default_exitschain still loads fromconfig.yamlfor back-compat, but the UI now models a full tunnel as a0.0.0.0/0exit rule and drivesdefault_route/fail_modetogether via the Local-network toggle.
Auto-start. Set vpn.enabled (or the panel's "Start on boot" toggle)
and the node brings the exit-VPN up itself at boot — deploy-and-forget on a
Windows box. The tunnel auto-reconnects if the exit reboots or the path
changes.
CLI — headless control
# Keep a LAN range local (off the exit)
meshhold vpn routes add 192.168.50.0/24 --note "lab subnet"
# Full tunnel through a node (0.0.0.0/0 = everything)
meshhold vpn policy add 0.0.0.0/0 \
--exit 12D3KooW…FRA --key fra-tunnel --priority 10 --name "FRA"
# Hot standby: a second full-tunnel exit at a lower priority — traffic rides
# FRA until it drops, then fails over to AMS
meshhold vpn policy add 0.0.0.0/0 \
--exit 12D3KooW…AMS --key ams-tunnel --priority 5 --name "AMS"
# Per-range: send only 203.0.113.0/24 out a specific exit
meshhold vpn policy add 203.0.113.0/24 \
--exit 12D3KooW…HOME --key home-tunnel --priority 20 --name "home subnet"
# Where unmatched traffic goes (Local-network tier): direct = router, drop = block
meshhold vpn config set --default-route direct --fail-mode direct
meshhold vpn config show # default route, fail mode, toggles
meshhold vpn policy list # exits (prefixes, priority, state)
meshhold vpn routes list # kept-local routes (system + auto + custom)
Full flags: meshhold vpn.
Platform note. The route-install layer (keeping excluded / selective prefixes off the TUN at the OS level) runs on Windows (vpnhelper) and Android (VpnService). The Linux exit-VPN client is fd-only and has no daemon-owned TUN yet, so it isn't a split-tunnel client — but a Linux node is still a perfectly good exit.
5. Forward / reverse TCP & UDP tunnels
The same tunnel primitive powers SSH-style -L / -R port-forwarding —
on top of TCP and UDP. Useful for game servers, DNS, VoIP, OpenVPN,
custom binary protocols, anything most overlay tools would skip.
Two directions, mirroring ssh:
| Direction | Who binds the listener? | Who dials the destination? | Mental model |
|---|---|---|---|
forward (-L) |
this node (local) | the peer | "Bring a remote LAN service to my localhost." Like ssh -L 1194:10.0.0.5:1194 peer. |
reverse (-R) |
the peer | this node (local) | "Publish my local service through a public peer." Like ssh -R 16261:192.168.1.50:16261 peer or an ngrok-style exit. |
Both directions ride the same encrypted tunnel transport, so they inherit multi-hop routing, libp2p Circuit Relay v2 fallback, and the REALITY / SSH masquerade transports if you've enabled them.
Prerequisites
You need the same pieces as a regular tunnel (steps 1–2 above):
- The peer mints a
tunnel-capable mgmt key and shares its QR / string with you. - You scan it on this node, so its peer mgmt key store has an authorised credential for the counter-party.
The forward refuses to come up without an authorised peer key, even if the swarm-PSK is correct.
5a. CLI — four canonical recipes
The CLI shape is the same in every case; only the flags change.
Forward TCP — reach a peer's internal web service on your localhost:
meshhold forwards add \
--name peer-grafana --forward --proto tcp \
--peer-node 12D3KooW…HOME --peer-key home-tunnel \
--listen 127.0.0.1:3000 --remote 192.168.1.10:3000
Forward UDP — dial a peer's OpenVPN over UDP through this node:
meshhold forwards add \
--name peer-openvpn --forward --proto udp \
--peer-node 12D3KooW…HOME --peer-key home-tunnel \
--listen 127.0.0.1:1194 --remote 10.0.0.5:1194
Reverse TCP — expose a LAN SSH server through a public VPS peer:
meshhold forwards add \
--name lan-ssh-via-vps --reverse --proto tcp \
--peer-node 12D3KooW…VPS --peer-key vps-tunnel \
--listen :2222 --remote 192.168.1.50:22
Reverse UDP — publish a Project Zomboid server through a public VPS:
meshhold forwards add \
--name pz-via-vps --reverse --proto udp \
--peer-node 12D3KooW…VPS --peer-key vps-tunnel \
--listen :16261 --remote 192.168.1.50:16261
--listen is parsed as a Go bind string: :PORT becomes 127.0.0.1:PORT
for forward and 0.0.0.0:PORT for reverse; pass an explicit host
when you want something else.
5b. Lifecycle
meshhold forwards list # table + live phase / counters
meshhold forwards stop pf-pz-via-vps # pause without losing config
meshhold forwards start pf-pz-via-vps # bring it back
meshhold forwards rm pf-pz-via-vps # forget completely
The default id is pf-<slug-of-name> — override with --id on add
if you want something stable. --no-autostart registers an entry that
stays dormant until you explicitly start it.
Full flag reference: see meshhold forwards.
5c. Opening the router port (optional)
Tack on --open-via-upnp on add, or flip the Router toggle in
the web UI, to ask the listener-side router to map the port for you:
- on a
forward, that's this node's router; - on a
reverse, that's the peer's router.
This is a best-effort UPnP IGD call. If the router answers, the
external host:port shows up in meshhold forwards list (and in the
"Public" line on the panel card); if it refuses or there's no IGD,
the listener still works LAN-only and no error is raised.
5d. Web UI — Forwards panel
Everything above is also available without touching the CLI.
- Open the web UI on the node that owns the local end of the forward (i.e. the side you're issuing commands from).
- Go to Settings → Forwards. The panel lists every registered
forward with its phase (
active/error/stopped), protocol chip (TCP/UDP), direction chip (Forward (ssh -L)/Reverse (ssh -R)), peer, listen/remote pair, and live byte counters. - Click Add forward. The dialog asks for:
- Name — free-form label.
- Direction — two buttons, Forward (-L) / Reverse (-R).
- Protocol — TCP / UDP.
- Listen address — label changes per direction (Local listen address on forward, Peer listen address on reverse) so it's clear which side actually binds.
- Remote dial address — label flips the same way.
- Peer node ID — paste the counter-party's
12D3KooW…ID. - Peer mgmt key — dropdown populated from your imported peer keys; if it's empty, pair with the peer's "Tunnel" key from Profile → Mgmt keys first.
- Router — checkbox toggling the UPnP request described above.
- Submit. The forward is persisted to
config.yaml'sport_forwardslist and (unless you cleared autostart) goes live immediately.
Each card has inline Start / Stop / Delete controls and a confirm
prompt on delete. The whole panel is just a thin client over
/api/v1/forwards,
so anything you can do in the UI you can also script with curl.
5e. Other surfaces
Same primitive, three entry points — pick whichever fits the workflow:
- REST —
/api/v1/forwards(GET/POST/PATCH/DELETE+start/stop). Body matches a single entry ofport_forwards[]. Full surface: REST API → port forwards. config.yaml— declarativeport_forwards: [...]list, useful on headless/VPS nodes where you don't want to run the web UI to bootstrap a forward. Schema: config reference →port_forwards.- CLI — the
meshhold forwardsfamily above, equivalent to the REST calls.
6. REALITY / SSH-banner masquerade (optional)
Some networks — mobile carriers, hotel or office Wi-Fi, some ISPs — only pass traffic that matches a familiar protocol and drop the rest. The masquerade transports make a MeshHold connection look like traffic those networks already allow, so the mesh keeps working:
- REALITY makes the link indistinguishable from a normal HTTPS/TLS
session to a real website (
dest, e.g.www.microsoft.com:443). Reach for it when a network only lets HTTPS through. - SSH-banner presents an OpenSSH server banner, so the link looks like an ordinary SSH session. Reach for it when plain and UDP transports are blocked but SSH is allowed.
Enable in config.yaml:
node:
obfs:
reality:
enabled: true
port: 8443
dest: "www.microsoft.com:443"
private_key_file: "/etc/meshhold/reality.key"
order: ["plain", "reality", "ssh"]
meshhold keygen-reality generates the X25519 keypair.
7. Quality of Service (QoS)
A bulk transfer — a torrent, a backup, a game-server upload — will happily eat the whole uplink and starve the interactive flows sharing it: DNS lookups stall, a VoIP call breaks up, a page won't load. Router-grade QoS built into the tunnel fixes that: it classifies traffic and shapes it so interactive flows keep their slice of the link.
It runs in both directions:
- Egress (uplink) is shaped on this node before traffic enters the tunnel.
- Download (downlink) is shaped at the exit — your node sends the policy across when the session comes up, and the exit paces the incoming direction to the rate you set.
Priority only bites once you set your line rate. The shaper has to be the bottleneck, not your ISP — exactly like a home router's QoS does nothing until you tell it your line speed. Set the uplink/downlink to ~90–95% of your real line; with no rate set, QoS still orders flows but can't hold back a saturating transfer. Rates are in kilobits per second (spelled out so it's never read as kilobytes): a 100-megabit line = 100000.
Edit it from the QoS item in an exit node's menu on the Network page, or headlessly:
meshhold vpn qos show # policy + live telemetry
meshhold vpn qos preset # list built-in presets
meshhold vpn qos preset gaming # apply one (keeps your rates)
meshhold vpn qos set --enabled --uplink-kbits 18000 --downlink-kbits 95000
meshhold vpn qos set --from-file mypolicy.json # full classes + rules
A class is a priority level (highest first), optionally capped by a ceiling
(a percentage of the link or an absolute rate) and able to demote a flow to
a lower class after it has moved a configured number of kilobytes — so a long
download drops out of the fast lane on its own. A rule maps traffic
(protocol, destination port range, destination CIDR, and direction —
out / in / both) to a class; the first matching rule wins. The full
schema is the vpn.qos config block.