#!/usr/bin/env bash # Copyright (c) 2021-2026 community-scripts ORG # License: MIT | https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/LICENSE # ============================================================================== # CORE FUNCTIONS - LXC CONTAINER UTILITIES # ============================================================================== # # This file provides core utility functions for LXC container management # including colors, formatting, validation checks, message output, and # execution helpers used throughout the Community-Scripts ecosystem. # # Usage: # source <(curl -fsSL https://git.community-scripts.org/.../core.func) # load_functions # # ============================================================================== [[ -n "${_CORE_FUNC_LOADED:-}" ]] && return _CORE_FUNC_LOADED=1 # ============================================================================== # SECTION 1: INITIALIZATION & SETUP # ============================================================================== # ------------------------------------------------------------------------------ # load_functions() # # - Initializes all core utility groups (colors, formatting, icons, defaults) # - Ensures functions are loaded only once via __FUNCTIONS_LOADED flag # - Must be called at start of any script using these utilities # ------------------------------------------------------------------------------ load_functions() { [[ -n "${__FUNCTIONS_LOADED:-}" ]] && return __FUNCTIONS_LOADED=1 color formatting icons default_vars set_std_mode } # ------------------------------------------------------------------------------ # color() # # - Sets ANSI color codes for styled terminal output # - Variables: YW (yellow), YWB (yellow bright), BL (blue), RD (red) # GN (green), DGN (dark green), BGN (background green), CL (clear) # ------------------------------------------------------------------------------ color() { YW=$(echo "\033[33m") YWB=$'\e[93m' BL=$(echo "\033[36m") RD=$(echo "\033[01;31m") BGN=$(echo "\033[4;92m") GN=$(echo "\033[1;92m") DGN=$(echo "\033[32m") CL=$(echo "\033[m") } # ------------------------------------------------------------------------------ # color_spinner() # # - Sets ANSI color codes specifically for spinner animation # - Variables: CS_YW (spinner yellow), CS_YWB (spinner yellow bright), # CS_CL (spinner clear) # - Used by spinner() function to avoid color conflicts # ------------------------------------------------------------------------------ color_spinner() { CS_YW=$'\033[33m' CS_YWB=$'\033[93m' CS_CL=$'\033[m' } # ------------------------------------------------------------------------------ # formatting() # # - Defines formatting helpers for terminal output # - BFR: Backspace and clear line sequence # - BOLD: Bold text escape code # - TAB/TAB3: Indentation spacing # ------------------------------------------------------------------------------ formatting() { BFR="\\r\\033[K" BOLD=$(echo "\033[1m") HOLD=" " TAB=" " TAB3=" " } # ------------------------------------------------------------------------------ # icons() # # - Sets symbolic emoji icons used throughout user feedback # - Provides consistent visual indicators for success, error, info, etc. # - Icons: CM (checkmark), CROSS (error), INFO (info), HOURGLASS (wait), etc. # ------------------------------------------------------------------------------ icons() { CM="${TAB}✔️${TAB}" CROSS="${TAB}✖️${TAB}" DNSOK="✔️ " DNSFAIL="${TAB}✖️${TAB}" INFO="${TAB}💡${TAB}${CL}" OS="${TAB}🖥️${TAB}${CL}" OSVERSION="${TAB}🌟${TAB}${CL}" CONTAINERTYPE="${TAB}📦${TAB}${CL}" DISKSIZE="${TAB}💾${TAB}${CL}" CPUCORE="${TAB}🧠${TAB}${CL}" RAMSIZE="${TAB}🛠️${TAB}${CL}" SEARCH="${TAB}🔍${TAB}${CL}" VERBOSE_CROPPED="🔍${TAB}" VERIFYPW="${TAB}🔐${TAB}${CL}" CONTAINERID="${TAB}🆔${TAB}${CL}" HOSTNAME="${TAB}🏠${TAB}${CL}" BRIDGE="${TAB}🌉${TAB}${CL}" NETWORK="${TAB}📡${TAB}${CL}" GATEWAY="${TAB}🌐${TAB}${CL}" ICON_DISABLEIPV6="${TAB}🚫${TAB}${CL}" DEFAULT="${TAB}⚙️${TAB}${CL}" MACADDRESS="${TAB}🔗${TAB}${CL}" VLANTAG="${TAB}🏷️${TAB}${CL}" ROOTSSH="${TAB}🔑${TAB}${CL}" CREATING="${TAB}🚀${TAB}${CL}" ADVANCED="${TAB}🧩${TAB}${CL}" FUSE="${TAB}🗂️${TAB}${CL}" GPU="${TAB}🎮${TAB}${CL}" HOURGLASS="${TAB}⏳${TAB}" } # ------------------------------------------------------------------------------ # ensure_profile_loaded() # # - Sources /etc/profile.d/*.sh scripts if not already loaded # - Fixes PATH issues when running via pct enter/exec (non-login shells) # - Safe to call multiple times (uses guard variable) # - Should be called in update_script() or any script running inside LXC # ------------------------------------------------------------------------------ ensure_profile_loaded() { # Skip if already loaded or running on Proxmox host [[ -n "${_PROFILE_LOADED:-}" ]] && return command -v pveversion &>/dev/null && return # Source all profile.d scripts to ensure PATH is complete if [[ -d /etc/profile.d ]]; then for script in /etc/profile.d/*.sh; do [[ -r "$script" ]] && source "$script" done fi # Also ensure /usr/local/bin is in PATH (common install location) if [[ ":$PATH:" != *":/usr/local/bin:"* ]]; then export PATH="/usr/local/bin:$PATH" fi export _PROFILE_LOADED=1 } # ------------------------------------------------------------------------------ # default_vars() # # - Sets default retry and wait variables used for system actions # - RETRY_NUM: Maximum number of retry attempts (default: 10) # - RETRY_EVERY: Seconds to wait between retries (default: 3) # - i: Counter variable initialized to RETRY_NUM # ------------------------------------------------------------------------------ default_vars() { RETRY_NUM=10 RETRY_EVERY=3 i=$RETRY_NUM } # ------------------------------------------------------------------------------ # set_std_mode() # # - Sets default verbose mode for script and OS execution # - If VERBOSE=yes: STD="" (show all output) # - If VERBOSE=no: STD="silent" (suppress output via silent() wrapper) # - If DEV_MODE_TRACE=true: Enables bash tracing (set -x) # ------------------------------------------------------------------------------ set_std_mode() { if [ "${VERBOSE:-no}" = "yes" ]; then STD="" else STD="silent" fi # Enable bash tracing if trace mode active if [[ "${DEV_MODE_TRACE:-false}" == "true" ]]; then set -x export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }' fi } # ------------------------------------------------------------------------------ # parse_dev_mode() # # - Parses comma-separated dev_mode variable (e.g., "motd,keep,trace") # - Sets global flags for each mode: # * DEV_MODE_MOTD: Setup SSH/MOTD before installation # * DEV_MODE_KEEP: Never delete container on failure # * DEV_MODE_TRACE: Enable bash set -x tracing # * DEV_MODE_PAUSE: Pause after each msg_info step # * DEV_MODE_BREAKPOINT: Open shell on error instead of cleanup # * DEV_MODE_LOGS: Persist all logs to /var/log/community-scripts/ # * DEV_MODE_DRYRUN: Show commands without executing # - Call this early in script execution # ------------------------------------------------------------------------------ parse_dev_mode() { local mode # Initialize all flags to false export DEV_MODE_MOTD=false export DEV_MODE_KEEP=false export DEV_MODE_TRACE=false export DEV_MODE_PAUSE=false export DEV_MODE_BREAKPOINT=false export DEV_MODE_LOGS=false export DEV_MODE_DRYRUN=false # Parse comma-separated modes if [[ -n "${dev_mode:-}" ]]; then IFS=',' read -ra MODES <<<"$dev_mode" for mode in "${MODES[@]}"; do mode="$(echo "$mode" | xargs)" # Trim whitespace case "$mode" in motd) export DEV_MODE_MOTD=true ;; keep) export DEV_MODE_KEEP=true ;; trace) export DEV_MODE_TRACE=true ;; pause) export DEV_MODE_PAUSE=true ;; breakpoint) export DEV_MODE_BREAKPOINT=true ;; logs) export DEV_MODE_LOGS=true ;; dryrun) export DEV_MODE_DRYRUN=true ;; *) if declare -f msg_warn >/dev/null 2>&1; then msg_warn "Unknown dev_mode: '$mode' (ignored)" else echo "[WARN] Unknown dev_mode: '$mode' (ignored)" >&2 fi ;; esac done # Show active dev modes local active_modes=() [[ $DEV_MODE_MOTD == true ]] && active_modes+=("motd") [[ $DEV_MODE_KEEP == true ]] && active_modes+=("keep") [[ $DEV_MODE_TRACE == true ]] && active_modes+=("trace") [[ $DEV_MODE_PAUSE == true ]] && active_modes+=("pause") [[ $DEV_MODE_BREAKPOINT == true ]] && active_modes+=("breakpoint") [[ $DEV_MODE_LOGS == true ]] && active_modes+=("logs") [[ $DEV_MODE_DRYRUN == true ]] && active_modes+=("dryrun") if [[ ${#active_modes[@]} -gt 0 ]]; then if declare -f msg_custom >/dev/null 2>&1; then msg_custom "🔧" "${YWB}" "Dev modes active: ${active_modes[*]}" else echo "[DEV] Active modes: ${active_modes[*]}" >&2 fi fi fi } # ============================================================================== # SECTION 2: VALIDATION CHECKS # ============================================================================== # ------------------------------------------------------------------------------ # shell_check() # # - Verifies that the script is running under Bash shell # - Exits with error message if different shell is detected # - Required because scripts use Bash-specific features # ------------------------------------------------------------------------------ shell_check() { if [[ "$(ps -p $$ -o comm=)" != "bash" ]]; then clear msg_error "Your default shell is currently not set to Bash. To use these scripts, please switch to the Bash shell." echo -e "\nExiting..." sleep 2 exit fi } # ------------------------------------------------------------------------------ # root_check() # # - Verifies script is running with root privileges # - Detects if executed via sudo (which can cause issues) # - Exits with error if not running as root directly # ------------------------------------------------------------------------------ root_check() { if [[ "$(id -u)" -ne 0 || $(ps -o comm= -p $PPID) == "sudo" ]]; then clear msg_error "Please run this script as root." echo -e "\nExiting..." sleep 2 exit fi } # ------------------------------------------------------------------------------ # pve_check() # # - Validates Proxmox VE version compatibility # - Supported: PVE 8.0-8.9 and PVE 9.0-9.1 # - Exits with error message if unsupported version detected # ------------------------------------------------------------------------------ pve_check() { local PVE_VER PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')" # Check for Proxmox VE 8.x: allow 8.0–8.9 if [[ "$PVE_VER" =~ ^8\.([0-9]+) ]]; then local MINOR="${BASH_REMATCH[1]}" if ((MINOR < 0 || MINOR > 9)); then msg_error "This version of Proxmox VE is not supported." msg_error "Supported: Proxmox VE version 8.0 – 8.9" exit 1 fi return 0 fi # Check for Proxmox VE 9.x: allow 9.0–9.1 if [[ "$PVE_VER" =~ ^9\.([0-9]+) ]]; then local MINOR="${BASH_REMATCH[1]}" if ((MINOR < 0 || MINOR > 1)); then msg_error "This version of Proxmox VE is not yet supported." msg_error "Supported: Proxmox VE version 9.0 – 9.1" exit 1 fi return 0 fi # All other unsupported versions msg_error "This version of Proxmox VE is not supported." msg_error "Supported versions: Proxmox VE 8.0 – 8.9 or 9.0 – 9.1" exit 1 } # ------------------------------------------------------------------------------ # arch_check() # # - Validates system architecture is amd64/x86_64 # - Exits with error message for unsupported architectures (e.g., ARM/PiMox) # - Provides link to ARM64-compatible scripts # ------------------------------------------------------------------------------ arch_check() { if [ "$(dpkg --print-architecture)" != "amd64" ]; then echo -e "\n ${INFO}${YWB}This script will not work with PiMox! \n" echo -e "\n ${YWB}Visit https://github.com/asylumexp/Proxmox for ARM64 support. \n" echo -e "Exiting..." sleep 2 exit fi } # ------------------------------------------------------------------------------ # ssh_check() # # - Detects if script is running over SSH connection # - Warns user for external SSH connections (recommends Proxmox shell) # - Skips warning for local/same-subnet connections # - Does not abort execution, only warns # ------------------------------------------------------------------------------ ssh_check() { if [ -n "$SSH_CLIENT" ]; then local client_ip=$(awk '{print $1}' <<<"$SSH_CLIENT") local host_ip=$(hostname -I | awk '{print $1}') # Check if connection is local (Proxmox WebUI or same machine) # - localhost (127.0.0.1, ::1) # - same IP as host # - local network range (10.x, 172.16-31.x, 192.168.x) if [[ "$client_ip" == "127.0.0.1" || "$client_ip" == "::1" || "$client_ip" == "$host_ip" ]]; then return fi # Check if client is in same local network (optional, safer approach) local host_subnet=$(echo "$host_ip" | cut -d. -f1-3) local client_subnet=$(echo "$client_ip" | cut -d. -f1-3) if [[ "$host_subnet" == "$client_subnet" ]]; then return fi # Only warn for truly external connections msg_warn "Running via external SSH (client: $client_ip)." msg_warn "For better stability, consider using the Proxmox Shell (Console) instead." fi } # ============================================================================== # SECTION 3: EXECUTION HELPERS # ============================================================================== # ------------------------------------------------------------------------------ # get_active_logfile() # # - Returns the appropriate log file based on execution context # - BUILD_LOG: Host operations (container creation) # - INSTALL_LOG: Container operations (application installation) # - Fallback to BUILD_LOG if neither is set # ------------------------------------------------------------------------------ get_active_logfile() { if [[ -n "${INSTALL_LOG:-}" ]]; then echo "$INSTALL_LOG" elif [[ -n "${BUILD_LOG:-}" ]]; then echo "$BUILD_LOG" else # Fallback for legacy scripts echo "/tmp/build-$(date +%Y%m%d_%H%M%S).log" fi } # Legacy compatibility: SILENT_LOGFILE points to active log SILENT_LOGFILE="$(get_active_logfile)" # ------------------------------------------------------------------------------ # silent() # # - Executes command with output redirected to active log file # - On error: displays last 10 lines of log and exits with original exit code # - Temporarily disables error trap to capture exit code correctly # - Sources explain_exit_code() for detailed error messages # ------------------------------------------------------------------------------ silent() { local cmd="$*" local caller_line="${BASH_LINENO[0]:-unknown}" local logfile="$(get_active_logfile)" # Dryrun mode: Show command without executing if [[ "${DEV_MODE_DRYRUN:-false}" == "true" ]]; then if declare -f msg_custom >/dev/null 2>&1; then msg_custom "🔍" "${BL}" "[DRYRUN] $cmd" else echo "[DRYRUN] $cmd" >&2 fi return 0 fi set +Eeuo pipefail trap - ERR "$@" >>"$logfile" 2>&1 local rc=$? set -Eeuo pipefail trap 'error_handler' ERR if [[ $rc -ne 0 ]]; then # Source explain_exit_code if needed if ! declare -f explain_exit_code >/dev/null 2>&1; then source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func) fi local explanation explanation="$(explain_exit_code "$rc")" printf "\e[?25h" msg_error "in line ${caller_line}: exit code ${rc} (${explanation})" msg_custom "→" "${YWB}" "${cmd}" if [[ -s "$logfile" ]]; then local log_lines=$(wc -l <"$logfile") echo "--- Last 10 lines of silent log ---" tail -n 10 "$logfile" echo "-----------------------------------" # Show how to view full log if there are more lines if [[ $log_lines -gt 10 ]]; then msg_custom "📋" "${YW}" "View full log (${log_lines} lines): ${logfile}" fi fi exit "$rc" fi } # ------------------------------------------------------------------------------ # spinner() # # - Displays animated spinner with rotating characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) # - Shows SPINNER_MSG alongside animation # - Runs in infinite loop until killed by stop_spinner() # - Uses color_spinner() colors for output # ------------------------------------------------------------------------------ spinner() { local chars=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) local msg="${SPINNER_MSG:-Processing...}" local i=0 while true; do local index=$((i++ % ${#chars[@]})) printf "\r\033[2K%s %b" "${CS_YWB}${chars[$index]}${CS_CL}" "${CS_YWB}${msg}${CS_CL}" sleep 0.1 done } # ------------------------------------------------------------------------------ # clear_line() # # - Clears current terminal line using tput or ANSI escape codes # - Moves cursor to beginning of line (carriage return) # - Erases from cursor to end of line # - Fallback to ANSI codes if tput not available # ------------------------------------------------------------------------------ clear_line() { tput cr 2>/dev/null || echo -en "\r" tput el 2>/dev/null || echo -en "\033[K" } # ------------------------------------------------------------------------------ # stop_spinner() # # - Stops running spinner process by PID # - Reads PID from SPINNER_PID variable or /tmp/.spinner.pid file # - Attempts graceful kill, then forced kill if needed # - Cleans up temp file and resets terminal state # - Unsets SPINNER_PID and SPINNER_MSG variables # ------------------------------------------------------------------------------ stop_spinner() { local pid="${SPINNER_PID:-}" [[ -z "$pid" && -f /tmp/.spinner.pid ]] && pid=$(/dev/null; then sleep 0.05 kill -9 "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true fi rm -f /tmp/.spinner.pid fi unset SPINNER_PID SPINNER_MSG stty sane 2>/dev/null || true } # ============================================================================== # SECTION 4: MESSAGE OUTPUT # ============================================================================== # ------------------------------------------------------------------------------ # msg_info() # # - Displays informational message with spinner animation # - Shows each unique message only once (tracked via MSG_INFO_SHOWN) # - In verbose/Alpine mode: shows hourglass icon instead of spinner # - Stops any existing spinner before starting new one # - Backgrounds spinner process and stores PID for later cleanup # ------------------------------------------------------------------------------ msg_info() { local msg="$1" [[ -z "$msg" ]] && return if ! declare -p MSG_INFO_SHOWN &>/dev/null || ! declare -A MSG_INFO_SHOWN &>/dev/null; then declare -gA MSG_INFO_SHOWN=() fi [[ -n "${MSG_INFO_SHOWN["$msg"]+x}" ]] && return MSG_INFO_SHOWN["$msg"]=1 stop_spinner SPINNER_MSG="$msg" if is_verbose_mode || is_alpine; then local HOURGLASS="${TAB}⏳${TAB}" printf "\r\e[2K%s %b" "$HOURGLASS" "${YW}${msg}${CL}" >&2 # Pause mode: Wait for Enter after each step if [[ "${DEV_MODE_PAUSE:-false}" == "true" ]]; then echo -en "\n${YWB}[PAUSE]${CL} Press Enter to continue..." >&2 read -r fi return fi color_spinner spinner & SPINNER_PID=$! echo "$SPINNER_PID" >/tmp/.spinner.pid disown "$SPINNER_PID" 2>/dev/null || true # Pause mode: Stop spinner and wait if [[ "${DEV_MODE_PAUSE:-false}" == "true" ]]; then stop_spinner echo -en "\n${YWB}[PAUSE]${CL} Press Enter to continue..." >&2 read -r fi } # ------------------------------------------------------------------------------ # msg_ok() # # - Displays success message with checkmark icon # - Stops spinner and clears line before output # - Removes message from MSG_INFO_SHOWN to allow re-display # - Uses green color for success indication # ------------------------------------------------------------------------------ msg_ok() { local msg="$1" [[ -z "$msg" ]] && return stop_spinner clear_line echo -e "$CM ${GN}${msg}${CL}" local sanitized_msg sanitized_msg=$(printf '%s' "$msg" | sed 's/\x1b\[[0-9;]*m//g; s/[^a-zA-Z0-9_]/_/g') unset 'MSG_INFO_SHOWN['"$sanitized_msg"']' 2>/dev/null || true } # ------------------------------------------------------------------------------ # msg_error() # # - Displays error message with cross/X icon # - Stops spinner before output # - Uses red color for error indication # - Outputs to stderr # ------------------------------------------------------------------------------ msg_error() { stop_spinner local msg="$1" echo -e "${BFR:-}${CROSS:-✖️} ${RD}${msg}${CL}" >&2 } # ------------------------------------------------------------------------------ # msg_warn() # # - Displays warning message with info/lightbulb icon # - Stops spinner before output # - Uses bright yellow color for warning indication # - Outputs to stderr # ------------------------------------------------------------------------------ msg_warn() { stop_spinner local msg="$1" echo -e "${BFR:-}${INFO:-ℹ️} ${YWB}${msg}${CL}" >&2 } # ------------------------------------------------------------------------------ # msg_custom() # # - Displays custom message with user-defined symbol and color # - Arguments: symbol, color code, message text # - Stops spinner before output # - Useful for specialized status messages # ------------------------------------------------------------------------------ msg_custom() { local symbol="${1:-"[*]"}" local color="${2:-"\e[36m"}" local msg="${3:-}" [[ -z "$msg" ]] && return stop_spinner echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}" } # ------------------------------------------------------------------------------ # msg_debug() # # - Displays debug message with timestamp when var_full_verbose=1 # - Automatically enables var_verbose if not already set # - Shows date/time prefix for log correlation # - Uses bright yellow color for debug output # ------------------------------------------------------------------------------ msg_debug() { if [[ "${var_full_verbose:-0}" == "1" ]]; then [[ "${var_verbose:-0}" != "1" ]] && var_verbose=1 echo -e "${YWB}[$(date '+%F %T')] [DEBUG]${CL} $*" fi } # ------------------------------------------------------------------------------ # msg_dev() # # - Display development mode messages with 🔧 icon # - Only shown when dev_mode is active # - Useful for debugging and development-specific output # - Format: [DEV] message with distinct formatting # - Usage: msg_dev "Container ready for debugging" # ------------------------------------------------------------------------------ msg_dev() { if [[ -n "${dev_mode:-}" ]]; then echo -e "${SEARCH}${BOLD}${DGN}🔧 [DEV]${CL} $*" fi } # # - Displays error message and immediately terminates script # - Sends SIGINT to current process to trigger error handler # - Use for unrecoverable errors that require immediate exit # ------------------------------------------------------------------------------ fatal() { msg_error "$1" kill -INT $$ } # ============================================================================== # SECTION 5: UTILITY FUNCTIONS # ============================================================================== # ------------------------------------------------------------------------------ # exit_script() # # - Called when user cancels an action # - Clears screen and displays exit message # - Exits with default exit code # ------------------------------------------------------------------------------ exit_script() { clear echo -e "\n${CROSS}${RD}User exited script${CL}\n" exit } # ------------------------------------------------------------------------------ # get_header() # # - Downloads and caches application header ASCII art # - Falls back to local cache if already downloaded # - Determines app type (ct/vm) from APP_TYPE variable # - Returns header content or empty string on failure # ------------------------------------------------------------------------------ get_header() { local app_name=$(echo "${APP,,}" | tr -d ' ') local app_type=${APP_TYPE:-ct} # Default to 'ct' if not set local header_url="https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/${app_type}/headers/${app_name}" local local_header_path="/usr/local/community-scripts/headers/${app_type}/${app_name}" mkdir -p "$(dirname "$local_header_path")" if [ ! -s "$local_header_path" ]; then if ! curl -fsSL "$header_url" -o "$local_header_path"; then return 1 fi fi cat "$local_header_path" 2>/dev/null || true } # ------------------------------------------------------------------------------ # header_info() # # - Displays application header ASCII art at top of screen # - Clears screen before displaying header # - Detects terminal width for formatting # - Returns silently if header not available # ------------------------------------------------------------------------------ header_info() { local app_name=$(echo "${APP,,}" | tr -d ' ') local header_content header_content=$(get_header "$app_name") || header_content="" clear local term_width term_width=$(tput cols 2>/dev/null || echo 120) if [ -n "$header_content" ]; then echo "$header_content" fi } # ------------------------------------------------------------------------------ # ensure_tput() # # - Ensures tput command is available for terminal control # - Installs ncurses-bin on Debian/Ubuntu or ncurses on Alpine # - Required for clear_line() and terminal width detection # ------------------------------------------------------------------------------ ensure_tput() { if ! command -v tput >/dev/null 2>&1; then if grep -qi 'alpine' /etc/os-release; then apk add --no-cache ncurses >/dev/null 2>&1 elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq >/dev/null apt-get install -y -qq ncurses-bin >/dev/null 2>&1 fi fi } # ------------------------------------------------------------------------------ # is_alpine() # # - Detects if running on Alpine Linux # - Checks var_os, PCT_OSTYPE, or /etc/os-release # - Returns 0 if Alpine, 1 otherwise # - Used to adjust behavior for Alpine-specific commands # ------------------------------------------------------------------------------ is_alpine() { local os_id="${var_os:-${PCT_OSTYPE:-}}" if [[ -z "$os_id" && -f /etc/os-release ]]; then os_id="$( . /etc/os-release 2>/dev/null echo "${ID:-}" )" fi [[ "$os_id" == "alpine" ]] } # ------------------------------------------------------------------------------ # is_verbose_mode() # # - Determines if script should run in verbose mode # - Checks VERBOSE and var_verbose variables # - Also returns true if not running in TTY (pipe/redirect scenario) # - Used by msg_info() to decide between spinner and static output # ------------------------------------------------------------------------------ is_verbose_mode() { local verbose="${VERBOSE:-${var_verbose:-no}}" local tty_status if [[ -t 2 ]]; then tty_status="interactive" else tty_status="not-a-tty" fi [[ "$verbose" != "no" || ! -t 2 ]] } # ------------------------------------------------------------------------------ # is_unattended() # # - Detects if script is running in unattended/non-interactive mode # - Checks MODE variable first (primary method) # - Falls back to legacy flags (PHS_SILENT, var_unattended) # - Returns 0 (true) if unattended, 1 (false) otherwise # - Used by prompt functions to auto-apply defaults # # Modes that are unattended: # - default (1) : Use script defaults, no prompts # - mydefaults (3) : Use user's default.vars, no prompts # - appdefaults (4) : Use app-specific defaults, no prompts # # Modes that are interactive: # - advanced (2) : Full wizard with all options # # Note: Even in advanced mode, install scripts run unattended because # all values are already collected during the wizard phase. # ------------------------------------------------------------------------------ is_unattended() { # Primary: Check MODE variable (case-insensitive) local mode="${MODE:-${mode:-}}" mode="${mode,,}" # lowercase case "$mode" in default|1) return 0 ;; mydefaults|userdefaults|3) return 0 ;; appdefaults|4) return 0 ;; advanced|2) # Advanced mode is interactive ONLY during wizard # Inside container (install scripts), it should be unattended # Check if we're inside a container (no pveversion command) if ! command -v pveversion &>/dev/null; then # We're inside the container - all values already collected return 0 fi # On host during wizard - interactive return 1 ;; esac # Legacy fallbacks for compatibility [[ "${PHS_SILENT:-0}" == "1" ]] && return 0 [[ "${var_unattended:-}" =~ ^(yes|true|1)$ ]] && return 0 [[ "${UNATTENDED:-}" =~ ^(yes|true|1)$ ]] && return 0 # No TTY available = unattended [[ ! -t 0 ]] && return 0 # Default: interactive return 1 } # ------------------------------------------------------------------------------ # show_missing_values_warning() # # - Displays a summary of required values that used fallback defaults # - Should be called at the end of install scripts # - Only shows warning if MISSING_REQUIRED_VALUES array has entries # - Provides clear guidance on what needs manual configuration # # Global: # MISSING_REQUIRED_VALUES - Array of variable names that need configuration # # Example: # # At end of install script: # show_missing_values_warning # ------------------------------------------------------------------------------ show_missing_values_warning() { if [[ ${#MISSING_REQUIRED_VALUES[@]} -gt 0 ]]; then echo "" echo -e "${YW}╔════════════════════════════════════════════════════════════╗${CL}" echo -e "${YW}║ ⚠️ MANUAL CONFIGURATION REQUIRED ║${CL}" echo -e "${YW}╠════════════════════════════════════════════════════════════╣${CL}" echo -e "${YW}║ The following values were not provided and need to be ║${CL}" echo -e "${YW}║ configured manually for the service to work properly: ║${CL}" echo -e "${YW}╟────────────────────────────────────────────────────────────╢${CL}" for val in "${MISSING_REQUIRED_VALUES[@]}"; do printf "${YW}║${CL} • %-56s ${YW}║${CL}\n" "$val" done echo -e "${YW}╟────────────────────────────────────────────────────────────╢${CL}" echo -e "${YW}║ Check the service configuration files or environment ║${CL}" echo -e "${YW}║ variables and update the placeholder values. ║${CL}" echo -e "${YW}╚════════════════════════════════════════════════════════════╝${CL}" echo "" return 1 fi return 0 } # ------------------------------------------------------------------------------ # prompt_confirm() # # - Prompts user for yes/no confirmation with timeout and unattended support # - In unattended mode: immediately returns default value # - In interactive mode: waits for user input with configurable timeout # - After timeout: auto-applies default value # # Arguments: # $1 - Prompt message (required) # $2 - Default value: "y" or "n" (optional, default: "n") # $3 - Timeout in seconds (optional, default: 60) # # Returns: # 0 - User confirmed (yes) # 1 - User declined (no) or timeout with default "n" # # Example: # if prompt_confirm "Proceed with installation?" "y" 30; then # echo "Installing..." # fi # # # Unattended: prompt_confirm will use default without waiting # var_unattended=yes # prompt_confirm "Delete files?" "n" && echo "Deleting" || echo "Skipped" # ------------------------------------------------------------------------------ prompt_confirm() { local message="${1:-Confirm?}" local default="${2:-n}" local timeout="${3:-60}" local response # Normalize default to lowercase default="${default,,}" [[ "$default" != "y" ]] && default="n" # Build prompt hint local hint if [[ "$default" == "y" ]]; then hint="[Y/n]" else hint="[y/N]" fi # Unattended mode: apply default immediately if is_unattended; then if [[ "$default" == "y" ]]; then return 0 else return 1 fi fi # Check if running in a TTY if [[ ! -t 0 ]]; then # Not a TTY, use default if [[ "$default" == "y" ]]; then return 0 else return 1 fi fi # Interactive prompt with timeout echo -en "${YW}${message} ${hint} (auto-${default} in ${timeout}s): ${CL}" if read -t "$timeout" -r response; then # User provided input response="${response,,}" # lowercase case "$response" in y|yes) return 0 ;; n|no) return 1 ;; "") # Empty response, use default if [[ "$default" == "y" ]]; then return 0 else return 1 fi ;; *) # Invalid input, use default echo -e "${YW}Invalid response, using default: ${default}${CL}" if [[ "$default" == "y" ]]; then return 0 else return 1 fi ;; esac else # Timeout occurred echo "" # Newline after timeout echo -e "${YW}Timeout - auto-selecting: ${default}${CL}" if [[ "$default" == "y" ]]; then return 0 else return 1 fi fi } # ------------------------------------------------------------------------------ # prompt_input() # # - Prompts user for text input with timeout and unattended support # - In unattended mode: immediately returns default value # - In interactive mode: waits for user input with configurable timeout # - After timeout: auto-applies default value # # Arguments: # $1 - Prompt message (required) # $2 - Default value (optional, default: "") # $3 - Timeout in seconds (optional, default: 60) # # Output: # Prints the user input or default value to stdout # # Example: # username=$(prompt_input "Enter username:" "admin" 30) # echo "Using username: $username" # # # With validation # while true; do # port=$(prompt_input "Enter port:" "8080" 30) # [[ "$port" =~ ^[0-9]+$ ]] && break # echo "Invalid port number" # done # ------------------------------------------------------------------------------ prompt_input() { local message="${1:-Enter value:}" local default="${2:-}" local timeout="${3:-60}" local response # Build display default hint local hint="" [[ -n "$default" ]] && hint=" (default: ${default})" # Unattended mode: return default immediately if is_unattended; then echo "$default" return 0 fi # Check if running in a TTY if [[ ! -t 0 ]]; then # Not a TTY, use default echo "$default" return 0 fi # Interactive prompt with timeout echo -en "${YW}${message}${hint} (auto-default in ${timeout}s): ${CL}" >&2 if read -t "$timeout" -r response; then # User provided input (or pressed Enter for empty) if [[ -n "$response" ]]; then echo "$response" else echo "$default" fi else # Timeout occurred echo "" >&2 # Newline after timeout echo -e "${YW}Timeout - using default: ${default}${CL}" >&2 echo "$default" fi } # ------------------------------------------------------------------------------ # prompt_input_required() # # - Prompts user for REQUIRED text input with fallback support # - In unattended mode: Uses fallback value if no env var set (with warning) # - In interactive mode: loops until user provides non-empty input # - Tracks missing required values for end-of-script summary # # Arguments: # $1 - Prompt message (required) # $2 - Fallback/example value for unattended mode (optional) # $3 - Timeout in seconds (optional, default: 120) # $4 - Environment variable name hint for error messages (optional) # # Output: # Prints the user input or fallback value to stdout # # Returns: # 0 - Success (value provided or fallback used) # 1 - Failed (interactive timeout without input) # # Global: # MISSING_REQUIRED_VALUES - Array tracking fields that used fallbacks # # Example: # # With fallback - script continues even in unattended mode # token=$(prompt_input_required "Enter API Token:" "YOUR_TOKEN_HERE" 60 "var_api_token") # # # Check at end of script if any values need manual configuration # if [[ ${#MISSING_REQUIRED_VALUES[@]} -gt 0 ]]; then # msg_warn "Please configure: ${MISSING_REQUIRED_VALUES[*]}" # fi # ------------------------------------------------------------------------------ # Global array to track missing required values declare -g -a MISSING_REQUIRED_VALUES=() prompt_input_required() { local message="${1:-Enter required value:}" local fallback="${2:-CHANGE_ME}" local timeout="${3:-120}" local env_var_hint="${4:-}" local response="" # Check if value is already set via environment variable (if hint provided) if [[ -n "$env_var_hint" ]]; then local env_value="${!env_var_hint:-}" if [[ -n "$env_value" ]]; then echo "$env_value" return 0 fi fi # Unattended mode: use fallback with warning if is_unattended; then if [[ -n "$env_var_hint" ]]; then echo -e "${YW}⚠ Required value '${env_var_hint}' not set - using fallback: ${fallback}${CL}" >&2 MISSING_REQUIRED_VALUES+=("$env_var_hint") else echo -e "${YW}⚠ Required value not provided - using fallback: ${fallback}${CL}" >&2 MISSING_REQUIRED_VALUES+=("(unnamed)") fi echo "$fallback" return 0 fi # Check if running in a TTY if [[ ! -t 0 ]]; then echo -e "${YW}⚠ Not interactive - using fallback: ${fallback}${CL}" >&2 MISSING_REQUIRED_VALUES+=("${env_var_hint:-unnamed}") echo "$fallback" return 0 fi # Interactive prompt - loop until non-empty input or use fallback on timeout local attempts=0 while [[ -z "$response" ]]; do ((attempts++)) if [[ $attempts -gt 3 ]]; then echo -e "${YW}Too many empty inputs - using fallback: ${fallback}${CL}" >&2 MISSING_REQUIRED_VALUES+=("${env_var_hint:-manual_input}") echo "$fallback" return 0 fi echo -en "${YW}${message} (required, timeout ${timeout}s): ${CL}" >&2 if read -t "$timeout" -r response; then if [[ -z "$response" ]]; then echo -e "${YW}This field is required. Please enter a value. (attempt ${attempts}/3)${CL}" >&2 fi else # Timeout occurred - use fallback echo "" >&2 echo -e "${YW}Timeout - using fallback value: ${fallback}${CL}" >&2 MISSING_REQUIRED_VALUES+=("${env_var_hint:-timeout}") echo "$fallback" return 0 fi done echo "$response" } # ------------------------------------------------------------------------------ # prompt_select() # # - Prompts user to select from a list of options with timeout support # - In unattended mode: immediately returns default selection # - In interactive mode: displays numbered menu and waits for choice # - After timeout: auto-applies default selection # # Arguments: # $1 - Prompt message (required) # $2 - Default option number, 1-based (optional, default: 1) # $3 - Timeout in seconds (optional, default: 60) # $4+ - Options to display (required, at least 2) # # Output: # Prints the selected option value to stdout # # Returns: # 0 - Success # 1 - No options provided or invalid state # # Example: # choice=$(prompt_select "Select database:" 1 30 "PostgreSQL" "MySQL" "SQLite") # echo "Selected: $choice" # # # With array # options=("Option A" "Option B" "Option C") # selected=$(prompt_select "Choose:" 2 60 "${options[@]}") # ------------------------------------------------------------------------------ prompt_select() { local message="${1:-Select option:}" local default="${2:-1}" local timeout="${3:-60}" shift 3 local options=("$@") local num_options=${#options[@]} # Validate options if [[ $num_options -eq 0 ]]; then echo "" >&2 return 1 fi # Validate default if [[ ! "$default" =~ ^[0-9]+$ ]] || [[ "$default" -lt 1 ]] || [[ "$default" -gt "$num_options" ]]; then default=1 fi # Unattended mode: return default immediately if is_unattended; then echo "${options[$((default - 1))]}" return 0 fi # Check if running in a TTY if [[ ! -t 0 ]]; then echo "${options[$((default - 1))]}" return 0 fi # Display menu echo -e "${YW}${message}${CL}" >&2 local i for i in "${!options[@]}"; do local num=$((i + 1)) if [[ $num -eq $default ]]; then echo -e " ${GN}${num})${CL} ${options[$i]} ${YW}(default)${CL}" >&2 else echo -e " ${GN}${num})${CL} ${options[$i]}" >&2 fi done # Interactive prompt with timeout echo -en "${YW}Select [1-${num_options}] (auto-select ${default} in ${timeout}s): ${CL}" >&2 local response if read -t "$timeout" -r response; then if [[ -z "$response" ]]; then # Empty response, use default echo "${options[$((default - 1))]}" elif [[ "$response" =~ ^[0-9]+$ ]] && [[ "$response" -ge 1 ]] && [[ "$response" -le "$num_options" ]]; then # Valid selection echo "${options[$((response - 1))]}" else # Invalid input, use default echo -e "${YW}Invalid selection, using default: ${options[$((default - 1))]}${CL}" >&2 echo "${options[$((default - 1))]}" fi else # Timeout occurred echo "" >&2 # Newline after timeout echo -e "${YW}Timeout - auto-selecting: ${options[$((default - 1))]}${CL}" >&2 echo "${options[$((default - 1))]}" fi } # ------------------------------------------------------------------------------ # prompt_password() # # - Prompts user for password input with hidden characters # - In unattended mode: returns default or generates random password # - Supports auto-generation of secure passwords # - After timeout: generates random password if allowed # # Arguments: # $1 - Prompt message (required) # $2 - Default value or "generate" for auto-generation (optional) # $3 - Timeout in seconds (optional, default: 60) # $4 - Minimum length for validation (optional, default: 0 = no minimum) # # Output: # Prints the password to stdout # # Example: # password=$(prompt_password "Enter password:" "generate" 30 8) # echo "Password set" # # # Require user input (no default) # db_pass=$(prompt_password "Database password:" "" 60 12) # ------------------------------------------------------------------------------ prompt_password() { local message="${1:-Enter password:}" local default="${2:-}" local timeout="${3:-60}" local min_length="${4:-0}" local response # Generate random password if requested local generated="" if [[ "$default" == "generate" ]]; then generated=$(openssl rand -base64 16 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c 16) [[ -z "$generated" ]] && generated=$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16) default="$generated" fi # Unattended mode: return default immediately if is_unattended; then echo "$default" return 0 fi # Check if running in a TTY if [[ ! -t 0 ]]; then echo "$default" return 0 fi # Build hint local hint="" if [[ -n "$generated" ]]; then hint=" (Enter for auto-generated)" elif [[ -n "$default" ]]; then hint=" (Enter for default)" fi [[ "$min_length" -gt 0 ]] && hint="${hint} [min ${min_length} chars]" # Interactive prompt with timeout (silent input) echo -en "${YW}${message}${hint} (timeout ${timeout}s): ${CL}" >&2 if read -t "$timeout" -rs response; then echo "" >&2 # Newline after hidden input if [[ -n "$response" ]]; then # Validate minimum length if [[ "$min_length" -gt 0 ]] && [[ ${#response} -lt "$min_length" ]]; then echo -e "${YW}Password too short (min ${min_length}), using default${CL}" >&2 echo "$default" else echo "$response" fi else echo "$default" fi else # Timeout occurred echo "" >&2 # Newline after timeout echo -e "${YW}Timeout - using generated password${CL}" >&2 echo "$default" fi } # ============================================================================== # SECTION 6: CLEANUP & MAINTENANCE # ============================================================================== # ------------------------------------------------------------------------------ # cleanup_lxc() # # - Cleans package manager and language caches (safe for installs AND updates) # - Supports Alpine (apk), Debian/Ubuntu (apt), Python, Node.js, Go, Rust, Ruby, PHP # - Uses fallback error handling to prevent cleanup failures from breaking installs # ------------------------------------------------------------------------------ cleanup_lxc() { msg_info "Cleaning up" if is_alpine; then $STD apk cache clean || true rm -rf /var/cache/apk/* else $STD apt -y autoremove 2>/dev/null || msg_warn "apt autoremove failed (non-critical)" $STD apt -y autoclean 2>/dev/null || msg_warn "apt autoclean failed (non-critical)" $STD apt -y clean 2>/dev/null || msg_warn "apt clean failed (non-critical)" fi find /tmp /var/tmp -type f -name 'tmp*' -delete 2>/dev/null || true find /tmp /var/tmp -type f -name 'tempfile*' -delete 2>/dev/null || true # Python if command -v pip &>/dev/null; then rm -rf /root/.cache/pip 2>/dev/null || true fi if command -v uv &>/dev/null; then rm -rf /root/.cache/uv 2>/dev/null || true fi # Node.js if command -v npm &>/dev/null; then rm -rf /root/.npm/_cacache /root/.npm/_logs 2>/dev/null || true fi if command -v yarn &>/dev/null; then rm -rf /root/.cache/yarn /root/.yarn/cache 2>/dev/null || true fi if command -v pnpm &>/dev/null; then pnpm store prune &>/dev/null || true fi # Go (only build cache, not modules) if command -v go &>/dev/null; then $STD go clean -cache 2>/dev/null || true fi # Rust (only registry cache, not build artifacts) if command -v cargo &>/dev/null; then rm -rf /root/.cargo/registry/cache /root/.cargo/.package-cache 2>/dev/null || true fi # Ruby if command -v gem &>/dev/null; then rm -rf /root/.gem/cache 2>/dev/null || true fi # PHP if command -v composer &>/dev/null; then rm -rf /root/.composer/cache 2>/dev/null || true fi msg_ok "Cleaned" } # ------------------------------------------------------------------------------ # check_or_create_swap() # # - Checks if swap is active on system # - Offers to create swap file if none exists # - Prompts user for swap size in MB # - Creates /swapfile with specified size # - Activates swap immediately # - Returns 0 if swap active or successfully created, 1 if declined/failed # ------------------------------------------------------------------------------ check_or_create_swap() { msg_info "Checking for active swap" if swapon --noheadings --show | grep -q 'swap'; then msg_ok "Swap is active" return 0 fi msg_error "No active swap detected" if ! prompt_confirm "Do you want to create a swap file?" "n" 60; then msg_info "Skipping swap file creation" return 1 fi local swap_size_mb swap_size_mb=$(prompt_input "Enter swap size in MB (e.g., 2048 for 2GB):" "2048" 60) if ! [[ "$swap_size_mb" =~ ^[0-9]+$ ]]; then msg_error "Invalid size input. Aborting." return 1 fi local swap_file="/swapfile" msg_info "Creating ${swap_size_mb}MB swap file at $swap_file" if dd if=/dev/zero of="$swap_file" bs=1M count="$swap_size_mb" status=progress && chmod 600 "$swap_file" && mkswap "$swap_file" && swapon "$swap_file"; then msg_ok "Swap file created and activated successfully" else msg_error "Failed to create or activate swap" return 1 fi } # ------------------------------------------------------------------------------ # Loads LOCAL_IP from persistent store or detects if missing. # # Description: # - Loads from /run/local-ip.env or performs runtime lookup # ------------------------------------------------------------------------------ function get_lxc_ip() { local IP_FILE="/run/local-ip.env" if [[ -f "$IP_FILE" ]]; then # shellcheck disable=SC1090 source "$IP_FILE" fi if [[ -z "${LOCAL_IP:-}" ]]; then get_current_ip() { local ip # Try direct interface lookup for eth0 FIRST (most reliable for LXC) - IPv4 ip=$(ip -4 addr show eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -n1) if [[ -n "$ip" && "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "$ip" return 0 fi # Fallback: Try hostname -I (returns IPv4 first if available) if command -v hostname >/dev/null 2>&1; then ip=$(hostname -I 2>/dev/null | awk '{print $1}') if [[ -n "$ip" && "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "$ip" return 0 fi fi # Try routing table with IPv4 targets local ipv4_targets=("8.8.8.8" "1.1.1.1" "default") for target in "${ipv4_targets[@]}"; do if [[ "$target" == "default" ]]; then ip=$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}') else ip=$(ip route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}') fi if [[ -n "$ip" ]]; then echo "$ip" return 0 fi done # IPv6 fallback: Try direct interface lookup for eth0 ip=$(ip -6 addr show eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n1) if [[ -n "$ip" && "$ip" =~ : ]]; then echo "$ip" return 0 fi # IPv6 fallback: Try hostname -I for IPv6 if command -v hostname >/dev/null 2>&1; then ip=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E ':' | head -n1) if [[ -n "$ip" && "$ip" =~ : ]]; then echo "$ip" return 0 fi fi # IPv6 fallback: Use routing table with IPv6 targets local ipv6_targets=("2001:4860:4860::8888" "2606:4700:4700::1111") for target in "${ipv6_targets[@]}"; do ip=$(ip -6 route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}') if [[ -n "$ip" && "$ip" =~ : ]]; then echo "$ip" return 0 fi done return 1 } LOCAL_IP="$(get_current_ip || true)" if [[ -z "$LOCAL_IP" ]]; then msg_error "Could not determine LOCAL_IP" return 1 fi fi export LOCAL_IP } # ============================================================================== # SIGNAL TRAPS # ============================================================================== trap 'stop_spinner' EXIT INT TERM