WireGuard / VPN
A p2proxy server can speak WireGuard instead of SOCKS5. Set protocol: WireGuard on a server and the daemon binds a UDP WireGuard listener, terminates the tunnel in userspace, and bridges every flow inside it out through a Bitping exit peer.
The practical difference from SOCKS5: you route a whole device rather than configuring each application. Anything with a WireGuard client — a phone, a laptop, a TV, an OpenWrt router — gets a peer’s IP with no per-app proxy settings.
How it works
p2proxy is the WireGuard server; your devices are its peers. There’s no kernel module, no wg-quick and no TUN device on the p2proxy host — the tunnel is terminated in process, so the daemon needs no root and no NET_ADMIN.
- Your client encrypts a packet and sends it to p2proxy’s UDP port.
- p2proxy decrypts it and runs it through a userspace network stack, which terminates each TCP connection and UDP flow individually.
- Each flow is bridged to the exit peer over the Bitping network — TCP over
/bitping-tcp-forwarder, UDP over/bitping-udp-forwarder. - The exit peer makes the connection from its own IP, and replies come back the same way.
Exit peers are chosen by the same options as any other server — country, city, isp, asn, min_bandwidth, destination_peers, sticky. See Configuration.
A listener has one exit peer at a time. Every client connected to it shares that egress IP. For a second egress, add a second
WireGuardserver on another port.
Generate the keys
p2proxy takes raw keys, not a wg config file, and it never logs or prints its own public key — so save the public half when you generate it:
wg genkey | tee server.key | wg pubkey > server.pub # p2proxy's interface
wg genkey | tee laptop.key | wg pubkey > laptop.pub # one pair per device
wg genpsk > laptop.psk # optional, per device
server.key→ the server’sprivate_key.server.pub→ the client’s[Peer] PublicKey.laptop.pub→ the server’speers[].public_key.laptop.key→ the client’s[Interface] PrivateKey.- A preshared key is optional but must match on both sides exactly, or the handshake fails.
Treat every key as a credential: anything that completes a handshake spends your account’s bandwidth.
Configure the server
bitping_api_key: <your-key>
servers:
- protocol: WireGuard
port: 51820
host: 0.0.0.0 # reachable from your other devices; firewall it
country: NL # the usual peer filters apply
min_bandwidth: 100Mbps
wireguard:
private_key: <contents of server.key>
address: 10.7.0.1/24
dns: 1.1.1.1
mtu: 1420
peers:
- public_key: <contents of laptop.pub>
preshared_key: <contents of laptop.psk>
allowed_ips:
- 10.7.0.2/32
host defaults to 127.0.0.1, which no other device can reach — set it to 0.0.0.0 (or a specific LAN address) when clients live elsewhere, and firewall the port.
Two mistakes abort startup rather than failing quietly: protocol: WireGuard with no wireguard: block, and a wireguard: block on a Socks5 server.
Connect a client
The matching config on the laptop — wg-quick up ./laptop.conf, or import the file into the macOS / iOS / Android app:
[Interface]
PrivateKey = <contents of laptop.key>
Address = 10.7.0.2/32
DNS = 1.1.1.1
[Peer]
PublicKey = <contents of server.pub>
PresharedKey = <contents of laptop.psk>
Endpoint = <host running p2proxy>:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
Then confirm the egress:
curl https://ifconfig.me # the peer's IP, not yours
AllowedIPs = 0.0.0.0/0, ::/0 on the client is what sends all of its traffic into the tunnel. Read the next section before you use it on the machine running p2proxy.
Gotchas
Never point a machine’s own client at a p2proxy running on it
This is the one that bites everyone. Run p2proxy on a machine, connect that same machine’s WireGuard client to it with AllowedIPs = 0.0.0.0/0, and the box loses all connectivity the moment the tunnel comes up.
The loop: AllowedIPs = 0.0.0.0/0 installs a default route into the tunnel. p2proxy’s own upstream traffic — libp2p connections to the exit peer, the bootstrap node, grpc.bitping.com — is ordinary userspace traffic on that machine, so it follows that default route straight into the tunnel p2proxy is itself serving. The daemon decrypts its own packets and tries to bridge them to an exit peer, which needs the very connections now trapped in the loop. Nothing reaches the network, the tunnel never carries a useful packet, and the daemon can’t reconnect to dig itself out.
wg-quick protects the tunnel’s own handshake datagrams with an fwmark and a suppress_prefixlength 0 rule, so people assume this case is handled. It isn’t: that rule only covers packets the WireGuard interface sends to the Endpoint. p2proxy’s libp2p sockets are unmarked, so they get swallowed like any other traffic.
Symptom: connectivity dies the instant wg-quick up returns, the TUI’s peer count falls to zero, and wg-quick down brings everything straight back. If that’s what you’re seeing, this is it.
Fixes, best first:
-
Run p2proxy somewhere else — another machine, a home server, a VM on a different host, a router. This is the intended topology: p2proxy is the VPN server, your devices are clients. It’s also the only fix that needs no routing surgery.
-
Don’t take the default route. Narrow the client’s
AllowedIPsto just the destinations you want tunnelled instead of0.0.0.0/0. The p2proxy host keeps its own path to the internet, so the loop never forms — at the cost of “everything goes through the peer”. -
Linux, advanced: keep p2proxy’s traffic out of the tunnel with policy routing. Run the daemon as a dedicated user and add a rule, ahead of the ones
wg-quickinstalls, that sends its packets to the normal routing table:ip rule add uidrange 998-998 lookup main priority 100This is the same trick
wg-quickuses for its own handshake, applied to the daemon. The exact incantation depends on your distro, your firewall and how the unit runs, so treat it as a sketch rather than a recipe.
Docker on the same host doesn’t help: a container NATs out through the host’s routing table, so it lands in exactly the same loop.
The listener is loopback-only until you say otherwise
host defaults to 127.0.0.1. A remote client pointed at a loopback listener simply times out — nothing arrives, so there’s nothing in the log. Set host: 0.0.0.0, open the UDP port, and in Docker publish it as -p 51820:51820/udp.
Handshakes are billed to you
Any device that completes a handshake relays traffic through the peer network on your API key. Firewall the port to the addresses you expect, use a preshared key, and remove peers entries for devices you no longer use. p2proxy has no other access control on the tunnel.
allowed_ips on the server is routing, not permission
It’s a routing and anti-spoofing table, not a list of what the client may reach. p2proxy sends a tunnelled packet to the first peer whose allowed_ips contains the destination, and drops any packet whose source isn’t in the sending peer’s allowed_ips.
So the 0.0.0.0/0 catch-all belongs in the client’s [Peer] AllowedIPs, never as the first entry on the server’s peer list — there it swallows the return traffic of every other client.
NAT’d clients need PersistentKeepalive
p2proxy replies to the address it last saw a packet arrive from; it never initiates. A client behind NAT that goes quiet loses its mapping and stops receiving. PersistentKeepalive = 25 in the client’s [Peer] block keeps it alive — it’s in the sample config for a reason.
Config is read once, at startup
Adding a peer, changing allowed_ips, moving to a different country: all require a restart of the daemon. There’s no reload signal.
One exit peer per listener
Clients on the same listener share one egress IP, and sticky (on by default) keeps that same peer across restarts. If you want per-device IPs, run one WireGuard server per device on its own port, each with its own filters.
Multiple clients
One listener can serve any number of devices. Give each its own key pair and its own address inside the tunnel:
wireguard:
private_key: <contents of server.key>
address: 10.7.0.1/24
peers:
- public_key: <contents of laptop.pub>
allowed_ips:
- 10.7.0.2/32
- public_key: <contents of phone.pub>
allowed_ips:
- 10.7.0.3/32
- public_key: <contents of router.pub>
allowed_ips:
- 10.7.0.4/32
Each peer gets a /32 (a /128 for IPv6) matching that device’s [Interface] Address, so return traffic routes to the right client. They all egress from the same exit peer.
For a dual-stack tunnel, give the interface both families:
wireguard:
private_key: <contents of server.key>
addresses:
- 10.7.0.1/24
- fd00::1/64
peers:
- public_key: <contents of laptop.pub>
allowed_ips:
- 10.7.0.2/32
- fd00::2/128
DNS and UDP
DNS is not pushed to clients. WireGuard has no mechanism for that — the DNS = line in the client’s own [Interface] is what your OS uses. The dns key on the server is something else: the resolver used by the DNS-over-TCP fallback below.
UDP is forwarded natively, but exit peers still running an older node build only speak the TCP forwarder. The first UDP flow to such a peer marks it UDP-incapable — it stays a perfectly good TCP exit, and from then on:
- DNS (port 53) is transparently retried over TCP against the server’s
dns, or against whatever resolver the client addressed ifdnsis unset. - All other UDP — QUIC/HTTP3, WebRTC, game traffic, VoIP — is dropped.
Browsers fall back from QUIC to TCP TLS on their own, so web traffic is unaffected and this usually goes unnoticed. If you need UDP end-to-end, watch p2proxy_wg_udp_peer_incapable_total and pin to peers you know are current with destination_peers.
Tuning throughput
Two knobs matter, and the defaults are right for most paths:
mtu(default1420). The bridge also clamps the TCP MSS, so lower it only when you’re tunnelling over something already encapsulated. Pages loading part-way then stalling is the classic MTU symptom — try1380.tcp_buffer_bytes(default262144, 256 KiB). This is the receive+send window for each TCP flow. Throughput per flow is roughly window ÷ round-trip time, so a distant exit peer needs a bigger window to fill the pipe. Measured on a single path: 64 KiB ≈ 14 Mbit/s, 1 MiB ≈ 20 Mbit/s.
Memory is roughly 2 × buffer × concurrent flows, so raising it to 1 MiB with hundreds of flows is real RAM. If throughput is the problem, also try a country closer to you — RTT dominates.
Config reference
wireguard keys
| Key | Type | Default | Description |
|---|---|---|---|
private_key |
string | — | Required. The tunnel interface’s private key — standard WireGuard base64 of 32 bytes (wg genkey). Never logged. |
address / addresses |
CIDR / list | — | Required. The server’s address inside the tunnel, in CIDR form (10.7.0.1/24). address: takes a single value, addresses: a list — give both an IPv4 and an IPv6 CIDR for a dual-stack tunnel. |
dns |
IP | — | Resolver used by the DNS-over-TCP fallback. Not pushed to clients. |
mtu |
u16 | 1420 |
Tunnel MTU. The TCP MSS is clamped as well, so the default suits almost every path. |
tcp_buffer_bytes |
number | 262144 |
Per-flow TCP window. Raise for long-RTT exits, lower if many concurrent flows are eating RAM. |
peers |
list | — | Required. One entry per client device. |
wireguard.peers keys
| Key | Type | Default | Description |
|---|---|---|---|
public_key |
string | — | Required. The client’s public key (wg pubkey < laptop.key). |
preshared_key |
string | — | Optional extra symmetric key (wg genpsk). Must match the client’s PresharedKey exactly. |
allowed_ips |
list | — | Required. CIDRs owned by this client inside the tunnel. Routing and anti-spoofing, not permissions — see Gotchas. |
Everything else on the server — port, host, country, asn, sticky, destination_peers, pool — behaves exactly as it does for a SOCKS5 server. See Configuration.
Metrics
The tunnel has its own Prometheus series alongside the usual ones:
curl -s http://localhost:9091/metrics | grep -E 'p2proxy_wg_'
| Series | Notes |
|---|---|
p2proxy_wg_flows_accepted_total |
Flows taken out of the tunnel. Stuck at 0 means no packet ever made it through — the problem is the handshake or allowed_ips, not the peer network. |
p2proxy_wg_tcp_flows_active, p2proxy_wg_udp_flows_active |
Flows currently bridged. |
p2proxy_wg_decap_errors_total |
Datagrams that failed to decrypt. A steady rate means a key or preshared-key mismatch — or just internet noise hitting an exposed port. |
p2proxy_wg_spoofed_source_total |
Packets whose source wasn’t in the sending peer’s allowed_ips. |
p2proxy_wg_egress_unrouted_total |
Return packets matching no peer’s allowed_ips — usually a CIDR that’s too narrow. |
p2proxy_wg_udp_peer_incapable_total, p2proxy_wg_udp_fallback_dns_total |
Exit peers that only speak TCP, and DNS queries retried over TCP as a result. |
p2proxy_wg_udp_dropped_total |
UDP discarded because the exit couldn’t carry it. |
p2proxy_wg_rejected_no_peer_total, p2proxy_wg_udp_no_peer_total |
Flows dropped with no exit peer available. |
Next
- Configuration — every other
Config.yamlfield, peer filters, sticky and pinned peers. - Operations — systemd, Docker (remember
/udp), Prometheus. - Troubleshooting — symptom → cause → fix, including the WireGuard rows.