Scenario

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:

  1. mesh-LAN range → stays on the overlay.
  2. 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).
  3. 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/0 rules at different priorities form a hot-standby pair (traffic rides the higher one until it drops, then the lower); a specific /32 ranked above a broad /8 carves out an exception. The Default egress (priority order) table on the Network page shows this evaluation order live, each exit badged active or down.
  4. 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_exits chain still loads from config.yaml for back-compat, but the UI now models a full tunnel as a 0.0.0.0/0 exit rule and drives default_route/fail_mode together 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):

  1. The peer mints a tunnel-capable mgmt key and shares its QR / string with you.
  2. 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.

  1. Open the web UI on the node that owns the local end of the forward (i.e. the side you're issuing commands from).
  2. 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.
  3. Click Add forward. The dialog asks for:
    • Name — free-form label.
    • Direction — two buttons, Forward (-L) / Reverse (-R).
    • ProtocolTCP / 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.
  4. Submit. The forward is persisted to config.yaml's port_forwards list 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 of port_forwards[]. Full surface: REST API → port forwards.
  • config.yaml — declarative port_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 forwards family 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.