From 7a8e9d95a0193af0157724d49fef55f70a22a52d Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 07:03:10 +0100 Subject: [PATCH 01/20] Added a global search bar --- backend/src/routes/searchRoutes.js | 247 +++++++++++++ backend/src/server.js | 2 + frontend/src/components/GlobalSearch.jsx | 428 +++++++++++++++++++++++ frontend/src/components/Layout.jsx | 13 +- frontend/src/utils/api.js | 5 + 5 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/searchRoutes.js create mode 100644 frontend/src/components/GlobalSearch.jsx diff --git a/backend/src/routes/searchRoutes.js b/backend/src/routes/searchRoutes.js new file mode 100644 index 0000000..077f780 --- /dev/null +++ b/backend/src/routes/searchRoutes.js @@ -0,0 +1,247 @@ +const express = require("express"); +const router = express.Router(); +const { createPrismaClient } = require("../config/database"); +const { authenticateToken } = require("../middleware/auth"); + +const prisma = createPrismaClient(); + +/** + * Global search endpoint + * Searches across hosts, packages, repositories, and users + * Returns categorized results + */ +router.get("/", authenticateToken, async (req, res) => { + try { + const { q } = req.query; + + if (!q || q.trim().length === 0) { + return res.json({ + hosts: [], + packages: [], + repositories: [], + users: [], + }); + } + + const searchTerm = q.trim(); + + // Prepare results object + const results = { + hosts: [], + packages: [], + repositories: [], + users: [], + }; + + // Get user permissions from database + let userPermissions = null; + try { + userPermissions = await prisma.role_permissions.findUnique({ + where: { role: req.user.role }, + }); + + // If no specific permissions found, default to admin permissions + if (!userPermissions) { + console.warn( + `No permissions found for role: ${req.user.role}, defaulting to admin access`, + ); + userPermissions = { + can_view_hosts: true, + can_view_packages: true, + can_view_users: true, + }; + } + } catch (permError) { + console.error("Error fetching permissions:", permError); + // Default to restrictive permissions on error + userPermissions = { + can_view_hosts: false, + can_view_packages: false, + can_view_users: false, + }; + } + + // Search hosts if user has permission + if (userPermissions.can_view_hosts) { + try { + const hosts = await prisma.hosts.findMany({ + where: { + OR: [ + { hostname: { contains: searchTerm, mode: "insensitive" } }, + { friendly_name: { contains: searchTerm, mode: "insensitive" } }, + { ip: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + select: { + id: true, + hostname: true, + friendly_name: true, + ip: true, + os_type: true, + os_version: true, + status: true, + last_update: true, + }, + take: 10, // Limit results + orderBy: { + last_update: "desc", + }, + }); + + results.hosts = hosts.map((host) => ({ + id: host.id, + hostname: host.hostname, + friendly_name: host.friendly_name, + ip: host.ip, + os_type: host.os_type, + os_version: host.os_version, + status: host.status, + last_update: host.last_update, + type: "host", + })); + } catch (error) { + console.error("Error searching hosts:", error); + } + } + + // Search packages if user has permission + if (userPermissions.can_view_packages) { + try { + const packages = await prisma.packages.findMany({ + where: { + name: { contains: searchTerm, mode: "insensitive" }, + }, + select: { + id: true, + name: true, + description: true, + category: true, + latest_version: true, + _count: { + select: { + host_packages: true, + }, + }, + }, + take: 10, + orderBy: { + name: "asc", + }, + }); + + results.packages = packages.map((pkg) => ({ + id: pkg.id, + name: pkg.name, + description: pkg.description, + category: pkg.category, + latest_version: pkg.latest_version, + host_count: pkg._count.host_packages, + type: "package", + })); + } catch (error) { + console.error("Error searching packages:", error); + } + } + + // Search repositories if user has permission (usually same as hosts) + if (userPermissions.can_view_hosts) { + try { + const repositories = await prisma.repositories.findMany({ + where: { + OR: [ + { name: { contains: searchTerm, mode: "insensitive" } }, + { url: { contains: searchTerm, mode: "insensitive" } }, + { description: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + select: { + id: true, + name: true, + url: true, + distribution: true, + repo_type: true, + is_active: true, + description: true, + _count: { + select: { + host_repositories: true, + }, + }, + }, + take: 10, + orderBy: { + name: "asc", + }, + }); + + results.repositories = repositories.map((repo) => ({ + id: repo.id, + name: repo.name, + url: repo.url, + distribution: repo.distribution, + repo_type: repo.repo_type, + is_active: repo.is_active, + description: repo.description, + host_count: repo._count.host_repositories, + type: "repository", + })); + } catch (error) { + console.error("Error searching repositories:", error); + } + } + + // Search users if user has permission + if (userPermissions.can_view_users) { + try { + const users = await prisma.users.findMany({ + where: { + OR: [ + { username: { contains: searchTerm, mode: "insensitive" } }, + { email: { contains: searchTerm, mode: "insensitive" } }, + { first_name: { contains: searchTerm, mode: "insensitive" } }, + { last_name: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + select: { + id: true, + username: true, + email: true, + first_name: true, + last_name: true, + role: true, + is_active: true, + last_login: true, + }, + take: 10, + orderBy: { + username: "asc", + }, + }); + + results.users = users.map((user) => ({ + id: user.id, + username: user.username, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + role: user.role, + is_active: user.is_active, + last_login: user.last_login, + type: "user", + })); + } catch (error) { + console.error("Error searching users:", error); + } + } + + res.json(results); + } catch (error) { + console.error("Global search error:", error); + res.status(500).json({ + error: "Failed to perform search", + message: error.message, + }); + } +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 78fc180..d4e529e 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -24,6 +24,7 @@ const { const repositoryRoutes = require("./routes/repositoryRoutes"); const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); +const searchRoutes = require("./routes/searchRoutes"); const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); const { cleanup_expired_sessions } = require("./utils/session_manager"); @@ -378,6 +379,7 @@ app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes); app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); app.use(`/api/${apiVersion}/tfa`, tfaRoutes); +app.use(`/api/${apiVersion}/search`, searchRoutes); // Error handling middleware app.use((err, _req, res, _next) => { diff --git a/frontend/src/components/GlobalSearch.jsx b/frontend/src/components/GlobalSearch.jsx new file mode 100644 index 0000000..27b515b --- /dev/null +++ b/frontend/src/components/GlobalSearch.jsx @@ -0,0 +1,428 @@ +import { GitBranch, Package, Search, Server, User, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { searchAPI } from "../utils/api"; + +const GlobalSearch = () => { + const [query, setQuery] = useState(""); + const [results, setResults] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const searchRef = useRef(null); + const inputRef = useRef(null); + const navigate = useNavigate(); + + // Debounce search + const debounceTimerRef = useRef(null); + + const performSearch = useCallback(async (searchQuery) => { + if (!searchQuery || searchQuery.trim().length === 0) { + setResults(null); + setIsOpen(false); + return; + } + + setIsLoading(true); + try { + const response = await searchAPI.global(searchQuery); + setResults(response.data); + setIsOpen(true); + setSelectedIndex(-1); + } catch (error) { + console.error("Search error:", error); + setResults(null); + } finally { + setIsLoading(false); + } + }, []); + + const handleInputChange = (e) => { + const value = e.target.value; + setQuery(value); + + // Clear previous timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set new timer + debounceTimerRef.current = setTimeout(() => { + performSearch(value); + }, 300); + }; + + const handleClear = () => { + // Clear debounce timer to prevent any pending searches + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setQuery(""); + setResults(null); + setIsOpen(false); + setSelectedIndex(-1); + inputRef.current?.focus(); + }; + + const handleResultClick = (result) => { + // Navigate based on result type + switch (result.type) { + case "host": + navigate(`/hosts/${result.id}`); + break; + case "package": + navigate(`/packages/${result.id}`); + break; + case "repository": + navigate(`/repositories/${result.id}`); + break; + case "user": + // Users don't have detail pages, so navigate to settings + navigate("/settings/users"); + break; + default: + break; + } + + // Close dropdown and clear + handleClear(); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (searchRef.current && !searchRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + // Keyboard navigation + const flattenedResults = []; + if (results) { + if (results.hosts?.length > 0) { + flattenedResults.push({ type: "header", label: "Hosts" }); + flattenedResults.push(...results.hosts); + } + if (results.packages?.length > 0) { + flattenedResults.push({ type: "header", label: "Packages" }); + flattenedResults.push(...results.packages); + } + if (results.repositories?.length > 0) { + flattenedResults.push({ type: "header", label: "Repositories" }); + flattenedResults.push(...results.repositories); + } + if (results.users?.length > 0) { + flattenedResults.push({ type: "header", label: "Users" }); + flattenedResults.push(...results.users); + } + } + + const navigableResults = flattenedResults.filter((r) => r.type !== "header"); + + const handleKeyDown = (e) => { + if (!isOpen || !results) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + prev < navigableResults.length - 1 ? prev + 1 : prev, + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + case "Enter": + e.preventDefault(); + if (selectedIndex >= 0 && navigableResults[selectedIndex]) { + handleResultClick(navigableResults[selectedIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setSelectedIndex(-1); + break; + default: + break; + } + }; + + // Get icon for result type + const getResultIcon = (type) => { + switch (type) { + case "host": + return ; + case "package": + return ; + case "repository": + return ; + case "user": + return ; + default: + return null; + } + }; + + // Get display text for result + const getResultDisplay = (result) => { + switch (result.type) { + case "host": + return { + primary: result.friendly_name || result.hostname, + secondary: result.ip || result.hostname, + }; + case "package": + return { + primary: result.name, + secondary: result.description || result.category, + }; + case "repository": + return { + primary: result.name, + secondary: result.distribution, + }; + case "user": + return { + primary: result.username, + secondary: result.email, + }; + default: + return { primary: "", secondary: "" }; + } + }; + + const hasResults = + results && + (results.hosts?.length > 0 || + results.packages?.length > 0 || + results.repositories?.length > 0 || + results.users?.length > 0); + + return ( +
+
+
+ +
+ { + if (query && results) setIsOpen(true); + }} + /> + {query && ( + + )} +
+ + {/* Dropdown Results */} + {isOpen && ( +
+ {isLoading ? ( +
+ Searching... +
+ ) : hasResults ? ( +
+ {/* Hosts */} + {results.hosts?.length > 0 && ( +
+
+ Hosts +
+ {results.hosts.map((host, idx) => { + const display = getResultDisplay(host); + const globalIdx = navigableResults.findIndex( + (r) => r.id === host.id && r.type === "host", + ); + return ( + + ); + })} +
+ )} + + {/* Packages */} + {results.packages?.length > 0 && ( +
+
+ Packages +
+ {results.packages.map((pkg, idx) => { + const display = getResultDisplay(pkg); + const globalIdx = navigableResults.findIndex( + (r) => r.id === pkg.id && r.type === "package", + ); + return ( + + ); + })} +
+ )} + + {/* Repositories */} + {results.repositories?.length > 0 && ( +
+
+ Repositories +
+ {results.repositories.map((repo, idx) => { + const display = getResultDisplay(repo); + const globalIdx = navigableResults.findIndex( + (r) => r.id === repo.id && r.type === "repository", + ); + return ( + + ); + })} +
+ )} + + {/* Users */} + {results.users?.length > 0 && ( +
+
+ Users +
+ {results.users.map((user, idx) => { + const display = getResultDisplay(user); + const globalIdx = navigableResults.findIndex( + (r) => r.id === user.id && r.type === "user", + ); + return ( + + ); + })} +
+ )} +
+ ) : query.trim() ? ( +
+ No results found for "{query}" +
+ ) : null} +
+ )} +
+ ); +}; + +export default GlobalSearch; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index a2a096e..4bb70a9 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -29,6 +29,7 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { dashboardAPI, versionAPI } from "../utils/api"; +import GlobalSearch from "./GlobalSearch"; import UpgradeNotificationIcon from "./UpgradeNotificationIcon"; const Layout = ({ children }) => { @@ -866,12 +867,18 @@ const Layout = ({ children }) => {
-
-

+
+

{getPageTitle()}

-
+ + {/* Global Search Bar */} +
+ +
+ +
{/* External Links */}
{ return `${seconds} second${seconds > 1 ? "s" : ""} ago`; }; +// Search API +export const searchAPI = { + global: (query) => api.get("/search", { params: { q: query } }), +}; + export default api; From b99f4aad4e89c1cc2208eedc684afb8ad00ff890 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 07:50:10 +0100 Subject: [PATCH 02/20] feat: Add Proxmox LXC auto-enrollment integration - Add auto_enrollment_tokens table with rate limiting and IP whitelisting - Create backend API routes for token management and enrollment - Build frontend UI for token creation and management - Add one-liner curl command for easy Proxmox deployment - Include Proxmox LXC discovery and enrollment script - Support future integrations with /proxmox-lxc endpoint pattern - Add comprehensive documentation Security features: - Hashed token secrets - Per-day rate limits - IP whitelist support - Token expiration - Separate enrollment vs host API credentials --- README.md | 1 + agents/proxmox_auto_enroll.sh | 242 +++++++ backend/prisma/schema.prisma | 72 +- backend/src/routes/autoEnrollmentRoutes.js | 724 +++++++++++++++++++ backend/src/server.js | 6 + frontend/src/pages/settings/Integrations.jsx | 679 ++++++++++++++++- 6 files changed, 1680 insertions(+), 44 deletions(-) create mode 100755 agents/proxmox_auto_enroll.sh create mode 100644 backend/src/routes/autoEnrollmentRoutes.js diff --git a/README.md b/README.md index ae7e47b..94dab7a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,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)) ### Security - Rate limiting for general, auth, and agent endpoints diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh new file mode 100755 index 0000000..79142f5 --- /dev/null +++ b/agents/proxmox_auto_enroll.sh @@ -0,0 +1,242 @@ +#!/bin/bash + +# ============================================================================= +# 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 +# ============================================================================= + +set -e + +# ===== 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:-proxmox-}" +SKIP_STOPPED="${SKIP_STOPPED:-true}" +PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}" +MAX_PARALLEL="${MAX_PARALLEL:-5}" + +# ===== 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"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } +debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1"; } + +# ===== 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 "" + +# ===== STATISTICS ===== +enrolled_count=0 +skipped_count=0 +failed_count=0 + +# ===== PROCESS CONTAINERS ===== +while IFS= read -r line; do + 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++)) + 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++)) + echo "" + continue + fi + + # Get container details + debug " Gathering container information..." + hostname=$(pct exec "$vmid" -- hostname 2>/dev/null || echo "$name") + ip_address=$(pct exec "$vmid" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") + os_info=$(pct exec "$vmid" -- cat /etc/os-release 2>/dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown") + + friendly_name="${HOST_PREFIX}${hostname}" + + info " Hostname: $hostname" + info " IP Address: $ip_address" + info " OS: $os_info" + + if [[ "$DRY_RUN" == "true" ]]; then + info " [DRY RUN] Would enroll: $friendly_name" + ((enrolled_count++)) + 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\", + \"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" + + # Install PatchMon agent in container + info " Installing PatchMon agent..." + + install_output=$(pct exec "$vmid" -- bash -c "curl $CURL_FLAGS \ + -H 'X-API-ID: $api_id' \ + -H 'X-API-KEY: $api_key' \ + '$PATCHMON_URL/api/v1/hosts/install' | bash" 2>&1) + + if [[ $? -eq 0 ]]; then + info " ✓ Agent installed successfully in $friendly_name" + ((enrolled_count++)) + else + error " ✗ Failed to install agent in $friendly_name" + debug " Install output: $install_output" + ((failed_count++)) + fi + + elif [[ "$http_code" == "409" ]]; then + warn " ⊘ Host $friendly_name already enrolled - skipping" + ((skipped_count++)) + elif [[ "$http_code" == "429" ]]; then + error " ✗ Rate limit exceeded - maximum hosts per day reached" + ((failed_count++)) + else + error " ✗ Failed to enroll $friendly_name - HTTP $http_code" + debug " Response: $body" + ((failed_count++)) + 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 "" + +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 + +if [[ $failed_count -gt 0 ]]; then + warn "Some containers failed to enroll. Check the logs above for details." + exit 1 +fi + +info "Auto-enrollment complete! ✓" +exit 0 + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 160aa1c..5db7dcb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,13 +21,14 @@ model dashboard_preferences { } model host_groups { - id String @id - name String @unique - description String? - color String? @default("#3B82F6") - created_at DateTime @default(now()) - updated_at DateTime - hosts hosts[] + id String @id + name String @unique + description String? + color String? @default("#3B82F6") + created_at DateTime @default(now()) + updated_at DateTime + hosts hosts[] + auto_enrollment_tokens auto_enrollment_tokens[] } model host_packages { @@ -172,22 +173,23 @@ model update_history { } model users { - id String @id - username String @unique - email String @unique - password_hash String - role String @default("admin") - is_active Boolean @default(true) - last_login DateTime? - created_at DateTime @default(now()) - updated_at DateTime - tfa_backup_codes String? - tfa_enabled Boolean @default(false) - tfa_secret String? - first_name String? - last_name String? - dashboard_preferences dashboard_preferences[] - user_sessions user_sessions[] + id String @id + username String @unique + email String @unique + password_hash String + role String @default("admin") + is_active Boolean @default(true) + last_login DateTime? + created_at DateTime @default(now()) + updated_at DateTime + tfa_backup_codes String? + tfa_enabled Boolean @default(false) + tfa_secret String? + first_name String? + last_name String? + dashboard_preferences dashboard_preferences[] + user_sessions user_sessions[] + auto_enrollment_tokens auto_enrollment_tokens[] } model user_sessions { @@ -207,3 +209,27 @@ model user_sessions { @@index([refresh_token]) @@index([expires_at]) } + +model auto_enrollment_tokens { + id String @id + token_name String + token_key String @unique + token_secret String + created_by_user_id String? + is_active Boolean @default(true) + allowed_ip_ranges String[] + max_hosts_per_day Int @default(100) + hosts_created_today Int @default(0) + last_reset_date DateTime @default(now()) @db.Date + default_host_group_id String? + created_at DateTime @default(now()) + updated_at DateTime + last_used_at DateTime? + expires_at DateTime? + metadata Json? + users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull) + host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull) + + @@index([token_key]) + @@index([is_active]) +} diff --git a/backend/src/routes/autoEnrollmentRoutes.js b/backend/src/routes/autoEnrollmentRoutes.js new file mode 100644 index 0000000..e475d56 --- /dev/null +++ b/backend/src/routes/autoEnrollmentRoutes.js @@ -0,0 +1,724 @@ +const express = require("express"); +const { PrismaClient } = require("@prisma/client"); +const crypto = require("node:crypto"); +const bcrypt = require("bcryptjs"); +const { body, validationResult } = require("express-validator"); +const { authenticateToken } = require("../middleware/auth"); +const { requireManageSettings } = require("../middleware/permissions"); +const { v4: uuidv4 } = require("uuid"); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Generate auto-enrollment token credentials +const generate_auto_enrollment_token = () => { + const token_key = `patchmon_ae_${crypto.randomBytes(16).toString("hex")}`; + const token_secret = crypto.randomBytes(48).toString("hex"); + return { token_key, token_secret }; +}; + +// Middleware to validate auto-enrollment token +const validate_auto_enrollment_token = async (req, res, next) => { + try { + const token_key = req.headers["x-auto-enrollment-key"]; + const token_secret = req.headers["x-auto-enrollment-secret"]; + + if (!token_key || !token_secret) { + return res + .status(401) + .json({ error: "Auto-enrollment credentials required" }); + } + + // Find token + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: token_key }, + }); + + if (!token || !token.is_active) { + return res.status(401).json({ error: "Invalid or inactive token" }); + } + + // Verify secret (hashed) + const is_valid = await bcrypt.compare(token_secret, token.token_secret); + if (!is_valid) { + return res.status(401).json({ error: "Invalid token secret" }); + } + + // Check expiration + if (token.expires_at && new Date() > new Date(token.expires_at)) { + return res.status(401).json({ error: "Token expired" }); + } + + // Check IP whitelist if configured + if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { + const client_ip = req.ip || req.connection.remoteAddress; + // Basic IP check - can be enhanced with CIDR matching + const ip_allowed = token.allowed_ip_ranges.some((allowed_ip) => { + return client_ip.includes(allowed_ip); + }); + + if (!ip_allowed) { + console.warn( + `Auto-enrollment attempt from unauthorized IP: ${client_ip}`, + ); + return res + .status(403) + .json({ error: "IP address not authorized for this token" }); + } + } + + // Check rate limit (hosts per day) + const today = new Date().toISOString().split("T")[0]; + const token_reset_date = token.last_reset_date.toISOString().split("T")[0]; + + if (token_reset_date !== today) { + // Reset daily counter + await prisma.auto_enrollment_tokens.update({ + where: { id: token.id }, + data: { + hosts_created_today: 0, + last_reset_date: new Date(), + updated_at: new Date(), + }, + }); + token.hosts_created_today = 0; + } + + if (token.hosts_created_today >= token.max_hosts_per_day) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: `Maximum ${token.max_hosts_per_day} hosts per day allowed for this token`, + }); + } + + req.auto_enrollment_token = token; + next(); + } catch (error) { + console.error("Auto-enrollment token validation error:", error); + res.status(500).json({ error: "Token validation failed" }); + } +}; + +// ========== ADMIN ENDPOINTS (Manage Tokens) ========== + +// Create auto-enrollment token +router.post( + "/tokens", + authenticateToken, + requireManageSettings, + [ + body("token_name") + .isLength({ min: 1, max: 255 }) + .withMessage("Token name is required (max 255 characters)"), + body("allowed_ip_ranges") + .optional() + .isArray() + .withMessage("Allowed IP ranges must be an array"), + body("max_hosts_per_day") + .optional() + .isInt({ min: 1, max: 1000 }) + .withMessage("Max hosts per day must be between 1 and 1000"), + body("default_host_group_id") + .optional({ nullable: true, checkFalsy: true }) + .isString(), + body("expires_at") + .optional({ nullable: true, checkFalsy: true }) + .isISO8601() + .withMessage("Invalid date format"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + token_name, + allowed_ip_ranges = [], + max_hosts_per_day = 100, + default_host_group_id, + expires_at, + metadata = {}, + } = req.body; + + // Validate host group if provided + if (default_host_group_id) { + const host_group = await prisma.host_groups.findUnique({ + where: { id: default_host_group_id }, + }); + + if (!host_group) { + return res.status(400).json({ error: "Host group not found" }); + } + } + + const { token_key, token_secret } = generate_auto_enrollment_token(); + const hashed_secret = await bcrypt.hash(token_secret, 10); + + const token = await prisma.auto_enrollment_tokens.create({ + data: { + id: uuidv4(), + token_name, + token_key: token_key, + token_secret: hashed_secret, + created_by_user_id: req.user.id, + allowed_ip_ranges, + max_hosts_per_day, + default_host_group_id: default_host_group_id || null, + expires_at: expires_at ? new Date(expires_at) : null, + metadata: { integration_type: "proxmox-lxc", ...metadata }, + updated_at: new Date(), + }, + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + users: { + select: { + id: true, + username: true, + first_name: true, + last_name: true, + }, + }, + }, + }); + + // Return unhashed secret ONLY once (like API keys) + res.status(201).json({ + message: "Auto-enrollment token created successfully", + token: { + id: token.id, + token_name: token.token_name, + token_key: token_key, + token_secret: token_secret, // ONLY returned here! + max_hosts_per_day: token.max_hosts_per_day, + default_host_group: token.host_groups, + created_by: token.users, + expires_at: token.expires_at, + }, + warning: "⚠️ Save the token_secret now - it cannot be retrieved later!", + }); + } catch (error) { + console.error("Create auto-enrollment token error:", error); + res.status(500).json({ error: "Failed to create token" }); + } + }, +); + +// List auto-enrollment tokens +router.get( + "/tokens", + authenticateToken, + requireManageSettings, + async (_req, res) => { + try { + const tokens = await prisma.auto_enrollment_tokens.findMany({ + select: { + id: true, + token_name: true, + token_key: true, + is_active: true, + allowed_ip_ranges: true, + max_hosts_per_day: true, + hosts_created_today: true, + last_used_at: true, + expires_at: true, + created_at: true, + default_host_group_id: true, + metadata: true, + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + users: { + select: { + id: true, + username: true, + first_name: true, + last_name: true, + }, + }, + }, + orderBy: { created_at: "desc" }, + }); + + res.json(tokens); + } catch (error) { + console.error("List auto-enrollment tokens error:", error); + res.status(500).json({ error: "Failed to list tokens" }); + } + }, +); + +// Get single token details +router.get( + "/tokens/:tokenId", + authenticateToken, + requireManageSettings, + async (req, res) => { + try { + const { tokenId } = req.params; + + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { id: tokenId }, + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + users: { + select: { + id: true, + username: true, + first_name: true, + last_name: true, + }, + }, + }, + }); + + if (!token) { + return res.status(404).json({ error: "Token not found" }); + } + + // Don't include the secret in response + const { token_secret: _secret, ...token_data } = token; + + res.json(token_data); + } catch (error) { + console.error("Get token error:", error); + res.status(500).json({ error: "Failed to get token" }); + } + }, +); + +// Update token (toggle active state, update limits, etc.) +router.patch( + "/tokens/:tokenId", + authenticateToken, + requireManageSettings, + [ + body("is_active").optional().isBoolean(), + body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }), + body("allowed_ip_ranges").optional().isArray(), + body("expires_at").optional().isISO8601(), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { tokenId } = req.params; + const update_data = { updated_at: new Date() }; + + if (req.body.is_active !== undefined) + update_data.is_active = req.body.is_active; + if (req.body.max_hosts_per_day !== undefined) + update_data.max_hosts_per_day = req.body.max_hosts_per_day; + if (req.body.allowed_ip_ranges !== undefined) + update_data.allowed_ip_ranges = req.body.allowed_ip_ranges; + if (req.body.expires_at !== undefined) + update_data.expires_at = new Date(req.body.expires_at); + + const token = await prisma.auto_enrollment_tokens.update({ + where: { id: tokenId }, + data: update_data, + include: { + host_groups: true, + users: { + select: { + username: true, + first_name: true, + last_name: true, + }, + }, + }, + }); + + const { token_secret: _secret, ...token_data } = token; + + res.json({ + message: "Token updated successfully", + token: token_data, + }); + } catch (error) { + console.error("Update token error:", error); + res.status(500).json({ error: "Failed to update token" }); + } + }, +); + +// Delete token +router.delete( + "/tokens/:tokenId", + authenticateToken, + requireManageSettings, + async (req, res) => { + try { + const { tokenId } = req.params; + + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { id: tokenId }, + }); + + if (!token) { + return res.status(404).json({ error: "Token not found" }); + } + + await prisma.auto_enrollment_tokens.delete({ + where: { id: tokenId }, + }); + + res.json({ + message: "Auto-enrollment token deleted successfully", + deleted_token: { + id: token.id, + token_name: token.token_name, + }, + }); + } catch (error) { + console.error("Delete token error:", error); + res.status(500).json({ error: "Failed to delete token" }); + } + }, +); + +// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ========== +// Future integrations can follow this pattern: +// - /proxmox-lxc - Proxmox LXC containers +// - /vmware-esxi - VMware ESXi VMs +// - /docker - Docker containers +// - /kubernetes - Kubernetes pods +// - /aws-ec2 - AWS EC2 instances + +// Serve the Proxmox LXC enrollment script with credentials injected +router.get("/proxmox-lxc", async (req, res) => { + try { + // Get token from query params + const token_key = req.query.token_key; + const token_secret = req.query.token_secret; + + if (!token_key || !token_secret) { + return res + .status(401) + .json({ error: "Token key and secret required as query parameters" }); + } + + // Validate token + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: token_key }, + }); + + if (!token || !token.is_active) { + return res.status(401).json({ error: "Invalid or inactive token" }); + } + + // Verify secret + const is_valid = await bcrypt.compare(token_secret, token.token_secret); + if (!is_valid) { + return res.status(401).json({ error: "Invalid token secret" }); + } + + // Check expiration + if (token.expires_at && new Date() > new Date(token.expires_at)) { + return res.status(401).json({ error: "Token expired" }); + } + + const fs = require("node:fs"); + const path = require("node:path"); + + const script_path = path.join( + __dirname, + "../../../agents/proxmox_auto_enroll.sh", + ); + + if (!fs.existsSync(script_path)) { + return res + .status(404) + .json({ error: "Proxmox enrollment script not found" }); + } + + let script = fs.readFileSync(script_path, "utf8"); + + // Convert Windows line endings to Unix line endings + script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Get the configured server URL from settings + let server_url = "http://localhost:3001"; + try { + const settings = await prisma.settings.findFirst(); + if (settings?.server_url) { + server_url = settings.server_url; + } + } catch (settings_error) { + console.warn( + "Could not fetch settings, using default server URL:", + settings_error.message, + ); + } + + // Determine curl flags dynamically from settings + let curl_flags = "-s"; + try { + const settings = await prisma.settings.findFirst(); + if (settings && settings.ignore_ssl_self_signed === true) { + curl_flags = "-sk"; + } + } catch (_) {} + + // Inject the token credentials, server URL, and curl flags into the script + const env_vars = `#!/bin/bash +# PatchMon Auto-Enrollment Configuration (Auto-generated) +export PATCHMON_URL="${server_url}" +export AUTO_ENROLLMENT_KEY="${token.token_key}" +export AUTO_ENROLLMENT_SECRET="${token_secret}" +export CURL_FLAGS="${curl_flags}" + +`; + + // Remove the shebang and configuration section from the original script + script = script.replace(/^#!/, "#"); + + // Remove the configuration section (between # ===== CONFIGURATION ===== and the next # =====) + script = script.replace( + /# ===== CONFIGURATION =====[\s\S]*?(?=# ===== COLOR OUTPUT =====)/, + "", + ); + + script = env_vars + script; + + res.setHeader("Content-Type", "text/plain"); + res.setHeader( + "Content-Disposition", + 'inline; filename="proxmox_auto_enroll.sh"', + ); + res.send(script); + } catch (error) { + console.error("Proxmox script serve error:", error); + res.status(500).json({ error: "Failed to serve enrollment script" }); + } +}); + +// Create host via auto-enrollment +router.post( + "/enroll", + validate_auto_enrollment_token, + [ + body("friendly_name") + .isLength({ min: 1, max: 255 }) + .withMessage("Friendly name is required"), + body("metadata").optional().isObject(), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { friendly_name } = req.body; + + // Generate host API credentials + const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; + const api_key = crypto.randomBytes(32).toString("hex"); + + // Check if host already exists + const existing_host = await prisma.hosts.findUnique({ + where: { friendly_name }, + }); + + if (existing_host) { + return res.status(409).json({ + error: "Host already exists", + host_id: existing_host.id, + api_id: existing_host.api_id, + message: "This host is already enrolled in PatchMon", + }); + } + + // Create host + const host = await prisma.hosts.create({ + data: { + id: uuidv4(), + friendly_name, + os_type: "unknown", + os_version: "unknown", + api_id: api_id, + api_key: api_key, + host_group_id: req.auto_enrollment_token.default_host_group_id, + status: "pending", + notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`, + updated_at: new Date(), + }, + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + }, + }); + + // Update token usage stats + await prisma.auto_enrollment_tokens.update({ + where: { id: req.auto_enrollment_token.id }, + data: { + hosts_created_today: { increment: 1 }, + last_used_at: new Date(), + updated_at: new Date(), + }, + }); + + console.log( + `Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`, + ); + + res.status(201).json({ + message: "Host enrolled successfully", + host: { + id: host.id, + friendly_name: host.friendly_name, + api_id: api_id, + api_key: api_key, + host_group: host.host_groups, + status: host.status, + }, + }); + } catch (error) { + console.error("Auto-enrollment error:", error); + res.status(500).json({ error: "Failed to enroll host" }); + } + }, +); + +// Bulk enroll multiple hosts at once +router.post( + "/enroll/bulk", + validate_auto_enrollment_token, + [ + body("hosts") + .isArray({ min: 1, max: 50 }) + .withMessage("Hosts array required (max 50)"), + body("hosts.*.friendly_name") + .isLength({ min: 1 }) + .withMessage("Each host needs a friendly_name"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hosts } = req.body; + + // Check rate limit + const remaining_quota = + req.auto_enrollment_token.max_hosts_per_day - + req.auto_enrollment_token.hosts_created_today; + + if (hosts.length > remaining_quota) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: `Only ${remaining_quota} hosts remaining in daily quota`, + }); + } + + const results = { + success: [], + failed: [], + skipped: [], + }; + + for (const host_data of hosts) { + try { + const { friendly_name } = host_data; + + // Check if host already exists + const existing_host = await prisma.hosts.findUnique({ + where: { friendly_name }, + }); + + if (existing_host) { + results.skipped.push({ + friendly_name, + reason: "Already exists", + api_id: existing_host.api_id, + }); + continue; + } + + // Generate credentials + const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; + const api_key = crypto.randomBytes(32).toString("hex"); + + // Create host + const host = await prisma.hosts.create({ + data: { + id: uuidv4(), + friendly_name, + os_type: "unknown", + os_version: "unknown", + api_id: api_id, + api_key: api_key, + host_group_id: req.auto_enrollment_token.default_host_group_id, + status: "pending", + notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`, + updated_at: new Date(), + }, + }); + + results.success.push({ + id: host.id, + friendly_name: host.friendly_name, + api_id: api_id, + api_key: api_key, + }); + } catch (error) { + results.failed.push({ + friendly_name: host_data.friendly_name, + error: error.message, + }); + } + } + + // Update token usage stats + if (results.success.length > 0) { + await prisma.auto_enrollment_tokens.update({ + where: { id: req.auto_enrollment_token.id }, + data: { + hosts_created_today: { increment: results.success.length }, + last_used_at: new Date(), + updated_at: new Date(), + }, + }); + } + + res.status(201).json({ + message: `Bulk enrollment completed: ${results.success.length} succeeded, ${results.failed.length} failed, ${results.skipped.length} skipped`, + results, + }); + } catch (error) { + console.error("Bulk auto-enrollment error:", error); + res.status(500).json({ error: "Failed to bulk enroll hosts" }); + } + }, +); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index d4e529e..6d83c2f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -25,6 +25,7 @@ const repositoryRoutes = require("./routes/repositoryRoutes"); const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); const searchRoutes = require("./routes/searchRoutes"); +const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); const { cleanup_expired_sessions } = require("./utils/session_manager"); @@ -380,6 +381,11 @@ app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); app.use(`/api/${apiVersion}/tfa`, tfaRoutes); app.use(`/api/${apiVersion}/search`, searchRoutes); +app.use( + `/api/${apiVersion}/auto-enrollment`, + authLimiter, + autoEnrollmentRoutes, +); // 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 200372e..ade539c 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -1,7 +1,164 @@ -import { Plug } from "lucide-react"; +import { + AlertCircle, + CheckCircle, + Copy, + Eye, + EyeOff, + Plus, + Server, + Trash2, + X, +} from "lucide-react"; +import { useEffect, useState } from "react"; import SettingsLayout from "../../components/SettingsLayout"; +import api from "../../utils/api"; const Integrations = () => { + const [tokens, setTokens] = useState([]); + const [host_groups, setHostGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [show_create_modal, setShowCreateModal] = useState(false); + const [new_token, setNewToken] = useState(null); + const [show_secret, setShowSecret] = useState(false); + const [server_url, setServerUrl] = useState(""); + + // Form state + const [form_data, setFormData] = useState({ + token_name: "", + max_hosts_per_day: 100, + default_host_group_id: "", + allowed_ip_ranges: "", + expires_at: "", + }); + + const [copy_success, setCopySuccess] = useState({}); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount + useEffect(() => { + load_tokens(); + load_host_groups(); + load_server_url(); + }, []); + + const load_tokens = async () => { + try { + setLoading(true); + const response = await api.get("/auto-enrollment/tokens"); + setTokens(response.data); + } catch (error) { + console.error("Failed to load tokens:", error); + } finally { + setLoading(false); + } + }; + + const load_host_groups = async () => { + try { + const response = await api.get("/host-groups"); + setHostGroups(response.data); + } catch (error) { + console.error("Failed to load host groups:", error); + } + }; + + const load_server_url = async () => { + try { + const response = await api.get("/settings"); + setServerUrl(response.data.server_url || window.location.origin); + } catch (error) { + console.error("Failed to load server URL:", error); + setServerUrl(window.location.origin); + } + }; + + const create_token = async (e) => { + e.preventDefault(); + + try { + const data = { + token_name: form_data.token_name, + max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10), + allowed_ip_ranges: form_data.allowed_ip_ranges + ? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim()) + : [], + metadata: { + integration_type: "proxmox-lxc", + }, + }; + + // Only add optional fields if they have values + if (form_data.default_host_group_id) { + data.default_host_group_id = form_data.default_host_group_id; + } + if (form_data.expires_at) { + data.expires_at = form_data.expires_at; + } + + const response = await api.post("/auto-enrollment/tokens", data); + setNewToken(response.data.token); + setShowCreateModal(false); + load_tokens(); + + // Reset form + setFormData({ + token_name: "", + max_hosts_per_day: 100, + default_host_group_id: "", + allowed_ip_ranges: "", + expires_at: "", + }); + } catch (error) { + console.error("Failed to create token:", error); + const error_message = error.response?.data?.errors + ? error.response.data.errors.map((e) => e.msg).join(", ") + : error.response?.data?.error || "Failed to create token"; + alert(error_message); + } + }; + + const delete_token = async (id, name) => { + if ( + !confirm( + `Are you sure you want to delete the token "${name}"? This action cannot be undone.`, + ) + ) { + return; + } + + try { + await api.delete(`/auto-enrollment/tokens/${id}`); + load_tokens(); + } catch (error) { + console.error("Failed to delete token:", error); + alert(error.response?.data?.error || "Failed to delete token"); + } + }; + + const toggle_token_active = async (id, current_status) => { + try { + await api.patch(`/auto-enrollment/tokens/${id}`, { + is_active: !current_status, + }); + load_tokens(); + } catch (error) { + console.error("Failed to toggle token:", error); + alert(error.response?.data?.error || "Failed to toggle token"); + } + }; + + const copy_to_clipboard = (text, key) => { + navigator.clipboard.writeText(text); + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + }; + + const format_date = (date_string) => { + if (!date_string) return "Never"; + return new Date(date_string).toLocaleString(); + }; + return (
@@ -12,36 +169,516 @@ const Integrations = () => { Integrations

- Connect PatchMon to third-party services + Manage auto-enrollment tokens for Proxmox and other integrations +

+
+ +
+ + {/* Proxmox Integration Section */} +
+
+
+ +
+
+

+ Proxmox LXC Auto-Enrollment +

+

+ Automatically discover and enroll LXC containers from Proxmox + hosts +

+
+
+ + {/* Token List */} + {loading ? ( +
+
+
+ ) : tokens.length === 0 ? ( +
+

No auto-enrollment tokens created yet.

+

+ Create a token to enable automatic host enrollment from Proxmox. +

+
+ ) : ( +
+ {tokens.map((token) => ( +
+
+
+
+

+ {token.token_name} +

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

+ Usage: {token.hosts_created_today}/ + {token.max_hosts_per_day} hosts today +

+ {token.host_groups && ( +

+ Default Group:{" "} + + {token.host_groups.name} + +

+ )} + {token.allowed_ip_ranges?.length > 0 && ( +

+ Allowed IPs: {token.allowed_ip_ranges.join(", ")} +

+ )} +

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 Auto-Enrollment +

+
    +
  1. Create a new auto-enrollment token using the button above
  2. +
  3. + Copy the one-line installation command shown in the success dialog +
  4. +
  5. SSH into your Proxmox host as root
  6. +
  7. + Paste and run the command - it will automatically discover and + enroll all running LXC containers +
  8. +
  9. View enrolled containers in the Hosts page
  10. +
+
+

+ 💡 Tip: You can run the same command multiple + times safely - already enrolled containers will be automatically + skipped.

+
- {/* Coming Soon Card */} -
-
-
-
- + {/* Create Token Modal */} + {show_create_modal && ( +
+
+
+
+

+ Create Auto-Enrollment Token +

+
+ +
+ + + + + + + + + + +
+ + +
+
-
-

- Integrations Coming Soon -

-

- We are building integrations for Slack, Discord, email, and - webhooks to streamline alerts and workflows. -

-
- - In Development - +
+
+ )} + + {/* New Token Display Modal */} + {new_token && ( +
+
+
+
+
+ +
+
+

+ Token Created Successfully +

+

+ Save these credentials now - the secret will not be shown + again! +

+
+
+ +
+
+ +

+ Important: Store the token secret securely. + You will not be able to view it again after closing this + dialog. +

+
+
+ +
+
+
+ Token Name +
+
+ +
+
+ +
+
+ Token Key +
+
+ + +
+
+ +
+
+ Token Secret +
+
+ + + +
+
+ +
+
+ One-Line Installation Command +
+

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

+
+ + +
+

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

+
+
+ +
+
-
+ )} ); }; From 4e6a9829cf1e15162a4adf64124653bdc9255896 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 08:13:04 +0100 Subject: [PATCH 03/20] chore: Add migration file for auto_enrollment_tokens table --- .../migration.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql diff --git a/backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql b/backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql new file mode 100644 index 0000000..16dea2b --- /dev/null +++ b/backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "auto_enrollment_tokens" ( + "id" TEXT NOT NULL, + "token_name" TEXT NOT NULL, + "token_key" TEXT NOT NULL, + "token_secret" TEXT NOT NULL, + "created_by_user_id" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "allowed_ip_ranges" TEXT[], + "max_hosts_per_day" INTEGER NOT NULL DEFAULT 100, + "hosts_created_today" INTEGER NOT NULL DEFAULT 0, + "last_reset_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "default_host_group_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "metadata" JSONB, + + CONSTRAINT "auto_enrollment_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "auto_enrollment_tokens_token_key_key" ON "auto_enrollment_tokens"("token_key"); + +-- CreateIndex +CREATE INDEX "auto_enrollment_tokens_token_key_idx" ON "auto_enrollment_tokens"("token_key"); + +-- CreateIndex +CREATE INDEX "auto_enrollment_tokens_is_active_idx" ON "auto_enrollment_tokens"("is_active"); + +-- AddForeignKey +ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_default_host_group_id_fkey" FOREIGN KEY ("default_host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE; + From 9963cfa41788c9a6b8c7a43f4cc460a492ff5399 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 08:28:07 +0100 Subject: [PATCH 04/20] fix: Add timeouts and stdin redirection to prevent pct exec hanging --- agents/proxmox_auto_enroll.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 79142f5..8cd2177 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -132,9 +132,9 @@ while IFS= read -r line; do # Get container details debug " Gathering container information..." - hostname=$(pct exec "$vmid" -- hostname 2>/dev/null || echo "$name") - ip_address=$(pct exec "$vmid" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") - os_info=$(pct exec "$vmid" -- cat /etc/os-release 2>/dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown") + hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null /dev/null /dev/null &1) + '$PATCHMON_URL/api/v1/hosts/install' | bash" 2>&1 Date: Thu, 2 Oct 2025 12:55:52 +0100 Subject: [PATCH 05/20] fix: Detach stdin globally to prevent curl pipe hangs in Proxmox script --- agents/proxmox_auto_enroll.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 8cd2177..a27713f 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -5,6 +5,9 @@ # ============================================================================= # This script discovers LXC containers on a Proxmox host and automatically # enrolls them into PatchMon for patch management. + +# Detach from stdin entirely to prevent hangs when piped from curl +exec Date: Thu, 2 Oct 2025 13:03:50 +0100 Subject: [PATCH 06/20] fix: Remove global exec stdin redirect that breaks curl pipe --- agents/proxmox_auto_enroll.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index a27713f..8cd2177 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -5,9 +5,6 @@ # ============================================================================= # This script discovers LXC containers on a Proxmox host and automatically # enrolls them into PatchMon for patch management. - -# Detach from stdin entirely to prevent hangs when piped from curl -exec Date: Thu, 2 Oct 2025 13:09:13 +0100 Subject: [PATCH 07/20] fix: Close stdin before while loop to prevent hang when piped from curl --- agents/proxmox_auto_enroll.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 8cd2177..5cadf58 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -106,6 +106,9 @@ enrolled_count=0 skipped_count=0 failed_count=0 +# Close stdin to prevent any interference when piped from curl +exec 0<&- + # ===== PROCESS CONTAINERS ===== while IFS= read -r line; do vmid=$(echo "$line" | awk '{print $1}') From e5f3b0ed26db929445811e0e61c69dcefa1355fa Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:10:51 +0100 Subject: [PATCH 08/20] debug: Add detailed logging to diagnose where script hangs --- agents/proxmox_auto_enroll.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 5cadf58..91d0a8d 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -106,11 +106,10 @@ enrolled_count=0 skipped_count=0 failed_count=0 -# Close stdin to prevent any interference when piped from curl -exec 0<&- - # ===== 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}') From 2abc9b1f8ad920973d9d58cdaf684f8bfcbd8c84 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:12:29 +0100 Subject: [PATCH 09/20] debug: Add strict error handling and exit trap to diagnose silent exit --- agents/proxmox_auto_enroll.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 91d0a8d..03aebe1 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -1,4 +1,8 @@ #!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# Trap to catch any unexpected exits +trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT # ============================================================================= # PatchMon Proxmox LXC Auto-Enrollment Script @@ -101,10 +105,12 @@ 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 +info "Statistics initialized" # ===== PROCESS CONTAINERS ===== info "Starting container processing loop..." From 8c326c8fe2b883a74fa4be9d0ac5bccf56a4b1f6 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:14:38 +0100 Subject: [PATCH 10/20] debug: Add version echo at script start for verification --- agents/proxmox_auto_enroll.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 03aebe1..0be2081 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,6 +4,9 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT +SCRIPT_VERSION="1.0.0-debug.5" +echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" + # ============================================================================= # PatchMon Proxmox LXC Auto-Enrollment Script # ============================================================================= @@ -20,8 +23,6 @@ trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $? # - Network access to PatchMon server # ============================================================================= -set -e - # ===== CONFIGURATION ===== PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}" AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}" From 16ea1dc743df6846c60b5da5ac1d1b74928b531a Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:15:57 +0100 Subject: [PATCH 11/20] fix: Make logging functions always return 0 to prevent set -e exit --- agents/proxmox_auto_enroll.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 0be2081..8d65e96 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -42,10 +42,11 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # ===== LOGGING FUNCTIONS ===== -info() { echo -e "${GREEN}[INFO]${NC} $1"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +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; } -debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $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" From 55c8f74b73d00232581fd3ef153f56890348dfee Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:16:09 +0100 Subject: [PATCH 12/20] chore: Bump debug version to 1.0.0-debug.6 --- agents/proxmox_auto_enroll.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 8d65e96..2d6b739 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.5" +SCRIPT_VERSION="1.0.0-debug.6" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= From bec09b9457048ce2f0c3f80d45eac9424b209cfa Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:37:55 +0100 Subject: [PATCH 13/20] fix: Download agent installer to file before executing to prevent stdin pipe hang --- agents/proxmox_auto_enroll.sh | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 2d6b739..150518b 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.6" +SCRIPT_VERSION="1.0.0-debug.7" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -194,16 +194,31 @@ while IFS= read -r line; do # Install PatchMon agent in container info " Installing PatchMon agent..." - install_output=$(timeout 120 pct exec "$vmid" -- bash -c "curl $CURL_FLAGS \ - -H 'X-API-ID: $api_id' \ - -H 'X-API-KEY: $api_key' \ - '$PATCHMON_URL/api/v1/hosts/install' | bash" 2>&1 &1 180s) in $friendly_name" + debug " Install output: $install_output" + ((failed_count++)) else - error " ✗ Failed to install agent in $friendly_name" + warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" debug " Install output: $install_output" ((failed_count++)) fi From dc68afcb87839e47f9b42f7692ff1183f37b3e1a Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:39:28 +0100 Subject: [PATCH 14/20] fix: Prevent set -e exit on agent install failure and show output --- agents/proxmox_auto_enroll.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 150518b..6bb8962 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.7" +SCRIPT_VERSION="1.0.0-debug.8" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -206,20 +206,21 @@ while IFS= read -r line; do '$PATCHMON_URL/api/v1/hosts/install' && \ bash patchmon-install.sh && \ rm -f patchmon-install.sh - " 2>&1 &1 180s) in $friendly_name" - debug " Install output: $install_output" + info " Install output: $install_output" ((failed_count++)) else warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" - debug " Install output: $install_output" + info " Install output: $install_output" ((failed_count++)) fi From 51982010db16e3d0fe85a47180c317145188b83a Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:41:12 +0100 Subject: [PATCH 15/20] fix: Pass API credentials directly to curl instead of via env vars --- agents/proxmox_auto_enroll.sh | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 6bb8962..c5aaa98 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.8" +SCRIPT_VERSION="1.0.0-debug.9" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -196,12 +196,10 @@ while IFS= read -r line; do # Download and execute in separate steps to avoid stdin issues with piping install_output=$(timeout 180 pct exec "$vmid" -- bash -c " - export API_ID='$api_id' - export API_KEY='$api_key' cd /tmp curl $CURL_FLAGS \ - -H 'X-API-ID: \$API_ID' \ - -H 'X-API-KEY: \$API_KEY' \ + -H \"X-API-ID: $api_id\" \ + -H \"X-API-KEY: $api_key\" \ -o patchmon-install.sh \ '$PATCHMON_URL/api/v1/hosts/install' && \ bash patchmon-install.sh && \ From e0eb544205ffd3734c87f66d3d6593674e238737 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:42:09 +0100 Subject: [PATCH 16/20] fix: Make all counter increments safe with || true to prevent set -e exit --- agents/proxmox_auto_enroll.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index c5aaa98..4ff08d7 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.9" +SCRIPT_VERSION="1.0.0-debug.10" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -127,7 +127,7 @@ while IFS= read -r line; do # Skip stopped containers if configured if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then warn " Skipping $name - container not running" - ((skipped_count++)) + ((skipped_count++)) || true echo "" continue fi @@ -135,7 +135,7 @@ while IFS= read -r line; do # Check if container is stopped if [[ "$status" != "running" ]]; then warn " Container $name is stopped - cannot gather info or install agent" - ((skipped_count++)) + ((skipped_count++)) || true echo "" continue fi @@ -154,7 +154,7 @@ while IFS= read -r line; do if [[ "$DRY_RUN" == "true" ]]; then info " [DRY RUN] Would enroll: $friendly_name" - ((enrolled_count++)) + ((enrolled_count++)) || true echo "" continue fi @@ -211,27 +211,27 @@ while IFS= read -r line; do if [[ $install_exit_code -eq 0 ]]; then info " ✓ Agent installed successfully in $friendly_name" - ((enrolled_count++)) + ((enrolled_count++)) || true elif [[ $install_exit_code -eq 124 ]]; then warn " ⏱ Agent installation timed out (>180s) in $friendly_name" info " Install output: $install_output" - ((failed_count++)) + ((failed_count++)) || true else warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" info " Install output: $install_output" - ((failed_count++)) + ((failed_count++)) || true fi elif [[ "$http_code" == "409" ]]; then warn " ⊘ Host $friendly_name already enrolled - skipping" - ((skipped_count++)) + ((skipped_count++)) || true elif [[ "$http_code" == "429" ]]; then error " ✗ Rate limit exceeded - maximum hosts per day reached" - ((failed_count++)) + ((failed_count++)) || true else error " ✗ Failed to enroll $friendly_name - HTTP $http_code" debug " Response: $body" - ((failed_count++)) + ((failed_count++)) || true fi echo "" From bbb97dbfda33d9727d583be7932fe37549d1a6a4 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 14:37:38 +0100 Subject: [PATCH 17/20] fix: Remove EXIT from error trap to prevent false failures on successful completion --- agents/proxmox_auto_enroll.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 4ff08d7..0c44b18 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -1,10 +1,10 @@ #!/bin/bash set -euo pipefail # Exit on error, undefined vars, pipe failures -# Trap to catch any unexpected exits -trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT +# Trap to catch errors only (not normal exits) +trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.0-debug.10" +SCRIPT_VERSION="1.0.1" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= From 13c43421357eb4c50062286738120214fb0cb1c2 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 14:39:36 +0100 Subject: [PATCH 18/20] feat: Remove 'proxmox-' prefix from friendly names, use hostname only --- agents/proxmox_auto_enroll.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 0c44b18..d8d4599 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.1" +SCRIPT_VERSION="1.0.2" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -29,7 +29,7 @@ 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:-proxmox-}" +HOST_PREFIX="${HOST_PREFIX:-}" SKIP_STOPPED="${SKIP_STOPPED:-true}" PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}" MAX_PARALLEL="${MAX_PARALLEL:-5}" From 513c268b369b49bb92fbc738a12e604c9e2cbcde Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 15:14:50 +0100 Subject: [PATCH 19/20] fix: Reset install_exit_code per container and detect success via output message --- agents/proxmox_auto_enroll.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index d8d4599..b151fe0 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.2" +SCRIPT_VERSION="1.0.3" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -194,6 +194,9 @@ while IFS= read -r line; do # Install PatchMon agent in container info " Installing PatchMon agent..." + # Reset exit code for this container + install_exit_code=0 + # Download and execute in separate steps to avoid stdin issues with piping install_output=$(timeout 180 pct exec "$vmid" -- bash -c " cd /tmp @@ -205,11 +208,9 @@ while IFS= read -r line; do bash patchmon-install.sh && \ rm -f patchmon-install.sh " 2>&1 Date: Thu, 2 Oct 2025 15:19:49 +0100 Subject: [PATCH 20/20] feat: Add interactive dpkg error recovery with automatic retry --- agents/proxmox_auto_enroll.sh | 86 ++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index b151fe0..ad573a9 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.3" +SCRIPT_VERSION="1.1.0" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -112,6 +112,9 @@ info "Initializing statistics..." enrolled_count=0 skipped_count=0 failed_count=0 + +# Track containers with dpkg errors for later recovery +declare -A dpkg_error_containers info "Statistics initialized" # ===== PROCESS CONTAINERS ===== @@ -218,7 +221,13 @@ while IFS= read -r line; do info " Install output: $install_output" ((failed_count++)) || true else - warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" + # 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" + else + warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" + fi info " Install output: $install_output" ((failed_count++)) || true fi @@ -257,6 +266,79 @@ if [[ "$DRY_RUN" == "true" ]]; then 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