Created
May 4, 2026 18:24
-
-
Save tulik/70ec9918cdfd070abf37936638c0eb25 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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