Autonomous WireGuard mesh VPN with P2P, relay fallback, and MCP server
Project description
wire
wire is a self-hosted WireGuard mesh VPN — like Tailscale, but you own every component.
Any machine (VPS, home server, laptop, cloud instance) installs the same wire_client.py. One server runs wire_server.py as the coordination server. Every node registers with the server, discovers peers, and establishes direct encrypted tunnels — the coordination server only facilitates introductions; your traffic never passes through it.
Table of Contents
- Architecture
- How It Works — Step by Step
- VPN IP Assignment
- NAT Traversal and Hole Punching
- UDP STUN — NAT Port Discovery
- Keeping Connections Alive
- Installation
- Usage — CLI
- Usage — MCP (Claude AI)
- Configuration
- Server API Reference
- File Reference
- Design Principles
Architecture
┌────────────────────────────────────────┐
│ Coordination Server (one always-on) │
│ wire_server.py │
│ HTTP :8787 — API │
│ UDP :8788 — STUN (NAT discovery) │
│ │
│ Knows: who exists, where they are │
│ Does NOT carry VPN traffic │
└──────────┬─────────────┬──────────────┘
│ │
registers / │ │ registers /
heartbeat │ │ heartbeat
│ │
┌────────────▼──┐ ┌──▼────────────┐
│ Node A │ │ Node B │
│ wire_client │ │ wire_client │
│ 10.99.x.x │ │ 10.99.y.y │
└──────┬────────┘ └────────┬───────┘
│ │
└────── direct P2P ───────┘
WireGuard tunnel
end-to-end encrypted
coordination server not involved
Three files, three roles:
| File | Role | Where it runs |
|---|---|---|
wire_server.py |
Coordination server + STUN | One always-on server |
wire_client.py |
VPN daemon + CLI | Every node |
wire_mcp_server.py |
MCP interface for Claude | Machines with Claude Desktop |
How It Works
Here is the full flow using generic examples.
Example network:
server1 = always-on VPS (runs wire_server.py, has public IP, no NAT)
node1 = server or desktop, direct public IP, no NAT
node2 = laptop, behind home router (NAT — no direct public IP)
node3 = home server, behind router (NAT — no direct public IP)
Step 1 — Start the coordination server
# on server1
python3 wire_server.py
# or specify port:
python3 wire_server.py 8787
wire server v2.2.0
HTTP :8787 — register / peers / status / health / ip / punch
UDP :8788 — STUN (NAT port discovery)
No nodes registered yet:
curl http://SERVER1_IP:8787/status
# → { "total": 0, "online": 0, "nodes": [] }
Step 2 — node1 joins the network
# on node1
sudo python3 wire_client.py up \
--server http://SERVER1_IP:8787 \
--name node1
What happens internally:
① Key generation (once, then reused)
/etc/wire/private.key ← never leaves this machine
/etc/wire/public.key ← shared with coordination server
② NAT port discovery via UDP STUN
Before WireGuard starts, the client opens a UDP socket on port 51820 and sends a probe to the server's STUN port (8788):
node1 UDP :51820 → SERVER1_IP:8788
server sees source: PUBLIC_IP:51820 (no NAT on node1, same port)
server replies: {"ip": "PUBLIC_IP", "port": 51820}
The socket closes. WireGuard will use the same port.
③ VPN IP assignment (deterministic, no DHCP)
node_id = sha256(hostname + mac_address)[:32]
vpn_ip = f"10.99.{hash[0]}.{hash[1]}" # e.g. 10.99.23.187
Same machine → same VPN IP, every time. No central allocation needed.
④ WireGuard interface brought up
# Linux
ip link add wire0 type wireguard
ip addr add 10.99.23.187/16 dev wire0
wg setconf wire0 /etc/wireguard/wire0.conf
ip link set wire0 up
# macOS
wireguard-go utun9
wg setconf utun9 ...
ifconfig utun9 inet 10.99.23.187 ...
⑤ Registration with coordination server
POST SERVER1_IP:8787/register
{
"node_id": "a1b2c3...",
"node_name": "node1",
"wg_public_key": "XYZ...",
"port": 51820,
"nat_port": 51820, ← discovered via UDP STUN
"lan_ip": "10.0.0.2"
}
Server records the node and replies:
{ "ok": true, "vpn_ip": "10.99.23.187", "your_ip": "PUBLIC_IP_OF_NODE1" }
⑥ Background daemon starts
A thread runs silently: every 30 seconds it heartbeats /register and syncs peers from /peers into WireGuard.
Step 3 — node2 joins (behind NAT)
# on node2 (laptop behind home router)
sudo python3 wire_client.py up \
--server http://SERVER1_IP:8787 \
--name node2
node2 is behind NAT. Its internal address is 192.168.x.x. It does not know its public IP or what port its router assigned to its WireGuard traffic.
UDP STUN probe from node2:
node2 UDP :51820 → SERVER1_IP:8788
node2's router NAT table:
internal 192.168.x.x:51820 → external ROUTER_IP:54321
server sees source: ROUTER_IP:54321
server replies: {"ip": "ROUTER_IP", "port": 54321}
node2 now knows its real external UDP endpoint: ROUTER_IP:54321.
node2 sends /register with nat_port: 54321. The coordination server stores this.
Step 4 — node1 and node2 discover each other
node1's daemon calls /peers and receives node2's entry:
{
"node_name": "node2",
"vpn_ip": "10.99.45.22",
"public_ip": "ROUTER_IP",
"nat_port": 54321,
"wg_public_key": "PQR..."
}
node1 applies this to WireGuard:
wg set wire0 \
peer PQR... \
allowed-ips 10.99.45.22/32 \
endpoint ROUTER_IP:54321 \
persistent-keepalive 25
node2 does the same for node1. Both WireGuard instances now have each other as peers with correct endpoints.
NAT Traversal and Hole Punching
When two nodes are both behind NAT, neither can receive incoming connections by default. WireGuard + PersistentKeepalive solves this:
node1 → UDP → node2's router (ROUTER2_IP:54321)
node2 → UDP → node1's router (ROUTER1_IP:44321)
node1's router NAT table entry: allow traffic from ROUTER2_IP:54321
node2's router NAT table entry: allow traffic from ROUTER1_IP:44321
Both packets arrive. Tunnel established.
This works for Full Cone, Restricted Cone, and Port-Restricted Cone NAT — which covers the vast majority of home routers. Symmetric NAT (rare, mostly corporate firewalls) requires the relay fallback (/punch → use_relay: true).
The coordination server's role ends here. It provided the addresses. It carries no VPN traffic.
UDP STUN
Standard STUN servers (e.g. Google's stun.l.google.com) are external services. wire has no external dependencies — the coordination server itself provides STUN over UDP.
Client: UDP socket bound to WireGuard port (51820)
→ sends probe to SERVER:8788
Server: observes source IP:port after NAT translation
→ responds with {"ip": "...", "port": ...}
Client: closes socket, WireGuard binds to same port
The UDP port (8788) is always HTTP_PORT + 1. Configurable via WIRE_PORT environment variable.
Why UDP, not TCP:
NAT routers maintain separate mapping tables for TCP and UDP. A TCP connection to port 8787 reveals the TCP NAT mapping. WireGuard uses UDP. The only way to see the UDP NAT mapping for port 51820 is to send a UDP packet from port 51820 and observe what the server receives.
Keeping Connections Alive
PersistentKeepalive = 25
Every peer gets this setting. A keepalive packet is sent every 25 seconds.
- Prevents NAT routers from expiring the UDP mapping (most expire after 30–120s of silence)
- Re-establishes the connection after an IP change (laptop switches from WiFi to mobile — next keepalive re-opens the path)
- No user action needed after initial setup
Installation
Coordination server
# Copy wire_server.py to your always-on server
scp wire_server.py user@YOUR_SERVER:/opt/wire/
# Run
python3 /opt/wire/wire_server.py
# Or with a custom port
python3 /opt/wire/wire_server.py 8787
Systemd service (recommended for always-on servers):
[Unit]
Description=wire coordination server
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/wire/wire_server.py
Restart=always
RestartSec=5
Environment=WIRE_STATE_FILE=/etc/wire/state.json
[Install]
WantedBy=multi-user.target
systemctl enable --now wire-server
WireGuard tools (every node)
# Debian / Ubuntu
apt install wireguard wireguard-tools
# RHEL / Fedora / CentOS
dnf install wireguard-tools
# Alpine
apk add wireguard-tools
# macOS
brew install wireguard-tools wireguard-go
Run wire install to check your platform and get the right command.
Usage — CLI
wire up — join the network
First time (server URL and name required):
sudo wire up --server http://YOUR_SERVER:8787 --name NODENAME
After first run the config is saved. Subsequent starts need no arguments:
sudo wire up
Options:
| Flag | Default | Description |
|---|---|---|
--server / -s |
saved config | Coordination server URL |
--name / -n |
hostname | This node's name |
--port / -p |
51820 |
WireGuard listen port |
wire status — see the whole network
wire status
Output:
wire status http://YOUR_SERVER:8787
3 online / 1 offline / 4 total
● node1 10.99.23.187 203.0.113.10 5s ago
● node2 10.99.45.22 198.51.100.20 12s ago (this node)
● node3 10.99.87.3 192.0.2.30 8s ago
○ node4 10.99.200.5 203.0.113.40 14m ago
● online — heartbeat within 5 minutes
○ offline — no heartbeat for 5+ minutes, kept in list for 24 hours
wire status --json # machine-readable output
wire peers — list all registered nodes
wire peers
wire ping — ping a peer by name
wire ping node1
wire ping 10.99.23.187
Resolves node names to VPN IPs via the coordination server, then pings.
wire down — leave the network
sudo wire down
Removes the WireGuard interface and stops the daemon. The node will appear offline after 5 minutes.
wire install — check WireGuard installation
wire install
Checks if WireGuard tools are present and prints platform-specific install instructions if not.
Usage — MCP
Add wire_mcp_server.py to your Claude Desktop config:
{
"mcpServers": {
"wire": {
"command": "python3",
"args": ["/path/to/wire_mcp_server.py"]
}
}
}
Available tools:
| Tool | What it does |
|---|---|
wire_status |
Full network view — all nodes, online/offline, VPN IPs |
wire_up |
Bring up VPN tunnel |
wire_down |
Tear down VPN tunnel |
wire_peers |
List all registered peers |
wire_ping |
Ping a peer by name or VPN IP |
wire_install |
Check WireGuard installation |
wire_diagnose |
Full diagnostic: WG installed? server reachable? interface up? |
wire_watchdog |
Peer handshakes, stale connections, service status |
The MCP server imports all logic from wire_client.py. CLI and MCP call the same core functions — behavior is always identical between the two interfaces.
Configuration
Config file locations:
| Context | Path |
|---|---|
| Root / system daemon | /etc/wire/config.json |
| Regular user | ~/.wire/config.json |
Written automatically by wire up. Example:
{
"server_url": "http://YOUR_SERVER:8787",
"node_name": "NODENAME",
"node_id": "a1b2c3d4e5f6...",
"vpn_ip": "10.99.x.x",
"listen_port": 51820,
"nat_port": 54321
}
Environment variables (server-side):
| Variable | Default | Description |
|---|---|---|
WIRE_PORT |
8787 |
HTTP listen port (UDP STUN = this + 1) |
WIRE_VPN_SUBNET |
10.99 |
VPN IP prefix |
WIRE_STATE_FILE |
/etc/wire/state.json |
Peer state persistence path |
Server API Reference
All HTTP endpoints return JSON.
POST /register
Node heartbeat. Call every 30 seconds to stay online.
Request body:
{
"node_id": "string (SHA-256 of hostname+MAC, 32 hex chars)",
"node_name": "string (human name, e.g. myserver)",
"wg_public_key": "string (WireGuard public key, base64)",
"port": 51820,
"nat_port": 54321,
"lan_ip": "192.168.x.x (optional)"
}
nat_port is the WireGuard UDP port as seen from outside NAT, discovered via UDP STUN before calling this endpoint. If the node has no NAT, nat_port equals port.
Response:
{
"ok": true,
"vpn_ip": "10.99.x.x",
"your_ip": "1.2.3.4"
}
GET /status
All nodes (online and offline). Used by wire status.
Response:
{
"version": "2.2.0",
"total": 4,
"online": 3,
"offline": 1,
"nodes": [
{
"node_name": "node1",
"vpn_ip": "10.99.23.187",
"public_ip": "203.0.113.10",
"nat_port": 51820,
"status": "online",
"last_seen_ago": 5
}
]
}
GET /peers
Online nodes only. Used by the client daemon for WireGuard peer sync every 30 seconds.
GET /ip
Returns the caller's public IP (TCP). Quick check only — not for WireGuard port discovery (use UDP STUN for that).
{ "ip": "1.2.3.4" }
GET /health
{ "ok": true, "version": "2.2.0", "total": 4, "online": 3 }
POST /punch
NAT hole-punch coordination. Called when a direct connection attempt fails. After 3 attempts the server sets use_relay: true, signaling that a relay path should be used.
Request:
{ "from_vpn_ip": "10.99.x.x", "to_vpn_ip": "10.99.y.y" }
Response:
{ "ok": true, "use_relay": false, "attempts": 1 }
UDP STUN — port HTTP_PORT + 1
Send any UDP packet from your WireGuard port. Receive the NAT-mapped external IP:port.
Client → UDP packet (from port 51820) → SERVER:8788
Server → {"ip": "EXTERNAL_IP", "port": EXTERNAL_PORT}
File Reference
wire/
├── wire_server.py Coordination server + UDP STUN — run on one always-on server
├── wire_client.py VPN daemon + CLI — run on every node
│ Exports: cmd_status, cmd_up, cmd_down,
│ cmd_peers, cmd_ping, cmd_install
├── wire_mcp_server.py MCP wrapper for Claude AI
│ Imports core functions from wire_client.py
└── wire_agent.py (optional) agent utilities
/etc/wire/ (root) or ~/.wire/ (user)
├── config.json Node config — written by wire up
├── private.key WireGuard private key (chmod 600)
├── public.key WireGuard public key
└── state.json Server peer state — written by wire_server.py
Design Principles
No hardcoded values. No IP addresses, hostnames, or node names in the code. Everything comes from config files or CLI arguments.
No external dependencies. No Google STUN servers, no relay services, no cloud providers. The coordination server you run handles everything including NAT port discovery.
No central bottleneck. The coordination server handles only small JSON messages. VPN traffic flows directly between nodes.
Deterministic VPN IPs. Derived from each machine's own identity hash. No DHCP, no manual assignment, no conflicts.
Same logic everywhere. wire_client.py exports the same functions used by both the CLI and the MCP server. They always behave identically.
Offline tolerance. Nodes keep their WireGuard peers configured even when the coordination server is unreachable. Established tunnels survive server restarts.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file meshpop_wire-2.2.8-py3-none-any.whl.
File metadata
- Download URL: meshpop_wire-2.2.8-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eb35d1216c49aeaec541ebd8a0998ab09dbfce608943ad04c2654231974285dd
|
|
| MD5 |
8051090dd5b1e2c9f8af252bf7cb7435
|
|
| BLAKE2b-256 |
0f29c347f0f400f246f305d840c3246ed356b388cd01049dbf0f4549a10fa099
|