#!/bin/bash # Debian 12 and Ubuntu Server Hardening Interactive Script # Version: 4.1 | 2025-06-28 # Changelog: # - v4.1: Generalized backup configuration to support any rsync-compatible SSH destination, renamed setup_hetzner_backup to setup_backup. # - v4.0: Added Hetzner Storage Box backup configuration with root SSH key automation, cron job scheduling, ntfy/Discord notifications, and exclude file defaults. # - v4.0: Enhanced generate_summary to include backup details (script path, cron schedule, notifications). # - v4.0: Tested on Debian 12, Ubuntu 20.04, 22.04, 24.04, and 24.10 (experimental) at DigitalOcean, Oracle Cloud, Netcup, Hetzner, and local VMs. # # Description: # This script provisions and hardens a fresh Debian 12 or Ubuntu server with essential security # configurations, user management, SSH hardening, firewall setup, and optional features # like Docker and Tailscale. It is designed to be idempotent, safe, and suitable for # production environments. # # Prerequisites: # - Run as root on a fresh Debian 12 or Ubuntu server (e.g., sudo ./setup_harden_debian_ubuntu.sh). # - Internet connectivity is required for package installation. # # Usage: # Download: wget https://raw.githubusercontent.com/buildplan/setup_harden_server/refs/heads/main/setup_harden_debian_ubuntu.sh # Make it executable: chmod +x setup_harden_debian_ubuntu.sh # Run it: sudo ./setup_harden_debian_ubuntu.sh [--quiet] # # Options: # --quiet: Suppress non-critical output for automation. # # Notes: # - The script creates a log file in /var/log/setup_harden_debian_ubuntu_*.log. # - Critical configurations are backed up before modification. Backup files are at /root/setup_harden_backup_*. # - A new admin user is created with a mandatory password or SSH key for authentication. # - Root SSH login is disabled; all access is via the new user with sudo privileges. # - The user will be prompted to select a timezone, swap size, and custom firewall ports. # - A reboot is recommended at the end to apply all changes. # - Test the script in a VM before production use. # # Troubleshooting: # - Check the log file for errors if the script fails. # - If SSH access is lost, use the server console to restore /etc/ssh/sshd_config.backup_*. # - Ensure sufficient disk space (>2GB) for swap file creation. set -euo pipefail # Exit on error, undefined vars, pipe failures # --- GLOBAL VARIABLES & CONFIGURATION --- # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' NC='\033[0m' # No Color # Script variables SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_FILE="/var/log/setup_harden_debian_ubuntu_$(date +%Y%m%d_%H%M%S).log" VERBOSE=true BACKUP_DIR="/root/setup_harden_backup_$(date +%Y%m%d_%H%M%S)" IS_CONTAINER=false SSHD_BACKUP_FILE="" LOCAL_KEY_ADDED=false SSH_SERVICE="" ID="" # This will be populated from /etc/os-release # --- PARSE ARGUMENTS --- while [[ $# -gt 0 ]]; do case $1 in --quiet) VERBOSE=false; shift ;; *) shift ;; esac done # --- LOGGING & PRINT FUNCTIONS --- log() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE" } print_header() { [[ $VERBOSE == false ]] && return echo -e "${CYAN}╔═════════════════════════════════════════════════════════════════╗${NC}" echo -e "${CYAN}║ ║${NC}" echo -e "${CYAN}║ DEBIAN/UBUNTU SERVER SETUP AND HARDENING SCRIPT ║${NC}" echo -e "${CYAN}║ v4.1 | 2025-06-28 ║${NC}" echo -e "${CYAN}║ ║${NC}" echo -e "${CYAN}╚═════════════════════════════════════════════════════════════════╝${NC}" echo } print_section() { [[ $VERBOSE == false ]] && return echo -e "\n${BLUE}▓▓▓ $1 ▓▓▓${NC}" | tee -a "$LOG_FILE" echo -e "${BLUE}$(printf '═%.0s' {1..65})${NC}" } print_success() { [[ $VERBOSE == false ]] && return echo -e "${GREEN}✓ $1${NC}" | tee -a "$LOG_FILE" } print_error() { echo -e "${RED}✗ $1${NC}" | tee -a "$LOG_FILE" } print_warning() { [[ $VERBOSE == false ]] && return echo -e "${YELLOW}⚠ $1${NC}" | tee -a "$LOG_FILE" } print_info() { [[ $VERBOSE == false ]] && return echo -e "${PURPLE}ℹ $1${NC}" | tee -a "$LOG_FILE" } # --- USER INTERACTION --- confirm() { local prompt="$1" local default="${2:-n}" local response [[ $VERBOSE == false ]] && return 0 if [[ $default == "y" ]]; then prompt="$prompt [Y/n]: " else prompt="$prompt [y/N]: " fi while true; do read -rp "$(echo -e "${CYAN}$prompt${NC}")" response response=${response,,} if [[ -z $response ]]; then response=$default fi case $response in y|yes) return 0 ;; n|no) return 1 ;; *) echo -e "${RED}Please answer yes or no.${NC}" ;; esac done } # --- VALIDATION FUNCTIONS --- validate_username() { local username="$1" [[ "$username" =~ ^[a-z_][a-z0-9_-]*$ && ${#username} -le 32 ]] } validate_hostname() { local hostname="$1" [[ "$hostname" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]{0,253}[a-zA-Z0-9]$ && ! "$hostname" =~ \.\. ]] } validate_port() { local port="$1" [[ "$port" =~ ^[0-9]+$ && "$port" -ge 1024 && "$port" -le 65535 ]] } validate_backup_port() { local port="$1" [[ "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] } validate_ssh_key() { local key="$1" [[ -n "$key" && "$key" =~ ^(ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|ssh-ed25519)\ ]] } validate_timezone() { local tz="$1" [[ -e "/usr/share/zoneinfo/$tz" ]] } validate_swap_size() { local size_upper="${1^^}" # Convert to uppercase for case-insensitivity [[ "$size_upper" =~ ^[0-9]+[MG]$ && "${size_upper%[MG]}" -ge 1 ]] } validate_ufw_port() { local port="$1" # Matches port (e.g., 8080) or port/protocol (e.g., 8080/tcp, 123/udp) [[ "$port" =~ ^[0-9]+(/tcp|/udp)?$ ]] } convert_to_bytes() { local size_upper="${1^^}" # Convert to uppercase for case-insensitivity local unit="${size_upper: -1}" local value="${size_upper%[MG]}" if [[ "$unit" == "G" ]]; then echo $((value * 1024 * 1024 * 1024)) elif [[ "$unit" == "M" ]]; then echo $((value * 1024 * 1024)) else echo 0 fi } # --- CORE FUNCTIONS --- check_dependencies() { print_section "Checking Dependencies" local missing_deps=() command -v curl >/dev/null || missing_deps+=("curl") command -v sudo >/dev/null || missing_deps+=("sudo") command -v gpg >/dev/null || missing_deps+=("gpg") if [[ ${#missing_deps[@]} -gt 0 ]]; then print_info "Installing missing dependencies: ${missing_deps[*]}" if ! apt-get update -qq || ! apt-get install -y -qq "${missing_deps[@]}"; then print_error "Failed to install dependencies: ${missing_deps[*]}" exit 1 fi print_success "Dependencies installed." else print_success "All essential dependencies are installed." fi log "Dependency check completed." } check_system() { print_section "System Compatibility Check" if [[ $(id -u) -ne 0 ]]; then print_error "This script must be run as root (e.g., sudo ./setup_harden_debian_ubuntu.sh)." exit 1 fi print_success "Running with root privileges." if [[ -f /proc/1/cgroup ]] && grep -qE '(docker|lxc|kubepod)' /proc/1/cgroup; then IS_CONTAINER=true print_warning "Container environment detected. Some features (like swap) will be skipped." fi if [[ -f /etc/os-release ]]; then source /etc/os-release ID=$ID # Populate global ID variable if [[ $ID == "debian" && $VERSION_ID == "12" ]] || \ [[ $ID == "ubuntu" && $VERSION_ID =~ ^(20.04|22.04|24.04)$ ]]; then print_success "Compatible OS detected: $PRETTY_NAME" else print_warning "Script not tested on $PRETTY_NAME. This is for Debian 12 or Ubuntu 20.04/22.04/24.04 LTS." if ! confirm "Continue anyway?"; then exit 1; fi fi else print_error "This does not appear to be a Debian or Ubuntu system." exit 1 fi # Preliminary SSH service check if ! dpkg -l openssh-server | grep -q ^ii; then print_warning "openssh-server not installed. It will be installed in the next step." else if systemctl is-enabled ssh.service >/dev/null 2>&1 || systemctl is-active ssh.service >/dev/null 2>&1; then print_info "Preliminary check: ssh.service detected." elif systemctl is-enabled sshd.service >/dev/null 2>&1 || systemctl is-active sshd.service >/dev/null 2>&1; then print_info "Preliminary check: sshd.service detected." elif ps aux | grep -q "[s]shd"; then print_warning "Preliminary check: SSH daemon running but no standard service detected." else print_warning "No SSH service or daemon detected. Ensure SSH is working after package installation." fi fi if curl -s --head https://deb.debian.org >/dev/null || curl -s --head https://archive.ubuntu.com >/dev/null; then print_success "Internet connectivity confirmed." else print_error "No internet connectivity. Please check your network." exit 1 fi if [[ ! -w /var/log ]]; then print_error "Failed to write to /var/log. Cannot create log file." exit 1 fi # Check /etc/shadow permissions if [[ ! -w /etc/shadow ]]; then print_error "/etc/shadow is not writable. Check permissions (should be 640, root:shadow)." exit 1 fi local SHADOW_PERMS SHADOW_PERMS=$(stat -c %a /etc/shadow) if [[ "$SHADOW_PERMS" != "640" ]]; then print_info "Fixing /etc/shadow permissions to 640..." chmod 640 /etc/shadow chown root:shadow /etc/shadow log "Fixed /etc/shadow permissions to 640." fi log "System compatibility check completed." } collect_config() { print_section "Configuration Setup" while true; do read -rp "$(echo -e "${CYAN}Enter username for new admin user: ${NC}")" USERNAME if validate_username "$USERNAME"; then if id "$USERNAME" &>/dev/null; then print_warning "User '$USERNAME' already exists." if confirm "Use this existing user?"; then USER_EXISTS=true; break; fi else USER_EXISTS=false; break fi else print_error "Invalid username. Use lowercase letters, numbers, hyphens, underscores (max 32 chars)." fi done while true; do read -rp "$(echo -e "${CYAN}Enter server hostname: ${NC}")" SERVER_NAME if validate_hostname "$SERVER_NAME"; then break; else print_error "Invalid hostname."; fi done read -rp "$(echo -e "${CYAN}Enter a 'pretty' hostname (optional): ${NC}")" PRETTY_NAME [[ -z "$PRETTY_NAME" ]] && PRETTY_NAME="$SERVER_NAME" while true; do read -rp "$(echo -e "${CYAN}Enter custom SSH port (1024-65535) [2222]: ${NC}")" SSH_PORT SSH_PORT=${SSH_PORT:-2222} if validate_port "$SSH_PORT"; then break; else print_error "Invalid port number."; fi done SERVER_IP=$(curl -s https://ifconfig.me 2>/dev/null || echo "unknown") print_info "Detected server IP: $SERVER_IP" echo -e "\n${YELLOW}Configuration Summary:${NC}" echo -e " Username: $USERNAME" echo -e " Hostname: $SERVER_NAME" echo -e " SSH Port: $SSH_PORT" echo -e " Server IP: $SERVER_IP" if ! confirm "\nContinue with this configuration?" "y"; then print_info "Exiting."; exit 0; fi log "Configuration collected: USER=$USERNAME, HOST=$SERVER_NAME, PORT=$SSH_PORT" } install_packages() { print_section "Package Installation" print_info "Updating package lists and upgrading system..." if ! apt-get update -qq || ! DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq; then print_error "Failed to update or upgrade system packages." exit 1 fi print_info "Installing essential packages..." if ! apt-get install -y -qq \ ufw fail2ban unattended-upgrades chrony \ rsync wget vim htop iotop nethogs netcat-traditional ncdu \ tree rsyslog cron jq gawk coreutils perl skopeo git \ openssh-client openssh-server; then print_error "Failed to install one or more essential packages." exit 1 fi print_success "Essential packages installed." log "Package installation completed." } setup_user() { print_section "User Management" local USER_HOME SSH_DIR AUTH_KEYS PASS1 PASS2 SSH_PUBLIC_KEY if [[ $USER_EXISTS == false ]]; then print_info "Creating user '$USERNAME'..." if ! adduser --disabled-password --gecos "" "$USERNAME"; then print_error "Failed to create user '$USERNAME'." exit 1 fi if ! id "$USERNAME" &>/dev/null; then print_error "User '$USERNAME' creation verification failed." exit 1 fi print_info "Set a password for '$USERNAME' (required for sudo, or press Enter twice to skip for key-only access):" while true; do read -sp "$(echo -e "${CYAN}New password: ${NC}")" PASS1 echo read -sp "$(echo -e "${CYAN}Retype new password: ${NC}")" PASS2 echo if [[ -z "$PASS1" && -z "$PASS2" ]]; then print_warning "Password skipped. Relying on SSH key authentication." log "Password setting skipped for '$USERNAME'." break elif [[ "$PASS1" == "$PASS2" ]]; then # **SECURITY FIX**: Do not tee chpasswd output to log file. if echo "$USERNAME:$PASS1" | chpasswd >/dev/null 2>&1; then print_success "Password for '$USERNAME' updated." break else print_error "Failed to set password. This could be a permissions issue." print_info "Try again or press Enter twice to skip." log "Failed to set password for '$USERNAME'." fi else print_error "Passwords do not match. Please try again." fi done USER_HOME=$(getent passwd "$USERNAME" | cut -d: -f6) SSH_DIR="$USER_HOME/.ssh" AUTH_KEYS="$SSH_DIR/authorized_keys" if confirm "Add SSH public key(s) from your local machine now?"; then while true; do # Loop to allow adding multiple keys local SSH_PUBLIC_KEY # Declare locally to avoid issues read -rp "$(echo -e "${CYAN}Paste your full SSH public key: ${NC}")" SSH_PUBLIC_KEY if validate_ssh_key "$SSH_PUBLIC_KEY"; then mkdir -p "$SSH_DIR" chmod 700 "$SSH_DIR" echo "$SSH_PUBLIC_KEY" >> "$AUTH_KEYS" # De-duplicate keys after adding the new one awk '!seen[$0]++' "$AUTH_KEYS" > "$AUTH_KEYS.tmp" && mv "$AUTH_KEYS.tmp" "$AUTH_KEYS" chmod 600 "$AUTH_KEYS" chown -R "$USERNAME:$USERNAME" "$SSH_DIR" print_success "SSH public key added." log "Added SSH public key for '$USERNAME'." LOCAL_KEY_ADDED=true # Set this flag to true since at least one key was added else print_error "Invalid SSH key format. It should start with 'ssh-rsa', 'ecdsa-*', or 'ssh-ed25519'." fi if ! confirm "Do you have another SSH public key to add?" "n"; then print_info "Finished adding SSH keys." break # User answered 'n', break the loop fi done fi print_success "User '$USERNAME' created." else print_info "Using existing user: $USERNAME" USER_HOME=$(getent passwd "$USERNAME" | cut -d: -f6) SSH_DIR="$USER_HOME/.ssh" AUTH_KEYS="$SSH_DIR/authorized_keys" fi print_info "Adding '$USERNAME' to sudo group..." if ! groups "$USERNAME" | grep -qw sudo; then if ! usermod -aG sudo "$USERNAME"; then print_error "Failed to add '$USERNAME' to sudo group." exit 1 fi print_success "User added to sudo group." else print_info "User '$USERNAME' is already in the sudo group." fi if getent group sudo | grep -qw "$USERNAME"; then print_success "Sudo group membership confirmed for '$USERNAME'." else print_warning "Sudo group membership verification failed. Please check manually with 'sudo -l' as $USERNAME." fi log "User management completed." } configure_system() { print_section "System Configuration" mkdir -p "$BACKUP_DIR" && chmod 700 "$BACKUP_DIR" cp /etc/hosts "$BACKUP_DIR/hosts.backup" cp /etc/fstab "$BACKUP_DIR/fstab.backup" cp /etc/sysctl.conf "$BACKUP_DIR/sysctl.conf.backup" 2>/dev/null || true print_info "Configuring timezone..." while true; do read -rp "$(echo -e "${CYAN}Enter desired timezone (e.g., Etc/UTC, America/New_York) [Etc/UTC]: ${NC}")" TIMEZONE TIMEZONE=${TIMEZONE:-Etc/UTC} if validate_timezone "$TIMEZONE"; then if [[ $(timedatectl status | grep "Time zone" | awk '{print $3}') != "$TIMEZONE" ]]; then timedatectl set-timezone "$TIMEZONE" print_success "Timezone set to $TIMEZONE." log "Timezone set to $TIMEZONE." else print_info "Timezone already set to $TIMEZONE." fi break else print_error "Invalid timezone. View list with 'timedatectl list-timezones'." fi done if confirm "Configure system locales interactively?"; then dpkg-reconfigure locales else print_info "Skipping locale configuration." fi print_info "Configuring hostname..." if [[ $(hostnamectl --static) != "$SERVER_NAME" ]]; then hostnamectl set-hostname "$SERVER_NAME" hostnamectl set-hostname "$PRETTY_NAME" --pretty if grep -q "^127.0.1.1" /etc/hosts; then sed -i "s/^127.0.1.1.*/127.0.1.1\t$SERVER_NAME/" /etc/hosts else echo "127.0.1.1 $SERVER_NAME" >> /etc/hosts fi print_success "Hostname configured: $SERVER_NAME" else print_info "Hostname already set to $SERVER_NAME." fi log "System configuration completed." } configure_ssh() { print_section "SSH Hardening" local CURRENT_SSH_PORT USER_HOME SSH_DIR SSH_KEY AUTH_KEYS NEW_SSH_CONFIG # Ensure openssh-server is installed if ! dpkg -l openssh-server | grep -q ^ii; then print_error "openssh-server package is not installed. Please ensure it is installed." exit 1 fi # Detect SSH service name, preserve socket activation on Ubuntu if active if [[ $ID == "ubuntu" ]] && systemctl is-active ssh.socket >/dev/null 2>&1; then SSH_SERVICE="ssh.socket" print_info "Using SSH socket activation: $SSH_SERVICE" elif [[ $ID == "ubuntu" ]] && { systemctl is-enabled ssh.service >/dev/null 2>&1 || systemctl is-active ssh.service >/dev/null 2>&1; }; then SSH_SERVICE="ssh.service" elif systemctl is-enabled sshd.service >/dev/null 2>&1 || systemctl is-active sshd.service >/dev/null 2>&1; then SSH_SERVICE="sshd.service" elif ps aux | grep -q "[s]shd"; then print_warning "SSH daemon running but no standard service detected." SSH_SERVICE="ssh.service" # Default for Debian if ! systemctl enable --now "$SSH_SERVICE" >/dev/null 2>&1; then print_error "Failed to enable and start $SSH_SERVICE. Attempting manual start..." if ! /usr/sbin/sshd; then print_error "Failed to start SSH daemon manually." exit 1 fi print_success "SSH daemon started manually." fi else print_error "No SSH service or daemon detected. Please verify openssh-server installation and daemon status." exit 1 fi print_info "Using SSH service: $SSH_SERVICE" log "Detected SSH service: $SSH_SERVICE" # Ensure SSH service is enabled and running if ! systemctl is-enabled "$SSH_SERVICE" >/dev/null 2>&1; then if ! systemctl enable "$SSH_SERVICE" >/dev/null 2>&1; then print_error "Failed to enable $SSH_SERVICE. Please check service status." exit 1 fi print_success "SSH service enabled: $SSH_SERVICE" fi if ! systemctl is-active "$SSH_SERVICE" >/dev/null 2>&1; then if ! systemctl start "$SSH_SERVICE" >/dev/null 2>&1; then print_error "Failed to start $SSH_SERVICE. Attempting manual start..." if ! /usr/sbin/sshd; then print_error "Failed to start SSH daemon manually." exit 1 fi print_success "SSH daemon started manually." fi fi CURRENT_SSH_PORT=$(ss -tuln | grep -E ":(22|.*$SSH_SERVICE.*)" | awk '{print $5}' | cut -d':' -f2 | head -n1 || echo "22") USER_HOME=$(getent passwd "$USERNAME" | cut -d: -f6) SSH_DIR="$USER_HOME/.ssh" SSH_KEY="$SSH_DIR/id_ed25519" AUTH_KEYS="$SSH_DIR/authorized_keys" if [[ $LOCAL_KEY_ADDED == false ]] && [[ ! -s "$AUTH_KEYS" ]]; then print_info "No local key provided and no existing keys found. Generating new SSH key..." mkdir -p "$SSH_DIR" chmod 700 "$SSH_DIR" sudo -u "$USERNAME" ssh-keygen -t ed25519 -f "$SSH_KEY" -N "" -q cat "$SSH_KEY.pub" >> "$AUTH_KEYS" chmod 600 "$AUTH_KEYS" chown -R "$USERNAME:$USERNAME" "$SSH_DIR" print_success "SSH key generated." echo -e "${YELLOW}Public key for remote access:${NC}" cat "$SSH_KEY.pub" | tee -a "$LOG_FILE" echo -e "${YELLOW}Copy this key to your local ~/.ssh/authorized_keys or use 'ssh-copy-id -p $CURRENT_SSH_PORT $USERNAME@$SERVER_IP' from your local machine.${NC}" else print_info "SSH key(s) already present or added. Skipping key generation." fi print_warning "SSH Key Authentication Required for Next Steps!" echo -e "${CYAN}Test SSH access from a SEPARATE terminal now: ssh -p $CURRENT_SSH_PORT $USERNAME@$SERVER_IP${NC}" if ! confirm "Can you successfully log in using your SSH key?"; then print_error "SSH key authentication is mandatory to proceed. Please fix and re-run." exit 1 fi print_info "Backing up original SSH config..." SSHD_BACKUP_FILE="$BACKUP_DIR/sshd_config.backup_$(date +%Y%m%d_%H%M%S)" cp /etc/ssh/sshd_config "$SSHD_BACKUP_FILE" # Apply port override based on SSH service type if [[ "$SSH_SERVICE" == "ssh.socket" ]]; then print_info "Configuring SSH socket to listen on port $SSH_PORT..." NEW_SSH_CONFIG=$(mktemp) tee "$NEW_SSH_CONFIG" > /dev/null < /dev/null < /dev/null < /dev/null <<'EOF' ****************************************************************************** 🔒AUTHORIZED ACCESS ONLY ════ all attempts are logged and reviewed ════ ****************************************************************************** EOF fi print_info "Reloading systemd and restarting SSH service..." systemctl daemon-reload if ! systemctl restart "$SSH_SERVICE"; then print_error "SSH service failed to restart! Reverting changes..." rm -f /etc/systemd/system/ssh.service.d/override.conf cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config rm -f /etc/ssh/sshd_config.d/99-hardening.conf systemctl daemon-reload systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true exit 1 fi # Wait and verify port binding sleep 5 if ! ss -tuln | grep -q ":$SSH_PORT"; then print_error "SSH not listening on port $SSH_PORT after restart! Reverting changes..." rm -f /etc/systemd/system/ssh.service.d/override.conf cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config rm -f /etc/ssh/sshd_config.d/99-hardening.conf systemctl daemon-reload systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true exit 1 fi print_success "SSH service restarted on port $SSH_PORT." # Verify root SSH is disabled print_info "Verifying root SSH login is disabled..." if ssh -p "$SSH_PORT" -o BatchMode=yes -o ConnectTimeout=5 root@localhost true 2>/dev/null; then print_error "Root SSH login is still possible! Check SSH configuration." exit 1 else print_success "Confirmed: Root SSH login is disabled." fi print_warning "CRITICAL: Test new SSH connection in a SEPARATE terminal NOW!" print_info "Use: ssh -p $SSH_PORT $USERNAME@$SERVER_IP" # Retry loop for SSH connection test local retry_count=0 local max_retries=3 while (( retry_count < max_retries )); do if confirm "Was the new SSH connection successful?"; then break else (( retry_count++ )) if (( retry_count < max_retries )); then print_info "Retrying SSH connection test ($retry_count/$max_retries)..." sleep 5 else print_error "Aborting. Restoring original SSH configuration." rm -f /etc/systemd/system/ssh.service.d/override.conf cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config rm -f /etc/ssh/sshd_config.d/99-hardening.conf systemctl daemon-reload systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true exit 1 fi fi done log "SSH hardening completed." } configure_firewall() { print_section "Firewall Configuration (UFW)" if ufw status | grep -q "Status: active"; then print_info "UFW already enabled." else print_info "Configuring UFW default policies..." ufw default deny incoming ufw default allow outgoing fi if ! ufw status | grep -qw "$SSH_PORT/tcp"; then print_info "Adding SSH rule for port $SSH_PORT..." ufw allow "$SSH_PORT"/tcp comment 'Custom SSH' else print_info "SSH rule for port $SSH_PORT already exists." fi if confirm "Allow HTTP traffic (port 80)?"; then if ! ufw status | grep -qw "80/tcp"; then ufw allow http comment 'HTTP' print_success "HTTP traffic allowed." else print_info "HTTP rule already exists." fi fi if confirm "Allow HTTPS traffic (port 443)?"; then if ! ufw status | grep -qw "443/tcp"; then ufw allow https comment 'HTTPS' print_success "HTTPS traffic allowed." else print_info "HTTPS rule already exists." fi fi if confirm "Add additional custom ports (e.g., 8080/tcp, 123/udp)?"; then while true; do local CUSTOM_PORTS # Make variable local to the loop read -rp "$(echo -e "${CYAN}Enter ports (space-separated, e.g., 8080/tcp 123/udp): ${NC}")" CUSTOM_PORTS if [[ -z "$CUSTOM_PORTS" ]]; then print_info "No custom ports entered. Skipping." break fi local valid=true for port in $CUSTOM_PORTS; do if ! validate_ufw_port "$port"; then print_error "Invalid port format: $port. Use [/tcp|/udp]." valid=false break fi done if [[ "$valid" == true ]]; then for port in $CUSTOM_PORTS; do if ufw status | grep -qw "$port"; then print_info "Rule for $port already exists." else ufw allow "$port" comment "Custom port $port" print_success "Added rule for $port." log "Added UFW rule for $port." fi done break else print_info "Please try again." fi done fi print_info "Enabling firewall..." if ! ufw --force enable; then print_error "Failed to enable UFW. Check 'journalctl -u ufw' for details." exit 1 fi if ufw status | grep -q "Status: active"; then print_success "Firewall is active." else print_error "UFW failed to activate. Check 'journalctl -u ufw' for details." exit 1 fi print_warning "ACTION REQUIRED: Check your VPS provider's edge firewall to allow opened ports (e.g., $SSH_PORT/tcp)." ufw status verbose | tee -a "$LOG_FILE" log "Firewall configuration completed." } setup_backup() { print_section "Backup Configuration (rsync over SSH)" if ! confirm "Configure rsync-based backups to a remote SSH server?"; then print_info "Skipping backup configuration." return 0 fi # Validate USERNAME if [[ -z "${USERNAME:-}" || ! id "$USERNAME" >/dev/null 2>&1 ]]; then print_error "Invalid or unset USERNAME. Please run user setup first." exit 1 fi print_warning "The backup cron job will run as root to ensure access to all files in /home/$USERNAME." print_info "This requires copying the root SSH key to the remote server after script completion." # Generate SSH key for root (if not exists) local ROOT_SSH_DIR="/root/.ssh" local ROOT_SSH_KEY="$ROOT_SSH_DIR/id_ed25519" if [[ ! -f "$ROOT_SSH_KEY" ]]; then print_info "Generating SSH key for root..." mkdir -p "$ROOT_SSH_DIR" chmod 700 "$ROOT_SSH_DIR" ssh-keygen -t ed25519 -f "$ROOT_SSH_KEY" -N "" -q || { print_error "Failed to generate SSH key." exit 1 } chown -R root:root "$ROOT_SSH_DIR" print_success "Root SSH key generated." else print_info "Root SSH key already exists at $ROOT_SSH_KEY." fi # Clean up stale backup artifacts from previous runs local BACKUP_SCRIPT="/root/backup.sh" local EXCLUDE_FILE="/root/backup_exclude.txt" local CRON_FILE=$(mktemp) if [[ -f "$BACKUP_SCRIPT" ]]; then print_info "Found existing backup script at $BACKUP_SCRIPT. It will be replaced." rm -f "$BACKUP_SCRIPT" || { print_error "Failed to remove stale backup script." exit 1 } fi if [[ -f "$EXCLUDE_FILE" ]]; then print_info "Found existing exclude file مهم at $EXCLUDE_FILE. It will be replaced." rm -f "$EXCLUDE_FILE" || { print_error "Failed to remove stale exclude file." exit 1 } fi # Remove existing backup cron job crontab -u root -l > "$CRON_FILE" 2>/dev/null || true if grep -q "$BACKUP_SCRIPT" "$CRON_FILE"; then print_info "Removing existing backup cron job..." sed -i "\|$BACKUP_SCRIPT|d" "$CRON_FILE" crontab -u root "$CRON_FILE" || { print_error "Failed to update crontab." exit 1 } fi rm -f "$CRON_FILE" # Ask for backup destination details with retry logic local BACKUP_DEST BACKUP_PORT REMOTE_BACKUP_DIR local retry_count=0 local max_retries=3 while [[ $retry_count -lt $max_retries ]]; do read -rp "$(echo -e "${CYAN}Enter backup destination (e.g., user@host or u45555-sub4@u45555.your-storagebox.de): ${NC}")" BACKUP_DEST BACKUP_DEST=${BACKUP_DEST:-user@host} if [[ "$BACKUP_DEST" =~ ^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+$ ]]; then break else print_error "Invalid backup destination format. Expected user@host." (( retry_count++ )) print_info "Please try again ($retry_count/$max_retries attempts)." fi done # If max retries reached, ask for confirmation if [[ $retry_count -ge $max_retries && ! "$BACKUP_DEST" =~ ^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+$ ]]; then if confirm "Proceed with potentially invalid destination '$BACKUP_DEST'?" "n"; then print_info "Proceeding with user-provided destination: $BACKUP_DEST" else print_info "Resetting retries. Please enter a valid backup destination." retry_count=0 while [[ $retry_count -lt $max_retries ]]; do read -rp "$(echo -e "${CYAN}Enter backup destination (e.g., user@host or u45555-sub4@u45555.your-storagebox.de): ${NC}")" BACKUP_DEST BACKUP_DEST=${BACKUP_DEST:-user@host} if [[ "$BACKUP_DEST" =~ ^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+$ ]]; then break else print_error "Invalid backup destination format. Expected user@host." (( retry_count++ )) print_info "Please try again ($retry_count/$max_retries attempts)." fi done # Final check after retry if [[ ! "$BACKUP_DEST" =~ ^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+$ ]]; then print_error "Invalid backup destination format after retries. Exiting." exit 1 fi fi fi read -rp "$(echo -e "${CYAN}Enter SSH port for backup destination [22]: ${NC}")" BACKUP_PORT read -rp "$(echo -e "${CYAN}Enter remote backup path (e.g., /home/myvps_backup/): ${NC}")" REMOTE_BACKUP_DIR BACKUP_PORT=${BACKUP_PORT:-22} REMOTE_BACKUP_DIR=${REMOTE_BACKUP_DIR:-/home/backup/} if ! validate_backup_port "$BACKUP_PORT"; then print_error "Invalid SSH port. Must be between 1 and 65535." exit 1 fi if [[ ! "$REMOTE_BACKUP_DIR" =~ ^/[^[:space:]]*/$ ]]; then print_error "Invalid remote backup path. Must start and end with '/' and contain no spaces." exit 1 fi # Optional SSH key copy attempt if confirm "Attempt to copy SSH key to the backup destination now? (Requires password)"; then if ssh-copy-id -p "$BACKUP_PORT" -s "$BACKUP_DEST" 2>/dev/null; then print_success "SSH key copied successfully." else print_warning "SSH key copy failed. You must manually copy the key later." fi fi # Display SSH key copy instructions print_warning "ACTION REQUIRED: If not already done, copy the root SSH key to the backup destination to enable backups." echo -e "${YELLOW}Root public key:${NC}" cat "$ROOT_SSH_KEY.pub" echo -e "${CYAN}Run this command on your local machine or another terminal:${NC}" echo -e " ssh-copy-id -p $BACKUP_PORT -s $BACKUP_DEST" print_info "You can copy the SSH key later, but the backup cron job will fail until this is done." # Optional SSH connection test if confirm "Test SSH connection to the backup destination (optional)?"; then print_info "Testing connection (timeout: 5 seconds)..." DEST_HOST=$(echo "$BACKUP_DEST" | cut -d'@' -f2) if ssh -p "$BACKUP_PORT" -o BatchMode=yes -o ConnectTimeout=5 "$BACKUP_DEST" exit 2>/dev/null; then print_success "SSH connection successful!" else print_error "SSH connection failed." print_info "Verify the following:" print_info " 1. The key was copied: ${YELLOW}ssh-copy-id -p $BACKUP_PORT -s $BACKUP_DEST${NC}" if command -v nc >/dev/null 2>&1; then print_info " 2. Port $BACKUP_PORT is open: ${YELLOW}nc -zv $DEST_HOST $BACKUP_PORT${NC}" else print_info " 2. Port $BACKUP_PORT is open: ${YELLOW}Contact your network admin or try 'telnet $DEST_HOST $BACKUP_PORT'${NC}" fi fi fi # Create exclude file print_info "Creating rsync exclude file at $EXCLUDE_FILE..." cat > "$EXCLUDE_FILE" <> "$EXCLUDE_FILE" done fi chmod 600 "$EXCLUDE_FILE" || { print_error "Failed to set permissions on exclude file." exit 1 } print_success "Rsync exclude file created." # Ask for cron schedule print_info "Configuring cron schedule for backups..." read -rp "$(echo -e "${CYAN}Enter cron schedule (e.g., '0 3 * * *' for daily at 3 AM): ${NC}")" CRON_SCHEDULE CRON_SCHEDULE=${CRON_SCHEDULE:-0 3 * * *} if ! echo "$CRON_SCHEDULE" | grep -qE '^((\*|[0-9,-]+(/[0-9]+)?)\s*){5}$'; then print_error "Invalid cron expression. Using default daily at 3 AM." CRON_SCHEDULE="0 3 * * *" fi # Ask for notification preference local NOTIFICATION_SETUP="none" NTFY_URL NTFY_TOPIC NTFY_TOKEN DISCORD_WEBHOOK if confirm "Enable backup notifications?"; then echo -e "${CYAN}Choose notification method:${NC}" echo -e " 1) ntfy" echo -e " 2) Discord" echo -e " 3) None" read -rp "$(echo -e "${CYAN}Enter choice (1-3): ${NC}")" NOTIFICATION_CHOICE case "$NOTIFICATION_CHOICE" in 1) read -rp "$(echo -e "${CYAN}Enter ntfy URL (e.g., https://ntfy.sh): ${NC}")" NTFY_URL read -rp "$(echo -e "${CYAN}Enter ntfy topic: ${NC}")" NTFY_TOPIC read -rp "$(echo -e "${CYAN}Enter ntfy token (optional, press Enter to skip): ${NC}")" NTFY_TOKEN NTFY_URL=${NTFY_URL:-https://ntfy.sh} NTFY_TOPIC=${NTFY_TOPIC:-vps-backups} NOTIFICATION_SETUP="ntfy" ;; 2) read -rp "$(echo -e "${CYAN}Enter Discord webhook URL: ${NC}")" DISCORD_WEBHOOK if [[ ! "$DISCORD_WEBHOOK" =~ ^https://discord.com/api/webhooks/ ]]; then print_error "Invalid Discord webhook URL." exit 1 fi NOTIFICATION_SETUP="discord" ;; *) NOTIFICATION_SETUP="none" ;; esac fi # Create backup script print_info "Creating backup script at $BACKUP_SCRIPT..." cat > "$BACKUP_SCRIPT" <> "$BACKUP_SCRIPT" <> "$BACKUP_SCRIPT" <> "$BACKUP_SCRIPT" <<'EOF' # Notification function send_notification() { local title="$1" local message="$2" local priority="${3:-default}" local color=65280 # Green for success if [[ "$title" == *"FAILED"* ]]; then color=16711680 # Red for failure fi EOF if [[ "$NOTIFICATION_SETUP" == "ntfy" ]]; then cat >> "$BACKUP_SCRIPT" < /dev/null 2>> "$LOG_FILE" EOF elif [[ "$NOTIFICATION_SETUP" == "discord" ]]; then cat >> "$BACKUP_SCRIPT" < /dev/null 2>> "$LOG_FILE" EOF else cat >> "$BACKUP_SCRIPT" <<'EOF' : # No notifications configured EOF fi cat >> "$BACKUP_SCRIPT" <<'EOF' } # Format backup stats format_backup_stats() { local stats_line stats_line=$("$GREP_CMD" 'Total transferred file size' "$LOG_FILE" | tail -n 1) if [ -n "$stats_line" ]; then local bytes bytes=$(echo "$stats_line" | "$AWK_CMD" '{gsub(/,/, ""); print $5}') if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then local human_readable human_readable=$("$NUMFMT_CMD" --to=iec-i --suffix=B --format="%.2f" "$bytes") printf "Data Transferred: $human_readable" else printf "Data Transferred: 0 B (No changes)" fi else printf "See log for statistics." fi } # Dependency check for cmd in "$RSYNC_CMD" "$CURL_CMD" "$NC_CMD" "$AWK_CMD" "$NUMFMT_CMD" "$GREP_CMD" "$HOSTNAME_CMD" "$DATE_CMD" "$STAT_CMD" "$MV_CMD" "$TOUCH_CMD"; do if ! command -v "$cmd" &>/dev/null; then echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] FATAL: Required command not found at '$cmd'" >> "$LOG_FILE" send_notification "❌ Backup FAILED: $("$HOSTNAME_CMD")" "Required command not found at '$cmd'" "high" exit 10 fi done # Pre-flight checks if [[ ! -f "$EXCLUDE_FILE" ]]; then echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] FATAL: Exclude file not found at $EXCLUDE_FILE" >> "$LOG_FILE" send_notification "❌ Backup FAILED: $("$HOSTNAME_CMD")" "Exclude file not found at $EXCLUDE_FILE" "high" exit 3 fi if [[ "$LOCAL_DIR" != */ ]]; then echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] FATAL: LOCAL_DIR must end with a trailing slash" >> "$LOG_FILE" send_notification "❌ Backup FAILED: $("$HOSTNAME_CMD")" "LOCAL_DIR must end with a trailing slash" "high" exit 2 fi # Log rotation if [ -f "$LOG_FILE" ] && [ "$("$STAT_CMD" -c%s "$LOG_FILE")" -gt "$MAX_LOG_SIZE" ]; then "$MV_CMD" "$LOG_FILE" "${LOG_FILE}.$("$DATE_CMD" +%Y%m%d_%H%M%S)" "$TOUCH_CMD" "$LOG_FILE" fi # Network connectivity check DEST_HOST=$(echo "$BACKUP_DEST" | cut -d'@' -f2) if ! "$NC_CMD" -z -w 5 "$DEST_HOST" "$SSH_PORT"; then echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] FATAL: Cannot reach $DEST_HOST on port $SSH_PORT" >> "$LOG_FILE" send_notification "❌ Backup FAILED: $("$HOSTNAME_CMD")" "Cannot reach $DEST_HOST on port $SSH_PORT" "high" exit 4 fi # Rsync backup echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] Starting rsync backup for $("$HOSTNAME_CMD")" >> "$LOG_FILE" if LC_ALL=C "$RSYNC_CMD" -avz --stats --delete --partial --timeout=60 --exclude-from="$EXCLUDE_FILE" -e "ssh -p $SSH_PORT" "$LOCAL_DIR" "$BACKUP_DEST:$REMOTE_DIR" >> "$LOG_FILE" 2>&1; then echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] SUCCESS: rsync completed successfully." >> "$LOG_FILE" BACKUP_STATS=$(format_backup_stats) send_notification "✅ Backup SUCCESS: $("$HOSTNAME_CMD")" "rsync backup completed successfully.\n\n$BACKUP_STATS" else EXIT_CODE=$? echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] FAILED: rsync exited with status code: $EXIT_CODE" >> "$LOG_FILE" send_notification "❌ Backup FAILED: $("$HOSTNAME_CMD")" "rsync failed with exit code $EXIT_CODE. Check log: $LOG_FILE" "high" exit $EXIT_CODE fi echo "[$("$DATE_CMD" '+%Y-%m-%d %H:%M:%S')] Run Finished" >> "$LOG_FILE" EOF chmod 700 "$BACKUP_SCRIPT" || { print_error "Failed to set permissions on backup script." exit 1 } print_success "Backup script created at $BACKUP_SCRIPT." # Add to crontab print_info "Adding cron job for root..." CRON_FILE=$(mktemp) crontab -u root -l > "$CRON_FILE" 2>/dev/null || true if grep -q "$BACKUP_SCRIPT" "$CRON_FILE"; then print_info "Cron job for $BACKUP_SCRIPT already exists. Updating schedule..." sed -i "\|$BACKUP_SCRIPT|d" "$CRON_FILE" fi echo "$CRON_SCHEDULE $BACKUP_SCRIPT" >> "$CRON_FILE" crontab -u root "$CRON_FILE" || { print_error "Failed to update crontab." exit 1 } rm -f "$CRON_FILE" print_success "Cron job added for root: $CRON_SCHEDULE $BACKUP_SCRIPT" log "Backup configuration completed." } configure_fail2ban() { print_section "Fail2Ban Configuration" # Set the SSH port for Fail2Ban to monitor. local SSH_PORTS_TO_MONITOR="$SSH_PORT" local NEW_FAIL2BAN_CONFIG NEW_FAIL2BAN_CONFIG=$(mktemp) tee "$NEW_FAIL2BAN_CONFIG" > /dev/null </dev/null 2>&1; then print_info "Docker already installed." return 0 fi print_info "Removing old container runtimes..." apt-get remove -y -qq docker docker-engine docker.io containerd runc 2>/dev/null || true print_info "Adding Docker's official GPG key and repository..." install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${ID} $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list print_info "Installing Docker packages..." if ! apt-get update -qq || ! apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then print_error "Failed to install Docker packages." exit 1 fi print_info "Adding '$USERNAME' to docker group..." getent group docker >/dev/null || groupadd docker if ! groups "$USERNAME" | grep -qw docker; then usermod -aG docker "$USERNAME" print_success "User '$USERNAME' added to docker group." else print_info "User '$USERNAME' is already in docker group." fi print_info "Configuring Docker daemon..." local NEW_DOCKER_CONFIG NEW_DOCKER_CONFIG=$(mktemp) tee "$NEW_DOCKER_CONFIG" > /dev/null <&1 | tee -a "$LOG_FILE" | grep -q "Hello from Docker"; then print_success "Docker sanity check passed." else print_error "Docker hello-world test failed. Please verify installation." exit 1 fi print_warning "NOTE: '$USERNAME' must log out and back in to use Docker without sudo." log "Docker installation completed." } install_tailscale() { if ! confirm "Install Tailscale VPN (Optional)?"; then print_info "Skipping Tailscale installation." return 0 fi print_section "Tailscale VPN Installation" if command -v tailscale >/dev/null 2>&1; then print_info "Tailscale already installed." return 0 fi print_info "Installing Tailscale..." curl -fsSL https://tailscale.com/install.sh -o /tmp/tailscale_install.sh chmod +x /tmp/tailscale_install.sh # Simple sanity check on the downloaded script if ! grep -q "tailscale" /tmp/tailscale_install.sh; then print_error "Downloaded Tailscale install script appears invalid." rm -f /tmp/tailscale_install.sh exit 1 fi if ! /tmp/tailscale_install.sh; then print_error "Failed to install Tailscale." rm -f /tmp/tailscale_install.sh exit 1 fi rm -f /tmp/tailscale_install.sh print_warning "ACTION REQUIRED: Run 'sudo tailscale up' after script finishes." print_success "Tailscale installation complete." log "Tailscale installation completed." } configure_swap() { if [[ $IS_CONTAINER == true ]]; then print_info "Swap configuration skipped in container." return 0 fi print_section "Swap Configuration" local existing_swap existing_swap=$(swapon --show --noheadings | awk '{print $1}' || true) if [[ -n "$existing_swap" ]]; then local current_size current_size=$(ls -lh "$existing_swap" | awk '{print $5}') print_info "Existing swap file found: $existing_swap ($current_size)" if confirm "Modify existing swap file size?"; then local SWAP_SIZE while true; do read -rp "$(echo -e "${CYAN}Enter new swap size (e.g., 2G, 512M) [current: $current_size]: ${NC}")" SWAP_SIZE SWAP_SIZE=${SWAP_SIZE:-$current_size} if validate_swap_size "$SWAP_SIZE"; then break else print_error "Invalid size. Use format like '2G' or '512M'." fi done local REQUIRED_SPACE REQUIRED_SPACE=$(convert_to_bytes "$SWAP_SIZE") local AVAILABLE_SPACE AVAILABLE_SPACE=$(df -k / | tail -n 1 | awk '{print $4}') if (( AVAILABLE_SPACE < REQUIRED_SPACE / 1024 )); then print_error "Insufficient disk space for $SWAP_SIZE swap file. Available: $((AVAILABLE_SPACE / 1024))MB" exit 1 fi print_info "Disabling existing swap file..." swapoff "$existing_swap" || { print_error "Failed to disable swap file."; exit 1; } print_info "Resizing swap file to $SWAP_SIZE..." if ! fallocate -l "$SWAP_SIZE" "$existing_swap" || ! chmod 600 "$existing_swap" || ! mkswap "$existing_swap" || ! swapon "$existing_swap"; then print_error "Failed to resize or enable swap file." exit 1 fi print_success "Swap file resized to $SWAP_SIZE." else print_info "Keeping existing swap file." return 0 fi else if ! confirm "Configure a swap file (recommended for < 4GB RAM)?"; then print_info "Skipping swap configuration." return 0 fi local SWAP_SIZE while true; do read -rp "$(echo -e "${CYAN}Enter swap file size (e.g., 2G, 512M) [2G]: ${NC}")" SWAP_SIZE SWAP_SIZE=${SWAP_SIZE:-2G} if validate_swap_size "$SWAP_SIZE"; then break else print_error "Invalid size. Use format like '2G' or '512M'." fi done local REQUIRED_SPACE REQUIRED_SPACE=$(convert_to_bytes "$SWAP_SIZE") local AVAILABLE_SPACE AVAILABLE_SPACE=$(df -k / | tail -n 1 | awk '{print $4}') if (( AVAILABLE_SPACE < REQUIRED_SPACE / 1024 )); then print_error "Insufficient disk space for $SWAP_SIZE swap file. Available: $((AVAILABLE_SPACE / 1024))MB" exit 1 fi print_info "Creating $SWAP_SIZE swap file..." if ! fallocate -l "$SWAP_SIZE" /swapfile || ! chmod 600 /swapfile || ! mkswap /swapfile || ! swapon /swapfile; then print_error "Failed to create or enable swap file." rm -f /swapfile || true exit 1 fi # Check for existing swap entry in /etc/fstab to prevent duplicates if grep -q '^/swapfile ' /etc/fstab; then print_info "Swap entry already exists in /etc/fstab. Skipping." else echo '/swapfile none swap sw 0 0' >> /etc/fstab print_success "Swap entry added to /etc/fstab." fi print_success "Swap file created: $SWAP_SIZE" fi print_info "Configuring swap settings..." local SWAPPINESS=10 local CACHE_PRESSURE=50 if confirm "Customize swap settings (vm.swappiness and vm.vfs_cache_pressure)?"; then while true; do read -rp "$(echo -e "${CYAN}Enter vm.swappiness (0-100) [default: $SWAPPINESS]: ${NC}")" INPUT_SWAPPINESS INPUT_SWAPPINESS=${INPUT_SWAPPINESS:-$SWAPPINESS} if [[ "$INPUT_SWAPPINESS" =~ ^[0-9]+$ && "$INPUT_SWAPPINESS" -ge 0 && "$INPUT_SWAPPINESS" -le 100 ]]; then SWAPPINESS=$INPUT_SWAPPINESS break else print_error "Invalid value for vm.swappiness. Must be between 0 and 100." fi done while true; do read -rp "$(echo -e "${CYAN}Enter vm.vfs_cache_pressure (1-1000) [default: $CACHE_PRESSURE]: ${NC}")" INPUT_CACHE_PRESSURE INPUT_CACHE_PRESSURE=${INPUT_CACHE_PRESSURE:-$CACHE_PRESSURE} if [[ "$INPUT_CACHE_PRESSURE" =~ ^[0-9]+$ && "$INPUT_CACHE_PRESSURE" -ge 1 && "$INPUT_CACHE_PRESSURE" -le 1000 ]]; then CACHE_PRESSURE=$INPUT_CACHE_PRESSURE break else print_error "Invalid value for vm.vfs_cache_pressure. Must be between 1 and 1000." fi done else print_info "Using default swap settings (vm.swappiness=$SWAPPINESS, vm.vfs_cache_pressure=$CACHE_PRESSURE)." fi local NEW_SWAP_CONFIG NEW_SWAP_CONFIG=$(mktemp) tee "$NEW_SWAP_CONFIG" > /dev/null </dev/null 2>&1; then if systemctl is-active --quiet docker; then print_success "Service docker is active." else print_error "Service docker is NOT active." fi fi if command -v tailscale >/dev/null 2>&1; then if systemctl is-active --quiet tailscaled; then print_success "Service tailscaled is active." else print_error "Service tailscaled is NOT active." fi fi echo -e "\n${GREEN}Server setup and hardening script has finished successfully.${NC}" echo echo -e "${YELLOW}Configuration Summary:${NC}" echo -e " Admin User: $USERNAME" echo -e " Hostname: $SERVER_NAME" echo -e " SSH Port: $SSH_PORT" echo -e " Server IP: $SERVER_IP" if [[ -f /root/backup.sh ]]; then local CRON_SCHEDULE=$(crontab -u root -l 2>/dev/null | grep -F "/root/backup.sh" | awk '{print $1, $2, $3, $4, $5}' || echo "Not configured") local NOTIFICATION_STATUS="None" local BACKUP_DEST=$(grep "^BACKUP_DEST=" /root/backup.sh | cut -d'"' -f2 || echo "Unknown") local BACKUP_PORT=$(grep "^SSH_PORT=" /root/backup.sh | cut -d'"' -f2 || echo "Unknown") local REMOTE_BACKUP_DIR=$(grep "^REMOTE_DIR=" /root/backup.sh | cut -d'"' -f2 || echo "Unknown") if grep -q "NTFY_URL" /root/backup.sh; then NOTIFICATION_STATUS="ntfy" elif grep -q "DISCORD_WEBHOOK" /root/backup.sh; then NOTIFICATION_STATUS="Discord" fi echo -e " Remote Backup: Enabled" echo -e " - Backup Script: /root/backup.sh" echo -e " - Destination: $BACKUP_DEST" echo -e " - SSH Port: $BACKUP_PORT" echo -e " - Remote Path: $REMOTE_BACKUP_DIR" echo -e " - Cron Schedule: $CRON_SCHEDULE" echo -e " - Notifications: $NOTIFICATION_STATUS" else echo -e " Remote Backup: Not configured" fi echo echo -e "${PURPLE}Log File: ${LOG_FILE}${NC}" echo -e "${PURPLE}Backups: ${BACKUP_DIR}${NC}" echo echo -e "${CYAN}Post-Reboot Verification Steps:${NC}" echo -e " - SSH access: ssh -p $SSH_PORT $USERNAME@$SERVER_IP" echo -e " - Firewall rules: sudo ufw status verbose" echo -e " - Time sync: chronyc tracking" echo -e " - Fail2Ban status: sudo fail2ban-client status sshd" echo -e " - Swap status: sudo swapon --show && free -h" echo -e " - Hostname: hostnamectl" if command -v docker >/dev/null 2>&1; then echo -e " - Docker status: docker ps" fi if command -v tailscale >/dev/null 2>&1; then echo -e " - Tailscale status: tailscale status" fi if [[ -f /root/backup.sh ]]; then echo -e " - Remote Backup:" echo -e " - Verify SSH key: cat /root/.ssh/id_ed25519.pub" echo -e " - Copy key if needed: ssh-copy-id -p $BACKUP_PORT -s $BACKUP_DEST" echo -e " - Test backup: sudo /root/backup.sh" echo -e " - Check logs: sudo less /var/log/backup_*.log" fi print_warning "\nACTION REQUIRED: If remote backup is enabled, ensure the root SSH key is copied to the destination server." print_warning "A reboot is required to apply all changes cleanly." if [[ $VERBOSE == true ]]; then if confirm "Reboot now?" "y"; then print_info "Rebooting now..." sleep 3 reboot else print_warning "Please reboot manually with 'sudo reboot'." fi else print_warning "Quiet mode enabled. Please reboot manually with 'sudo reboot'." fi log "Script finished successfully." } handle_error() { local exit_code=$? local line_no="$1" print_error "An error occurred on line $line_no (exit code: $exit_code)." print_info "Log file: $LOG_FILE" print_info "Backups: $BACKUP_DIR" exit $exit_code } main() { trap 'handle_error $LINENO' ERR touch "$LOG_FILE" && chmod 600 "$LOG_FILE" log "Starting Debian/Ubuntu hardening script." print_header check_dependencies check_system collect_config install_packages setup_user configure_system configure_ssh configure_firewall configure_fail2ban configure_auto_updates configure_time_sync install_docker install_tailscale setup_backup configure_swap final_cleanup generate_summary } # Run main function main "$@"