Wireguard Mesh
  • Shell 52.6%
  • Go 47.3%
  • Dockerfile 0.1%
Find a file
john enlow ec860ad017
All checks were successful
Publish wm scripts / publish (push) Successful in 18s
Publish wm-cli / publish (push) Successful in -16s
Harden nftables DNAT persistence and verify rule loaded
2026-05-11 13:15:26 +12:00
.gitea/workflows Simplify CI workflows and add publish script for wm-cli 2026-04-21 21:40:17 +12:00
cli Add device-setup command to reprint install command for existing devices 2026-05-11 11:34:19 +12:00
setup Clarify host-setup gateway arg and de-hardcode mesh supernet 2026-04-30 18:05:01 +12:00
wm-gateway Harden nftables DNAT persistence and verify rule loaded 2026-05-11 13:15:26 +12:00
wm-registry Add device-setup command to reprint install command for existing devices 2026-05-11 11:34:19 +12:00
CLAUDE.md Update project description to site-to-site-to-site mesh 2026-04-27 14:14:31 +12:00
README.md Clarify host-setup gateway arg and de-hardcode mesh supernet 2026-04-30 18:05:01 +12:00

wm

WireGuard site-to-site mesh VPN with a central registry, auto-configuring gateways, and one-liner device setup. Recommend using on top of magicssh.

Overview

              +----------------------+
              |   Hub (Vultr/VPS)    |
              |  registry + gateway  |
              +--+------+------+----+
                 |      |      |
          +------+      |      +-------+
          v             v              v
     site gateways   roaming        isolated
     (LXC each)    (full access)  (can't initiate)
  • Registry: Central API that stores all sites, hosts, and devices.
  • Gateways: One per site, in an LXC container. Poll the registry every 5s and auto-configure WireGuard, NAT, firewall, and DNS.
  • Hosts: Machines at a site. Don't run WireGuard — they route through their gateway
  • Devices: Roaming (full access) or isolated (can be reached but can't initiate). Peer with the hub gateway

Overlay network: configurable via MESH_SUPERNET in the registry (default 172.30.0.0/16). Each site gets a /24 within it. Gateways are always .1.

Multi-network: A single host can participate in multiple independent mesh networks (e.g. work and personal). Each network has its own registry, and all resources are namespaced by a network name derived from the registry domain.

Getting Started

1. Deploy the registry

The registry is a Docker service deployed with Dropshell on an internet-facing machine (e.g. a VPS).

# On your management machine
ds create-service YOUR_VPS wm-registry

# Edit the config
ds edit YOUR_VPS wm-registry

Set these values in service.env:

WM_REGISTRY_PORT=8099
WM_REGISTRY_TOKEN="generate-a-strong-random-token"
WM_MESH_SUPERNET=172.30.0.0/16             # overlay network range (each site gets a /24)
WM_MESH_NAME="home"                         # human-friendly name (returned in /config as mesh_name)
CF_DOMAIN="wm-registry.example.com|8099"   # Cloudflare tunnel — public URL derived automatically

The registry binds to localhost only — access is via Cloudflare tunnel (HTTPS). Set up a Cloudflare tunnel pointing to localhost:8099.

ds install YOUR_VPS wm-registry

Verify:

curl https://wm-registry.example.com/health
# {"status":"ok"}

2. Install the CLI

getpkg install wm-cli

Configure it (option A — connect to a registry):

wm mesh add https://wm-registry.example.com your-master-token

This fetches the mesh config, derives a slug from the supernet (e.g. 172-30), and saves the env file to ~/.config/wm/<slug>.env. Repeat for additional meshes.

Or manually:

mkdir -p ~/.config/wm
cat > ~/.config/wm/mymesh.env <<'EOF'
REGISTRY_URL=https://wm-registry.example.com
REGISTRY_TOKEN=your-master-token
EOF

Verify:

wm health

3. Set up the hub gateway

The hub is a site gateway on the same machine as the registry (or any internet-facing machine). It relays traffic for roaming and isolated devices. Deployed as a dropshell service:

ds create-service YOUR_VPS wm-gw-wg wm-gateway
ds edit YOUR_VPS wm-gw-wg

Set these values in service.env:

SITE_DOMAIN=wg
SITE_SUBNET=172.30.1.0/24
IS_HUB=true
WM_REGISTRY_URL=https://wm-registry.example.com:8099
WM_REGISTRY_TOKEN=your-master-token

# Leave blank for hub — auto-detected from lxc-net
LXC_IFACES=
LXC_GATEWAY=

# Public IP — leave blank to auto-detect (tries ifconfig.me, ipify, etc.).
# Set explicitly if auto-detection is unreliable or returns the wrong IP.
PUBLIC_IP=

For the hub, LXC networking is auto-detected from /etc/default/lxc-net. The script creates an LXC on the default bridge, sets up nftables port forwarding and NAT automatically. The nftables rules are stored in /etc/nftables.d/wm-gateway.nft and survive Docker restarts (unlike iptables rules which Docker can overwrite).

ds install YOUR_VPS wm-gw-wg

The install creates the LXC container, provisions WireGuard/dnsmasq/nftables inside it, generates a keypair, registers the site with the registry, and auto-registers the host server as a mesh host. Idempotent and non-disruptive — re-running ds install on a running gateway updates scripts and config without dropping connections. All gateways can be updated in parallel.

4. Set up spoke site gateways

Same template on each site's host (Proxmox or standalone LXC):

ds create-service MYHOST wm-gw-sheen wm-gateway
ds edit MYHOST wm-gw-sheen
SITE_DOMAIN=sheen
SITE_SUBNET=172.30.3.0/24
IS_HUB=false
WM_REGISTRY_URL=https://wm-registry.example.com:8099
WM_REGISTRY_TOKEN=your-master-token

# Spoke: just the static IP for the new gateway LXC
LXC_IFACES=192.168.1.250

Bridge, netmask, and gateway are auto-detected from the host. For multi-LAN sites, comma-separate:

LXC_IFACES=192.168.77.19,192.168.1.250

Manual override is available if auto-detection can't work (e.g. host has no IP on the target subnet):

LXC_IFACES=vmbr0:192.168.1.250/24
LXC_GATEWAY=192.168.1.1
ds install MYHOST wm-gw-sheen

Then port forward UDP 51820 on the site's router to the gateway LXC IP.

5. Gateway lifecycle

ds install SERVER SERVICE    # create/update LXC, register, start
ds start SERVER SERVICE      # start LXC + timer
ds stop SERVER SERVICE       # stop timer + LXC
ds status SERVER SERVICE     # check LXC + WireGuard state
ds logs SERVER SERVICE       # view gateway sync logs
ds uninstall SERVER SERVICE  # stop + remove host nftables rules (preserves LXC)
ds destroy SERVER SERVICE    # deregister site + destroy LXC

The host server is auto-registered as a mesh host during install (using the detected gateway as its local IP).

6. Connect machines to the mesh

Machines at a site need two things to use the mesh: routing (so mesh traffic goes through the gateway) and DNS (so mesh names resolve via the gateway's dnsmasq). There are three ways to set this up, from easiest to most manual.

The easiest approach — configure once on the site router and every machine on the LAN gets mesh access automatically. No per-machine setup.

wm openwrt-setup 192.168.1.250 | ssh root@router 'sh -s'

This automates the setup needed on the router:

  1. Static route: MESH_SUPERNET (e.g. 172.30.0.0/16) via gateway IP (so LAN machines can reach the mesh)
  2. Remove conflicting local=: If the router's domain matches a mesh domain (e.g. home), dnsmasq's local=/home/ directive blocks forwarding — the script detects and removes it
  3. Split DNS: Forward mesh domains (.sheen, .osrc, .hub, etc.) to the gateway's dnsmasq, with rebind protection whitelist and negative caching disabled

The script is idempotent — re-running it skips existing entries and only restarts services when config actually changes.

After this, every machine on the LAN can resolve mesh names and reach mesh hosts with zero per-machine config. Only requirement: machines must be registered with wm add-host (unregistered machines are blocked by the gateway firewall).

Manual OpenWrt setup (if not using the script):

# 1. Port forward: UDP 51820 -> gateway
uci add firewall redirect
uci set firewall.@redirect[-1].name='wm'
uci set firewall.@redirect[-1].src='wan'
uci set firewall.@redirect[-1].dest='lan'
uci set firewall.@redirect[-1].proto='udp'
uci set firewall.@redirect[-1].src_dport='51820'
uci set firewall.@redirect[-1].dest_ip='192.168.1.250'
uci commit firewall

# 2. Static route: mesh subnet -> gateway
uci add network route
uci set network.@route[-1].interface='lan'
uci set network.@route[-1].target='172.30.0.0/16'   # your MESH_SUPERNET value
uci set network.@route[-1].gateway='192.168.1.250'
uci commit network

# 3. Split DNS: forward mesh domains to gateway
uci add_list dhcp.@dnsmasq[0].server='/*.sheen/192.168.1.250'
uci add_list dhcp.@dnsmasq[0].server='/*.osrc/192.168.1.250'
uci add_list dhcp.@dnsmasq[0].server='/*.hub/192.168.1.250'
# Rebind protection whitelist (allows private IPs from forwarded DNS)
uci add_list dhcp.@dnsmasq[0].rebind_domain='sheen'
uci add_list dhcp.@dnsmasq[0].rebind_domain='osrc'
uci add_list dhcp.@dnsmasq[0].rebind_domain='hub'
# Disable negative caching (new mesh hosts resolve immediately)
grep -q 'no-negcache' /etc/dnsmasq.conf 2>/dev/null || echo 'no-negcache' >> /etc/dnsmasq.conf
uci commit dhcp

/etc/init.d/firewall restart
/etc/init.d/network restart
/etc/init.d/dnsmasq restart

Other routers: Look for "port forwarding", "static routes", and "conditional DNS forwarding" in your router's admin interface. The concept is the same for all three.

Option B: Use the gateway as default gateway

Set the gateway LXC as the machine's default gateway. All traffic goes through it — routing and DNS just work (the gateway runs dnsmasq).

Option C: Add mesh route and DNS per machine

Add only the mesh route and DNS — keep the machine's existing default gateway for internet traffic. Best for the hub host itself (which can't use its own LXC as default gateway) or machines where you want minimal changes.

wm host-setup <wm-gateway-ip> | sudo bash

The <wm-gateway-ip> is the LAN IP of the wm gateway LXC at the site this host is on — not the LAN router or default gateway. Run wm host-setup with no args to see the list of available wm gateways from the registry.

Works on any Linux host with systemd-resolved (Ubuntu, Debian, Fedora, …). The script:

  • Saves mesh metadata to /etc/wm/meshes.d/<slug>.env (one file per mesh).
  • Installs /usr/local/bin/wm-host-apply — for each mesh, detects the outbound interface to the gateway, adds an ip route to the mesh supernet, and configures link-scoped DNS (resolvectl dns/domain) so mesh names resolve via the gateway and everything else stays on your normal upstream DNS.
  • Installs wm-host.service (oneshot, runs at boot) and wm-host.timer (re-applies every 5 minutes, picks up newly added sites, reasserts settings if anything wiped them).

Why link-scoped DNS rather than a global resolved.conf.d drop-in: on Debian VPSes (and any host where cloud-init pre-populates /etc/systemd/resolved.conf with upstream DNS), a global drop-in races the upstream and mesh names silently NXDOMAIN. Link-scoped configuration on the interface that reaches the gateway is unambiguous and works the same on every distro.

To remove:

sudo systemctl disable --now wm-host.timer wm-host.service
sudo rm -f /etc/wm/meshes.d/*.env /etc/systemd/system/wm-host.{service,timer} /usr/local/bin/wm-host-apply
sudo systemctl daemon-reload

The old name wm netplan-setup still works as a deprecated alias and prints a notice. It now invokes the same distro-agnostic host-setup flow — the previous netplan yaml + global resolved drop-in approach has been retired.

7. Add hosts

Every machine that needs mesh access must be registered. Unregistered machines are blocked by the gateway firewall even if they have the route set up. This prevents unauthorised LAN devices from accessing the mesh.

wm add-host myserver.home --local-ip 192.168.1.10
wm add-host nas.home --local-ip 192.168.1.20

The WG IP is auto-assigned (next available in the site's subnet). You can override with --wg-ip if needed.

The gateway picks up changes within a few seconds. After that:

  • myserver.sheen resolves from anywhere on the mesh
  • 172.30.1.10 routes to the machine's real LAN IP via the gateway
  • The machine can reach other hosts across the mesh

8. Add roaming devices

Roaming devices (laptops, phones) have full access to the mesh. They peer directly with the hub.

wm add-device laptop

This prints a one-liner. Run it on the device:

curl -sL https://getbin.xyz/wm-setup | sudo bash -s -- \
  --token <TOKEN> --registry <URL> --name laptop --type roaming

The --type flag is required: roaming (full access) or isolated (can't initiate).

Works on Linux (x86_64, aarch64) and macOS. The device gets:

  • WireGuard interface: wg-<network> (e.g. wg-jde), namespaced per registry
  • Split DNS: Mesh domains resolve through the hub, external DNS is unaffected (Linux with systemd-resolved)
  • Auto-update timer: Polls the registry every 5 minutes and applies config changes (new sites, subnets, DNS domains)
  • wg-<network>-status — run diagnostics
  • wg-<network>-uninstall — remove and deregister

Roaming devices also get getpkg and wm-cli installed automatically.

A device can join multiple mesh networks by running the setup script with different --registry URLs. Each network gets its own interface, config, and timer — no conflicts.

9. Add isolated devices

Isolated devices can be reached from the mesh but cannot initiate connections into it. For untrusted environments.

wm add-device field-sensor --type isolated

Same one-liner setup as roaming (with --type isolated). The firewall rules are applied automatically by the hub gateway.

DNS

Each gateway runs dnsmasq and generates a hosts file (/etc/wm/hosts) from the registry config on every sync. No centralized DNS server — each gateway serves DNS independently.

Each site has a DNS domain. Names resolve across the entire mesh:

Name Same-site query Cross-site query
myserver.sheen Local IP (192.168.1.10) WG IP (172.30.1.10)
nas.osrc WG IP WG IP
gw.sheen Gateway WG IP (172.30.1.1) Gateway WG IP (172.30.1.1)
laptop.hub WG IP WG IP
field-sensor.hub WG IP WG IP

Same-site hosts return local LAN IPs for direct access without traversing WireGuard. All other entries (cross-site hosts, gateways, devices) return WG overlay IPs. Non-mesh queries are forwarded upstream by dnsmasq.

If the registry goes down, gateways keep serving their last-known DNS entries — the hosts file is only updated when a config change is detected.

Duplicate hostnames across sites are safe: openwrt.sheen and openwrt.osrc resolve to different IPs.

CLI Reference

Install: getpkg install wm-cli. The CLI command is wm.

# Mesh management
wm mesh list                         # show configured meshes (slug, name, URL, health)
wm mesh add <registry-url> <token>   # connect to registry, save ~/.config/wm/<slug>.env
wm mesh remove <slug>                # remove a mesh config

# Read commands (iterate all configured meshes with section headers)
wm list                              # compact table of everything
wm check                             # full network health check
wm health                            # registry health check
wm show-config                       # full JSON
wm list-sites
wm list-hosts
wm list-devices
wm info
wm deepcheck

# Write commands (use --mesh <slug-or-name> when multiple meshes configured)
wm add-host                          # interactive prompts
wm add-host myserver.osrc --local-ip 192.168.77.10
wm add-host kiosk.osrc --local-ip 192.168.1.50 --type isolated
wm add-device laptop
wm add-device sensor --type isolated
wm remove dev.osrc
wm remove laptop.hub
wm add-site ...
wm remove-site ...
wm export ...
wm import ...

# Setup helpers
wm openwrt-setup 192.168.77.19      # generate OpenWrt config script
wm host-setup 10.0.3.2              # configure host (route + link-scoped DNS via systemd-resolved)

Write commands (add-host, add-device, remove, add-site, remove-site, export, import) accept --mesh <slug-or-name> to select which mesh to operate on. With a single mesh configured, it auto-selects. With multiple meshes and no --mesh flag, the CLI prompts interactively. Host commands always use name.domain format (e.g. myhost.osrc) — the site is derived from the domain.

Multi-Mesh CLI

The CLI supports managing multiple independent mesh networks. Config files are stored in ~/.config/wm/<slug>.env (e.g. ~/.config/wm/172-30.env).

# Connect to two meshes
wm mesh add https://wm-registry.home.example.com token1    # slug: 172-30
wm mesh add https://wm-registry.work.example.com token2    # slug: 172-31

# Read commands show all meshes with section headers
wm list
# === home (172-30) ===
# ...sites/hosts/devices...
# === work (172-31) ===
# ...sites/hosts/devices...

# Write commands — specify mesh when ambiguous
wm add-host myserver.lab --local-ip 10.0.0.5 --mesh work
wm add-device phone --mesh home

The slug is derived from the supernet (e.g. 172.30.0.0/16 becomes 172-30). The human-friendly name (e.g. "home", "work") comes from the registry's MESH_NAME env var and is shown alongside the slug. Either the slug or name can be used with --mesh.

How It Works

Gateways poll the registry

A systemd timer calls wm-agent every few seconds (configurable, default 5s). The agent handles two things:

Config sync (every invocation): runs wm-gateway, which fetches the full config from the registry and regenerates:

  1. WireGuard config — peers for other site gateways (+ devices if hub)
  2. NAT rules — DNAT overlay IPs to local LAN IPs for that site's hosts
  3. Firewall — isolated hosts and isolated devices can respond but not initiate
  4. DNS hosts — generates /etc/wm/hosts for dnsmasq (same-site hosts get local IPs, everything else gets WG IPs)

Changes only apply when the combined hash (registry config + script) differs. Even when a re-apply is triggered, each config (WireGuard, nftables, DNS) is diffed against its previous version — unchanged configs are skipped entirely. When changes do apply: wg syncconf updates peers without dropping connections, nft -f atomically replaces firewall rules (no gap), and dnsmasq reloads via SIGHUP. Established connections are never interrupted.

Auto-update (every 10 minutes): checks getbin.xyz for a newer version of the wm-gateway script. Downloads, validates, and atomically replaces it. The agent itself never self-updates (only changes when the provisioning script is re-run), so a bad wm-gateway publish can never break the updater — the next fix will be picked up automatically.

Devices peer with the hub

Roaming and isolated devices run WireGuard and connect to the hub gateway. The hub routes their traffic into the mesh. Devices behind NAT work fine — they connect outbound and PersistentKeepalive maintains the connection.

Firewall rules

Type Can reach mesh Mesh can reach it
standard Yes Yes
roaming (device) Yes Yes
isolated (host or device) No (respond only) Yes
unregistered No No

Each gateway only allows registered hosts (by local IP) to forward traffic into the mesh. Unregistered machines on the LAN are silently dropped — even if they have the route configured. Enforced via nftables on each gateway.

File Locations

On the gateway LXC

File Purpose
/etc/wm/gateway.env Site config
/etc/wm/private.key WireGuard private key
/etc/wm/hosts Auto-generated DNS hosts (dnsmasq)
/etc/wireguard/wg0.conf Auto-generated WireGuard config
/etc/nftables.d/wm.nft Auto-generated firewall
/usr/local/bin/wm-agent Dispatcher (config sync + auto-update)
/usr/local/bin/wm-gateway Config sync script (auto-updated)
/var/lib/wm/last-update-check Update check timestamp

On a roaming/isolated device

Files are namespaced by network name (e.g. jde for wm-registry.jde.nz):

File Purpose
/etc/wireguard/wg-<net>.key WireGuard private key
/etc/wireguard/wg-<net>.conf WireGuard config
/etc/wireguard/wg-<net>.env Token, registry URL, device config
/var/lib/wm/<net>.hash Config change detection hash
/usr/local/bin/wg-<net>-status Diagnostics
/usr/local/bin/wg-<net>-uninstall Remove + deregister
/usr/local/bin/wg-<net>-update Auto-update script (called by timer)

Security

  • Registry: Binds to 127.0.0.1 only — not directly accessible from the internet. Access via Cloudflare tunnel (HTTPS).
  • Cloudflare tunnel: Set CF_DOMAIN=wm-registry.example.com|8099 in the registry's service.env. Public URL is derived automatically.
  • Unregistered machines: Blocked by the gateway firewall even if they have the mesh route configured.
  • Isolated devices/hosts: Can be reached but cannot initiate connections into the mesh.

Gateway Maintenance

  • Update: ds install SERVER SERVICE is idempotent and non-disruptive. It pushes updated scripts into the LXC and the running agent detects the change via hash mismatch within one poll interval (~5s). No service restart, no connection drops. All gateways can be updated in parallel.
  • Auto-update: The gateway agent checks getbin.xyz every 10 minutes for a newer wm-gateway script and updates atomically. The agent itself never self-updates — only the config sync script is replaced.
  • Unattended upgrades: Gateway LXCs (Alpine) run apk upgrade daily via cron. Package upgrades don't restart services on Alpine, so running WireGuard/nftables/dnsmasq are unaffected.
  • Backup/restore: ds backup saves the private key + gateway.env. ds restore puts them back. Generated configs rebuild from the registry.
  • Destroy: ds destroy SERVER SERVICE deregisters the site from the registry and removes the LXC container.

Troubleshooting

On a device

sudo wg-<network>-status    # e.g. sudo wg-jde-status

Shows: installation status, WireGuard interface state, last handshake, registry connectivity, tunnel connectivity.

Check the auto-update timer:

journalctl -t wg-<network>-update --no-pager -n 10
systemctl status wg-<network>-update.timer

On a gateway

ds logs SERVER SERVICE               # view sync logs via dropshell
ds status SERVER SERVICE             # check LXC + WG state

# Or SSH into the LXC directly:
ssh root@SERVER "lxc-attach -n CONTAINER -- bash"
wg show wg0                          # WireGuard peers
journalctl -t wm-gateway -n 20      # sync logs
/usr/local/bin/wm-gateway            # force a sync
nft list ruleset | grep -A5 wm      # firewall rules
dig myserver.sheen @localhost        # DNS resolution

Common issues

Symptom Likely cause
wm health fails Registry unreachable — check firewall, port, DNS
Gateway not syncing Check ds logs — token or URL wrong?
Device no handshake Hub unreachable — check port forward (UDP 51820) and nft list table ip wm-gateway on the host
gw.<domain> not resolving Check dnsmasq is running inside LXC and /etc/wm/hosts has entries. Force sync: run wm-gateway inside the LXC
Can't resolve .sheen names DNS not reaching gateway — configure machine/router to use gateway IP as DNS. See "Connect machines" above
New hosts never resolve on a site Router's local=/<domain>/ claims authority and blocks forwarding to the gateway. Re-run wm openwrt-setup — it now detects and removes conflicting local= directives
Host unreachable via overlay IP Check NAT rules (nft list table ip wm), check route on machine
Works locally but not across sites Port forward UDP 51820 missing on router
Machine has route but can't reach mesh Not registered — run wm add-host with its local IP
LXC has wrong network after config change Fixed in current template — ds install now updates network config on existing containers
Device connected before but stopped working Stale conntrack entry — manual fix: conntrack -D -p udp --dport 51820 on the gateway host (note: this forces all WireGuard peers to re-handshake)