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.jsonwritten0600. - Two modes:
wgcoord coordinator …andwgcoord 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 throughwg-quick, then reconciled live withwg syncconf). On any other host apply is skipped gracefully and an inspectablewg-quick.confis written instead, so the control plane still works everywhere.
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) │
- 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).
- Both endpoints require
Authorization: Bearer <token>. Each client has its own token, generated by the coordinator and rotatable. - 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.
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.
Requires Go ≥ 1.25.
make build # -> ./wgcoord
sudo make install # -> /usr/local/bin/wgcoordLive apply additionally needs wireguard-tools (wg) and root (to manage the
interface) on the coordinator and each client, plus:
- Linux — iproute2 (
ip); the interface is the in-kernel WireGuard device. - macOS — wireguard-go and wg-quick (
brew install wireguard-tools wireguard-go); there is no kernel WireGuard on macOS, so the tunnel runs in userspace on autundevice thatwg-quickbrings up.
Without these the tool still coordinates and writes wg0.conf for a manual
wg-quick up.
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).
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 runJoin 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 runThe 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:portto 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.
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 routesEquivalently, 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.
AllowedIPsdoubles 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/24→10.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 routeyourself.
Both modes run a foreground daemon (coordinator run / client run), so they
belong under an init system. Initialize/join first — run needs an existing
config — then install the unit.
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.targetsudo systemctl daemon-reload
sudo systemctl enable --now wgcoord-coordinator
sudo journalctl -u wgcoord-coordinator -fThe 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.
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.targetsudo systemctl daemon-reload
sudo systemctl enable --now wgcoord-client
sudo journalctl -u wgcoord-client -f- 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.