Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wgcoord

A small CLI that coordinates a full-mesh WireGuard network. One node runs as the coordinator (a control-plane hub); every other node runs as a client. Clients authenticate to the coordinator with a per-client token, exchange public keys through it, and heartbeat to stay in sync. The coordinator programs its own WireGuard interface live and can blacklist a client to cut it off from the mesh.

  • Strictly a terminal CLI — no daemon UI, no database. All state lives in a single config.json written 0600.
  • Two modes: wgcoord coordinator … and wgcoord client ….
  • Full mesh: the coordinator is peer‑0; every client peers with the hub and with every other client.
  • Live WireGuard apply on Linux (in-kernel WireGuard via wg syncconf + ip) and macOS (userspace wireguard-go driven through wg-quick, then reconciled live with wg syncconf). On any other host apply is skipped gracefully and an inspectable wg-quick .conf is written instead, so the control plane still works everywhere.

How it works

                 control plane (TCP, e.g. :51821)          WireGuard (UDP, e.g. :51820)
   ┌────────────┐  register / heartbeat   ┌────────────┐        full mesh
   │  client A  │ ──────────────────────▶ │ coordinator│◀── udp ──▶ client A
   │            │ ◀── missing peers ───── │   (hub)    │◀── udp ──▶ client B
   └────────────┘                         └────────────┘             ▲   ▲
        ▲  ▲                                                         │   │
        └──┴──────────────── client A ◀── udp ──▶ client B ──────────┘   │
                             (peers exchanged via the coordinator)       │
  1. The coordinator exposes an HTTP control port (separate from the WireGuard UDP port) with two endpoints:
    • POST /register — a client sends its public key + public endpoint, gets an assigned mesh IP and the full current peer set.
    • POST /heartbeat — a client reports the peer ids it already holds; the coordinator returns only the peers it is missing (plus any it should remove, e.g. blacklisted).
  2. Both endpoints require Authorization: Bearer <token>. Each client has its own token, generated by the coordinator and rotatable.
  3. The coordinator reconciles its live interface from the registry every ~15s (and on demand). Blacklisting a client removes it from the hub interface and from every other client's config on their next heartbeat.

The control plane bootstraps out-of-band, then moves inside the mesh

A client must reach the coordinator to obtain its address and peer set before any tunnel exists, so register always travels over the client's ordinary network to the coordinator's public control endpoint (e.g. http://203.0.113.1:51821).

Once the tunnel is up, that changes. Every response carries the coordinator's in-mesh control URL (control_url, e.g. http://10.8.0.1:51821 — the hub's own mesh address on the control port), and subsequent heartbeats prefer it, so the bearer token and public keys ride inside WireGuard's encryption:

  • The mesh path is used only when the client has a mesh address, holds the coordinator peer, is on a host that can bring the interface up, and the advertised URL is inside the mesh range.
  • Any transport failure falls back to the public URL — a dial error, timeout, or 5xx. The client then sticks to the public URL for 5 minutes before trying the mesh again, so a broken tunnel costs one short stall rather than slowing every beat. A definitive answer from the coordinator (401, 403, …) is not retried: it proves the mesh path works.
  • A coordinator that advertises no control_url (or a client that never applies a tunnel) simply keeps using the public URL, exactly as before.

The consequence for bootstrap: register — the one request that carries the join token before any tunnel exists — is not protected by WireGuard's encryption. In any real deployment run the control plane over HTTPS too, either with the built-in TLS flags or, preferably, behind a reverse proxy (see HTTPS / reverse proxy). When the coordinator terminates TLS itself, the advertised in-mesh URL is https:// on the mesh address, so the certificate must cover it — otherwise clients simply fall back to the public URL.

Build & install

Requires Go ≥ 1.25.

make build            # -> ./wgcoord
sudo make install     # -> /usr/local/bin/wgcoord

