#!/usr/bin/env bash # Copyright (c) 2021-2026 community-scripts ORG # Author: tteck (tteckster) | MickLesk | michelroegl-brunner # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/branch/main/LICENSE # ============================================================================== # BUILD.FUNC - LXC CONTAINER BUILD & CONFIGURATION # ============================================================================== # # This file provides the main build functions for creating and configuring # LXC containers in Proxmox VE. It handles: # # - Variable initialization and defaults # - Container creation and resource allocation # - Storage selection and management # - Advanced configuration and customization # - User interaction menus and prompts # # Usage: # - Sourced automatically by CT creation scripts # - Requires core.func and error_handler.func to be loaded first # # ============================================================================== # ============================================================================== # SECTION 1: INITIALIZATION & CORE VARIABLES # ============================================================================== # ------------------------------------------------------------------------------ # variables() # # - Initializes core variables for container creation # - Normalizes application name (NSAPP = lowercase, no spaces) # - Builds installer filename (var_install) # - Defines regex patterns for validation # - Fetches Proxmox hostname and version # - Generates unique session ID for tracking and logging # - Captures app-declared resource defaults (CPU, RAM, Disk) # ------------------------------------------------------------------------------ variables() { NSAPP=$(echo "${APP,,}" | tr -d ' ') # This function sets the NSAPP variable by converting the value of the APP variable to lowercase and removing any spaces. var_install="${NSAPP}-install" # sets the var_install variable by appending "-install" to the value of NSAPP. INTEGER='^[0-9]+([.][0-9]+)?$' # it defines the INTEGER regular expression pattern. PVEHOST_NAME=$(hostname) # gets the Proxmox Hostname and sets it to Uppercase DIAGNOSTICS="no" # Safe default: no telemetry until user consents via diagnostics_check() METHOD="default" # sets the METHOD variable to "default", used for the API call. RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" # generates a random UUID and sets it to the RANDOM_UUID variable. EXECUTION_ID="${RANDOM_UUID}" # Unique execution ID for telemetry record identification (unique-indexed in PocketBase) SESSION_ID="${RANDOM_UUID:0:8}" # Short session ID (first 8 chars of UUID) for log files BUILD_LOG="/tmp/create-lxc-${SESSION_ID}.log" # Host-side container creation log # NOTE: combined_log is constructed locally in build_container() and ensure_log_on_host() # as "/tmp/${NSAPP}-${CTID}-${SESSION_ID}.log" (requires CTID, not available here) CTTYPE="${CTTYPE:-${CT_TYPE:-1}}" # ARM64 Template default variables DEBIAN_DEFAULT_CODENAME="trixie" UBUNTU_DEFAULT_CODENAME="noble" ALPINE_DEFAULT_VERSION="3.23" # Parse dev_mode early parse_dev_mode # Setup persistent log directory if logs mode active if [[ "${DEV_MODE_LOGS:-false}" == "true" ]]; then mkdir -p /var/log/community-scripts BUILD_LOG="/var/log/community-scripts/create-lxc-${SESSION_ID}-$(date +%Y%m%d_%H%M%S).log" fi # Get Proxmox VE version and kernel version if command -v pveversion >/dev/null 2>&1; then PVEVERSION="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')" else PVEVERSION="N/A" fi KERNEL_VERSION=$(uname -r) # Capture app-declared defaults (for precedence logic) # These values are set by the app script BEFORE default.vars is loaded # If app declares higher values than default.vars, app values take precedence if [[ -n "${var_cpu:-}" && "${var_cpu}" =~ ^[0-9]+$ ]]; then export APP_DEFAULT_CPU="${var_cpu}" fi if [[ -n "${var_ram:-}" && "${var_ram}" =~ ^[0-9]+$ ]]; then export APP_DEFAULT_RAM="${var_ram}" fi if [[ -n "${var_disk:-}" && "${var_disk}" =~ ^[0-9]+$ ]]; then export APP_DEFAULT_DISK="${var_disk}" fi } source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/api.func) if command -v curl >/dev/null 2>&1; then source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func) source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/error_handler.func) load_functions catch_errors elif command -v wget >/dev/null 2>&1; then source <(wget -qO- https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func) source <(wget -qO- https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/error_handler.func) load_functions catch_errors fi # ============================================================================== # SECTION 2: PRE-FLIGHT CHECKS & SYSTEM VALIDATION # ============================================================================== # ------------------------------------------------------------------------------ # maxkeys_check() # # - Reads kernel keyring limits (maxkeys, maxbytes) # - Checks current usage for LXC user (UID 100000) # - Warns if usage is close to limits and suggests sysctl tuning # - Exits if thresholds are exceeded # - https://cleveruptime.com/docs/files/proc-key-users | https://docs.kernel.org/security/keys/core.html # ------------------------------------------------------------------------------ maxkeys_check() { # Read kernel parameters per_user_maxkeys=$(cat /proc/sys/kernel/keys/maxkeys 2>/dev/null || echo 0) per_user_maxbytes=$(cat /proc/sys/kernel/keys/maxbytes 2>/dev/null || echo 0) # Exit if kernel parameters are unavailable if [[ "$per_user_maxkeys" -eq 0 || "$per_user_maxbytes" -eq 0 ]]; then msg_error "Unable to read kernel key parameters. Ensure proper permissions." exit 107 fi # Fetch key usage for user ID 100000 (typical for containers) used_lxc_keys=$(awk '/100000:/ {print $2}' /proc/key-users 2>/dev/null || echo 0) used_lxc_bytes=$(awk '/100000:/ {split($5, a, "/"); print a[1]}' /proc/key-users 2>/dev/null || echo 0) # Calculate thresholds and suggested new limits threshold_keys=$((per_user_maxkeys - 100)) threshold_bytes=$((per_user_maxbytes - 1000)) new_limit_keys=$((per_user_maxkeys * 2)) new_limit_bytes=$((per_user_maxbytes * 2)) # Check if key or byte usage is near limits failure=0 if [[ "$used_lxc_keys" -gt "$threshold_keys" ]]; then msg_warn "Key usage is near the limit (${used_lxc_keys}/${per_user_maxkeys})" echo -e "${INFO} Suggested action: Set ${GN}kernel.keys.maxkeys=${new_limit_keys}${CL} in ${BOLD}/etc/sysctl.d/98-community-scripts.conf${CL}." failure=1 fi if [[ "$used_lxc_bytes" -gt "$threshold_bytes" ]]; then msg_warn "Key byte usage is near the limit (${used_lxc_bytes}/${per_user_maxbytes})" echo -e "${INFO} Suggested action: Set ${GN}kernel.keys.maxbytes=${new_limit_bytes}${CL} in ${BOLD}/etc/sysctl.d/98-community-scripts.conf${CL}." failure=1 fi # Provide next steps if issues are detected if [[ "$failure" -eq 1 ]]; then msg_error "Kernel key limits exceeded - see suggestions above" exit 108 fi # Silent success - only show errors if they exist } # ============================================================================== # SECTION 3: CONTAINER SETUP UTILITIES # ============================================================================== # ------------------------------------------------------------------------------ # get_current_ip() # # - Returns current container IP depending on OS type # - Debian/Ubuntu: uses `hostname -I` # - Alpine: parses eth0 via `ip -4 addr` or `ip -6 addr` # - Supports IPv6-only environments as fallback # - Returns "Unknown" if OS type cannot be determined # ------------------------------------------------------------------------------ get_current_ip() { CURRENT_IP="" if [ -f /etc/os-release ]; then # Check for Debian/Ubuntu (uses hostname -I) if grep -qE 'ID=debian|ID=ubuntu' /etc/os-release; then # Try IPv4 first CURRENT_IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true) # Fallback to IPv6 if no IPv4 if [[ -z "$CURRENT_IP" ]]; then CURRENT_IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E ':' | head -n1 || true) fi # Check for Alpine (uses ip command) elif grep -q 'ID=alpine' /etc/os-release; then # Try IPv4 first CURRENT_IP=$(ip -4 addr show eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -n 1) # Fallback to IPv6 if no IPv4 if [[ -z "$CURRENT_IP" ]]; then CURRENT_IP=$(ip -6 addr show eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n 1) fi else CURRENT_IP="Unknown" fi fi echo "$CURRENT_IP" } # ------------------------------------------------------------------------------ # update_motd_ip() # # - Updates /etc/motd with current container IP # - Removes old IP entries to avoid duplicates # - Regenerates /etc/profile.d/00_lxc-details.sh with dynamic OS/IP info # ------------------------------------------------------------------------------ update_motd_ip() { MOTD_FILE="/etc/motd" PROFILE_FILE="/etc/profile.d/00_lxc-details.sh" if [ -f "$MOTD_FILE" ]; then # Remove existing IP Address lines to prevent duplication sed -i '/IP Address:/d' "$MOTD_FILE" IP=$(get_current_ip) # Add the new IP address echo -e "${TAB}${NETWORK}${YW} IP Address: ${GN}${IP}${CL}" >>"$MOTD_FILE" fi # Update dynamic LXC details profile if values changed (e.g., after OS upgrade) # Only update if file exists and is from community-scripts if [ -f "$PROFILE_FILE" ] && grep -q "community-scripts" "$PROFILE_FILE" 2>/dev/null; then # Get current values local current_hostname="$(hostname)" local current_ip="$(hostname -I | awk '{print $1}')" # Escape sed special chars in replacement strings (& \ |) current_hostname="${current_hostname//\\/\\\\}" current_hostname="${current_hostname//&/\\&}" current_ip="${current_ip//\\/\\\\}" current_ip="${current_ip//&/\\&}" # Update only if values actually changed if ! grep -q "Hostname:.*$current_hostname" "$PROFILE_FILE" 2>/dev/null; then sed -i "s|Hostname:.*|Hostname: \${GN}$current_hostname\${CL}\\\"|" "$PROFILE_FILE" fi if ! grep -q "IP Address:.*$current_ip" "$PROFILE_FILE" 2>/dev/null; then sed -i "s|IP Address:.*|IP Address: \${GN}$current_ip\${CL}\\\"|" "$PROFILE_FILE" fi fi } # ------------------------------------------------------------------------------ # install_ssh_keys_into_ct() # # - Installs SSH keys into container root account if SSH is enabled # - Uses pct push or direct input to authorized_keys # - Supports both SSH_KEYS_FILE (from advanced settings) and SSH_AUTHORIZED_KEY (from user defaults) # - Falls back to warning if no keys provided # ------------------------------------------------------------------------------ install_ssh_keys_into_ct() { [[ "${SSH:-no}" != "yes" ]] && return 0 # Ensure SSH_KEYS_FILE is defined (may not be set if advanced_settings was skipped) : "${SSH_KEYS_FILE:=}" # If SSH_KEYS_FILE doesn't exist but SSH_AUTHORIZED_KEY is set (from user defaults), # create a temporary SSH_KEYS_FILE with the key if [[ -z "$SSH_KEYS_FILE" || ! -s "$SSH_KEYS_FILE" ]] && [[ -n "${SSH_AUTHORIZED_KEY:-}" ]]; then SSH_KEYS_FILE="$(mktemp)" printf '%s\n' "$SSH_AUTHORIZED_KEY" >"$SSH_KEYS_FILE" fi if [[ -n "$SSH_KEYS_FILE" && -s "$SSH_KEYS_FILE" ]]; then msg_info "Installing selected SSH keys into CT ${CTID}" pct exec "$CTID" -- sh -c 'mkdir -p /root/.ssh && chmod 700 /root/.ssh' || { msg_error "prepare /root/.ssh failed" return 252 } pct push "$CTID" "$SSH_KEYS_FILE" /root/.ssh/authorized_keys >/dev/null 2>&1 || pct exec "$CTID" -- sh -c "cat > /root/.ssh/authorized_keys" <"$SSH_KEYS_FILE" || { msg_error "write authorized_keys failed" return 252 } pct exec "$CTID" -- sh -c 'chmod 600 /root/.ssh/authorized_keys' || true msg_ok "Installed SSH keys into CT ${CTID}" return 0 fi # Fallback msg_warn "No SSH keys to install (skipping)." return 0 } # ------------------------------------------------------------------------------ # validate_container_id() # # - Validates if a container ID is available for use (CLUSTER-WIDE) # - Checks cluster resources via pvesh for VMs/CTs on ALL nodes # - Falls back to local config file check if pvesh unavailable # - Checks if ID is used in LVM logical volumes # - Returns 0 if ID is available, 1 if already in use # ------------------------------------------------------------------------------ validate_container_id() { local ctid="$1" # Check if ID is numeric if ! [[ "$ctid" =~ ^[0-9]+$ ]]; then return 1 fi # CLUSTER-WIDE CHECK: Query all VMs/CTs across all nodes # This catches IDs used on other nodes in the cluster # NOTE: Works on single-node too - Proxmox always has internal cluster structure # Falls back gracefully if pvesh unavailable or returns empty if command -v pvesh &>/dev/null; then local cluster_ids cluster_ids=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K[0-9]+' 2>/dev/null || true) if [[ -n "$cluster_ids" ]] && echo "$cluster_ids" | grep -qw "$ctid"; then return 1 fi fi # LOCAL FALLBACK: Check if config file exists for VM or LXC # This handles edge cases where pvesh might not return all info if [[ -f "/etc/pve/qemu-server/${ctid}.conf" ]] || [[ -f "/etc/pve/lxc/${ctid}.conf" ]]; then return 1 fi # Check ALL nodes in cluster for config files (handles pmxcfs sync delays) # NOTE: On single-node, /etc/pve/nodes/ contains just the one node - still works if [[ -d "/etc/pve/nodes" ]]; then for node_dir in /etc/pve/nodes/*/; do if [[ -f "${node_dir}qemu-server/${ctid}.conf" ]] || [[ -f "${node_dir}lxc/${ctid}.conf" ]]; then return 1 fi done fi # Check if ID is used in LVM logical volumes if lvs --noheadings -o lv_name 2>/dev/null | grep -qE "(^|[-_])${ctid}($|[-_])"; then return 1 fi return 0 } # ------------------------------------------------------------------------------ # get_valid_container_id() # # - Returns a valid, unused container ID (CLUSTER-AWARE) # - Uses pvesh /cluster/nextid as starting point (already cluster-aware) # - If provided ID is valid, returns it # - Otherwise increments until a free one is found across entire cluster # - Calls validate_container_id() to check availability # ------------------------------------------------------------------------------ get_valid_container_id() { local suggested_id="${1:-$(pvesh get /cluster/nextid 2>/dev/null || echo 100)}" # Ensure we have a valid starting ID if ! [[ "$suggested_id" =~ ^[0-9]+$ ]]; then suggested_id=$(pvesh get /cluster/nextid 2>/dev/null || echo 100) fi local max_attempts=1000 local attempts=0 while ! validate_container_id "$suggested_id"; do suggested_id=$((suggested_id + 1)) attempts=$((attempts + 1)) if [[ $attempts -ge $max_attempts ]]; then msg_error "Could not find available container ID after $max_attempts attempts" exit 109 fi done echo "$suggested_id" } # ------------------------------------------------------------------------------ # validate_hostname() # # - Validates hostname/FQDN according to RFC 1123/952 # - Checks total length (max 253 characters for FQDN) # - Validates each label (max 63 chars, alphanumeric + hyphens) # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_hostname() { local hostname="$1" # Check total length (max 253 for FQDN) if [[ ${#hostname} -gt 253 ]] || [[ -z "$hostname" ]]; then return 1 fi # Split by dots and validate each label local IFS='.' read -ra labels <<<"$hostname" for label in "${labels[@]}"; do # Each label: 1-63 chars, alphanumeric, hyphens allowed (not at start/end) if [[ -z "$label" ]] || [[ ${#label} -gt 63 ]]; then return 1 fi if [[ ! "$label" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] && [[ ! "$label" =~ ^[a-z0-9]$ ]]; then return 1 fi done return 0 } # ------------------------------------------------------------------------------ # validate_mac_address() # # - Validates MAC address format (XX:XX:XX:XX:XX:XX) # - Empty value is allowed (auto-generated) # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_mac_address() { local mac="$1" [[ -z "$mac" ]] && return 0 if [[ ! "$mac" =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]]; then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_vlan_tag() # # - Validates VLAN tag (1-4094) # - Empty value is allowed (no VLAN) # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_vlan_tag() { local vlan="$1" [[ -z "$vlan" ]] && return 0 if ! [[ "$vlan" =~ ^[0-9]+$ ]] || ((vlan < 1 || vlan > 4094)); then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_mtu() # # - Validates MTU size (576-65535, common values: 1500, 9000) # - Empty value is allowed (default 1500) # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_mtu() { local mtu="$1" [[ -z "$mtu" ]] && return 0 if ! [[ "$mtu" =~ ^[0-9]+$ ]] || ((mtu < 576 || mtu > 65535)); then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_ipv6_address() # # - Validates IPv6 address with optional CIDR notation # - Supports compressed (::) and full notation # - Empty value is allowed # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_ipv6_address() { local ipv6="$1" [[ -z "$ipv6" ]] && return 0 # Extract address and CIDR local addr="${ipv6%%/*}" local cidr="${ipv6##*/}" # Validate CIDR if present (1-128) if [[ "$ipv6" == */* ]]; then if ! [[ "$cidr" =~ ^[0-9]+$ ]] || ((cidr < 1 || cidr > 128)); then return 1 fi fi # Basic IPv6 validation - check for valid characters and structure # Must contain only hex digits and colons if [[ ! "$addr" =~ ^[0-9a-fA-F:]+$ ]]; then return 1 fi # Must contain at least one colon if [[ ! "$addr" == *:* ]]; then return 1 fi # Check for valid double-colon usage (only one :: allowed) if [[ "$addr" == *::*::* ]]; then return 1 fi # Check that no segment exceeds 4 hex chars local IFS=':' local -a segments read -ra segments <<<"$addr" for seg in "${segments[@]}"; do if [[ ${#seg} -gt 4 ]]; then return 1 fi done return 0 } # ------------------------------------------------------------------------------ # validate_bridge() # # - Validates that network bridge exists and is active # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_bridge() { local bridge="$1" [[ -z "$bridge" ]] && return 1 # Check if bridge interface exists if ! ip link show dev "$bridge" &>/dev/null; then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_sdn_vnet() # # - Validates that an SDN vnet exists in the cluster config # ------------------------------------------------------------------------------ validate_sdn_vnet() { local vnet="$1" [[ -z "$vnet" ]] && return 1 [[ -f /etc/pve/sdn/vnets.cfg ]] && grep -qE "^vnet:[[:space:]]*${vnet}([[:space:]]|$)" /etc/pve/sdn/vnets.cfg && return 0 command -v pvesh &>/dev/null && pvesh get "/cluster/sdn/vnets/${vnet}" &>/dev/null && return 0 return 1 } # ------------------------------------------------------------------------------ # validate_gateway_in_subnet() # # - Validates that gateway IP is in the same subnet as static IP # - Arguments: static_ip (with CIDR), gateway_ip # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_gateway_in_subnet() { local static_ip="$1" local gateway="$2" [[ -z "$static_ip" || -z "$gateway" ]] && return 0 # Extract IP and CIDR local ip="${static_ip%%/*}" local cidr="${static_ip##*/}" # /31 and /32 are valid point-to-point / zero-trust DMZ configurations # where the gateway is technically outside the subnet — skip validation ((cidr >= 31)) && return 0 # Convert CIDR to netmask bits local mask=$((0xFFFFFFFF << (32 - cidr) & 0xFFFFFFFF)) # Convert IPs to integers local IFS='.' read -r i1 i2 i3 i4 <<<"$ip" read -r g1 g2 g3 g4 <<<"$gateway" local ip_int=$(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4)) local gw_int=$(((g1 << 24) + (g2 << 16) + (g3 << 8) + g4)) # Check if both are in same network if (((ip_int & mask) != (gw_int & mask))); then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_ip_address() # # - Validates IPv4 address with CIDR notation # - Checks each octet is 0-255 # - Checks CIDR is 1-32 # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_ip_address() { local ip="$1" [[ -z "$ip" ]] && return 1 # Check format with CIDR if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$ ]]; then return 1 fi local o1="${BASH_REMATCH[1]}" local o2="${BASH_REMATCH[2]}" local o3="${BASH_REMATCH[3]}" local o4="${BASH_REMATCH[4]}" local cidr="${BASH_REMATCH[5]}" # Validate octets (0-255) for octet in "$o1" "$o2" "$o3" "$o4"; do if ((octet > 255)); then return 1 fi done # Validate CIDR (1-32) if ((cidr < 1 || cidr > 32)); then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_gateway_ip() # # - Validates gateway IPv4 address (without CIDR) # - Checks each octet is 0-255 # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_gateway_ip() { local ip="$1" [[ -z "$ip" ]] && return 0 # Check format without CIDR if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then return 1 fi local o1="${BASH_REMATCH[1]}" local o2="${BASH_REMATCH[2]}" local o3="${BASH_REMATCH[3]}" local o4="${BASH_REMATCH[4]}" # Validate octets (0-255) for octet in "$o1" "$o2" "$o3" "$o4"; do if ((octet > 255)); then return 1 fi done return 0 } # ------------------------------------------------------------------------------ # validate_timezone() # # - Validates timezone string against system zoneinfo # - Empty value or "host" is allowed # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_timezone() { local tz="$1" [[ -z "$tz" || "$tz" == "host" ]] && return 0 # Check if timezone file exists if [[ ! -f "/usr/share/zoneinfo/$tz" ]]; then return 1 fi return 0 } # ------------------------------------------------------------------------------ # validate_tags() # # - Validates Proxmox tags format # - Only alphanumeric, hyphens, underscores, and semicolons allowed # - Empty value is allowed # - Returns 0 if valid, 1 if invalid # ------------------------------------------------------------------------------ validate_tags() { local tags="$1" [[ -z "$tags" ]] && return 0 # Tags can only contain alphanumeric, -, _, and ; (separator) if [[ ! "$tags" =~ ^[a-zA-Z0-9_\;-]+$ ]]; then return 1 fi return 0 } # ------------------------------------------------------------------------------ # find_host_ssh_keys() # # - Scans system for available SSH keys # - Supports defaults (~/.ssh, /etc/ssh/authorized_keys) # - Returns list of files containing valid SSH public keys # - Sets FOUND_HOST_KEY_COUNT to number of keys found # ------------------------------------------------------------------------------ find_host_ssh_keys() { local re='(ssh-(rsa|ed25519)|ecdsa-sha2-nistp256|sk-(ssh-ed25519|ecdsa-sha2-nistp256))' local -a files=() cand=() local g="${var_ssh_import_glob:-}" local total=0 f base c shopt -s nullglob if [[ -n "$g" ]]; then for pat in $g; do cand+=($pat); done else cand+=(/root/.ssh/authorized_keys /root/.ssh/authorized_keys2) cand+=(/root/.ssh/*.pub) cand+=(/etc/ssh/authorized_keys /etc/ssh/authorized_keys.d/*) fi shopt -u nullglob for f in "${cand[@]}"; do [[ -f "$f" && -r "$f" ]] || continue base="$(basename -- "$f")" case "$base" in known_hosts | known_hosts.* | config) continue ;; id_*) [[ "$f" != *.pub ]] && continue ;; esac # CRLF safe check for host keys c=$(tr -d '\r' <"$f" | awk ' /^[[:space:]]*#/ {next} /^[[:space:]]*$/ {next} {print} ' | grep -E -c "$re" || true) if ((c > 0)); then files+=("$f") total=$((total + c)) fi done # Fallback to /root/.ssh/authorized_keys if ((${#files[@]} == 0)) && [[ -r /root/.ssh/authorized_keys ]]; then if grep -E -q "$re" /root/.ssh/authorized_keys; then files+=(/root/.ssh/authorized_keys) total=$((total + $(grep -E -c "$re" /root/.ssh/authorized_keys || echo 0))) fi fi FOUND_HOST_KEY_COUNT="$total" ( IFS=: echo "${files[*]}" ) } # ============================================================================== # SECTION 3B: IP RANGE SCANNING # ============================================================================== # ------------------------------------------------------------------------------ # ip_to_int() / int_to_ip() # # - Converts IP address to integer and vice versa for range iteration # ------------------------------------------------------------------------------ ip_to_int() { local IFS=. read -r i1 i2 i3 i4 <<<"$1" echo $(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4)) } int_to_ip() { local ip=$1 echo "$(((ip >> 24) & 0xFF)).$(((ip >> 16) & 0xFF)).$(((ip >> 8) & 0xFF)).$((ip & 0xFF))" } # ------------------------------------------------------------------------------ # resolve_ip_from_range() # # - Takes an IP range in format "10.0.0.1/24-10.0.0.10/24" # - Pings each IP in the range to find the first available one # - Returns the first free IP with CIDR notation # - Sets NET_RESOLVED to the resolved IP or empty on failure # ------------------------------------------------------------------------------ resolve_ip_from_range() { local range="$1" local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$' local ip_start ip_end # Parse range: "10.0.0.1/24-10.0.0.10/24" ip_start="${range%%-*}" ip_end="${range##*-}" if [[ ! "$ip_start" =~ $ip_cidr_regex ]] || [[ ! "$ip_end" =~ $ip_cidr_regex ]]; then NET_RESOLVED="" return 1 fi local ip1="${ip_start%%/*}" local ip2="${ip_end%%/*}" local cidr="${ip_start##*/}" local start_int=$(ip_to_int "$ip1") local end_int=$(ip_to_int "$ip2") for ((ip_int = start_int; ip_int <= end_int; ip_int++)); do local ip=$(int_to_ip $ip_int) msg_info "Checking IP: $ip" if ! ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then NET_RESOLVED="$ip/$cidr" msg_ok "Found free IP: ${BGN}$NET_RESOLVED${CL}" return 0 fi done NET_RESOLVED="" msg_error "No free IP found in range $range" return 1 } # ------------------------------------------------------------------------------ # is_ip_range() # # - Checks if a string is an IP range (contains - and looks like IP/CIDR) # - Returns 0 if it's a range, 1 otherwise # ------------------------------------------------------------------------------ is_ip_range() { local value="$1" local ip_start ip_end if [[ "$value" == *-* ]] && [[ "$value" != "dhcp" ]]; then local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$' ip_start="${value%%-*}" ip_end="${value##*-}" if [[ "$ip_start" =~ $ip_cidr_regex ]] && [[ "$ip_end" =~ $ip_cidr_regex ]]; then return 0 fi fi return 1 } # ============================================================================== # SECTION 4: STORAGE & RESOURCE MANAGEMENT # ============================================================================== # ------------------------------------------------------------------------------ # _write_storage_to_vars() # # - Writes storage selection to vars file # - Removes old entries (commented and uncommented) to avoid duplicates # - Arguments: vars_file, key (var_container_storage/var_template_storage), value # ------------------------------------------------------------------------------ _write_storage_to_vars() { # $1 = vars_file, $2 = key (var_container_storage / var_template_storage), $3 = value local vf="$1" key="$2" val="$3" # remove uncommented and commented versions to avoid duplicates sed -i "/^[#[:space:]]*${key}=/d" "$vf" echo "${key}=${val}" >>"$vf" } choose_and_set_storage_for_file() { # $1 = vars_file, $2 = class ('container'|'template') local vf="$1" class="$2" key="" current="" case "$class" in container) key="var_container_storage" ;; template) key="var_template_storage" ;; *) msg_error "Unknown storage class: $class" return 65 ;; esac current=$(awk -F= -v k="^${key}=" '$0 ~ k {print $2; exit}' "$vf") # If only one storage exists for the content type, auto-pick. Else always ask (your wish #4). local content="rootdir" [[ "$class" == "template" ]] && content="vztmpl" local count count=$(pvesm status -content "$content" | awk 'NR>1{print $1}' | wc -l) if [[ "$count" -eq 1 ]]; then STORAGE_RESULT=$(pvesm status -content "$content" | awk 'NR>1{print $1; exit}') STORAGE_INFO="" # Validate storage space for auto-picked container storage if [[ "$class" == "container" && -n "${DISK_SIZE:-}" ]]; then validate_storage_space "$STORAGE_RESULT" "$DISK_SIZE" "yes" # Continue even if validation fails - user was warned fi else # If the current value is preselectable, we could show it, but per your requirement we always offer selection select_storage "$class" || return 150 fi _write_storage_to_vars "$vf" "$key" "$STORAGE_RESULT" # Keep environment in sync for later steps (e.g. app-default save) if [[ "$class" == "container" ]]; then export var_container_storage="$STORAGE_RESULT" export CONTAINER_STORAGE="$STORAGE_RESULT" else export var_template_storage="$STORAGE_RESULT" export TEMPLATE_STORAGE="$STORAGE_RESULT" fi # Silent operation - no output message } # ============================================================================== # SECTION 5: CONFIGURATION & DEFAULTS MANAGEMENT # ============================================================================== # ------------------------------------------------------------------------------ # Distinct versions of OS type $1 in the cached pveam catalog (no network). # `pveam available` prints "
\t