VPN status monitoring: WireGuard + IKEv2 in real time
Published: 2026-06-02
The VPN page on this site shows live peer data — which WireGuard clients are online, when they last did a handshake, how much traffic they've passed, and which IKEv2 sessions strongSwan is currently holding. Here's how that works.
Two VPN protocols
The server runs two independent VPN stacks:
- WireGuard — modern, fast, kernel-level. Clients connect on UDP 51820. Each peer has a static IP inside the
10.0.0.0/24tunnel network. - IKEv2 / IPsec (strongSwan) — useful for iOS, macOS, and Windows which support it natively without any extra app. Clients get a pool address from strongSwan's internal range.
Both are set up via the k0s-setup scripts (04-setup-vpn.sh, 08-setup-wireguard.sh) and configured on the host, not inside the cluster.
The status collector
A Python script lives at /usr/local/bin/vpn-status-update on the host. A systemd timer fires it every 30 seconds. It does four things:
-
WireGuard dump — runs
wg show wg0 dumpand parses the tab-separated output. For each peer it extracts: public key (first 8 chars shown), tunnel IP, last handshake timestamp, RX/TX bytes, and whether the peer is "online" (handshake within the last 3 minutes). -
WireGuard endpoint history — maintains a JSON file at
/var/lib/vpn-status/wg-history.json. Each time a peer shows a non-(none)endpoint, the timestamp and IP:port are appended. The history is trimmed to 24 hours. This gives a rough connection log without any external logging infrastructure. -
IKEv2 sessions — runs
swanctl --list-sas --rawand parses the output for active child SAs. -
Proxy stats — collects request counts from tinyproxy, SOCKS5, and Shadowsocks. All four data sets are merged into one JSON object and written atomically to
/var/lib/vpn-status/status.json.
The FastAPI app reads that file on every /vpn/status request — no database, no sockets, just a file read.
Serving config files and QR codes
The vpn/ directory in the repo contains client configuration files:
| File | Protocol | MIME type |
|---|---|---|
wireguard.conf |
WireGuard | application/octet-stream |
apple.mobileconfig |
IKEv2 for iOS/macOS | application/x-apple-aspen-config |
android.sswan |
IKEv2 for Android | application/vnd.strongswan.profile |
The router has an explicit allowlist (ALLOWED_VPN_FILES). Any filename not in that dict gets a 404. ipsec.env and other server-side secrets are never exposed.
For each allowed file, there's a /vpn/qr/{filename} endpoint. It encodes the file's text content as a QR code PNG and caches it in memory:
pythondef _make_qr(filename: str) -> bytes:
if filename not in _qr_cache:
content = (VPN_DIR / filename).read_text()
img = qrcode.make(content)
buf = io.BytesIO()
img.save(buf, format="PNG")
_qr_cache[filename] = buf.getvalue()
return _qr_cache[filename]
The cache is process-scoped, so it survives across requests but resets on pod restart.
"What's my IP" endpoint
/vpn/myip returns the caller's real IP and a city/country lookup from ip-api.com. This is done server-side to avoid CSP issues with fetching from the browser. Private IPs (RFC 1918 and IPv6 ULA) skip the geolocation call. The response is Cache-Control: no-store so browsers never cache it.
Why not use a VPN management UI?
Tailscale, Headscale, WireGuard Easy — they all work, but they add complexity and processes I'd need to maintain. The current setup is three files and a 100-line Python script. The status page is read-only; I add peers by editing wg0.conf directly.
The systemd timer
ini# /etc/systemd/system/vpn-status-update.service
[Unit]
Description=Update VPN status JSON
[Service]
Type=oneshot
ExecStart=/usr/local/bin/vpn-status-update
User=root
# /etc/systemd/system/vpn-status-update.timer
[Unit]
Description=Run vpn-status-update every 30 seconds
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
[Install]
WantedBy=timers.target
bashsystemctl enable --now vpn-status-update.timer
systemctl list-timers vpn-status-update.timer
WireGuard peer management
Adding a new peer:
bash# Generate keys on the server
wg genkey | tee /tmp/peer_private | wg pubkey > /tmp/peer_public
# Add to wg0.conf
cat >> /etc/wireguard/wg0.conf << EOF
[Peer]
PublicKey = $(cat /tmp/peer_public)
AllowedIPs = 10.0.0.5/32
EOF
# Apply without restarting
wg addconf wg0 <(echo "[Peer]"; echo "PublicKey = $(cat /tmp/peer_public)"; echo "AllowedIPs = 10.0.0.5/32")
# Generate client config
cat > /tmp/client.conf << EOF
[Interface]
PrivateKey = $(cat /tmp/peer_private)
Address = 10.0.0.5/32
DNS = 1.1.1.1
[Peer]
PublicKey = $(wg show wg0 public-key)
Endpoint = $(curl -s ifconfig.me):51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
EOF
Status JSON structure
The status file at /var/lib/vpn-status/status.json:
json{
"wireguard": {
"peers": [
{
"public_key_short": "abc12345",
"tunnel_ip": "10.0.0.2",
"last_handshake": 1720000000,
"online": true,
"rx_bytes": 1234567,
"tx_bytes": 987654
}
]
},
"ikev2": {
"sessions": [
{
"id": "1",
"remote_ip": "1.2.3.4",
"user": "vpnuser"
}
]
},
"updated_at": "2026-06-02T14:30:00Z"
}
The FastAPI endpoint returns this verbatim (after auth check), letting the frontend JS render the table.
What can go wrong
wg show wg0 dump returns nothing.
If WireGuard isn't running or the interface name is different (e.g. wg1), the collector writes an empty peers array. Check wg show directly on the host and update the script's interface name.
swanctl --list-sas --raw fails with "connecting to charon socket failed".
strongSwan's vici socket is at /run/charon.vici. If strongSwan isn't running or the socket path changed, the IKEv2 section in the status JSON will be empty. Verify with systemctl status strongswan.
Status page shows stale data.
The systemd timer runs vpn-status-update every 30 seconds. If the service fails (Python exception), the timer marks the unit as failed and stops re-firing. Check with systemctl status vpn-status-update and journalctl -u vpn-status-update -n 20.
Summary
- A Python oneshot service + systemd timer polls WireGuard and IKEv2 every 30 seconds
- All data is merged into a single JSON file; FastAPI reads it on every status request — no database or push needed
- WireGuard peer "online" threshold is 3 minutes since last handshake
- Endpoint history per peer is kept in a separate JSON, trimmed to 24 hours
- Config files (
.conf,.mobileconfig,.sswan) are served via an explicit allowlist to prevent leaking server secrets