Live apply additionally needs wireguard-tools (wg) and root (to manage the interface) on the coordinator and each client, plus:

  • Linuxiproute2 (ip); the interface is the in-kernel WireGuard device.
  • macOSwireguard-go and wg-quick (brew install wireguard-tools wireguard-go); there is no kernel WireGuard on macOS, so the tunnel runs in userspace on a utun device that wg-quick brings up.

Without these the tool still coordinates and writes wg0.conf for a manual wg-quick up.

Configuration file

Stored at (first match): --config, $WGCOORD_CONFIG, /etc/wgcoord/config.json when root, else ~/.config/wgcoord/config.json. Always written 0600 (it holds private keys and, on clients, the token).

Coordinator

Manage and run the hub. Every management command edits the shared config.json under a file lock; a running coordinator run picks up the change on its next reconcile (≤15s). Run them as the same user as the daemon — root, when the config lives in /etc/wgcoord/.

Command What it does Example
coordinator init Initialize this host as the hub (writes config, generates the hub keypair). wgcoord coordinator init --public-ip 203.0.113.1
coordinator run Start the control plane and manage the hub interface (foreground; alias serve). wgcoord coordinator run
coordinator client add <name> Create a named client and print its one-time token + join command. wgcoord coordinator client add laptop
coordinator client remove <name> Delete a client from the registry (aliases rm, delete). wgcoord coordinator client remove laptop
coordinator client rename <old> <new> Rename a client. wgcoord coordinator client rename laptop workstation
coordinator route add <node> <cidr>… Advertise extra CIDRs a node carries (pod subnets, LANs) in its AllowedIPs. <node> is a client name or coordinator. wgcoord coordinator route add node1 10.12.1.0/24
coordinator route remove <node> <cidr>… Drop advertised routes from a node (aliases rm, delete). wgcoord coordinator route remove node1 10.12.1.0/24
coordinator route list [<node>] Show a node's routes, or every node's when omitted. wgcoord coordinator route list
coordinator token regenerate <name> Rotate a client's token; the old one stops working immediately (aliases regen, new). wgcoord coordinator token regenerate laptop
coordinator blacklist <name> Block a client — refused at the control plane and dropped from the hub. wgcoord coordinator blacklist laptop
coordinator blacklist remove <name> Lift the blacklist; the client may rejoin on its next heartbeat. wgcoord coordinator blacklist remove laptop
coordinator status Show the hub and every configured client. wgcoord coordinator status

The coordinator command also accepts the alias coord.

coordinator init flags

Flag Default Description
--public-ip (none) Public IP/host clients dial to reach the hub. Without it, clients can't join until one is set.
--control-port 51821 HTTP control-plane (TCP) port for register/heartbeat.
--wg-port 51820 Hub WireGuard UDP port.
--client-wg-port 51820 Default WireGuard UDP port advertised for clients.
--ip-range 10.8.0.0/24 Mesh address pool (CIDR).
--address first host Hub's own mesh IP (e.g. 10.8.0.1).
--interface wg0 WireGuard interface name.
--tls-cert (none) TLS certificate file for the control plane (enables HTTPS directly).
--tls-key (none) TLS private key file for the control plane (enables HTTPS directly).
--route (none) Extra CIDR the hub carries, added to its advertised AllowedIPs (e.g. its pod subnet). Repeatable. See Routing extra subnets.
--force false Overwrite an existing config.

coordinator client add flags

Flag Default Description
--address auto Assign a specific mesh IP; otherwise the lowest free host is allocated.
--route (none) Extra CIDR this client carries, added to its advertised AllowedIPs (e.g. its pod subnet). Repeatable. See Routing extra subnets.

Example — initialize the hub, add a client with a pinned IP, then run:

wgcoord coordinator init \
  --public-ip 203.0.113.1 \      # public IP/host clients dial to reach the hub
  --control-port 51821 \         # HTTP control-plane port
  --wg-port 51820 \              # hub WireGuard UDP port
  --ip-range 10.8.0.0/24 \       # mesh address pool
  --interface wg0

wgcoord coordinator client add server-a --address 10.8.0.20
wgcoord coordinator run

Client

Join a coordinator and keep this node's interface in sync.

Command What it does Example
client join Register with a coordinator using a token; write config and apply the first peer set. wgcoord client join --server http://203.0.113.1:51821 --token <TOKEN>
client run Heartbeat the coordinator and keep the interface in sync (foreground). wgcoord client run
client sync Send one heartbeat, fetch missing peers, and re-apply. wgcoord client sync
client up Apply the cached peer set to the interface (no coordinator call). wgcoord client up
client down Tear down the WireGuard interface. wgcoord client down
client status Show this node's identity and its peers. wgcoord client status
client endpoint set <peer> <host[:port]> Pin the address this node dials for a peer, overriding the coordinator (- drops the endpoint). wgcoord client endpoint set coordinator 192.168.1.10
client endpoint clear <peer> Drop the override and go back to the advertised endpoint (aliases unset, rm). wgcoord client endpoint clear coordinator
client endpoint list Show this node's overrides (alias ls). wgcoord client endpoint list

client join flags

Flag Default Description
--server (required) Coordinator control-plane URL, e.g. http://203.0.113.1:51821 (or https://… behind a proxy).
--token (required) Auth token from coordinator client add.
--public-ip (none) This node's public IP/host to share with peers. Omit behind NAT.
--address auto Request a specific mesh IP (granted if free).
--interface wg0 WireGuard interface name.
--wg-port 51820 Local WireGuard UDP port.
--keepalive 25 Persistent-keepalive seconds toward peers.
--heartbeat 25 Heartbeat interval seconds.
--peer-endpoint (none) Dial a peer at a different address than the coordinator advertises: <peer>=<host[:port]>. Repeatable.

A client behind NAT can omit --public-ip; it still reaches peers by dialing out and is kept reachable by persistent-keepalive:

wgcoord client join \
  --server http://203.0.113.1:51821 \
  --token <TOKEN>
wgcoord client run

Endpoint overrides (NAT hairpin, split LANs)

The coordinator advertises one endpoint per peer — the public address that peer reported — and that is the right answer only from outside. Two nodes behind the same NAT that dial each other's public IP depend on the router hairpinning its own WAN address back inside; many (Proxmox hosts behind a consumer router, most cloud NAT gateways) simply drop it, and the handshake never completes.

An override pins the address this node uses for a given peer. It is local, never sent to the coordinator, and re-applied on every sync — so a heartbeat that refreshes the peer's public endpoint doesn't undo it. Note that --server alone is not enough: pointing the control plane at a LAN address moves only register/heartbeat, not the tunnel.

# at join time — before the first apply, so the tunnel never uses the public IP
wgcoord client join --server http://192.168.1.10:51821 --token <TOKEN> \
  --peer-endpoint coordinator=192.168.1.10 \
  --peer-endpoint server-b=192.168.1.51:51820

# or any time afterwards; both re-apply the interface immediately
wgcoord client endpoint set coordinator 192.168.1.10
wgcoord client endpoint list
wgcoord client endpoint clear coordinator
  • A peer is named by its name or its id (the hub is coordinator). An override for a peer that doesn't exist yet is kept and applies when it joins.
  • A bare host keeps the port the coordinator advertised, so pinning a LAN address can't silently move a peer off the port it listens on. Give host:port to set both; bracket IPv6 when you include a port ([2001:db8::1]:51820).
  • - removes the endpoint entirely, leaving that peer dial-out-only.
  • Usually only one side needs it. WireGuard learns a peer's endpoint from its authenticated handshakes, so once the LAN-side node dials out correctly, the other end picks up the working source address on its own.

Global flag: --config <path> (available on every command) overrides the config location; $WGCOORD_CONFIG does the same. See Configuration file.

Routing extra subnets (Kubernetes pods, LANs)

