From 430c7f058e831c4a7dd6729b52b373377915720e Mon Sep 17 00:00:00 2001 From: buildplan <170122315+buildplan@users.noreply.github.com> Date: Thu, 26 Jun 2025 16:22:23 +0100 Subject: [PATCH] Create setup_harden_debian_ubuntu.sh --- setup_harden_debian_ubuntu.sh | 1366 +++++++++++++++++++++++++++++++++ 1 file changed, 1366 insertions(+) create mode 100644 setup_harden_debian_ubuntu.sh diff --git a/setup_harden_debian_ubuntu.sh b/setup_harden_debian_ubuntu.sh new file mode 100644 index 0000000..a42fa3d --- /dev/null +++ b/setup_harden_debian_ubuntu.sh @@ -0,0 +1,1366 @@ +#!/bin/bash + +# Debian/Ubuntu Server Setup and Hardening Script +# Version: 4.0 | 2025-06-26 +# Compatible with: Debian 12 (Bookworm), Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS, 24.10 (experimental) +# +# See README.md for full documentation. + +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 +CONFIG_FILE="" +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="" +SKIPPED_SETTINGS=() +PROMPTED_SETTINGS=() + +# --- PARSE ARGUMENTS --- +while [[ $# -gt 0 ]]; do + case $1 in + --quiet) VERBOSE=false; shift ;; + --config) CONFIG_FILE="$2"; shift 2 ;; + *) 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.0 | 2025-06-26 ║${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_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="$1" + [[ "$size" =~ ^[0-9]+[MG]$ ]] && [[ "${size%[MG]}" -ge 1 ]] +} + +validate_ufw_port() { + local port="$1" + [[ "$port" =~ ^[0-9]+(/tcp|/udp)?$ ]] +} + +validate_url() { + local url="$1" + [[ "$url" =~ ^https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?$ ]] +} + +validate_email() { + local email="$1" + [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] +} + +validate_smtp_port() { + local port="$1" + [[ "$port" =~ ^[0-9]+$ && "$port" =~ ^(25|2525|8025|587|80|465|8465|443)$ ]] +} + +validate_ntfy_token() { + local token="$1" + [[ "$token" =~ ^tk_[a-zA-Z0-9_-]+$ || -z "$token" ]] +} + +convert_to_bytes() { + local size="$1" + local unit="${size: -1}" + local value="${size%[MG]}" + if [[ "$unit" == "G" ]]; then + echo $((value * 1024 * 1024 * 1024)) + elif [[ "$unit" == "M" ]]; then + echo $((value * 1024 * 1024)) + else + echo 0 + fi +} + +# --- CONFIG FILE LOADING --- + +load_config() { + local config_file="$1" + if [[ ! -f "$config_file" ]]; then + print_error "Config file $config_file not found." + return 1 + fi + print_info "Loaded configuration from $config_file" + log "Loaded configuration from $config_file" + source "$config_file" + + # Initialize defaults + USERNAME="${USERNAME:-}" + HOSTNAME="${HOSTNAME:-}" + SSH_PORT="${SSH_PORT:-5595}" + TIMEZONE="${TIMEZONE:-Etc/UTC}" + SWAP_SIZE="${SWAP_SIZE:-2G}" + UFW_PORTS="${UFW_PORTS:-}" + AUTO_UPDATES="${AUTO_UPDATES:-no}" + INSTALL_DOCKER="${INSTALL_DOCKER:-no}" + INSTALL_TAILSCALE="${INSTALL_TAILSCALE:-no}" + TAILSCALE_LOGIN_SERVER="${TAILSCALE_LOGIN_SERVER:-}" + TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:-}" + TAILSCALE_OPERATOR="${TAILSCALE_OPERATOR:-${USERNAME:-}}" + TAILSCALE_ACCEPT_DNS="${TAILSCALE_ACCEPT_DNS:-yes}" + TAILSCALE_ACCEPT_ROUTES="${TAILSCALE_ACCEPT_ROUTES:-yes}" + SMTP_SERVER="${SMTP_SERVER:-}" + SMTP_PORT="${SMTP_PORT:-}" + SMTP_FROM="${SMTP_FROM:-}" + SMTP_TO="${SMTP_TO:-}" + NTFY_SERVER="${NTFY_SERVER:-}" + NTFY_TOKEN="${NTFY_TOKEN:-}" + + # Validate required fields + local errors=() + if [[ -z "$USERNAME" ]]; then + errors+=("Missing USERNAME") + elif ! validate_username "$USERNAME"; then + errors+=("Invalid USERNAME") + fi + if [[ -z "$HOSTNAME" ]]; then + errors+=("Missing HOSTNAME") + elif ! validate_hostname "$HOSTNAME"; then + errors+=("Invalid HOSTNAME") + fi + if [[ -z "$SSH_PORT" ]]; then + errors+=("Missing SSH_PORT") + elif ! validate_port "$SSH_PORT"; then + errors+=("Invalid SSH_PORT") + fi + if [[ -z "$TIMEZONE" ]]; then + errors+=("Missing TIMEZONE") + elif ! validate_timezone "$TIMEZONE"; then + errors+=("Invalid TIMEZONE") + fi + if [[ -n "$SWAP_SIZE" && ! "$SWAP_SIZE" =~ ^[0-9]+[MG]$ ]]; then + errors+=("Invalid SWAP_SIZE") + fi + if [[ -n "$UFW_PORTS" ]]; then + for port in ${UFW_PORTS//,/ }; do + if ! validate_ufw_port "$port"; then + errors+=("Invalid UFW_PORTS format: $port") + fi + done + fi + if [[ -n "$AUTO_UPDATES" && ! "$AUTO_UPDATES" =~ ^(yes|no)$ ]]; then + errors+=("Invalid AUTO_UPDATES (must be yes/no)") + fi + if [[ -n "$INSTALL_DOCKER" && ! "$INSTALL_DOCKER" =~ ^(yes|no)$ ]]; then + errors+=("Invalid INSTALL_DOCKER (must be yes/no)") + fi + if [[ -n "$INSTALL_TAILSCALE" && ! "$INSTALL_TAILSCALE" =~ ^(yes|no)$ ]]; then + errors+=("Invalid INSTALL_TAILSCALE (must be yes/no)") + fi + if [[ "$INSTALL_TAILSCALE" == "yes" && -n "$TAILSCALE_LOGIN_SERVER" ]]; then + if ! validate_url "$TAILSCALE_LOGIN_SERVER"; then + errors+=("Invalid TAILSCALE_LOGIN_SERVER") + fi + if [[ -z "$TAILSCALE_AUTH_KEY" ]]; then + errors+=("Missing TAILSCALE_AUTH_KEY (required with TAILSCALE_LOGIN_SERVER)") + fi + if [[ -z "$TAILSCALE_OPERATOR" ]]; then + errors+=("Missing TAILSCALE_OPERATOR") + elif ! validate_username "$TAILSCALE_OPERATOR"; then + errors+=("Invalid TAILSCALE_OPERATOR") + fi + if [[ -n "$TAILSCALE_ACCEPT_DNS" && ! "$TAILSCALE_ACCEPT_DNS" =~ ^(yes|no)$ ]]; then + errors+=("Invalid TAILSCALE_ACCEPT_DNS (must be yes/no)") + fi + if [[ -n "$TAILSCALE_ACCEPT_ROUTES" && ! "$TAILSCALE_ACCEPT_ROUTES" =~ ^(yes|no)$ ]]; then + errors+=("Invalid TAILSCALE_ACCEPT_ROUTES (must be yes/no)") + fi + fi + if [[ -n "$SMTP_SERVER" ]]; then + if [[ ! "$SMTP_SERVER" =~ ^[a-zA-Z0-9.-]+$ ]]; then + errors+=("Invalid SMTP_SERVER") + fi + if [[ -z "$SMTP_PORT" ]]; then + errors+=("Missing SMTP_PORT") + elif ! validate_smtp_port "$SMTP_PORT"; then + errors+=("Invalid SMTP_PORT") + fi + if [[ -z "$SMTP_FROM" ]]; then + errors+=("Missing SMTP_FROM") + elif ! validate_email "$SMTP_FROM"; then + errors+=("Invalid SMTP_FROM") + fi + if [[ -z "$SMTP_TO" ]]; then + errors+=("Missing SMTP_TO") + elif ! validate_email "$SMTP_TO"; then + errors+=("Invalid SMTP_TO") + fi + fi + if [[ -n "$NTFY_SERVER" ]]; then + if ! validate_url "$NTFY_SERVER"; then + errors+=("Invalid NTFY_SERVER") + fi + if [[ -z "$NTFY_TOKEN" && "$VERBOSE" == false ]]; then + errors+=("Missing NTFY_TOKEN (skipping ntfy in quiet mode)") + SKIPPED_SETTINGS+=("ntfy") + NTFY_SERVER="" + NTFY_TOKEN="" + elif [[ -n "$NTFY_TOKEN" && ! "$NTFY_TOKEN" =~ ^tk_[a-zA-Z0-9_-]+$ ]]; then + errors+=("Invalid NTFY_TOKEN") + fi + fi + + if [[ ${#errors[@]} -gt 0 ]]; then + for error in "${errors[@]}"; do + print_error "$error" + done + if [[ $VERBOSE == true ]]; then + print_info "Prompting for missing/invalid required settings..." + [[ -z "$USERNAME" || ! $(validate_username "$USERNAME") ]] && prompt_username + [[ -z "$HOSTNAME" || ! $(validate_hostname "$HOSTNAME") ]] && prompt_hostname + [[ -z "$SSH_PORT" || ! $(validate_port "$SSH_PORT") ]] && prompt_ssh_port + [[ -z "$TIMEZONE" || ! $(validate_timezone "$TIMEZONE") ]] && prompt_timezone + [[ -n "$SWAP_SIZE" && ! $(validate_swap_size "$SWAP_SIZE") ]] && prompt_swap_size + if [[ -n "$UFW_PORTS" ]]; then + local valid_ports=true + for port in ${UFW_PORTS//,/ }; do + if ! validate_ufw_port "$port"; then + valid_ports=false + break + fi + done + [[ "$valid_ports" == false ]] && prompt_ufw_ports + fi + [[ -n "$AUTO_UPDATES" && ! "$AUTO_UPDATES" =~ ^(yes|no)$ ]] && prompt_auto_updates + [[ -n "$INSTALL_DOCKER" && ! "$INSTALL_DOCKER" =~ ^(yes|no)$ ]] && prompt_install_docker + [[ -n "$INSTALL_TAILSCALE" && ! "$INSTALL_TAILSCALE" =~ ^(yes|no)$ ]] && prompt_install_tailscale + if [[ "$INSTALL_TAILSCALE" == "yes" && -n "$TAILSCALE_LOGIN_SERVER" ]]; then + [[ ! $(validate_url "$TAILSCALE_LOGIN_SERVER") ]] && prompt_tailscale_login_server + [[ -z "$TAILSCALE_AUTH_KEY" ]] && prompt_tailscale_auth_key + [[ -z "$TAILSCALE_OPERATOR" || ! $(validate_username "$TAILSCALE_OPERATOR") ]] && prompt_tailscale_operator + [[ -n "$TAILSCALE_ACCEPT_DNS" && ! "$TAILSCALE_ACCEPT_DNS" =~ ^(yes|no)$ ]] && prompt_tailscale_accept_dns + [[ -n "$TAILSCALE_ACCEPT_ROUTES" && ! "$TAILSCALE_ACCEPT_ROUTES" =~ ^(yes|no)$ ]] && prompt_tailscale_accept_routes + fi + if [[ -n "$SMTP_SERVER" ]]; then + [[ ! "$SMTP_SERVER" =~ ^[a-zA-Z0-9.-]+$ ]] && prompt_smtp_server + [[ -z "$SMTP_PORT" || ! $(validate_smtp_port "$SMTP_PORT") ]] && prompt_smtp_port + [[ -z "$SMTP_FROM" || ! $(validate_email "$SMTP_FROM") ]] && prompt_smtp_from + [[ -z "$SMTP_TO" || ! $(validate_email "$SMTP_TO") ]] && prompt_smtp_to + fi + if [[ -n "$NTFY_SERVER" ]]; then + [[ ! $(validate_url "$NTFY_SERVER") ]] && prompt_ntfy_server + [[ -z "$NTFY_TOKEN" || ! $(validate_ntfy_token "$NTFY_TOKEN") ]] && prompt_ntfy_token + fi + return 0 + else + print_error "Invalid or missing configuration in quiet mode. Exiting." + exit 1 + fi + fi + return 0 +} + +prompt_username() { + 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 + PROMPTED_SETTINGS+=("USERNAME") +} + +prompt_hostname() { + while true; do + read -rp "$(echo -e "${CYAN}Enter server hostname: ${NC}")" HOSTNAME + if validate_hostname "$HOSTNAME"; then + SERVER_NAME="$HOSTNAME" + PRETTY_NAME="${PRETTY_NAME:-$HOSTNAME}" + break + else + print_error "Invalid hostname." + fi + done + PROMPTED_SETTINGS+=("HOSTNAME") +} + +prompt_ssh_port() { + while true; do + read -rp "$(echo -e "${CYAN}Enter custom SSH port (1024-65535) [5595]: ${NC}")" SSH_PORT + SSH_PORT=${SSH_PORT:-5595} + if validate_port "$SSH_PORT"; then break; else print_error "Invalid port number."; fi + done + PROMPTED_SETTINGS+=("SSH_PORT") +} + +prompt_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 break; else print_error "Invalid timezone."; fi + done + PROMPTED_SETTINGS+=("TIMEZONE") +} + +prompt_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 + PROMPTED_SETTINGS+=("SWAP_SIZE") +} + +prompt_ufw_ports() { + if confirm "Add custom UFW ports (e.g., 80/tcp, 443/tcp)?"; then + while true; do + read -rp "$(echo -e "${CYAN}Enter ports (comma-separated, e.g., 80/tcp,443/tcp): ${NC}")" UFW_PORTS + if [[ -z "$UFW_PORTS" ]]; then + print_info "No custom ports entered. Skipping." + UFW_PORTS="" + break + fi + local valid=true + for port in ${UFW_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 break; fi + done + PROMPTED_SETTINGS+=("UFW_PORTS") + else + UFW_PORTS="" + fi +} + +prompt_auto_updates() { + AUTO_UPDATES="no" + confirm "Enable automatic security updates?" && AUTO_UPDATES="yes" + PROMPTED_SETTINGS+=("AUTO_UPDATES") +} + +prompt_install_docker() { + INSTALL_DOCKER="no" + confirm "Install Docker Engine?" && INSTALL_DOCKER="yes" + PROMPTED_SETTINGS+=("INSTALL_DOCKER") +} + +prompt_install_tailscale() { + INSTALL_TAILSCALE="no" + confirm "Install Tailscale VPN?" && INSTALL_TAILSCALE="yes" + PROMPTED_SETTINGS+=("INSTALL_TAILSCALE") +} + +prompt_tailscale_login_server() { + read -rp "$(echo -e "${CYAN}Enter Tailscale login server (e.g., https://hs.mydomain.com, press Enter to skip): ${NC}")" TAILSCALE_LOGIN_SERVER + if [[ -n "$TAILSCALE_LOGIN_SERVER" && ! $(validate_url "$TAILSCALE_LOGIN_SERVER") ]]; then + print_error "Invalid Tailscale login server URL." + TAILSCALE_LOGIN_SERVER="" + fi + PROMPTED_SETTINGS+=("TAILSCALE_LOGIN_SERVER") +} + +prompt_tailscale_auth_key() { + read -rp "$(echo -e "${CYAN}Enter Tailscale auth key: ${NC}")" TAILSCALE_AUTH_KEY + PROMPTED_SETTINGS+=("TAILSCALE_AUTH_KEY") +} + +prompt_tailscale_operator() { + read -rp "$(echo -e "${CYAN}Enter Tailscale operator username [$USERNAME]: ${NC}")" TAILSCALE_OPERATOR + TAILSCALE_OPERATOR=${TAILSCALE_OPERATOR:-$USERNAME} + if [[ -n "$TAILSCALE_OPERATOR" && ! $(validate_username "$TAILSCALE_OPERATOR") ]]; then + print_error "Invalid Tailscale operator username." + TAILSCALE_OPERATOR="$USERNAME" + fi + PROMPTED_SETTINGS+=("TAILSCALE_OPERATOR") +} + +prompt_tailscale_accept_dns() { + TAILSCALE_ACCEPT_DNS="yes" + confirm "Accept Tailscale DNS?" "y" || TAILSCALE_ACCEPT_DNS="no" + PROMPTED_SETTINGS+=("TAILSCALE_ACCEPT_DNS") +} + +prompt_tailscale_accept_routes() { + TAILSCALE_ACCEPT_ROUTES="yes" + confirm "Accept Tailscale routes?" "y" || TAILSCALE_ACCEPT_ROUTES="no" + PROMPTED_SETTINGS+=("TAILSCALE_ACCEPT_ROUTES") +} + +prompt_smtp_server() { + read -rp "$(echo -e "${CYAN}Enter SMTP server [mail.smtp2go.com]: ${NC}")" SMTP_SERVER + SMTP_SERVER=${SMTP_SERVER:-mail.smtp2go.com} + if [[ ! "$SMTP_SERVER" =~ ^[a-zA-Z0-9.-]+$ ]]; then + print_error "Invalid SMTP server." + SMTP_SERVER="" + fi + PROMPTED_SETTINGS+=("SMTP_SERVER") +} + +prompt_smtp_port() { + read -rp "$(echo -e "${CYAN}Enter SMTP port [587]: ${NC}")" SMTP_PORT + SMTP_PORT=${SMTP_PORT:-587} + if ! validate_smtp_port "$SMTP_PORT"; then + print_error "Invalid SMTP port (use 25,2525,8025,587,80,465,8465,443)." + SMTP_PORT="" + fi + PROMPTED_SETTINGS+=("SMTP_PORT") +} + +prompt_smtp_from() { + read -rp "$(echo -e "${CYAN}Enter SMTP from email address: ${NC}")" SMTP_FROM + if [[ -z "$SMTP_FROM" || ! $(validate_email "$SMTP_FROM") ]]; then + print_error "Invalid SMTP from email." + SMTP_FROM="" + fi + PROMPTED_SETTINGS+=("SMTP_FROM") +} + +prompt_smtp_to() { + read -rp "$(echo -e "${CYAN}Enter SMTP to email address: ${NC}")" SMTP_TO + if [[ -z "$SMTP_TO" || ! $(validate_email "$SMTP_TO") ]]; then + print_error "Invalid SMTP to email." + SMTP_TO="" + fi + PROMPTED_SETTINGS+=("SMTP_TO") +} + +prompt_ntfy_server() { + read -rp "$(echo -e "${CYAN}Enter ntfy server URL (e.g., https://ntfy.mydomain.com/ovps, press Enter to skip): ${NC}")" NTFY_SERVER + if [[ -n "$NTFY_SERVER" && ! $(validate_url "$NTFY_SERVER") ]]; then + print_error "Invalid ntfy server URL." + NTFY_SERVER="" + fi + PROMPTED_SETTINGS+=("NTFY_SERVER") +} + +prompt_ntfy_token() { + read -rp "$(echo -e "${CYAN}Enter ntfy token: ${NC}")" NTFY_TOKEN + if [[ -n "$NTFY_TOKEN" && ! $(validate_ntfy_token "$NTFY_TOKEN") ]]; then + print_error "Invalid ntfy token (must start with tk_)." + NTFY_TOKEN="" + fi + PROMPTED_SETTINGS+=("NTFY_TOKEN") +} + +# --- 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") + command -v postmap >/dev/null || missing_deps+=("postfix") + + 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" + if [[ $ID == "debian" && $VERSION_ID == "12" ]] || \ + [[ $ID == "ubuntu" && $VERSION_ID =~ ^(20.04|22.04|24.04|24.10)$ ]]; 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 + + 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 + + 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" + + if [[ -n "$CONFIG_FILE" || -f "/etc/setup_harden.conf" ]]; then + CONFIG_FILE=${CONFIG_FILE:-/etc/setup_harden.conf} + if ! load_config "$CONFIG_FILE"; then + if [[ $VERBOSE == false ]]; then + print_error "Configuration file invalid in quiet mode. Exiting." + exit 1 + else + print_info "Falling back to interactive mode due to config issues." + full_interactive_config + fi + fi + else + full_interactive_config + fi + + 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 [[ ${#PROMPTED_SETTINGS[@]} -gt 0 ]]; then + echo -e " Prompted: ${PROMPTED_SETTINGS[*]}" + fi + if [[ ${#SKIPPED_SETTINGS[@]} -gt 0 ]]; then + echo -e " Skipped: ${SKIPPED_SETTINGS[*]}" + fi + if [[ "$VERBOSE" == true ]]; then + if ! confirm "\nContinue with this configuration?" "y"; then print_info "Exiting."; exit 0; fi + fi + log "Configuration collected: USER=$USERNAME, HOST=$SERVER_NAME, PORT=$SSH_PORT" +} + +full_interactive_config() { + prompt_username + prompt_hostname + read -rp "$(echo -e "${CYAN}Enter a 'pretty' hostname (optional): ${NC}")" PRETTY_NAME + [[ -z "$PRETTY_NAME" ]] && PRETTY_NAME="$SERVER_NAME" + prompt_ssh_port + prompt_timezone + prompt_auto_updates + prompt_install_docker + prompt_install_tailscale + if [[ "$INSTALL_TAILSCALE" == "yes" ]]; then + prompt_tailscale_login_server + if [[ -n "$TAILSCALE_LOGIN_SERVER" ]]; then + prompt_tailscale_auth_key + prompt_tailscale_operator + prompt_tailscale_accept_dns + prompt_tailscale_accept_routes + fi + fi + if confirm "Configure system monitoring with SMTP and/or ntfy?"; then + prompt_smtp_server + if [[ -n "$SMTP_SERVER" ]]; then + prompt_smtp_port + prompt_smtp_from + prompt_smtp_to + fi + prompt_ntfy_server + if [[ -n "$NTFY_SERVER" ]]; then + prompt_ntfy_token + fi + fi +} + +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 ncdu tree \ + rsyslog cron jq gawk coreutils perl skopeo git \ + openssh-client openssh-server postfix libsasl2-modules curl; 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" + + 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, 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 + if echo "$USERNAME:$PASS1" | chpasswd 2>&1 | tee -a "$LOG_FILE"; then + print_success "Password for '$USERNAME' updated." + break + else + print_error "Failed to set password. Check log file for details." + 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 an SSH public key from your local machine now?"; then + while true; do + 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" + 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 + break + else + print_error "Invalid SSH key format. It should start with 'ssh-rsa', 'ecdsa-*', or 'ssh-ed25519'." + if ! confirm "Try again?"; then print_info "Skipping SSH key addition."; break; fi + 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..." + 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 + + 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" + + 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 + + if [[ $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. Checking for socket activation..." + if systemctl is-active ssh.socket >/dev/null 2>&1; then + print_info "Disabling ssh.socket to enable ssh.service..." + systemctl disable --now ssh.socket + fi + SSH_SERVICE="ssh.service" + 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. Please check openssh-server installation." + exit 1 + fi + print_success "SSH daemon started manually." + fi + else + print_error "No SSH service or daemon detected. Please verify openssh-server installation." + exit 1 + fi + print_info "Using SSH service: $SSH_SERVICE" + log "Detected SSH service: $SSH_SERVICE" + systemctl status "$SSH_SERVICE" --no-pager >> "$LOG_FILE" 2>&1 + ps aux | grep "[s]shd" >> "$LOG_FILE" 2>&1 + + 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" + + NEW_SSH_CONFIG=$(mktemp) + tee "$NEW_SSH_CONFIG" > /dev/null < /dev/null <<'EOF' +****************************************************************************** + AUTHORIZED ACCESS ONLY + ═════ all attempts are logged and reviewed ═════ +****************************************************************************** +EOF + fi + + print_info "Testing and restarting SSH service..." + if sshd -t; then + if ! systemctl restart "$SSH_SERVICE"; then + print_error "SSH service failed to restart! Reverting changes..." + cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config + systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true + exit 1 + fi + if systemctl is-active --quiet "$SSH_SERVICE"; then + print_success "SSH service restarted on port $SSH_PORT." + else + print_error "SSH service failed to start! Reverting changes..." + cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config + systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true + exit 1 + fi + else + print_error "SSH config test failed! Reverting changes..." + cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config + systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true + rm -f "$NEW_SSH_CONFIG" + exit 1 + fi + + print_info "Verifying root SSH login is disabled..." + if grep -q "^PermitRootLogin no" /etc/ssh/sshd_config.d/99-hardening.conf; then + print_success "Root SSH login is disabled." + else + print_error "Failed to disable root SSH login. Please check /etc/ssh/sshd_config.d/99-hardening.conf." + exit 1 + fi + 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! Please 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" + + if ! confirm "Was the new SSH connection successful?"; then + print_error "Aborting. Restoring original SSH configuration." + cp "$SSHD_BACKUP_FILE" /etc/ssh/sshd_config + systemctl restart "$SSH_SERVICE" || /usr/sbin/sshd || true + exit 1 + fi + 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 [[ -n "${UFW_PORTS:-}" ]]; then + for port in ${UFW_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 + elif [[ $VERBOSE == true ]]; then + 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 + 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 + 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 + fi + if [[ "$INSTALL_TAILSCALE" == "yes" ]]; then + if ! ufw status | grep -qw "41641/udp"; then + ufw allow 41641/udp comment 'Tailscale' + print_success "Tailscale port 41641/udp allowed." + else + print_info "Tailscale port 41641/udp rule already exists." + fi + fi + if [[ -n "${SMTP_PORT:-}" ]]; then + if ! ufw status | grep -qw "$SMTP_PORT/tcp"; then + ufw allow "$SMTP_PORT"/tcp comment 'SMTP' + print_success "SMTP port $SMTP_PORT/tcp allowed." + else + print_info "SMTP port $SMTP_PORT/tcp rule already exists." + fi + 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)." + print_info " - DigitalOcean: Configure Firewall in Control Panel -> Networking -> Firewalls." + print_info " - AWS: Update Security Groups in EC2 Dashboard." + print_info " - GCP: Update Firewall Rules in VPC Network -> Firewall." + print_info " - Oracle: Configure Security Lists in Virtual Cloud Network." + ufw status verbose | tee -a "$LOG_FILE" + iptables -L >> "$LOG_FILE" 2>&1 + log "Firewall configuration completed." +} + +install_tailscale() { + if [[ "$INSTALL_TAILSCALE" != "yes" ]]; then + print_info "Skipping Tailscale installation." + SKIPPED_SETTINGS+=("Tailscale installation") + return 0 + fi + print_section "Tailscale VPN Installation" + if command -v tailscale >/dev/null 2>&1; then + print_info "Tailscale already installed." + if [[ -n "${TAILSCALE_LOGIN_SERVER:-}" && -n "${TAILSCALE_AUTH_KEY:-}" && -n "${TAILSCALE_OPERATOR:-}" ]]; then + print_info "Configuring Tailscale with Headscale..." + local ts_cmd="tailscale up --login-server=$TAILSCALE_LOGIN_SERVER --operator=$TAILSCALE_OPERATOR" + [[ "$TAILSCALE_ACCEPT_DNS" == "yes" ]] && ts_cmd="$ts_cmd --accept-dns" + [[ "$TAILSCALE_ACCEPT_ROUTES" == "yes" ]] && ts_cmd="$ts_cmd --accept-routes" + [[ -n "${TAILSCALE_AUTH_KEY:-}" ]] && ts_cmd="$ts_cmd --authkey=$TAILSCALE_AUTH_KEY" + ts_cmd="$ts_cmd --hostname=$SERVER_NAME" + if eval "$ts_cmd" 2>&1 | tee -a "$LOG_FILE"; then + if sudo tailscale status | grep -q "connected"; then + print_success "Tailscale connected successfully." + else + print_error "Tailscale connection failed. Run 'sudo tailscale up' manually." + fi + else + print_error "Failed to run Tailscale up. Check log for details." + fi + else + print_info "Incomplete Tailscale configuration. Skipping Headscale setup." + SKIPPED_SETTINGS+=("Tailscale Headscale configuration") + fi + 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 + 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 + systemctl enable --now tailscaled + if [[ -n "${TAILSCALE_LOGIN_SERVER:-}" && -n "${TAILSCALE_AUTH_KEY:-}" && -n "${TAILSCALE_OPERATOR:-}" ]]; then + print_info "Configuring Tailscale with Headscale..." + local ts_cmd="tailscale up --login-server=$TAILSCALE_LOGIN_SERVER --operator=$TAILSCALE_OPERATOR" + [[ "$TAILSCALE_ACCEPT_DNS" == "yes" ]] && ts_cmd="$ts_cmd --accept-dns" + [[ "$TAILSCALE_ACCEPT_ROUTES" == "yes" ]] && ts_cmd="$ts_cmd --accept-routes" + [[ -n "${TAILSCALE_AUTH_KEY:-}" ]] && ts_cmd="$ts_cmd --authkey=$TAILSCALE_AUTH_KEY" + ts_cmd="$ts_cmd --hostname=$SERVER_NAME" + if eval "$ts_cmd" 2>&1 | tee -a "$LOG_FILE"; then + if sudo tailscale status | grep -q "connected"; then + print_success "Tailscale connected successfully." + else + print_error "Tailscale connection failed. Run 'sudo tailscale up' manually." + fi + else + print_error "Failed to run Tailscale up. Check log for details." + fi + else + print_info "Incomplete Tailscale configuration. Skipping Headscale setup." + SKIPPED_SETTINGS+=("Tailscale Headscale configuration") + fi + print_success "Tailscale installation completed." + log "Tailscale installation and configuration completed." +} + +configure_swap() { + print_section "Swap Configuration" + if [[ "$IS_CONTAINER" == true ]]; then + print_info "Container environment detected. Skipping swap configuration." + SKIPPED_SETTINGS+=("swap configuration") + return 0 + fi + if [[ -z "${SWAP_SIZE:-}" ]]; then + print_info "No swap size specified. Skipping swap configuration." + SKIPPED_SETTINGS+=("swap configuration") + return 0 + fi + if ! validate_swap_size "$SWAP_SIZE"; then + print_error "Invalid SWAP_SIZE format: $SWAP_SIZE. Skipping swap configuration." + SKIPPED_SETTINGS+=("swap configuration") + return 0 + fi + if [[ -f /swapfile ]]; then + CURRENT_SWAP_SIZE=$(ls -lh /swapfile | awk '{print $5}') + print_info "Swap file already exists with size $CURRENT_SWAP_SIZE." + if [[ "$CURRENT_SWAP_SIZE" == "$SWAP_SIZE" ]]; then + print_info "Swap size matches requested size. Skipping." + return 0 + else + print_info "Removing existing swap file to create new one..." + swapoff /swapfile 2>/dev/null || true + rm -f /swapfile + sed -i '/\/swapfile/d' /etc/fstab + fi + fi + print_info "Creating swap file of size $SWAP_SIZE..." + SWAP_BYTES=$(convert_to_bytes "$SWAP_SIZE") + if [[ $SWAP_BYTES -eq 0 ]]; then + print_error "Invalid swap size calculation. Skipping." + SKIPPED_SETTINGS+=("swap configuration") + return 0 + fi + if ! fallocate -l "$SWAP_SIZE" /swapfile; then + print_error "Failed to create swap file. Check disk space." + exit 1 + fi + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + if ! grep -q "/swapfile" /etc/fstab; then + echo "/swapfile none swap sw 0 0" >> /etc/fstab + fi + print_info "Configuring swappiness..." + sysctl vm.swappiness=10 + if ! grep -q "vm.swappiness" /etc/sysctl.conf; then + echo "vm.swappiness=10" >> /etc/sysctl.conf + else + sed -i 's/.*vm\.swappiness.*/vm.swappiness=10/' /etc/sysctl.conf + fi + if swapon --show | grep -q "/swapfile"; then + print_success "Swap file of $SWAP_SIZE configured." + else + print_error "Swap activation failed. Check 'swapon --show'." + exit 1 + fi + log "Swap configuration completed." +} + +configure_time() { + print_section "Time Synchronization" + if ! dpkg -l chrony | grep -q ^ii; then + print_error "chrony package is not installed." + exit 1 + fi + systemctl enable chrony + systemctl restart chrony + sleep 2 + if systemctl is-active --quiet chrony; then + print_success "Time synchronization enabled with chrony." + chronyc sources | tee -a "$LOG_FILE" + else + print_error "Failed to start chrony service." + exit 1 + fi + log "Time synchronization configuration completed." +} + +cleanup() { + print_section "System Cleanup" + print_info "Cleaning up package cache..." + apt-get autoremove -y -qq + apt-get autoclean -y -qq + print_info "Removing temporary files..." + find /tmp -type f -atime +1 -delete + print_info "Clearing log files..." + find /var/log -type f -name "*.log" -exec truncate -s 0 {} \; + print_success "System cleanup completed." + log "System cleanup completed." +} + +print_summary() { + print_section "Setup Summary" + echo -e "${GREEN}Setup completed successfully!${NC}" | tee -a "$LOG_FILE" + echo -e "${CYAN}Configuration Details:${NC}" | tee -a "$LOG_FILE" + echo -e " - Username: $USERNAME" | tee -a "$LOG_FILE" + echo -e " - Hostname: $SERVER_NAME" | tee -a "$LOG_FILE" + echo -e " - SSH Port: $SSH_PORT" | tee -a "$LOG_FILE" + echo -e " - Server IP: $SERVER_IP" | tee -a "$LOG_FILE" + echo -e " - Timezone: $TIMEZONE" | tee -a "$LOG_FILE" + [[ -n "${SWAP_SIZE:-}" && "$IS_CONTAINER" == false ]] && echo -e " - Swap Size: $SWAP_SIZE" | tee -a "$LOG_FILE" + [[ -n "${UFW_PORTS:-}" ]] && echo -e " - UFW Ports: $UFW_PORTS" | tee -a "$LOG_FILE" + [[ "$AUTO_UPDATES" == "yes" ]] && echo -e " - Automatic Updates: Enabled" | tee -a "$LOG_FILE" + [[ "$INSTALL_DOCKER" == "yes" ]] && echo -e " - Docker: Installed" | tee -a "$LOG_FILE" + [[ "$INSTALL_TAILSCALE" == "yes" ]] && echo -e " - Tailscale: Installed" | tee -a "$LOG_FILE" + [[ -n "${TAILSCALE_LOGIN_SERVER:-}" ]] && echo -e " - Tailscale Login Server: $TAILSCALE_LOGIN_SERVER" | tee -a "$LOG_FILE" + [[ -n "${SMTP_SERVER:-}" ]] && echo -e " - SMTP Server: $SMTP_SERVER:$SMTP_PORT" | tee -a "$LOG_FILE" + [[ -n "${NTFY_SERVER:-}" ]] && echo -e " - ntfy Server: $NTFY_SERVER" | tee -a "$LOG_FILE" + if [[ ${#PROMPTED_SETTINGS[@]} -gt 0 ]]; then + echo -e " - Prompted Settings: ${PROMPTED_SETTINGS[*]}" | tee -a "$LOG_FILE" + fi + if [[ ${#SKIPPED_SETTINGS[@]} -gt 0 ]]; then + echo -e " - Skipped Settings: ${SKIPPED_SETTINGS[*]}" | tee -a "$LOG_FILE" + fi + echo -e "\n${YELLOW}Log file: $LOG_FILE${NC}" | tee -a "$LOG_FILE" + echo -e "${YELLOW}Backup directory: $BACKUP_DIR${NC}" | tee -a "$LOG_FILE" + echo -e "\n${CYAN}Verification Steps:${NC}" | tee -a "$LOG_FILE" + echo -e " 1. Verify SSH: ssh -p $SSH_PORT $USERNAME@$SERVER_IP" | tee -a "$LOG_FILE" + echo -e " 2. Check firewall: sudo ufw status verbose" | tee -a "$LOG_FILE" + echo -e " 3. Check services: systemctl status ssh fail2ban chrony" | tee -a "$LOG_FILE" + [[ "$INSTALL_DOCKER" == "yes" ]] && echo -e " 3.1 Check Docker: systemctl status docker; docker ps" | tee -a "$LOG_FILE" + [[ "$INSTALL_TAILSCALE" == "yes" ]] && echo -e " 3.2 Check Tailscale: sudo tailscale status" | tee -a "$LOG_FILE" + [[ -n "${SMTP_SERVER:-}" ]] && echo -e " 3.3 Check Postfix: systemctl status postfix; tail /var/log/mail.log" | tee -a "$LOG_FILE" + echo -e "\n${YELLOW}ACTION REQUIRED:${NC}" | tee -a "$LOG_FILE" + echo -e " - Check your VPS provider's edge firewall to allow opened ports (e.g., $SSH_PORT/tcp)." | tee -a "$LOG_FILE" + echo -e " - Reboot recommended: sudo reboot" | tee -a "$LOG_FILE" + log "Setup summary completed." +} + +main() { + print_header + mkdir -p "$(dirname "$LOG_FILE")" + touch "$LOG_FILE" + chmod 640 "$LOG_FILE" + check_system + check_dependencies + collect_config + install_packages + setup_user + configure_system + configure_ssh + configure_firewall + configure_fail2ban + configure_auto_updates + configure_monitoring + install_docker + install_tailscale + configure_swap + configure_time + cleanup + print_summary +} + +main