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 (Android)

From NetworkPage, hit "🛡 Start system VPN". The daemon brings up a TUN device through VpnService.Builder + gVisor netstack. Loopback + RFC1918 LAN ranges are excluded so your local network stays reachable.

4. 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.

4a. 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.

4b. 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.

4c. 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.

4d. 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.

4e. 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.

5. REALITY / SSH-banner masquerade (optional)

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.