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. The only network requirement is that each host has the ability to make outbound UDP connections (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 greater than 25 seconds, 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
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.
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.
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."
}
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 "$@"
;;
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. ❤️