WireGuard P2P VPN
WireGuard is a super fast and simple VPN that makes it easy to set up secure, ad-hoc, private connections using the latest encryption tech. WireGuard’s design makes no distinction between “server” and “client” — every node is simply a peer. You could designate a particular node as a “server”, and build a hub-and-spoke architecture, or you can design a full mesh network where every node can talk to every other node. This post will focus on the latter.
The following Bash script sets up a p2p VPN between two or more Linux (systemd) machines (also Windows, macOS, Android, iOS, etc. via provisioned keys). The only network requirement is that each host has the ability to make outbound UDP connections and where the router doesn’t change the source port (i.e. Full Cone or Restricted Cone NAT). For most residential ISP connections, this will work out of the box. If your connection uses Symmetric NAT or CGNAT (typical in corporate, hotel, and mobile networks) this might not work so well.
The magic of this setup is that it works without needing to make any modifications to your home router – you don’t need to open any static ports, and you don’t need to pay for an external VPN server or provider!
Note: this script will create VPN routes only between the hosts that you specify. It will not modify your normal Internet connection to any other hosts.
Example
Lets say you have three Linux hosts, with the following hostnames and public IP addresses, all on different networks:
defiant
- 45.67.89.10enterprise
- 156.123.98.34voyager
- 23.47.88.14
If your hosts don’t have static IP addresses, you might want to set up dynamic DNS and use fully qualified domain names instead. For this example, we’ll just use the public IP addresses (most residential IP addresses tend to stay the same for long periods).
You must download the script onto each Linux host you want to join the VPN. The script will handle installing WireGuard if it’s not already installed (if your OS is unsupported, try installing WireGuard manually first).
On defiant
, run:
wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/wireguard/wireguard_p2p.sh
chmod +x wireguard_p2p.sh
./wireguard_p2p.sh install 10.15.0.1/24
This will give defiant
the VPN address of 10.15.0.1
.
It will print out the add-peer
command you need to run on the other
hosts, which includes the public endpoint and key for defiant
:
------------------------------------------------------------
To add THIS node as a peer on another WireGuard server using this script, run:
./wireguard_p2p.sh add-peer defiant 45.67.89.10:51820 du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI= 10.15.0.1
(Replace 'defiant' with your desired label for this peer.)
------------------------------------------------------------
Notes:
-
If you need to print the
add-peer
information again, run./wireguard_p2p.sh add-peer
with no other arguments. -
If you don’t have public static IP addresses, simply replace the IP address with the domain name (FQDN). Keep the
:51820
port the same. (e.g.host.example.com:51820
)
Don’t run the add-peer
command on the other hosts yet. You must
install the script on enterprise
and voyager
the same way as you
did on defiant
, except with different (sequential) VPN addresses:
- On
enterprise
:./wireguard_p2p.sh install 10.15.0.2/24
- On
voyager
:./wireguard_p2p.sh install 10.15.0.3/24
These commands will print out a similar add-peer
command with their
own endpoint and key. Gather all three add-peer
commands,
and then run them on each other host:
(Note: all of the IP addresses and public keys listed here are
examples for demonstration purposes. You should use the real
add-peer
command for your actual hosts instead!)
On defiant
: add enterprise
and voyager
:
./wireguard_p2p.sh add-peer enterprise 156.123.98.34:51820 Tx+JOAaZmGZCsE8qqy5AYFnXI7zksC4C2GOjfRlb8lk= 10.15.0.2
./wireguard_p2p.sh add-peer voyager 23.47.88.14:51820 xpW2S5aJaEj2JSbmRdUMBt12y1lhz003m5WKi70YOj4= 10.15.0.3
On enterprise
: add defiant
and voyager
:
./wireguard_p2p.sh add-peer defiant 45.67.89.10:51820 du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI= 10.15.0.1
./wireguard_p2p.sh add-peer voyager 23.47.88.14:51820 xpW2S5aJaEj2JSbmRdUMBt12y1lhz003m5WKi70YOj4= 10.15.0.3
On voyager
: add defiant
and enterprise
:
./wireguard_p2p.sh add-peer defiant 45.67.89.10:51820 du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI= 10.15.0.1
./wireguard_p2p.sh add-peer enterprise 156.123.98.34:51820 Tx+JOAaZmGZCsE8qqy5AYFnXI7zksC4C2GOjfRlb8lk= 10.15.0.2
Now that all three hosts have been installed, and have added each other peer in full mesh, the VPN should be up and fully functional!
Check the status on each peer. Just run wg
. For example, on
defiant
, it will list two peers:
root@defiant:~# wg
interface: wg0
public key: du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI=
private key: (hidden)
listening port: 51820
peer: xpW2S5aJaEj2JSbmRdUMBt12y1lhz003m5WKi70YOj4=
endpoint: 23.47.88.14:51820
allowed ips: 10.15.0.3/32
latest handshake: 16 seconds ago
transfer: 92 B received, 180 B sent
persistent keepalive: every 25 seconds
peer: Tx+JOAaZmGZCsE8qqy5AYFnXI7zksC4C2GOjfRlb8lk=
endpoint: 156.123.98.34:51820
allowed ips: 10.15.0.2/32
latest handshake: 16 seconds ago
transfer: 124 B received, 180 B sent
persistent keepalive: every 25 seconds
This shows that both of the other peers (enterprise
and voyager
)
are added. Unfortunately, it won’t show their hostnames, but it does
show their public keys.
The most important thing to look for is: latest handshake: X seconds ago
. If you don’t see latest handshake
, or if it shows a
time thats larger than a few minutes, then something is wrong, and
preventing p2p connection between the machines.
If all three machines show a good handshake, you should now be able to
ping each other host, e.g. on defiant
:
ping -c1 10.15.0.2
ping -c1 10.15.0.3
Thanks for reading, now you know how to setup a peer-to-peer VPN, between any number of hosts, all without needing to pay for any third party service!
Usage
Usage: ./wireguard_p2p.sh <command>
Commands:
dependencies Install required packages.
install <address-cidr> Install and configure WireGuard. Required first time.
uninstall Remove WireGuard configuration and keys.
status Show the WireGuard service status.
start Start the WireGuard service.
stop Stop the WireGuard service.
import-key PRIVATE_KEY Import a private key instead of generating one.
add-peer NAME ENDPOINT PUBLIC_KEY Add peer live and auto-save into config.
remove-peer PUBLIC_KEY Remove peer live and auto-save into config.
help Show this help message.
Outbound NAT issues
Even if you have verified that your ISP has given you a public, static IP address, with full Internet connectivity, you might still find that your router prevents this scenario from working as described.
pfSense
Here is one example with a pfSense router.
pfSense has four possible Outbound NAT settings, (but you only need to be concerned with the first two):
-
Automatic Outbound NAT
- this is the default setting, and is most useful for home Internet services. This will allow high levels of TCP and UDP traffic, from many clients, without interference. All outbound connections will be assigned a temporary random source port mapping between the LAN client and the WAN interface. The router will use this unique port mapping to create the bi-directional route between your client and destination for the duration of the call.This setting cannot be used with the WireGuard scenario we’ve described so far. This is because the outbound source UDP port must be static.
-
Hybrid Outbound NAT
- this setting is just likeAutomatic Outbound NAT
, except that it also lets you create exceptional rules for certain traffic routes. This lets you use the automatic mode for most traffic (random source ports), but will also set up a specific rule to let WireGuard use a static source port from a specific host.
To create a custom rule for WireGuard traffic, make sure to select
Hybrid Outbound NAT
.
Create a Host Alias for both peers:
-
For the local WireGuard peer, enter the private LAN IP address.
-
For the destination peer, enter the public FQDN or IP address.
Create the Static Port outbound NAT rule.
- Interface:
WAN
- Address Family:
IPv4+IPv6
- Protocol:
UDP
- Source: Choose
Network or Alias
and select the LAN host alias ({host}/32
) and choose the source port that WireGuard is listening to. - Destination: Choose
Network or Alias
and select the WAN host alias of your remote peer ({remote}/32
) and choose the destination port that WireGuard peer is listening to.
With this rule active, the source port on the receiving end will now always match the WireGuard listening port.
Troubleshooting NAT
You can verify the UDP source ports using tcpdump
:
tcpdump -n -i any udp
The output of tcpdump
shows packets both being sent and received,
with the source and destintations hosts and ports. To deduce whether
NAT is being (mis)applied, you must run this on both peers to get
their own perspective. Critically, for this scenario to work, both the
sender and receiver must see the same source ports. Whereas the IP
address will be translated public<->private, the ports are static.
Provisioning new peers
WireGuard supports many platforms beyond just Linux. However, this
Bash script is only designed for Linux. No problem though, because you
can use this script to create keys and config files using the standard
WireGuard .conf
file format, which is supported on Windows, macOS,
Linux, Android, and iOS.
For the most secure VPN, you should have each peer generate their own
key. In the context of this script, “provisioning” a peer means that
we are taking a shortcut, and generating a key on behalf of that peer
and providing it to them ahead of time, embedded into a .conf
file.
This is convenient to hand to your your trusted friend, moreso than
having to exchange keys. Another advantage is that it means we don’t
have to port the script to any other platforms – the provisioned
machines just need to install WireGuard and import the .conf
file.
Usage: ./wireguard_p2p.sh provision-peer <name> <endpoint> <address/CIDR>
Running the provision-peer
subcommand, on the Linux host, will:
-
Generate a fresh keypair for the other (provisioned) peer.
-
Emit a ready-to-import
.conf
file (complete with their new private key, VPN address, and the Linux peer’s public key and endpoint to connect to). -
Print the exact
add-peer
command to run on the Linux host to add the new peer connection.
Install the .conf file using WireGuard (GUI) on the other side.
Example of provisioning
For example, your friend has a Windows computer (or mac, android, ios, etc.):
borg
- 34.56.78.90
Generate a new key and config on their behalf:
./wireguard_p2p.sh provision-peer borg 34.56.78.90:51820 10.15.0.4/24
- Make sure to enter borg’s public IP address (e.g. 34.56.78.90) - Your friend should double check via ifconfig.me.
- The endpoint port (e.g. 51820) can be customized for their machine. You may need to experiment with different ports especially if its already being used for something else on their end.
- Enter an unused private IP address/CIDR for use on the VPN subnet.
The provision-peer
will output an add-peer
command that you must
copy, paste, and run, to add the peer connection:
./wireguard_p2p.sh add-peer borg 34.56.78.90:51820 o7iXZVberLzQclBG+9U4+BJozKVhOl3Mqgaj9MCLST8= 10.15.0.4/24
Grab the contents of the peer’s config file:
cat /etc/wireguard/provisioned_peers/borg.conf
### Example borg.conf connects to defiant
[Interface]
PrivateKey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=
Address = 10.15.0.2/24
ListenPort = 51820
[Peer]
PublicKey = du6ODGzyU742OIOMNjB3lu5nzUR4zxLnsrTuIrb1ZhI=
Endpoint = 45.67.89.10:51820
AllowedIPs = 10.15.0.1/24
PersistentKeepalive = 25
-
Copy the text from the config file and give it to your friend in an email or chat message.
-
Your friend needs to download WireGuard and install it.
-
They should save the config text into a file named
defiant.conf
(they should use the name of your peer, as this will be the name of the connection from their perspective). -
The config file can be imported via the WireGuard GUI. Activate the connection.
-
Tell your friend to look for the
handshake
, or how to ping your peer’s VPN address to check that everything is working.
The script
#!/bin/bash
######################################################
# WireGuard P2P #
# https://blog.rymcg.tech/blog/linux/wireguard_p2p #
######################################################
set -euo pipefail
###################################
# Configurable Environment Vars #
###################################
WG_INTERFACE="wg0"
WG_PORT="51820"
WG_CONFIG_DIR="/etc/wireguard"
###################################
# Internal Globals #
###################################
PRIVATE_KEY_FILE="$WG_CONFIG_DIR/privatekey"
PUBLIC_KEY_FILE="$WG_CONFIG_DIR/publickey"
CONFIG_FILE="$WG_CONFIG_DIR/${WG_INTERFACE}.conf"
SERVICE_NAME="wg-quick@${WG_INTERFACE}"
ADDRESS_FILE="$WG_CONFIG_DIR/address"
###################################
# Functions #
###################################
load_address() {
if [[ ! -f "$ADDRESS_FILE" ]]; then
echo "Error: Address file not found at $ADDRESS_FILE. Run install with an address." >&2
exit 1
fi
WG_ADDRESS=$(<"$ADDRESS_FILE")
}
dependencies() {
if command -v wg >/dev/null 2>&1; then
echo "WireGuard already installed. Skipping dependency installation."
return
fi
echo "Installing dependencies..."
if [ -f /etc/debian_version ]; then
apt update
apt install -y wireguard-tools wireguard curl
elif [ -f /etc/fedora-release ]; then
dnf install -y wireguard-tools curl
elif [ -f /etc/arch-release ]; then
pacman -Sy --noconfirm wireguard-tools curl
else
echo "Cannot detect platform. Please manually install the wireguard and curl packages for your system." >&2
exit 1
fi
}
generate_keys() {
echo "Generating WireGuard keys..."
mkdir -p "$WG_CONFIG_DIR"
chmod 700 "$WG_CONFIG_DIR"
cd "$WG_CONFIG_DIR"
if [[ ! -f "$PRIVATE_KEY_FILE" ]]; then
umask 077
wg genkey | tee privatekey | wg pubkey > publickey
echo "Keys generated."
else
echo "Keys already exist."
fi
}
create_base_config() {
echo "Creating base WireGuard config file..."
tee "$CONFIG_FILE" > /dev/null <<EOF
[Interface]
Address = $WG_ADDRESS
PrivateKey = $(<"$PRIVATE_KEY_FILE")
ListenPort = $WG_PORT
SaveConfig = true
EOF
echo "Base config created at $CONFIG_FILE"
}
get_add_peer_command() {
echo ""
echo "------------------------------------------------------------"
echo "To add THIS node as a peer on another WireGuard server using this script, run:"
echo ""
local public_key
public_key=$(<"$PUBLIC_KEY_FILE")
local public_ip
public_ip=$(curl -s ifconfig.me)
load_address
echo "./wireguard_p2p.sh add-peer $(hostname) ${public_ip}:${WG_PORT} ${public_key}" "${WG_ADDRESS%%/*}"
echo ""
echo "(Replace '$(hostname)' with your desired label for this peer.)"
echo "------------------------------------------------------------"
echo ""
}
add_peer() {
load_address
local name="$2"
local endpoint="$3"
local public_key="$4"
local peer_ip="$5"
if [[ -z "$name" || -z "$endpoint" || -z "$public_key" || -z "$peer_ip" ]]; then
echo "Error: name, endpoint, public_key, and peer_ip cannot be blank." >&2
exit 1
fi
echo "Adding peer to WireGuard interface..."
wg set "$WG_INTERFACE" peer "$public_key" \
allowed-ips "$peer_ip" \
endpoint "$endpoint" \
persistent-keepalive 25
echo "Peer added live. Restarting interface to save into config..."
wg-quick down "$WG_INTERFACE" || true
wg-quick up "$WG_INTERFACE"
echo "Peer added and saved: $name ($endpoint)"
}
remove_peer() {
local public_key="$2"
if [[ -z "$public_key" ]]; then
echo "Error: public_key cannot be blank." >&2
exit 1
fi
echo "Removing peer from WireGuard interface..."
wg set "$WG_INTERFACE" peer "$public_key" remove
echo "Peer removed live. Restarting interface to save into config..."
wg-quick down "$WG_INTERFACE" || true
wg-quick up "$WG_INTERFACE"
echo "Peer removed and config saved."
}
install() {
dependencies
generate_keys
local given_address="${2:-}"
if [[ -f "$ADDRESS_FILE" ]]; then
local saved_address
saved_address=$(<"$ADDRESS_FILE")
if [[ -n "$given_address" ]]; then
# Check that given address contains a "/" character
if [[ "$given_address" != */* ]]; then
echo "Error: Address must include a CIDR subnet (example: 10.10.0.1/24)" >&2
exit 1
fi
if [[ "$given_address" != "$saved_address" ]]; then
echo "Error: Address already exists at $ADDRESS_FILE and differs from the given one."
echo "Saved: $saved_address"
echo "Given: $given_address"
echo ""
echo "If you want to change the WireGuard address, you must uninstall first:"
echo ""
echo " ./wireguard_p2p.sh uninstall"
echo ""
exit 1
fi
# Addresses match; continue normally
fi
WG_ADDRESS="$saved_address"
echo "Loaded existing address: $WG_ADDRESS"
else
if [[ -z "$given_address" ]]; then
echo "Error: No address specified and no existing address file." >&2
echo "Usage: $0 install <your-address-in-cidr>"
exit 1
fi
# Check that given address contains a "/" character
if [[ "$given_address" != */* ]]; then
echo "Error: Address must include a CIDR subnet (example: 10.10.0.1/24)" >&2
exit 1
fi
WG_ADDRESS="$given_address"
mkdir -p "$WG_CONFIG_DIR"
echo "$WG_ADDRESS" | tee "$ADDRESS_FILE" > /dev/null
echo "Saved address to $ADDRESS_FILE"
fi
echo "Ensuring WireGuard service is fully stopped before reinstalling..."
wg-quick down "$WG_INTERFACE" || true
systemctl stop "$SERVICE_NAME" || true
create_base_config
echo "Bringing up WireGuard interface..."
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"
echo "WireGuard installed and service started."
get_add_peer_command
}
uninstall() {
echo "Stopping and disabling service..."
systemctl stop "$SERVICE_NAME" || true
systemctl disable "$SERVICE_NAME" || true
wg-quick down ${WG_INTERFACE} 2>/dev/null || true
echo "Removing config and keys..."
rm -f "$CONFIG_FILE" "$PRIVATE_KEY_FILE" "$PUBLIC_KEY_FILE" "$ADDRESS_FILE"
echo "WireGuard config and keys removed."
}
status() {
systemctl status "$SERVICE_NAME"
echo
wg
}
start() {
echo "Starting WireGuard service..."
systemctl start "$SERVICE_NAME"
}
stop() {
echo "Stopping WireGuard service..."
systemctl stop "$SERVICE_NAME"
}
help() {
cat <<EOF
Usage: $0 <command>
Commands:
dependencies Install required packages.
install <address-cidr> Install and configure WireGuard. Required first time.
uninstall Remove WireGuard configuration and keys.
status Show the WireGuard service status.
start Start the WireGuard service.
stop Stop the WireGuard service.
import-key PRIVATE_KEY Import a private key instead of generating one.
add-peer NAME ENDPOINT PUBLIC_KEY Add peer live and auto-save into config.
remove-peer PUBLIC_KEY Remove peer live and auto-save into config.
provision-peer NAME ENDPOINT ADDRESS Provision keys and .conf for a new peer.
help Show this help message.
Make sure required environment variables are set before running.
EOF
}
import_key() {
local private_key="$2"
if [[ -z "$private_key" ]]; then
echo "Error: private_key cannot be blank." >&2
exit 1
fi
echo "Importing provided private key..."
mkdir -p "$WG_CONFIG_DIR"
chmod 700 "$WG_CONFIG_DIR"
echo "$private_key" | tee "$PRIVATE_KEY_FILE" > /dev/null
echo "$private_key" | wg pubkey | tee "$PUBLIC_KEY_FILE" > /dev/null
echo "Key imported."
}
provision_peer() {
shift
load_address
if [[ $# -ne 3 ]]; then
echo "Usage: $0 provision-peer <name> <endpoint> <address/CIDR>" >&2
exit 1
fi
local name="$1"
local endpoint="$2" # e.g. alice.example.com:51820
local peer_cidr="$3" # e.g. 10.15.0.4/32
if [[ "$endpoint" != *:* ]]; then
echo "Error: endpoint must be in host:port form" >&2
exit 1
fi
local peer_host="${endpoint%:*}"
local peer_port="${endpoint##*:}"
local public_ip=$(curl -s ifconfig.me)
# Directory for the new peer’s artifacts
local dir="$WG_CONFIG_DIR/provisioned-peers"
mkdir -p "$dir"
chmod 700 "$dir"
# Generate a fresh keypair
umask 077
wg genkey | tee "$dir/${name}.priv" \
| wg pubkey > "$dir/${name}.pub"
local priv="$dir/${name}.priv"
local pub="$(<"$dir/${name}.pub")"
local conf="$dir/${name}.conf"
# Build the turnkey .conf
{
echo "[Interface]"
echo "PrivateKey = $(<"$priv")"
echo "Address = $peer_cidr"
echo "ListenPort = $peer_port"
echo
echo "[Peer]"
echo "PublicKey = $(<"$PUBLIC_KEY_FILE")"
echo "Endpoint = $public_ip:$WG_PORT"
echo "AllowedIPs = $WG_ADDRESS"
echo "PersistentKeepalive = 25"
} > "$conf"
chmod 600 "$conf"
# Print the add-peer command for THIS node
cat <<EOF
▶️ Provisioned peer bundle for '$name':
• $conf
▶️ To stitch '$name' into this node’s mesh, run:
./wireguard_p2p.sh add-peer \\
$name \\
$endpoint \\
$pub \\
$peer_cidr
Now copy ${name}.conf to your client, import & activate, then run the above add-peer command here.
EOF
}
main() {
if [[ $EUID -ne 0 ]]; then
echo "Error: This script must be run as root." >&2
exit 1
fi
if [[ $# -eq 0 ]]; then
help
exit 0
fi
case "$1" in
dependencies) dependencies ;;
install) install "$@" ;;
uninstall) uninstall ;;
status) status ;;
start) start ;;
stop) stop ;;
import-key)
if [[ $# -ne 2 ]]; then
echo "Usage: $0 import-key <private_key>" >&2
exit 1
fi
import_key "$@"
;;
add-peer)
if [[ $# -eq 1 ]]; then
get_add_peer_command
exit 0
elif [[ $# -ne 5 ]]; then
echo "Usage: $0 add-peer <name> <endpoint> <public_key> <peer_ip>" >&2
exit 1
fi
add_peer "$@"
;;
remove-peer)
if [[ $# -ne 2 ]]; then
echo "Usage: $0 remove-peer <public_key>" >&2
exit 1
fi
remove_peer "$@"
;;
provision-peer)
provision_peer "$@"
;;
help|-h|--help) help ;;
*)
echo "Unknown command: $1" >&2
help
exit 1
;;
esac
}
###################################
# Entrypoint #
###################################
main "$@"
You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com
This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️