Skip to content

Instantly share code, notes, and snippets.

@tulik
Created May 4, 2026 18:24
Show Gist options
  • Select an option

  • Save tulik/70ec9918cdfd070abf37936638c0eb25 to your computer and use it in GitHub Desktop.

Select an option

Save tulik/70ec9918cdfd070abf37936638c0eb25 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
set -euo pipefail
WG_IFACE="${WG_IFACE:-wg0}"
WG_PORT="${WG_PORT:-51820}"
WG_SERVER_IP="${WG_SERVER_IP:-10.8.0.1}"
WG_PREFIX_LEN="${WG_PREFIX_LEN:-24}"
WG_SUBNET="${WG_SUBNET:-10.8.0.0/24}"
WG_ADMIN_PEER_IP="${WG_ADMIN_PEER_IP:-10.8.0.2}"
VPN_SSH_ALLOWED_CIDR="${VPN_SSH_ALLOWED_CIDR:-10.8.0.2/32}"
BOOTSTRAP_ALLOWED_IPS="${BOOTSTRAP_ALLOWED_IPS:-10.8.0.0/24}"
BOOTSTRAP_DNS="${BOOTSTRAP_DNS:-1.1.1.1}"
PUBLIC_ENDPOINT="${PUBLIC_ENDPOINT:-}"
ADMIN_PUBLIC_IP="${ADMIN_PUBLIC_IP:-}"
NFT_MAIN="/etc/sysconfig/nftables.conf"
NFT_RULES="/etc/nftables/wg-vpn.nft"
NFT_FINAL="/root/nftables-wg-final.nft"
umask 077
die() {
echo "ERROR: $*" >&2
exit 1
}
require_root() {
[ "$(id -u)" -eq 0 ] || die "Run as root."
}
is_ipv4_or_cidr() {
local value="$1" ip prefix o1 o2 o3 o4 octet
ip="${value%/*}"
if [[ "$value" == */* ]]; then
prefix="${value#*/}"
[[ "$prefix" =~ ^[0-9]+$ ]] || return 1
(( prefix >= 0 && prefix <= 32 )) || return 1
fi
IFS=. read -r o1 o2 o3 o4 <<< "$ip"
for octet in "$o1" "$o2" "$o3" "$o4"; do
[[ "$octet" =~ ^[0-9]+$ ]] || return 1
(( octet >= 0 && octet <= 255 )) || return 1
done
[ -n "${o1:-}" ] && [ -n "${o2:-}" ] && [ -n "${o3:-}" ] && [ -n "${o4:-}" ]
}
is_port() {
[[ "$1" =~ ^[0-9]+$ ]] && (( "$1" >= 1 && "$1" <= 65535 ))
}
is_prefix_len() {
[[ "$1" =~ ^[0-9]+$ ]] && (( "$1" >= 0 && "$1" <= 32 ))
}
pause_for_enter() {
echo
echo "$1"
echo "Press ENTER to continue, or Ctrl-C to abort."
read -r _ < /dev/tty
}
write_nftables_rules() {
local file="$1"
local allow_public_ssh="${2:-no}"
local public_ssh_rule=""
if [ "$allow_public_ssh" = "yes" ]; then
public_ssh_rule=" ip saddr ${ADMIN_PUBLIC_IP} tcp dport 22 accept comment \"temporary setup ssh\""
fi
cat > "$file" <<EOF
#!/usr/sbin/nft -f
flush ruleset
table inet wg_filter {
chain input {
type filter hook input priority filter; policy drop;
ct state invalid drop
ct state { established, related } accept
iifname "lo" accept
ip protocol icmp accept
${public_ssh_rule}
udp dport ${WG_PORT} accept comment "wireguard"
iifname "${WG_IFACE}" ip saddr ${VPN_SSH_ALLOWED_CIDR} tcp dport 22 accept comment "ssh over vpn"
iifname "${WG_IFACE}" ip saddr ${WG_SUBNET} ip protocol icmp accept comment "ping over vpn"
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state invalid drop
iifname "${WG_IFACE}" oifname "${NIC}" ip saddr ${WG_SUBNET} accept
iifname "${NIC}" oifname "${WG_IFACE}" ip daddr ${WG_SUBNET} ct state { established, related } accept
}
chain output {
type filter hook output priority filter; policy accept;
}
}
table ip wg_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr ${WG_SUBNET} oifname "${NIC}" masquerade
}
}
EOF
}
backup_if_exists() {
local path="$1"
[ ! -e "$path" ] || cp -a "$path" "/root/$(basename "$path").backup.$(date +%Y%m%d%H%M%S)"
}
install_nftables_service_config() {
install -d -m 755 /etc/nftables
backup_if_exists "$NFT_MAIN"
cat > "$NFT_MAIN" <<EOF
include "${NFT_RULES}"
EOF
}
require_root
[ ! -e "/etc/wireguard/${WG_IFACE}.conf" ] || die "/etc/wireguard/${WG_IFACE}.conf already exists."
if [ -z "$ADMIN_PUBLIC_IP" ] && [ -n "${SSH_CLIENT:-}" ]; then
ADMIN_PUBLIC_IP="${SSH_CLIENT%% *}"
fi
[ -n "$ADMIN_PUBLIC_IP" ] || die "Set ADMIN_PUBLIC_IP or run through SSH."
is_ipv4_or_cidr "$ADMIN_PUBLIC_IP" || die "Invalid ADMIN_PUBLIC_IP: $ADMIN_PUBLIC_IP"
is_ipv4_or_cidr "$VPN_SSH_ALLOWED_CIDR" || die "Invalid VPN_SSH_ALLOWED_CIDR: $VPN_SSH_ALLOWED_CIDR"
is_ipv4_or_cidr "$WG_SERVER_IP" || die "Invalid WG_SERVER_IP: $WG_SERVER_IP"
is_prefix_len "$WG_PREFIX_LEN" || die "Invalid WG_PREFIX_LEN: $WG_PREFIX_LEN"
is_ipv4_or_cidr "$WG_SUBNET" || die "Invalid WG_SUBNET: $WG_SUBNET"
is_ipv4_or_cidr "$WG_ADMIN_PEER_IP" || die "Invalid WG_ADMIN_PEER_IP: $WG_ADMIN_PEER_IP"
is_ipv4_or_cidr "$BOOTSTRAP_ALLOWED_IPS" || die "Invalid BOOTSTRAP_ALLOWED_IPS: $BOOTSTRAP_ALLOWED_IPS"
is_port "$WG_PORT" || die "Invalid WG_PORT: $WG_PORT"
cat <<EOF
Configuration:
WG_IFACE = ${WG_IFACE}
WG_PORT = ${WG_PORT}/udp
WG_SERVER_IP = ${WG_SERVER_IP}/${WG_PREFIX_LEN}
WG_SUBNET = ${WG_SUBNET}
WG_ADMIN_PEER_IP = ${WG_ADMIN_PEER_IP}
VPN_SSH_ALLOWED_CIDR = ${VPN_SSH_ALLOWED_CIDR}
ADMIN_PUBLIC_IP = ${ADMIN_PUBLIC_IP}
EOF
pause_for_enter "Review the configuration above."
dnf upgrade -y
dnf install -y wireguard-tools nftables iproute curl ca-certificates dnf-automatic
NIC="$(ip -4 route show default 0.0.0.0/0 | awk '{print $5; exit}')"
[ -n "$NIC" ] || die "Could not detect default IPv4 NIC."
if [ -z "$PUBLIC_ENDPOINT" ]; then
PUBLIC_ENDPOINT="$({ curl -4fsSL --max-time 10 https://api.ipify.org || curl -4fsSL --max-time 10 https://icanhazip.com || true; } 2>/dev/null)"
PUBLIC_ENDPOINT="$(printf "%s" "$PUBLIC_ENDPOINT" | tr -d '[:space:]')"
fi
[ -n "$PUBLIC_ENDPOINT" ] || die "Could not detect PUBLIC_ENDPOINT."
if systemctl is-active --quiet firewalld 2>/dev/null; then
systemctl disable --now firewalld
fi
cat > /etc/sysctl.d/99-wireguard.conf <<EOF
net.ipv4.ip_forward = 1
net.ipv4.conf.all.src_valid_mark = 1
net.ipv6.conf.all.forwarding = 0
EOF
sysctl --system >/dev/null
install_nftables_service_config
write_nftables_rules "$NFT_RULES" yes
write_nftables_rules "$NFT_FINAL" no
chmod 600 "$NFT_RULES" "$NFT_FINAL"
nft -c -f "$NFT_RULES"
nft -f "$NFT_RULES"
systemctl enable --now nftables
install -d -m 700 /etc/wireguard
SERVER_PRIV="$(wg genkey)"
SERVER_PUB="$(printf "%s\n" "$SERVER_PRIV" | wg pubkey)"
ADMIN_PRIV="$(wg genkey)"
ADMIN_PUB="$(printf "%s\n" "$ADMIN_PRIV" | wg pubkey)"
ADMIN_PSK="$(wg genpsk)"
cat > /etc/wireguard/server.key <<EOF
${SERVER_PRIV}
EOF
cat > /etc/wireguard/server.pub <<EOF
${SERVER_PUB}
EOF
cat > "/etc/wireguard/${WG_IFACE}.conf" <<EOF
[Interface]
Address = ${WG_SERVER_IP}/${WG_PREFIX_LEN}
ListenPort = ${WG_PORT}
PrivateKey = ${SERVER_PRIV}
[Peer]
# bootstrap-admin
PublicKey = ${ADMIN_PUB}
PresharedKey = ${ADMIN_PSK}
AllowedIPs = ${WG_ADMIN_PEER_IP}/32
EOF
chmod 600 /etc/wireguard/server.key "/etc/wireguard/${WG_IFACE}.conf"
chmod 644 /etc/wireguard/server.pub
cat > /root/admin-vpn.conf <<EOF
[Interface]
PrivateKey = ${ADMIN_PRIV}
Address = ${WG_ADMIN_PEER_IP}/32
DNS = ${BOOTSTRAP_DNS}
[Peer]
PublicKey = ${SERVER_PUB}
PresharedKey = ${ADMIN_PSK}
Endpoint = ${PUBLIC_ENDPOINT}:${WG_PORT}
AllowedIPs = ${BOOTSTRAP_ALLOWED_IPS}
PersistentKeepalive = 25
EOF
chmod 600 /root/admin-vpn.conf
systemctl enable --now "wg-quick@${WG_IFACE}"
cat <<EOF
Bootstrap client config:
/root/admin-vpn.conf
Copy it locally before public SSH is removed:
scp root@${PUBLIC_ENDPOINT}:/root/admin-vpn.conf .
Then connect the VPN and test:
ping ${WG_SERVER_IP}
ssh root@${WG_SERVER_IP}
EOF
pause_for_enter "Continue only after SSH over VPN works."
echo "Waiting for recent WireGuard handshake..."
deadline=$((SECONDS + 300))
handshake_ok=0
age=0
while [ "$SECONDS" -lt "$deadline" ]; do
ts="$(wg show "$WG_IFACE" latest-handshakes | awk -v pub="$ADMIN_PUB" '$1 == pub { print $2 }')"
if [ -n "${ts:-}" ] && [ "$ts" -gt 0 ]; then
age=$(($(date +%s) - ts))
if [ "$age" -lt 300 ]; then
handshake_ok=1
break
fi
fi
sleep 5
done
if [ "$handshake_ok" -ne 1 ]; then
die "No recent handshake. Public SSH is still restricted to ${ADMIN_PUBLIC_IP}. To lock it later: cp ${NFT_FINAL} ${NFT_RULES} && nft -f ${NFT_RULES}"
fi
cp "$NFT_FINAL" "$NFT_RULES"
nft -c -f "$NFT_RULES"
nft -f "$NFT_RULES"
systemctl enable --now nftables
sed -i \
-e 's/^[[:space:]]*upgrade_type[[:space:]]*=.*/upgrade_type = security/' \
-e 's/^[[:space:]]*download_updates[[:space:]]*=.*/download_updates = yes/' \
-e 's/^[[:space:]]*apply_updates[[:space:]]*=.*/apply_updates = yes/' \
/etc/dnf/automatic.conf
systemctl enable --now dnf-automatic.timer
cat > /root/wireguard-peer-template.txt <<EOF
Add peer to /etc/wireguard/${WG_IFACE}.conf:
[Peer]
# name: replace-me
PublicKey = CLIENT_PUBLIC_KEY
PresharedKey = CLIENT_PRESHARED_KEY
AllowedIPs = 10.8.0.X/32
Apply without restarting the interface:
wg syncconf ${WG_IFACE} <(wg-quick strip ${WG_IFACE})
Client:
[Interface]
PrivateKey = CLIENT_PRIVATE_KEY
Address = 10.8.0.X/32
DNS = ${BOOTSTRAP_DNS}
[Peer]
PublicKey = ${SERVER_PUB}
PresharedKey = CLIENT_PRESHARED_KEY
Endpoint = ${PUBLIC_ENDPOINT}:${WG_PORT}
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Split tunnel:
AllowedIPs = ${WG_SUBNET}
Server public key:
${SERVER_PUB}
EOF
chmod 600 /root/wireguard-peer-template.txt
cat <<EOF
SETUP COMPLETE
Public endpoint:
${PUBLIC_ENDPOINT}:${WG_PORT}/udp
VPN SSH:
ssh root@${WG_SERVER_IP}
Public SSH:
blocked by nftables
Files:
/root/admin-vpn.conf
/root/wireguard-peer-template.txt
/etc/wireguard/${WG_IFACE}.conf
${NFT_RULES}
${NFT_MAIN}
/etc/sysctl.d/99-wireguard.conf
Check:
wg show
nft list ruleset
systemctl status wg-quick@${WG_IFACE}
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment