#!/usr/bin/env bash # # wg-install.sh - WireGuard VPN Server Installation Script # # This script handles the complete installation of a WireGuard VPN server on Debian 13. # It includes dependency checks, package installation, firewall setup (nftables), # server key generation, interface initialization, and systemd service setup. # # Settings can be provided via interactive prompts or environment variables prefixed with WGI_ # (e.g., WGI_SERVER_DOMAIN, WGI_WG_PORT, WGI_VPN_IPV4_RANGE, etc.) set -euo pipefail # Default Configuration (can be overridden by environment variables with WGI_ prefix) WGI_SERVER_DOMAIN="${WGI_SERVER_DOMAIN:-}" WGI_WG_PORT="${WGI_WG_PORT:-51820}" WGI_VPN_IPV4_RANGE="${WGI_VPN_IPV4_RANGE:-10.10.69.0/24}" WGI_VPN_IPV6_RANGE="${WGI_VPN_IPV6_RANGE:-fd69:dead:beef:69::/64}" WGI_WG_INTERFACE="${WGI_WG_INTERFACE:-wg0}" WGI_DNS_SERVERS="${WGI_DNS_SERVERS:-8.8.8.8, 8.8.4.4}" WGI_LOG_FILE="${WGI_LOG_FILE:-/var/log/wg-admin-install.log}" WGI_MIN_DISK_SPACE_MB=100 # Derived paths CONF_D_DIR="/etc/wireguard/conf.d" SERVER_CONF="${CONF_D_DIR}/server.conf" CLIENT_OUTPUT_DIR="/etc/wireguard/clients" WG_CONFIG="/etc/wireguard/${WGI_WG_INTERFACE}.conf" BACKUP_DIR="/etc/wg-admin/backups" # Global variables for cleanup and rollback TEMP_DIR="" ROLLBACK_BACKUP_DIR="" ROLLBACK_NEEDED=false PUBLIC_INTERFACE="" SERVER_PRIVATE_KEY="" SERVER_PUBLIC_KEY="" # ============================================================================ # Logging Functions # ============================================================================ log() { local level="$1" shift local message="$*" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[${timestamp}] [${level}] ${message}" | tee -a "${WGI_LOG_FILE}" } log_info() { log "INFO" "$@" } log_error() { log "ERROR" "$@" >&2 } log_warn() { log "WARN" "$@" } # ============================================================================ # Cleanup and Error Handling # ============================================================================ cleanup_handler() { local exit_code=$? # Remove temporary directories if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then log_info "Cleaning up temporary directory: ${TEMP_DIR}" rm -rf "${TEMP_DIR}" fi # Rollback on failure if needed if [[ ${ROLLBACK_NEEDED} == true ]] && [[ ${exit_code} -ne 0 ]]; then log_error "Installation failed, attempting rollback..." rollback_installation fi exit ${exit_code} } # Set up traps for cleanup trap cleanup_handler EXIT INT TERM HUP # ============================================================================ # Input Validation Functions # ============================================================================ validate_dns_servers() { local dns="$1" if [[ -z "$dns" ]]; then return 0 # Empty DNS is allowed fi # Split by comma and validate each DNS server IFS=',' read -ra dns_array <<< "$dns" for dns_server in "${dns_array[@]}"; do dns_server=$(echo "$dns_server" | xargs) # Trim whitespace if [[ ! "$dns_server" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then log_error "Invalid DNS server format: ${dns_server}" return 1 fi done return 0 } validate_port_range() { local port="$1" if [[ -z "$port" ]]; then log_error "Port cannot be empty" return 1 fi if [[ ! "$port" =~ ^[0-9]+$ ]]; then log_error "Port must be a number" return 1 fi if [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then log_error "Port must be between 1 and 65535" return 1 fi return 0 } validate_cidr() { local cidr="$1" local is_ipv6="$2" if [[ -z "$cidr" ]]; then log_error "CIDR cannot be empty" return 1 fi if [[ "$is_ipv6" == "true" ]]; then # Basic IPv6 CIDR validation if [[ ! "$cidr" =~ ^[0-9a-fA-F:]+/[0-9]+$ ]]; then log_error "Invalid IPv6 CIDR format: ${cidr}" return 1 fi local prefix="${cidr#*/}" if [[ "$prefix" -lt 0 ]] || [[ "$prefix" -gt 128 ]]; then log_error "Invalid IPv6 prefix length: ${prefix}" return 1 fi else # IPv4 CIDR validation if [[ ! "$cidr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then log_error "Invalid IPv4 CIDR format: ${cidr}" return 1 fi local ip="${cidr%/*}" local prefix="${cidr#*/}" # Validate each octet IFS='.' read -ra octets <<< "$ip" for octet in "${octets[@]}"; do if [[ "$octet" -lt 0 ]] || [[ "$octet" -gt 255 ]]; then log_error "Invalid IPv4 octet: ${octet} in ${cidr}" return 1 fi done if [[ "$prefix" -lt 0 ]] || [[ "$prefix" -gt 32 ]]; then log_error "Invalid IPv4 prefix length: ${prefix}" return 1 fi fi return 0 } validate_server_domain() { local domain="$1" if [[ -z "$domain" ]]; then log_error "Server domain cannot be empty" return 1 fi # Basic domain validation (alphanumeric, hyphens, dots) if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then log_error "Invalid server domain format: ${domain}" return 1 fi return 0 } # ============================================================================ # Pre-installation Validation # ============================================================================ pre_install_validation() { log_info "Running pre-installation validation..." # Check root privileges if [[ $EUID -ne 0 ]]; then log_error "This script must be run as root" exit 1 fi # Check disk space (at least MIN_DISK_SPACE_MB free) local free_space_kb=$(df / | awk 'NR==2 {print $4}') local free_space_mb=$((free_space_kb / 1024)) if [[ ${free_space_mb} -lt ${WGI_MIN_DISK_SPACE_MB} ]]; then log_error "Insufficient disk space (${free_space_mb}MB free, ${WGI_MIN_DISK_SPACE_MB}MB required)" exit 1 fi log_info "Disk space validation passed (${free_space_mb}MB free)" # Check port availability if ss -ulnp 2>/dev/null | grep -q ":${WGI_WG_PORT}"; then log_error "Port ${WGI_WG_PORT} is already in use" echo "ERROR: WireGuard port ${WGI_WG_PORT} is already in use." echo "Action: Stop the service using port ${WGI_WG_PORT} or change the port." echo "To find what's using the port: 'sudo ss -tulnp | grep ${WGI_WG_PORT}'" exit 1 fi log_info "Port ${WGI_WG_PORT} is available" log_info "Pre-installation validation passed" } # ============================================================================ # Backup Functions # ============================================================================ backup_config() { local operation="${1:-manual}" local timestamp=$(date +"%Y%m%d_%H%M%S") local backup_name="wg-backup-${operation}-${timestamp}" local backup_path="${BACKUP_DIR}/${backup_name}" log_info "Creating configuration backup: ${backup_name}" # Create backup directory if it doesn't exist mkdir -p "${BACKUP_DIR}" chmod 700 "${BACKUP_DIR}" # Create backup directory mkdir -p "${backup_path}" # Backup WireGuard configurations if [[ -d "/etc/wireguard" ]]; then cp -a /etc/wireguard "${backup_path}/" 2>/dev/null || true fi # Backup nftables configurations if [[ -f "/etc/nftables.conf" ]]; then cp -a /etc/nftables.conf "${backup_path}/" 2>/dev/null || true fi if [[ -d "/etc/nftables.d" ]]; then cp -a /etc/nftables.d "${backup_path}/" 2>/dev/null || true fi # Backup sysctl configuration if [[ -f "/etc/sysctl.d/99-wireguard.conf" ]]; then cp -a /etc/sysctl.d/99-wireguard.conf "${backup_path}/" 2>/dev/null || true fi # Create backup metadata cat > "${backup_path}/backup-info.txt" </dev/null | grep -E '^wg-backup-')) # If we have more than 10 backups, remove oldest backups if [[ ${#backups[@]} -gt 10 ]]; then log_info "Applying retention policy (keeping last 10 backups)..." local to_remove=(${backups[@]:10}) for backup in "${to_remove[@]}"; do log_info "Removing old backup: ${backup}" rm -rf "${BACKUP_DIR}/${backup}" done fi } # ============================================================================ # Rollback Functions # ============================================================================ rollback_installation() { log_warn "Rolling back installation..." # Stop services systemctl stop wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true systemctl disable wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true # Restore from backup if exists if [[ -d "${ROLLBACK_BACKUP_DIR}" ]]; then log_info "Restoring from backup: ${ROLLBACK_BACKUP_DIR}" if [[ -f "${ROLLBACK_BACKUP_DIR}/wireguard.conf" ]]; then cp "${ROLLBACK_BACKUP_DIR}/wireguard.conf" "${WG_CONFIG}" fi if [[ -f "${ROLLBACK_BACKUP_DIR}/nftables.conf" ]]; then cp "${ROLLBACK_BACKUP_DIR}/nftables.conf" /etc/nftables.conf nft -f /etc/nftables.conf 2>/dev/null || true fi if [[ -d "${ROLLBACK_BACKUP_DIR}/conf.d" ]]; then rm -rf "${CONF_D_DIR}" cp -r "${ROLLBACK_BACKUP_DIR}/conf.d" "${CONF_D_DIR}" chmod 700 "${CONF_D_DIR}" fi fi # Bring down interface wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true log_warn "Rollback complete. Please review the logs and try again." } # ============================================================================ # Interactive Configuration # ============================================================================ prompt_configuration() { echo "" echo "=== WireGuard VPN Server Configuration ===" echo "" echo "Press Enter to accept the default value (shown in brackets)" echo "" # Server Domain if [[ -z "${WGI_SERVER_DOMAIN}" ]]; then read -p "Server Domain [e.g., vpn.example.com]: " input_domain WGI_SERVER_DOMAIN="${input_domain}" else echo "Server Domain: ${WGI_SERVER_DOMAIN}" fi # Port if [[ -z "${WGI_WG_PORT}" ]] || [[ "${WGI_WG_PORT}" == "51820" ]]; then read -p "WireGuard Port [${WGI_WG_PORT}]: " input_port if [[ -n "$input_port" ]]; then WGI_WG_PORT="$input_port" fi else echo "WireGuard Port: ${WGI_WG_PORT}" fi # IPv4 Range if [[ -z "${WGI_VPN_IPV4_RANGE}" ]] || [[ "${WGI_VPN_IPV4_RANGE}" == "10.10.69.0/24" ]]; then read -p "VPN IPv4 Range [${WGI_VPN_IPV4_RANGE}]: " input_ipv4 if [[ -n "$input_ipv4" ]]; then WGI_VPN_IPV4_RANGE="$input_ipv4" fi else echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}" fi # IPv6 Range if [[ -z "${WGI_VPN_IPV6_RANGE}" ]] || [[ "${WGI_VPN_IPV6_RANGE}" == "fd69:dead:beef:69::/64" ]]; then read -p "VPN IPv6 Range [${WGI_VPN_IPV6_RANGE}]: " input_ipv6 if [[ -n "$input_ipv6" ]]; then WGI_VPN_IPV6_RANGE="$input_ipv6" fi else echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}" fi # DNS Servers if [[ -z "${WGI_DNS_SERVERS}" ]] || [[ "${WGI_DNS_SERVERS}" == "8.8.8.8, 8.8.4.4" ]]; then read -p "DNS Servers [${WGI_DNS_SERVERS}]: " input_dns if [[ -n "$input_dns" ]]; then WGI_DNS_SERVERS="$input_dns" fi else echo "DNS Servers: ${WGI_DNS_SERVERS}" fi # Interface Name if [[ -z "${WGI_WG_INTERFACE}" ]] || [[ "${WGI_WG_INTERFACE}" == "wg0" ]]; then read -p "WireGuard Interface [${WGI_WG_INTERFACE}]: " input_interface if [[ -n "$input_interface" ]]; then WGI_WG_INTERFACE="$input_interface" # Update derived paths WG_CONFIG="/etc/wireguard/${WGI_WG_INTERFACE}.conf" fi else echo "WireGuard Interface: ${WGI_WG_INTERFACE}" fi echo "" echo "=== Configuration Summary ===" echo "Server Domain: ${WGI_SERVER_DOMAIN}" echo "WireGuard Port: ${WGI_WG_PORT}" echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}" echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}" echo "DNS Servers: ${WGI_DNS_SERVERS}" echo "WireGuard Interface: ${WGI_WG_INTERFACE}" echo "" read -p "Proceed with installation? (yes/no): " confirm if [[ "${confirm}" != "yes" ]]; then log_info "Installation cancelled by user" exit 0 fi } # ============================================================================ # Package Installation # ============================================================================ install_packages() { log_info "Installing packages..." echo "Updating package lists..." apt-get update -qq echo "Installing WireGuard and dependencies..." apt-get install -y wireguard wireguard-tools qrencode nftables log_info "Package installation complete" } # ============================================================================ # Cleanup Existing Installation # ============================================================================ cleanup_existing_installation() { log_info "Checking for existing WireGuard installation..." # Stop and disable WireGuard service if systemctl is-enabled --quiet wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || systemctl list-unit-files | grep -q wg-quick@${WGI_WG_INTERFACE}.service; then log_info "Stopping WireGuard service..." echo "Stopping WireGuard service..." systemctl stop wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true echo "Disabling WireGuard service..." systemctl disable wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true fi # Stop and disable old client loader service if systemctl is-enabled --quiet wg-load-clients.service 2>/dev/null || systemctl list-unit-files | grep -q wg-load-clients.service; then echo "Removing old WireGuard client loader service..." systemctl disable wg-load-clients.service 2>/dev/null || true systemctl stop wg-load-clients.service 2>/dev/null || true rm -f /etc/systemd/system/wg-load-clients.service systemctl daemon-reload 2>/dev/null || true fi # Bring down interface if running if wg show ${WGI_WG_INTERFACE} &>/dev/null; then echo "WireGuard interface is active, bringing down..." wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true fi # Remove existing configuration directories if [[ -d "/etc/wireguard" ]]; then echo "Removing existing WireGuard configuration..." rm -rf /etc/wireguard fi if [[ -d "/etc/clients.d" ]]; then echo "Removing old client directory..." rm -rf /etc/clients.d fi if [[ -d "/etc/wireguard/conf.d" ]]; then echo "Removing existing config.d directory..." rm -rf /etc/wireguard/conf.d fi if [[ -d "/etc/wireguard/peer.d" ]]; then echo "Removing old peer.d directory..." rm -rf /etc/wireguard/peer.d fi if [[ -d "/root/wireguard-clients" ]]; then echo "Removing old client config directory..." rm -rf /root/wireguard-clients fi # Flush nftables rules if command -v nft &> /dev/null; then echo "Flushing nftables rules..." nft flush ruleset 2>/dev/null || true fi echo "Cleanup complete." } # ============================================================================ # Network Configuration # ============================================================================ detect_public_interface() { PUBLIC_INTERFACE=$(ip route get 8.8.8.8 | grep -oP 'dev \K\S+' | head -1) log_info "Public interface detected: ${PUBLIC_INTERFACE}" echo "Public interface: ${PUBLIC_INTERFACE}" } enable_ip_forwarding() { log_info "Enabling IP forwarding..." echo "Enabling IP forwarding..." cat > /etc/sysctl.d/99-wireguard.conf < /etc/nftables.d/wireguard.conf < /etc/nftables.conf < "$temp_private" wg pubkey < "$temp_private" > "$temp_public" SERVER_PRIVATE_KEY=$(cat "$temp_private") SERVER_PUBLIC_KEY=$(cat "$temp_public") # Move to final location with proper permissions mv "$temp_private" server_private.key mv "$temp_public" server_public.key chmod 600 server_private.key chmod 644 server_public.key log_info "Server keys generated and secured" } # ============================================================================ # WireGuard Configuration # ============================================================================ configure_wireguard() { log_info "Configuring WireGuard interface..." echo "Configuring WireGuard interface..." # Create config.d directory mkdir -p "${CONF_D_DIR}" # Create server.conf in conf.d directory cat > "${SERVER_CONF}" < "${WG_CONFIG}" </dev/null; then wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true fi systemctl start wg-quick@${WGI_WG_INTERFACE}.service # Wait for WireGuard to initialize sleep 2 # Verify WireGuard is running if ! systemctl is-active --quiet wg-quick@${WGI_WG_INTERFACE}.service; then log_error "WireGuard service failed to start" echo "=== Service Status ===" systemctl status wg-quick@${WGI_WG_INTERFACE}.service exit 1 fi # Verify correct listening port ACTUAL_PORT=$(wg show ${WGI_WG_INTERFACE} listen-port) if [[ "$ACTUAL_PORT" != "$WGI_WG_PORT" ]]; then log_error "WireGuard listening on port $ACTUAL_PORT instead of $WGI_WG_PORT" echo "=== Config File ===" grep ListenPort "${WG_CONFIG}" echo "=== Running Interface ===" wg show ${WGI_WG_INTERFACE} exit 1 fi echo "WireGuard started successfully on port $ACTUAL_PORT" log_info "WireGuard service started successfully" } # ============================================================================ # Main Installation Function # ============================================================================ main() { log_info "=== WireGuard VPN Installation for Debian 13 ===" # Validate configuration validate_server_domain "${WGI_SERVER_DOMAIN}" validate_port_range "${WGI_WG_PORT}" validate_cidr "${WGI_VPN_IPV4_RANGE}" false validate_cidr "${WGI_VPN_IPV6_RANGE}" true validate_dns_servers "${WGI_DNS_SERVERS}" # Interactive configuration if not all values set if [[ -z "${WGI_SERVER_DOMAIN}" ]] || \ [[ -z "${WGI_WG_PORT}" ]] || \ [[ -z "${WGI_VPN_IPV4_RANGE}" ]] || \ [[ -z "${WGI_VPN_IPV6_RANGE}" ]]; then prompt_configuration fi # Print configuration echo "" echo "=== WireGuard VPN Installation for Debian 13 ===" echo "Server: ${WGI_SERVER_DOMAIN}" echo "IPv4 VPN range: ${WGI_VPN_IPV4_RANGE}" echo "IPv6 VPN range: ${WGI_VPN_IPV6_RANGE}" echo "Port: ${WGI_WG_PORT}" echo "Interface: ${WGI_WG_INTERFACE}" echo "" # Pre-installation validation pre_install_validation # Auto-backup before install (only if config exists) if [[ -f "${WG_CONFIG}" ]] || [[ -f "/etc/nftables.conf" ]]; then backup_config "install-pre" fi # Enable rollback flag ROLLBACK_NEEDED=true # Create backup directory for potential rollback ROLLBACK_BACKUP_DIR=$(mktemp -d) log_info "Created rollback backup directory: ${ROLLBACK_BACKUP_DIR}" # Backup existing configs if they exist if [[ -f "${WG_CONFIG}" ]]; then cp "${WG_CONFIG}" "${ROLLBACK_BACKUP_DIR}/wireguard.conf" log_info "Backed up existing WireGuard config" fi if [[ -f "/etc/nftables.conf" ]]; then cp /etc/nftables.conf "${ROLLBACK_BACKUP_DIR}/nftables.conf" log_info "Backed up existing nftables config" fi if [[ -d "${CONF_D_DIR}" ]]; then cp -r "${CONF_D_DIR}" "${ROLLBACK_BACKUP_DIR}/conf.d" log_info "Backed up existing client configs" fi # Cleanup existing installation cleanup_existing_installation # Install packages install_packages # Detect public interface detect_public_interface # Enable IP forwarding enable_ip_forwarding # Configure nftables firewall configure_nftables # Generate server keys generate_server_keys # Configure WireGuard configure_wireguard # Setup systemd service setup_systemd_service # Disable rollback flag - installation successful ROLLBACK_NEEDED=false # Clean up rollback backup directory if [[ -d "${ROLLBACK_BACKUP_DIR}" ]]; then rm -rf "${ROLLBACK_BACKUP_DIR}" log_info "Cleaned up rollback backup directory" fi # Installation complete echo "" log_info "=== Installation Complete ===" echo "=== Installation Complete ===" echo "" echo "Server Public Key: ${SERVER_PUBLIC_KEY}" echo "Endpoint: ${WGI_SERVER_DOMAIN}:${WGI_WG_PORT}" echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}" echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}" echo "" echo "Use 'wireguard.sh add [--psk]' to add clients" echo "Use 'wireguard.sh list' to list clients" echo "Configs will be merged from: ${CONF_D_DIR}" echo " - ${SERVER_CONF} (server interface)" echo " - ${CONF_D_DIR}/client-*.conf (client peers)" echo "" echo "Check status: wg show" echo "System status: systemctl status wg-quick@${WGI_WG_INTERFACE}" echo "" log_info "Installation completed successfully" } # ============================================================================ # Entry Point # ============================================================================ # Only run main if script is executed directly (not sourced) if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # Show usage if help requested if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "help" ]]; then echo "Usage: $0 [options]" echo "" echo "Environment Variables (prefix with WGI_):" echo " WGI_SERVER_DOMAIN Server domain (e.g., vpn.example.com)" echo " WGI_WG_PORT WireGuard UDP port (default: 51820)" echo " WGI_VPN_IPV4_RANGE IPv4 VPN range (default: 10.10.69.0/24)" echo " WGI_VPN_IPV6_RANGE IPv6 VPN range (default: fd69:dead:beef:69::/64)" echo " WGI_DNS_SERVERS DNS servers (default: 8.8.8.8, 8.8.4.4)" echo " WGI_WG_INTERFACE WireGuard interface name (default: wg0)" echo "" echo "Examples:" echo " # Interactive installation" echo " sudo $0" echo "" echo " # Non-interactive with environment variables" echo " sudo WGI_SERVER_DOMAIN=vpn.example.com $0" echo "" echo " # Custom port and VPN range" echo " sudo WGI_SERVER_DOMAIN=vpn.example.com WGI_WG_PORT=443 $0" exit 0 fi # Run main installation main fi