By default each peer's AllowedIPs is just its own mesh address as a /32, so only node-to-node traffic on the mesh range crosses the tunnel. WireGuard's AllowedIPs is also a firewall: a packet whose source or destination falls outside a peer's AllowedIPs is dropped before it leaves the interface. So if a node sits in front of another subnet — a Kubernetes pod CIDR, a LAN behind it — that subnet's packets are silently discarded until the subnet is added to the AllowedIPs on every peer.

Routes are the extra CIDRs a node carries. A route on node1 is appended to the AllowedIPs advertised for node1 to every other peer (and to the hub's own interface), so the mesh sends that subnet through node1's tunnel. Routes are set on the coordinator — the hub is the source of truth for who may carry what — and propagate to every peer on its next heartbeat; nothing is hand-edited on the nodes (and manual wg0.conf edits wouldn't survive the next reconcile anyway). <node> is a client name, or coordinator for the hub itself.

# each node advertises only its own pod CIDR — not a blanket cluster CIDR, so
# WireGuard's cryptokey routing stays unambiguous about which peer owns which pod
wgcoord coordinator route add coordinator 10.12.0.0/24   # the hub's / master's pod subnet
wgcoord coordinator route add node1       10.12.1.0/24   # node1's pod subnet
wgcoord coordinator route add node2       10.12.2.0/24
wgcoord coordinator route list                           # every node's routes

Equivalently, set them when the node is created: coordinator init --route 10.12.0.0/24 for the hub, coordinator client add node1 --route 10.12.1.0/24.

  • One CIDR per owning node, not one shared cluster CIDR on all of them. AllowedIPs doubles as WireGuard's routing table, and it must map each destination to exactly one peer — so give each node the pod subnet it owns.
  • CIDRs are validated and normalized to their network form (10.12.1.5/2410.12.1.0/24); a single host needs an explicit /32.
  • wgcoord only manages WireGuard's cryptokey routing (AllowedIPs). The kernel still needs a route pointing the CIDR at the interface. In a Kubernetes cluster the CNI installs those routes (Flannel/Calico point each pod CIDR at the owning node's mesh IP); for a plain LAN behind a node, add the ip route yourself.

Running as a service (systemd)

Both modes run a foreground daemon (coordinator run / client run), so they belong under an init system. Initialize/join firstrun needs an existing config — then install the unit.

Coordinator

Bootstrap the hub, then install and start the unit:

sudo wgcoord coordinator init --public-ip 203.0.113.1
sudo wgcoord coordinator client add laptop      # note the printed token for the client

/etc/systemd/system/wgcoord-coordinator.service:

[Unit]
Description=wgcoord coordinator (WireGuard mesh control plane)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/wgcoord coordinator run
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now wgcoord-coordinator
sudo journalctl -u wgcoord-coordinator -f

The unit runs as root so wgcoord can program the interface (wg syncconf + ip) and read /etc/wgcoord/config.json. Run the management commands (client add, blacklist, …) with sudo too, so they edit the same config the daemon uses.

Client

Join first (this writes the config and brings the interface up once), then hand the heartbeat loop to systemd:

sudo wgcoord client join --server http://203.0.113.1:51821 --token <TOKEN>

/etc/systemd/system/wgcoord-client.service:

[Unit]
Description=wgcoord client (WireGuard mesh member)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/wgcoord client run
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now wgcoord-client
sudo journalctl -u wgcoord-client -f

Notes

  • Blacklist enforcement. A blacklisted client is refused at the control plane (403) and, where wireguard-tools is present, removed from the hub's live interface — so it can neither fetch peers nor keep a session with the hub. Direct client↔client tunnels drop when each peer re-applies on its next heartbeat.
  • Single coordinator host. The registry is one JSON file guarded by an in-process mutex and a file lock, so the running daemon and CLI edits on the same host are safe. It is not a clustered/HA control plane.
  • Keys never leave the node. Each node generates its own X25519 keypair locally; only public keys are exchanged. Tokens are stored on the coordinator as SHA-256 hashes.

About

A small CLI that coordinates a **full-mesh WireGuard** network

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages