- Shell 52.6%
- Go 47.3%
- Dockerfile 0.1%
| .gitea/workflows | ||
| cli | ||
| setup | ||
| wm-gateway | ||
| wm-registry | ||
| CLAUDE.md | ||
| README.md | ||
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.
Option A: Configure on the router (recommended)
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:
- Static route:
MESH_SUPERNET(e.g.172.30.0.0/16) via gateway IP (so LAN machines can reach the mesh) - Remove conflicting
local=: If the router's domain matches a mesh domain (e.g.home), dnsmasq'slocal=/home/directive blocks forwarding — the script detects and removes it - 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 anip routeto 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) andwm-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-setupstill works as a deprecated alias and prints a notice. It now invokes the same distro-agnostichost-setupflow — 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.sheenresolves from anywhere on the mesh172.30.1.10routes 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 diagnosticswg-<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:
- WireGuard config — peers for other site gateways (+ devices if hub)
- NAT rules — DNAT overlay IPs to local LAN IPs for that site's hosts
- Firewall — isolated hosts and isolated devices can respond but not initiate
- DNS hosts — generates
/etc/wm/hostsfor 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.1only — not directly accessible from the internet. Access via Cloudflare tunnel (HTTPS). - Cloudflare tunnel: Set
CF_DOMAIN=wm-registry.example.com|8099in the registry'sservice.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 SERVICEis 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-gatewayscript and updates atomically. The agent itself never self-updates — only the config sync script is replaced. - Unattended upgrades: Gateway LXCs (Alpine) run
apk upgradedaily via cron. Package upgrades don't restart services on Alpine, so running WireGuard/nftables/dnsmasq are unaffected. - Backup/restore:
ds backupsaves the private key + gateway.env.ds restoreputs them back. Generated configs rebuild from the registry. - Destroy:
ds destroy SERVER SERVICEderegisters 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) |