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 — the key prints once, on stdout, so capture it before the command exits.
--expires-in takes days, hours or minutes (30d, 12h, 90m); there
is no month or year unit, so for a long-lived key give it in days —
--expires-in=730d is ~2 years. Use --never-expires for a key that
never expires. Re-print the share URL for an existing key anytime with
meshhold mgmt-keys share <id> [--qr].
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, Linux & Android)
From the Network page, start the system VPN. The daemon brings up a
single TUN device — VpnService.Builder + gVisor netstack on Android, the
meshhold-vpnhelper Wintun adapter on Windows, and a daemon-owned
/dev/net/tun on Linux — carrying the mesh overlay and the exit routing
together. Only what actually enters a tunnel is captured (the mesh ranges and
your exit rules); everything else stays on your normal network path. 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.
What's captured depends on your exits. A 0.0.0.0/0 exit rule captures
everything (a full tunnel); narrower rules capture only their prefixes; a
mesh-only session (no exit) captures only the mesh ranges. Anything not
captured — because no rule covers it — stays on your normal network path out
the physical router. For a captured flow 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. - Captured but matching no exit (and no exit's chain is reachable) → the
Local network (direct) tier: on, it egresses your physical router; off,
it's dropped (fail-closed). This same toggle governs what happens when a
matched exit's whole chain is down — fall through to the router, or drop.
This tier only bites when something is captured in the first place: with a
0.0.0.0/0full tunnel it's your kill-switch; with a mesh-only or narrow selective session there's nothing general to drop.
The legacy whole-device
default_route=exit/default_exitschain still loads fromconfig.yamlfor back-compat, but the UI models a full tunnel as a0.0.0.0/0exit rule and drivesdefault_route/fail_modevia 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"
# When a full-tunnel exit is down (Local-network tier): direct = fall through to
# the router, drop = fail-closed. (Unmatched non-tunnel traffic always egresses
# the router — it isn't captured.)
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 system-VPN client — a daemon-owned TUN with the
route-install layer that keeps excluded / selective prefixes off it — runs on
Windows (vpnhelper), Linux (/dev/net/tun), and Android
(VpnService). macOS isn't a client yet, but any of them (and macOS) 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. Sharing a port you already expose (optional)
A node normally wants its own inbound port. The REALITY and SSH transports remove that requirement: they put MeshHold in front of a server you already run, on the port that server already occupies.
- REALITY shares
443with a web server. Browsers reach the site; peers reach the mesh. - SSH shares
22with your sshd.sshstill works; peers reach the mesh.
Both work the same way. MeshHold owns the public port and routes each connection by what arrives on it — a valid auth frame gets the mesh, everything else is spliced to the real server behind it, byte for byte.
Two reasons to want this even on a node with ports to spare:
- Nothing new is exposed. No extra listener on the public interface, no extra firewall rule, no second port to keep an eye on.
- The mesh listener does not answer strangers. Authentication happens before the mesh service is reachable, so a scanner sees your website or your sshd and no sign a node is there. Shodan and Censys index it as nginx or OpenSSH. A flaw in the mesh handshake is not reachable pre-auth, which is the same idea as single-packet authorisation or WireGuard staying silent to unauthenticated packets.
It follows that the connection is shaped like ordinary HTTPS or SSH on the wire, which also keeps a node working on networks that pass only familiar protocols and drop the rest — mobile carriers, hotel and office Wi-Fi, some ISPs. Whether you may do that on a given network is between you and whoever runs it; see Acceptable Use.
What it is not: this gates strangers, not peers. Anyone holding the network key is a member, and the server behind the mask is exactly as exposed as it was before.
Enable in config.yaml:
node:
obfs:
reality:
enabled: true
port: 443
dest: "127.0.0.1:4443" # your front server, or any real TLS 1.3 site
sni: "example.com" # required when dest is a local address
ssh:
enabled: true
port: 22
dest: "127.0.0.1:2222" # your sshd, moved off 22
order: ["plain", "reality", "ssh"]
Check each backend before restarting the daemon:
meshhold check-reality-dest --sni example.com 127.0.0.1:4443
meshhold check-ssh-dest 127.0.0.1:2222
Both dest values are optional in the sense that the transports run
without them — but then there is no real server behind the port, and a
prober that pushes past the first exchange finds nothing there. Point
them at something real.
Full setup for each, including moving the existing server off the port: Custom obfs-https front site and Run MeshHold in front of your sshd.
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.