wgctl
v1.14.0
Published
WireGuard overlay network manager — hub and spoke, join via token or .conf.
Readme
wgctl
A CLI tool for running a WireGuard overlay network. One server acts as the hub; any number of peers join it with a single command. All peers share a flat tunnel subnet and can reach each other through the hub — no hand-written WireGuard configs required.
Requirements: Node.js 22+, Linux, wireguard-tools (wg and wg-quick).
Install
On a fresh Debian/Ubuntu server:
curl -fsSL https://raw.githubusercontent.com/AlexanderSlaa/wgctl/main/scripts/install.sh | sudo bashThis installs wireguard-tools, ensures Node.js 22 is available, installs
wgctl from npm, and starts wgctl setup. To install without running setup:
curl -fsSL https://raw.githubusercontent.com/AlexanderSlaa/wgctl/main/scripts/install.sh | sudo env RUN_SETUP=0 bashManual install:
npm install -g wgctl
apt-get install -y wireguard-tools iptables # if not already installedCommands that configure WireGuard (peer, status, setup)
require root / CAP_NET_ADMIN. If you run one without it, wgctl re-runs
itself under sudo automatically. Set WGCTL_NO_SUDO=1 to disable this
and get a plain permission error instead.
Hub server setup
Run this once on the machine that will act as the central hub:
sudo wgctl setup [--interface <name>] [--force]The wizard asks for:
- WireGuard interface name (default
wg0) - UDP listen port (default 51820, or 51820+N for
wgN) - Tunnel subnet CIDR (default
10.88.0.0/24) - Public hostname or IP peers connect to (auto-detected from network interfaces)
- Systemd service mode: start now + autostart on boot, autostart only, start now only, or install unit only
It writes /etc/wireguard/<iface>.conf (with PostUp/PreDown forwarding rules
built in) and an env file at /etc/wgctl/<iface>.env. Use --force to
overwrite an existing configuration without prompting. If setup was already
run, the wizard detects this and offers to re-run or exit.
Running as a systemd service
wgctl setup installs and starts the service for you. You can also manage
it directly:
sudo wgctl service enable # start now and on every boot
sudo wgctl service disable # stop and remove from boot
sudo wgctl service start
sudo wgctl service stop
sudo wgctl service restart
sudo wgctl service status
sudo wgctl service logs [-f] [-n N]
sudo wgctl service uninstall [-y] # disable and remove env fileAdding peers
Each peer gets a unique tunnel IP allocated from the hub's subnet. Run on the hub:
sudo wgctl peer add <label> --join-tokenThis prints a one-liner to paste on the peer machine. The token is one-time-use and contains everything the peer needs — no further communication with the hub is required after joining.
To export a standard .conf file instead (for use with any WireGuard
client, including mobile apps):
sudo wgctl peer add <label> --output peer.conf
# or print to stdout:
sudo wgctl peer add <label>Other peer management:
sudo wgctl peer ls # list all peers with tunnel IPs, routes, and last handshake
sudo wgctl peer rm <id|label> # remove a peer
sudo wgctl peer token <label> # re-generate a join token for an existing peerOverlay routing
Every peer receives AllowedIPs = <tunnel-subnet> (e.g. 10.88.0.0/24),
so peer A can reach peer B at 10.88.0.B by routing through the hub. The
hub enables IP forwarding and an iptables FORWARD rule automatically.
Exposing LAN subnets to the overlay
A peer can advertise subnets from its local network so other peers in the
overlay can reach them. Pass --routes when adding the peer on the hub:
sudo wgctl peer add office --routes 192.168.1.0/24 --join-tokenMultiple subnets are comma-separated:
sudo wgctl peer add office --routes 192.168.1.0/24,10.0.0.0/8 --join-tokenWhen the token is applied on the peer machine with wgctl join, wgctl
automatically:
- Detects the default outbound interface (e.g.
eth0) - Writes
PostUp/PostDowniptables masquerade rules into the WireGuard config for that interface - Enables
net.ipv4.ip_forwardand persists it to/etc/sysctl.d/99-wgctl-<iface>.conf
Every join token generated after this point includes the advertised subnets
in its AllowedIPs, so any peer that joins later can reach the exposed LAN
automatically.
Note: Tokens generated before the
--routespeer was added will not include the new subnets. Regenerate them withwgctl peer token <label>and re-apply on those machines.
Joining the overlay
On any Linux machine that should join the network, paste the token printed
by wgctl peer add --join-token:
sudo wgctl join 'wgctl-join-v1.<token>'This writes /etc/wireguard/wg0.conf and enables wg-quick@wg0 via
systemd so the tunnel comes back up automatically after a reboot.
To use a different interface name:
sudo wgctl join 'wgctl-join-v1.<token>' --interface wg1To leave the overlay:
sudo wgctl join rm [-y] # stops wg-quick@wg0 and removes the config
sudo wgctl join rm --interface wg1For non-Linux peers (mobile, Windows, macOS), use the .conf export on the
hub and import it into the WireGuard app:
sudo wgctl peer add my-phone --output phone.confStarting and stopping tunnels
Start or stop an existing tunnel without changing its configuration or systemd autostart setting:
sudo wgctl up [--interface <name>] # start a stopped tunnel (hub or peer)
sudo wgctl down [--interface <name>] # stop a running tunnel, keep configBoth commands work on any wgctl-managed unit — the wg-quick@<iface> unit
used by joined peers or the legacy wgctl-<iface> hub unit.
Checking status
On the hub:
sudo wgctl statusOn a joined peer, use standard WireGuard tools:
sudo wg show wg0
systemctl status wg-quick@wg0Docker / Kubernetes
wgctl normally manages the tunnel through a systemd unit (wg-quick@<iface>
or, on the hub, wgctl service ...). Containers don't have systemd, so
every command that would otherwise touch it — setup, join, up,
down, service — detects that (via /run/systemd/system) and drives
wg-quick directly instead. Nothing else about the CLI changes.
CI publishes an image on every release to ghcr.io/alexanderslaa/wgctl,
tagged with the release version and latest (a package GHCR creates as
private the first time it's pushed — flip it to public in the repo's
Packages settings if it should be pullable without auth):
docker pull ghcr.io/alexanderslaa/wgctl:latestOr build it yourself from the Dockerfile in this repo:
docker build -t wgctl:latest .The image's entrypoint has two modes:
- Admin passthrough —
docker run wgctl:latest peer add alice --join-token(orkubectl exec <pod> -- wgctl peer ls) runs that subcommand and exits, same as running it on a host. - Default (
CMD ["hub"]) — on first start, runswgctl joinifJOIN_TOKENis set, otherwisewgctl setup --yesusingWG_INTERFACE/WG_LISTEN_PORT/WG_SUBNET/PUBLIC_HOSTenv vars; on later starts (config already present, e.g. on a mounted volume) it just brings the interface up. Either way it then stays in the foreground and brings the interface down cleanly onSIGTERM.
Run a hub:
docker run -d --name wgctl-hub \
--cap-add NET_ADMIN --cap-add NET_RAW \
--sysctl net.ipv4.ip_forward=1 \
-p 51820:51820/udp \
-e PUBLIC_HOST=vpn.example.com \
-v wgctl-wireguard:/etc/wireguard -v wgctl-data:/etc/wgctl \
wgctl:latestRun a peer that joins with a token. The token embeds the peer's private
key, so treat it like a secret: pass it via --env-file (a chmod 600
file, not committed) rather than -e, which leaks it into docker inspect
and your shell history:
echo "JOIN_TOKEN=wgctl-join-v1...." > wgctl-peer.env && chmod 600 wgctl-peer.env
docker run -d --name wgctl-peer \
--cap-add NET_ADMIN --cap-add NET_RAW \
--env-file wgctl-peer.env \
-v wgctl-wireguard:/etc/wireguard \
wgctl:latest
rm wgctl-peer.envRequirements either way: the host/node kernel needs the WireGuard module
(most kernels ≥5.6 have it built in or loadable — this is not something the
container can provide), and the container needs CAP_NET_ADMIN +
CAP_NET_RAW plus a writable net.ipv4.ip_forward sysctl in its network
namespace (--sysctl on plain Docker; a pod-level securityContext.sysctls
entry on Kubernetes — only needed on the hub, which forwards traffic for
peers' advertised routes). Neither requires --privileged.
Docker Compose
Example Compose files are in deploy/docker-compose/.
hub.yml runs a standalone hub — set PUBLIC_HOST, then:
docker compose -f deploy/docker-compose/hub.yml up -d
docker compose -f deploy/docker-compose/hub.yml exec wgctl-hub wgctl peer add alice --join-tokenapp-with-vpn.yml is the sidecar pattern for an app that needs the VPN
(e.g. vLLM reaching a service that only lives on the overlay) — put a
generated JOIN_TOKEN in wgctl-peer.env (chmod 600, not committed)
next to it, then:
docker compose -f deploy/docker-compose/app-with-vpn.yml up -dIt uses network_mode: "service:wgctl" on the app container — Compose's
equivalent of Kubernetes' shared-Pod-netns, since Compose has no per-pod
network isolation of its own. Once wgctl brings wg0 up, the app
container reaches the overlay directly with no proxying and no changes on
its side, at the cost that any port the app needs published has to go on
the wgctl service's ports: instead of its own (they share one network
stack, so only its owner can publish).
Kubernetes
Example manifests are in deploy/k8s/:
hub.yaml (a StatefulSet hub behind a LoadBalancer Service),
peer.yaml (a standalone Pod using the sidecar pattern below), and
deployment-sidecar.yaml (the same sidecar added to a Deployment — the
one to copy from for a real app). Read the comments at the top of each
file before applying — in particular, PUBLIC_HOST has a chicken-and-egg
dependency on the hub Service's external address.
Adding the VPN sidecar to an existing Deployment
The common case (e.g. vLLM needing to reach a model/service that only
lives on the VPN): add wgctl as a sidecar in the same Pod as your app,
rather than running it as its own Deployment. Containers in a Pod share
one network namespace, so once the sidecar brings wg0 up, the app
container reaches the overlay directly — no proxying, no client-side
networking changes, no extra Service.
Generate a join token on the hub (one per app instance is fine; tokens don't expire — see Security considerations):
kubectl exec -it wgctl-hub-0 -- wgctl peer add my-vllm-deploy --join-tokenStore it as a Secret — never inline it in the Deployment YAML:
kubectl create secret generic wgctl-peer-token --from-literal=JOIN_TOKEN='wgctl-join-v1....'Add the sidecar to your Deployment's pod template (
spec.template.spec): aninitContainersentry for wgctl withrestartPolicy: Always(this is what makes it a native sidecar — starts before, keeps running alongside, the app container), astartupProbeso the app container doesn't race the tunnel coming up, and a scratch volume for wgctl's config. The app container itself needs no changes — no capabilities, no volume mount, nothing:spec: template: spec: initContainers: - name: wgctl image: your-registry/wgctl:latest restartPolicy: Always env: - name: WG_INTERFACE value: wg0 - name: JOIN_TOKEN valueFrom: secretKeyRef: name: wgctl-peer-token key: JOIN_TOKEN securityContext: capabilities: add: ["NET_ADMIN", "NET_RAW"] volumeMounts: - name: wireguard-conf mountPath: /etc/wireguard startupProbe: exec: command: ["sh", "-c", "wg show \"$WG_INTERFACE\""] periodSeconds: 2 failureThreshold: 30 containers: - name: vllm # your existing app container, unchanged # ... volumes: - name: wireguard-conf emptyDir: {} # token embeds the private key, so this is safe to lose on rescheduleThe full, copy-pasteable version is
deploy/k8s/deployment-sidecar.yaml.Apply it:
kubectl apply -f your-deployment.yaml. Watch the sidecar come up withkubectl logs <pod> -c wgctl; oncestartupProbepasses the app container starts and can reach addresses on the overlay/advertised subnets.
Native sidecars (restartPolicy: Always on an initContainer) need
Kubernetes 1.29+. On older clusters, put the wgctl container in
containers: instead and drop restartPolicy / startupProbe — it'll
still work, but container start order within a Pod isn't guaranteed, so
the app should retry its VPN-side connections rather than assume the
tunnel is already up.
Scope what the app can reach: generate the join token with
wgctl peer add ... --routes <cidr,...> on the hub so AllowedIPs covers
only the overlay/advertised subnets the app actually needs, not
0.0.0.0/0 — otherwise all of the app container's traffic gets pulled
through the tunnel.
Updating
sudo wgctl update # checks npm for a newer version, asks for confirmation (-y to skip)Installing a new version does not interrupt running tunnels — the kernel keeps WireGuard interfaces and peers up independently of the wgctl process. The update takes effect once you restart the service:
sudo wgctl service restartUninstalling
sudo wgctl uninstall # stops services, removes unit/env files
sudo wgctl uninstall --purge-data # also removes SQLite DB, WireGuard conf, sysctl files
sudo wgctl uninstall --client-config # also removes ~/.config/wgctl client sessions/keys
sudo wgctl uninstall --npm # also runs npm uninstall -g wgctlFlags can be combined. Use -y to skip confirmation prompts.
Security considerations
Share join tokens over secure channels. Each token contains the peer's WireGuard private key as a base64 blob. Anyone who sees the token before it is used can impersonate that peer. Use SSH, an encrypted messenger, or similar — not plain email or chat.
Tokens do not expire. A generated but unused token stays valid indefinitely. If you generate one and no longer need it, remove the peer with
wgctl peer rm <label>to invalidate it.IP forwarding is enabled globally. The
PostUprule inwg0.confrunssysctl -w net.ipv4.ip_forward=1, which enables packet forwarding for all interfaces on the host, not justwg0. The iptablesFORWARDrule is scoped to the WireGuard interface only, so other traffic is still subject to your existing iptables policy.The SQLite database is world-readable by default.
/etc/wgctl/<iface>.sqlitecontains peer public keys and pre-shared keys. Restrict its permissions after setup:chmod 600 /etc/wgctl/wg0.sqlite
Contributing
See CONTRIBUTING.md for building from source, the project layout, and the release process.
