From 88130797e4ebf4979ec1881d4b9d68895b0a54ed Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 10 Oct 2025 12:39:17 +0100 Subject: [PATCH 1/7] Updated Version to 1.2.8 --- backend/package.json | 2 +- backend/src/routes/versionRoutes.js | 10 +++++----- backend/src/services/updateScheduler.js | 4 ++-- frontend/package.json | 2 +- package.json | 2 +- setup.sh | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/package.json b/backend/package.json index a12467e..50700b0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "patchmon-backend", - "version": "1.2.7", + "version": "1.2.8", "description": "Backend API for Linux Patch Monitoring System", "license": "AGPL-3.0", "main": "src/server.js", diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 81b82c9..7211d2c 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -14,13 +14,13 @@ const router = express.Router(); function getCurrentVersion() { try { const packageJson = require("../../package.json"); - return packageJson?.version || "1.2.7"; + return packageJson?.version || "1.2.8"; } catch (packageError) { console.warn( "Could not read version from package.json, using fallback:", packageError.message, ); - return "1.2.7"; + return "1.2.8"; } } @@ -274,11 +274,11 @@ router.get( ) { console.log("GitHub API rate limited, providing fallback data"); latestRelease = { - tagName: "v1.2.7", - version: "1.2.7", + tagName: "1.2.8", + version: "1.2.8", publishedAt: "2025-10-02T17:12:53Z", htmlUrl: - "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7", + "https://github.com/PatchMon/PatchMon/releases/tag/1.2.8", }; latestCommit = { sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd", diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js index 34f1979..ab6f12d 100644 --- a/backend/src/services/updateScheduler.js +++ b/backend/src/services/updateScheduler.js @@ -104,7 +104,7 @@ class UpdateScheduler { } // Read version from package.json dynamically - let currentVersion = "1.2.7"; // fallback + let currentVersion = "1.2.8"; // fallback try { const packageJson = require("../../package.json"); if (packageJson?.version) { @@ -214,7 +214,7 @@ class UpdateScheduler { const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; // Get current version for User-Agent - let currentVersion = "1.2.7"; // fallback + let currentVersion = "1.2.8"; // fallback try { const packageJson = require("../../package.json"); if (packageJson?.version) { diff --git a/frontend/package.json b/frontend/package.json index 3a7bd29..8c88948 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "patchmon-frontend", "private": true, - "version": "1.2.7", + "version": "1.2.8", "license": "AGPL-3.0", "type": "module", "scripts": { diff --git a/package.json b/package.json index c4fd693..ff8a7e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "patchmon", - "version": "1.2.7", + "version": "1.2.8", "description": "Linux Patch Monitoring System", "license": "AGPL-3.0", "private": true, diff --git a/setup.sh b/setup.sh index 0908e14..fa3a932 100755 --- a/setup.sh +++ b/setup.sh @@ -34,7 +34,7 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # Global variables -SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1" +SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-1" DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git" FQDN="" CUSTOM_FQDN="" @@ -834,7 +834,7 @@ EOF cat > frontend/.env << EOF VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1 VITE_APP_NAME=PatchMon -VITE_APP_VERSION=1.2.7 +VITE_APP_VERSION=1.2.8 EOF print_status "Environment files created" @@ -1206,7 +1206,7 @@ create_agent_version() { # Priority 2: Use fallback version if not found if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then - current_version="1.2.7" + current_version="1.2.8" print_warning "Could not determine version, using fallback: $current_version" fi From 1e5ee668250d842ad855a6b9f2e3ee5a544019c3 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 10 Oct 2025 19:32:44 +0100 Subject: [PATCH 2/7] Fixed version update checking mechanism Updated the setup.sh script to have the --update flag --- backend/src/routes/versionRoutes.js | 88 +++-- .../components/settings/VersionUpdateTab.jsx | 14 +- setup.sh | 354 +++++++++++++++++- 3 files changed, 399 insertions(+), 57 deletions(-) diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 7211d2c..4cc557b 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -126,43 +126,61 @@ async function getLatestCommit(owner, repo) { // Helper function to get commit count difference async function getCommitDifference(owner, repo, currentVersion) { - try { - const currentVersionTag = `v${currentVersion}`; - // Compare main branch with the released version tag - const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...main`; + // Try both with and without 'v' prefix for compatibility + const versionTags = [ + currentVersion, // Try without 'v' first (new format) + `v${currentVersion}`, // Try with 'v' prefix (old format) + ]; - const response = await fetch(apiUrl, { - method: "GET", - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": `PatchMon-Server/${getCurrentVersion()}`, - }, - }); + for (const versionTag of versionTags) { + try { + // Compare main branch with the released version tag + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${versionTag}...main`; - if (!response.ok) { - const errorText = await response.text(); - if ( - errorText.includes("rate limit") || - errorText.includes("API rate limit") - ) { - throw new Error("GitHub API rate limit exceeded"); + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": `PatchMon-Server/${getCurrentVersion()}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if ( + errorText.includes("rate limit") || + errorText.includes("API rate limit") + ) { + throw new Error("GitHub API rate limit exceeded"); + } + // If 404, try next tag format + if (response.status === 404) { + continue; + } + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); } - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } - const compareData = await response.json(); - return { - commitsBehind: compareData.behind_by || 0, // How many commits main is behind release - commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release - totalCommits: compareData.total_commits || 0, - branchInfo: "main branch vs release", - }; - } catch (error) { - console.error("Error fetching commit difference:", error.message); - throw error; + const compareData = await response.json(); + return { + commitsBehind: compareData.behind_by || 0, // How many commits main is behind release + commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release + totalCommits: compareData.total_commits || 0, + branchInfo: "main branch vs release", + }; + } catch (error) { + // If rate limit, throw immediately + if (error.message.includes("rate limit")) { + throw error; + } + } } + + // If all attempts failed, throw error + throw new Error( + `Could not find tag '${currentVersion}' or 'v${currentVersion}' in repository`, + ); } // Helper function to compare version strings (semantic versioning) @@ -296,10 +314,14 @@ router.get( }; } else { // Fall back to cached data for other errors + const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO; latestRelease = settings.latest_version ? { version: settings.latest_version, - tagName: `v${settings.latest_version}`, + tagName: settings.latest_version, + publishedAt: null, // Only use date from GitHub API, not cached data + // Note: URL may need 'v' prefix depending on actual tag format in repo + htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/${settings.latest_version}`, } : null; } diff --git a/frontend/src/components/settings/VersionUpdateTab.jsx b/frontend/src/components/settings/VersionUpdateTab.jsx index ed99728..6ed0ffb 100644 --- a/frontend/src/components/settings/VersionUpdateTab.jsx +++ b/frontend/src/components/settings/VersionUpdateTab.jsx @@ -128,12 +128,14 @@ const VersionUpdateTab = () => { {versionInfo.github.latestRelease.tagName} -
- Published:{" "} - {new Date( - versionInfo.github.latestRelease.publishedAt, - ).toLocaleDateString()} -
+ {versionInfo.github.latestRelease.publishedAt && ( +
+ Published:{" "} + {new Date( + versionInfo.github.latestRelease.publishedAt, + ).toLocaleDateString()} +
+ )} )} diff --git a/setup.sh b/setup.sh index fa3a932..bb916c7 100755 --- a/setup.sh +++ b/setup.sh @@ -34,7 +34,7 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # Global variables -SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-1" +SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-5" DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git" FQDN="" CUSTOM_FQDN="" @@ -60,6 +60,9 @@ SERVICE_USE_LETSENCRYPT="true" # Will be set based on user input SERVER_PROTOCOL_SEL="https" SERVER_PORT_SEL="" # Will be set to BACKEND_PORT in init_instance_vars SETUP_NGINX="true" +UPDATE_MODE="false" +SELECTED_INSTANCE="" +SELECTED_SERVICE_NAME="" # Functions print_status() { @@ -642,31 +645,61 @@ EOF # Setup database for instance setup_database() { - print_info "Creating database: $DB_NAME" + print_info "Setting up database: $DB_NAME" # Check if sudo is available for user switching if command -v sudo >/dev/null 2>&1; then - # Drop and recreate database and user for clean state - sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" || true - sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;" || true + # Check if user exists + user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" || echo "0") - # Create database and user - sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" - sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" + if [ "$user_exists" = "1" ]; then + print_info "Database user $DB_USER already exists, skipping creation" + else + print_info "Creating database user $DB_USER" + sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" + fi + + # Check if database exists + db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" || echo "0") + + if [ "$db_exists" = "1" ]; then + print_info "Database $DB_NAME already exists, skipping creation" + else + print_info "Creating database $DB_NAME" + sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" + fi + + # Always grant privileges (in case they were revoked) sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" else # Alternative method for systems without sudo (run as postgres user directly) print_warning "sudo not available, using alternative method for PostgreSQL setup" - # Switch to postgres user using su - su - postgres -c "psql -c \"DROP DATABASE IF EXISTS $DB_NAME;\"" || true - su - postgres -c "psql -c \"DROP USER IF EXISTS $DB_USER;\"" || true - su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" - su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" + # Check if user exists + user_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" || echo "0") + + if [ "$user_exists" = "1" ]; then + print_info "Database user $DB_USER already exists, skipping creation" + else + print_info "Creating database user $DB_USER" + su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" + fi + + # Check if database exists + db_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" || echo "0") + + if [ "$db_exists" = "1" ]; then + print_info "Database $DB_NAME already exists, skipping creation" + else + print_info "Creating database $DB_NAME" + su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" + fi + + # Always grant privileges (in case they were revoked) su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;\"" fi - print_status "Database $DB_NAME created with user $DB_USER" + print_status "Database setup complete for $DB_NAME" } # Clone application repository @@ -1550,11 +1583,271 @@ deploy_instance() { : } +# Detect existing PatchMon installations +detect_installations() { + local installations=() + + # Find all directories in /opt that contain PatchMon installations + if [ -d "/opt" ]; then + for dir in /opt/*/; do + local dirname=$(basename "$dir") + # Skip backup directories + if [[ "$dirname" =~ \.backup\. ]]; then + continue + fi + # Check if it's a PatchMon installation + if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then + installations+=("$dirname") + fi + done + fi + + echo "${installations[@]}" +} + +# Select installation to update +select_installation_to_update() { + local installations=($(detect_installations)) + + if [ ${#installations[@]} -eq 0 ]; then + print_error "No existing PatchMon installations found in /opt" + exit 1 + fi + + print_info "Found ${#installations[@]} existing installation(s):" + echo "" + + local i=1 + declare -A install_map + for install in "${installations[@]}"; do + # Get current version if possible + local version="unknown" + if [ -f "/opt/$install/backend/package.json" ]; then + version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + fi + + # Get service status - try multiple naming conventions + # Convention 1: Just the install name (e.g., patchmon.internal) + local service_name="$install" + # Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal) + local alt_service_name1="patchmon.$install" + # Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal) + local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')" + local status="unknown" + + # Try convention 1 first (most common) + if systemctl is-active --quiet "$service_name" 2>/dev/null; then + status="running" + elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then + status="stopped" + # Try convention 2 + elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then + status="running" + service_name="$alt_service_name1" + elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then + status="stopped" + service_name="$alt_service_name1" + # Try convention 3 + elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then + status="running" + service_name="$alt_service_name2" + elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then + status="stopped" + service_name="$alt_service_name2" + fi + + printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status" + install_map[$i]="$install" + # Store the service name for later use + declare -g "service_map_$i=$service_name" + i=$((i + 1)) + done + + echo "" + + while true; do + read_input "Select installation number to update" SELECTION "1" + + if [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ -n "${install_map[$SELECTION]}" ]; then + SELECTED_INSTANCE="${install_map[$SELECTION]}" + # Get the stored service name + local varname="service_map_$SELECTION" + SELECTED_SERVICE_NAME="${!varname}" + print_status "Selected: $SELECTED_INSTANCE" + print_info "Service: $SELECTED_SERVICE_NAME" + return 0 + else + print_error "Invalid selection. Please enter a number from 1 to ${#installations[@]}" + fi + done +} + +# Update existing installation +update_installation() { + local instance_dir="/opt/$SELECTED_INSTANCE" + local service_name="$SELECTED_SERVICE_NAME" + + print_info "Updating PatchMon installation: $SELECTED_INSTANCE" + print_info "Installation directory: $instance_dir" + print_info "Service name: $service_name" + + # Verify it's a git repository + if [ ! -d "$instance_dir/.git" ]; then + print_error "Installation directory is not a git repository" + print_error "Cannot perform git-based update" + exit 1 + fi + + # Add git safe.directory to avoid ownership issues when running as root + print_info "Configuring git safe.directory..." + git config --global --add safe.directory "$instance_dir" 2>/dev/null || true + + # Load existing .env to get database credentials + if [ -f "$instance_dir/backend/.env" ]; then + source "$instance_dir/backend/.env" + print_status "Loaded existing configuration" + else + print_error "Cannot find .env file at $instance_dir/backend/.env" + exit 1 + fi + + # Select branch/version to update to + select_branch + + print_info "Updating to: $DEPLOYMENT_BRANCH" + echo "" + + read_yes_no "Proceed with update? This will pull new code and restart services" CONFIRM_UPDATE "y" + + if [ "$CONFIRM_UPDATE" != "y" ]; then + print_warning "Update cancelled by user" + exit 0 + fi + + # Stop the service + print_info "Stopping service: $service_name" + systemctl stop "$service_name" || true + + # Create backup directory + local timestamp=$(date +%Y%m%d_%H%M%S) + local backup_dir="$instance_dir.backup.$timestamp" + local db_backup_file="$backup_dir/database_backup_$timestamp.sql" + + print_info "Creating backup directory: $backup_dir" + mkdir -p "$backup_dir" + + # Backup database + print_info "Backing up database: $DATABASE_NAME" + if PGPASSWORD="$DATABASE_PASSWORD" pg_dump -h localhost -U "$DATABASE_USER" -d "$DATABASE_NAME" -F c -f "$db_backup_file" 2>/dev/null; then + print_status "Database backup created: $db_backup_file" + else + print_warning "Database backup failed, but continuing with code backup" + fi + + # Backup code + print_info "Backing up code files..." + cp -r "$instance_dir" "$backup_dir/code" + print_status "Code backup created" + + # Update code + print_info "Pulling latest code from branch: $DEPLOYMENT_BRANCH" + cd "$instance_dir" + + # Fetch latest changes + git fetch origin + + # Checkout the selected branch/tag + git checkout "$DEPLOYMENT_BRANCH" + git pull origin "$DEPLOYMENT_BRANCH" || git pull # For tags, just pull + + print_status "Code updated successfully" + + # Update dependencies + print_info "Updating backend dependencies..." + cd "$instance_dir/backend" + npm install --production --ignore-scripts + + print_info "Updating frontend dependencies..." + cd "$instance_dir/frontend" + npm install --ignore-scripts + + # Build frontend + print_info "Building frontend..." + npm run build + + # Run database migrations and generate Prisma client + print_info "Running database migrations..." + cd "$instance_dir/backend" + npx prisma generate + npx prisma migrate deploy + + # Start the service + print_info "Starting service: $service_name" + systemctl start "$service_name" + + # Wait a moment and check status + sleep 3 + + if systemctl is-active --quiet "$service_name"; then + print_success "✅ Update completed successfully!" + print_status "Service $service_name is running" + + # Get new version + local new_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + print_info "Updated to version: $new_version" + echo "" + print_info "Backup Information:" + print_info " Code backup: $backup_dir/code" + print_info " Database backup: $db_backup_file" + echo "" + print_info "To restore database if needed:" + print_info " PGPASSWORD=\"$DATABASE_PASSWORD\" pg_restore -h localhost -U \"$DATABASE_USER\" -d \"$DATABASE_NAME\" -c \"$db_backup_file\"" + echo "" + else + print_error "Service failed to start after update" + echo "" + print_warning "ROLLBACK INSTRUCTIONS:" + print_info "1. Restore code:" + print_info " sudo rm -rf $instance_dir" + print_info " sudo mv $backup_dir/code $instance_dir" + echo "" + print_info "2. Restore database:" + print_info " PGPASSWORD=\"$DATABASE_PASSWORD\" pg_restore -h localhost -U \"$DATABASE_USER\" -d \"$DATABASE_NAME\" -c \"$db_backup_file\"" + echo "" + print_info "3. Restart service:" + print_info " sudo systemctl start $service_name" + echo "" + print_info "Check logs: journalctl -u $service_name -f" + exit 1 + fi +} + # Main script execution main() { - # Log script entry - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Interactive installation started" >> "$DEBUG_LOG" + # Parse command-line arguments + if [ "$1" = "--update" ]; then + UPDATE_MODE="true" + fi + # Log script entry + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script started - Update mode: $UPDATE_MODE" >> "$DEBUG_LOG" + + # Handle update mode + if [ "$UPDATE_MODE" = "true" ]; then + print_banner + print_info "🔄 PatchMon Update Mode" + echo "" + + # Select installation to update + select_installation_to_update + + # Perform update + update_installation + + exit 0 + fi + + # Normal installation mode # Run interactive setup interactive_setup @@ -1588,5 +1881,30 @@ main() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy_instance function completed" >> "$DEBUG_LOG" } -# Run main function (no arguments needed for interactive mode) -main +# Show usage/help +show_usage() { + echo "PatchMon Self-Hosting Installation & Update Script" + echo "Version: $SCRIPT_VERSION" + echo "" + echo "Usage:" + echo " $0 # Interactive installation (default)" + echo " $0 --update # Update existing installation" + echo " $0 --help # Show this help message" + echo "" + echo "Examples:" + echo " # New installation:" + echo " sudo bash $0" + echo "" + echo " # Update existing installation:" + echo " sudo bash $0 --update" + echo "" +} + +# Check for help flag +if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + show_usage + exit 0 +fi + +# Run main function +main "$@" From d99ded6d65c3b565bdd9461ce9aed8975e48acc9 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 10 Oct 2025 20:16:24 +0100 Subject: [PATCH 3/7] Added Database Backup ability when doing setup.sh -- update --- setup.sh | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/setup.sh b/setup.sh index bb916c7..37a44ef 100755 --- a/setup.sh +++ b/setup.sh @@ -34,7 +34,7 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # Global variables -SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-5" +SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-6" DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git" FQDN="" CUSTOM_FQDN="" @@ -1706,6 +1706,22 @@ update_installation() { if [ -f "$instance_dir/backend/.env" ]; then source "$instance_dir/backend/.env" print_status "Loaded existing configuration" + + # Parse DATABASE_URL to extract credentials + # Format: postgresql://user:password@host:port/database + if [ -n "$DATABASE_URL" ]; then + # Extract components using regex + DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p') + DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p') + DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p') + DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p') + DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p') + + print_info "Database: $DB_NAME (user: $DB_USER)" + else + print_error "DATABASE_URL not found in .env file" + exit 1 + fi else print_error "Cannot find .env file at $instance_dir/backend/.env" exit 1 @@ -1737,8 +1753,8 @@ update_installation() { mkdir -p "$backup_dir" # Backup database - print_info "Backing up database: $DATABASE_NAME" - if PGPASSWORD="$DATABASE_PASSWORD" pg_dump -h localhost -U "$DATABASE_USER" -d "$DATABASE_NAME" -F c -f "$db_backup_file" 2>/dev/null; then + print_info "Backing up database: $DB_NAME" + if PGPASSWORD="$DB_PASS" pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -F c -f "$db_backup_file" 2>/dev/null; then print_status "Database backup created: $db_backup_file" else print_warning "Database backup failed, but continuing with code backup" @@ -1801,7 +1817,7 @@ update_installation() { print_info " Database backup: $db_backup_file" echo "" print_info "To restore database if needed:" - print_info " PGPASSWORD=\"$DATABASE_PASSWORD\" pg_restore -h localhost -U \"$DATABASE_USER\" -d \"$DATABASE_NAME\" -c \"$db_backup_file\"" + print_info " PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\"" echo "" else print_error "Service failed to start after update" @@ -1812,7 +1828,7 @@ update_installation() { print_info " sudo mv $backup_dir/code $instance_dir" echo "" print_info "2. Restore database:" - print_info " PGPASSWORD=\"$DATABASE_PASSWORD\" pg_restore -h localhost -U \"$DATABASE_USER\" -d \"$DATABASE_NAME\" -c \"$db_backup_file\"" + print_info " PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\"" echo "" print_info "3. Restart service:" print_info " sudo systemctl start $service_name" From a3d0dfd6650c6860a91d4b1a0118b9bd4a5863a2 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 10 Oct 2025 21:52:57 +0100 Subject: [PATCH 4/7] Fixed entrypoint to handle better updating of Agent mechanism Updated Readme to show the --update flag --- README.md | 9 ++- docker/backend.docker-entrypoint.sh | 95 ++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 840d0f0..2cd6f83 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ PatchMon provides centralized patch management across diverse server environment ### API & Integrations - REST API under `/api/v1` with JWT auth -- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md)) +- Proxmox LXC Auto-Enrollment - Automatically discover and enroll LXC containers from Proxmox hosts ### Security - Rate limiting for general, auth, and agent endpoints @@ -85,11 +85,16 @@ apt-get upgrade -y apt install curl -y ``` -#### Script +#### Install Script ```bash curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh ``` +#### Update Script (--update flag) +```bash +curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh --update +``` + #### Minimum specs for building : ##### CPU : 2 vCPU RAM : 2GB diff --git a/docker/backend.docker-entrypoint.sh b/docker/backend.docker-entrypoint.sh index 486f05d..9f1a59d 100755 --- a/docker/backend.docker-entrypoint.sh +++ b/docker/backend.docker-entrypoint.sh @@ -8,19 +8,94 @@ log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2 } -# Copy files from agents_backup to agents if agents directory is empty and no .sh files are present -if [ -d "/app/agents" ] && [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' | head -n 1)" ]; then - if [ -d "/app/agents_backup" ]; then - log "Agents directory is empty, copying from backup..." - cp -r /app/agents_backup/* /app/agents/ +# Function to extract version from agent script +get_agent_version() { + local file="$1" + if [ -f "$file" ]; then + grep -m 1 '^AGENT_VERSION=' "$file" | cut -d'"' -f2 2>/dev/null || echo "0.0.0" else - log "Warning: agents_backup directory not found" + echo "0.0.0" fi -else - log "Agents directory already contains files, skipping copy" -fi +} -log "Starting PatchMon Backend (${NODE_ENV:-production})..." +# Function to compare versions (returns 0 if $1 > $2) +version_greater() { + # Use sort -V for version comparison + test "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" && test "$1" != "$2" +} + +# Check and update agent files if necessary +update_agents() { + local backup_agent="/app/agents_backup/patchmon-agent.sh" + local current_agent="/app/agents/patchmon-agent.sh" + + # Check if agents directory exists + if [ ! -d "/app/agents" ]; then + log "ERROR: /app/agents directory not found" + return 1 + fi + + # Check if backup exists + if [ ! -d "/app/agents_backup" ]; then + log "WARNING: agents_backup directory not found, skipping agent update" + return 0 + fi + + # Get versions + local backup_version=$(get_agent_version "$backup_agent") + local current_version=$(get_agent_version "$current_agent") + + log "Agent version check:" + log " Image version: ${backup_version}" + log " Volume version: ${current_version}" + + # Determine if update is needed + local needs_update=0 + + # Case 1: No agents in volume (first time setup) + if [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' 2>/dev/null | head -n 1)" ]; then + log "Agents directory is empty - performing initial copy" + needs_update=1 + # Case 2: Backup version is newer + elif version_greater "$backup_version" "$current_version"; then + log "Newer agent version available (${backup_version} > ${current_version})" + needs_update=1 + else + log "Agents are up to date" + needs_update=0 + fi + + # Perform update if needed + if [ $needs_update -eq 1 ]; then + log "Updating agents to version ${backup_version}..." + + # Create backup of existing agents if they exist + if [ -f "$current_agent" ]; then + local backup_timestamp=$(date +%Y%m%d_%H%M%S) + local backup_name="/app/agents/patchmon-agent.sh.backup.${backup_timestamp}" + cp "$current_agent" "$backup_name" 2>/dev/null || true + log "Previous agent backed up to: $(basename $backup_name)" + fi + + # Copy new agents + cp -r /app/agents_backup/* /app/agents/ + + # Verify update + local new_version=$(get_agent_version "$current_agent") + if [ "$new_version" = "$backup_version" ]; then + log "✅ Agents successfully updated to version ${new_version}" + else + log "⚠️ Warning: Agent update may have failed (expected: ${backup_version}, got: ${new_version})" + fi + fi +} + +# Main execution +log "PatchMon Backend Container Starting..." +log "Environment: ${NODE_ENV:-production}" + +# Update agents (version-aware) +update_agents log "Running database migrations..." npx prisma migrate deploy From 6ebcdd57d578bb7635d0690fe8df8381ea9a2ceb Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 11 Oct 2025 14:47:27 +0100 Subject: [PATCH 5/7] Fixed Migration order issue where users were getting error of "add_user_sessions" does not exist --- .../migration.sql | 0 .../migration.sql | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename backend/prisma/migrations/{add_user_sessions => 20251005000000_add_user_sessions}/migration.sql (100%) rename backend/prisma/migrations/{add_host_packages_indexes => 20251008000000_add_host_packages_indexes}/migration.sql (100%) diff --git a/backend/prisma/migrations/add_user_sessions/migration.sql b/backend/prisma/migrations/20251005000000_add_user_sessions/migration.sql similarity index 100% rename from backend/prisma/migrations/add_user_sessions/migration.sql rename to backend/prisma/migrations/20251005000000_add_user_sessions/migration.sql diff --git a/backend/prisma/migrations/add_host_packages_indexes/migration.sql b/backend/prisma/migrations/20251008000000_add_host_packages_indexes/migration.sql similarity index 100% rename from backend/prisma/migrations/add_host_packages_indexes/migration.sql rename to backend/prisma/migrations/20251008000000_add_host_packages_indexes/migration.sql From 5c4353a688a4820b4d980fcf3c6e6993530bef86 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 11 Oct 2025 20:04:29 +0100 Subject: [PATCH 6/7] Fixed linting errors with gethomepage area --- backend/src/routes/gethomepageRoutes.js | 236 +++++ backend/src/server.js | 2 + frontend/src/pages/settings/Integrations.jsx | 968 ++++++++++++++----- 3 files changed, 960 insertions(+), 246 deletions(-) create mode 100644 backend/src/routes/gethomepageRoutes.js diff --git a/backend/src/routes/gethomepageRoutes.js b/backend/src/routes/gethomepageRoutes.js new file mode 100644 index 0000000..cc44163 --- /dev/null +++ b/backend/src/routes/gethomepageRoutes.js @@ -0,0 +1,236 @@ +const express = require("express"); +const { createPrismaClient } = require("../config/database"); +const bcrypt = require("bcryptjs"); + +const router = express.Router(); +const prisma = createPrismaClient(); + +// Middleware to authenticate API key +const authenticateApiKey = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Basic ")) { + return res + .status(401) + .json({ error: "Missing or invalid authorization header" }); + } + + // Decode base64 credentials + const base64Credentials = authHeader.split(" ")[1]; + const credentials = Buffer.from(base64Credentials, "base64").toString( + "ascii", + ); + const [apiKey, apiSecret] = credentials.split(":"); + + if (!apiKey || !apiSecret) { + return res.status(401).json({ error: "Invalid credentials format" }); + } + + // Find the token in database + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: apiKey }, + include: { + users: { + select: { + id: true, + username: true, + role: true, + }, + }, + }, + }); + + if (!token) { + console.log(`API key not found: ${apiKey}`); + return res.status(401).json({ error: "Invalid API key" }); + } + + // Check if token is active + if (!token.is_active) { + return res.status(401).json({ error: "API key is disabled" }); + } + + // Check if token has expired + if (token.expires_at && new Date(token.expires_at) < new Date()) { + return res.status(401).json({ error: "API key has expired" }); + } + + // Check if token is for gethomepage integration + if (token.metadata?.integration_type !== "gethomepage") { + return res.status(401).json({ error: "Invalid API key type" }); + } + + // Verify the secret + const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret); + if (!isValidSecret) { + return res.status(401).json({ error: "Invalid API secret" }); + } + + // Check IP restrictions if any + if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { + const clientIp = req.ip || req.connection.remoteAddress; + const forwardedFor = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + + // Get the actual client IP (considering proxies) + const actualClientIp = forwardedFor + ? forwardedFor.split(",")[0].trim() + : realIp || clientIp; + + const isAllowedIp = token.allowed_ip_ranges.some((range) => { + // Simple IP range check (can be enhanced for CIDR support) + return actualClientIp.startsWith(range) || actualClientIp === range; + }); + + if (!isAllowedIp) { + console.log( + `IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`, + ); + return res.status(403).json({ error: "IP address not allowed" }); + } + } + + // Update last used timestamp + await prisma.auto_enrollment_tokens.update({ + where: { id: token.id }, + data: { last_used_at: new Date() }, + }); + + // Attach token info to request + req.apiToken = token; + next(); + } catch (error) { + console.error("API key authentication error:", error); + res.status(500).json({ error: "Authentication failed" }); + } +}; + +// Get homepage widget statistics +router.get("/stats", authenticateApiKey, async (_req, res) => { + try { + // Get total hosts count + const totalHosts = await prisma.hosts.count({ + where: { status: "active" }, + }); + + // Get total outdated packages count + const totalOutdatedPackages = await prisma.host_packages.count({ + where: { needs_update: true }, + }); + + // Get total repositories count + const totalRepos = await prisma.repositories.count({ + where: { is_active: true }, + }); + + // Get hosts that need updates (have outdated packages) + const hostsNeedingUpdates = await prisma.hosts.count({ + where: { + status: "active", + host_packages: { + some: { + needs_update: true, + }, + }, + }, + }); + + // Get security updates count + const securityUpdates = await prisma.host_packages.count({ + where: { + needs_update: true, + is_security_update: true, + }, + }); + + // Get hosts with security updates + const hostsWithSecurityUpdates = await prisma.hosts.count({ + where: { + status: "active", + host_packages: { + some: { + needs_update: true, + is_security_update: true, + }, + }, + }, + }); + + // Get up-to-date hosts count + const upToDateHosts = totalHosts - hostsNeedingUpdates; + + // Get recent update activity (last 24 hours) + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recentUpdates = await prisma.update_history.count({ + where: { + timestamp: { + gte: oneDayAgo, + }, + status: "success", + }, + }); + + // Get OS distribution + const osDistribution = await prisma.hosts.groupBy({ + by: ["os_type"], + where: { status: "active" }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "desc", + }, + }, + }); + + // Format OS distribution data + const osDistributionFormatted = osDistribution.map((os) => ({ + name: os.os_type, + count: os._count.id, + })); + + // Extract top 3 OS types for flat display in widgets + const top_os_1 = osDistributionFormatted[0] || { name: "None", count: 0 }; + const top_os_2 = osDistributionFormatted[1] || { name: "None", count: 0 }; + const top_os_3 = osDistributionFormatted[2] || { name: "None", count: 0 }; + + // Prepare response data + const stats = { + total_hosts: totalHosts, + total_outdated_packages: totalOutdatedPackages, + total_repos: totalRepos, + hosts_needing_updates: hostsNeedingUpdates, + up_to_date_hosts: upToDateHosts, + security_updates: securityUpdates, + hosts_with_security_updates: hostsWithSecurityUpdates, + recent_updates_24h: recentUpdates, + os_distribution: osDistributionFormatted, + // Flattened OS data for easy widget display + top_os_1_name: top_os_1.name, + top_os_1_count: top_os_1.count, + top_os_2_name: top_os_2.name, + top_os_2_count: top_os_2.count, + top_os_3_name: top_os_3.name, + top_os_3_count: top_os_3.count, + last_updated: new Date().toISOString(), + }; + + res.json(stats); + } catch (error) { + console.error("Error fetching homepage stats:", error); + res.status(500).json({ error: "Failed to fetch statistics" }); + } +}); + +// Health check endpoint for the API +router.get("/health", authenticateApiKey, async (req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + api_key: req.apiToken.token_name, + }); +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 58bcbbb..229b5fb 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -62,6 +62,7 @@ const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); const searchRoutes = require("./routes/searchRoutes"); const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); +const gethomepageRoutes = require("./routes/gethomepageRoutes"); const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); const { cleanup_expired_sessions } = require("./utils/session_manager"); @@ -422,6 +423,7 @@ app.use( authLimiter, autoEnrollmentRoutes, ); +app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); // Error handling middleware app.use((err, _req, res, _next) => { diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index 8732c12..75853ea 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -1,5 +1,6 @@ import { AlertCircle, + BookOpen, CheckCircle, Copy, Eye, @@ -9,11 +10,18 @@ import { Trash2, X, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import SettingsLayout from "../../components/SettingsLayout"; import api from "../../utils/api"; const Integrations = () => { + // Generate unique IDs for form elements + const token_name_id = useId(); + const token_key_id = useId(); + const token_secret_id = useId(); + const token_base64_id = useId(); + const gethomepage_config_id = useId(); + const [activeTab, setActiveTab] = useState("proxmox"); const [tokens, setTokens] = useState([]); const [host_groups, setHostGroups] = useState([]); @@ -94,7 +102,8 @@ const Integrations = () => { ? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim()) : [], metadata: { - integration_type: "proxmox-lxc", + integration_type: + activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc", }, }; @@ -158,12 +167,49 @@ const Integrations = () => { } }; - const copy_to_clipboard = (text, key) => { - navigator.clipboard.writeText(text); - setCopySuccess({ ...copy_success, [key]: true }); - setTimeout(() => { - setCopySuccess({ ...copy_success, [key]: false }); - }, 2000); + const copy_to_clipboard = async (text, key) => { + // Check if Clipboard API is available + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + return; + } catch (error) { + console.error("Clipboard API failed:", error); + // Fall through to fallback method + } + } + + // Fallback method for older browsers or non-secure contexts + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand("copy"); + document.body.removeChild(textArea); + + if (successful) { + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + } else { + console.error("Fallback copy failed"); + alert("Failed to copy to clipboard. Please copy manually."); + } + } catch (fallbackError) { + console.error("Fallback copy failed:", fallbackError); + alert("Failed to copy to clipboard. Please copy manually."); + } }; const format_date = (date_string) => { @@ -198,6 +244,17 @@ const Integrations = () => { > Proxmox LXC + {/* Future tabs can be added here */} @@ -367,9 +424,20 @@ const Integrations = () => { {/* Documentation Section */}
-

- How to Use Auto-Enrollment -

+
+

+ How to Use Auto-Enrollment +

+ + + Documentation + +
  1. Create a new auto-enrollment token using the button above @@ -395,6 +463,266 @@ const Integrations = () => {
)} + + {/* GetHomepage Tab */} + {activeTab === "gethomepage" && ( +
+ {/* Header with New API Key Button */} +
+
+
+ +
+
+

+ GetHomepage Widget Integration +

+

+ Create API keys to display PatchMon statistics in your + GetHomepage dashboard +

+
+
+ +
+ + {/* API Keys List */} + {loading ? ( +
+
+
+ ) : tokens.filter( + (token) => + token.metadata?.integration_type === "gethomepage", + ).length === 0 ? ( +
+

No GetHomepage API keys created yet.

+

+ Create an API key to enable GetHomepage widget + integration. +

+
+ ) : ( +
+ {tokens + .filter( + (token) => + token.metadata?.integration_type === "gethomepage", + ) + .map((token) => ( +
+
+
+
+

+ {token.token_name} +

+ + GetHomepage + + {token.is_active ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ + {token.token_key} + + +
+

Created: {format_date(token.created_at)}

+ {token.last_used_at && ( +

+ Last Used: {format_date(token.last_used_at)} +

+ )} + {token.expires_at && ( +

+ Expires: {format_date(token.expires_at)} + {new Date(token.expires_at) < + new Date() && ( + + (Expired) + + )} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Documentation Section */} +
+
+

+ How to Use GetHomepage Integration +

+ + + Documentation + +
+
    +
  1. Create a new API key using the button above
  2. +
  3. Copy the API key and secret from the success dialog
  4. +
  5. + Add the following widget configuration to your GetHomepage{" "} + + services.yml + {" "} + file: +
  6. +
+ +
+
+											{`- PatchMon:
+    href: ${server_url}
+    description: PatchMon Statistics
+    icon: ${server_url}/assets/favicon.svg
+    widget:
+      type: customapi
+      url: ${server_url}/api/v1/gethomepage/stats
+      headers:
+        Authorization: Basic BASE64_ENCODED_CREDENTIALS
+      mappings:
+        - field: total_hosts
+          label: Total Hosts
+        - field: hosts_needing_updates
+          label: Needs Updates
+        - field: security_updates
+          label: Security Updates`}
+										
+
+ +
+

+ + How to generate BASE64_ENCODED_CREDENTIALS: + +

+
+											{`echo -n "YOUR_API_KEY:YOUR_API_SECRET" | base64`}
+										
+

+ Replace YOUR_API_KEY and YOUR_API_SECRET with your actual + credentials, then run this command to get the base64 + string. +

+
+ +
+

+ Additional Widget Examples +

+

+ You can create multiple widgets to display different + statistics: +

+
+
+ Security Updates Widget: +
+ type: customapi +
+ key: security_updates +
+ value: hosts_with_security_updates +
+ label: Security Updates +
+
+ Up-to-Date Hosts Widget: +
+ type: customapi +
+ key: up_to_date_hosts +
+ value: total_hosts +
+ label: Up-to-Date Hosts +
+
+ Recent Activity Widget: +
+ type: customapi +
+ key: recent_updates_24h +
+ value: total_hosts +
+ label: Updates (24h) +
+
+
+
+
+ )}
@@ -406,7 +734,9 @@ const Integrations = () => {

- Create Auto-Enrollment Token + {activeTab === "gethomepage" + ? "Create GetHomepage API Key" + : "Create Auto-Enrollment Token"}

-
-
- -
-
- Token Secret -
-
- - - -
-
- -
-
- One-Line Installation Command -
-

- Run this command on your Proxmox host to download and - execute the enrollment script: -

- - {/* Force Install Toggle */} -
- -

- Enable this if your LXC containers have broken packages - (CloudPanel, WHM, etc.) that block apt-get operations -

-
- -
- - -
-

- 💡 This command will automatically discover and enroll all - running LXC containers. -

-
- - -
+
+ +
+
+ +

+ Important: Save these credentials - the + secret won't be shown again. +

+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+
+ + {activeTab === "proxmox" && ( +
+
+ One-Line Installation Command +
+

+ Run this command on your Proxmox host to download and + execute the enrollment script: +

+ + {/* Force Install Toggle */} +
+ +

+ Enable this if your LXC containers have broken packages + (CloudPanel, WHM, etc.) that block apt-get operations +

+
+ +
+ + +
+

+ 💡 This command will automatically discover and enroll all + running LXC containers. +

+
+ )} + + {activeTab === "gethomepage" && ( +
+
+ +
+ + +
+
+ +
+
+ + +
+