#!/bin/bash set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset vars explicitly) # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR SCRIPT_VERSION="2.0.0" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= # PatchMon Proxmox LXC Auto-Enrollment Script # ============================================================================= # This script discovers LXC containers on a Proxmox host and automatically # enrolls them into PatchMon for patch management. # # Usage: # 1. Set environment variables or edit configuration below # 2. Run: bash proxmox_auto_enroll.sh # # Requirements: # - Must run on Proxmox host (requires 'pct' command) # - Auto-enrollment token from PatchMon # - Network access to PatchMon server # ============================================================================= # ===== CONFIGURATION ===== PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}" AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}" AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}" CURL_FLAGS="${CURL_FLAGS:--s}" DRY_RUN="${DRY_RUN:-false}" HOST_PREFIX="${HOST_PREFIX:-}" SKIP_STOPPED="${SKIP_STOPPED:-true}" PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}" MAX_PARALLEL="${MAX_PARALLEL:-5}" FORCE_INSTALL="${FORCE_INSTALL:-false}" # ===== COLOR OUTPUT ===== RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # ===== LOGGING FUNCTIONS ===== info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; } debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; } # ===== BANNER ===== cat << "EOF" ╔═══════════════════════════════════════════════════════════════╗ ║ ║ ║ ____ _ _ __ __ ║ ║ | _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║ ║ | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \ ║ ║ | __/ (_| | || (__| | | | | | | (_) | | | | ║ ║ |_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_| ║ ║ ║ ║ Proxmox LXC Auto-Enrollment Script ║ ║ ║ ╚═══════════════════════════════════════════════════════════════╝ EOF echo "" # ===== VALIDATION ===== info "Validating configuration..." if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set" fi if [[ -z "$PATCHMON_URL" ]]; then error "PATCHMON_URL must be set" fi # Check if running on Proxmox if ! command -v pct &> /dev/null; then error "This script must run on a Proxmox host (pct command not found)" fi # Check for required commands for cmd in curl jq; do if ! command -v $cmd &> /dev/null; then error "Required command '$cmd' not found. Please install it first." fi done info "Configuration validated successfully" info "PatchMon Server: $PATCHMON_URL" info "Dry Run Mode: $DRY_RUN" info "Skip Stopped Containers: $SKIP_STOPPED" echo "" # ===== DISCOVER LXC CONTAINERS ===== info "Discovering LXC containers..." lxc_list=$(pct list | tail -n +2) # Skip header if [[ -z "$lxc_list" ]]; then warn "No LXC containers found on this Proxmox host" exit 0 fi # Count containers total_containers=$(echo "$lxc_list" | wc -l) info "Found $total_containers LXC container(s)" echo "" info "Initializing statistics..." # ===== STATISTICS ===== enrolled_count=0 skipped_count=0 failed_count=0 # Track containers with dpkg errors for later recovery declare -A dpkg_error_containers # Track all failed containers for summary declare -A failed_containers info "Statistics initialized" # ===== PROCESS CONTAINERS ===== info "Starting container processing loop..." while IFS= read -r line; do info "[DEBUG] Read line from lxc_list" vmid=$(echo "$line" | awk '{print $1}') status=$(echo "$line" | awk '{print $2}') name=$(echo "$line" | awk '{print $3}') info "Processing LXC $vmid: $name (status: $status)" # Skip stopped containers if configured if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then warn " Skipping $name - container not running" ((skipped_count++)) || true echo "" continue fi # Check if container is stopped if [[ "$status" != "running" ]]; then warn " Container $name is stopped - cannot gather info or install agent" ((skipped_count++)) || true echo "" continue fi # Get container details debug " Gathering container information..." hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null /dev/null /dev/null /dev/null $architecture" # Get machine ID from container machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" /dev/null || echo "proxmox-lxc-$vmid-unknown") friendly_name="${HOST_PREFIX}${hostname}" info " Hostname: $hostname" info " IP Address: $ip_address" info " OS: $os_info" info " Architecture: $architecture ($arch_raw)" info " Machine ID: ${machine_id:0:16}..." if [[ "$DRY_RUN" == "true" ]]; then info " [DRY RUN] Would enroll: $friendly_name" ((enrolled_count++)) || true echo "" continue fi # Call PatchMon auto-enrollment API info " Enrolling $friendly_name in PatchMon..." response=$(curl $CURL_FLAGS -X POST \ -H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \ -H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \ -H "Content-Type: application/json" \ -d "{ \"friendly_name\": \"$friendly_name\", \"machine_id\": \"$machine_id\", \"metadata\": { \"vmid\": \"$vmid\", \"proxmox_node\": \"$(hostname)\", \"ip_address\": \"$ip_address\", \"os_info\": \"$os_info\" } }" \ "$PATCHMON_URL/api/v1/auto-enrollment/enroll" \ -w "\n%{http_code}" 2>&1) http_code=$(echo "$response" | tail -n 1) body=$(echo "$response" | sed '$d') if [[ "$http_code" == "201" ]]; then api_id=$(echo "$body" | jq -r '.host.api_id' 2>/dev/null || echo "") api_key=$(echo "$body" | jq -r '.host.api_key' 2>/dev/null || echo "") if [[ -z "$api_id" ]] || [[ -z "$api_key" ]]; then error " Failed to parse API credentials from response" fi info " ✓ Host enrolled successfully: $api_id" # Ensure curl is installed in the container info " Checking for curl in container..." curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null /dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y -qq curl elif command -v yum >/dev/null 2>&1; then yum install -y -q curl elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl elif command -v apk >/dev/null 2>&1; then apk add --no-cache curl else echo 'ERROR: No supported package manager found' exit 1 fi " 2>&1 &1 180s) in $friendly_name" info " Install output: $install_output" # Store failure details failed_containers["$vmid"]="$friendly_name|Timeout (>180s)|$install_output" ((failed_count++)) || true else # Check if it's a dpkg error if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)" dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key" # Store failure details failed_containers["$vmid"]="$friendly_name|dpkg error|$install_output" else warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" # Store failure details failed_containers["$vmid"]="$friendly_name|Exit code $install_exit_code|$install_output" fi info " Install output: $install_output" ((failed_count++)) || true fi elif [[ "$http_code" == "409" ]]; then warn " ⊘ Host $friendly_name already enrolled - skipping" ((skipped_count++)) || true elif [[ "$http_code" == "429" ]]; then error " ✗ Rate limit exceeded - maximum hosts per day reached" failed_containers["$vmid"]="$friendly_name|Rate limit exceeded|$body" ((failed_count++)) || true else error " ✗ Failed to enroll $friendly_name - HTTP $http_code" debug " Response: $body" failed_containers["$vmid"]="$friendly_name|HTTP $http_code enrollment failed|$body" ((failed_count++)) || true fi echo "" sleep 1 # Rate limiting between containers done <<< "$lxc_list" # ===== SUMMARY ===== echo "" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ENROLLMENT SUMMARY ║" echo "╚═══════════════════════════════════════════════════════════════╝" echo "" info "Total Containers Found: $total_containers" info "Successfully Enrolled: $enrolled_count" info "Skipped: $skipped_count" info "Failed: $failed_count" echo "" # ===== FAILURE DETAILS ===== if [[ ${#failed_containers[@]} -gt 0 ]]; then echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ FAILURE DETAILS ║" echo "╚═══════════════════════════════════════════════════════════════╝" echo "" for vmid in "${!failed_containers[@]}"; do IFS='|' read -r name reason output <<< "${failed_containers[$vmid]}" warn "Container $vmid: $name" info " Reason: $reason" info " Last 5 lines of output:" # Get last 5 lines of output last_5_lines=$(echo "$output" | tail -n 5) # Display each line with proper indentation while IFS= read -r line; do echo " $line" done <<< "$last_5_lines" echo "" done fi if [[ "$DRY_RUN" == "true" ]]; then warn "This was a DRY RUN - no actual changes were made" warn "Set DRY_RUN=false to perform actual enrollment" fi # ===== DPKG ERROR RECOVERY ===== if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then echo "" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ DPKG ERROR RECOVERY AVAILABLE ║" echo "╚═══════════════════════════════════════════════════════════════╝" echo "" warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:" for vmid in "${!dpkg_error_containers[@]}"; do IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}" info " • Container $vmid: $name" done echo "" # Ask user if they want to fix dpkg errors read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r echo "" if [[ $REPLY =~ ^[Yy]$ ]]; then echo "" info "Starting dpkg recovery process..." echo "" recovered_count=0 for vmid in "${!dpkg_error_containers[@]}"; do IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}" info "Fixing dpkg in container $vmid ($name)..." # Run dpkg --configure -a dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 &1