wgkex (WireGuard key exchange) is the node management service of Freifunk München. Mesh nodes register their WireGuard public key with a central broker, which picks a gateway for them and distributes the key via MQTT to the workers running on the gateways. The workers add the peer to WireGuard and the VXLAN FDB, so that nodes can run BATMAN over VXLAN over WireGuard as encrypted, high-performance mesh links.
On the node side, wgkex is used by the Gluon package
ffmuc-mesh-vpn-wireguard-vxlan.
flowchart TD
node["Gluon node<br/>(ffmuc-mesh-vpn-wireguard-vxlan)"]
broker["wgkex broker<br/>(HTTP API)"]
mqtt[("MQTT broker<br/>(e.g. Mosquitto)")]
subgraph gw["Gateway (one per worker)"]
worker["wgkex worker"]
wg["WireGuard<br/>wg-{domain}"]
vx["VXLAN FDB<br/>vx-{domain}"]
route["Routes<br/>(netlink)"]
end
node -- "POST /api/v2/wg/key/exchange" --> broker
broker -- "endpoint of chosen gateway" --> node
broker -- "wireguard/{domain}/{worker|all}<br/>wireguard-worker/{worker}/{domain}/remove<br/>wireguard-worker/{worker}/rebalance" --> mqtt
mqtt --> worker
worker -- "wireguard-worker/{worker}/status<br/>wireguard-worker/{worker}/{domain}/data<br/>wireguard-metrics/{domain}/{worker}/connected_peers" --> mqtt
mqtt --> broker
worker -- pyroute2 --> wg & vx & route
node -. "WireGuard + VXLAN tunnel" .-> wg
- Broker (
wgkex/broker): a Flask app served by waitress. It validates key exchange requests, chooses a gateway weighted by load (/api/v2), and publishes the key via MQTT. It also serves Prometheus metrics and runs the optional rebalancing loop. - Worker (
wgkex/worker): runs on each gateway. It subscribes to the keys for its domains and adds them as WireGuard peers, routes and VXLAN FDB entries via netlink (pyroute2). It reports its status, endpoint data and connected peers back to the broker, removes stale peers, and supports drain mode for planned maintenance. - WireGuard and VXLAN interface names are derived from the domain: the matching
domain_prefixesentry is replaced bywg-/vx-(e.g.ffmuc_welt→wg-welt,vx-welt).
Important
Every worker must run on a host with a unique hostname. The hostname identifies the worker in the MQTT topics,
in the workers config and in the metrics.
The broker publishes /api/v1 keys to all workers and /api/v2 keys only to the chosen worker; see
MQTT topics and Docs/operations.md for details.
All components read their configuration from /etc/wgkex.yaml, or from the file in the environment variable
WGKEX_CONFIG_FILE. Start from wgkex.yaml.example.
Images are published to ghcr.io/freifunkmuc/wgkex on
every push to main (tags main and sha-<commit>) and for release tags vX.Y.Z. The image command selects the
component (broker, the default, or worker):
docker run -d --name wgkex-broker -p 5000:5000 \
-v "$PWD/wgkex.yaml:/etc/wgkex.yaml:ro" \
ghcr.io/freifunkmuc/wgkex:main brokerdocker-compose.yml starts Mosquitto, a broker and a worker. Without a mounted
/etc/wgkex.yaml, the entrypoint generates a minimal config from the environment variables in
env.example (WGKEX_DOMAINS, WGKEX_DOMAIN_PREFIXES, WGKEX_DEBUG,
WGKEX_BROKER_LISTEN_HOST/_PORT (default 0.0.0.0:5000), MQTT_*). WGKEX_IMAGE_TAG selects the image tag
(default main; the latest tag still points to v0.2.1).
docker-compose.override.yaml.example shows how to mount configs or disable
services.
Note
The worker manages WireGuard and VXLAN interfaces of the host via netlink. It needs the host's network namespace and
CAP_NET_ADMIN (e.g. --network host --cap-add NET_ADMIN), and the interfaces must already exist.
bazel build //wgkex/broker:app //wgkex/worker:app
export WGKEX_CONFIG_FILE=$PWD/wgkex.yaml
./bazel-bin/wgkex/broker/app
# the worker needs root (or CAP_NET_ADMIN) to manage the interfaces
sudo -E ./bazel-bin/wgkex/worker/appBazelisk picks the Bazel version from .bazelversion.
See Docs/development.md for a local setup with dummy interfaces.
Bazel uses Python 3.13 (see MODULE.bazel). To run from a checkout without Bazel:
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements_lock.txt
export WGKEX_CONFIG_FILE=$PWD/wgkex.yaml
python3 -m wgkex.broker.app
sudo -E .venv/bin/python3 -m wgkex.worker.appwgkex.yaml.example is the source of truth: it documents every option with its default and
whether it applies to the broker, the worker or both. Overview:
| Key | Used by | Description |
|---|---|---|
domains |
broker, worker | Accepted domains; each needs matching wg-* / vx-* interfaces on the workers. Required. |
domain_prefixes |
broker, worker | Prefixes stripped from the domain to derive the interface names. Required. |
mqtt |
broker, worker | broker_url, username, password (required); broker_port (1883), keepalive (5), tls (false). |
broker_listen |
broker | host and port of the HTTP API. Both are required by the broker (no built-in default). |
externalName |
worker | Public hostname or address of this worker, returned to clients as Endpoint.Address. Defaults to the hostname. |
workers |
broker | Hostname → weight (default 1) and optional location per worker. Unlisted workers get the average weight. |
load_balancing |
broker, worker | Peer distribution, targeted publishing, location preference, shared assignments and rebalancing. |
peer_cleanup |
worker | Removal of peers without handshake: stale_timeout (1800 s), interval (300 s). |
drain |
worker | Drain mode: file (unset), check_interval (5 s). |
metrics |
broker, worker | broker_endpoint (true), worker_port (unset = disabled), worker_host (0.0.0.0). |
rate_limit |
broker | Per-client and global token buckets for the key exchange (30/60 s per client, 6000/60 s global). |
broker |
broker | trusted_proxies (0) and log_client_ip (false). |
key_lists |
broker, worker | blocklist/blocklist_file (broker), allowlist/allowlist_file (worker), reload_interval (60 s). |
log_level |
broker, worker | Root log level (INFO). Overrides the root level of logging_config. |
logging_config |
broker, worker | Optional Python logging configuration in dictConfig format. |
The broker exposes:
| Endpoint | Description |
|---|---|
POST /api/v1/wg/key/exchange |
Publish a key to all workers of the domain; the client picks a gateway itself. |
POST /api/v2/wg/key/exchange |
Publish a key and get the connection data of the gateway chosen for the client. |
GET /metrics |
Prometheus metrics (unless metrics.broker_endpoint: false). |
GET / |
Static index page. |
Docs/openapi.yaml (OpenAPI 3) is the authoritative specification of requests, responses and
status codes, and the reference for other client and broker implementations. View it with e.g. the
Swagger Editor; //wgkex/broker:openapi_test checks the broker against it.
curl -s -X POST http://127.0.0.1:5000/api/v2/wg/key/exchange \
-d '{"domain": "ffmuc_welt", "public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg=", "location": "muc"}'{
"Endpoint": {
"Address": "gw04.ext.ffmuc.net",
"Port": "40011",
"AllowedIPs": ["fe80::27c:16ff:fec0:6c74"],
"PublicKey": "TszFS3oFRdhsJP3K0VOlklGMGYZy+oFCtlaghXJqW2g="
}
}location is optional; workers configured with the same location are preferred.
Important
Compatibility with the Gluon client ffmuc-mesh-vpn-wireguard-vxlan is part of the contract:
- The request body is parsed as JSON regardless of the
Content-Typeheader. Endpoint.Portis a string, not a number.- On any non-
200response, the client falls back to/api/v1and a random gateway from its own configuration. Rate-limited requests therefore still get a200response; there is no429. - New response fields may be added, but existing fields are never changed. Clients must ignore unknown fields.
Detailed guides are in Docs/operations.md and Docs/metrics.md:
- Load balancing and rebalancing: weighted, randomized gateway selection with pending assignments, location preference, optional active rebalancing, and multiple broker instances.
- Drain mode and planned reboots:
SIGUSR1/SIGUSR2or a drain file, plus a step-by-step reboot runbook. - Stale peer cleanup and worker internals.
- Rate limiting and key block-/allowlists.
- Running behind a reverse proxy:
broker.trusted_proxiesandbroker.log_client_ip. - Prometheus metrics and Grafana dashboards: metric reference and the dashboards in
contrib/grafanafor Prometheus and InfluxDB/Telegraf.
Warning
GET /metrics on the broker exposes worker names and loads. Disable it with metrics.broker_endpoint: false or block
the path at your reverse proxy if that should not be public.
All topics are defined in wgkex/common/mqtt.py. {worker} is the worker's hostname.
| Topic | Direction | Payload | Notes |
|---|---|---|---|
wireguard/{domain}/{worker} |
broker → worker | public key | {worker} is all for /api/v1 keys, and the chosen worker for /api/v2 keys (with load_balancing.targeted_publish, the default). |
wireguard-worker/{worker}/{domain}/remove |
broker → worker | public key | Remove a peer whose key moved to another worker. |
wireguard-worker/{worker}/rebalance |
broker → worker | JSON {"excess": int, "domains": [str]} |
Only with rebalancing enabled. Draining workers also get "max_evictions_ratio": float. |
wireguard-worker/{worker}/status |
worker → broker | 0 offline, 1 online, 2 draining |
Retained. The worker's MQTT last will sets 0. |
wireguard-worker/{worker}/{domain}/data |
worker → broker | JSON {"ExternalAddress", "Port", "PublicKey", "LinkAddress"} |
Retained. Endpoint data returned by /api/v2. |
wireguard-metrics/{domain}/{worker}/connected_peers |
worker → broker | integer | Peers with a recent handshake, sent every 60 s. |
wireguard-broker/{broker_id}/assignment |
broker → broker | JSON {"worker": str, "domain": str, "key": str} |
Only with load_balancing.share_pending_assignments. |
The remove, rebalance and assignment topics, the targeted wireguard/{domain}/{worker} keys and status 2 were
added without breaking older components, so brokers and workers can be upgraded one at a time:
- Older workers subscribe to
wireguard/{domain}/+and add every key; newer workers only act onalland their own hostname. Older brokers publish every key toall. The bandwidth savings of targeted publishing only apply once both sides are updated. - Older workers ignore
removeandrebalancemessages; older brokers ignoreassignmentmessages. - Older brokers treat any status
>= 1, including draining (2), as online.
bazel build //...
# all tests, as run in CI
bazel test //... --test_output=errors --action_env=WGKEX_CONFIG_FILE=$PWD/wgkex.yaml.exampleWithout Bazel, run the test modules one at a time (they share module-level state, so a single
unittest discover run does not work):
export WGKEX_CONFIG_FILE=$PWD/wgkex.yaml.example
for t in $(git ls-files 'wgkex/*_test.py'); do python3 -m unittest "$(echo "${t%.py}" | tr / .)" || break; doneCI (.github/workflows) runs the Bazel tests with coverage, ruff check on ./wgkex (ruff is
pinned to 0.16.1, so new default rules don't break CI) and black --check (the psf/black action, v26.5.1):
pip install ruff==0.16.1 black==26.5.1
ruff check ./wgkex
black --check .Python dependencies are declared in requirements.txt and locked in
requirements_lock.txt via Bazel's compile_pip_requirements. After changing
requirements.txt, regenerate the lock file:
bazel run //:requirements.updateNote
The Bazel version in .bazelversion must match the bazel-public/bazel builder image in the
Dockerfile; a workflow updates .bazelversion on Dependabot pull requests.
See Docs/development.md for running broker and worker locally with dummy interfaces and example clients.
Issues and pull requests are welcome. Please keep CI green (tests, ruff, black), add tests for new behaviour, and update
wgkex.yaml.example and Docs/openapi.yaml when changing configuration or
the API. New MQTT topics and payload fields must stay compatible with older brokers and workers.
Questions? Reach us on the Freifunk München Mattermost. Background on the architecture: FFMUC goes wild – infrastructure recap 2020 (rC3).
wgkex is licensed under the GNU General Public License v3.0.